diff --git a/articles/automatic-software-install-in-fleet.md b/articles/automatic-software-install-in-fleet.md index 0f6d79e8400b..69cec4d6fcac 100644 --- a/articles/automatic-software-install-in-fleet.md +++ b/articles/automatic-software-install-in-fleet.md @@ -112,6 +112,19 @@ Fleet provides a REST API for managing policies, including software install auto To manage software automations using Fleet's best practice GitOps, check out the `install_software` key in the [policies section of the GitOps reference documentation](https://fleetdm.com/docs/configuration/yaml-files#policies). +You can use Fleet-maintained apps in policies by specifying the `slug` field in `install_software`: + +```yaml +policies: + - name: macOS - Company Portal installed + query: "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.CompanyPortalMac';" + install_software: + slug: intune-company-portal/darwin + platform: darwin +``` + +Note that the Fleet-maintained app must be added to the team first (via the `fleet_maintained_apps` section in the software configuration) before it can be referenced in a policy. + ## Conclusion Software deployment can be time-consuming and risky. This guide presents Fleet's ability to mass deploy software to your fleet in a simple and safe way. Starting with uploading a trusted installer and ending with deploying it to the proper set of machines answering the exact policy defined by you. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 514e523e26a8..d7c81e960544 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -85,6 +85,7 @@ type generateGitopsClient interface { GetAppleMDMEnrollmentProfile(teamID uint) (*fleet.MDMAppleSetupAssistant, error) GetCertificateAuthoritiesSpec(includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) GetCertificateTemplates(teamID string) ([]*fleet.CertificateTemplateResponseSummary, error) + ListFleetMaintainedApps(teamID *uint, query string) ([]fleet.MaintainedApp, error) } // Given a struct type and a field name, return the JSON field name. @@ -132,6 +133,7 @@ type GenerateGitopsCommand struct { AppConfig *fleet.EnrichedAppConfig SoftwareList map[uint]Software ScriptList map[uint]string + FMASlugMap map[uint]string // Maps software_title_id to FMA slug } func generateGitopsCommand() *cli.Command { @@ -190,6 +192,7 @@ func createGenerateGitopsAction(fleetClient generateGitopsClient) func(*cli.Cont FilesToWrite: make(map[string]interface{}), SoftwareList: make(map[uint]Software), ScriptList: make(map[uint]string), + FMASlugMap: make(map[uint]string), } return cmd.Run() } @@ -1317,12 +1320,17 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string } // Handle software automation. if policy.InstallSoftware != nil { - if software, ok := cmd.SoftwareList[policy.InstallSoftware.SoftwareTitleID]; ok { - policySpec["install_software"] = map[string]interface{}{ + // Check if this is a Fleet-maintained app + if slug, ok := cmd.FMASlugMap[policy.InstallSoftware.SoftwareTitleID]; ok { + policySpec["install_software"] = map[string]any{ + "slug": slug, + } + } else if software, ok := cmd.SoftwareList[policy.InstallSoftware.SoftwareTitleID]; ok { + policySpec["install_software"] = map[string]any{ "hash_sha256": software.Hash + " " + software.Comment, } } else { - policySpec["install_software"] = map[string]interface{}{ + policySpec["install_software"] = map[string]any{ "hash_sha256": cmd.AddComment(filePath, "TODO: Add your hash_sha256 here"), } cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ @@ -1403,7 +1411,21 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, return nil, nil // software is premium-only } - query := fmt.Sprintf("available_for_install=1&team_id=%d", teamID) + // Get Fleet-maintained apps for the team to build slug map + query := fmt.Sprintf("team_id=%d&per_page=10000", teamID) + fleetMaintainedApps, err := cmd.Client.ListFleetMaintainedApps(&teamID, query) + if err != nil { + // Log warning but continue - FMAs might not be available + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Warning: failed to get Fleet-maintained apps: %s\n", err) + } else { + for _, app := range fleetMaintainedApps { + if app.TitleID != nil && app.Slug != "" { + cmd.FMASlugMap[*app.TitleID] = app.Slug + } + } + } + + query = fmt.Sprintf("available_for_install=1&team_id=%d", teamID) software, err := cmd.Client.ListSoftwareTitles(query) if err != nil { fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting software: %s\n", err) diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index bf95f105a526..20fcd0d716ca 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -430,6 +430,11 @@ func (MockClient) GetSoftwareTitleIcon(titleID uint, teamID uint) ([]byte, error return []byte(fmt.Sprintf("icon for title %d on team %d", titleID, teamID)), nil } +func (MockClient) ListFleetMaintainedApps(teamID *uint, query string) ([]fleet.MaintainedApp, error) { + // Return empty list for tests - FMAs can be added if needed for specific test cases + return []fleet.MaintainedApp{}, nil +} + func (MockClient) GetLabels() ([]*fleet.LabelSpec, error) { return []*fleet.LabelSpec{{ Name: "Label A", diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md index b12edfd1d8e5..79cb50094f8f 100644 --- a/docs/Configuration/yaml-files.md +++ b/docs/Configuration/yaml-files.md @@ -121,7 +121,10 @@ For possible options, see the parameters for the [Add policy API endpoint](https In Fleet Premium you can trigger software installs or script runs on policy failure: -- For software installs, specify either `install_software.package_path` or `install_software.hash_sha256` in your YAML. If `install_software.package_path` only one package can be specified in the package YAML. +- For software installs, specify one of the following in your YAML: + - `install_software.package_path` - Path to a software package YAML file (only one package can be specified in the package YAML) + - `install_software.hash_sha256` - SHA256 hash of the software package + - `install_software.slug` - Slug of a Fleet-maintained app (e.g., `intune-company-portal/darwin`) - For script runs, specify `run_script.path`. > Specifying one package without a list is deprecated as of Fleet 4.73. It is maintained for backwards compatibility. Please use a list instead even if you're only specifying one package. @@ -184,6 +187,13 @@ policies: install_software: package_path: ./linux-firefox.deb.package.yml # app_store_id: "1487937127" (for App Store apps) +- name: macOS - Company Portal installed + platform: darwin + description: This policy checks that Company Portal is installed + resolution: Company Portal should be automatically installed. If it is missing, install it from self-service. + query: "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.CompanyPortalMac';" + install_software: + slug: intune-company-portal/darwin ``` `default.yml` (for policies that neither install software nor run scripts), `teams/team-name.yml`, or `teams/no-team.yml` @@ -508,7 +518,7 @@ The `software` section allows you to configure packages, store apps (Apple App S - `app_store_apps` is a list of Apple App Store or Android Play Store apps. - `fleet_maintained_apps` is a list of Fleet-maintained apps. -Currently, you can specify `install_software` in the [`policies` YAML](#policies) to automatically install a custom package or App Store app when a host fails a policy. [Automatic install support for Fleet-maintained apps](https://github.com/fleetdm/fleet/issues/29584) is coming soon. +Currently, you can specify `install_software` in the [`policies` YAML](#policies) to automatically install a custom package, App Store app, or Fleet-maintained app when a host fails a policy. For Fleet-maintained apps, use the `slug` field (e.g., `slug: intune-company-portal/darwin`). Currently, Fleet only allows one package, Apple App Store app, or Fleet-maintained app for a specific software. This means, if you specify a Google Chrome for macOS twice in `packages` or once in `packages` and once in `fleet_maintained_apps`, only one of them will be added to Fleet. diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 78d0bcca6c2f..292970f6801e 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -149,6 +149,7 @@ type PolicyInstallSoftware struct { PackagePath string `json:"package_path"` AppStoreID string `json:"app_store_id"` HashSHA256 string `json:"hash_sha256"` + Slug string `json:"slug"` } type Query struct { @@ -1081,14 +1082,30 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy policy.SoftwareTitleID = ptr.Uint(0) // unset the installer return nil } - if policy.InstallSoftware != nil && (policy.InstallSoftware.PackagePath != "" || policy.InstallSoftware.AppStoreID != "") && teamName == nil { + if policy.InstallSoftware != nil && (policy.InstallSoftware.PackagePath != "" || policy.InstallSoftware.AppStoreID != "" || policy.InstallSoftware.Slug != "") && teamName == nil { return errors.New("install_software can only be set on team policies") } - if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" && policy.InstallSoftware.HashSHA256 == "" { - return errors.New("install_software must include either a package_path, an app_store_id or a hash_sha256") + if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" && policy.InstallSoftware.HashSHA256 == "" && policy.InstallSoftware.Slug == "" { + return errors.New("install_software must include either a package_path, an app_store_id, a hash_sha256, or a slug") } - if policy.InstallSoftware.PackagePath != "" && policy.InstallSoftware.AppStoreID != "" { - return errors.New("install_software must have only one of package_path or app_store_id") + // Count how many mutually exclusive fields are set + // Note: hash_sha256 can be used standalone, but slug should be mutually exclusive with all others + fieldsSet := 0 + if policy.InstallSoftware.PackagePath != "" { + fieldsSet++ + } + if policy.InstallSoftware.AppStoreID != "" { + fieldsSet++ + } + if policy.InstallSoftware.Slug != "" { + fieldsSet++ + } + if fieldsSet > 1 { + return errors.New("install_software must have only one of package_path, app_store_id, or slug") + } + // Slug should also be mutually exclusive with hash_sha256 + if policy.InstallSoftware.Slug != "" && policy.InstallSoftware.HashSHA256 != "" { + return errors.New("install_software must have only one of hash_sha256 or slug") } if policy.InstallSoftware.PackagePath != "" { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index e4da977e9ac0..35aad4b8204b 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -1049,7 +1049,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 slug") config = getTeamConfig([]string{"policies"}) config += ` @@ -1061,7 +1061,72 @@ 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, or slug") + + // Test slug validation + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + slug: intune-company-portal/darwin +` + gitops, err := gitOpsFromString(t, config) + require.NoError(t, err) + require.Len(t, gitops.Policies, 1) + assert.NotNil(t, gitops.Policies[0].InstallSoftware) + assert.Equal(t, "intune-company-portal/darwin", gitops.Policies[0].InstallSoftware.Slug) + + // Test slug cannot be used on global policies + config = getGlobalConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + slug: intune-company-portal/darwin +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "install_software can only be set on team policies") + + // Test slug cannot be combined with other install_software options + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + slug: intune-company-portal/darwin + package_path: ./some_path.yml +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "must have only one of package_path, app_store_id, or slug") + + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + slug: intune-company-portal/darwin + app_store_id: "123456" +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "must have only one of package_path, app_store_id, or slug") + + // Test slug cannot be combined with hash_sha256 + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + slug: intune-company-portal/darwin + hash_sha256: abc123def456 +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "must have only one of hash_sha256 or slug") // Software has a URL that's too big tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 4000-23)) diff --git a/server/mock/datastore.go b/server/mock/datastore.go index 3237c236db87..e0e4e3fd9648 100644 --- a/server/mock/datastore.go +++ b/server/mock/datastore.go @@ -42,3 +42,12 @@ func (m *Store) MigrationStatus(ctx context.Context) (*fleet.MigrationStatus, er return &fleet.MigrationStatus{}, nil } func (m *Store) Name() string { return "mock" } + +// ListAvailableFleetMaintainedApps provides a default implementation that returns empty results +// to prevent nil pointer dereference when the function is not set in tests. +func (m *Store) ListAvailableFleetMaintainedApps(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { + if m.DataStore.ListAvailableFleetMaintainedAppsFunc != nil { + return m.DataStore.ListAvailableFleetMaintainedApps(ctx, teamID, opt) + } + return []fleet.MaintainedApp{}, &fleet.PaginationMetadata{}, nil +} diff --git a/server/service/client.go b/server/service/client.go index cc8404ede6f2..c79a96037ae2 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2753,6 +2753,41 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] softwareTitleIDsByAppStoreAppID[vppApp.AppStoreID] = *vppApp.TitleID } + // Get Fleet-maintained apps for the team to resolve slugs to software_title_id + // Only fetch if there are policies that use slugs + softwareTitleIDsBySlug := make(map[string]uint) + hasSlugPolicies := false + for i := range config.Policies { + if config.Policies[i].InstallSoftware != nil && config.Policies[i].InstallSoftware.Slug != "" { + hasSlugPolicies = true + break + } + } + fmaFetchSucceeded := false + if hasSlugPolicies { + query := fmt.Sprintf("team_id=%d&per_page=10000", *teamID) + fleetMaintainedApps, err := c.ListFleetMaintainedApps(teamID, query) + if err != nil { + // Check if this is a license/forbidden error - if so, silently skip (Fleet-maintained apps require Premium) + errStr := err.Error() + isLicenseError := strings.Contains(errStr, "missing or invalid license") || + strings.Contains(errStr, "Requires Fleet Premium license") || + strings.Contains(errStr, "forbidden") || + strings.Contains(errStr, "403") + // Only log non-license errors - license errors are expected in non-premium environments + if !isLicenseError && !dryRun { + logFn("[!] failed to get Fleet-maintained apps for team %d: %v\n", *teamID, err) + } + } else { + fmaFetchSucceeded = true + for _, app := range fleetMaintainedApps { + if app.TitleID != nil && app.Slug != "" { + softwareTitleIDsBySlug[app.Slug] = *app.TitleID + } + } + } + } + for i := range config.Policies { config.Policies[i].SoftwareTitleID = ptr.Uint(0) // 0 unsets the installer @@ -2792,6 +2827,18 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } config.Policies[i].SoftwareTitleID = &softwareTitleID } + if config.Policies[i].InstallSoftware.Slug != "" { + softwareTitleID, ok := softwareTitleIDsBySlug[config.Policies[i].InstallSoftware.Slug] + if !ok { + // Only log if we successfully fetched the FMA list (meaning Premium is available) + // If we didn't fetch due to license errors, skip logging to avoid false warnings + if fmaFetchSucceeded && !dryRun { + logFn("[!] Fleet-maintained app slug %q not found on team %d (make sure the app is added to the team first)\n", config.Policies[i].InstallSoftware.Slug, *teamID) + } + continue + } + config.Policies[i].SoftwareTitleID = &softwareTitleID + } } // Get scripts for the team. diff --git a/server/service/client_software.go b/server/service/client_software.go index 739b07d60f14..45cf4a016c95 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -247,3 +247,14 @@ func (c *Client) InstallSoftware(hostID uint, softwareTitleID uint) error { var responseBody installSoftwareResponse return c.authenticatedRequest(nil, verb, path, &responseBody) } + +// ListFleetMaintainedApps retrieves the Fleet-maintained apps available for a team. +func (c *Client) ListFleetMaintainedApps(teamID *uint, query string) ([]fleet.MaintainedApp, error) { + verb, path := "GET", "/api/latest/fleet/software/fleet_maintained_apps" + var responseBody listFleetMaintainedAppsResponse + err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query) + if err != nil { + return nil, err + } + return responseBody.FleetMaintainedApps, nil +}