fleet/cmd/fleetctl/gitops.go
Victor Lyuboslavsky 4f4800be19
GitOps remove teams (#18640)
#16677 

Improvements to `fleetctl gitops` command:
- Added the ability to pass multiple files, like `fleetctl gitops -f
file1 -f file2`, where the first file must be the global configuration
- Added the ability to remove teams that were not specified in team
configs using the switch `--delete-other-teams`
- When passing a global config and team config during initial
configuration, the `org_settings.mdm.apple_bm_default_team` value can be
set to match the team that will be created by the provided team config.

After these changes are released to prod, we can update
https://github.com/fleetdm/fleet-gitops to use the new switches: #18692

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
2024-05-03 08:03:00 -05:00

215 lines
6.6 KiB
Go

package main
import (
"errors"
"fmt"
"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"
"os"
"path/filepath"
"slices"
"strings"
)
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")
}
}
// 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")
}
var appleBMDefaultTeam string
var appleBMDefaultTeamFound bool
var teamNames []string
var firstFileMustBeGlobal *bool
var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions
if totalFilenames > 1 {
firstFileMustBeGlobal = ptr.Bool(true)
}
for _, flFilename := range flFilenames.Value() {
b, err := os.ReadFile(flFilename)
if err != nil {
return err
}
baseDir := filepath.Dir(flFilename)
config, err := spec.GitOpsFromBytes(b, baseDir)
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 && totalFilenames > 1 {
// Check if Apple BM default team already exists
appleBMDefaultTeam, appleBMDefaultTeamFound, err = checkAppleBMDefaultTeam(config, fleetClient)
if err != nil {
return err
}
}
logf := func(format string, a ...interface{}) {
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
}
assumptions, err := fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun, teamDryRunAssumptions, appConfig)
if err != nil {
return err
}
if config.TeamName != nil {
teamNames = append(teamNames, *config.TeamName)
} else {
teamDryRunAssumptions = assumptions
}
}
if appleBMDefaultTeam != "" && !appleBMDefaultTeamFound {
// If the Apple BM default team did not exist earlier, check again and apply it if needed
err = applyAppleBMDefaultTeamIfNeeded(c, teamNames, appleBMDefaultTeam, flDryRun, fleetClient)
if err != nil {
return err
}
}
if flDeleteOtherTeams {
teams, err := fleetClient.ListTeams("")
if err != nil {
return err
}
for _, team := range teams {
if !slices.Contains(teamNames, team.Name) {
if appleBMDefaultTeam == team.Name {
return fmt.Errorf("apple_bm_default_team %s cannot be deleted", appleBMDefaultTeam)
}
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 checkAppleBMDefaultTeam(config *spec.GitOps, fleetClient *service.Client) (
appleBMDefaultTeam string, appleBMDefaultTeamFound bool, err error,
) {
if mdm, ok := config.OrgSettings["mdm"]; ok {
if mdmMap, ok := mdm.(map[string]interface{}); ok {
if appleBMDT, ok := mdmMap["apple_bm_default_team"]; ok {
if appleBMDefaultTeam, ok = appleBMDT.(string); ok {
teams, err := fleetClient.ListTeams("")
if err != nil {
return "", false, err
}
// Normalize AppleBMDefaultTeam for Unicode support
appleBMDefaultTeam = norm.NFC.String(appleBMDefaultTeam)
for _, team := range teams {
if team.Name == appleBMDefaultTeam {
appleBMDefaultTeamFound = true
break
}
}
if !appleBMDefaultTeamFound {
// 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_bm_default_team"] = ""
}
}
}
}
}
return appleBMDefaultTeam, appleBMDefaultTeamFound, nil
}
func applyAppleBMDefaultTeamIfNeeded(
ctx *cli.Context, teamNames []string, appleBMDefaultTeam string, flDryRun bool, fleetClient *service.Client,
) error {
if !slices.Contains(teamNames, appleBMDefaultTeam) {
return fmt.Errorf("apple_bm_default_team %s not found in team configs", appleBMDefaultTeam)
}
appConfigUpdate := map[string]map[string]interface{}{
"mdm": {
"apple_bm_default_team": appleBMDefaultTeam,
},
}
if flDryRun {
_, _ = fmt.Fprintf(ctx.App.Writer, "[!] would apply apple_bm_default_team %s\n", appleBMDefaultTeam)
} else {
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying apple_bm_default_team %s\n", appleBMDefaultTeam)
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
return fmt.Errorf("applying fleet config: %w", err)
}
}
return nil
}