Implement FMA software policy automation (#42533)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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
This commit is contained in:
Scott Gress 2026-03-30 11:25:46 -05:00 committed by GitHub
parent ec35465d1f
commit 07a8378a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 219 additions and 30 deletions

View file

@ -0,0 +1 @@
- Allow specifying a Fleet-Maintained App (FMA) as a policy software automation in GitOps

View file

@ -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

View file

@ -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,

View file

@ -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)
}

View file

@ -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

View file

@ -7,4 +7,4 @@
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';

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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
}

View file

@ -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) {

View file

@ -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.