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:
Nico
2026-05-05 18:18:08 +02:00
committed by GitHub
parent c3b82539a5
commit f2b2e23b0a
21 changed files with 1088 additions and 85 deletions
+87 -1
View File
@@ -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")
})
}
@@ -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: