Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions articles/automatic-software-install-in-fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 26 additions & 4 deletions cmd/fleetctl/fleetctl/generate_gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions cmd/fleetctl/fleetctl/generate_gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions docs/Configuration/yaml-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.

Expand Down
27 changes: 22 additions & 5 deletions pkg/spec/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 != "" {
Expand Down
69 changes: 67 additions & 2 deletions pkg/spec/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `
Expand All @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions server/mock/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
47 changes: 47 additions & 0 deletions server/service/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions server/service/client_software.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading