From dfe6b539630198c4a5fba08861ca858c380770e4 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 2 Feb 2023 14:46:50 -0300 Subject: [PATCH] Make `fleetctl get teams --yaml` output compatible with `fleetctl apply -f` (#9626) #9535 - [X] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - ~[ ] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md)~ - ~[ ] Documented any permissions changes~ - ~[ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements)~ - ~[ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.~ - [X] Added/updated tests - [X] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ --- changes/9535-change-get-teams-yaml-output | 1 + cmd/fleetctl/get.go | 32 ++++- cmd/fleetctl/get_test.go | 161 ++++++++++++++++------ server/fleet/teams.go | 29 +++- 4 files changed, 172 insertions(+), 51 deletions(-) create mode 100644 changes/9535-change-get-teams-yaml-output diff --git a/changes/9535-change-get-teams-yaml-output b/changes/9535-change-get-teams-yaml-output new file mode 100644 index 0000000000..b726fa8a52 --- /dev/null +++ b/changes/9535-change-get-teams-yaml-output @@ -0,0 +1 @@ +* Make `fleetctl get teams --yaml` output compatible with `fleetctl apply -f`. diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 49a59ea5e0..28ee714fc6 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -237,11 +237,19 @@ func printUserRoles(c *cli.Context, users []fleet.User) error { func printTeams(c *cli.Context, teams []fleet.Team) error { for _, team := range teams { + var teamItem interface{} = team + if c.Bool(yamlFlagName) { + teamSpec, err := fleet.TeamSpecFromTeam(&team) + if err != nil { + return err + } + teamItem = teamSpec + } spec := specGeneric{ Kind: fleet.TeamKind, Version: fleet.ApiVersion, Spec: map[string]interface{}{ - "team": team, + "team": teamItem, }, } @@ -907,14 +915,28 @@ func printKeyValueTable(c *cli.Context, rows [][]string) { table.Render() } +func getTeamsJSONFlag() cli.Flag { + return &cli.BoolFlag{ + Name: jsonFlagName, + Usage: "Output all team information in JSON format", + } +} + +func getTeamsYAMLFlag() cli.Flag { + return &cli.BoolFlag{ + Name: yamlFlagName, + Usage: "Output team configuration in yaml format. Intended for use with \"fleetctl apply -f\"", + } +} + func getTeamsCommand() *cli.Command { return &cli.Command{ Name: "teams", Aliases: []string{"t"}, Usage: "List teams", Flags: []cli.Flag{ - jsonFlag(), - yamlFlag(), + getTeamsJSONFlag(), + getTeamsYAMLFlag(), configFlag(), contextFlag(), debugFlag(), @@ -959,11 +981,11 @@ func getTeamsCommand() *cli.Command { for _, team := range teams { data = append(data, []string{ team.Name, - team.Description, + fmt.Sprintf("%d", team.HostCount), fmt.Sprintf("%d", team.UserCount), }) } - columns := []string{"Team Name", "Description", "User count"} + columns := []string{"Team name", "Host count", "User count"} printTable(c, columns, data) return nil diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 08a3eebe1e..8f5f756beb 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "io/ioutil" "path/filepath" "strings" @@ -130,6 +131,7 @@ func TestGetTeams(t *testing.T) { Name: "team1", Description: "team1 description", UserCount: 99, + HostCount: 42, Config: fleet.TeamConfig{ Features: fleet.Features{ EnableHostUsers: true, @@ -143,6 +145,7 @@ func TestGetTeams(t *testing.T) { Name: "team2", Description: "team2 description", UserCount: 87, + HostCount: 43, Config: fleet.TeamConfig{ AgentOptions: &agentOpts, Features: fleet.Features{ @@ -159,41 +162,27 @@ func TestGetTeams(t *testing.T) { }, nil } - expectedText := `+-----------+-------------------+------------+ -| TEAM NAME | DESCRIPTION | USER COUNT | -+-----------+-------------------+------------+ -| team1 | team1 description | 99 | -+-----------+-------------------+------------+ -| team2 | team2 description | 87 | -+-----------+-------------------+------------+ + expectedText := `+-----------+------------+------------+ +| TEAM NAME | HOST COUNT | USER COUNT | ++-----------+------------+------------+ +| team1 | 42 | 99 | ++-----------+------------+------------+ +| team2 | 43 | 87 | ++-----------+------------+------------+ ` expectedYaml := `--- apiVersion: v1 kind: team spec: team: - created_at: "1999-03-10T02:45:06.371Z" - description: team1 description features: enable_host_users: true enable_software_inventory: true - host_count: 0 - id: 42 - integrations: - jira: null - zendesk: null mdm: macos_updates: minimum_version: "" deadline: "" name: team1 - user_count: 99 - webhook_settings: - failing_policies_webhook: - destination_url: "" - enable_failing_policies_webhook: false - host_batch_size: 0 - policy_ids: null --- apiVersion: v1 kind: team @@ -206,43 +195,35 @@ spec: platforms: darwin: foo: override - created_at: "1999-03-10T02:45:06.371Z" - description: team2 description features: additional_queries: foo: bar enable_host_users: false enable_software_inventory: false - host_count: 0 - id: 43 - integrations: - jira: null - zendesk: null mdm: macos_updates: minimum_version: "12.3.1" deadline: "2021-12-14" name: team2 - user_count: 87 - webhook_settings: - failing_policies_webhook: - destination_url: "" - enable_failing_policies_webhook: false - host_batch_size: 0 - policy_ids: null ` - expectedJson := `{"kind":"team","apiVersion":"v1","spec":{"team":{"id":42,"created_at":"1999-03-10T02:45:06.371Z","name":"team1","description":"team1 description","webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"integrations":{"jira":null,"zendesk":null},"features":{"enable_host_users":true,"enable_software_inventory":true},"mdm":{"macos_updates":{"minimum_version":"","deadline":""}},"user_count":99,"host_count":0}}} -{"kind":"team","apiVersion":"v1","spec":{"team":{"id":43,"created_at":"1999-03-10T02:45:06.371Z","name":"team2","description":"team2 description","agent_options":{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}},"webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"integrations":{"jira":null,"zendesk":null},"features":{"enable_host_users":false,"enable_software_inventory":false,"additional_queries":{"foo":"bar"}},"mdm":{"macos_updates":{"minimum_version":"12.3.1","deadline":"2021-12-14"}},"user_count":87,"host_count":0}}} + expectedJson := `{"kind":"team","apiVersion":"v1","spec":{"team":{"id":42,"created_at":"1999-03-10T02:45:06.371Z","name":"team1","description":"team1 description","webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"integrations":{"jira":null,"zendesk":null},"features":{"enable_host_users":true,"enable_software_inventory":true},"mdm":{"macos_updates":{"minimum_version":"","deadline":""}},"user_count":99,"host_count":42}}} +{"kind":"team","apiVersion":"v1","spec":{"team":{"id":43,"created_at":"1999-03-10T02:45:06.371Z","name":"team2","description":"team2 description","agent_options":{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}},"webhook_settings":{"failing_policies_webhook":{"enable_failing_policies_webhook":false,"destination_url":"","policy_ids":null,"host_batch_size":0}},"integrations":{"jira":null,"zendesk":null},"features":{"enable_host_users":false,"enable_software_inventory":false,"additional_queries":{"foo":"bar"}},"mdm":{"macos_updates":{"minimum_version":"12.3.1","deadline":"2021-12-14"}},"user_count":87,"host_count":43}}} ` + if tt.shouldHaveExpiredBanner { expectedJson = expiredBanner.String() + expectedJson - expectedYaml = expiredBanner.String() + expectedYaml expectedText = expiredBanner.String() + expectedText } - assert.YAMLEq(t, expectedText, runAppForTest(t, []string{"get", "teams"})) - assert.YAMLEq(t, expectedYaml, runAppForTest(t, []string{"get", "teams", "--yaml"})) + assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "teams"})) assert.Equal(t, expectedJson, runAppForTest(t, []string{"get", "teams", "--json"})) + + actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"}) + if tt.shouldHaveExpiredBanner { + require.True(t, strings.HasPrefix(actualYaml, expiredBanner.String())) + actualYaml = strings.TrimPrefix(actualYaml, expiredBanner.String()) + } + assert.YAMLEq(t, expectedYaml, actualYaml) }) } } @@ -262,15 +243,16 @@ func TestGetTeamsByName(t *testing.T) { Name: "team1", Description: "team1 description", UserCount: 99, + HostCount: 43, }, }, nil } - expectedText := `+-----------+-------------------+------------+ -| TEAM NAME | DESCRIPTION | USER COUNT | -+-----------+-------------------+------------+ -| team1 | team1 description | 99 | -+-----------+-------------------+------------+ + expectedText := `+-----------+------------+------------+ +| TEAM NAME | HOST COUNT | USER COUNT | ++-----------+------------+------------+ +| team1 | 43 | 99 | ++-----------+------------+------------+ ` assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "teams", "--name", "test1"})) } @@ -1665,3 +1647,92 @@ func TestGetCarveWithError(t *testing.T) { runAppCheckErr(t, []string{"get", "carve", "1"}, "test error") } + +// TestGetTeamsYAMLAndApply checks that the output of `get teams --yaml` can be applied +// via the `apply` command. +func TestGetTeamsYAMLAndApply(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ + License: &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}, + }) + + created_at, err := time.Parse(time.RFC3339, "1999-03-10T02:45:06.371Z") + require.NoError(t, err) + agentOpts := json.RawMessage(` +{ + "config": { + "options": { + "distributed_interval": 10 + } + }, + "overrides": { + "platforms": { + "darwin": { + "options": { + "distributed_interval": 5 + } + } + } + } +}`) + additionalQueries := json.RawMessage(`{"time":"SELECT * FROM time;"}`) + team1 := &fleet.Team{ + ID: 42, + CreatedAt: created_at, + Name: "team1", + Description: "team1 description", + UserCount: 99, + Config: fleet.TeamConfig{ + Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }, + }, + } + team2 := &fleet.Team{ + ID: 43, + CreatedAt: created_at, + Name: "team2", + Description: "team2 description", + UserCount: 87, + Config: fleet.TeamConfig{ + AgentOptions: &agentOpts, + Features: fleet.Features{ + AdditionalQueries: &additionalQueries, + }, + MDM: fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: "12.3.1", + Deadline: "2021-12-14", + }, + }, + }, + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{team1, team2}, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{AgentOptions: &agentOpts}, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + return team, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + return nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == "team1" { + return team1, nil + } else if name == "team2" { + return team2, nil + } + return nil, fmt.Errorf("team not found: %s", name) + } + + actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"}) + yamlFilePath := writeTmpYml(t, actualYaml) + + require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath})) +} diff --git a/server/fleet/teams.go b/server/fleet/teams.go index efd319a292..95dad61282 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -268,7 +268,34 @@ type TeamSpec struct { // set to the agent options JSON object. AgentOptions json.RawMessage `json:"agent_options,omitempty"` // marshals as "null" if omitempty is not set - Secrets []EnrollSecret `json:"secrets"` + Secrets []EnrollSecret `json:"secrets,omitempty"` Features *json.RawMessage `json:"features"` MDM TeamMDM `json:"mdm"` } + +// TeamSpecFromTeam returns a TeamSpec constructed from the given Team. +func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { + features, err := json.Marshal(t.Config.Features) + if err != nil { + return nil, err + } + featuresJSON := json.RawMessage(features) + var secrets []EnrollSecret + if len(t.Secrets) > 0 { + secrets = make([]EnrollSecret, 0, len(t.Secrets)) + for _, secret := range t.Secrets { + secrets = append(secrets, *secret) + } + } + var agentOptions json.RawMessage + if t.Config.AgentOptions != nil { + agentOptions = *t.Config.AgentOptions + } + return &TeamSpec{ + Name: t.Name, + AgentOptions: agentOptions, + Features: &featuresJSON, + Secrets: secrets, + MDM: t.Config.MDM, + }, nil +}