mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
For #25648 Fixed issue where `fleetctl gitops` was NOT deleting macOS setup experience bootstrap package and enrollment profile. GitOps should clear all settings that are not explicitly set in YAML config files. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated automated tests - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - [x] Manual QA for all new/changed functionality
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/spec"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/service"
|
|
"github.com/urfave/cli/v2"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
const filenameMaxLength = 255
|
|
|
|
func gitopsCommand() *cli.Command {
|
|
var (
|
|
flFilenames cli.StringSlice
|
|
flDryRun bool
|
|
flDeleteOtherTeams bool
|
|
)
|
|
return &cli.Command{
|
|
Name: "gitops",
|
|
Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.",
|
|
UsageText: `fleetctl gitops [options]`,
|
|
Flags: []cli.Flag{
|
|
&cli.StringSliceFlag{
|
|
Name: "f",
|
|
Required: true,
|
|
EnvVars: []string{"FILENAME"},
|
|
Destination: &flFilenames,
|
|
Usage: "The file(s) with the GitOps configuration. If multiple files are provided, the first file must be the global configuration and the rest must be team configurations.",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "delete-other-teams",
|
|
EnvVars: []string{"DELETE_OTHER_TEAMS"},
|
|
Destination: &flDeleteOtherTeams,
|
|
Usage: "Delete other teams not present in the GitOps configuration",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "dry-run",
|
|
EnvVars: []string{"DRY_RUN"},
|
|
Destination: &flDryRun,
|
|
Usage: "Do not apply the file(s), just validate",
|
|
},
|
|
configFlag(),
|
|
contextFlag(),
|
|
debugFlag(),
|
|
},
|
|
Action: func(c *cli.Context) error {
|
|
totalFilenames := len(flFilenames.Value())
|
|
if totalFilenames == 0 {
|
|
return errors.New("-f must be specified")
|
|
}
|
|
for _, flFilename := range flFilenames.Value() {
|
|
if strings.TrimSpace(flFilename) == "" {
|
|
return errors.New("file name cannot be empty")
|
|
}
|
|
if len(filepath.Base(flFilename)) > filenameMaxLength {
|
|
return fmt.Errorf("file name must be less than %d characters: %s", filenameMaxLength, filepath.Base(flFilename))
|
|
}
|
|
}
|
|
|
|
// Check license
|
|
fleetClient, err := clientFromCLI(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appConfig, err := fleetClient.GetAppConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if appConfig.License == nil {
|
|
return errors.New("no license struct found in app config")
|
|
}
|
|
logf := func(format string, a ...interface{}) {
|
|
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
|
|
}
|
|
|
|
// We need the controls from no-team.yml to apply them when applying the global app config.
|
|
noTeamControls, noTeamPresent, err := extractControlsForNoTeam(flFilenames, appConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("extracting controls from no-team.yml: %w", err)
|
|
}
|
|
|
|
var originalABMConfig []any
|
|
var originalVPPConfig []any
|
|
var teamNames []string
|
|
var firstFileMustBeGlobal *bool
|
|
var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions
|
|
var abmTeams, vppTeams []string
|
|
var hasMissingABMTeam, hasMissingVPPTeam, usesLegacyABMConfig bool
|
|
if totalFilenames > 1 {
|
|
firstFileMustBeGlobal = ptr.Bool(true)
|
|
}
|
|
|
|
// we keep track of team software installers and scripts for correct policy application
|
|
teamsSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse)
|
|
teamsVPPApps := make(map[string][]fleet.VPPAppResponse)
|
|
teamsScripts := make(map[string][]fleet.ScriptResponse)
|
|
|
|
// We keep track of the secrets to check if duplicates exist during dry run
|
|
secrets := make(map[string]struct{})
|
|
// We keep track of the environment FLEET_SECRET_* variables
|
|
allFleetSecrets := make(map[string]string)
|
|
for _, flFilename := range flFilenames.Value() {
|
|
baseDir := filepath.Dir(flFilename)
|
|
config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
isGlobalConfig := config.TeamName == nil
|
|
if firstFileMustBeGlobal != nil {
|
|
switch {
|
|
case *firstFileMustBeGlobal && !isGlobalConfig:
|
|
return fmt.Errorf("first file %s must be the global config", flFilename)
|
|
case !*firstFileMustBeGlobal && isGlobalConfig:
|
|
return fmt.Errorf(
|
|
"the file %s cannot be the global config, only the first file can be the global config", flFilename,
|
|
)
|
|
}
|
|
firstFileMustBeGlobal = ptr.Bool(false)
|
|
}
|
|
|
|
if isGlobalConfig {
|
|
if noTeamControls.Set() && config.Controls.Set() {
|
|
return errors.New("'controls' cannot be set on both global config and on no-team.yml")
|
|
}
|
|
if !noTeamControls.Defined && !config.Controls.Defined {
|
|
if appConfig.License.IsPremium() {
|
|
return errors.New("'controls' must be set on global config or no-team.yml")
|
|
}
|
|
return errors.New("'controls' must be set on global config")
|
|
}
|
|
if !config.Controls.Set() {
|
|
config.Controls = noTeamControls
|
|
}
|
|
} else if !appConfig.License.IsPremium() {
|
|
logf("[!] skipping team config %s since teams are only supported for premium Fleet users\n", flFilename)
|
|
continue
|
|
}
|
|
|
|
// Special handling for tokens is required because they link to teams (by
|
|
// name.) Because teams can be created/deleted during the same gitops run, we
|
|
// grab some information to help us determine allowed/restricted actions and
|
|
// when to perform the associations.
|
|
|
|
if isGlobalConfig && totalFilenames > 1 && !(totalFilenames == 2 && noTeamPresent) && appConfig.License.IsPremium() {
|
|
abmTeams, hasMissingABMTeam, usesLegacyABMConfig, err = checkABMTeamAssignments(config, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vppTeams, hasMissingVPPTeam, err = checkVPPTeamAssignments(config, fleetClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// if one of the teams assigned to an ABM token doesn't exist yet, we need to
|
|
// submit the configs without the ABM default team set. We'll set those
|
|
// separately later when the teams are already created.
|
|
if hasMissingABMTeam {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
if appleBM, ok := mdmMap["apple_business_manager"]; ok {
|
|
if bmSettings, ok := appleBM.([]any); ok {
|
|
originalABMConfig = bmSettings
|
|
}
|
|
}
|
|
|
|
// If team is not found, we need to remove the AppleBMDefaultTeam from
|
|
// the global config, and then apply it after teams are processed
|
|
mdmMap["apple_business_manager"] = nil
|
|
mdmMap["apple_bm_default_team"] = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasMissingVPPTeam {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
if vpp, ok := mdmMap["volume_purchasing_program"]; ok {
|
|
if vppSettings, ok := vpp.([]any); ok {
|
|
originalVPPConfig = vppSettings
|
|
}
|
|
}
|
|
|
|
// If team is not found, we need to remove the VPP config from
|
|
// the global config, and then apply it after teams are processed
|
|
mdmMap["volume_purchasing_program"] = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
incomingSecrets := fleetClient.GetGitOpsSecrets(config)
|
|
for _, secret := range incomingSecrets {
|
|
if _, ok := secrets[secret]; ok {
|
|
return fmt.Errorf("duplicate enroll secret found in %s", flFilename)
|
|
}
|
|
secrets[secret] = struct{}{}
|
|
}
|
|
}
|
|
|
|
err = fleetClient.SaveEnvSecrets(allFleetSecrets, config.FleetSecrets, flDryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assumptions, err := fleetClient.DoGitOps(c.Context, config, flFilename, logf, flDryRun, teamDryRunAssumptions, appConfig,
|
|
teamsSoftwareInstallers, teamsVPPApps, teamsScripts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if config.TeamName != nil {
|
|
teamNames = append(teamNames, *config.TeamName)
|
|
} else {
|
|
teamDryRunAssumptions = assumptions
|
|
}
|
|
}
|
|
|
|
// if there were assignments to tokens, and some of the teams were missing at that time, submit a separate patch request to set them now.
|
|
if len(abmTeams) > 0 && hasMissingABMTeam {
|
|
if err = applyABMTokenAssignmentIfNeeded(c, teamNames, abmTeams, originalABMConfig, usesLegacyABMConfig, flDryRun,
|
|
fleetClient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(vppTeams) > 0 && hasMissingVPPTeam {
|
|
if err = applyVPPTokenAssignmentIfNeeded(c, teamNames, vppTeams, originalVPPConfig, flDryRun, fleetClient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if flDeleteOtherTeams && appConfig.License.IsPremium() { // skip team deletion for non-premium users
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, team := range teams {
|
|
if !slices.Contains(teamNames, team.Name) {
|
|
if slices.Contains(abmTeams, team.Name) {
|
|
if usesLegacyABMConfig {
|
|
return fmt.Errorf("apple_bm_default_team %s cannot be deleted", team.Name)
|
|
}
|
|
return fmt.Errorf("apple_business_manager team %s cannot be deleted", team.Name)
|
|
}
|
|
if slices.Contains(vppTeams, team.Name) {
|
|
return fmt.Errorf("volume_purchasing_program team %s cannot be deleted", team.Name)
|
|
}
|
|
if flDryRun {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] would delete team %s\n", team.Name)
|
|
} else {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[-] deleting team %s\n", team.Name)
|
|
if err := fleetClient.DeleteTeam(team.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n")
|
|
} else {
|
|
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops succeeded\n")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func extractControlsForNoTeam(flFilenames cli.StringSlice, appConfig *fleet.EnrichedAppConfig) (spec.GitOpsControls, bool, error) {
|
|
for _, flFilename := range flFilenames.Value() {
|
|
if filepath.Base(flFilename) == "no-team.yml" {
|
|
if !appConfig.License.IsPremium() {
|
|
// Message is printed in the next flFilenames loop to avoid printing it multiple times
|
|
break
|
|
}
|
|
baseDir := filepath.Dir(flFilename)
|
|
config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, func(format string, a ...interface{}) {})
|
|
if err != nil {
|
|
return spec.GitOpsControls{}, false, err
|
|
}
|
|
return config.Controls, true, nil
|
|
}
|
|
}
|
|
return spec.GitOpsControls{}, false, nil
|
|
}
|
|
|
|
// checkABMTeamAssignments validates the spec, and finds if:
|
|
//
|
|
// 1. The user is using the legacy apple_bm_default_team config.
|
|
// 2. All teams assigned to ABM tokens already exist.
|
|
// 3. Performs validations according to the spec for both the new and the
|
|
// deprecated key used for this setting.
|
|
func checkABMTeamAssignments(config *spec.GitOps, fleetClient *service.Client) (
|
|
abmTeams []string, missingTeam bool, usesLegacyConfig bool, err error,
|
|
) {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
appleBMDT, hasLegacyConfig := mdmMap["apple_bm_default_team"]
|
|
appleBM, hasNewConfig := mdmMap["apple_business_manager"]
|
|
|
|
if hasLegacyConfig && hasNewConfig {
|
|
return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
abmToks, err := fleetClient.CountABMTokens()
|
|
if err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
|
|
if hasLegacyConfig && abmToks > 1 {
|
|
return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
if !hasLegacyConfig && !hasNewConfig {
|
|
return nil, false, false, nil
|
|
}
|
|
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
teamNames := map[string]struct{}{}
|
|
for _, tm := range teams {
|
|
teamNames[tm.Name] = struct{}{}
|
|
}
|
|
|
|
if hasLegacyConfig {
|
|
if appleBMDefaultTeam, ok := appleBMDT.(string); ok {
|
|
// normalize for Unicode support
|
|
appleBMDefaultTeam = norm.NFC.String(appleBMDefaultTeam)
|
|
abmTeams = append(abmTeams, appleBMDefaultTeam)
|
|
usesLegacyConfig = true
|
|
if _, ok = teamNames[appleBMDefaultTeam]; !ok {
|
|
missingTeam = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasNewConfig {
|
|
if settingMap, ok := appleBM.([]any); ok {
|
|
for _, item := range settingMap {
|
|
if cfg, ok := item.(map[string]any); ok {
|
|
for _, teamConfigKey := range []string{"macos_team", "ios_team", "ipados_team"} {
|
|
if team, ok := cfg[teamConfigKey].(string); ok && team != "" {
|
|
// normalize for Unicode support
|
|
team = norm.NFC.String(team)
|
|
abmTeams = append(abmTeams, team)
|
|
if _, ok := teamNames[team]; !ok {
|
|
missingTeam = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return abmTeams, missingTeam, usesLegacyConfig, nil
|
|
}
|
|
|
|
func applyABMTokenAssignmentIfNeeded(
|
|
ctx *cli.Context,
|
|
teamNames []string,
|
|
abmTeamNames []string,
|
|
originalMDMConfig []any,
|
|
usesLegacyConfig bool,
|
|
flDryRun bool,
|
|
fleetClient *service.Client,
|
|
) error {
|
|
if usesLegacyConfig && len(abmTeamNames) > 1 {
|
|
return errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage)
|
|
}
|
|
|
|
if usesLegacyConfig && len(abmTeamNames) == 0 {
|
|
return errors.New("using legacy config without any ABM teams defined")
|
|
}
|
|
|
|
var appConfigUpdate map[string]map[string]any
|
|
if usesLegacyConfig {
|
|
appleBMDefaultTeam := abmTeamNames[0]
|
|
if !slices.Contains(teamNames, appleBMDefaultTeam) {
|
|
return fmt.Errorf("apple_bm_default_team team %q not found in team configs", appleBMDefaultTeam)
|
|
}
|
|
appConfigUpdate = map[string]map[string]any{
|
|
"mdm": {
|
|
"apple_bm_default_team": appleBMDefaultTeam,
|
|
},
|
|
}
|
|
} else {
|
|
for _, abmTeam := range abmTeamNames {
|
|
if !slices.Contains(teamNames, abmTeam) {
|
|
return fmt.Errorf("apple_business_manager team %q not found in team configs", abmTeam)
|
|
}
|
|
}
|
|
|
|
appConfigUpdate = map[string]map[string]any{
|
|
"mdm": {
|
|
"apple_business_manager": originalMDMConfig,
|
|
},
|
|
}
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply ABM teams\n")
|
|
return nil
|
|
}
|
|
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying ABM teams\n")
|
|
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
|
|
return fmt.Errorf("applying fleet config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) (
|
|
vppTeams []string, missingTeam bool, err error,
|
|
) {
|
|
if mdm, ok := config.OrgSettings["mdm"]; ok {
|
|
if mdmMap, ok := mdm.(map[string]any); ok {
|
|
teams, err := fleetClient.ListTeams("")
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
teamNames := map[string]struct{}{}
|
|
for _, tm := range teams {
|
|
teamNames[tm.Name] = struct{}{}
|
|
}
|
|
|
|
if vpp, ok := mdmMap["volume_purchasing_program"]; ok {
|
|
if vppInterfaces, ok := vpp.([]any); ok {
|
|
for _, item := range vppInterfaces {
|
|
if itemMap, ok := item.(map[string]any); ok {
|
|
if teams, ok := itemMap["teams"].([]any); ok {
|
|
for _, team := range teams {
|
|
if teamStr, ok := team.(string); ok {
|
|
// normalize for Unicode support
|
|
normalizedTeam := norm.NFC.String(teamStr)
|
|
vppTeams = append(vppTeams, normalizedTeam)
|
|
if _, ok := teamNames[normalizedTeam]; !ok {
|
|
missingTeam = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return vppTeams, missingTeam, nil
|
|
}
|
|
|
|
func applyVPPTokenAssignmentIfNeeded(
|
|
ctx *cli.Context,
|
|
teamNames []string,
|
|
vppTeamNames []string,
|
|
originalVPPConfig []any,
|
|
flDryRun bool,
|
|
fleetClient *service.Client,
|
|
) error {
|
|
var appConfigUpdate map[string]map[string]any
|
|
for _, vppTeam := range vppTeamNames {
|
|
if !fleet.IsReservedTeamName(vppTeam) && !slices.Contains(teamNames, vppTeam) {
|
|
return fmt.Errorf("volume_purchasing_program team %s not found in team configs", vppTeam)
|
|
}
|
|
}
|
|
|
|
appConfigUpdate = map[string]map[string]any{
|
|
"mdm": {
|
|
"volume_purchasing_program": originalVPPConfig,
|
|
},
|
|
}
|
|
|
|
if flDryRun {
|
|
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply volume_purchasing_program teams\n")
|
|
return nil
|
|
}
|
|
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying volume_purchasing_program teams\n")
|
|
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
|
|
return fmt.Errorf("applying fleet config for volume_purchasing_program teams: %w", err)
|
|
}
|
|
return nil
|
|
}
|