GitOps changes for custom org's logo uploads (#44550)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #44333 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests. Also added some integration tests as a follow-up of the first PR (https://github.com/fleetdm/fleet/pull/44390). - [x] QA'd all new/changed functionality manually #### generate-gitops - Branched off to main, no URLs set, then ran generate-gitops on this branch. Deprecated keys gone, new keys present. <img width="447" height="170" alt="nourls_new" src="https://github.com/user-attachments/assets/61931615-d61b-44d3-8095-f7a2b9bd8871" /> - Branched off to main, set external URLs for both light and dark modes, then ran generate-gitops on this branch. Deprecated keys gone, new keys set with the external URLs. <img width="637" height="471" alt="externalurl_main" src="https://github.com/user-attachments/assets/c3782756-acc2-4b99-812d-86e145f11ad5" /> <img width="459" height="168" alt="externalurl_new" src="https://github.com/user-attachments/assets/aa2d8825-3c47-40ba-ab91-bb8202afe81a" /> - Within this branch, after uploading a custom logo for light mode, ran generate-gitops. The logo was saved in lib/org_logo/light.webp <img width="1510" height="639" alt="Screenshot 2026-05-04 at 4 06 59 PM" src="https://github.com/user-attachments/assets/13318c24-8fa4-4e29-b629-ff723d4afe5a" /> <img width="786" height="172" alt="Screenshot 2026-05-04 at 4 07 30 PM" src="https://github.com/user-attachments/assets/b46bd1df-7dcd-4489-b7da-4cbad77b25b8" /> #### gitops - Applied gitops with two external URLs. Verified in the UI that those are still present <img width="944" height="189" alt="Screenshot 2026-05-04 at 7 54 53 AM" src="https://github.com/user-attachments/assets/a34813ca-beb1-403e-9793-d42cc9c72f8b" /> <img width="637" height="259" alt="Screenshot 2026-05-04 at 8 01 04 AM" src="https://github.com/user-attachments/assets/74c2cd56-ab1d-4ddd-9b8e-22c49e9ae9d5" /> - Applied gitops with "" as the URLs to clear them. Verified the default fleet logo is shown. <img width="460" height="201" alt="Screenshot 2026-05-04 at 8 15 11 AM" src="https://github.com/user-attachments/assets/dcbafea3-b4ea-44aa-9045-08c4f5a64e98" /> <img width="648" height="269" alt="Screenshot 2026-05-04 at 8 15 50 AM" src="https://github.com/user-attachments/assets/451a28f9-e929-4b84-93d3-a7dd9afd5eca" /> - Applied gitops with a custom logo for light theme, using **org_logo_path_light_mode**: <img width="948" height="207" alt="Screenshot 2026-05-04 at 4 10 05 PM" src="https://github.com/user-attachments/assets/b1418cd4-31cc-4e53-b566-9af11ec21970" /> <img width="774" height="168" alt="Screenshot 2026-05-04 at 4 10 35 PM" src="https://github.com/user-attachments/assets/63f596eb-308f-4122-ad86-e1d718e9b525" /> ## New Fleet configuration settings - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - See https://github.com/fleetdm/fleet/pull/43808. - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * GitOps support for uploading custom org logos (dark/light) via local files. * `fleetctl generate-gitops` exports Fleet-hosted logos as local files and inserts path references. * New API endpoints to upload, delete, and fetch org logos. * **Deprecated** * Legacy logo keys consolidated into mode-specific URL keys (`org_logo_url_dark_mode`, `org_logo_url_light_mode`). * **Bug Fixes / Validation** * Validation/error when both a path and URL are provided for the same mode; file size and image-format checks enforced. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"mime"
|
||||
"os"
|
||||
pathUtils "path"
|
||||
"path/filepath"
|
||||
@@ -75,6 +76,7 @@ type generateGitopsClient interface {
|
||||
GetProfileContents(profileID string) ([]byte, error)
|
||||
GetEULAMetadata() (*fleet.MDMEULA, error)
|
||||
GetEULAContent(token string) ([]byte, error)
|
||||
GetOrgLogoContent(mode fleet.OrgLogoMode) (body []byte, contentType string, err error)
|
||||
GetTeam(teamID uint) (*fleet.Team, error)
|
||||
ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error)
|
||||
GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error)
|
||||
@@ -792,12 +794,17 @@ func (cmd *GenerateGitopsCommand) generateOrgSettings() (orgSettings map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgInfo, err := cmd.generateOrgInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgSettings = map[string]interface{}{
|
||||
jsonFieldName(t, "ActivityExpirySettings"): cmd.AppConfig.ActivityExpirySettings,
|
||||
jsonFieldName(t, "Features"): cmd.AppConfig.Features,
|
||||
jsonFieldName(t, "FleetDesktop"): cmd.AppConfig.FleetDesktop,
|
||||
jsonFieldName(t, "HostExpirySettings"): cmd.AppConfig.HostExpirySettings,
|
||||
jsonFieldName(t, "OrgInfo"): cmd.AppConfig.OrgInfo,
|
||||
jsonFieldName(t, "OrgInfo"): orgInfo,
|
||||
jsonFieldName(t, "ServerSettings"): cmd.AppConfig.ServerSettings,
|
||||
jsonFieldName(t, "WebhookSettings"): webhookSettings,
|
||||
}
|
||||
@@ -1057,6 +1064,85 @@ func (cmd *GenerateGitopsCommand) generateCertificateAuthorities(filePath string
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateOrgInfo returns OrgInfo as a map so we can swap a Fleet-hosted
|
||||
// logo URL for a path key plus an exported file. External URLs flow through
|
||||
// unchanged; the deprecated logo URL keys are renamed to the mode-aware
|
||||
// variants by yamlMarshalRenamed at write time.
|
||||
func (cmd *GenerateGitopsCommand) generateOrgInfo() (map[string]any, error) {
|
||||
raw, err := json.Marshal(cmd.AppConfig.OrgInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalling org_info: %w", err)
|
||||
}
|
||||
orgInfo := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &orgInfo); err != nil {
|
||||
return nil, fmt.Errorf("unmarshalling org_info: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.exportFleetHostedLogo(orgInfo, fleet.OrgLogoModeLight,
|
||||
cmd.AppConfig.OrgInfo.OrgLogoURLLightMode,
|
||||
"org_logo_path_light_mode", "org_logo_url_light_mode", "org_logo_url_light_background"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.exportFleetHostedLogo(orgInfo, fleet.OrgLogoModeDark,
|
||||
cmd.AppConfig.OrgInfo.OrgLogoURLDarkMode,
|
||||
"org_logo_path_dark_mode", "org_logo_url_dark_mode", "org_logo_url"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return orgInfo, nil
|
||||
}
|
||||
|
||||
// exportFleetHostedLogo, when the given URL points at the Fleet logo serving
|
||||
// endpoint, downloads the logo bytes, writes them to lib/org_logo/<mode>.<ext>,
|
||||
// and rewrites the orgInfo map to reference the file via pathKey instead of
|
||||
// the URL keys. External URLs are left untouched.
|
||||
func (cmd *GenerateGitopsCommand) exportFleetHostedLogo(
|
||||
orgInfo map[string]any, mode fleet.OrgLogoMode, urlValue string,
|
||||
pathKey, newURLKey, deprecatedURLKey string,
|
||||
) error {
|
||||
if !fleet.IsFleetHostedLogoURL(urlValue) {
|
||||
return nil
|
||||
}
|
||||
body, contentType, err := cmd.Client.GetOrgLogoContent(mode)
|
||||
if err != nil {
|
||||
// Server reports a Fleet-hosted URL but no blob is stored. Leave
|
||||
// the URL keys in place so the user can investigate; the rest of
|
||||
// the export should still succeed.
|
||||
if service.IsNotFoundErr(err) {
|
||||
fmt.Fprintf(cmd.CLI.App.ErrWriter,
|
||||
"warning: org logo for %s mode references Fleet but no logo content was found; leaving URL as-is\n", mode)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("fetching org logo (%s): %w", mode, err)
|
||||
}
|
||||
ext, err := orgLogoExtFromContentType(contentType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("org logo (%s): %w", mode, err)
|
||||
}
|
||||
fileName := fmt.Sprintf("lib/org_logo/%s%s", mode, ext)
|
||||
cmd.FilesToWrite[fileName] = string(body)
|
||||
|
||||
orgInfo[pathKey] = fmt.Sprintf("./%s", fileName)
|
||||
delete(orgInfo, newURLKey)
|
||||
delete(orgInfo, deprecatedURLKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func orgLogoExtFromContentType(contentType string) (string, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing logo Content-Type %q: %w", contentType, err)
|
||||
}
|
||||
switch mediaType {
|
||||
case "image/png":
|
||||
return ".png", nil
|
||||
case "image/jpeg":
|
||||
return ".jpg", nil
|
||||
case "image/webp":
|
||||
return ".webp", nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported logo Content-Type %q (expected image/png, image/jpeg, or image/webp)", mediaType)
|
||||
}
|
||||
|
||||
func (cmd *GenerateGitopsCommand) generateEULA() (string, error) {
|
||||
// Download the eula metadata for the token.
|
||||
eulaMetadata, err := cmd.Client.GetEULAMetadata()
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/client"
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/ghodss/yaml"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -686,6 +688,11 @@ func (MockClient) GetEULAContent(token string) ([]byte, error) {
|
||||
return []byte("This is the EULA content."), nil
|
||||
}
|
||||
|
||||
func (MockClient) GetOrgLogoContent(mode fleet.OrgLogoMode) ([]byte, string, error) {
|
||||
// PNG magic bytes + filler so validators that sniff content type see it as a real PNG.
|
||||
return []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03}, "image/png", nil
|
||||
}
|
||||
|
||||
func (MockClient) GetSetupExperienceSoftware(platform string, teamID uint) ([]fleet.SoftwareTitleListResult, error) {
|
||||
if teamID == 1 {
|
||||
return []fleet.SoftwareTitleListResult{
|
||||
@@ -2402,3 +2409,116 @@ func TestReplaceAliasKeys(t *testing.T) {
|
||||
replaceAliasKeys(m, rules, true)
|
||||
})
|
||||
}
|
||||
|
||||
// orgLogoStub embeds *MockClient so the full generateGitopsClient interface is
|
||||
// implemented; only GetOrgLogoContent is overridden so each test can drive the
|
||||
// fetch outcome (200, 404, or generic error).
|
||||
type orgLogoStub struct {
|
||||
*MockClient
|
||||
body []byte
|
||||
contentType string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *orgLogoStub) GetOrgLogoContent(_ fleet.OrgLogoMode) ([]byte, string, error) {
|
||||
return s.body, s.contentType, s.err
|
||||
}
|
||||
|
||||
// newOrgLogoCommand builds a minimal GenerateGitopsCommand for exercising
|
||||
// generateOrgInfo / exportFleetHostedLogo without going through the full
|
||||
// generate-gitops action.
|
||||
func newOrgLogoCommand(t *testing.T, client generateGitopsClient, orgInfo fleet.OrgInfo) (*GenerateGitopsCommand, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
errBuf := new(bytes.Buffer)
|
||||
return &GenerateGitopsCommand{
|
||||
Client: client,
|
||||
CLI: cli.NewContext(&cli.App{
|
||||
Writer: new(bytes.Buffer),
|
||||
ErrWriter: errBuf,
|
||||
}, nil, nil),
|
||||
AppConfig: &fleet.EnrichedAppConfig{
|
||||
AppConfig: fleet.AppConfig{OrgInfo: orgInfo},
|
||||
},
|
||||
FilesToWrite: map[string]any{},
|
||||
}, errBuf
|
||||
}
|
||||
|
||||
func TestGenerateGitopsExportOrgLogos(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pngBody := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02}
|
||||
|
||||
t.Run("Fleet-hosted logo is downloaded and emitted as a path key", func(t *testing.T) {
|
||||
cmd, _ := newOrgLogoCommand(t,
|
||||
&orgLogoStub{MockClient: &MockClient{}, body: pngBody, contentType: "image/png"},
|
||||
fleet.OrgInfo{
|
||||
OrgName: "ACME",
|
||||
OrgLogoURLDarkMode: "https://fleet.example.com/api/latest/fleet/logo?mode=dark",
|
||||
OrgLogoURLLightMode: "", // no light logo
|
||||
},
|
||||
)
|
||||
|
||||
orgInfo, err := cmd.generateOrgInfo()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Path key replaces the URL key for dark; light is left empty.
|
||||
assert.Equal(t, "./lib/org_logo/dark.png", orgInfo["org_logo_path_dark_mode"])
|
||||
_, hasDarkURL := orgInfo["org_logo_url_dark_mode"]
|
||||
assert.False(t, hasDarkURL, "Fleet-hosted dark URL should be removed in favor of the path key")
|
||||
// The deprecated alias is also cleared so aliasRules can't resurrect the URL.
|
||||
_, hasOldDark := orgInfo["org_logo_url"]
|
||||
assert.False(t, hasOldDark)
|
||||
|
||||
// Bytes were queued for the on-disk export.
|
||||
assert.Equal(t, string(pngBody), cmd.FilesToWrite["lib/org_logo/dark.png"])
|
||||
})
|
||||
|
||||
t.Run("external URLs are exported unchanged (existing customer configs)", func(t *testing.T) {
|
||||
stub := &orgLogoStub{
|
||||
MockClient: &MockClient{},
|
||||
err: errors.New("GetOrgLogoContent should not be called for external URLs"),
|
||||
}
|
||||
cmd, _ := newOrgLogoCommand(t, stub, fleet.OrgInfo{
|
||||
OrgName: "ACME",
|
||||
OrgLogoURLDarkMode: "https://customer.example.com/dark.png",
|
||||
OrgLogoURLLightMode: "https://customer.example.com/light.png",
|
||||
})
|
||||
|
||||
orgInfo, err := cmd.generateOrgInfo()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://customer.example.com/dark.png", orgInfo["org_logo_url_dark_mode"])
|
||||
assert.Equal(t, "https://customer.example.com/light.png", orgInfo["org_logo_url_light_mode"])
|
||||
_, hasDarkPath := orgInfo["org_logo_path_dark_mode"]
|
||||
_, hasLightPath := orgInfo["org_logo_path_light_mode"]
|
||||
assert.False(t, hasDarkPath)
|
||||
assert.False(t, hasLightPath)
|
||||
assert.Empty(t, cmd.FilesToWrite, "no logo files should be written for external URLs")
|
||||
})
|
||||
|
||||
t.Run("404 from server prints a warning and keeps the URL", func(t *testing.T) {
|
||||
stub := &orgLogoStub{
|
||||
MockClient: &MockClient{},
|
||||
err: &client.NotFoundErr{Msg: "no logo stored"},
|
||||
}
|
||||
// Sanity: the stub error is recognized as a not-found.
|
||||
require.True(t, service.IsNotFoundErr(stub.err))
|
||||
|
||||
cmd, errBuf := newOrgLogoCommand(t, stub, fleet.OrgInfo{
|
||||
OrgName: "ACME",
|
||||
OrgLogoURLDarkMode: "https://fleet.example.com/api/latest/fleet/logo?mode=dark",
|
||||
})
|
||||
|
||||
orgInfo, err := cmd.generateOrgInfo()
|
||||
require.NoError(t, err, "404 must not abort the export")
|
||||
|
||||
// URL is kept (the inconsistency is upstream — operator needs to investigate),
|
||||
// no path key is added, no bytes are queued for export.
|
||||
assert.Equal(t, "https://fleet.example.com/api/latest/fleet/logo?mode=dark", orgInfo["org_logo_url_dark_mode"])
|
||||
_, hasDarkPath := orgInfo["org_logo_path_dark_mode"]
|
||||
assert.False(t, hasDarkPath)
|
||||
assert.Empty(t, cmd.FilesToWrite)
|
||||
assert.Contains(t, errBuf.String(), "warning")
|
||||
assert.Contains(t, errBuf.String(), "dark")
|
||||
})
|
||||
}
|
||||
|
||||
-2
@@ -99,9 +99,7 @@ mdm:
|
||||
- "\U0001F4F1\U0001F510 Personal mobile devices"
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: http://some-org-logo-url.com
|
||||
org_logo_url_dark_mode: http://some-org-logo-url.com
|
||||
org_logo_url_light_background: http://some-org-logo-url-light-background.com
|
||||
org_logo_url_light_mode: http://some-org-logo-url-light-background.com
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
|
||||
@@ -98,9 +98,7 @@ mdm:
|
||||
- "\U0001F4F1\U0001F510 Personal mobile devices"
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: http://some-org-logo-url.com
|
||||
org_logo_url_dark_mode: http://some-org-logo-url.com
|
||||
org_logo_url_light_background: http://some-org-logo-url-light-background.com
|
||||
org_logo_url_light_mode: http://some-org-logo-url-light-background.com
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
|
||||
@@ -137,9 +137,7 @@ org_settings:
|
||||
metadata_url: # TODO: Add your MDM end user auth metadata URL here
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: http://some-org-logo-url.com
|
||||
org_logo_url_dark_mode: http://some-org-logo-url.com
|
||||
org_logo_url_light_background: http://some-org-logo-url-light-background.com
|
||||
org_logo_url_light_mode: http://some-org-logo-url-light-background.com
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
|
||||
@@ -132,9 +132,7 @@ org_settings:
|
||||
location: Fleet Device Management Inc.
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: http://some-org-logo-url.com
|
||||
org_logo_url_dark_mode: http://some-org-logo-url.com
|
||||
org_logo_url_light_background: http://some-org-logo-url-light-background.com
|
||||
org_logo_url_light_mode: http://some-org-logo-url-light-background.com
|
||||
org_name: Fleet
|
||||
secrets:
|
||||
|
||||
Reference in New Issue
Block a user