<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #45230, Resolves #45213 # Checklist for submitter - [ ] 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 - [x] QA'd all new/changed functionality manually #### For 45213 Did a gitops run to apply external URLs as logos: <img width="395" height="122" alt="Screenshot 2026-05-12 at 10 52 25 AM" src="https://github.com/user-attachments/assets/a1fea9ce-7a3d-419b-8c56-68568dcc704e" /> Command: `./build/fleetctl gitops -f /Users/nico/dev/gitops-output-test/default.yml` (**gitops-output-test** is where I usually have my gitops outputs). Then I deleted both from the UI: https://github.com/user-attachments/assets/03899795-7cda-485d-b87e-25f829b928b7 #### For 45230 - Uploaded logos using **org_logo_path_dark_mode** and **org_logo_path_light_mode** in the first GitOps run. - In the second GitOps run, set **org_logo_url_dark_mode: "https://placehold.co/100"** and **org_logo_url_light_mode: "https://placehold.co/100"**. https://github.com/user-attachments/assets/4dfd0440-9a17-44e1-aa7d-395afd9c7d7a For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Organization logo deletion is idempotent and no longer errors on repeated deletes. * Orphaned hosted logo blobs are cleaned up after config changes; deletion or activity-emission failures are logged and do not abort requests. * Conflicting/contradictory URL updates now produce validation errors. * **Improvements** * GitOps/logo sync strips gitops-only path keys and only performs uploads in non-dry-run flows. * Logo URL handling simplified: deprecated alias fields are mirrored and path keys are stripped to avoid unintended deletions. * **Tests** * Added lifecycle tests for upload/delete, activity emissions, and in-memory PNG generation. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45236) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
298 lines
10 KiB
Go
298 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
"github.com/fleetdm/fleet/v4/server/version"
|
|
)
|
|
|
|
// ApplyAppConfig sends the application config to be applied to the Fleet instance.
|
|
func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions) error {
|
|
verb, path := "PATCH", "/api/latest/fleet/config"
|
|
var responseBody appConfigResponse
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err = endpointer.RewriteOldToNewKeys(data, endpointer.ExtractAliasRules(fleet.AppConfig{}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.authenticatedRequestWithQuery(data, verb, path, &responseBody, opts.RawQuery())
|
|
}
|
|
|
|
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
|
|
// in no team.
|
|
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error {
|
|
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
|
|
query := opts.RawQuery()
|
|
if assumeEnabled {
|
|
if query != "" {
|
|
query += "&"
|
|
}
|
|
query += "assume_enabled=true"
|
|
}
|
|
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query)
|
|
}
|
|
|
|
// GetAppConfig fetches the application config from the server API
|
|
func (c *Client) GetAppConfig() (*fleet.EnrichedAppConfig, error) {
|
|
verb, path := "GET", "/api/latest/fleet/config"
|
|
var responseBody fleet.EnrichedAppConfig
|
|
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
|
return &responseBody, err
|
|
}
|
|
|
|
// GetEnrollSecretSpec fetches the enroll secrets stored on the server
|
|
func (c *Client) GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error) {
|
|
verb, path := "GET", "/api/latest/fleet/spec/enroll_secret"
|
|
var responseBody getEnrollSecretSpecResponse
|
|
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
|
return responseBody.Spec, err
|
|
}
|
|
|
|
// ApplyEnrollSecretSpec applies the enroll secrets.
|
|
func (c *Client) ApplyEnrollSecretSpec(spec *fleet.EnrollSecretSpec, opts fleet.ApplySpecOptions) error {
|
|
req := applyEnrollSecretSpecRequest{Spec: spec}
|
|
verb, path := "POST", "/api/latest/fleet/spec/enroll_secret"
|
|
var responseBody applyEnrollSecretSpecResponse
|
|
return c.authenticatedRequestWithQuery(req, verb, path, &responseBody, opts.RawQuery())
|
|
}
|
|
|
|
func (c *Client) Version() (*version.Info, error) {
|
|
verb, path := "GET", "/api/latest/fleet/version"
|
|
var responseBody versionResponse
|
|
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
|
return responseBody.Info, err
|
|
}
|
|
|
|
type orgLogoAction struct {
|
|
mode fleet.OrgLogoMode
|
|
uploadPath string
|
|
}
|
|
|
|
// planAndStripOrgLogos plans the PUT /logo uploads that run after the
|
|
// AppConfig PATCH and strips the gitops-only `path` keys from the PATCH
|
|
// payload (they have no matching field on fleet.OrgInfo). When a `path`
|
|
// is given, the URL keys for that mode are stripped too so the follow-up
|
|
// PUT is the sole writer.
|
|
//
|
|
// URL changes (set or clear) ride on the PATCH. The server deletes any
|
|
// orphan blob whose URL was just overwritten with an external or empty
|
|
// value, so the client doesn't need to orchestrate a separate DELETE.
|
|
func (c *Client) planAndStripOrgLogos(
|
|
orgSettings map[string]any,
|
|
baseDir string,
|
|
dryRun bool,
|
|
logFn func(format string, args ...any),
|
|
) ([]orgLogoAction, error) {
|
|
orgInfo, _ := orgSettings["org_info"].(map[string]any)
|
|
if orgInfo == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
type modeSpec struct {
|
|
mode fleet.OrgLogoMode
|
|
pathKey string
|
|
urlKey string
|
|
deprecatedURLKey string
|
|
}
|
|
specs := []modeSpec{
|
|
{fleet.OrgLogoModeLight, "org_logo_path_light_mode", "org_logo_url_light_mode", "org_logo_url_light_background"},
|
|
{fleet.OrgLogoModeDark, "org_logo_path_dark_mode", "org_logo_url_dark_mode", "org_logo_url"},
|
|
}
|
|
|
|
var actions []orgLogoAction
|
|
for _, s := range specs {
|
|
_, pathPresent := orgInfo[s.pathKey]
|
|
_, urlPresent := orgInfo[s.urlKey]
|
|
if !pathPresent && !urlPresent {
|
|
continue
|
|
}
|
|
|
|
yamlPath, _ := orgInfo[s.pathKey].(string)
|
|
yamlURL, _ := orgInfo[s.urlKey].(string)
|
|
if yamlPath != "" && yamlURL != "" {
|
|
return nil, fmt.Errorf(
|
|
"org_settings.org_info: cannot specify both '%s' and '%s' for %s mode",
|
|
s.pathKey, s.urlKey, s.mode,
|
|
)
|
|
}
|
|
|
|
switch {
|
|
case yamlPath != "":
|
|
absPath := resolveApplyRelativePath(baseDir, yamlPath)
|
|
if err := validateOrgLogoFile(absPath); err != nil {
|
|
return nil, fmt.Errorf("org logo (%s): %w", s.mode, err)
|
|
}
|
|
actions = append(actions, orgLogoAction{mode: s.mode, uploadPath: absPath})
|
|
// Strip every URL key for this mode: PUT will set the served URL
|
|
// (and its deprecated alias) after the PATCH, so we must keep
|
|
// PATCH from writing anything to either URL field.
|
|
delete(orgInfo, s.pathKey)
|
|
delete(orgInfo, s.urlKey)
|
|
delete(orgInfo, s.deprecatedURLKey)
|
|
if dryRun {
|
|
logFn("[+] would upload org logo (%s) from %s\n", s.mode, yamlPath)
|
|
}
|
|
case yamlURL != "":
|
|
// Mirror the new key into the deprecated alias: PATCH merges,
|
|
// and the server rejects the request if the two keys end up
|
|
// disagreeing after the merge. If the user explicitly set the
|
|
// deprecated alias to a different value, surface the
|
|
// conflict instead of silently overwriting it.
|
|
if dep, ok := orgInfo[s.deprecatedURLKey].(string); ok && dep != yamlURL {
|
|
return nil, fmt.Errorf(
|
|
"org_settings.org_info: '%s' (%q) conflicts with '%s' (%q) for %s mode",
|
|
s.urlKey, yamlURL, s.deprecatedURLKey, dep, s.mode,
|
|
)
|
|
}
|
|
delete(orgInfo, s.pathKey)
|
|
orgInfo[s.deprecatedURLKey] = yamlURL
|
|
default:
|
|
// Clearing this mode: surface any contradictory deprecated
|
|
// alias value rather than silently overwriting it.
|
|
if dep, ok := orgInfo[s.deprecatedURLKey].(string); ok && dep != "" {
|
|
return nil, fmt.Errorf(
|
|
"org_settings.org_info: '%s' is being cleared but '%s' is set to %q for %s mode",
|
|
s.urlKey, s.deprecatedURLKey, dep, s.mode,
|
|
)
|
|
}
|
|
delete(orgInfo, s.pathKey)
|
|
// Send both URL keys as "". If only the deprecated alias is
|
|
// sent, NormalizeLogoFields mirrors the still-populated new
|
|
// key back into it after merge, leaving the URL unchanged.
|
|
orgInfo[s.urlKey] = ""
|
|
orgInfo[s.deprecatedURLKey] = ""
|
|
}
|
|
}
|
|
return actions, nil
|
|
}
|
|
|
|
// doGitOpsOrgLogos executes the upload actions planned by
|
|
// planAndStripOrgLogos. Runs after the AppConfig PATCH so a PATCH failure
|
|
// leaves the logo store untouched.
|
|
func (c *Client) doGitOpsOrgLogos(
|
|
actions []orgLogoAction, dryRun bool, logFn func(format string, args ...any),
|
|
) error {
|
|
for _, a := range actions {
|
|
if dryRun {
|
|
continue // already logged at planning time
|
|
}
|
|
if err := c.UploadOrgLogo(a.mode, a.uploadPath); err != nil {
|
|
return fmt.Errorf("uploading org logo (%s): %w", a.mode, err)
|
|
}
|
|
logFn("[+] applied org logo (%s) from %s\n", a.mode, a.uploadPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateOrgLogoFile reads the file at path and runs the canonical
|
|
// fleet.ValidateOrgLogoBytes check on its contents, so a YAML referencing
|
|
// an invalid image fails fast at gitops apply time rather than mid-PATCH.
|
|
// The LimitReader caps the read at the file-size cap so a mis-pointed
|
|
// huge file doesn't get slurped into memory before being rejected.
|
|
func validateOrgLogoFile(path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("opening logo file %q: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
body, err := io.ReadAll(io.LimitReader(f, fleet.OrgLogoMaxFileSize+1))
|
|
if err != nil {
|
|
return fmt.Errorf("reading logo file %q: %w", path, err)
|
|
}
|
|
if err := fleet.ValidateOrgLogoBytes(body); err != nil {
|
|
return fmt.Errorf("logo file at %q: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UploadOrgLogo uploads the file at logoPath as the org logo for the given
|
|
// mode (light or dark) via PUT /api/latest/fleet/logo. The endpoint is
|
|
// multipart/form-data with a single "logo" field. Server-side validation
|
|
// rejects files larger than 100KB or that aren't PNG/JPEG/WebP.
|
|
func (c *Client) UploadOrgLogo(mode fleet.OrgLogoMode, logoPath string) error {
|
|
verb, path := "PUT", "/api/latest/fleet/logo"
|
|
|
|
var b bytes.Buffer
|
|
w := multipart.NewWriter(&b)
|
|
fw, err := w.CreateFormFile("logo", filepath.Base(logoPath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Open(logoPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
if _, err := io.Copy(fw, file); err != nil {
|
|
return err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("closing writer: %w", err)
|
|
}
|
|
|
|
resp, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path,
|
|
fmt.Sprintf("mode=%s", mode),
|
|
b.Bytes(),
|
|
map[string]string{
|
|
"Content-Type": w.FormDataContentType(),
|
|
"Accept": "application/json",
|
|
"Authorization": fmt.Sprintf("Bearer %s", c.token),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("do multipart request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("uploading org logo (%s): %s: %s", mode, resp.Status, string(body))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteOrgLogo clears the stored org logo for the given mode (light, dark, or
|
|
// all) via DELETE /api/latest/fleet/logo. The endpoint is idempotent — deleting
|
|
// an absent logo is a no-op server-side.
|
|
func (c *Client) DeleteOrgLogo(mode fleet.OrgLogoMode) error {
|
|
verb, path := "DELETE", "/api/latest/fleet/logo"
|
|
var responseBody deleteOrgLogoResponse
|
|
return c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, fmt.Sprintf("mode=%s", mode))
|
|
}
|
|
|
|
// GetOrgLogoContent fetches the stored org logo bytes for the given mode
|
|
// (light or dark) via GET /api/latest/fleet/logo. Returns the bytes and the
|
|
// detected Content-Type. The endpoint returns 404 when no logo is stored for
|
|
// the requested mode; callers should treat that as "no logo present".
|
|
func (c *Client) GetOrgLogoContent(mode fleet.OrgLogoMode) (body []byte, contentType string, err error) {
|
|
verb, path := "GET", "/api/latest/fleet/logo"
|
|
resp, err := c.AuthenticatedDo(verb, path, fmt.Sprintf("mode=%s", mode), nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("fetching org logo (%s): %w", mode, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, "", ¬FoundErr{Msg: fmt.Sprintf("no org logo stored for %s mode", mode)}
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
errBody, _ := io.ReadAll(resp.Body)
|
|
return nil, "", fmt.Errorf("fetching org logo (%s): %s: %s", mode, resp.Status, string(errBody))
|
|
}
|
|
body, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("reading org logo (%s) body: %w", mode, err)
|
|
}
|
|
return body, resp.Header.Get("Content-Type"), nil
|
|
}
|