mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- 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
3395 lines
122 KiB
Go
3395 lines
122 KiB
Go
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 don’t 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
|
||
}
|