From 07a8378a684bc465c83c42c2541238cc2febdb03 Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Mon, 30 Mar 2026 11:25:46 -0500 Subject: [PATCH] Implement FMA software policy automation (#42533) **Related issue:** Resolves #36751 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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 - [X] QA'd all new/changed functionality manually - [X] Verified that `fleetctl generate-gitops` correctly outputs policies with `install_software.fleet_maintained_app_slug` populated when the policies have FMA automation - [X] Verified that running `fleetctl gitops` using files with `install_software.fleet_maintained_app_slug` creates/updates FMA policy automation correctly - [X] Verified no changes to the above for custom packages or VPP apps - [X] Verified that when software is excepted from GitOps, FMA policy automations still work (correctly validates FMAs exist before applying) ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [X] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] 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) checking on this - [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 --- changes/36751-add-fmas-to-policy-automation | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 15 +++- cmd/fleetctl/fleetctl/generate_gitops_test.go | 42 +++++++++- cmd/fleetctl/fleetctl/gitops_test.go | 26 +++++- .../generateGitops/expectedGlobalReports.yaml | 2 +- .../generateGitops/expectedTeamReports.yaml | 2 +- .../generateGitops/test_dir_free/default.yml | 2 +- .../test_dir_premium/default.yml | 2 +- .../fleets/team-a-thumbsup.yml | 27 +++++- pkg/spec/gitops.go | 36 +++++--- pkg/spec/gitops_test.go | 82 +++++++++++++++++-- server/service/client.go | 12 ++- 12 files changed, 219 insertions(+), 30 deletions(-) create mode 100644 changes/36751-add-fmas-to-policy-automation diff --git a/changes/36751-add-fmas-to-policy-automation b/changes/36751-add-fmas-to-policy-automation new file mode 100644 index 0000000000..99755d3c60 --- /dev/null +++ b/changes/36751-add-fmas-to-policy-automation @@ -0,0 +1 @@ +- Allow specifying a Fleet-Maintained App (FMA) as a policy software automation in GitOps \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 18dee1b3f1..a9e96e5c73 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -57,6 +57,7 @@ type Software struct { AppStoreId string Comment string MaintainedAppID uint + Slug string } type teamToProcess struct { @@ -1542,11 +1543,16 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string // Handle software automation. if policy.InstallSoftware != nil { if software, ok := cmd.SoftwareList[policy.InstallSoftware.SoftwareTitleID]; ok { - if software.Hash != "" { + switch { + case software.MaintainedAppID != 0 && software.Slug != "": + policySpec["install_software"] = map[string]any{ + "fleet_maintained_app_slug": software.Slug, + } + case software.Hash != "": policySpec["install_software"] = map[string]any{ "hash_sha256": software.Hash + " " + software.Comment, } - } else if software.AppStoreId != "" { + case software.AppStoreId != "": policySpec["install_software"] = map[string]any{ "app_store_id": software.AppStoreId, } @@ -1869,6 +1875,11 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, } if sw.SoftwarePackage != nil && sw.SoftwarePackage.FleetMaintainedAppID != nil { swEntry.MaintainedAppID = *sw.SoftwarePackage.FleetMaintainedAppID + slug, err := slugResolver.resolve(*sw.SoftwarePackage.FleetMaintainedAppID) + if err != nil { + return nil, err + } + swEntry.Slug = slug } cmd.SoftwareList[sw.ID] = swEntry diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 6bd82cf90b..5c842d7615 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -368,7 +368,7 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { }, }, nil } - return []*fleet.Policy{ + policies := []*fleet.Policy{ { PolicyData: fleet.PolicyData{ ID: 1, @@ -414,7 +414,41 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { SoftwareTitleID: 2, }, }, - }, nil + } + // Only add FMA and package install policies for actual teams, not unassigned. + if *teamID != 0 { + policies = append(policies, + &fleet.Policy{ + PolicyData: fleet.PolicyData{ + ID: 4, + Name: "Team FMA install policy", + Query: "SELECT 1 FROM filevault_status WHERE status LIKE '%on%';", + Resolution: ptr.String("Install the FMA"), + Description: "This is a team policy with FMA install automation", + Platform: "darwin", + Type: fleet.PolicyTypeDynamic, + }, + InstallSoftware: &fleet.PolicySoftwareTitle{ + SoftwareTitleID: 8, + }, + }, + &fleet.Policy{ + PolicyData: fleet.PolicyData{ + ID: 5, + Name: "Team package install policy", + Query: "SELECT 1 FROM mounts WHERE path = '/' AND CAST(blocks_available AS REAL) / blocks > 0.10;", + Resolution: ptr.String("Install the package"), + Description: "This is a team policy with custom package install automation", + Platform: "linux,windows", + Type: fleet.PolicyTypeDynamic, + }, + InstallSoftware: &fleet.PolicySoftwareTitle{ + SoftwareTitleID: 1, + }, + }, + ) + } + return policies, nil } func (MockClient) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) { @@ -423,7 +457,7 @@ func (MockClient) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) { ID: 1, Name: "Global Query", - Query: "SELECT * FROM global_query WHERE id = 1", + Query: "SELECT * FROM os_version;", Description: "This is a global query", Platform: "darwin", Interval: 3600, @@ -443,7 +477,7 @@ func (MockClient) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) { ID: 1, Name: "Team Query", - Query: "SELECT * FROM team_query WHERE id = 1", + Query: "SELECT * FROM plist WHERE path LIKE '/Users/%/Library/Preferences/com.apple.CloudSubscriptionFeatures.optIn.plist';", Description: "This is a team query", Platform: "linux,windows", Interval: 1800, diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 0d4fc735fc..ec25a2ca48 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -1240,7 +1240,23 @@ func TestGitOpsSoftwareExceptionPolicyValidation(t *testing.T) { Platform: string(fleet.MacOSPlatform), }, }, - }, 2, nil, nil + { + ID: 40, + Name: "Zoom", + HashSHA256: ptr.String("fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1fma1"), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "zoom.pkg", + Platform: "darwin", + Version: "6.0", + FleetMaintainedAppID: ptr.Uint(100), + }, + }, + }, 3, nil, nil + } + ds.ListAvailableFleetMaintainedAppsFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { + return []fleet.MaintainedApp{ + {ID: 100, Slug: "zoom/darwin"}, + }, nil, nil } // Config files that omit software: but have policies referencing server-side software @@ -1270,6 +1286,10 @@ policies: query: SELECT 1 install_software: app_store_id: "5128675309" + - name: FMA Policy + query: SELECT 1 + install_software: + fleet_maintained_app_slug: zoom/darwin agent_options: reports: `), 0o644)) @@ -1290,13 +1310,15 @@ policies: require.NoError(t, err, "gitops should succeed when policies reference server-side software and software is excepted") // Check that policies for "Test Fleet" contained the expected software title IDs. testFleetPolicySpecs := policySpecsByTeam["Test Fleet"] - require.Len(t, testFleetPolicySpecs, 2, "expected 2 policies for Test Fleet") + require.Len(t, testFleetPolicySpecs, 3, "expected 3 policies for Test Fleet") for _, spec := range testFleetPolicySpecs { switch spec.Name { case "Package Policy": assert.Equal(t, uint(10), *spec.SoftwareTitleID, "expected server-side software ID to be injected into Package Policy spec") case "VPP Policy": assert.Equal(t, uint(20), *spec.SoftwareTitleID, "expected server-side software ID to be injected into VPP Policy spec") + case "FMA Policy": + assert.Equal(t, uint(40), *spec.SoftwareTitleID, "expected server-side software ID to be injected into FMA Policy spec") default: t.Errorf("unexpected policy name: %s", spec.Name) } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalReports.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalReports.yaml index fcfa479144..01a904ff4a 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalReports.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalReports.yaml @@ -9,5 +9,5 @@ name: Global Query observer_can_run: true platform: darwin - query: SELECT * FROM global_query WHERE id = 1 + query: SELECT * FROM os_version; discard_data: false \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamReports.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamReports.yaml index f55970732a..cfa2192c2b 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamReports.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamReports.yaml @@ -7,4 +7,4 @@ name: Team Query observer_can_run: false platform: linux,windows - query: SELECT * FROM team_query WHERE id = 1 \ No newline at end of file + query: SELECT * FROM plist WHERE path LIKE '/Users/%/Library/Preferences/com.apple.CloudSubscriptionFeatures.optIn.plist'; \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml index af912fe2df..93eb4fab3b 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml @@ -204,4 +204,4 @@ reports: name: Global Query observer_can_run: true platform: darwin - query: SELECT * FROM global_query WHERE id = 1 + query: SELECT * FROM os_version; diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml index ffb3ccfd80..31f8f93410 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -200,4 +200,4 @@ reports: name: Global Query observer_can_run: true platform: darwin - query: SELECT * FROM global_query WHERE id = 1 + query: SELECT * FROM os_version; diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml index 3dc5a02118..0fe28ba024 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml @@ -82,6 +82,31 @@ policies: resolution: Install the app type: dynamic webhooks_and_tickets_enabled: true +- calendar_events_enabled: false + conditional_access_enabled: false + critical: false + description: This is a team policy with FMA install automation + install_software: + fleet_maintained_app_slug: fma1/darwin + name: Team FMA install policy + platform: darwin + query: SELECT 1 FROM filevault_status WHERE status LIKE '%on%'; + resolution: Install the FMA + type: dynamic + webhooks_and_tickets_enabled: false +- calendar_events_enabled: false + conditional_access_enabled: false + critical: false + description: This is a team policy with custom package install automation + install_software: + hash_sha256: software-package-hash # My Software Package (my-software.pkg) version 13.37 + name: Team package install policy + platform: linux,windows + query: SELECT 1 FROM mounts WHERE path = '/' AND CAST(blocks_available AS REAL) + / blocks > 0.10; + resolution: Install the package + type: dynamic + webhooks_and_tickets_enabled: false reports: - automations_enabled: true description: This is a team query @@ -92,7 +117,7 @@ reports: name: Team Query observer_can_run: false platform: linux,windows - query: SELECT * FROM team_query WHERE id = 1 + query: SELECT * FROM plist WHERE path LIKE '/Users/%/Library/Preferences/com.apple.CloudSubscriptionFeatures.optIn.plist'; settings: features: enable_host_users: true diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index d16528e2a7..46729ed1d1 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -227,9 +227,10 @@ type PolicyRunScript struct { } type PolicyInstallSoftware struct { - PackagePath string `json:"package_path"` - AppStoreID string `json:"app_store_id"` - HashSHA256 string `json:"hash_sha256"` + PackagePath string `json:"package_path"` + AppStoreID string `json:"app_store_id"` + HashSHA256 string `json:"hash_sha256"` + FleetMaintainedAppSlug string `json:"fleet_maintained_app_slug"` } type Query struct { @@ -1420,7 +1421,7 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError = multierror.Append(multiError, validateRawKeys(policiesRaw, reflect.TypeFor[[]Policy](), filePath, []string{"policies"})...) for _, item := range policies { if item.Path == nil { - if errs := parsePolicyInstallSoftware(baseDir, result.TeamName, &item, result.Software.Packages, result.Software.AppStoreApps); errs != nil { + if errs := parsePolicyInstallSoftware(baseDir, result.TeamName, &item, result.Software.Packages, result.Software.AppStoreApps, fmasBySlug); errs != nil { multiError = multierror.Append(multiError, errs...) continue } @@ -1456,7 +1457,7 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path), ) } else { - if errs := parsePolicyInstallSoftware(filepath.Dir(*item.Path), result.TeamName, pp, result.Software.Packages, result.Software.AppStoreApps); errs != nil { + if errs := parsePolicyInstallSoftware(filepath.Dir(*item.Path), result.TeamName, pp, result.Software.Packages, result.Software.AppStoreApps, fmasBySlug); errs != nil { multiError = multierror.Append(multiError, errs...) continue } @@ -1555,7 +1556,7 @@ func parsePolicyRunScript(baseDir string, parentFilePath string, teamName *strin return nil } -func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec, appStoreApps []*fleet.TeamSpecAppStoreApp) []error { +func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec, appStoreApps []*fleet.TeamSpecAppStoreApp, fmasBySlug map[string]struct{}) []error { installSoftwareObj := policy.InstallSoftware.Other if installSoftwareObj == nil { policy.SoftwareTitleID = ptr.Uint(0) // unset the installer @@ -1568,14 +1569,20 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy wrapErrs := func(err error) []error { return []error{wrapErr(err)} } - if (installSoftwareObj.PackagePath != "" || installSoftwareObj.AppStoreID != "") && teamName == nil { + if (installSoftwareObj.PackagePath != "" || installSoftwareObj.AppStoreID != "" || installSoftwareObj.HashSHA256 != "" || installSoftwareObj.FleetMaintainedAppSlug != "") && teamName == nil { return wrapErrs(errors.New("install_software can only be set on team policies")) } - if installSoftwareObj.PackagePath == "" && installSoftwareObj.AppStoreID == "" && installSoftwareObj.HashSHA256 == "" { - return wrapErrs(errors.New("install_software must include either a package_path, an app_store_id or a hash_sha256")) + if installSoftwareObj.PackagePath == "" && installSoftwareObj.AppStoreID == "" && installSoftwareObj.HashSHA256 == "" && installSoftwareObj.FleetMaintainedAppSlug == "" { + return wrapErrs(errors.New("install_software must include either a package_path, an app_store_id, a hash_sha256 or a fleet_maintained_app_slug")) } - if installSoftwareObj.PackagePath != "" && installSoftwareObj.AppStoreID != "" { - return wrapErrs(errors.New("install_software must have only one of package_path or app_store_id")) + setCount := 0 + for _, s := range []string{installSoftwareObj.PackagePath, installSoftwareObj.AppStoreID, installSoftwareObj.HashSHA256, installSoftwareObj.FleetMaintainedAppSlug} { + if s != "" { + setCount++ + } + } + if setCount > 1 { + return wrapErrs(errors.New("install_software must have only one of package_path, app_store_id, hash_sha256 or fleet_maintained_app_slug")) } var errs []error @@ -1639,6 +1646,13 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy } } + if installSoftwareObj.FleetMaintainedAppSlug != "" { + if _, ok := fmasBySlug[installSoftwareObj.FleetMaintainedAppSlug]; !ok { + errs = append(errs, wrapErr(fmt.Errorf("install_software.fleet_maintained_app_slug %q not found in software.fleet_maintained_apps for team %s", installSoftwareObj.FleetMaintainedAppSlug, *teamName))) + } + policy.FleetMaintainedAppSlug = installSoftwareObj.FleetMaintainedAppSlug + } + return errs } diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 3b9c27c882..b0ded593b6 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -1207,7 +1207,7 @@ policies: package_path: ` _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "install_software must include either a package_path, an app_store_id or a hash_sha256") + assert.ErrorContains(t, err, "install_software must include either a package_path, an app_store_id, a hash_sha256 or a fleet_maintained_app_slug") config = getTeamConfig([]string{"policies"}) config += ` @@ -1219,7 +1219,7 @@ policies: app_store_id: "123456" ` _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "must have only one of package_path or app_store_id") + assert.ErrorContains(t, err, "must have only one of package_path, app_store_id, hash_sha256 or fleet_maintained_app_slug") // Software has a URL that's too big tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 4000-23)) @@ -3373,9 +3373,9 @@ func TestParsePolicyInstallSoftware(t *testing.T) { InstallSoftware: installSoftware, // no package_path, app_store_id, or hash_sha256 }, } - errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil) + errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, nil) require.Len(t, errs, 1) - assert.Equal(t, errs[0].Error(), `failed to parse policy install_software "my policy": install_software must include either a package_path, an app_store_id or a hash_sha256`) + assert.Equal(t, errs[0].Error(), `failed to parse policy install_software "my policy": install_software must include either a package_path, an app_store_id, a hash_sha256 or a fleet_maintained_app_slug`) }) t.Run("unknown key in package_path file", func(t *testing.T) { @@ -3396,12 +3396,84 @@ func TestParsePolicyInstallSoftware(t *testing.T) { }, } packages := []*fleet.SoftwarePackageSpec{{SHA256: sha}} - errs := parsePolicyInstallSoftware(".", &teamName, policy, packages, nil) + errs := parsePolicyInstallSoftware(".", &teamName, policy, packages, nil, nil) require.Len(t, errs, 1) var unknownErr *ParseUnknownKeyError require.ErrorAs(t, errs[0], &unknownErr) assert.Equal(t, "bad_field", unknownErr.Field) }) + t.Run("fleet_maintained_app_slug valid", func(t *testing.T) { + t.Parallel() + + var installSoftware optjson.BoolOr[*PolicyInstallSoftware] + installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"} + + policy := &Policy{ + GitOpsPolicySpec: GitOpsPolicySpec{ + PolicySpec: fleet.PolicySpec{Name: "fma policy"}, + InstallSoftware: installSoftware, + }, + } + fmasBySlug := map[string]struct{}{"zoom/darwin": {}} + errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug) + require.Nil(t, errs) + assert.Equal(t, "zoom/darwin", policy.FleetMaintainedAppSlug) + }) + + t.Run("fleet_maintained_app_slug not in FMAs", func(t *testing.T) { + t.Parallel() + + var installSoftware optjson.BoolOr[*PolicyInstallSoftware] + installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "notreal/darwin"} + + policy := &Policy{ + GitOpsPolicySpec: GitOpsPolicySpec{ + PolicySpec: fleet.PolicySpec{Name: "bad fma policy"}, + InstallSoftware: installSoftware, + }, + } + fmasBySlug := map[string]struct{}{"zoom/darwin": {}} + errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), `fleet_maintained_app_slug "notreal/darwin" not found`) + }) + + t.Run("fleet_maintained_app_slug with other fields errors", func(t *testing.T) { + t.Parallel() + + var installSoftware optjson.BoolOr[*PolicyInstallSoftware] + installSoftware.Other = &PolicyInstallSoftware{ + FleetMaintainedAppSlug: "zoom/darwin", + HashSHA256: "abc123", + } + + policy := &Policy{ + GitOpsPolicySpec: GitOpsPolicySpec{ + PolicySpec: fleet.PolicySpec{Name: "conflicting policy"}, + InstallSoftware: installSoftware, + }, + } + errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, nil) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "install_software must have only one of") + }) + + t.Run("fleet_maintained_app_slug on global policy errors", func(t *testing.T) { + t.Parallel() + + var installSoftware optjson.BoolOr[*PolicyInstallSoftware] + installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"} + + policy := &Policy{ + GitOpsPolicySpec: GitOpsPolicySpec{ + PolicySpec: fleet.PolicySpec{Name: "global fma policy"}, + InstallSoftware: installSoftware, + }, + } + errs := parsePolicyInstallSoftware(".", nil, policy, nil, nil, nil) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "install_software can only be set on team policies") + }) } func TestGitOpsPresenceTracking(t *testing.T) { diff --git a/server/service/client.go b/server/service/client.go index fb5f3306e2..6c1f4183b9 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2880,7 +2880,6 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] config.Policies[i].SoftwareTitleID = ptr.Uint(0) // 0 unsets the installer if !config.Policies[i].InstallSoftware.IsOther && config.Policies[i].InstallSoftware.Bool { - fmt.Printf("softwareTitleIDsBySlug: %v\n", softwareTitleIDsBySlug) softwareTitleID, ok := softwareTitleIDsBySlug[config.Policies[i].FleetMaintainedAppSlug] if !ok { // Should not happen because FMAs are uploaded first. @@ -2928,6 +2927,17 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } config.Policies[i].SoftwareTitleID = &softwareTitleID } + if config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug != "" { + softwareTitleID, ok := softwareTitleIDsBySlug[config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug] + if !ok { + // Should not happen because FMAs are uploaded first. + if !dryRun { + logFn("[!] fleet-maintained app slug without software title ID: %s\n", config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug) + } + continue + } + config.Policies[i].SoftwareTitleID = &softwareTitleID + } } // Get scripts for the team.