Files
fleet/server/service/client.go
T
Nico 5a1365dc41 40493 webhooks for host activities (#50595)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40493

Changes already reviewed in the PRs merged to this feature branch.
Only additive change was
https://github.com/fleetdm/fleet/pull/50595/commits/c0934e1fee46a734f9499a4c782563d4fcc345c4
to address CodeRabbit's comments.

# Checklist for submitter

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



https://github.com/user-attachments/assets/ea7f5157-a67a-4d83-842d-62197bd1546d



## 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`
- [x] 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)
- [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 is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Added host activity automations with configurable webhook
destinations.
* Manage automations from the Hosts page with validation, permissions,
and enable/disable controls.
  * Added GitOps support for team and unassigned-host webhook settings.
* Activity webhooks now include fleet-scoped host IDs where applicable.
  * Added profile UUIDs to MDM profile resend activity details.

* **Bug Fixes**
* Improved Windows MDM enrollment activity details by including the
linked host ID when available.
  * Preserved existing webhook settings when omitted during updates.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-07 09:49:24 -03:00

4102 lines
151 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"gopkg.in/yaml.v2"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/spec"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/fleetdm/fleet/v4/server/ptr"
kithttp "github.com/go-kit/kit/transport/http"
)
const batchSize = 100
// Client is used to consume Fleet APIs from Go code
type Client struct {
*baseClient
addr string
token string
customHeaders map[string]string
outputWriter io.Writer
errWriter io.Writer
seenLicenseExpired bool
}
type ClientOption func(*Client) error
func NewClient(addr string, insecureSkipVerify bool, rootCA, urlPrefix string, options ...ClientOption) (*Client, error) {
// TODO #265 refactor all optional parameters to functional options
// API breaking change, needs a major version release
baseClient, err := newBaseClient(addr, insecureSkipVerify, rootCA, urlPrefix, nil, fleet.CapabilityMap{}, nil)
if err != nil {
return nil, err
}
client := &Client{
baseClient: baseClient,
addr: addr,
}
for _, option := range options {
err := option(client)
if err != nil {
return nil, err
}
}
if client.errWriter == nil {
client.errWriter = os.Stderr
}
return client, nil
}
func EnableClientDebug() ClientOption {
return func(c *Client) error {
httpClient, ok := c.HTTP.(*http.Client)
if !ok {
return errors.New("client is not *http.Client")
}
httpClient.Transport = &logRoundTripper{roundtripper: httpClient.Transport}
return nil
}
}
func SetClientOutputWriter(w io.Writer) ClientOption {
return func(c *Client) error {
c.outputWriter = w
return nil
}
}
func SetClientErrorWriter(w io.Writer) ClientOption {
return func(c *Client) error {
c.errWriter = w
return nil
}
}
// WithCustomHeaders sets custom headers to be sent with every request made
// with the client.
func WithCustomHeaders(headers map[string]string) ClientOption {
return func(c *Client) error {
// clone the map to prevent any changes in the original affecting the client
m := make(map[string]string, len(headers))
for k, v := range headers {
m[k] = v
}
c.customHeaders = m
return nil
}
}
func (c *Client) doContextWithBodyAndHeaders(ctx context.Context, verb, path, rawQuery string, bodyBytes []byte, headers map[string]string) (*http.Response, error) {
request, err := http.NewRequestWithContext(
ctx,
verb,
c.URL(path, rawQuery).String(),
bytes.NewBuffer(bodyBytes),
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating request object")
}
// set the custom headers first, they should not override the actual headers
// we set explicitly.
for k, v := range c.customHeaders {
request.Header.Set(k, v)
}
for k, v := range headers {
request.Header.Set(k, v)
}
resp, err := c.HTTP.Do(request)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "do request")
}
if !c.seenLicenseExpired && resp.Header.Get(fleet.HeaderLicenseKey) == fleet.HeaderLicenseValueExpired {
fleet.WriteExpiredLicenseBanner(c.errWriter)
c.seenLicenseExpired = true
}
return resp, nil
}
func (c *Client) doContextWithHeaders(ctx context.Context, verb, path, rawQuery string, params interface{}, headers map[string]string) (*http.Response, error) {
var bodyBytes []byte
var err error
if params != nil {
switch p := params.(type) {
case *bytes.Buffer:
bodyBytes = p.Bytes()
case []byte:
bodyBytes = p
default:
bodyBytes, err = json.Marshal(params)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshaling json")
}
if rules := endpointer.ExtractAliasRules(params); len(rules) > 0 {
bodyBytes, err = endpointer.RewriteOldToNewKeys(bodyBytes, rules)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "rewriting deprecated keys")
}
}
}
}
return c.doContextWithBodyAndHeaders(ctx, verb, path, rawQuery, bodyBytes, headers)
}
func (c *Client) Do(verb, path, rawQuery string, params interface{}) (*http.Response, error) {
return c.DoContext(context.Background(), verb, path, rawQuery, params)
}
func (c *Client) DoContext(ctx context.Context, verb, path, rawQuery string, params interface{}) (*http.Response, error) {
headers := map[string]string{
"Content-type": "application/json",
"Accept": "application/json",
}
return c.doContextWithHeaders(ctx, verb, path, rawQuery, params, headers)
}
func (c *Client) AuthenticatedDo(verb, path, rawQuery string, params interface{}) (*http.Response, error) {
if c.token == "" {
return nil, errors.New("authentication token is empty")
}
headers := map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
}
return c.doContextWithHeaders(context.Background(), verb, path, rawQuery, params, headers)
}
func (c *Client) AuthenticatedDoCustomHeaders(verb, path, rawQuery string, params interface{}, customHeaders map[string]string) (*http.Response, error) {
if c.token == "" {
return nil, errors.New("authentication token is empty")
}
headers := map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
}
for key, value := range customHeaders {
headers[key] = value
}
return c.doContextWithHeaders(context.Background(), verb, path, rawQuery, params, headers)
}
func (c *Client) SetToken(t string) {
c.token = t
}
// http.RoundTripper that will log debug information about the request and
// response, including paths, timing, and body.
//
// Inspired by https://stackoverflow.com/a/39528716/491710 and
// github.com/motemen/go-loghttp
type logRoundTripper struct {
roundtripper http.RoundTripper
}
// RoundTrip implements http.RoundTripper
func (l *logRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Log request
fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
reqBody, err := req.GetBody()
if err != nil {
fmt.Fprintf(os.Stderr, "GetBody error: %v\n", err)
} else {
defer reqBody.Close()
buf := &bytes.Buffer{}
_, _ = io.Copy(buf, reqBody)
if utf8.Valid(buf.Bytes()) {
fmt.Fprintf(os.Stderr, "[body length: %d bytes]\n", buf.Len())
if _, err := io.Copy(os.Stderr, buf); err != nil {
fmt.Fprintf(os.Stderr, "Copy body error: %v\n", err)
}
} else {
fmt.Fprintf(os.Stderr, "[binary output suppressed: %d bytes]\n", buf.Len())
}
}
fmt.Fprintf(os.Stderr, "\n")
// Perform request using underlying roundtripper
start := time.Now()
res, err := l.roundtripper.RoundTrip(req)
if err != nil {
fmt.Fprintf(os.Stderr, "RoundTrip error: %v", err)
return nil, err
}
// Log response
took := time.Since(start).Truncate(time.Millisecond)
fmt.Fprintf(os.Stderr, "%s %s %s (%s)\n", res.Request.Method, res.Request.URL, res.Status, took)
resBody := &bytes.Buffer{}
if _, err := io.Copy(resBody, res.Body); err != nil {
return nil, fmt.Errorf("response buffer copy: %w", err)
}
res.Body = io.NopCloser(resBody)
// Only print text output, don't flood the terminal with binary content
if utf8.Valid(resBody.Bytes()) {
fmt.Fprintf(os.Stderr, "[body length: %d bytes]\n", resBody.Len())
fmt.Fprint(os.Stderr, resBody.String())
} else {
fmt.Fprintf(os.Stderr, "[binary output suppressed: %d bytes]\n", resBody.Len())
}
return res, nil
}
func (c *Client) authenticatedRequestWithQuery(params interface{}, verb string, path string, responseDest interface{}, query string) error {
start := time.Now()
response, err := c.AuthenticatedDo(verb, path, query, params)
if err != nil {
return fmt.Errorf("%s %s: %w (API time: %s)", verb, path, err, time.Since(start).Truncate(time.Millisecond))
}
if err := c.ParseResponse(verb, path, response, responseDest); err != nil {
// ParseResponse already adds verb and path to the err.
return fmt.Errorf("%w (API time: %s)", err, time.Since(start).Truncate(time.Millisecond))
}
return nil
}
func (c *Client) authenticatedRequest(params interface{}, verb string, path string, responseDest interface{}) error {
return c.authenticatedRequestWithQuery(params, verb, path, responseDest, "")
}
func (c *Client) CheckAnyMDMEnabled() error {
return c.runAppConfigChecks(func(ac *fleet.EnrichedAppConfig) error {
if !ac.MDM.EnabledAndConfigured && !ac.MDM.WindowsEnabledAndConfigured && !ac.MDM.AndroidEnabledAndConfigured {
return errors.New(fleet.MDMNotConfiguredMessage)
}
return nil
})
}
func (c *Client) CheckAppleMDMEnabled() error {
return c.runAppConfigChecks(func(ac *fleet.EnrichedAppConfig) error {
if !ac.MDM.EnabledAndConfigured {
return errors.New(fleet.AppleMDMNotConfiguredMessage)
}
return nil
})
}
func (c *Client) CheckPremiumMDMEnabled() error {
return c.runAppConfigChecks(func(ac *fleet.EnrichedAppConfig) error {
if ac.License == nil || !ac.License.IsPremium() {
return errors.New("missing or invalid license")
}
if !ac.MDM.EnabledAndConfigured {
return errors.New(fleet.AppleMDMNotConfiguredMessage)
}
return nil
})
}
func (c *Client) runAppConfigChecks(fn func(ac *fleet.EnrichedAppConfig) error) error {
appCfg, err := c.GetAppConfig()
if err != nil {
var sce kithttp.StatusCoder
if errors.As(err, &sce) && sce.StatusCode() == http.StatusForbidden {
// do not return an error, user may not have permission to read app
// config (e.g. gitops) and those appconfig checks are just convenience
// to avoid the round-trip with potentially large payload to the server.
// Those will still be validated with the actual API call.
return nil
}
return err
}
return fn(appCfg)
}
// getProfilesContents takes file paths and creates a slice of profile payloads
// ready to batch-apply.
func getProfilesContents(baseDir string, macProfiles, windowsProfiles, androidProfiles []fleet.MDMProfileSpec, expandEnv bool) ([]fleet.MDMProfileBatchPayload, error) {
// map to check for duplicate names across all profiles
extByName := make(map[string]string, len(macProfiles))
result := make([]fleet.MDMProfileBatchPayload, 0, len(macProfiles))
// iterate over the profiles for each platform
for platform, profiles := range map[string][]fleet.MDMProfileSpec{
"macos": macProfiles,
"windows": windowsProfiles,
"android": androidProfiles,
} {
for _, profile := range profiles {
filePath := resolveApplyRelativePath(baseDir, profile.Path)
fileContents, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("applying custom settings: %w", err)
}
ext := filepath.Ext(filePath)
// by default, use the file name (for macOS mobileconfig profiles, we'll switch to
// their PayloadDisplayName when we parse the profile below)
name := strings.TrimSuffix(filepath.Base(filePath), ext)
// for validation errors, we want to include the platform and file name in the error message
prefixErrMsg := fmt.Sprintf("Couldn't edit %s_settings.custom_settings (%s%s)", platform, name, ext)
if platform == "macos" && (ext == ".mobileconfig" || ext == ".xml") {
// Parse and check for signed mobile configs before expanding.
mc := mobileconfig.Mobileconfig(fileContents)
if mc.IsSignedProfile() {
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Configuration profiles can't be signed. Fleet will sign the profile for you. Learn more: https://fleetdm.com/learn-more-about/unsigning-configuration-profiles")
}
}
if expandEnv {
// Secrets are handled earlier in the flow when config files are initially read
fileContents, err = spec.ExpandEnvBytesIgnoreSecrets(fileContents)
if err != nil {
return nil, fmt.Errorf("expanding environment on file %q: %w", profile.Path, err)
}
}
// validate macOS profiles
if platform == "macos" {
switch ext {
case ".mobileconfig", ".xml": // allowing .xml for backwards compatibility
// For validation, we need to expand FLEET_SECRET_ variables in <data> tags so the XML parser
// can properly validate the profile structure. However, we must be careful not to expose
// secrets in the profile name.
containsSecrets := len(fleet.ContainsPrefixVars(string(fileContents), fleet.ServerSecretPrefix)) > 0
if containsSecrets {
// If profile contains secrets, check for secrets in PayloadDisplayName first
if err := fleet.ValidateNoSecretsInProfileName(fileContents); err != nil {
return nil, fmt.Errorf("%s: %w", prefixErrMsg, err)
}
// Expand secrets for validation
validationContents, expandErr := spec.ExpandEnvBytesIncludingSecrets(fileContents)
if expandErr != nil {
return nil, fmt.Errorf("%s: expanding secrets for validation: %w", prefixErrMsg, expandErr)
}
// Validate the profile structure with expanded secrets
mcExpanded, validationErr := fleet.NewMDMAppleConfigProfile(validationContents, nil)
if validationErr != nil {
errForMsg := errors.Unwrap(validationErr)
if errForMsg == nil {
errForMsg = validationErr
}
return nil, fmt.Errorf("%s: %w", prefixErrMsg, errForMsg)
}
name = strings.TrimSpace(mcExpanded.Name)
} else {
// No secrets, parse normally
mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil {
errForMsg := errors.Unwrap(err)
if errForMsg == nil {
errForMsg = err
}
return nil, fmt.Errorf("%s: %w", prefixErrMsg, errForMsg)
}
name = strings.TrimSpace(mc.Name)
}
case ".json":
if mdm.GetRawProfilePlatform(fileContents) != "darwin" {
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Declaration profiles should include valid JSON.")
}
default:
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "macOS configuration profiles must be .mobileconfig or .json files.")
}
}
if platform == "android" {
switch ext {
case ".json":
if mdm.GetRawProfilePlatform(fileContents) != "android" {
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Android profiles should include valid JSON.")
}
default:
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Android configuration profiles must be .json files.")
}
}
// validate windows profiles
if platform == "windows" {
switch ext {
case ".xml":
if mdm.GetRawProfilePlatform(fileContents) != "windows" {
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements")
}
default:
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Windows configuration profiles must be .xml files.")
}
}
// check for duplicate names across all profiles
if _, isDuplicate := extByName[name]; isDuplicate {
return nil, errors.New(fmtDuplicateNameErrMsg(name))
}
extByName[name] = ext
var activationContents []byte
if profile.Activation != "" {
if platform != "macos" || ext != ".json" {
return nil, fmt.Errorf("%s: %s", prefixErrMsg,
"activation is only supported for declaration (DDM) profiles.")
}
activationContents, err = os.ReadFile(resolveApplyRelativePath(baseDir, profile.Activation))
if err != nil {
return nil, fmt.Errorf("%s: reading activation: %w", prefixErrMsg, err)
}
}
result = append(result, fleet.MDMProfileBatchPayload{
Name: name,
Contents: fileContents,
Labels: profile.Labels,
LabelsIncludeAll: profile.LabelsIncludeAll,
LabelsIncludeAny: profile.LabelsIncludeAny,
LabelsExcludeAny: profile.LabelsExcludeAny,
Activation: activationContents,
})
}
}
return result, nil
}
// getAssetsContents reads and env-expands the Apple DDM asset files referenced
// by the given specs, returning them as batch payloads. As with profiles,
// FLEET_SECRET_ variables are left for the server to expand.
func getAssetsContents(baseDir string, assets []fleet.MDMProfileSpec, expandEnv bool) ([]fleet.MDMAppleDDMAssetBatchPayload, error) {
result := make([]fleet.MDMAppleDDMAssetBatchPayload, 0, len(assets))
seenNames := make(map[string]struct{}, len(assets))
for _, asset := range assets {
filePath := resolveApplyRelativePath(baseDir, asset.Path)
fileContents, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("applying assets: %w", err)
}
if expandEnv {
fileContents, err = spec.ExpandEnvBytesIgnoreSecrets(fileContents)
if err != nil {
return nil, fmt.Errorf("expanding environment on file %q: %w", asset.Path, err)
}
}
name := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if _, isDuplicate := seenNames[name]; isDuplicate {
return nil, errors.New(fmtDuplicateNameErrMsg(name))
}
seenNames[name] = struct{}{}
result = append(result, fleet.MDMAppleDDMAssetBatchPayload{Name: name, Contents: fileContents})
}
return result, nil
}
// fileContent is used to store the name of a file and its content.
type fileContent struct {
Filename string
Content []byte
}
type errConflictingSoftwareSetupExperienceDeclarations struct {
URL string
}
func (e errConflictingSoftwareSetupExperienceDeclarations) Error() string {
return fmt.Sprintf("Couldn't edit software (%s). Setup experience may only be specified directly on software or within macos_setup, but not both. See https://fleetdm.com/learn-more-about/yaml-software-setup-experience.", e.URL)
}
var errConflictingVPPSetupExperienceDeclarations = errors.New("Couldn't edit app store apps. Setup experience may only be specified directly on software or within macos_setup, but not both. See https://fleetdm.com/learn-more-about/yaml-software-setup-experience.")
// TODO: as confirmed by Noah and Marko on Slack:
//
// > from Noah: "We want to support existing features w/ fleetctl apply for
// > backwards compatibility GitOps but we dont need to add new features."
//
// We should deprecate ApplyGroup and use it only for `fleetctl apply` (and
// its current minimal use in `preview`), and have a distinct implementation
// that is `gitops`-only, because both uses have subtle differences in
// behaviour that make it hard to reuse a single implementation (e.g. a missing
// key in gitops means "remove what is absent" while in apply it means "leave
// as-is").
//
// For now I'm just passing a "gitops" bool for a quick fix, but we should
// properly plan that separation and refactor so that gitops can be
// significantly cleaned up and simplified going forward.
func numberWithPluralization(n int, singular string, plural string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, singular)
}
return fmt.Sprintf("%d %s", n, plural)
}
const (
dryRunAppliedFormat = "[+] would've applied %s\n"
appliedFormat = "[+] applied %s\n"
applyingTeamFormat = "[+] applying %s for fleet %s\n"
appliedTeamFormat = "[+] applied %s for fleet %s\n"
dryRunAppliedTeamFormat = "[+] would've applied %s for fleet %s\n"
)
// ApplyGroup applies the given spec group to Fleet.
func (c *Client) ApplyGroup(
ctx context.Context,
viaGitOps bool, // TODO: should we fail if this gets called for CAs outside of gitops?
specs *spec.Group,
baseDir string,
logf func(format string, args ...interface{}),
appconfig *fleet.EnrichedAppConfig,
opts fleet.ApplyClientSpecOptions,
teamsSoftwareInstallers map[string][]fleet.SoftwarePackageResponse,
teamsVPPApps map[string][]fleet.VPPAppResponse,
teamsScripts map[string][]fleet.ScriptResponse,
filename *string,
afterTeamApply func(teamIDsByName map[string]uint) error,
) (map[string]uint, map[string][]fleet.SoftwarePackageResponse, map[string][]fleet.VPPAppResponse, map[string][]fleet.ScriptResponse, error) {
logfn := func(format string, args ...interface{}) {
if logf != nil {
logf(format, args...)
}
}
// specs.Queries must be applied before specs.Packs because packs reference queries.
if len(specs.Queries) > 0 {
if opts.DryRun {
logfn("[!] ignoring reports, dry run mode only supported for 'config' and 'fleet' specs\n")
} else {
if err := c.ApplyQueries(specs.Queries); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying reports: %w", err)
}
logfn(appliedFormat, numberWithPluralization(len(specs.Queries), "report", "reports"))
}
}
if len(specs.Labels) > 0 {
if opts.DryRun {
logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'fleet' specs\n")
} else {
for _, label := range specs.Labels {
if label.LabelType == fleet.LabelTypeBuiltIn {
return nil, nil, nil, nil, errors.New("Cannot import built-in labels. Please remove labels with a label_type of builtin and try again.")
}
}
if err := c.ApplyLabels(specs.Labels, nil, nil); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying labels: %w", err)
}
logfn(appliedFormat, numberWithPluralization(len(specs.Labels), "label", "labels"))
}
}
if len(specs.Packs) > 0 {
if opts.DryRun {
logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'fleet' specs\n")
} else {
if err := c.ApplyPacks(specs.Packs); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying packs: %w", err)
}
logfn(appliedFormat, numberWithPluralization(len(specs.Packs), "pack", "packs"))
}
}
if specs.CertificateAuthorities != nil {
// In GitOps, skip deletes here. CA deletions are deferred to a post-op so that team configs
// can clean up certificate templates (which have FK references to CAs) first.
if err := c.ApplyCertificateAuthoritiesSpec(*specs.CertificateAuthorities, opts.ApplySpecOptions, fleet.BatchApplyCertificateAuthoritiesOpts{SkipDeletes: viaGitOps}); err != nil {
// only do this custom message for gitops as we reference the applying filename which only makes sense in gitops
if err.Error() == "missing or invalid license" && viaGitOps && filename != nil {
return nil, nil, nil, nil, fmt.Errorf("Couldn't edit \"%s\" at \"certificate_authorities\": Missing or invalid license. Certificate authorities are available in Fleet Premium only.", *filename)
}
return nil, nil, nil, nil, fmt.Errorf("applying certificate authorities: %w", err)
}
// TODO(hca): is more detailed logging a hard requirement or can it be a follow up improvement?
if opts.DryRun {
logfn("[+] would've applied certificate authorities\n")
} else {
logfn("[+] applied certificate authorities\n")
}
}
if specs.AppConfig != nil {
windowsCustomSettings := extractAppCfgWindowsCustomSettings(specs.AppConfig)
macosCustomSettings := extractAppCfgMacOSCustomSettings(specs.AppConfig)
androidCustomSettings := extractAppCfgAndroidCustomSettings(specs.AppConfig)
if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil {
switch {
case macosSetup.BootstrapPackage.Value != "":
pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("verifying bootstrap package: %w", err)
}
if err := c.UploadBootstrapPackageIfNeeded(pkg, uint(0), opts.DryRun); err != nil {
return nil, nil, nil, nil, fmt.Errorf("uploading bootstrap package: %w", err)
}
case macosSetup.BootstrapPackage.Valid && appconfig != nil && appconfig.MDM.EnabledAndConfigured && appconfig.License.IsPremium():
// bootstrap package is explicitly empty (only for GitOps)
if err := c.DeleteBootstrapPackageIfNeeded(uint(0), opts.DryRun); err != nil {
return nil, nil, nil, nil, err
}
}
switch {
case macosSetup.MacOSSetupAssistant.Value != "":
content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value))
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("validating apple setup assistant: %w", err)
}
if !opts.DryRun {
if err := c.uploadMacOSSetupAssistant(content, nil, filepath.Base(macosSetup.MacOSSetupAssistant.Value)); err != nil {
return nil, nil, nil, nil, fmt.Errorf("uploading apple setup assistant: %w", err)
}
}
case macosSetup.MacOSSetupAssistant.Valid && !opts.DryRun &&
appconfig != nil && appconfig.MDM.EnabledAndConfigured && appconfig.License.IsPremium():
// setup assistant is explicitly empty (only for GitOps)
if err := c.deleteMacOSSetupAssistant(nil); err != nil {
return nil, nil, nil, nil, fmt.Errorf("deleting macOS enrollment profile: %w", err)
}
}
}
if scripts := extractAppCfgScripts(specs.AppConfig); scripts != nil {
scriptPayloads := make([]fleet.ScriptPayload, len(scripts))
for i, f := range scripts {
b, err := os.ReadFile(f)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying scripts for unassigned hosts: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
Name: filepath.Base(f),
}
}
noTeamScripts, err := c.ApplyNoTeamScripts(scriptPayloads, opts.ApplySpecOptions)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying scripts for unassigned hosts: %w", err)
}
teamsScripts["No team"] = noTeamScripts
if opts.DryRun {
logfn(dryRunAppliedFormat, numberWithPluralization(len(scriptPayloads), "script", "scripts"))
} else {
logfn(appliedFormat, numberWithPluralization(len(noTeamScripts), "script", "scripts"))
}
}
rules, err := extractAppCfgYaraRules(specs.AppConfig)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying yara rules: %w", err)
}
if rules != nil {
rulePayloads := make([]fleet.YaraRule, len(rules))
for i, f := range rules {
path := resolveApplyRelativePath(baseDir, f.Path)
b, err := os.ReadFile(path)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying yara rules: %w", err)
}
rulePayloads[i] = fleet.YaraRule{
Contents: string(b),
Name: filepath.Base(f.Path),
}
}
specs.AppConfig.(map[string]interface{})["yara_rules"] = rulePayloads
}
// GitOps `exceptions` remain UI-only for now; strip them defense-in-depth
// in case the parse-time reject in pkg/spec is bypassed. The other
// `gitops` sub-keys (`gitops_mode_enabled`, `repository_url`) are
// settable via YAML and flow through to ModifyAppConfig.
if gitopsBlock, ok := specs.AppConfig.(map[string]any)["gitops"].(map[string]any); ok {
delete(gitopsBlock, "exceptions")
}
if err := c.ApplyAppConfig(specs.AppConfig, opts.ApplySpecOptions); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleet config: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied fleet config\n")
} else {
logfn("[+] applied fleet config\n")
}
// We apply profiles after the main AppConfig org_settings because profiles may
// contain Fleet variables that are set in org_settings, such as $FLEET_VAR_DIGICERT_PASSWORD_My_CA
//
// if there is no custom setting but the windows and mac settings are
// non-nil, this means that we want to clear the existing custom settings,
// so we still go on with calling the batch-apply endpoint.
//
// TODO(mna): shouldn't that be an || instead of && ? I.e. if there are no
// custom settings but windows is present and empty (but mac is absent),
// shouldn't that clear the windows ones?
// Apply DDM assets before profiles/declarations so a declaration in the
// same GitOps run can reference an asset uploaded alongside it. Assets
// require premium and turned-on MDM (the batch endpoint rejects the call
// otherwise), so skip it when either is missing. A non-nil spec means
// macos_settings is managed, so reconcile even when the asset set is empty
// (that clears any existing assets).
if macosAssets := extractAppCfgMacOSAssets(specs.AppConfig); macosAssets != nil &&
appconfig != nil && appconfig.License.IsPremium() && appconfig.MDM.EnabledAndConfigured {
assetContents, err := getAssetsContents(baseDir, macosAssets, opts.ExpandEnvConfigProfiles)
if err != nil {
return nil, nil, nil, nil, err
}
if err := c.applyDDMAssets("", assetContents, opts.ApplySpecOptions); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying assets: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied DDM assets\n")
} else {
logfn("[+] applied DDM assets\n")
}
}
if (windowsCustomSettings != nil || macosCustomSettings != nil || androidCustomSettings != nil) || len(windowsCustomSettings)+len(macosCustomSettings)+len(androidCustomSettings) > 0 {
fileContents, err := getProfilesContents(baseDir, macosCustomSettings, windowsCustomSettings, androidCustomSettings, opts.ExpandEnvConfigProfiles)
if err != nil {
return nil, nil, nil, nil, err
}
// Figure out if MDM should be enabled.
assumeEnabled := false
// This cast is safe because we've already checked AppConfig when extracting custom settings
mdmConfigMap, ok := specs.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
if ok {
mdmEnabled, ok := mdmConfigMap["windows_enabled_and_configured"]
if ok {
assumeEnabled, ok = mdmEnabled.(bool)
assumeEnabled = ok && assumeEnabled
}
}
profilesSpecOptions := opts.ApplySpecOptions
// Since we just updated AppConfig, we don't want to get a stale (cached) AppConfig on the server if
// this HTTP request gets routed to another Fleet server.
profilesSpecOptions.NoCache = true
if err := c.ApplyNoTeamProfiles(fileContents, profilesSpecOptions, assumeEnabled); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying custom settings: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied MDM profiles\n")
} else {
logfn("[+] applied MDM profiles\n")
}
}
}
if specs.EnrollSecret != nil {
if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret, opts.ApplySpecOptions); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying enroll secrets: %w", err)
}
if opts.DryRun {
logfn("[+] would've applied enroll secrets\n")
} else {
logfn("[+] applied enroll secrets\n")
}
}
var teamIDsByName map[string]uint
if len(specs.Teams) > 0 {
// extract the teams' custom settings and resolve the files immediately, so
// that any non-existing file error is found before applying the specs.
tmMDMSettings := extractTmSpecsMDMCustomSettings(specs.Teams)
tmFileContents := make(map[string][]fleet.MDMProfileBatchPayload, len(tmMDMSettings))
for k, profileSpecs := range tmMDMSettings {
fileContents, err := getProfilesContents(baseDir, profileSpecs.macos, profileSpecs.windows, profileSpecs.android, opts.ExpandEnvConfigProfiles)
if err != nil {
// TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once
return nil, nil, nil, nil, fmt.Errorf("Team %s: %w", k, err)
}
tmFileContents[k] = fileContents
}
// Resolve DDM asset files up front so missing-file errors surface early.
tmAssetSpecs := extractTmSpecsMDMAssets(specs.Teams)
tmAssetContents := make(map[string][]fleet.MDMAppleDDMAssetBatchPayload, len(tmAssetSpecs))
for k, assetSpecs := range tmAssetSpecs {
assetContents, err := getAssetsContents(baseDir, assetSpecs, opts.ExpandEnvConfigProfiles)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("Team %s: %w", k, err)
}
tmAssetContents[k] = assetContents
}
tmMacSetup := extractTmSpecsMacOSSetup(specs.Teams)
tmBootstrapPackages := make(map[string]*fleet.MDMAppleBootstrapPackage, len(tmMacSetup))
tmMacSetupAssistants := make(map[string][]byte, len(tmMacSetup))
// those are gitops-only features
tmMacSetupScript := make(map[string]fileContent, len(tmMacSetup))
tmMacSetupSoftware := make(map[string][]*fleet.MacOSSetupSoftware, len(tmMacSetup))
// this is a set of software packages or VPP apps that are configured as
// install_during_setup, by team. This is a gitops-only setting, so it will
// only be filled when called via this command.
tmSoftwareMacOSSetup := make(map[string]map[fleet.MacOSSetupSoftware]struct{}, len(tmMacSetup))
for k, setup := range tmMacSetup {
switch {
case setup.BootstrapPackage.Value != "":
bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleets: %w", err)
}
tmBootstrapPackages[k] = bp
case setup.BootstrapPackage.Valid: // explicitly empty
tmBootstrapPackages[k] = nil
}
switch {
case setup.MacOSSetupAssistant.Value != "":
b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value))
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleets: %w", err)
}
tmMacSetupAssistants[k] = b
case setup.MacOSSetupAssistant.Valid: // explicitly empty
tmMacSetupAssistants[k] = nil
}
if setup.Script.Value != "" {
if setup.ManualAgentInstall.Value {
return nil, nil, nil, nil, fmt.Errorf(
"Couldn\u2019t add setup experience script. To add script, first disable macos_manual_agent_install (fleet %q).", k,
)
}
b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, setup.Script.Value))
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleets: %w", err)
}
tmMacSetupScript[k] = fileContent{Filename: filepath.Base(setup.Script.Value), Content: b}
}
if viaGitOps {
m, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, setup.Software.Value)
if err != nil {
return nil, nil, nil, nil, err
}
tmSoftwareMacOSSetup[k] = m
tmMacSetupSoftware[k] = setup.Software.Value
}
}
tmScripts := extractTmSpecsScripts(specs.Teams)
tmScriptsPayloads := make(map[string][]fleet.ScriptPayload, len(tmScripts))
for k, paths := range tmScripts {
scriptPayloads := make([]fleet.ScriptPayload, len(paths))
for i, f := range paths {
b, err := os.ReadFile(f)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("reading script file: %w", err)
}
scriptPayloads[i] = fleet.ScriptPayload{
ScriptContents: b,
Name: filepath.Base(f),
}
}
tmScriptsPayloads[k] = scriptPayloads
}
tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams)
tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmSoftwarePackages))
tmSoftwarePackagesWithPaths := make(map[string]map[string]struct{}, len(tmSoftwarePackages))
for tmName, software := range tmSoftwarePackages {
installDuringSetupKeys := tmSoftwareMacOSSetup[tmName]
softwarePayloads, err := buildSoftwarePackagesPayload(software, installDuringSetupKeys)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying software installers for fleet %q: %w", tmName, err)
}
tmSoftwarePackagesPayloads[tmName] = softwarePayloads
for _, swSpec := range software {
if swSpec.ReferencedYamlPath != "" {
// can be referenced by setup_experience.software.package_path
if tmSoftwarePackagesWithPaths[tmName] == nil {
tmSoftwarePackagesWithPaths[tmName] = make(map[string]struct{}, len(software))
}
tmSoftwarePackagesWithPaths[tmName][swSpec.ReferencedYamlPath] = struct{}{}
}
}
}
tmFleetMaintainedApps := extractTmSpecsFleetMaintainedApps(specs.Teams)
for tmName, apps := range tmFleetMaintainedApps {
installDuringSetupKeys := tmSoftwareMacOSSetup[tmName]
softwarePayloads, err := buildSoftwarePackagesPayload(apps, installDuringSetupKeys)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying software installers for fleet %q: %w", tmName, err)
}
if existingPayloads, ok := tmSoftwarePackagesPayloads[tmName]; ok {
tmSoftwarePackagesPayloads[tmName] = append(existingPayloads, softwarePayloads...)
} else {
tmSoftwarePackagesPayloads[tmName] = softwarePayloads
}
}
tmSoftwareApps := extractTmSpecsSoftwareApps(specs.Teams)
tmSoftwareAppsPayloads := make(map[string][]fleet.VPPBatchPayload)
tmSoftwareAppsByAppID := make(map[string]map[string]fleet.TeamSpecAppStoreApp, len(tmSoftwareApps))
for tmName, apps := range tmSoftwareApps {
installDuringSetupKeys := tmSoftwareMacOSSetup[tmName]
appPayloads := make([]fleet.VPPBatchPayload, 0, len(apps))
for _, app := range apps {
var installDuringSetup *bool
if installDuringSetupKeys != nil {
_, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{AppStoreID: app.AppStoreID}]
installDuringSetup = &ok
}
if app.InstallDuringSetup.Valid {
if len(installDuringSetupKeys) > 0 {
return nil, nil, nil, nil, errConflictingVPPSetupExperienceDeclarations
}
installDuringSetup = &app.InstallDuringSetup.Value
}
iconHash, err := getIconHashIfValid(app.Icon.Path)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Invalid custom icon file %s: %w", app.AppStoreID, app.Icon.Path, err)
}
appConfig, err := getAppStoreAppConfig(app.Platform, app.Configuration.Path)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Reading configuration %s: %w", app.AppStoreID, app.Configuration.Path, err)
}
payload := fleet.VPPBatchPayload{
AppStoreID: app.AppStoreID,
SelfService: app.SelfService,
InstallDuringSetup: installDuringSetup,
LabelsExcludeAny: app.LabelsExcludeAny,
LabelsIncludeAny: app.LabelsIncludeAny,
LabelsIncludeAll: app.LabelsIncludeAll,
Categories: app.Categories,
DisplayName: app.DisplayName,
IconPath: app.Icon.Path,
IconHash: iconHash,
Platform: fleet.InstallableDevicePlatform(app.Platform),
AutoUpdateEnabled: app.AutoUpdateEnabled,
AutoUpdateStartTime: app.AutoUpdateStartTime,
AutoUpdateEndTime: app.AutoUpdateEndTime,
}
if appConfig != nil {
payload.Configuration = appConfig
}
appPayloads = append(appPayloads, payload)
// can be referenced by setup_experience.software.app_store_id
if tmSoftwareAppsByAppID[tmName] == nil {
tmSoftwareAppsByAppID[tmName] = make(map[string]fleet.TeamSpecAppStoreApp, len(apps))
}
tmSoftwareAppsByAppID[tmName][app.AppStoreID] = app
}
tmSoftwareAppsPayloads[tmName] = appPayloads
}
// if setup_experience.software has some values, they must exist in the software
// packages or vpp apps. When software is excepted from GitOps, the validation
// maps (tmSoftwarePackagesWithPaths/tmSoftwareAppsByAppID) are empty because
// the team spec doesn't include software. Additionally, setup_experience
// references packages by file path which server-side data doesn't have.
// The server will validate when the setup experience is applied.
softwareExcepted := viaGitOps && appconfig != nil && appconfig.GitOpsConfig.Exceptions.Software
for tmName, setupSw := range tmMacSetupSoftware {
if softwareExcepted {
continue
}
if err := validateTeamOrNoTeamMacOSSetupSoftware(tmName, setupSw, tmSoftwarePackagesWithPaths[tmName], tmSoftwareAppsByAppID[tmName]); err != nil {
return nil, nil, nil, nil, err
}
}
// Next, apply the teams specs before saving the profiles, so that any
// non-existing team gets created.
var err error
teamOpts := fleet.ApplyTeamSpecOptions{
ApplySpecOptions: opts.ApplySpecOptions,
DryRunAssumptions: specs.TeamsDryRunAssumptions,
}
// In dry-run, the team names returned are the old team names (when team name is modified via gitops)
teamIDsByName, err = c.ApplyTeams(specs.Teams, teamOpts)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleets: %w", err)
}
// Run any caller-supplied hook between fleet creation and fleet-resource
// apply. The GitOps fleet flow uses this to apply fleet-scoped labels
// before possible consumers (profiles, policies etc.) validate label
// references against the database.
if afterTeamApply != nil {
if err := afterTeamApply(teamIDsByName); err != nil {
return nil, nil, nil, nil, fmt.Errorf("after-team-create hook: %w", err)
}
}
// When using GitOps, the team name could change, so we need to check for that
getTeamName := func(teamName string) string {
return teamName
}
if len(specs.Teams) == 1 && len(teamIDsByName) == 1 {
for key := range teamIDsByName {
if key != extractTeamName(specs.Teams[0]) {
getTeamName = func(teamName string) string {
return key
}
}
}
}
// Apply DDM assets before profiles/declarations so a declaration in the
// same GitOps run can reference an asset uploaded alongside it. Assets
// require premium and turned-on MDM (the batch endpoint rejects the call
// otherwise), so skip it when either is missing.
if len(tmAssetContents) > 0 && appconfig != nil && appconfig.License.IsPremium() && appconfig.MDM.EnabledAndConfigured {
for tmName, assets := range tmAssetContents {
currentTeamName := getTeamName(tmName)
teamID, ok := teamIDsByName[currentTeamName]
if opts.DryRun && (teamID == 0 || !ok) {
logfn("[+] would've applied DDM assets for new fleet %s\n", tmName)
continue
}
if opts.DryRun {
logfn("[+] would've applied DDM assets for fleet %s\n", tmName)
} else {
logfn("[+] applying DDM assets for fleet %s\n", tmName)
}
if err := c.applyDDMAssets(currentTeamName, assets, teamOpts.ApplySpecOptions); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying assets for fleet %q: %w", tmName, err)
}
}
}
if len(tmFileContents) > 0 {
// A prior step in this GitOps run may have updated AppConfig (e.g. enabled Windows MDM), so bypass the cached AppConfig on the
// server. This lets profile validation read the freshly persisted state.
teamProfilesOpts := teamOpts
teamProfilesOpts.NoCache = true
for tmName, profs := range tmFileContents {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
teamID, ok := teamIDsByName[currentTeamName]
if opts.DryRun && (teamID == 0 || !ok) {
logfn("[+] would've applied MDM profiles for new fleet %s\n", tmName)
} else {
if opts.DryRun {
logfn("[+] would've applied MDM profiles for new fleet %s\n", tmName)
} else {
logfn("[+] applying MDM profiles for fleet %s\n", tmName)
}
if err := c.ApplyTeamProfiles(currentTeamName, profs, teamProfilesOpts); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying custom settings for fleet %q: %w", tmName, err)
}
}
}
}
if len(tmBootstrapPackages)+len(tmMacSetupAssistants) > 0 && !opts.DryRun {
for tmName, tmID := range teamIDsByName {
if bp, ok := tmBootstrapPackages[tmName]; ok {
switch {
case bp != nil:
if err := c.UploadBootstrapPackageIfNeeded(bp, tmID, opts.DryRun); err != nil {
return nil, nil, nil, nil, fmt.Errorf("uploading bootstrap package for fleet %q: %w", tmName, err)
}
case appconfig != nil && appconfig.MDM.EnabledAndConfigured && appconfig.License.IsPremium(): // explicitly empty (only for GitOps)
if err := c.DeleteBootstrapPackageIfNeeded(tmID, opts.DryRun); err != nil {
return nil, nil, nil, nil, fmt.Errorf("deleting bootstrap package for fleet %q: %w", tmName, err)
}
}
}
if b, ok := tmMacSetupAssistants[tmName]; ok {
switch {
case b != nil:
if err := c.uploadMacOSSetupAssistant(b, &tmID, filepath.Base(tmMacSetup[tmName].MacOSSetupAssistant.Value)); err != nil {
if strings.Contains(err.Error(), "Couldn't add") {
// Then the error should look something like this:
// "Couldn't add. CONFIG_NAME_INVALID"
// We want the part after the period (this is the error name from Apple)
// to render a more helpful error message.
parts := strings.Split(err.Error(), ".")
if len(parts) < 2 {
return nil, nil, nil, nil, fmt.Errorf("unexpected error while uploading macOS setup assistant for fleet %q: %w",
tmName, err)
}
return nil, nil, nil, nil, fmt.Errorf("Couldn't edit apple_setup_assistant. Response from Apple: %s. Learn more at %s",
strings.Trim(parts[1], " "), "https://fleetdm.com/learn-more-about/dep-profile")
}
return nil, nil, nil, nil, fmt.Errorf("uploading macOS setup assistant for fleet %q: %w", tmName, err)
}
case appconfig != nil && appconfig.MDM.EnabledAndConfigured && appconfig.License.IsPremium(): // explicitly empty (only for GitOps)
if err := c.deleteMacOSSetupAssistant(&tmID); err != nil {
return nil, nil, nil, nil, fmt.Errorf("deleting macOS enrollment profile for fleet %q: %w", tmName, err)
}
}
}
}
}
if viaGitOps && !opts.DryRun {
for tmName, tmID := range teamIDsByName {
if fc, ok := tmMacSetupScript[tmName]; ok {
if err := c.uploadMacOSSetupScript(fc.Filename, fc.Content, &tmID); err != nil {
return nil, nil, nil, nil, fmt.Errorf("uploading setup experience script for fleet %q: %w", tmName, err)
}
} else {
if err := c.deleteMacOSSetupScript(&tmID); err != nil {
return nil, nil, nil, nil, fmt.Errorf("deleting setup experience script for fleet %q: %w", tmName, err)
}
}
}
}
format := applyingTeamFormat
if opts.DryRun {
format = dryRunAppliedTeamFormat
}
if len(tmScriptsPayloads) > 0 {
for tmName, scripts := range tmScriptsPayloads {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
scriptResponses, err := c.ApplyTeamScripts(currentTeamName, scripts, opts.ApplySpecOptions)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying scripts for fleet %q: %w", tmName, err)
}
teamsScripts[tmName] = scriptResponses
if opts.DryRun {
// We split here on dry-run to capture the number we want to apply
logfn(dryRunAppliedTeamFormat, numberWithPluralization(len(scripts), "script", "scripts"), tmName)
} else {
// vs. the number that actually got applied and returned by the server
logfn(appliedTeamFormat, numberWithPluralization(len(scriptResponses), "script", "scripts"), tmName)
}
}
}
// Categories referenced across both the installer and app store batches, unused ones should be deleted.
categoriesByTeam := map[string][]string{}
if len(tmSoftwarePackagesPayloads) > 0 {
for tmName, software := range tmSoftwarePackagesPayloads {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
softwareCount := numberWithPluralization(len(software), "software package", "software packages")
if !opts.DryRun {
logfn(applyingTeamFormat, softwareCount, tmName)
}
installers, deletedInstallers, categories, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions, logfn)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying software installers for fleet %q: %w", tmName, err)
}
if opts.DryRun {
logfn(dryRunAppliedTeamFormat, softwareCount, tmName)
} else {
logfn(appliedTeamFormat, softwareCount, tmName)
}
logSoftwareDeletions(logfn, deletedInstallers, opts.DryRun)
teamsSoftwareInstallers[tmName] = installers
categoriesByTeam[currentTeamName] = append(categoriesByTeam[currentTeamName], categories...)
}
}
if len(tmSoftwareAppsPayloads) > 0 {
for tmName, apps := range tmSoftwareAppsPayloads {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
logfn(format, numberWithPluralization(len(apps), "app store app", "app store apps"), tmName)
appsResponse, categories, err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying app store apps for fleet: %q: %w", tmName, err)
}
teamsVPPApps[tmName] = appsResponse
categoriesByTeam[currentTeamName] = append(categoriesByTeam[currentTeamName], categories...)
}
}
// Delete categories no longer referenced by any of the fleet's software.
if viaGitOps && !opts.DryRun && !softwareExcepted {
for tmName, tmID := range teamIDsByName {
if err := c.deleteUnusedSelfServiceCategories(tmID, categoriesByTeam[tmName]); err != nil {
return nil, nil, nil, nil, fmt.Errorf("deleting unused self-service categories for fleet %q: %w", tmName, err)
}
}
}
if opts.DryRun {
logfn(dryRunAppliedFormat, numberWithPluralization(len(specs.Teams), "fleet", "fleets"))
} else {
logfn(appliedFormat, numberWithPluralization(len(specs.Teams), "fleet", "fleets"))
}
}
// Policies can reference software installers thus they are applied at this point.
if len(specs.Policies) > 0 {
// Policy names must be unique, return error if duplicate policy names are found
if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" {
return nil, nil, nil, nil, fmt.Errorf(
"applying policies: policy names must be unique. Please correct policy %q and try again.", policyName,
)
}
if opts.DryRun {
logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'fleet' specs\n")
} else {
// If set, override the team in all the policies.
if opts.TeamForPolicies != "" {
for _, policySpec := range specs.Policies {
policySpec.Team = opts.TeamForPolicies
}
}
if err := c.ApplyPolicies(specs.Policies); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying policies: %w", err)
}
logfn(appliedFormat, numberWithPluralization(len(specs.Policies), "policy", "policies"))
}
}
if specs.UsersRoles != nil {
if opts.DryRun {
logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'fleet' specs\n")
} else {
if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying user roles: %w", err)
}
logfn("[+] applied user roles\n")
}
}
return teamIDsByName, teamsSoftwareInstallers, teamsVPPApps, teamsScripts, nil
}
func extractTeamOrNoTeamMacOSSetupSoftware(baseDir string, software []*fleet.MacOSSetupSoftware) (map[fleet.MacOSSetupSoftware]struct{}, error) {
m := make(map[fleet.MacOSSetupSoftware]struct{}, len(software))
for _, sw := range software {
if sw.AppStoreID != "" && sw.PackagePath != "" {
return nil, errors.New("applying fleets: only one of app_store_id or package_path can be set")
}
if sw.PackagePath != "" {
sw.PackagePath = resolveApplyRelativePath(baseDir, sw.PackagePath)
}
m[*sw] = struct{}{}
}
return m, nil
}
func validateTeamOrNoTeamMacOSSetupSoftware(teamName string, macOSSetupSoftware []*fleet.MacOSSetupSoftware, pathsWithPackages map[string]struct{}, vppAppsByAppID map[string]fleet.TeamSpecAppStoreApp) error {
// if setup_experience.software has some values, they must exist in the software
// packages or vpp apps.
for _, ssw := range macOSSetupSoftware {
var valid bool
if ssw.AppStoreID != "" {
// check that it exists in the team's Apps
_, valid = vppAppsByAppID[ssw.AppStoreID]
} else if ssw.PackagePath != "" {
// check that it exists in the team's Software installers (PackagePath is
// already resolved to abs dir)
_, valid = pathsWithPackages[ssw.PackagePath]
}
if !valid {
label := ssw.AppStoreID
if label == "" {
label = ssw.PackagePath
}
return fmt.Errorf("applying macOS setup experience software for fleet %q: software %q does not exist for that fleet", teamName, label)
}
}
return nil
}
func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuringSetupKeys map[fleet.MacOSSetupSoftware]struct{}) ([]fleet.SoftwareInstallerPayload, error) {
softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs))
for i, si := range specs {
if si.AlwaysDownload && si.SHA256 != "" {
return nil, errors.New("Couldn't edit software. The 'always_download' option cannot be used with 'hash_sha256'.")
}
var qc string
var err error
if si.PreInstallQuery.Path != "" {
queryFile := si.PreInstallQuery.Path
rawSpec, err := os.ReadFile(queryFile)
if err != nil {
return nil, fmt.Errorf("reading pre-install query: %w", err)
}
rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec)
if err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
var top any
if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil {
return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err)
}
if _, ok := top.(map[any]any); ok {
// Old apply format
group, err := spec.GroupFromBytes(rawSpecExpanded)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err)
}
if len(group.Queries) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(group.Queries) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = group.Queries[0].Query
} else {
// Gitops format
var querySpecs []fleet.QuerySpec
if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err)
}
if len(querySpecs) > 1 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile)
}
if len(querySpecs) == 0 {
return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile)
}
qc = querySpecs[0].Query
}
}
iconHash, err := getIconHashIfValid(si.Icon.Path)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Invalid icon file %s: %w", si.URL, si.Icon.Path, err)
}
var ic []byte
if si.InstallScript.Path != "" {
installScriptFile := si.InstallScript.Path
ic, err = os.ReadFile(installScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err)
}
}
var pc []byte
if si.PostInstallScript.Path != "" {
postInstallScriptFile := si.PostInstallScript.Path
pc, err = os.ReadFile(postInstallScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err)
}
}
var us []byte
if si.UninstallScript.Path != "" {
uninstallScriptFile := si.UninstallScript.Path
us, err = os.ReadFile(uninstallScriptFile)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read uninstall script file %s: %w", si.URL,
si.UninstallScript.Path, err)
}
}
var installDuringSetup *bool
if installDuringSetupKeys != nil {
_, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{PackagePath: si.ReferencedYamlPath}]
installDuringSetup = &ok
}
if si.InstallDuringSetup.Valid {
if len(installDuringSetupKeys) > 0 {
return nil, errConflictingSoftwareSetupExperienceDeclarations{si.URL}
}
installDuringSetup = &si.InstallDuringSetup.Value
}
// For script packages from path, use "script://" URL scheme to pass the filename
urlValue := si.URL
sha256Value := si.SHA256
if fleet.IsScriptPackage(filepath.Ext(si.ReferencedYamlPath)) && si.URL == "" {
scriptFilename := filepath.Base(si.ReferencedYamlPath)
urlValue = "script://" + scriptFilename
if sha256Value == "" && len(ic) > 0 {
hash := sha256.Sum256(ic)
sha256Value = hex.EncodeToString(hash[:])
}
}
var cfg []byte
if si.Configuration.Path != "" {
cfg, err = os.ReadFile(si.Configuration.Path)
if err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Reading configuration %s: %w", si.URL, si.Configuration.Path, err)
}
if err := fleet.ValidateAppleAppConfiguration(cfg); err != nil {
return nil, fmt.Errorf("Couldn't edit software (%s). Configuration %s: %w", si.URL, si.Configuration.Path, err)
}
}
// 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.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
}
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: urlValue,
SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
PostInstallScript: string(pc),
UninstallScript: string(us),
InstallDuringSetup: installDuringSetup,
SetupExperiencePlatforms: setupExperiencePlatforms,
LabelsIncludeAny: si.LabelsIncludeAny,
LabelsExcludeAny: si.LabelsExcludeAny,
LabelsIncludeAll: si.LabelsIncludeAll,
SHA256: sha256Value,
Categories: si.Categories,
DisplayName: si.DisplayName,
IconPath: si.Icon.Path,
IconHash: iconHash,
AlwaysDownload: si.AlwaysDownload,
Configuration: cfg,
}
if si.Slug != nil {
softwarePayloads[i].Slug = si.Slug
softwarePayloads[i].RollbackVersion = si.Version
}
}
return softwarePayloads, nil
}
func getIconHashIfValid(path string) (string, error) {
if path == "" {
return "", nil
}
// TODO cache hash for a given path so we don't have to duplicate reads for multiple references to the same icon
iconReader, err := os.OpenFile(path, os.O_RDONLY, 0o0755)
if err != nil {
return "", fmt.Errorf("reading icon file: %w", err)
}
defer iconReader.Close()
if err = ValidateIcon(iconReader); err != nil {
return "", err
}
// Reset file pointer to beginning before hashing
if _, err := iconReader.Seek(0, io.SeekStart); err != nil {
return "", fmt.Errorf("seeking icon file: %w", err)
}
h := sha256.New()
if _, err := io.Copy(h, iconReader); err != nil {
return "", fmt.Errorf("hashing icon file: %w", err)
}
hash := hex.EncodeToString(h.Sum(nil))
return hash, nil
}
func getAppStoreAppConfig(platform, path string) (json.RawMessage, error) {
if path == "" {
return nil, nil
}
contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading app store app configuration file: %w", err)
}
switch platform {
case string(fleet.AndroidPlatform):
config := json.RawMessage(contents)
if err := fleet.ValidateAndroidAppConfiguration(config); err != nil {
return nil, err
}
return config, nil
case string(fleet.IOSPlatform), string(fleet.IPadOSPlatform):
if err := fleet.ValidateAppleAppConfiguration(contents); err != nil {
return nil, err
}
// Wrap as a JSON-encoded string of XML to match the wire format
// BatchAssociateVPPApps expects (see ee/server/service/vpp.go).
wrapped, err := json.Marshal(string(contents))
if err != nil {
return nil, fmt.Errorf("wrapping apple app configuration: %w", err)
}
return wrapped, nil
}
return nil, nil
}
func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil
}
mmdm, ok := asMap["mdm"].(map[string]interface{})
if !ok {
return nil
}
// GitOps
mosGitOps, ok := mmdm["macos_setup"].(*fleet.MacOSSetup)
if ok {
return mosGitOps
}
// Legacy fleetctl apply
mos, ok := mmdm["macos_setup"].(map[string]interface{})
if !ok || mos == nil {
return nil
}
bp, _ := mos["bootstrap_package"].(string) // if not a string, bp == ""
msa, _ := mos["macos_setup_assistant"].(string)
return &fleet.MacOSSetup{
BootstrapPackage: optjson.SetString(bp),
MacOSSetupAssistant: optjson.SetString(msa),
}
}
func resolveApplyRelativePath(baseDir, path string) string {
return resolveApplyRelativePaths(baseDir, []string{path})[0]
}
// resolves the paths to an absolute path relative to the baseDir, which should
// be the path of the YAML file where the relative paths were specified. If the
// path is already absolute, it is left untouched.
func resolveApplyRelativePaths(baseDir string, paths []string) []string {
if baseDir == "" {
return paths
}
resolved := make([]string, 0, len(paths))
for _, p := range paths {
if !filepath.IsAbs(p) {
p = filepath.Join(baseDir, p)
}
resolved = append(resolved, p)
}
return resolved
}
func extractAppCfgCustomSettings(appCfg interface{}, platformKey string) []fleet.MDMProfileSpec {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil
}
mmdm, ok := asMap["mdm"].(map[string]interface{})
if !ok {
return nil
}
mos, ok := mmdm[platformKey].(fleet.WithMDMProfileSpecs)
if !ok || mos == nil {
return legacyExtractAppCfgCustomSettings(mmdm, platformKey)
}
return mos.GetMDMProfileSpecs()
}
// legacyExtractAppCfgCustomSettings is used to extract custom settings for legacy fleetctl apply use case
func legacyExtractAppCfgCustomSettings(mmdm map[string]interface{}, platformKey string) []fleet.MDMProfileSpec {
mos, ok := mmdm[platformKey].(map[string]interface{})
if !ok || mos == nil {
return nil
}
cs, ok := mos["custom_settings"]
if !ok {
// custom settings is not present
return nil
}
csAny, ok := cs.([]interface{})
if !ok || csAny == nil {
// return a non-nil, empty slice instead, so the caller knows that the
// custom_settings key was actually provided.
return []fleet.MDMProfileSpec{}
}
extractLabelField := func(parentMap map[string]interface{}, fieldName string) []string {
var ret []string
if labels, ok := parentMap[fieldName].([]interface{}); ok {
for _, label := range labels {
if strLabel, ok := label.(string); ok {
ret = append(ret, strLabel)
}
}
}
return ret
}
csSpecs := make([]fleet.MDMProfileSpec, 0, len(csAny))
for _, v := range csAny {
if m, ok := v.(map[string]interface{}); ok {
var profSpec fleet.MDMProfileSpec
// extract the Path field
if path, ok := m["path"].(string); ok {
profSpec.Path = path
}
// at this stage we extract and return all supported label fields, the
// validations are done later on in the Fleet API endpoint.
profSpec.Labels = extractLabelField(m, "labels")
profSpec.LabelsIncludeAll = extractLabelField(m, "labels_include_all")
profSpec.LabelsIncludeAny = extractLabelField(m, "labels_include_any")
profSpec.LabelsExcludeAny = extractLabelField(m, "labels_exclude_any")
if profSpec.Path != "" {
csSpecs = append(csSpecs, profSpec)
}
} else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles
if m != "" {
csSpecs = append(csSpecs, fleet.MDMProfileSpec{Path: m})
}
}
}
return csSpecs
}
func extractAppCfgMacOSCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
return extractAppCfgCustomSettings(appCfg, "macos_settings")
}
func extractAppCfgWindowsCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
return extractAppCfgCustomSettings(appCfg, "windows_settings")
}
func extractAppCfgAndroidCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
return extractAppCfgCustomSettings(appCfg, "android_settings")
}
// extractMacOSAssetSpecs returns the Apple DDM asset specs from a macos_settings
// value, which may be a fleet.MacOSSettings struct (GitOps) or a raw map
// (fleetctl apply).
func extractMacOSAssetSpecs(macOSSettings any) []fleet.MDMProfileSpec {
switch v := macOSSettings.(type) {
case fleet.MacOSSettings:
// macos_settings is present (GitOps decodes it into the struct), so assets
// are reconciled even when none are listed. Normalize nil to a non-nil
// empty slice so the caller treats it as "reconcile to empty".
if v.Assets == nil {
return []fleet.MDMProfileSpec{}
}
return v.Assets
case *fleet.MacOSSettings:
if v == nil {
return nil
}
if v.Assets == nil {
return []fleet.MDMProfileSpec{}
}
return v.Assets
case map[string]any:
raw, ok := v["assets"].([]any)
if !ok {
return nil
}
specs := make([]fleet.MDMProfileSpec, 0, len(raw))
for _, a := range raw {
if m, ok := a.(map[string]any); ok {
if path, ok := m["path"].(string); ok && path != "" {
specs = append(specs, fleet.MDMProfileSpec{Path: path})
}
}
}
return specs
}
return nil
}
func extractAppCfgMacOSAssets(appCfg any) []fleet.MDMProfileSpec {
asMap, ok := appCfg.(map[string]any)
if !ok {
return nil
}
mmdm, ok := asMap["mdm"].(map[string]any)
if !ok {
return nil
}
return extractMacOSAssetSpecs(mmdm["macos_settings"])
}
// extractTmSpecsMDMAssets returns the Apple DDM asset specs keyed by team name.
//
// Assets are reconciled whenever a team manages macos_settings (the key is
// present), mirroring how custom_settings behaves: a present-but-empty asset
// set is a request to clear all of the team's assets. macos_settings is decoded
// through a pointer so its absence (leave assets untouched) is distinguishable
// from an empty macos_settings (reconcile assets to the provided, possibly
// empty, set).
func extractTmSpecsMDMAssets(tmSpecs []json.RawMessage) map[string][]fleet.MDMProfileSpec {
var m map[string][]fleet.MDMProfileSpec
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
MDM struct {
MacOSSettings *struct {
Assets []fleet.MDMProfileSpec `json:"assets"`
} `json:"macos_settings"`
} `json:"mdm"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name == "" || spec.MDM.MacOSSettings == nil {
// macos_settings not managed for this team; leave its assets untouched.
continue
}
if m == nil {
m = make(map[string][]fleet.MDMProfileSpec)
}
// A present but empty (or absent) assets key clears the team's assets, so
// normalize nil to a non-nil empty slice to signal "reconcile to empty".
assets := spec.MDM.MacOSSettings.Assets
if assets == nil {
assets = []fleet.MDMProfileSpec{}
}
m[spec.Name] = assets
}
return m
}
func extractAppCfgScripts(appCfg interface{}) []string {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil
}
scripts, ok := asMap["scripts"]
if !ok {
// scripts is not present
return nil
}
scriptsAny, ok := scripts.([]interface{})
if !ok || scriptsAny == nil {
// return a non-nil, empty slice instead, so the caller knows that the
// scripts key was actually provided.
return []string{}
}
scriptsStrings := make([]string, 0, len(scriptsAny))
for _, v := range scriptsAny {
s, _ := v.(string)
if s != "" {
scriptsStrings = append(scriptsStrings, s)
}
}
return scriptsStrings
}
func extractAppCfgYaraRules(appCfg interface{}) ([]fleet.YaraRuleSpec, error) {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil, errors.New("extract yara rules: app config is not a map")
}
rules, ok := asMap["yara_rules"]
if !ok {
// yara_rules is not present. Return an empty slice so that the value is cleared.
return []fleet.YaraRuleSpec{}, nil
}
rulesAny, ok := rules.([]interface{})
if !ok || rulesAny == nil {
// If nil, return an empty slice so the value will be cleared.
return []fleet.YaraRuleSpec{}, nil
}
ruleSpecs := make([]fleet.YaraRuleSpec, 0, len(rulesAny))
for _, v := range rulesAny {
smap, ok := v.(map[string]interface{})
if !ok {
return nil, errors.New("extract yara rules: rule entry is not a map")
}
pathEntry, ok := smap["path"]
if !ok {
return nil, errors.New("extract yara rules: rule entry missing path")
}
path, ok := pathEntry.(string)
if !ok {
return nil, errors.New("extract yara rules: rule entry path is not string")
}
ruleSpecs = append(ruleSpecs, fleet.YaraRuleSpec{Path: path})
}
return ruleSpecs, nil
}
type profileSpecsByPlatform struct {
macos []fleet.MDMProfileSpec
windows []fleet.MDMProfileSpec
android []fleet.MDMProfileSpec
}
func extractTeamName(tmSpec json.RawMessage) string {
var s struct {
Name string `json:"name"`
}
if err := json.Unmarshal(tmSpec, &s); err != nil {
return ""
}
return norm.NFC.String(s.Name)
}
// returns the custom macOS, Windows and Android settings keyed by team name.
func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profileSpecsByPlatform {
var m map[string]profileSpecsByPlatform
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
MDM struct {
MacOSSettings struct {
CustomSettings json.RawMessage `json:"custom_settings"`
} `json:"macos_settings"`
WindowsSettings struct {
CustomSettings json.RawMessage `json:"custom_settings"`
} `json:"windows_settings"`
AndroidSettings struct {
CustomSettings json.RawMessage `json:"custom_settings"`
} `json:"android_settings"`
} `json:"mdm"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" {
var macOSSettings []fleet.MDMProfileSpec
var windowsSettings []fleet.MDMProfileSpec
var androidSettings []fleet.MDMProfileSpec
// to keep existing bahavior, if any of the custom
// settings is provided, make the map a non-nil map
if len(spec.MDM.MacOSSettings.CustomSettings) > 0 ||
len(spec.MDM.WindowsSettings.CustomSettings) > 0 ||
len(spec.MDM.AndroidSettings.CustomSettings) > 0 {
if m == nil {
m = make(map[string]profileSpecsByPlatform)
}
}
if len(spec.MDM.MacOSSettings.CustomSettings) > 0 {
if err := json.Unmarshal(spec.MDM.MacOSSettings.CustomSettings, &macOSSettings); err != nil {
// ignore, will fail in apply team specs call
continue
}
if macOSSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
macOSSettings = []fleet.MDMProfileSpec{}
}
}
if len(spec.MDM.WindowsSettings.CustomSettings) > 0 {
if err := json.Unmarshal(spec.MDM.WindowsSettings.CustomSettings, &windowsSettings); err != nil {
// ignore, will fail in apply team specs call
continue
}
if windowsSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
windowsSettings = []fleet.MDMProfileSpec{}
}
}
if len(spec.MDM.AndroidSettings.CustomSettings) > 0 {
if err := json.Unmarshal(spec.MDM.AndroidSettings.CustomSettings, &androidSettings); err != nil {
// ignore, will fail in apply team specs call
continue
}
if androidSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
androidSettings = []fleet.MDMProfileSpec{}
}
}
// TODO: validate equal names here and API?
var result profileSpecsByPlatform
if macOSSettings != nil {
result.macos = macOSSettings
}
if windowsSettings != nil {
result.windows = windowsSettings
}
if androidSettings != nil {
result.android = androidSettings
}
if macOSSettings != nil || windowsSettings != nil || androidSettings != nil {
m[spec.Name] = result
}
}
}
return m
}
func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.SoftwarePackageSpec {
var m map[string][]fleet.SoftwarePackageSpec
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
Software json.RawMessage `json:"software"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" && len(spec.Software) > 0 {
if m == nil {
m = make(map[string][]fleet.SoftwarePackageSpec)
}
var software fleet.SoftwareSpec
var packages []fleet.SoftwarePackageSpec
if err := json.Unmarshal(spec.Software, &software); err != nil {
// ignore, will fail in apply team specs call
continue
}
if !software.Packages.Valid {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
packages = []fleet.SoftwarePackageSpec{}
} else {
packages = software.Packages.Value
}
m[spec.Name] = packages
}
}
return m
}
func extractTmSpecsFleetMaintainedApps(tmSpecs []json.RawMessage) map[string][]fleet.SoftwarePackageSpec {
var m map[string][]fleet.SoftwarePackageSpec
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
Software json.RawMessage `json:"software"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" && len(spec.Software) > 0 {
if m == nil {
m = make(map[string][]fleet.SoftwarePackageSpec)
}
var software fleet.SoftwareSpec
var packages []fleet.SoftwarePackageSpec
if err := json.Unmarshal(spec.Software, &software); err != nil {
// ignore, will fail in apply team specs call
continue
}
if !software.FleetMaintainedApps.Valid {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
packages = []fleet.SoftwarePackageSpec{}
} else {
for _, app := range software.FleetMaintainedApps.Value {
packages = append(packages, app.ToSoftwarePackageSpec())
}
}
m[spec.Name] = packages
}
}
return m
}
func extractTmSpecsSoftwareApps(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecAppStoreApp {
var m map[string][]fleet.TeamSpecAppStoreApp
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
Software json.RawMessage `json:"software"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" && len(spec.Software) > 0 {
if m == nil {
m = make(map[string][]fleet.TeamSpecAppStoreApp)
}
var software fleet.SoftwareSpec
var apps []fleet.TeamSpecAppStoreApp
if err := json.Unmarshal(spec.Software, &software); err != nil {
// ignore, will fail in apply team specs call
continue
}
if !software.AppStoreApps.Valid {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
apps = []fleet.TeamSpecAppStoreApp{}
} else {
apps = software.AppStoreApps.Value
}
m[spec.Name] = apps
}
}
return m
}
func extractTmSpecsScripts(tmSpecs []json.RawMessage) map[string][]string {
var m map[string][]string
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
Scripts json.RawMessage `json:"scripts"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" && len(spec.Scripts) > 0 {
if m == nil {
m = make(map[string][]string)
}
var scripts []string
if err := json.Unmarshal(spec.Scripts, &scripts); err != nil {
// ignore, will fail in apply team specs call
continue
}
if scripts == nil {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
scripts = []string{}
}
m[spec.Name] = scripts
}
}
return m
}
// returns the macos_setup keyed by team name.
func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOSSetup {
var m map[string]*fleet.MacOSSetup
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
MDM struct {
MacOSSetup fleet.MacOSSetup `json:"macos_setup"`
} `json:"mdm"`
}
if err := json.Unmarshal(tm, &spec); err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
spec.Name = norm.NFC.String(spec.Name)
if spec.Name != "" {
if m == nil {
m = make(map[string]*fleet.MacOSSetup)
}
m[spec.Name] = &spec.MDM.MacOSSetup
}
}
return m
}
func (c *Client) SaveEnvSecrets(alreadySaved map[string]string, toSave map[string]string, dryRun bool) error {
if len(toSave) == 0 {
return nil
}
// Figure out which secrets need to be saved
var secretsToSave []fleet.SecretVariable
for key, value := range toSave {
if _, ok := alreadySaved[key]; !ok {
secretsToSave = append(secretsToSave, fleet.SecretVariable{Name: key, Value: value})
alreadySaved[key] = value
}
}
if len(secretsToSave) == 0 {
return nil
}
return c.SaveSecretVariables(secretsToSave, dryRun)
}
// allGoogleWorkspaceEntriesEmpty reports whether every google_workspace entry in
// a GitOps org_settings.integrations payload has only empty fields. Such entries
// (e.g. produced by unset GitOps variables) are treated as "not configured" so
// the integration is cleared rather than failing validation. An empty list also
// returns true.
func allGoogleWorkspaceEntriesEmpty(entries []any) bool {
for _, e := range entries {
m, ok := e.(map[string]any)
if !ok {
return false
}
for _, v := range m {
switch t := v.(type) {
case nil:
case string:
if strings.TrimSpace(t) != "" {
return false
}
case map[string]any:
if len(t) != 0 {
return false
}
case []any:
if len(t) != 0 {
return false
}
default:
return false
}
}
}
return true
}
// DoGitOps applies the GitOps config to Fleet.
func (c *Client) DoGitOps(
ctx context.Context,
incoming *spec.GitOps,
fullFilename string,
logf func(format string, args ...interface{}),
dryRun bool,
teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions,
appConfig *fleet.EnrichedAppConfig, // pass-by-ref to build lists
teamsSoftwareInstallers map[string][]fleet.SoftwarePackageResponse,
teamsVPPApps map[string][]fleet.VPPAppResponse,
teamsScripts map[string][]fleet.ScriptResponse,
iconSettings *fleet.IconGitOpsSettings,
) (*fleet.TeamSpecsDryRunAssumptions, error) {
baseDir := filepath.Dir(fullFilename)
filename := filepath.Base(fullFilename)
var teamAssumptions *fleet.TeamSpecsDryRunAssumptions
var err error
logFn := func(format string, args ...interface{}) {
if logf != nil {
logf(format, args...)
}
}
scripts := make([]interface{}, len(incoming.Controls.Scripts))
for i, script := range incoming.Controls.Scripts {
scripts[i] = *script.Path
}
var mdmAppConfig map[string]interface{}
var team map[string]interface{}
var eulaPath string
group := spec.Group{} // as we parse the incoming gitops spec, we'll build out various group specs that will each be applied separately
// Check GitOps exception enforcement. When an entity type is excepted:
// - If the key is present in the YAML, fail with an error (premium tier only)
// - If the key is absent, it's a no-op (existing entities preserved).
// When an entity type is NOT excepted:
// - If the key is absent, all entities of that type are deleted.
var exceptions fleet.GitOpsExceptions
if appConfig != nil {
exceptions = appConfig.GitOpsConfig.Exceptions
if appConfig.License.IsPremium() {
changeManagementURL := strings.TrimRight(appConfig.ServerSettings.ServerURL, "/") + "/settings/integrations/change-management"
if exceptions.Labels && incoming.LabelsPresent {
return nil, fmt.Errorf(
`"labels" is excepted from GitOps management. Remove the "labels:" key from your GitOps file or disable the exception in Fleet settings at %s`, changeManagementURL)
}
if exceptions.Secrets && incoming.SecretsPresent {
return nil, fmt.Errorf(
`"secrets" is excepted from GitOps management. Remove the "secrets:" key from your GitOps file or disable the exception in Fleet settings at %s`, changeManagementURL)
}
if exceptions.Software && incoming.SoftwarePresent && incoming.TeamName != nil {
return nil, fmt.Errorf(
`"software" is excepted from GitOps management. Remove the "software:" key from your GitOps file or disable the exception in Fleet settings at %s`, changeManagementURL)
}
}
}
var orgLogoActions []orgLogoAction
if incoming.TeamName == nil {
// OrgSettings is the basis of the group AppConfig, but we will be adding and removing some
// items because the GitOps structure is not the same as the AppConfig structure.
group.AppConfig = incoming.OrgSettings
// Agent options is a top-level key in the GitOps file but is also stored in the AppConfig.
group.AppConfig.(map[string]interface{})["agent_options"] = incoming.AgentOptions
// Enroll secrets are managed separately in Client.ApplyGroup, so we remove them from the
// OrgSettings so that they are not applied as part of the AppConfig.
// If secrets are not excepted and key is absent, treat as delete-all.
if !exceptions.Secrets && !incoming.SecretsPresent {
incoming.OrgSettings["secrets"] = make([]*fleet.EnrollSecret, 0)
}
if orgSecrets, ok := incoming.OrgSettings["secrets"]; ok && (!exceptions.Secrets || !appConfig.License.IsPremium()) {
group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: orgSecrets.([]*fleet.EnrollSecret)}
}
delete(incoming.OrgSettings, "secrets")
// Certificate authorities are managed separately in Client.ApplyGroup, so we remove them from the
// OrgSettings so that they are not applied as part of the AppConfig.
groupedCAs, err := fleet.ValidateCertificateAuthoritiesSpec(incoming.OrgSettings["certificate_authorities"])
if err != nil {
return nil, fmt.Errorf("invalid certificate_authorities: %w", err)
}
group.CertificateAuthorities = groupedCAs
delete(incoming.OrgSettings, "certificate_authorities")
// Plan PUT uploads and strip the gitops-only `path` keys, which
// aren't part of fleet.OrgInfo. URL changes ride on the PATCH.
orgLogoActions, err = c.planAndStripOrgLogos(incoming.OrgSettings, baseDir, dryRun, logFn)
if err != nil {
return nil, err
}
// Update labels if there were any changes.
if incoming.LabelChangesSummary.HasChanges() {
err := c.doGitOpsLabels(incoming, logFn, dryRun)
if err != nil {
return nil, err
}
}
// Features
var features any
var ok bool
if features, ok = group.AppConfig.(map[string]any)["features"]; !ok || features == nil {
features = map[string]any{}
group.AppConfig.(map[string]any)["features"] = features
}
features, ok = features.(map[string]any)
if !ok {
return nil, errors.New("org_settings.features config is not a map")
}
if enableSoftwareInventory, ok := features.(map[string]any)["enable_software_inventory"]; !ok || enableSoftwareInventory == nil {
features.(map[string]any)["enable_software_inventory"] = true
}
if enableHostUsers, ok := features.(map[string]any)["enable_host_users"]; !ok || enableHostUsers == nil {
features.(map[string]any)["enable_host_users"] = true
}
// historical_data sub-keys default to true on every gitops apply so a
// deployment that doesn't pin them in YAML keeps dashboard collection
// enabled. Mirrors the enable_software_inventory carve-out above.
if err := ensureHistoricalDataDefaults(features.(map[string]any)); err != nil {
return nil, fmt.Errorf("org_settings.%w", err)
}
// Integrations
var integrations interface{}
if integrations, ok = group.AppConfig.(map[string]interface{})["integrations"]; !ok || integrations == nil {
integrations = map[string]interface{}{}
group.AppConfig.(map[string]interface{})["integrations"] = integrations
}
integrations, ok = integrations.(map[string]interface{})
if !ok {
return nil, errors.New("org_settings.integrations config is not a map")
}
if jira, ok := integrations.(map[string]interface{})["jira"]; !ok || jira == nil {
integrations.(map[string]interface{})["jira"] = []interface{}{}
}
if zendesk, ok := integrations.(map[string]interface{})["zendesk"]; !ok || zendesk == nil {
integrations.(map[string]interface{})["zendesk"] = []interface{}{}
}
if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil {
integrations.(map[string]interface{})["google_calendar"] = []interface{}{}
}
// Google Workspace is cleared when it is not set, set to empty, or when all
// of its entries have only empty fields (e.g. from unset GitOps variables),
// so the declarative "absent means remove" behavior holds.
if gw, ok := integrations.(map[string]any)["google_workspace"]; !ok || gw == nil {
integrations.(map[string]any)["google_workspace"] = []any{}
} else if gwList, ok := gw.([]any); ok && allGoogleWorkspaceEntriesEmpty(gwList) {
integrations.(map[string]any)["google_workspace"] = []any{}
}
if conditionalAccessEnabled, ok := integrations.(map[string]interface{})["conditional_access_enabled"]; !ok || conditionalAccessEnabled == nil {
integrations.(map[string]interface{})["conditional_access_enabled"] = false
}
// ensure that legacy certificate authorities are not set in integrations
if _, ok := integrations.(map[string]interface{})["ndes_scep_proxy"]; ok {
return nil, errors.New("org_settings.integrations.ndes_scep_proxy is not supported, please use org_settings.certificate_authorities.ndes_scep_proxy instead")
}
if _, ok := integrations.(map[string]interface{})["digicert"]; ok {
return nil, errors.New("org_settings.integrations.digicert is not supported, please use org_settings.certificate_authorities.digicert instead")
}
if _, ok := integrations.(map[string]interface{})["custom_scep_proxy"]; ok {
return nil, errors.New("org_settings.integrations.custom_scep_proxy is not supported, please use org_settings.certificate_authorities.custom_scep_proxy instead")
}
// Ensure webhooks settings exists
webhookSettings, ok := group.AppConfig.(map[string]any)["webhook_settings"]
if !ok || webhookSettings == nil {
webhookSettings = map[string]any{}
group.AppConfig.(map[string]any)["webhook_settings"] = webhookSettings
}
activitiesWebhook, ok := webhookSettings.(map[string]any)["activities_webhook"]
if !ok || activitiesWebhook == nil {
activitiesWebhook = map[string]any{}
webhookSettings.(map[string]any)["activities_webhook"] = activitiesWebhook
}
// make sure the "enable" key either exists, or set to false
if _, ok := activitiesWebhook.(map[string]any)["enable_activities_webhook"]; !ok {
activitiesWebhook.(map[string]any)["enable_activities_webhook"] = false
}
hostStatusWebhook, ok := webhookSettings.(map[string]any)["host_status_webhook"]
if !ok || hostStatusWebhook == nil {
hostStatusWebhook = map[string]any{}
webhookSettings.(map[string]any)["host_status_webhook"] = hostStatusWebhook
}
if _, ok := hostStatusWebhook.(map[string]any)["enable_host_status_webhook"]; !ok {
hostStatusWebhook.(map[string]any)["enable_host_status_webhook"] = false
}
failingPoliciesWebhook, ok := webhookSettings.(map[string]any)["failing_policies_webhook"]
if !ok || failingPoliciesWebhook == nil {
failingPoliciesWebhook = map[string]any{}
webhookSettings.(map[string]any)["failing_policies_webhook"] = failingPoliciesWebhook
}
if _, ok := failingPoliciesWebhook.(map[string]any)["enable_failing_policies_webhook"]; !ok {
failingPoliciesWebhook.(map[string]any)["enable_failing_policies_webhook"] = false
}
vulnerabilitiesWebhook, ok := webhookSettings.(map[string]any)["vulnerabilities_webhook"]
if !ok || vulnerabilitiesWebhook == nil {
vulnerabilitiesWebhook = map[string]any{}
webhookSettings.(map[string]any)["vulnerabilities_webhook"] = vulnerabilitiesWebhook
}
if _, ok := vulnerabilitiesWebhook.(map[string]any)["enable_vulnerabilities_webhook"]; !ok {
vulnerabilitiesWebhook.(map[string]any)["enable_vulnerabilities_webhook"] = false
}
// Ensure mdm config exists
mdmConfig, ok := group.AppConfig.(map[string]interface{})["mdm"]
if !ok || mdmConfig == nil {
mdmConfig = map[string]interface{}{}
group.AppConfig.(map[string]interface{})["mdm"] = mdmConfig
}
mdmAppConfig, ok = mdmConfig.(map[string]interface{})
if !ok {
return nil, errors.New("org_settings.mdm config is not a map")
}
if _, ok := mdmAppConfig["apple_bm_default_team"]; !ok && appConfig.License.IsPremium() {
if _, ok := mdmAppConfig["apple_business"]; !ok {
mdmAppConfig["apple_business"] = []any{}
}
}
// Put in default value for volume_purchasing_program to clear the configuration if it's not set.
if v, ok := mdmAppConfig["volume_purchasing_program"]; !ok || v == nil {
mdmAppConfig["volume_purchasing_program"] = []any{}
}
// Put in default values for macos_migration
if incoming.Controls.MacOSMigration != nil {
mdmAppConfig["macos_migration"] = incoming.Controls.MacOSMigration
} else {
mdmAppConfig["macos_migration"] = map[string]interface{}{}
}
macOSMigration := mdmAppConfig["macos_migration"].(map[string]interface{})
if enable, ok := macOSMigration["enable"]; !ok || enable == nil {
macOSMigration["enable"] = false
}
// Put in default value for apple_account_provisioning to clear the
// configuration if it's not set in the gitops config.
if incoming.Controls.AppleAccountProvisioning != nil {
mdmAppConfig["apple_account_provisioning"] = incoming.Controls.AppleAccountProvisioning
} else {
mdmAppConfig["apple_account_provisioning"] = map[string]any{}
}
// Put in default values for windows_enabled_and_configured
mdmAppConfig["windows_enabled_and_configured"] = incoming.Controls.WindowsEnabledAndConfigured
if incoming.Controls.WindowsEnabledAndConfigured != nil {
mdmAppConfig["windows_enabled_and_configured"] = incoming.Controls.WindowsEnabledAndConfigured
} else {
mdmAppConfig["windows_enabled_and_configured"] = false
}
// Put in default values for windows_migration_enabled
mdmAppConfig["windows_migration_enabled"] = incoming.Controls.WindowsMigrationEnabled
if incoming.Controls.WindowsMigrationEnabled == nil {
mdmAppConfig["windows_migration_enabled"] = false
}
mdmAppConfig["windows_entra_tenant_ids"] = incoming.Controls.WindowsEntraTenantIDs
if incoming.Controls.WindowsEntraTenantIDs == nil {
mdmAppConfig["windows_entra_tenant_ids"] = []any{}
}
mdmAppConfig["windows_entra_client_ids"] = incoming.Controls.WindowsEntraClientIDs
if incoming.Controls.WindowsEntraClientIDs == nil {
mdmAppConfig["windows_entra_client_ids"] = []any{}
}
// Put in default values for enable_turn_on_windows_mdm_manually
mdmAppConfig["enable_turn_on_windows_mdm_manually"] = incoming.Controls.EnableTurnOnWindowsMDMManually
if incoming.Controls.EnableTurnOnWindowsMDMManually == nil {
mdmAppConfig["enable_turn_on_windows_mdm_manually"] = false
}
if windowsEnabledAndConfiguredAssumption, ok := mdmAppConfig["windows_enabled_and_configured"].(bool); ok {
teamAssumptions = &fleet.TeamSpecsDryRunAssumptions{
WindowsEnabledAndConfigured: optjson.SetBool(windowsEnabledAndConfiguredAssumption),
}
}
// Put in default values for apple_require_hardware_attestation
mdmAppConfig["apple_require_hardware_attestation"] = incoming.Controls.AppleRequireHardwareAttestation
if incoming.Controls.AppleRequireHardwareAttestation == nil {
mdmAppConfig["apple_require_hardware_attestation"] = false
}
mdmAppConfig["android_enabled_and_configured"] = incoming.Controls.AndroidEnabledAndConfigured
if incoming.Controls.AndroidEnabledAndConfigured != nil {
mdmAppConfig["android_enabled_and_configured"] = incoming.Controls.AndroidEnabledAndConfigured
} else {
mdmAppConfig["android_enabled_and_configured"] = false
}
if androidEnabledAndConfiguredAssumption, ok := mdmAppConfig["android_enabled_and_configured"].(bool); ok {
if teamAssumptions == nil {
teamAssumptions = &fleet.TeamSpecsDryRunAssumptions{
AndroidEnabledAndConfigured: optjson.SetBool(androidEnabledAndConfiguredAssumption),
}
} else {
teamAssumptions.AndroidEnabledAndConfigured = optjson.SetBool(androidEnabledAndConfiguredAssumption)
}
}
// check for the eula in the mdmAppConfig. If it exists we want to assign it
// to eulaPath so that it will be applied later. We always delete it from
// mdmAppConfig so it will not be applied to the group/team though the
// ApplyGroup method.
if endUserLicenseAgreement, exists := mdmAppConfig["end_user_license_agreement"]; !exists || endUserLicenseAgreement == nil || (endUserLicenseAgreement == "") {
eulaPath = ""
} else if eulaStr, ok := endUserLicenseAgreement.(string); ok && len(eulaStr) > 0 {
eulaPath = eulaStr
}
delete(mdmAppConfig, "end_user_license_agreement")
group.AppConfig.(map[string]interface{})["scripts"] = scripts
// we want to apply the EULA only for the global settings
if appConfig.License.IsPremium() && appConfig.MDM.EnabledAndConfigured {
if eulaPath != "" {
eulaPath = resolveApplyRelativePath(baseDir, eulaPath)
}
err = c.doGitOpsEULA(eulaPath, logFn, dryRun)
if err != nil {
return nil, err
}
}
// Custom host vitals are global-only and fully declarative: an absent
// `custom_host_vitals:` key clears all existing definitions. Runs last in
// this branch, after all local-only validation above, since it fetches
// current server state over the network to compute the diff.
if err := c.doGitOpsCustomHostVitals(incoming, logFn, dryRun); err != nil {
return nil, err
}
} else if !incoming.IsNoTeam() {
team = make(map[string]interface{})
team["name"] = *incoming.TeamName
team["agent_options"] = incoming.AgentOptions
if hostExpirySettings, ok := incoming.TeamSettings["host_expiry_settings"]; ok {
team["host_expiry_settings"] = hostExpirySettings
}
if features, ok := incoming.TeamSettings["features"]; ok {
team["features"] = features
}
team["scripts"] = scripts
// Software: skip if excepted (key already validated as absent above).
if !exceptions.Software {
team["software"] = map[string]any{}
team["software"].(map[string]any)["app_store_apps"] = incoming.Software.AppStoreApps
team["software"].(map[string]any)["packages"] = incoming.Software.Packages
team["software"].(map[string]any)["fleet_maintained_apps"] = incoming.Software.FleetMaintainedApps
}
// Secrets: if not excepted and key is absent, treat as delete-all.
if !exceptions.Secrets && !incoming.SecretsPresent {
if incoming.TeamSettings == nil {
incoming.TeamSettings = make(map[string]any)
}
incoming.TeamSettings["secrets"] = make([]*fleet.EnrollSecret, 0)
}
if teamSecrets, ok := incoming.TeamSettings["secrets"]; ok && (!exceptions.Secrets || !appConfig.License.IsPremium()) {
team["secrets"] = teamSecrets
}
// Ensure webhooks settings exists
webhookSettings, ok := incoming.TeamSettings["webhook_settings"]
if !ok || webhookSettings == nil {
webhookSettings = map[string]any{}
}
hostStatusWebhook, ok := webhookSettings.(map[string]any)["host_status_webhook"]
if !ok || hostStatusWebhook == nil {
hostStatusWebhook = map[string]any{}
webhookSettings.(map[string]any)["host_status_webhook"] = hostStatusWebhook
}
if _, ok := hostStatusWebhook.(map[string]any)["enable_host_status_webhook"]; !ok {
hostStatusWebhook.(map[string]any)["enable_host_status_webhook"] = false
}
failingPoliciesWebhook, ok := webhookSettings.(map[string]any)["failing_policies_webhook"]
if !ok || failingPoliciesWebhook == nil {
failingPoliciesWebhook = map[string]any{}
webhookSettings.(map[string]any)["failing_policies_webhook"] = failingPoliciesWebhook
}
if _, ok := failingPoliciesWebhook.(map[string]any)["enable_failing_policies_webhook"]; !ok {
failingPoliciesWebhook.(map[string]any)["enable_failing_policies_webhook"] = false
}
hostActivitiesWebhook, ok := webhookSettings.(map[string]any)["host_activities_webhook"]
if !ok || hostActivitiesWebhook == nil {
hostActivitiesWebhook = map[string]any{}
webhookSettings.(map[string]any)["host_activities_webhook"] = hostActivitiesWebhook
}
if _, ok := hostActivitiesWebhook.(map[string]any)["enable_host_activities_webhook"]; !ok {
hostActivitiesWebhook.(map[string]any)["enable_host_activities_webhook"] = false
}
team["webhook_settings"] = webhookSettings
// Features
var features any
if features, ok = team["features"]; !ok || features == nil {
features = map[string]any{}
team["features"] = features
}
features, ok = features.(map[string]any)
if !ok {
return nil, fmt.Errorf("Team %s features config is not a map", *incoming.TeamName)
}
if enableSoftwareInventory, ok := features.(map[string]any)["enable_software_inventory"]; !ok || enableSoftwareInventory == nil {
features.(map[string]any)["enable_software_inventory"] = true
}
if enableHostUsers, ok := features.(map[string]any)["enable_host_users"]; !ok || enableHostUsers == nil {
features.(map[string]any)["enable_host_users"] = true
}
// historical_data sub-keys default to true on every gitops apply.
// See ensureHistoricalDataDefaults for the rationale.
if err := ensureHistoricalDataDefaults(features.(map[string]any)); err != nil {
return nil, fmt.Errorf("Team %s %w", *incoming.TeamName, err)
}
// Integrations
var integrations interface{}
if integrations, ok = incoming.TeamSettings["integrations"]; !ok || integrations == nil {
integrations = map[string]interface{}{}
}
team["integrations"] = integrations
_, ok = integrations.(map[string]interface{})
if !ok {
return nil, errors.New("settings.integrations config is not a map")
}
if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil {
integrations.(map[string]interface{})["google_calendar"] = map[string]interface{}{}
} else {
_, ok = googleCal.(map[string]interface{})
if !ok {
return nil, errors.New("settings.integrations.google_calendar config is not a map")
}
}
if conditionalAccessEnabled, ok := integrations.(map[string]interface{})["conditional_access_enabled"]; !ok || conditionalAccessEnabled == nil {
integrations.(map[string]interface{})["conditional_access_enabled"] = false
} else {
_, ok = conditionalAccessEnabled.(bool)
if !ok {
return nil, errors.New("settings.integrations.conditional_access_enabled config is not a bool")
}
}
team["mdm"] = map[string]interface{}{}
mdmAppConfig = team["mdm"].(map[string]interface{})
}
// name_template (host name template) applies to fleets and to the global
// config for "No team": for "No team" the controls are merged onto the global
// config upstream (extractControlsForNoTeam), so it rides the global/team
// apply block below.
nameTemplate := ""
if incoming.Controls.NameTemplate != nil {
var ok bool
nameTemplate, ok = incoming.Controls.NameTemplate.(string)
if !ok {
return nil, errors.New("controls.name_template must be a string")
}
}
if !incoming.IsNoTeam() {
mdmAppConfig["name_template"] = nameTemplate
// Common controls settings between org and team settings
// Put in default values for macos_settings
if incoming.Controls.MacOSSettings != nil {
mdmAppConfig["macos_settings"] = incoming.Controls.MacOSSettings
} else {
mdmAppConfig["macos_settings"] = fleet.MacOSSettings{
CustomSettings: []fleet.MDMProfileSpec{},
}
}
// Put in default values for macos_updates
if incoming.Controls.MacOSUpdates != nil {
mdmAppConfig["macos_updates"] = incoming.Controls.MacOSUpdates
} else {
mdmAppConfig["macos_updates"] = map[string]interface{}{}
}
macOSUpdates := mdmAppConfig["macos_updates"].(map[string]interface{})
if minimumVersion, ok := macOSUpdates["minimum_version"]; !ok || minimumVersion == nil {
macOSUpdates["minimum_version"] = ""
}
if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil {
macOSUpdates["deadline"] = ""
}
// Send an explicit null when the file omits deadline_days, otherwise the
// PATCH would leave a previously stored value in place and the YAML would
// stop being the source of truth.
if _, ok := macOSUpdates["deadline_days"]; !ok {
macOSUpdates["deadline_days"] = nil
}
// When update_new_hosts isn't explicitly set, derive it from whether OS updates
// are configured: default to true when both minimum_version and deadline are set
// (kept for backward compatibility) and false otherwise. Defaulting to false when
// updates aren't configured prevents a previously stored "true" from sticking
// around once minimum_version/deadline are cleared.
if macOSUpdates["update_new_hosts"] == nil {
// "latest" mode has no deadline — deadline_days replaces it — so the
// deadline check alone would read as "not configured" and silently
// leave new hosts unenforced.
enforcingLatest := macOSUpdates["minimum_version"] == fleet.AppleOSUpdateLatestVersion &&
macOSUpdates["deadline_days"] != nil
macOSUpdates["update_new_hosts"] = enforcingLatest ||
(macOSUpdates["minimum_version"] != "" && macOSUpdates["deadline"] != "")
}
// Put in default values for ios_updates
if incoming.Controls.IOSUpdates != nil {
mdmAppConfig["ios_updates"] = incoming.Controls.IOSUpdates
} else {
mdmAppConfig["ios_updates"] = map[string]interface{}{}
}
iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{})
if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil {
iOSUpdates["minimum_version"] = ""
}
if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil {
iOSUpdates["deadline"] = ""
}
if _, ok := iOSUpdates["deadline_days"]; !ok {
iOSUpdates["deadline_days"] = nil
}
// update_new_hosts is only used for macOS so ignore any values posted for iOS
iOSUpdates["update_new_hosts"] = nil
// Put in default values for ipados_updates
if incoming.Controls.IPadOSUpdates != nil {
mdmAppConfig["ipados_updates"] = incoming.Controls.IPadOSUpdates
} else {
mdmAppConfig["ipados_updates"] = map[string]interface{}{}
}
iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{})
if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil {
iPadOSUpdates["minimum_version"] = ""
}
if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil {
iPadOSUpdates["deadline"] = ""
}
if _, ok := iPadOSUpdates["deadline_days"]; !ok {
iPadOSUpdates["deadline_days"] = nil
}
// update_new_hosts is only used for macOS so ignore any values posted for iPadOS
iPadOSUpdates["update_new_hosts"] = nil
// Put in default values for macos_setup
if incoming.Controls.MacOSSetup != nil {
incoming.Controls.MacOSSetup.SetDefaultsIfNeeded()
mdmAppConfig["macos_setup"] = incoming.Controls.MacOSSetup
} else {
mdmAppConfig["macos_setup"] = fleet.NewMacOSSetupWithDefaults()
}
// Put in default values for windows_settings
if incoming.Controls.WindowsSettings != nil {
mdmAppConfig["windows_settings"] = incoming.Controls.WindowsSettings
} else {
mdmAppConfig["windows_settings"] = fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Value: []fleet.MDMProfileSpec{}},
}
}
// Put in default values for android_settings
if incoming.Controls.AndroidSettings != nil {
mdmAppConfig["android_settings"] = incoming.Controls.AndroidSettings
} else {
mdmAppConfig["android_settings"] = fleet.AndroidSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Value: []fleet.MDMProfileSpec{}},
}
}
// Put in default values for windows_updates
if incoming.Controls.WindowsUpdates != nil {
mdmAppConfig["windows_updates"] = incoming.Controls.WindowsUpdates
} else {
mdmAppConfig["windows_updates"] = map[string]interface{}{}
}
if appConfig.License.IsPremium() {
windowsUpdates := mdmAppConfig["windows_updates"].(map[string]interface{})
if deadlineDays, ok := windowsUpdates["deadline_days"]; !ok || deadlineDays == nil {
windowsUpdates["deadline_days"] = nil
}
if gracePeriodDays, ok := windowsUpdates["grace_period_days"]; !ok || gracePeriodDays == nil {
windowsUpdates["grace_period_days"] = nil
}
}
// Put in default value for enable_disk_encryption
enableDiskEncryption := false
enableRecoveryLockPassword := false
requireBitLockerPIN := false
if incoming.Controls.EnableDiskEncryption != nil {
enableDiskEncryption = incoming.Controls.EnableDiskEncryption.(bool)
}
if incoming.Controls.EnableRecoveryLockPassword != nil {
enableRecoveryLockPassword = incoming.Controls.EnableRecoveryLockPassword.(bool)
}
if incoming.Controls.RequireBitLockerPIN != nil {
requireBitLockerPIN = incoming.Controls.RequireBitLockerPIN.(bool)
}
if !enableDiskEncryption && requireBitLockerPIN {
return nil, errors.New("enable_disk_encryption cannot be false if windows_require_bitlocker_pin is true")
}
mdmAppConfig["enable_disk_encryption"] = enableDiskEncryption
mdmAppConfig["enable_recovery_lock_password"] = enableRecoveryLockPassword
mdmAppConfig["windows_require_bitlocker_pin"] = requireBitLockerPIN
if incoming.TeamName != nil {
team["gitops_filename"] = filename
rawTeam, err := json.Marshal(team)
if err != nil {
return nil, fmt.Errorf("error marshalling fleet spec: %w", err)
}
group.Teams = []json.RawMessage{rawTeam}
group.TeamsDryRunAssumptions = teamDryRunAssumptions
}
}
// For the fleet-scoped flow, apply fleet labels via an ApplyGroup hook that
// fires after the fleet is created and before possible label consumers
// (profiles, software, policies, declarations) are applied.
var afterTeamApply func(teamIDsByName map[string]uint) error
if incoming.TeamName != nil && !incoming.IsNoTeam() {
afterTeamApply = func(teamIDsByName map[string]uint) error {
if len(teamIDsByName) != 1 {
return fmt.Errorf("expected 1 fleet spec to be applied, got %d", len(teamIDsByName))
}
for _, teamID := range teamIDsByName {
incoming.TeamID = &teamID
}
if incoming.Labels == nil || len(incoming.Labels) > 0 {
return c.doGitOpsLabels(incoming, logFn, dryRun)
}
return nil
}
}
// Apply org settings, scripts, enroll secrets, certificate authorities, team entities (software, scripts, etc.), and controls.
teamIDsByName, teamsSoftwareInstallers, teamsVPPApps, teamsScripts, err := c.ApplyGroup(ctx, true, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{
ApplySpecOptions: fleet.ApplySpecOptions{
DryRun: dryRun,
Overwrite: true,
},
ExpandEnvConfigProfiles: true,
}, teamsSoftwareInstallers, teamsVPPApps, teamsScripts, &filename, afterTeamApply)
if err != nil {
return nil, err
}
// Apply org logo uploads/deletes after the AppConfig PATCH succeeded.
if incoming.TeamName == nil {
if err := c.doGitOpsOrgLogos(orgLogoActions, dryRun, logFn); err != nil {
return nil, err
}
}
var teamSoftwareInstallers []fleet.SoftwarePackageResponse
var teamVPPApps []fleet.VPPAppResponse
var teamScripts []fleet.ScriptResponse
if incoming.TeamName != nil {
if !incoming.IsNoTeam() {
if len(teamIDsByName) != 1 {
return nil, fmt.Errorf("expected 1 fleet spec to be applied, got %d", len(teamIDsByName))
}
teamID, ok := teamIDsByName[*incoming.TeamName]
if ok && teamID == 0 {
if dryRun {
logFn("[+] would've added any policies/reports to new fleet %s\n", *incoming.TeamName)
numCerts := 0
if incoming.Controls.AndroidSettings != nil {
if androidSettings, ok := incoming.Controls.AndroidSettings.(fleet.AndroidSettings); ok {
if androidSettings.Certificates.Valid {
numCerts = len(androidSettings.Certificates.Value)
}
}
}
if numCerts > 0 {
logFn("[+] would've added %s to new fleet %s\n",
numberWithPluralization(numCerts, "Android certificate", "Android certificates"),
*incoming.TeamName)
}
return nil, nil
}
return nil, fmt.Errorf("fleet %s not created", *incoming.TeamName)
}
// incoming.TeamID and fleet-scoped labels were both already applied
// by the afterTeamApply closure passed to ApplyGroup above.
teamSoftwareInstallers = teamsSoftwareInstallers[*incoming.TeamName]
teamVPPApps = teamsVPPApps[*incoming.TeamName]
teamScripts = teamsScripts[*incoming.TeamName]
} else {
noTeamSoftwareInstallers, noTeamVPPApps, err := c.doGitOpsNoTeamSetupAndSoftware(incoming, baseDir, appConfig, exceptions.Software, logFn, dryRun)
if err != nil {
return nil, err
}
// Apply webhook settings for "No Team"
if err := c.doGitOpsNoTeamWebhookSettings(incoming, appConfig, logFn, dryRun); err != nil {
return nil, fmt.Errorf("applying webhook settings for unassigned hosts: %w", err)
}
if exceptions.Software && !incoming.SoftwarePresent {
// Software is excepted and not present in YAML — use pre-fetched data
// for policy validation instead of the empty data from the parser.
teamSoftwareInstallers = teamsSoftwareInstallers[*incoming.TeamName]
teamVPPApps = teamsVPPApps[*incoming.TeamName]
} else {
teamSoftwareInstallers = noTeamSoftwareInstallers
teamVPPApps = noTeamVPPApps
}
teamScripts = teamsScripts["No team"]
}
}
// Apply Android certificates before policies so that certificate templates
// exist on the server before the profile reconciler's next cron cycle. This
// prevents a race where the cron fires after profiles are uploaded (by
// ApplyGroup above) but before cert templates exist, which would cause ONC
// profiles to be sent without waiting for the cert.
err = c.doGitOpsAndroidCertificates(incoming, appConfig, logFn, dryRun)
if err != nil {
var gitOpsErr *gitOpsValidationError
if errors.As(err, &gitOpsErr) {
return nil, gitOpsErr.WithFileContext(baseDir, filename)
}
return nil, err
}
err = c.doGitOpsPolicies(incoming, teamSoftwareInstallers, teamVPPApps, teamScripts, logFn, dryRun)
if err != nil {
return nil, err
}
// apply icon changes from software installers and VPP apps
if len(teamSoftwareInstallers) > 0 || len(teamVPPApps) > 0 {
iconUpdates := fleet.IconChanges{}.WithUploadedHashes(iconSettings.UploadedHashes).WithSoftware(teamSoftwareInstallers, teamVPPApps)
if dryRun {
logFn(
"[+] Would've set icons on %s and deleted icons on %s\n",
numberWithPluralization(len(iconUpdates.IconsToUpdate)+len(iconUpdates.IconsToUpload), "software title", "software titles"),
numberWithPluralization(len(iconUpdates.TitleIDsToRemoveIconsFrom), "title", "titles"),
)
} else {
if err = c.doGitOpsIcons(iconUpdates, iconSettings.ConcurrentUploads, iconSettings.ConcurrentUpdates); err != nil {
return nil, err
}
logFn(
"[+] set icons on %s and deleted icons on %s\n",
numberWithPluralization(len(iconUpdates.IconsToUpdate)+len(iconUpdates.IconsToUpload), "software title", "software titles"),
numberWithPluralization(len(iconUpdates.TitleIDsToRemoveIconsFrom), "title", "titles"),
)
iconSettings.UploadedHashes = iconUpdates.UploadedHashes
}
}
// We currently don't support queries for "No team" thus
// we just do GitOps for queries for global and team files.
if !incoming.IsNoTeam() {
err = c.doGitOpsQueries(incoming, logFn, dryRun)
if err != nil {
return nil, err
}
}
return teamAssumptions, nil
}
func (c *Client) doGitOpsIcons(iconUpdates fleet.IconChanges, concurrentUploads int, concurrentUpdates int) error {
var uploads errgroup.Group
uploads.SetLimit(concurrentUploads)
for _, toUpload := range iconUpdates.IconsToUpload {
uploads.Go(func() error {
iconReader, err := os.OpenFile(toUpload.Path, os.O_RDONLY, 0o0755)
if err != nil {
return fmt.Errorf("failed to read software icon for upload %s: %w", toUpload.Path, err)
}
defer iconReader.Close()
if err = c.UploadIcon(iconUpdates.TeamID, toUpload.TitleID, filepath.Base(toUpload.Path), iconReader); err != nil {
return fmt.Errorf("failed to upload software icon %s: %w", toUpload.Path, err)
}
return nil
})
}
if uploadErr := uploads.Wait(); uploadErr != nil {
return uploadErr
}
var updates errgroup.Group
updates.SetLimit(concurrentUpdates)
for _, toUpdate := range iconUpdates.IconsToUpdate {
updates.Go(func() error {
err := c.UpdateIcon(iconUpdates.TeamID, toUpdate.TitleID, filepath.Base(toUpdate.Path), toUpdate.Hash)
if err == nil {
return nil
}
if !errors.Is(err, ErrIconBytesMissing) {
return fmt.Errorf("failed to update software icon %s: %w", toUpdate.Path, err)
}
iconReader, openErr := os.OpenFile(toUpdate.Path, os.O_RDONLY, 0o0755)
if openErr != nil {
return fmt.Errorf("failed to read software icon for fallback upload %s: %w", toUpdate.Path, openErr)
}
defer iconReader.Close()
if uploadErr := c.UploadIcon(iconUpdates.TeamID, toUpdate.TitleID, filepath.Base(toUpdate.Path), iconReader); uploadErr != nil {
return fmt.Errorf("failed to recover software icon via upload %s: %w", toUpdate.Path, uploadErr)
}
return nil
})
}
for _, titleToDelete := range iconUpdates.TitleIDsToRemoveIconsFrom {
updates.Go(func() error {
if err := c.DeleteIcon(iconUpdates.TeamID, titleToDelete); err != nil {
return fmt.Errorf("failed to delete software icon: %w", err)
}
return nil
})
}
return updates.Wait()
}
func (c *Client) doGitOpsNoTeamSetupAndSoftware(
config *spec.GitOps,
baseDir string,
appconfig *fleet.EnrichedAppConfig,
softwareExcepted bool,
logFn func(format string, args ...interface{}),
dryRun bool,
) ([]fleet.SoftwarePackageResponse, []fleet.VPPAppResponse, error) {
if !config.IsNoTeam() || appconfig == nil || !appconfig.License.IsPremium() {
return nil, nil, nil
}
var macOSSetup fleet.MacOSSetup
if config.Controls.MacOSSetup == nil {
config.Controls.MacOSSetup = &macOSSetup
}
macOSSetup = *config.Controls.MacOSSetup
// load the no-team setup_experience.macos_script if any
var macosSetupScript *fileContent
if macOSSetup.Script.Value != "" {
if macOSSetup.ManualAgentInstall.Value {
return nil, nil, errors.New(
"Couldn\u2019t add setup experience script. To add script, first disable macos_manual_agent_install (unassigned hosts).",
)
}
b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, macOSSetup.Script.Value))
if err != nil {
return nil, nil, fmt.Errorf("applying setup_experience.macos_script for unassigned hosts: %w", err)
}
macosSetupScript = &fileContent{Filename: filepath.Base(macOSSetup.Script.Value), Content: b}
}
// Apply the setup experience script regardless of software exception status,
// since it's part of controls, not software.
if !dryRun {
if macosSetupScript != nil {
logFn("[+] applying macos setup experience script for unassigned hosts\n")
if err := c.uploadMacOSSetupScript(macosSetupScript.Filename, macosSetupScript.Content, nil); err != nil {
return nil, nil, fmt.Errorf("uploading setup experience script for unassigned hosts: %w", err)
}
} else if err := c.deleteMacOSSetupScript(nil); err != nil {
return nil, nil, fmt.Errorf("deleting setup experience script for unassigned hosts: %w", err)
}
}
// When software is excepted from GitOps, don't apply software for no-team
// (which would wipe existing software with an empty payload). The caller
// uses pre-fetched server data for policy validation instead.
if softwareExcepted && !config.SoftwarePresent {
return nil, nil, nil
}
noTeamSoftwareMacOSSetup, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, macOSSetup.Software.Value)
if err != nil {
return nil, nil, err
}
var softwareInstallers []fleet.SoftwarePackageResponse
packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages))
packagesWithPaths := make(map[string]struct{}, len(config.Software.Packages))
for _, software := range config.Software.Packages {
if software != nil {
packages = append(packages, *software)
if software.ReferencedYamlPath != "" {
// can be referenced by setup_experience.software
packagesWithPaths[software.ReferencedYamlPath] = struct{}{}
}
}
}
for _, software := range config.Software.FleetMaintainedApps {
if software != nil {
packages = append(packages, software.ToSoftwarePackageSpec())
}
}
var appsPayload []fleet.VPPBatchPayload
appsByAppID := make(map[string]fleet.TeamSpecAppStoreApp, len(config.Software.AppStoreApps))
for _, appStoreApp := range config.Software.AppStoreApps {
if appStoreApp != nil {
// can be referenced by setup_experience.software
appsByAppID[appStoreApp.AppStoreID] = *appStoreApp
_, installDuringSetup := noTeamSoftwareMacOSSetup[fleet.MacOSSetupSoftware{AppStoreID: appStoreApp.AppStoreID}]
if appStoreApp.InstallDuringSetup.Valid {
if len(noTeamSoftwareMacOSSetup) > 0 {
return nil, nil, errConflictingVPPSetupExperienceDeclarations
}
installDuringSetup = appStoreApp.InstallDuringSetup.Value
}
iconHash, err := getIconHashIfValid(appStoreApp.Icon.Path)
if err != nil {
return nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Invalid custom icon file %s: %w", appStoreApp.AppStoreID, appStoreApp.Icon.Path, err)
}
appConfig, err := getAppStoreAppConfig(appStoreApp.Platform, appStoreApp.Configuration.Path)
if err != nil {
return nil, nil, fmt.Errorf("Couldn't edit app store app (%s). Reading configuration %s: %w", appStoreApp.AppStoreID, appStoreApp.Configuration.Path, err)
}
payload := fleet.VPPBatchPayload{
AppStoreID: appStoreApp.AppStoreID,
SelfService: appStoreApp.SelfService,
InstallDuringSetup: &installDuringSetup,
DisplayName: appStoreApp.DisplayName,
IconPath: appStoreApp.Icon.Path,
IconHash: iconHash,
Platform: fleet.InstallableDevicePlatform(appStoreApp.Platform),
AutoUpdateEnabled: appStoreApp.AutoUpdateEnabled,
AutoUpdateStartTime: appStoreApp.AutoUpdateStartTime,
AutoUpdateEndTime: appStoreApp.AutoUpdateEndTime,
Categories: appStoreApp.Categories,
}
if appConfig != nil {
payload.Configuration = appConfig
}
appsPayload = append(appsPayload, payload)
}
}
if err := validateTeamOrNoTeamMacOSSetupSoftware(*config.TeamName, macOSSetup.Software.Value, packagesWithPaths, appsByAppID); err != nil {
return nil, nil, err
}
swPkgPayload, err := buildSoftwarePackagesPayload(packages, noTeamSoftwareMacOSSetup)
if err != nil {
return nil, nil, fmt.Errorf("applying software installers: %w", err)
}
format := applyingTeamFormat
if dryRun {
format = dryRunAppliedTeamFormat
}
softwareCount := numberWithPluralization(len(swPkgPayload), "software package", "software packages")
if !dryRun {
logFn(applyingTeamFormat, softwareCount, "'Unassigned'")
}
softwareInstallers, deletedInstallers, installerCategories, err := c.ApplyNoTeamSoftwareInstallers(swPkgPayload, fleet.ApplySpecOptions{DryRun: dryRun}, logFn)
if err != nil {
return nil, nil, fmt.Errorf("applying software installers: %w", err)
}
if dryRun {
logFn(dryRunAppliedTeamFormat, softwareCount, "'Unassigned'")
} else {
logFn(appliedTeamFormat, softwareCount, "'Unassigned'")
}
logSoftwareDeletions(logFn, deletedInstallers, dryRun)
logFn(format, numberWithPluralization(len(appsPayload), "app store app", "app store apps"), "'Unassigned'")
vppApps, appCategories, err := c.ApplyNoTeamAppStoreAppsAssociation(appsPayload, fleet.ApplySpecOptions{DryRun: dryRun})
if err != nil {
return nil, nil, fmt.Errorf("applying app store apps: %w", err)
}
// Delete categories not referenced by any software.
if !dryRun && !softwareExcepted {
categories := slices.Concat(installerCategories, appCategories)
if err := c.deleteUnusedSelfServiceCategories(0, categories); err != nil {
return nil, nil, fmt.Errorf("deleting unused self-service categories: %w", err)
}
}
return softwareInstallers, vppApps, nil
}
// logSoftwareDeletions prints one message per software package that a batch
// set deleted (real run) or would delete (dry run), following the per-item
// deletion message pattern used for labels and policies.
func logSoftwareDeletions(logFn func(format string, args ...any), deleted []fleet.DeletedSoftwarePackage, dryRun bool) {
for _, pkg := range deleted {
if dryRun {
logFn("[-] would've deleted software - %s\n", pkg.DisplayName)
} else {
logFn("[-] deleted software - %s\n", pkg.DisplayName)
}
}
}
// extractFailingPoliciesWebhook extracts and processes failing policies webhook settings from a map
func extractFailingPoliciesWebhook(webhookSettings interface{}) fleet.FailingPoliciesWebhookSettings {
// Marshal the interface{} to JSON, which handles type conversions
jsonBytes, err := json.Marshal(webhookSettings)
if err != nil {
return fleet.FailingPoliciesWebhookSettings{Enable: false}
}
// Unmarshal into a wrapper struct to extract failing_policies_webhook
var ws struct {
FailingPoliciesWebhook fleet.FailingPoliciesWebhookSettings `json:"failing_policies_webhook"`
}
if err := json.Unmarshal(jsonBytes, &ws); err != nil {
return fleet.FailingPoliciesWebhookSettings{Enable: false}
}
return ws.FailingPoliciesWebhook
}
func extractHostActivitiesWebhook(webhookSettings any) *fleet.HostActivitiesWebhookSettings {
disabled := &fleet.HostActivitiesWebhookSettings{Enable: false}
jsonBytes, err := json.Marshal(webhookSettings)
if err != nil {
return disabled
}
var ws struct {
HostActivitiesWebhook *fleet.HostActivitiesWebhookSettings `json:"host_activities_webhook"`
}
if err := json.Unmarshal(jsonBytes, &ws); err != nil {
return disabled
}
if ws.HostActivitiesWebhook == nil {
return disabled
}
return ws.HostActivitiesWebhook
}
func (c *Client) doGitOpsNoTeamWebhookSettings(
config *spec.GitOps,
appCfg *fleet.EnrichedAppConfig,
logFn func(format string, args ...interface{}),
dryRun bool,
) error {
// Check if premium license is available for No Team webhook settings
if !config.IsNoTeam() || appCfg == nil || appCfg.License == nil || !appCfg.License.IsPremium() {
return nil
}
// Apply webhook settings for "No Team"
// If webhook_settings are not specified, they will be applied as disabled to clear existing settings
teamPayload := fleet.TeamPayload{
WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
Enable: false,
},
HostActivitiesWebhook: &fleet.HostActivitiesWebhookSettings{
Enable: false,
},
},
}
// Extract webhook settings if they exist
if config.TeamSettings != nil {
if webhookSettings, ok := config.TeamSettings["webhook_settings"]; ok {
fpw := extractFailingPoliciesWebhook(webhookSettings)
teamPayload.WebhookSettings.FailingPoliciesWebhook = fpw
teamPayload.WebhookSettings.HostActivitiesWebhook = extractHostActivitiesWebhook(webhookSettings)
}
}
if !dryRun {
logFn("[+] applying webhook settings for unassigned hosts\n")
err := c.PatchFleet(0, teamPayload)
if err != nil {
return fmt.Errorf("applying webhook settings for unassigned hosts: %w", err)
}
logFn("[+] applied webhook settings for unassigned hosts\n")
} else {
logFn("[+] would've applied webhook settings for unassigned hosts\n")
}
return nil
}
func (c *Client) doGitOpsLabels(
config *spec.GitOps,
logFn func(format string, args ...any),
dryRun bool,
) error {
toDelete := config.LabelChangesSummary.LabelsToRemove
toMove := config.LabelChangesSummary.LabelsMovements
nToAdd := len(config.LabelChangesSummary.LabelsToAdd)
nToUpdate := len(config.LabelChangesSummary.LabelsToUpdate)
if dryRun {
if len(toDelete) > 0 {
logFn("[-] would've deleted %s (This includes renames, since labels are identified by name in YAML.)\n", numberWithPluralization(len(toDelete), "label", "labels"))
}
for _, labelToDelete := range toDelete {
logFn("[-] would've deleted label '%s'\n", labelToDelete)
}
if nToAdd > 0 {
logFn("[+] would've created %s\n", numberWithPluralization(nToAdd, "label", "labels"))
}
if nToUpdate > 0 {
logFn("[+] would've updated %s\n", numberWithPluralization(nToUpdate, "label", "labels"))
}
for _, l := range toMove {
logFn("[-] would've moved label %q from fleet %q to fleet %q\n", l.Name, l.FromTeamName, l.ToTeamName)
}
return nil
}
if len(toDelete) > 0 {
logFn("[-] deleting %s (This includes renames, since labels are identified by name in YAML.)\n", numberWithPluralization(len(toDelete), "label", "labels"))
}
for _, l := range toDelete {
logFn("[-] deleting label '%s'\n", l)
}
namesToMove := make([]string, 0, len(toMove))
for _, l := range toMove {
namesToMove = append(namesToMove, l.Name)
logFn("[-] moving label %q from fleet %q to fleet %q\n", l.Name, l.FromTeamName, l.ToTeamName)
}
logFn("[+] applying %s (%d new and %d updated)\n", numberWithPluralization(len(config.Labels), "label", "labels"), nToAdd, nToUpdate)
return c.ApplyLabels(config.Labels, config.TeamID, namesToMove)
}
// doGitOpsCustomHostVitals reconciles custom host vital definitions against
// config.CustomHostVitals (an absent key clears all, per parseCustomHostVitals).
// Global-only, so this is a no-op on a team file (config.CustomHostVitals is
// always empty there).
func (c *Client) doGitOpsCustomHostVitals(config *spec.GitOps, logFn func(format string, args ...any), dryRun bool) error {
if config.TeamName != nil {
return nil
}
desired := config.CustomHostVitals
if !config.CustomHostVitalsPresent {
desired = []fleet.CustomHostVital{}
}
existing, err := c.listAllCustomHostVitals()
if err != nil {
return err
}
existingNames := make(map[string]struct{}, len(existing))
for _, v := range existing {
existingNames[v.Name] = struct{}{}
}
desiredNames := make(map[string]struct{}, len(desired))
for _, v := range desired {
desiredNames[v.Name] = struct{}{}
}
var toDelete []string
for _, v := range existing {
if _, ok := desiredNames[v.Name]; !ok {
toDelete = append(toDelete, v.Name)
}
}
var toAdd []string
for _, v := range desired {
if _, ok := existingNames[v.Name]; !ok {
toAdd = append(toAdd, v.Name)
}
}
if dryRun {
if len(toDelete) > 0 {
logFn("[-] would've deleted %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals"))
}
for _, name := range toDelete {
logFn("[-] would've deleted custom host vital '%s'\n", name)
}
if len(toAdd) > 0 {
logFn("[+] would've created %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals"))
}
return c.SaveCustomHostVitals(desired, true)
}
if len(toDelete) > 0 {
logFn("[-] deleting %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals"))
}
for _, name := range toDelete {
logFn("[-] deleting custom host vital '%s'\n", name)
}
if len(toAdd) > 0 {
logFn("[+] creating %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals"))
}
return c.SaveCustomHostVitals(desired, false)
}
// resolvePolicySoftwareTitleID attempts to resolve the software title ID for a
// policy by trying each available identifier in order: URL, App Store ID, hash,
// then FMA slug. Returns the resolved title ID and true if found, or 0 and
// false if no identifier matched.
func resolvePolicySoftwareTitleID(
policy *spec.GitOpsPolicySpec,
byURL, byAppStoreID, byHash, bySlug map[string]uint,
) (titleID uint, resolved bool) {
if policy.InstallSoftwareURL != "" {
if id, ok := byURL[policy.InstallSoftwareURL]; ok {
return id, true
}
}
if policy.InstallSoftware.Other == nil {
return 0, false
}
if policy.InstallSoftware.Other.AppStoreID != "" {
if id, ok := byAppStoreID[policy.InstallSoftware.Other.AppStoreID]; ok {
return id, true
}
}
if policy.InstallSoftware.Other.HashSHA256 != "" {
if id, ok := byHash[policy.InstallSoftware.Other.HashSHA256]; ok {
return id, true
}
}
if policy.InstallSoftware.Other.FleetMaintainedAppSlug != "" {
if id, ok := bySlug[policy.InstallSoftware.Other.FleetMaintainedAppSlug]; ok {
return id, true
}
}
return 0, false
}
func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwarePackageResponse, teamVPPApps []fleet.VPPAppResponse, teamScripts []fleet.ScriptResponse, logFn func(format string, args ...interface{}), dryRun bool) error {
// Collect policy names that have webhooks_and_tickets_enabled set.
var policyNamesWithWebhooks []string
for _, p := range config.Policies {
if p.WebhooksAndTicketsEnabled {
policyNamesWithWebhooks = append(policyNamesWithWebhooks, p.Name)
}
}
// Get failing policies webhook settings to check for conflicts with policies that have webhooks_and_tickets_enabled.
fpw := extractFailingPoliciesWebhookFromConfig(config)
if len(fpw.PolicyIDs) > 0 {
if len(policyNamesWithWebhooks) > 0 {
return errors.New(
"cannot use both 'webhooks_and_tickets_enabled' on policies and 'policy_ids' in failing_policies_webhook settings; please use one or the other",
)
}
// Log a deprecation warning.
logFn("[!] WARNING: using 'policy_ids' in failing_policies_webhook settings is deprecated; please use 'webhooks_and_tickets_enabled: true' on individual policies instead\n")
}
var teamID *uint // Global policies (nil)
switch {
case config.TeamID != nil: // Team policies
teamID = config.TeamID
case config.IsNoTeam(): // "No team" policies
teamID = ptr.Uint(0)
}
if teamID != nil {
// Get software titles of packages for the team.
softwareTitleIDsByInstallerURL := make(map[string]uint)
softwareTitleIDsByAppStoreAppID := make(map[string]uint)
softwareTitleIDsByHash := make(map[string]uint)
softwareTitleIDsBySlug := make(map[string]uint)
for _, softwareInstaller := range teamSoftwareInstallers {
if softwareInstaller.TitleID == nil {
// Should not happen, but to not panic we just log a warning.
logFn("[!] software installer without title id: fleet_id=%d, url=%s\n", *teamID, softwareInstaller.URL)
continue
}
if softwareInstaller.URL == "" && softwareInstaller.HashSHA256 == "" {
// Should not happen because we previously applied packages via gitops, but to not panic we just log a warning.
logFn("[!] software installer without url: fleet_id=%d, title_id=%d\n", *teamID, *softwareInstaller.TitleID)
continue
}
softwareTitleIDsByInstallerURL[softwareInstaller.URL] = *softwareInstaller.TitleID
softwareTitleIDsByHash[softwareInstaller.HashSHA256] = *softwareInstaller.TitleID
if softwareInstaller.Slug != "" {
softwareTitleIDsBySlug[softwareInstaller.Slug] = *softwareInstaller.TitleID
}
}
for _, vppApp := range teamVPPApps {
if vppApp.Platform != fleet.MacOSPlatform {
continue // ignore iPad/iPhone VPP apps as they aren't relevant for policies
}
if vppApp.TitleID == nil {
// Should not happen, but to not panic we just log a warning.
logFn("[!] VPP app without title id: fleet_id=%d, app_store_id=%s\n", *teamID, vppApp.AppStoreID)
continue
}
if vppApp.AppStoreID == "" {
// Should not happen because we previously applied apps via gitops, but to not panic we just log a warning.
logFn("[!] VPP app without app ID: fleet_id=%d, title_id=%d\n", *teamID, *vppApp.TitleID)
continue
}
softwareTitleIDsByAppStoreAppID[vppApp.AppStoreID] = *vppApp.TitleID
}
for i := range config.Policies {
config.Policies[i].SoftwareTitleID = ptr.Uint(0) // 0 unsets the installer
if config.Policies[i].Type == fleet.PolicyTypePatch && !config.Policies[i].InstallSoftware.IsOther && config.Policies[i].InstallSoftware.Bool {
softwareTitleID, ok := softwareTitleIDsBySlug[config.Policies[i].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].FleetMaintainedAppSlug)
}
continue
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
if config.Policies[i].InstallSoftware.Other == nil {
continue
}
// Try each identifier type in order of specificity. For package policies,
// both URL and hash are set (from the referenced YAML file). If the primary
// identifier (URL) fails, fall back to secondary identifiers rather than
// skipping the policy entirely.
softwareTitleID, resolved := resolvePolicySoftwareTitleID(
config.Policies[i],
softwareTitleIDsByInstallerURL,
softwareTitleIDsByAppStoreAppID,
softwareTitleIDsByHash,
softwareTitleIDsBySlug,
)
if resolved {
config.Policies[i].SoftwareTitleID = &softwareTitleID
// Log a warning if URL was set but didn't match (resolved via fallback).
if !dryRun && config.Policies[i].InstallSoftwareURL != "" {
if _, urlOK := softwareTitleIDsByInstallerURL[config.Policies[i].InstallSoftwareURL]; !urlOK {
logFn("[!] policy %q: software URL lookup failed, resolved via fallback (url=%q, hash=%q)\n",
config.Policies[i].Name, config.Policies[i].InstallSoftwareURL, config.Policies[i].InstallSoftware.Other.HashSHA256)
}
}
} else {
if !dryRun {
logFn("[!] policy %q: could not resolve software title ID (url=%q, hash=%q)\n",
config.Policies[i].Name, config.Policies[i].InstallSoftwareURL,
config.Policies[i].InstallSoftware.Other.HashSHA256)
}
continue
}
}
// Get scripts for the team.
scriptIDsByName := make(map[string]uint)
for _, script := range teamScripts {
scriptIDsByName[script.Name] = script.ID
}
for i := range config.Policies {
config.Policies[i].ScriptID = ptr.Uint(0) // 0 unsets the script
if config.Policies[i].RunScript == nil {
continue
}
scriptID, ok := scriptIDsByName[*config.Policies[i].RunScriptName]
if !ok {
if !dryRun { // this shouldn't happen
logFn("[!] reference to an unknown script: %s\n", *config.Policies[i].RunScriptName)
}
continue
}
config.Policies[i].ScriptID = &scriptID
}
// Get patch policy title IDs for the team
for _, policy := range config.Policies {
if policy.Type == fleet.PolicyTypePatch {
policy.PatchSoftwareTitleID = softwareTitleIDsBySlug[policy.FleetMaintainedAppSlug]
}
}
}
// Get the ids and names of current policies to figure out which ones to delete
policies, err := c.GetPolicies(teamID)
if err != nil {
return fmt.Errorf("error getting current policies: %w", err)
}
if len(config.Policies) > 0 {
numPolicies := len(config.Policies)
if dryRun {
logFn("[+] would've applied %s\n", numberWithPluralization(numPolicies, "policy", "policies"))
} else {
logFn("[+] applying %s\n", numberWithPluralization(numPolicies, "policy", "policies"))
}
if !dryRun {
totalApplied := 0
for i := 0; i < len(config.Policies); i += batchSize {
end := i + batchSize
if end > len(config.Policies) {
end = len(config.Policies)
}
totalApplied += end - i
// Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps.
policiesToApply := config.Policies[i:end]
policiesSpec := make([]*fleet.PolicySpec, len(policiesToApply))
for i := range policiesToApply {
policiesSpec[i] = &policiesToApply[i].PolicySpec
}
if err := c.ApplyPolicies(policiesSpec); err != nil {
return fmt.Errorf("error applying policies: %w", err)
}
logFn("[+] applied %s\n", numberWithPluralization(totalApplied, "policy", "policies"))
}
}
}
var policiesToDelete []uint
for _, oldItem := range policies {
found := false
for _, newItem := range config.Policies {
if oldItem.Name == newItem.Name {
found = true
break
}
// patch policies are unique by patch_software_title_id so matching by name doesn't always work
if newItem.Type == fleet.PolicyTypePatch && oldItem.PatchSoftware != nil {
if oldItem.PatchSoftware.SoftwareTitleID == newItem.PatchSoftwareTitleID {
found = true
break
}
}
}
if !found {
policiesToDelete = append(policiesToDelete, oldItem.ID)
if !dryRun {
logFn("[-] deleting policy %s\n", oldItem.Name)
} else {
logFn("[-] would've deleted policy %s\n", oldItem.Name)
}
}
}
if len(policiesToDelete) > 0 {
if dryRun {
logFn("[-] would've deleted %s\n", numberWithPluralization(len(policiesToDelete), "policy", "policies"))
} else {
logFn("[-] deleting %s\n", numberWithPluralization(len(policiesToDelete), "policy", "policies"))
}
if !dryRun {
totalDeleted := 0
for i := 0; i < len(policiesToDelete); i += batchSize {
end := i + batchSize
if end > len(policiesToDelete) {
end = len(policiesToDelete)
}
totalDeleted += end - i
var teamID *uint
switch {
case config.TeamID != nil: // Team policies
teamID = config.TeamID
case config.IsNoTeam(): // No team policies
teamID = ptr.Uint(fleet.PolicyNoTeamID)
default: // Global policies
teamID = nil
}
if err := c.DeletePolicies(teamID, policiesToDelete[i:end]); err != nil {
return fmt.Errorf("error deleting policies: %w", err)
}
logFn("[-] deleted %s\n", numberWithPluralization(totalDeleted, "policy", "policies"))
}
}
}
// If any policies have webhooks_and_tickets_enabled, resolve their IDs
// and update the failing policies webhook settings with the policy_ids list.
if len(policyNamesWithWebhooks) > 0 {
if dryRun {
logFn("[+] would've enabled failed policy reporting for %s\n",
numberWithPluralization(len(policyNamesWithWebhooks), "policy", "policies"))
} else {
// Resolve policy names to IDs. First try using the policies we already
// fetched earlier; only re-fetch if some names are missing (i.e.,
// newly created policies whose IDs we don't have yet).
policyNameSet := make(map[string]bool, len(policyNamesWithWebhooks))
for _, name := range policyNamesWithWebhooks {
policyNameSet[name] = true
}
var resolvedIDs []uint
for _, p := range policies {
if policyNameSet[p.Name] {
resolvedIDs = append(resolvedIDs, p.ID)
delete(policyNameSet, p.Name)
}
}
// If we have names left that couldn't resolve, re-fetch all the policies
// to get the IDs for the newly created ones.
if len(policyNameSet) > 0 {
// Some policies were newly created; re-fetch to get their IDs.
allPolicies, err := c.GetPolicies(teamID)
if err != nil {
return fmt.Errorf("error getting policies to resolve webhooks_and_tickets_enabled: %w", err)
}
for _, p := range allPolicies {
if policyNameSet[p.Name] {
resolvedIDs = append(resolvedIDs, p.ID)
}
}
}
// Extract the existing webhook config and set the resolved policy IDs.
fpw.PolicyIDs = resolvedIDs
// Re-apply the webhook settings with the resolved policy IDs.
if config.TeamName == nil {
if err := c.ApplyAppConfig(map[string]any{
"webhook_settings": map[string]any{"failing_policies_webhook": fpw},
}, fleet.ApplySpecOptions{}); err != nil {
return fmt.Errorf("error updating failing policies webhook: %w", err)
}
} else {
var patchTeamID uint
if config.IsNoTeam() {
patchTeamID = 0
} else {
patchTeamID = *config.TeamID
}
if err := c.PatchFleet(patchTeamID, fleet.TeamPayload{
WebhookSettings: &fleet.TeamWebhookSettings{FailingPoliciesWebhook: fpw},
}); err != nil {
return fmt.Errorf("error updating failing policies webhook: %w", err)
}
}
logFn("[+] enabled failed policy reporting for %s\n",
numberWithPluralization(len(resolvedIDs), "policy", "policies"))
}
}
return nil
}
// extractFailingPoliciesWebhookFromConfig extracts the FailingPoliciesWebhookSettings
// from the gitops config's settings (org or team).
func extractFailingPoliciesWebhookFromConfig(config *spec.GitOps) fleet.FailingPoliciesWebhookSettings {
var settings map[string]any
if config.TeamName == nil {
settings = config.OrgSettings
} else {
settings = config.TeamSettings
}
if settings == nil {
return fleet.FailingPoliciesWebhookSettings{}
}
webhookSettings, ok := settings["webhook_settings"]
if !ok || webhookSettings == nil {
return fleet.FailingPoliciesWebhookSettings{}
}
return extractFailingPoliciesWebhook(webhookSettings)
}
func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
batchSize := 100
// Get the ids and names of current queries to figure out which ones to delete
queries, err := c.GetQueries(config.TeamID, nil)
if err != nil {
return fmt.Errorf("error getting current reports: %w", err)
}
if len(config.Queries) > 0 {
numQueries := len(config.Queries)
if dryRun {
logFn("[+] would've applied %s\n", numberWithPluralization(numQueries, "report", "reports"))
} else {
logFn("[+] applying %s\n", numberWithPluralization(numQueries, "report", "reports"))
}
if !dryRun {
appliedCount := 0
for i := 0; i < len(config.Queries); i += batchSize {
end := i + batchSize
if end > len(config.Queries) {
end = len(config.Queries)
}
appliedCount += end - i
// Note: We are reusing the spec flow here for adding/updating queries, instead of creating a new flow for GitOps.
if err := c.ApplyQueries(config.Queries[i:end]); err != nil {
return fmt.Errorf("error applying reports: %w", err)
}
logFn("[+] applied %s\n", numberWithPluralization(appliedCount, "report", "reports"))
}
}
}
var queriesToDelete []uint
for _, oldQuery := range queries {
found := false
for _, newQuery := range config.Queries {
if oldQuery.Name == newQuery.Name {
found = true
break
}
}
if !found {
queriesToDelete = append(queriesToDelete, oldQuery.ID)
if !dryRun {
fmt.Printf("[-] deleting report %s\n", oldQuery.Name)
} else {
fmt.Printf("[-] would've deleted report %s\n", oldQuery.Name)
}
}
}
if len(queriesToDelete) > 0 {
if dryRun {
logFn("[-] would've deleted %s\n", numberWithPluralization(len(queriesToDelete), "report", "reports"))
} else {
logFn("[-] deleting %s\n", numberWithPluralization(len(queriesToDelete), "report", "reports"))
deleteCount := 0
for i := 0; i < len(queriesToDelete); i += batchSize {
end := i + batchSize
if end > len(queriesToDelete) {
end = len(queriesToDelete)
}
deleteCount += end - i
if err := c.DeleteQueries(queriesToDelete[i:end]); err != nil {
return fmt.Errorf("error deleting reports: %w", err)
}
logFn("[-] deleted %s\n", numberWithPluralization(deleteCount, "report", "reports"))
}
}
}
return nil
}
func (c *Client) doGitOpsAndroidCertificates(config *spec.GitOps, appConfig *fleet.EnrichedAppConfig, logFn func(format string, args ...any), dryRun bool) error {
certificates := make([]fleet.CertificateTemplateSpec, 0)
// Extract Android certificates from config if there are any.
if config.Controls.AndroidSettings != nil {
androidSettings, ok := config.Controls.AndroidSettings.(fleet.AndroidSettings)
if ok && androidSettings.Certificates.Valid && len(androidSettings.Certificates.Value) > 0 {
certificates = androidSettings.Certificates.Value
}
}
numCerts := len(certificates)
teamID := ""
teamName := ""
switch {
case config.IsNoTeam():
teamName = "No team"
teamID = "0"
case config.TeamID != nil:
teamName = *config.TeamName
teamID = fmt.Sprintf("%d", *config.TeamID)
default:
// global config, ignore
return nil
}
// existing certificate templates
existingCertificates, err := c.GetCertificateTemplates(teamID)
if err != nil {
return fmt.Errorf("applying Android certificates: getting existing Android certificates: %w", err)
}
if numCerts == 0 && len(existingCertificates) == 0 {
return nil
}
// Certificate templates require a custom SCEP CA, and CAs are Premium-only. If the YAML
// declares any certificates against a Free server, bail with a friendly error before doing
// anything destructive on the team. Mirrors the certificate_authorities check at line ~597.
// appConfig is passed in from DoGitOps, which has already fetched it once for upstream
// Premium checks; reuse rather than refetch.
if numCerts > 0 && (appConfig == nil || appConfig.License == nil || !appConfig.License.IsPremium()) {
return newGitOpsValidationError(
"Android certificate templates require a custom SCEP CA, which is available in Fleet Premium only.")
}
if dryRun {
logFn("[+] would have attempted to apply %s\n", numberWithPluralization(numCerts, "Android certificate", "Android certificates"))
} else {
logFn("[+] attempting to apply %s\n", numberWithPluralization(numCerts, "Android certificate", "Android certificates"))
}
certRequests := make([]*fleet.CertificateRequestSpec, len(certificates))
certsToBeAdded := make(map[string]*fleet.CertificateRequestSpec, len(certificates))
// CA lookups + per-cert validation are only needed when the YAML declares at least one cert.
// Skipping the GetCertificateAuthorities call lets delete-only syncs (numCerts == 0,
// existingCertificates non-empty) succeed without CA-read access.
if numCerts > 0 {
cas, err := c.GetCertificateAuthorities()
if err != nil {
return fmt.Errorf("getting certificate authorities: %w", err)
}
casByName := make(map[string]*fleet.CertificateAuthoritySummary)
for _, ca := range cas {
casByName[ca.Name] = ca
}
for i := range certificates {
if !certificates[i].NameValid() {
return newGitOpsValidationError(
`Invalid characters in "name" field. Only letters, numbers, spaces, dashes, and underscores allowed.`,
)
}
// Validate Fleet variables in subject name
if err := validateCertificateTemplateFleetVariables(certificates[i].SubjectName); err != nil {
return newGitOpsValidationError(
fmt.Sprintf(`Invalid Fleet variable in certificate %q: %s`, certificates[i].Name, err.Error()),
)
}
// Normalize whitespace-only SAN to "" so the value we send matches the server's
// read-back (the server stores whitespace-only as NULL and COALESCEs reads to ""). Without
// this, hand-authored YAML like `subject_alternative_name: " "` would mismatch on every
// apply and trigger an infinite delete+recreate cycle.
san := certificates[i].SubjectAlternativeName
if strings.TrimSpace(san) == "" {
san = ""
}
// Validate the optional SAN at GitOps time so admins get fast feedback before the apply
// reaches the server. Server re-validates as the source of truth.
if san != "" {
if err := validateCertificateTemplateSubjectAlternativeName(san, certificates[i].Name); err != nil {
return newGitOpsValidationError(
fmt.Sprintf(`Invalid subject_alternative_name in certificate %q: %s`, certificates[i].Name, err.Error()),
)
}
if err := validateCertificateTemplateFleetVariables(san); err != nil {
return newGitOpsValidationError(
fmt.Sprintf(`Invalid Fleet variable in subject_alternative_name of certificate %q: %s`,
certificates[i].Name, err.Error()),
)
}
}
ca, ok := casByName[certificates[i].CertificateAuthorityName]
if !ok {
return fmt.Errorf("certificate authority %q not found for certificate %q",
certificates[i].CertificateAuthorityName, certificates[i].Name)
}
// Validate that the CA is the right type.
if ca.Type != string(fleet.CATypeCustomSCEPProxy) {
return newGitOpsValidationError(fmt.Sprintf("Android certificates: CA `%s` has type `%s`. Currently, only the custom_scep_proxy certificate authority is supported.", ca.Name, ca.Type))
}
certRequests[i] = &fleet.CertificateRequestSpec{
Name: certificates[i].Name,
Team: teamName,
CertificateAuthorityId: ca.ID,
SubjectName: certificates[i].SubjectName,
SubjectAlternativeName: san,
}
if _, ok := certsToBeAdded[certificates[i].Name]; ok {
return newGitOpsValidationError(
fmt.Sprintf(
`The name %q is already used by another certificate. Please choose a different name and try again.`,
certificates[i].Name,
),
)
}
certsToBeAdded[certificates[i].Name] = certRequests[i]
}
}
var certificatesToDelete []uint
for _, cert := range existingCertificates {
if cert != nil {
newCert, exists := certsToBeAdded[cert.Name]
if !exists {
certificatesToDelete = append(certificatesToDelete, cert.ID)
} else if cert.SubjectName != newCert.SubjectName ||
cert.SubjectAlternativeName != newCert.SubjectAlternativeName ||
cert.CertificateAuthorityId != newCert.CertificateAuthorityId {
// Subject, SAN, or CA changed; mark for deletion (will be recreated).
certificatesToDelete = append(certificatesToDelete, cert.ID)
}
}
}
if len(certificatesToDelete) > 0 {
if dryRun {
logFn("[-] would've deleted %s\n", numberWithPluralization(len(certificatesToDelete), "Android certificate", "Android certificates"))
} else {
logFn("[-] deleting %s\n", numberWithPluralization(len(certificatesToDelete), "Android certificate", "Android certificates"))
tmId, err := strconv.ParseUint(teamID, 10, 0)
if err != nil {
return fmt.Errorf("applying Android certificates: parsing fleet ID: %w", err)
}
if err := c.DeleteCertificateTemplates(certificatesToDelete, uint(tmId)); err != nil {
return fmt.Errorf("applying Android certificates: deleting existing Android certificates: %w", err)
}
}
}
if dryRun {
logFn("[+] would've applied %s\n", numberWithPluralization(numCerts, "Android certificate", "Android certificates"))
} else {
if numCerts > 0 {
if err := c.ApplyCertificateSpecs(certRequests); err != nil {
return fmt.Errorf("applying Android certificates: %w", err)
}
}
logFn("[+] applied %s\n", numberWithPluralization(numCerts, "Android certificate", "Android certificates"))
}
return nil
}
func (c *Client) doGitOpsEULA(eulaPath string, logFn func(format string, args ...interface{}), dryRun bool) error {
if eulaPath == "" {
err := c.DeleteEULAIfNeeded(dryRun)
if err != nil {
return fmt.Errorf("error deleting EULA: %w", err)
}
} else {
err := c.UploadEULAIfNeeded(eulaPath, dryRun)
if err != nil {
return fmt.Errorf("error uploading EULA: %w", err)
}
}
if dryRun {
logFn("[+] would've applied EULA\n")
} else {
logFn("[+] applied EULA\n")
}
return nil
}
func (c *Client) GetGitOpsSecrets(
config *spec.GitOps,
) []string {
if config.TeamName == nil {
orgSecrets, ok := config.OrgSettings["secrets"]
if ok {
secrets, ok := orgSecrets.([]*fleet.EnrollSecret)
if ok {
secretValues := make([]string, 0, len(secrets))
for _, secret := range secrets {
secretValues = append(secretValues, secret.Secret)
}
return secretValues
}
}
} else {
teamSecrets, ok := config.TeamSettings["secrets"]
if ok {
secrets, ok := teamSecrets.([]*fleet.EnrollSecret)
if ok {
secretValues := make([]string, 0, len(secrets))
for _, secret := range secrets {
secretValues = append(secretValues, secret.Secret)
}
return secretValues
}
}
}
return nil
}
// ensureHistoricalDataDefaults injects default `true` values for any
// `historical_data` sub-key not explicitly specified in the gitops payload.
// Called for both global and team-spec gitops applies so a deployment that
// doesn't pin `historical_data` in YAML keeps the upgrade-friendly default
// of dashboard collection enabled. Mirrors the existing carve-out for
// `enable_software_inventory`.
//
// Returns an error if `historical_data` is present but is not a map (e.g.
// `historical_data: true` or a list), so a malformed YAML fails the gitops
// run loudly instead of being silently replaced with defaults.
func ensureHistoricalDataDefaults(features map[string]any) error {
raw, present := features["historical_data"]
historicalData, ok := raw.(map[string]any)
switch {
case !present || raw == nil:
historicalData = map[string]any{}
features["historical_data"] = historicalData
case !ok:
return fmt.Errorf("features.historical_data must be a map, got %T", raw)
}
if v, ok := historicalData["uptime"]; !ok || v == nil {
historicalData["uptime"] = true
}
if v, ok := historicalData["vulnerabilities"]; !ok || v == nil {
historicalData["vulnerabilities"] = true
}
return nil
}