Skip to content

Commit

Permalink
GitOps remove teams (#18640)
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
getvictor authored May 3, 2024
1 parent 0289ea2 commit 4f4800b
Show file tree
Hide file tree
Showing 21 changed files with 773 additions and 104 deletions.
4 changes: 4 additions & 0 deletions changes/18640-gitops-remove-teams
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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.
181 changes: 159 additions & 22 deletions cmd/fleetctl/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,154 @@ 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 (
flFilename string
flDryRun bool
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.StringFlag{
&cli.StringSliceFlag{
Name: "f",
Required: true,
EnvVars: []string{"FILENAME"},
Value: "",
Destination: &flFilename,
Usage: "The file with the GitOps configuration",
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, just validate it",
Usage: "Do not apply the file(s), just validate",
},
configFlag(),
contextFlag(),
debugFlag(),
},
Action: func(c *cli.Context) error {
if flFilename == "" {
totalFilenames := len(flFilenames.Value())
if totalFilenames == 0 {
return errors.New("-f must be specified")
}
b, err := os.ReadFile(flFilename)
if err != nil {
return err
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
}
baseDir := filepath.Dir(flFilename)
config, err := spec.GitOpsFromBytes(b, baseDir)
if err != nil {
return err
}
logf := func(format string, a ...interface{}) {
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
}
appConfig, err := fleetClient.GetAppConfig()
if err != nil {
return err
}
if appConfig.License == nil {
return errors.New("no license struct found in app config")
}
err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun, appConfig)
if err != nil {
return err

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 {
Expand All @@ -76,3 +161,55 @@ func gitopsCommand() *cli.Command {
},
}
}

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
}
74 changes: 65 additions & 9 deletions cmd/fleetctl/gitops_enterprise_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path"
"path/filepath"
"strings"
"testing"

"github.com/fleetdm/fleet/v4/server/config"
Expand All @@ -17,6 +18,7 @@ import (
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
Expand Down Expand Up @@ -146,21 +148,75 @@ contexts:
teamsDir := path.Join(repoDir, "teams")
teamFiles, err := os.ReadDir(teamsDir)
require.NoError(t, err)

// Dry run
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"})
teamFileNames := make([]string, 0, len(teamFiles))
for _, file := range teamFiles {
if filepath.Ext(file.Name()) == ".yml" {
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", path.Join(teamsDir, file.Name()), "--dry-run"})
teamFileNames = append(teamFileNames, path.Join(teamsDir, file.Name()))
}
}

// Real run
// Create a team to be deleted.
deletedTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
const deletedTeamName = "team_to_be_deleted"

_, err = deletedTeamFile.WriteString(
fmt.Sprintf(
`
controls:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"deleted_team_secret"}]
`, deletedTeamName,
),
)
require.NoError(t, err)
// Apply the team to be deleted
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()})

// Dry run
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"})
for _, fileName := range teamFileNames {
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "--dry-run"})
}

// Dry run with all the files
args := []string{"gitops", "--config", fleetctlConfig.Name(), "--dry-run", "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
_ = runAppForTest(t, args)

// Real run with all the files, but don't delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
_ = runAppForTest(t, args)

// Check that all the teams exist
teamsJSON := runAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 3, strings.Count(teamsJSON, "team_id"))

// Real run with all the files, and delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
_ = runAppForTest(t, args)

// Check that only the right teams exist
teamsJSON = runAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 2, strings.Count(teamsJSON, "team_id"))
assert.NotContains(t, teamsJSON, deletedTeamName)

// Real run with one file at a time
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile})
for _, file := range teamFiles {
if filepath.Ext(file.Name()) == ".yml" {
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", path.Join(teamsDir, file.Name())})
}
for _, fileName := range teamFileNames {
_ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName})
}

}
Loading

0 comments on commit 4f4800b

Please sign in to comment.