fleet/server/service/client.go
Scott Gress 07a8378a68
Implement FMA software policy automation (#42533)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #36751 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
- [X] Verified that `fleetctl generate-gitops` correctly outputs
policies with `install_software.fleet_maintained_app_slug` populated
when the policies have FMA automation
- [X] Verified that running `fleetctl gitops` using files with
`install_software.fleet_maintained_app_slug` creates/updates FMA policy
automation correctly
  - [X] Verified no changes to the above for custom packages or VPP apps
- [X] Verified that when software is excepted from GitOps, FMA policy
automations still work (correctly validates FMAs exist before applying)

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [X] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
checking on this
- [X] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [X] Verified that any relevant UI is disabled when GitOps mode is
enabled
2026-03-30 11:25:46 -05:00

3395 lines
122 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"
"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/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")
}
}
}
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 {
response, err := c.AuthenticatedDo(verb, path, query, params)
if err != nil {
return fmt.Errorf("%s %s: %w", verb, path, err)
}
return c.ParseResponse(verb, path, response, responseDest)
}
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
result = append(result, fleet.MDMProfileBatchPayload{
Name: name,
Contents: fileContents,
Labels: profile.Labels,
LabelsIncludeAll: profile.LabelsIncludeAll,
LabelsIncludeAny: profile.LabelsIncludeAny,
LabelsExcludeAny: profile.LabelsExcludeAny,
})
}
}
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"
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,
) (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("applying fleet config: %w", err)
}
if err := c.UploadBootstrapPackageIfNeeded(pkg, uint(0), opts.DryRun); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleet config: %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, fmt.Errorf("applying fleet config: %w", 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("applying fleet config: %w", err)
}
if !opts.DryRun {
if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying fleet config: %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
}
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
}
// Keep any existing GitOps mode config rather than attempting to set via GitOps.
if appconfig != nil {
specs.AppConfig.(map[string]any)["gitops"] = appconfig.GitOpsConfig
}
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?
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
}
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 != "" {
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("applying fleet config: %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)
}
var androidConfig json.RawMessage
if app.Platform == string(fleet.AndroidPlatform) {
androidConfig, err = getAndroidAppConfig(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 androidConfig != nil {
payload.Configuration = androidConfig
}
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 teams: %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
}
}
}
}
if len(tmFileContents) > 0 {
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, teamOpts); 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, 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 len(tmSoftwarePackagesPayloads) > 0 {
for tmName, software := range tmSoftwarePackagesPayloads {
// For non-dry run, currentTeamName and tmName are the same
currentTeamName := getTeamName(tmName)
logfn(format, numberWithPluralization(len(software), "software package", "software packages"), tmName)
installers, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("applying software installers for fleet %q: %w", tmName, err)
}
teamsSoftwareInstallers[tmName] = installers
}
}
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, 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
}
}
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 teams: 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 {
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[:])
}
}
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: urlValue,
SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
PostInstallScript: string(pc),
UninstallScript: string(us),
InstallDuringSetup: installDuringSetup,
LabelsIncludeAny: si.LabelsIncludeAny,
LabelsExcludeAny: si.LabelsExcludeAny,
LabelsIncludeAll: si.LabelsIncludeAll,
SHA256: sha256Value,
Categories: si.Categories,
DisplayName: si.DisplayName,
IconPath: si.Icon.Path,
IconHash: iconHash,
}
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 getAndroidAppConfig(path string) (json.RawMessage, error) {
if path == "" {
return nil, nil
}
configReader, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading android app configuration file: %w", err)
}
config := json.RawMessage(configReader)
err = fleet.ValidateAndroidAppConfiguration(config)
if err != nil {
return nil, err
}
return config, 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")
}
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)
}
// 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.
// - 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 exceptions.Labels && incoming.LabelsPresent {
return nil, errors.New(
`"labels" is excepted from GitOps management. Remove the "labels:" key from your GitOps file or disable the exception in Fleet settings.`)
}
if exceptions.Secrets && incoming.SecretsPresent {
return nil, errors.New(
`"secrets" is excepted from GitOps management. Remove the "secrets:" key from your GitOps file or disable the exception in Fleet settings.`)
}
if exceptions.Software && incoming.SoftwarePresent && incoming.TeamName != nil {
return nil, errors.New(
`"software" is excepted from GitOps management. Remove the "software:" key from your GitOps file or disable the exception in Fleet settings.`)
}
}
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 {
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")
// 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
}
// 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{}{}
}
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_manager"]; !ok {
mdmAppConfig["apple_business_manager"] = []interface{}{}
}
}
// 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 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{}
}
// 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),
}
}
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
}
}
} 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 {
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
}
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
}
// 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{})
}
if !incoming.IsNoTeam() {
// 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"] = ""
}
// To keep things backward compatible, if a minimum_version and deadline are both set but the user hasn't set update_new_hosts,
// then we default update_new_hosts to true
if macOSUpdates["minimum_version"] != "" && macOSUpdates["deadline"] != "" && macOSUpdates["update_new_hosts"] == nil {
macOSUpdates["update_new_hosts"] = true
}
// 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"] = ""
}
// 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"] = ""
}
// 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
}
}
// 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)
if 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)
}
for _, teamID = range teamIDsByName {
incoming.TeamID = &teamID
}
// Apply team labels after any possible new teams are created
if incoming.Labels == nil || len(incoming.Labels) > 0 {
err := c.doGitOpsLabels(incoming, logFn, dryRun)
if err != nil {
return nil, err
}
}
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"]
}
}
err = c.doGitOpsPolicies(incoming, teamSoftwareInstallers, teamVPPApps, teamScripts, logFn, dryRun)
if err != nil {
return nil, err
}
// Apply Android certificates if present
err = c.doGitOpsAndroidCertificates(incoming, logFn, dryRun)
if err != nil {
var gitOpsErr *gitOpsValidationError
if errors.As(err, &gitOpsErr) {
return nil, gitOpsErr.WithFileContext(baseDir, filename)
}
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 {
if err := c.UpdateIcon(iconUpdates.TeamID, toUpdate.TitleID, filepath.Base(toUpdate.Path), toUpdate.Hash); err != nil {
return fmt.Errorf("failed to update software icon %s: %w", toUpdate.Path, err)
}
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 != "" {
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)
}
var androidConfig json.RawMessage
if appStoreApp.Platform == string(fleet.AndroidPlatform) {
androidConfig, err = getAndroidAppConfig(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 androidConfig != nil {
payload.Configuration = androidConfig
}
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
}
logFn(format, numberWithPluralization(len(swPkgPayload), "software package", "software packages"), "'Unassigned'")
softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(swPkgPayload, fleet.ApplySpecOptions{DryRun: dryRun})
if err != nil {
return nil, nil, fmt.Errorf("applying software installers: %w", err)
}
logFn(format, numberWithPluralization(len(appsPayload), "app store app", "app store apps"), "'Unassigned'")
vppApps, err := c.ApplyNoTeamAppStoreAppsAssociation(appsPayload, fleet.ApplySpecOptions{DryRun: dryRun})
if err != nil {
return nil, nil, fmt.Errorf("applying app store apps: %w", err)
}
if !dryRun {
logFn("[+] applied software packages for unassigned hosts\n")
}
return softwareInstallers, vppApps, nil
}
// 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 (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 nil to clear existing settings
teamPayload := fleet.TeamPayload{
WebhookSettings: &fleet.TeamWebhookSettings{
FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{
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
}
}
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)
}
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].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
}
if config.Policies[i].InstallSoftwareURL != "" {
softwareTitleID, ok := softwareTitleIDsByInstallerURL[config.Policies[i].InstallSoftwareURL]
if !ok {
// Should not happen because software packages are uploaded first.
if !dryRun {
logFn("[!] software URL without software title ID: %s\n", config.Policies[i].InstallSoftwareURL)
}
continue
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
if config.Policies[i].InstallSoftware.Other.AppStoreID != "" {
softwareTitleID, ok := softwareTitleIDsByAppStoreAppID[config.Policies[i].InstallSoftware.Other.AppStoreID]
if !ok {
// Should not happen because app store apps are uploaded first.
if !dryRun {
logFn("[!] software app store app ID without software title ID: %s\n", config.Policies[i].InstallSoftware.Other.AppStoreID)
}
continue
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
if config.Policies[i].InstallSoftware.Other.HashSHA256 != "" {
softwareTitleID, ok := softwareTitleIDsByHash[config.Policies[i].InstallSoftware.Other.HashSHA256]
if !ok {
// Should not happen because software packages are uploaded first.
if !dryRun {
logFn("[!] software hash without software title ID: %s\n", config.Policies[i].InstallSoftware.Other.HashSHA256)
}
continue
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
if config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug != "" {
softwareTitleID, ok := softwareTitleIDsBySlug[config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug]
if !ok {
// Should not happen because FMAs are uploaded first.
if !dryRun {
logFn("[!] fleet-maintained app slug without software title ID: %s\n", config.Policies[i].InstallSoftware.Other.FleetMaintainedAppSlug)
}
continue
}
config.Policies[i].SoftwareTitleID = &softwareTitleID
}
}
// Get scripts for the team.
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 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
}
}
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, logFn func(format string, args ...interface{}), 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
}
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"))
}
// getting certificate authorities
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
}
certRequests := make([]*fleet.CertificateRequestSpec, len(certificates))
certsToBeAdded := make(map[string]*fleet.CertificateRequestSpec, len(certificates))
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()),
)
}
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,
}
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.CertificateAuthorityId != newCert.CertificateAuthorityId {
// SubjectName 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
}