diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index c382160bbe..0beddcf96e 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -2002,8 +2002,8 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, setupSoftwareBySoftwareTitle := make(map[uint]struct{}) setupSoftwareByPlatformAndAppID := make(map[string]struct{}) - // Emitted as setup_experience_platforms so a UI-set non-native selection - // round-trips through generate → apply unchanged. + // Emitted as setup_experience_platform (comma-separated) so a UI-set + // non-native selection round-trips through generate → apply unchanged. crossPlatformSelectionsByTitleID := make(map[uint][]string) // Fill in InstallDuringSetup for software, as that information is only available @@ -2043,7 +2043,9 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, if pkg.Platform == fleet.CanonicalPlatform(crossTarget) { continue } - crossPlatformSelectionsByTitleID[t.ID] = append(crossPlatformSelectionsByTitleID[t.ID], crossTarget) + // Emit the canonical platform token ("darwin", not "macos") to match + // the query/policy/label `platform` convention. + crossPlatformSelectionsByTitleID[t.ID] = append(crossPlatformSelectionsByTitleID[t.ID], fleet.CanonicalPlatform(crossTarget)) } } @@ -2353,7 +2355,7 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, softwareSpec["setup_experience"] = true } if crosses, ok := crossPlatformSelectionsByTitleID[softwareTitle.ID]; ok && len(crosses) > 0 { - softwareSpec["setup_experience_platforms"] = crosses + softwareSpec["setup_experience_platform"] = strings.Join(crosses, ",") } } else { platformAndAppID := softwareTitle.AppStoreApp.VPPAppID.String() diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5b7cac8929..b99894eb33 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -2043,9 +2043,11 @@ func (svc *Service) GetSelfServiceUninstallScriptResult(ctx context.Context, hos return scriptResult, nil } -// normalizeSetupExperiencePlatforms canonicalizes, deduplicates, and -// validates the incoming platforms against the extension's allowlist. Returns -// an error on the first incompatible entry; empty input is legal. +// normalizeSetupExperiencePlatforms lowercases, deduplicates, and validates +// the incoming platforms against the extension's allowlist. The "macos" alias +// is not accepted — only canonical tokens ("darwin", "linux"), consistent with +// the query/policy `platform` field. Returns an error on the first +// incompatible entry; empty input is legal. func normalizeSetupExperiencePlatforms(platforms []string, extension string) ([]string, error) { allowed := fleet.AllowedSetupExperiencePlatformsForExtension(extension) allowedSet := make(map[string]struct{}, len(allowed)) @@ -2055,21 +2057,22 @@ func normalizeSetupExperiencePlatforms(platforms []string, extension string) ([] seen := make(map[string]struct{}, len(platforms)) out := make([]string, 0, len(platforms)) for _, raw := range platforms { - canonical := fleet.CanonicalPlatform(raw) - if canonical == "" { + // No canonicalization, so "macos" is rejected rather than mapped to "darwin". + platform := strings.ToLower(strings.TrimSpace(raw)) + if platform == "" { continue } - if _, ok := allowedSet[canonical]; !ok { + if _, ok := allowedSet[platform]; !ok { return nil, fmt.Errorf( - `platform %q is not a valid "setup_experience_platforms" value for a .%s package (allowed: %s)`, + `platform %q is not a valid "setup_experience_platform" value for a .%s package (allowed: %s)`, raw, extension, strings.Join(allowed, ", "), ) } - if _, ok := seen[canonical]; ok { + if _, ok := seen[platform]; ok { continue } - seen[canonical] = struct{}{} - out = append(out, canonical) + seen[platform] = struct{}{} + out = append(out, platform) } return out, nil } @@ -3248,7 +3251,7 @@ func (svc *Service) softwareBatchUpload( installer.SetupExperiencePlatforms = &normalized if slices.Contains(normalized, "darwin") && manualAgentInstall { - return errors.New(`Couldn't edit software. "setup_experience_platforms" cannot include macOS if "macos_manual_agent_install" is enabled.`) + return errors.New(`Couldn't edit software. "setup_experience_platform" cannot include macOS if "macos_manual_agent_install" is enabled.`) } nativeSelected := slices.Contains(normalized, installer.Platform) @@ -3404,7 +3407,7 @@ func (svc *Service) softwareBatchUpload( } // Reconcile cross-platform setup experience selections when the incoming - // batch mentions them. A batch that never touches setup_experience_platforms + // batch mentions them. A batch that never touches setup_experience_platform // leaves the cross-table alone so UI-set selections aren't clobbered. if err := svc.reconcileGitOpsSetupExperienceCrossInstallers(ctx, ptr.ValOrZero(teamID), softwareInstallers); err != nil { batchErr = fmt.Errorf("reconciling cross-platform setup experience selections: %w", err) diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 23a72c238d..6f560787dd 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -1855,14 +1855,15 @@ func TestNormalizeSetupExperiencePlatforms(t *testing.T) { wantErr string }{ {name: "empty input", input: nil, extension: "sh", want: []string{}}, - {name: "sh macos alias", input: []string{"macos"}, extension: "sh", want: []string{"darwin"}}, - {name: "sh native only", input: []string{"linux"}, extension: "sh", want: []string{"linux"}}, - {name: "sh both platforms", input: []string{"macos", "linux"}, extension: "sh", want: []string{"darwin", "linux"}}, - {name: "sh dedupe canonical", input: []string{"macos", "darwin", "macos"}, extension: "sh", want: []string{"darwin"}}, - {name: "sh case + whitespace", input: []string{" MacOS ", "LINUX"}, extension: "sh", want: []string{"darwin", "linux"}}, - {name: "pkg any rejected", input: []string{"macos"}, extension: "pkg", wantErr: `platform "macos" is not a valid "setup_experience_platforms" value for a .pkg package`}, - {name: "msi any rejected", input: []string{"macos"}, extension: "msi", wantErr: `platform "macos" is not a valid "setup_experience_platforms" value for a .msi package`}, - {name: "sh unsupported windows", input: []string{"windows"}, extension: "sh", wantErr: `platform "windows" is not a valid "setup_experience_platforms" value for a .sh package`}, + {name: "sh darwin", input: []string{"darwin"}, extension: "sh", want: []string{"darwin"}}, + {name: "sh linux", input: []string{"linux"}, extension: "sh", want: []string{"linux"}}, + {name: "sh both platforms", input: []string{"darwin", "linux"}, extension: "sh", want: []string{"darwin", "linux"}}, + {name: "sh dedupe", input: []string{"darwin", "DARWIN", "darwin"}, extension: "sh", want: []string{"darwin"}}, + {name: "sh case + whitespace", input: []string{" Darwin ", "LINUX"}, extension: "sh", want: []string{"darwin", "linux"}}, + {name: "sh macos rejected", input: []string{"macos"}, extension: "sh", wantErr: `platform "macos" is not a valid "setup_experience_platform" value for a .sh package`}, + {name: "pkg any rejected", input: []string{"darwin"}, extension: "pkg", wantErr: `platform "darwin" is not a valid "setup_experience_platform" value for a .pkg package`}, + {name: "msi any rejected", input: []string{"darwin"}, extension: "msi", wantErr: `platform "darwin" is not a valid "setup_experience_platform" value for a .msi package`}, + {name: "sh unsupported windows", input: []string{"windows"}, extension: "sh", wantErr: `platform "windows" is not a valid "setup_experience_platform" value for a .sh package`}, {name: "empty string skipped", input: []string{""}, extension: "sh", want: []string{}}, } diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 3e46b63a60..ce78e0c95f 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -312,7 +312,7 @@ func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePac packageLevel.LabelsExcludeAny = spec.LabelsExcludeAny packageLevel.LabelsIncludeAll = spec.LabelsIncludeAll packageLevel.InstallDuringSetup = spec.InstallDuringSetup - packageLevel.SetupExperiencePlatforms = spec.SetupExperiencePlatforms + packageLevel.SetupExperiencePlatform = spec.SetupExperiencePlatform packageLevel.SelfService = spec.SelfService packageLevel.Configuration = spec.Configuration diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index de2e3272c4..87e07070db 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -788,7 +788,7 @@ func CanonicalPlatform(p string) string { } // AllowedSetupExperiencePlatformsForExtension returns the canonical platform -// names that may appear in a package's setup_experience_platforms field. Both +// names that may appear in a package's setup_experience_platform field. Both // the native platform and any supported non-native targets are allowed — // listing the native platform is the declarative equivalent of // setup_experience: true. @@ -914,13 +914,15 @@ type SoftwarePackageSpec struct { LabelsExcludeAny []string `json:"labels_exclude_any"` LabelsIncludeAll []string `json:"labels_include_all"` InstallDuringSetup optjson.Bool `json:"setup_experience"` - // SetupExperiencePlatforms selects the installer for the setup experience - // on non-native platforms. Additive with InstallDuringSetup: the native - // platform is controlled by that bool, this list feeds the - // setup_experience_software_installers cross-table. Only meaningful for - // packages whose file can run on more than one platform. - SetupExperiencePlatforms optjson.Slice[string] `json:"setup_experience_platforms,omitzero"` - Icon TeamSpecSoftwareAsset `json:"icon"` + // SetupExperiencePlatform selects the installer for the setup experience, + // as a comma-separated string of platforms (e.g. "darwin,linux"), + // consistent with the query/policy `platform` field. Additive with + // InstallDuringSetup: the native platform is controlled by that bool, the + // non-native entries feed the setup_experience_software_installers + // cross-table. Only meaningful for packages whose file can run on more than + // one platform (today: .sh). + SetupExperiencePlatform optjson.String `json:"setup_experience_platform,omitzero"` + Icon TeamSpecSoftwareAsset `json:"icon"` // Configuration is the managed app configuration file path; only meaningful for .ipa packages. Configuration TeamSpecSoftwareAsset `json:"configuration"` @@ -961,7 +963,7 @@ func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) Soft func (spec SoftwarePackageSpec) IncludesFieldsDisallowedInPackageFile() bool { return len(spec.LabelsExcludeAny) > 0 || len(spec.LabelsIncludeAny) > 0 || len(spec.LabelsIncludeAll) > 0 || len(spec.Categories.Value) > 0 || spec.SelfService || spec.InstallDuringSetup.Valid || - spec.SetupExperiencePlatforms.Set + spec.SetupExperiencePlatform.Set } func resolveApplyRelativePath(baseDir string, path string) string { @@ -973,38 +975,38 @@ func resolveApplyRelativePath(baseDir string, path string) string { } type MaintainedAppSpec struct { - Slug string `json:"slug"` - Version string `json:"version"` - SelfService bool `json:"self_service"` - PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` //nolint:apiparamcheck // SQL precondition for install - InstallScript TeamSpecSoftwareAsset `json:"install_script"` - PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` - UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` - LabelsIncludeAny []string `json:"labels_include_any"` - LabelsExcludeAny []string `json:"labels_exclude_any"` - LabelsIncludeAll []string `json:"labels_include_all"` - Categories optjson.Slice[string] `json:"categories,omitzero"` - InstallDuringSetup optjson.Bool `json:"setup_experience"` - SetupExperiencePlatforms optjson.Slice[string] `json:"setup_experience_platforms,omitzero"` - Icon TeamSpecSoftwareAsset `json:"icon"` + Slug string `json:"slug"` + Version string `json:"version"` + SelfService bool `json:"self_service"` + PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` //nolint:apiparamcheck // SQL precondition for install + InstallScript TeamSpecSoftwareAsset `json:"install_script"` + PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` + UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` + Categories optjson.Slice[string] `json:"categories,omitzero"` + InstallDuringSetup optjson.Bool `json:"setup_experience"` + SetupExperiencePlatform optjson.String `json:"setup_experience_platform,omitzero"` + Icon TeamSpecSoftwareAsset `json:"icon"` } func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec { return SoftwarePackageSpec{ - Slug: &spec.Slug, - Version: spec.Version, - PreInstallQuery: spec.PreInstallQuery, - InstallScript: spec.InstallScript, - PostInstallScript: spec.PostInstallScript, - UninstallScript: spec.UninstallScript, - SelfService: spec.SelfService, - SetupExperiencePlatforms: spec.SetupExperiencePlatforms, - LabelsIncludeAny: spec.LabelsIncludeAny, - LabelsExcludeAny: spec.LabelsExcludeAny, - LabelsIncludeAll: spec.LabelsIncludeAll, - InstallDuringSetup: spec.InstallDuringSetup, - Icon: spec.Icon, - Categories: spec.Categories, + Slug: &spec.Slug, + Version: spec.Version, + PreInstallQuery: spec.PreInstallQuery, + InstallScript: spec.InstallScript, + PostInstallScript: spec.PostInstallScript, + UninstallScript: spec.UninstallScript, + SelfService: spec.SelfService, + SetupExperiencePlatform: spec.SetupExperiencePlatform, + LabelsIncludeAny: spec.LabelsIncludeAny, + LabelsExcludeAny: spec.LabelsExcludeAny, + LabelsIncludeAll: spec.LabelsIncludeAll, + InstallDuringSetup: spec.InstallDuringSetup, + Icon: spec.Icon, + Categories: spec.Categories, } } diff --git a/server/service/client.go b/server/service/client.go index f0892b8ace..0e209425d9 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1431,15 +1431,21 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri } } - // Pointer-to-slice preserves the tri-state: nil = no change, empty = - // clear all cross-platform selections, non-empty = replace. The slice - // must stay non-nil so an empty list marshals as [] rather than null — - // null unmarshals server-side as "no change" and would silently swallow - // an explicit clear. + // setup_experience_platform is authored as a comma-separated string + // (consistent with the query/policy `platform` field); split it into the + // tri-state pointer-to-slice the batch payload uses: nil = no change, + // non-nil empty = clear all cross-platform selections, non-empty = + // replace. The slice must stay non-nil on an explicit empty value so it + // marshals as [] rather than null — null unmarshals server-side as "no + // change" and would silently swallow an explicit clear. var setupExperiencePlatforms *[]string - if si.SetupExperiencePlatforms.Set { - ps := make([]string, len(si.SetupExperiencePlatforms.Value)) - copy(ps, si.SetupExperiencePlatforms.Value) + if si.SetupExperiencePlatform.Set { + ps := make([]string, 0) + for tok := range strings.SplitSeq(si.SetupExperiencePlatform.Value, ",") { + if t := strings.TrimSpace(tok); t != "" { + ps = append(ps, t) + } + } setupExperiencePlatforms = &ps } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 958b37526f..14e14718a0 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -17124,7 +17124,7 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { crossScript := "echo 'cross-platform hello'" crossHash := sha256.Sum256([]byte(crossScript)) - darwinOnly := []string{"macos"} + darwinOnly := []string{"darwin"} crossPkg := []*fleet.SoftwareInstallerPayload{ { URL: "script://cross-hello.sh", @@ -17134,7 +17134,7 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { }, } - // [macos] on a .sh (native=linux): cross-table row for darwin, install_during_setup stays off. + // [darwin] on a .sh (native=linux): cross-table row for darwin, install_during_setup stays off. s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: crossPkg}, http.StatusAccepted, &batchResp, "team_name", crossTeam.Name) waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, crossTeam.Name, batchResp.RequestUUID) @@ -17199,7 +17199,7 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { // alone (native "linux" is present), and the darwin cross-row is // preserved. When SetupExperiencePlatforms is set it's authoritative for // both the native flag and the cross-table. - bothPlatforms := []string{"macos", "linux"} + bothPlatforms := []string{"darwin", "linux"} crossPkgBoth := []*fleet.SoftwareInstallerPayload{ { URL: "script://cross-hello.sh", @@ -17248,18 +17248,18 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { } s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: crossPkgBad}, http.StatusAccepted, &batchResp, "team_name", crossTeam.Name) failure = waitBatchSetSoftwareInstallersFailed(t, &s.withServer, crossTeam.Name, batchResp.RequestUUID) - require.Contains(t, failure, `platform "windows" is not a valid "setup_experience_platforms" value for a .sh package`) + require.Contains(t, failure, `platform "windows" is not a valid "setup_experience_platform" value for a .sh package`) - // CR-4: multi-installer batch with mixed opt-in. Installer A opts into - // [macos]; installer B leaves SetupExperiencePlatforms nil. B's existing + // Multi-installer batch with mixed opt-in. Installer A opts into + // [darwin]; installer B leaves SetupExperiencePlatforms nil. B's existing // darwin cross-row (seeded via a prior explicit apply) must survive the // batch instead of being wiped by A's opt-in. scriptB := "echo 'sibling'" scriptBHash := sha256.Sum256([]byte(scriptB)) // First, give both A and B a darwin cross-row so we have prior state to // preserve. - seedA := []string{"macos"} - seedB := []string{"macos"} + seedA := []string{"darwin"} + seedB := []string{"darwin"} s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{ {URL: "script://cross-hello.sh", SHA256: hex.EncodeToString(crossHash[:]), InstallScript: crossScript, SetupExperiencePlatforms: &seedA}, {URL: "script://sibling.sh", SHA256: hex.EncodeToString(scriptBHash[:]), InstallScript: scriptB, SetupExperiencePlatforms: &seedB}, diff --git a/server/service/pack_config_cache_test.go b/server/service/pack_config_cache_test.go index e43c4e04c2..9e48278590 100644 --- a/server/service/pack_config_cache_test.go +++ b/server/service/pack_config_cache_test.go @@ -329,4 +329,3 @@ func TestPackConfigCacheLegacyPacksBypass(t *testing.T) { assert.Greater(t, callCounter.Load(), callsAfterFirst, "expected DB call even on second request when legacy packs are present") } - diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index 541407efb8..db4405793f 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -127,7 +127,7 @@ github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsIncludeAny [] github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsExcludeAny []string github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsIncludeAll []string github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec InstallDuringSetup optjson.Bool -github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec SetupExperiencePlatforms optjson.Slice[string] +github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec SetupExperiencePlatform optjson.String github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Icon fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Configuration fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Slug *string @@ -153,7 +153,7 @@ github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec LabelsExcludeAny []st github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec LabelsIncludeAll []string github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Categories optjson.Slice[string] github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec InstallDuringSetup optjson.Bool -github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec SetupExperiencePlatforms optjson.Slice[string] +github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec SetupExperiencePlatform optjson.String github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Icon fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/SoftwareSpec AppStoreApps optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.TeamSpecAppStoreApp] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.TeamSpecAppStoreApp] Set bool