diff --git a/lib/services/presets.go b/lib/services/presets.go index 9c534d64b1d22..61658a7ddb91c 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -196,6 +196,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindWorkloadIdentity, RW()), types.NewRule(types.KindAutoUpdateVersion, RW()), types.NewRule(types.KindAutoUpdateConfig, RW()), + types.NewRule(types.KindAutoUpdateAgentRollout, RO()), }, }, }, diff --git a/tool/tctl/common/autoupdate_command.go b/tool/tctl/common/autoupdate_command.go index c089010c091f4..11593a3ab24bc 100644 --- a/tool/tctl/common/autoupdate_command.go +++ b/tool/tctl/common/autoupdate_command.go @@ -23,6 +23,8 @@ import ( "fmt" "io" "os" + "strings" + "time" "github.com/alecthomas/kingpin/v2" "github.com/coreos/go-semver/semver" @@ -32,7 +34,7 @@ import ( "github.com/gravitational/teleport/api/client/webclient" autoupdatev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" "github.com/gravitational/teleport/api/types/autoupdate" - "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" commonclient "github.com/gravitational/teleport/tool/tctl/common/client" @@ -48,10 +50,11 @@ type AutoUpdateCommand struct { app *kingpin.Application ccf *tctlcfg.GlobalCLIFlags - targetCmd *kingpin.CmdClause - enableCmd *kingpin.CmdClause - disableCmd *kingpin.CmdClause - statusCmd *kingpin.CmdClause + toolsTargetCmd *kingpin.CmdClause + toolsEnableCmd *kingpin.CmdClause + toolsDisableCmd *kingpin.CmdClause + toolsStatusCmd *kingpin.CmdClause + agentsStatusCmd *kingpin.CmdClause toolsTargetVersion string proxy string @@ -71,16 +74,19 @@ func (c *AutoUpdateCommand) Initialize(app *kingpin.Application, ccf *tctlcfg.Gl clientToolsCmd := autoUpdateCmd.Command("client-tools", "Manage client tools auto update configuration.") - c.statusCmd = clientToolsCmd.Command("status", "Prints if the client tools updates are enabled/disabled, and the target version in specified format.") - c.statusCmd.Flag("proxy", "Address of the Teleport proxy. When defined this address will be used to retrieve client tools auto update configuration.").StringVar(&c.proxy) - c.statusCmd.Flag("format", "Output format: 'yaml' or 'json'").Default(teleport.YAML).StringVar(&c.format) + c.toolsStatusCmd = clientToolsCmd.Command("status", "Prints if the client tools updates are enabled/disabled, and the target version in specified format.") + c.toolsStatusCmd.Flag("proxy", "Address of the Teleport proxy. When defined this address will be used to retrieve client tools auto update configuration.").StringVar(&c.proxy) + c.toolsStatusCmd.Flag("format", "Output format: 'yaml' or 'json'").Default(teleport.YAML).StringVar(&c.format) - c.enableCmd = clientToolsCmd.Command("enable", "Enables client tools auto updates. Clients will be told to update to the target version.") - c.disableCmd = clientToolsCmd.Command("disable", "Disables client tools auto updates. Clients will not be told to update to the target version.") + c.toolsEnableCmd = clientToolsCmd.Command("enable", "Enables client tools auto updates. Clients will be told to update to the target version.") + c.toolsDisableCmd = clientToolsCmd.Command("disable", "Disables client tools auto updates. Clients will not be told to update to the target version.") - c.targetCmd = clientToolsCmd.Command("target", "Sets the client tools target version. This command is not supported on Teleport Cloud.") - c.targetCmd.Arg("version", "Client tools target version. Clients will be told to update to this version.").StringVar(&c.toolsTargetVersion) - c.targetCmd.Flag("clear", "removes the target version, Teleport will default to its current proxy version.").BoolVar(&c.clear) + c.toolsTargetCmd = clientToolsCmd.Command("target", "Sets the client tools target version. This command is not supported on Teleport Cloud.") + c.toolsTargetCmd.Arg("version", "Client tools target version. Clients will be told to update to this version.").StringVar(&c.toolsTargetVersion) + c.toolsTargetCmd.Flag("clear", "removes the target version, Teleport will default to its current proxy version.").BoolVar(&c.clear) + + agentsCmd := autoUpdateCmd.Command("agents", "Manage agents auto update configuration.") + c.agentsStatusCmd = agentsCmd.Command("status", "Prints agents auto update status.") if c.stdout == nil { c.stdout = os.Stdout @@ -89,19 +95,21 @@ func (c *AutoUpdateCommand) Initialize(app *kingpin.Application, ccf *tctlcfg.Gl // TryRun takes the CLI command as an argument and executes it. func (c *AutoUpdateCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) { - var commandFunc func(ctx context.Context, client *authclient.Client) error + var commandFunc func(ctx context.Context, client autoupdateClient) error switch { - case cmd == c.targetCmd.FullCommand(): + case cmd == c.toolsTargetCmd.FullCommand(): commandFunc = c.TargetVersion - case cmd == c.enableCmd.FullCommand(): + case cmd == c.toolsEnableCmd.FullCommand(): commandFunc = c.SetModeCommand(true) - case cmd == c.disableCmd.FullCommand(): + case cmd == c.toolsDisableCmd.FullCommand(): commandFunc = c.SetModeCommand(false) - case c.proxy == "" && cmd == c.statusCmd.FullCommand(): - commandFunc = c.Status - case c.proxy != "" && cmd == c.statusCmd.FullCommand(): - err = c.StatusByProxy(ctx) + case c.proxy == "" && cmd == c.toolsStatusCmd.FullCommand(): + commandFunc = c.ToolsStatus + case c.proxy != "" && cmd == c.toolsStatusCmd.FullCommand(): + err = c.ToolsStatusByProxy(ctx) return true, trace.Wrap(err) + case cmd == c.agentsStatusCmd.FullCommand(): + commandFunc = c.agentsStatusCommand default: return false, nil } @@ -117,17 +125,17 @@ func (c *AutoUpdateCommand) TryRun(ctx context.Context, cmd string, clientFunc c } // TargetVersion creates or updates AutoUpdateVersion resource with client tools target version. -func (c *AutoUpdateCommand) TargetVersion(ctx context.Context, client *authclient.Client) error { +func (c *AutoUpdateCommand) TargetVersion(ctx context.Context, client autoupdateClient) error { var err error switch { case c.clear: - err = c.clearTargetVersion(ctx, client) + err = c.clearToolsTargetVersion(ctx, client) case c.toolsTargetVersion != "": // For parallel requests where we attempt to create a resource simultaneously, retries should be implemented. // The same approach applies to updates if the resource has been deleted during the process. // Second create request must return `AlreadyExists` error, update for deleted resource `NotFound` error. for i := 0; i < maxRetries; i++ { - err = c.setTargetVersion(ctx, client) + err = c.setToolsTargetVersion(ctx, client) if err == nil { break } @@ -140,13 +148,13 @@ func (c *AutoUpdateCommand) TargetVersion(ctx context.Context, client *authclien } // SetModeCommand returns a command to enable or disable client tools auto-updates in the cluster. -func (c *AutoUpdateCommand) SetModeCommand(enabled bool) func(ctx context.Context, client *authclient.Client) error { - return func(ctx context.Context, client *authclient.Client) error { +func (c *AutoUpdateCommand) SetModeCommand(enabled bool) func(ctx context.Context, client autoupdateClient) error { + return func(ctx context.Context, client autoupdateClient) error { // For parallel requests where we attempt to create a resource simultaneously, retries should be implemented. // The same approach applies to updates if the resource has been deleted during the process. // Second create request must return `AlreadyExists` error, update for deleted resource `NotFound` error. for i := 0; i < maxRetries; i++ { - err := c.setMode(ctx, client, enabled) + err := c.setToolsMode(ctx, client, enabled) if err == nil { break } @@ -164,8 +172,95 @@ type getResponse struct { TargetVersion string `json:"target_version"` } -// Status makes request to auth service to fetch client tools auto update version and mode. -func (c *AutoUpdateCommand) Status(ctx context.Context, client *authclient.Client) error { +// autoupdateClient is a subset of the Teleport client, with functions used to interact with automatic update resources. +// Not every AU function is part of the interface, we'll add them as we need. +type autoupdateClient interface { + GetAutoUpdateAgentRollout(context.Context) (*autoupdatev1pb.AutoUpdateAgentRollout, error) + GetAutoUpdateVersion(context.Context) (*autoupdatev1pb.AutoUpdateVersion, error) + GetAutoUpdateConfig(context.Context) (*autoupdatev1pb.AutoUpdateConfig, error) + CreateAutoUpdateConfig(context.Context, *autoupdatev1pb.AutoUpdateConfig) (*autoupdatev1pb.AutoUpdateConfig, error) + CreateAutoUpdateVersion(context.Context, *autoupdatev1pb.AutoUpdateVersion) (*autoupdatev1pb.AutoUpdateVersion, error) + UpdateAutoUpdateConfig(context.Context, *autoupdatev1pb.AutoUpdateConfig) (*autoupdatev1pb.AutoUpdateConfig, error) + UpdateAutoUpdateVersion(context.Context, *autoupdatev1pb.AutoUpdateVersion) (*autoupdatev1pb.AutoUpdateVersion, error) +} + +func (c *AutoUpdateCommand) agentsStatusCommand(ctx context.Context, client autoupdateClient) error { + rollout, err := client.GetAutoUpdateAgentRollout(ctx) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + + sb := strings.Builder{} + if rollout.GetSpec() == nil { + sb.WriteString("No active agent rollout (autoupdate_agent_rollout).\n") + } + if mode := rollout.GetSpec().GetAutoupdateMode(); mode != "" { + sb.WriteString("Agent autoupdate mode: " + mode + "\n") + } + if st := formatTimeIfNotEmpty(rollout.GetStatus().GetStartTime().AsTime(), time.DateTime); st != "" { + sb.WriteString("Rollout creation date: " + st + "\n") + } + if start := rollout.GetSpec().GetStartVersion(); start != "" { + sb.WriteString("Start version: " + start + "\n") + } + if target := rollout.GetSpec().GetTargetVersion(); target != "" { + sb.WriteString("Target version: " + target + "\n") + } + if state := rollout.GetStatus().GetState(); state != autoupdatev1pb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_UNSPECIFIED { + sb.WriteString("Rollout state: " + userFriendlyState(state) + "\n") + } + if schedule := rollout.GetSpec().GetSchedule(); schedule == autoupdate.AgentsScheduleImmediate { + sb.WriteString("Schedule is immediate. Every group immediately updates to the target version.\n") + } + if strategy := rollout.GetSpec().GetStrategy(); strategy != "" { + sb.WriteString("Strategy: " + strategy + "\n") + } + + if groups := rollout.GetStatus().GetGroups(); len(groups) > 0 { + sb.WriteRune('\n') + headers := []string{"Group Name", "State", "Start Time", "State Reason"} + table := asciitable.MakeTable(headers) + for _, group := range groups { + table.AddRow([]string{ + group.GetName(), + userFriendlyState(group.GetState()), + formatTimeIfNotEmpty(group.GetStartTime().AsTime(), time.DateTime), + group.GetLastUpdateReason()}) + } + sb.Write(table.AsBuffer().Bytes()) + } + + fmt.Fprint(c.stdout, sb.String()) + return nil +} + +func formatTimeIfNotEmpty(t time.Time, format string) string { + if t.IsZero() || t.Unix() == 0 { + return "" + } + return t.Format(format) +} + +func userFriendlyState[T autoupdatev1pb.AutoUpdateAgentGroupState | autoupdatev1pb.AutoUpdateAgentRolloutState](state T) string { + switch state { + case 0: + return "Unknown" + case 1: + return "Unstarted" + case 2: + return "Active" + case 3: + return "Done" + case 4: + return "Rolledback" + default: + // If we don't know anything about this state, we display its integer + return fmt.Sprintf("Unknown state (%d)", state) + } +} + +// ToolsStatus makes request to auth service to fetch client tools auto update version and mode. +func (c *AutoUpdateCommand) ToolsStatus(ctx context.Context, client autoupdateClient) error { var response getResponse config, err := client.GetAutoUpdateConfig(ctx) if err != nil && !trace.IsNotFound(err) { @@ -183,12 +278,12 @@ func (c *AutoUpdateCommand) Status(ctx context.Context, client *authclient.Clien response.TargetVersion = version.Spec.Tools.TargetVersion } - return c.printResponse(response) + return c.printToolsResponse(response) } -// StatusByProxy makes request to `webapi/find` endpoint to fetch tools auto update version and mode +// ToolsStatusByProxy makes request to `webapi/find` endpoint to fetch tools auto update version and mode // without authentication. -func (c *AutoUpdateCommand) StatusByProxy(ctx context.Context) error { +func (c *AutoUpdateCommand) ToolsStatusByProxy(ctx context.Context) error { find, err := webclient.Find(&webclient.Config{ Context: ctx, ProxyAddr: c.proxy, @@ -201,13 +296,13 @@ func (c *AutoUpdateCommand) StatusByProxy(ctx context.Context) error { if find.AutoUpdate.ToolsAutoUpdate { mode = autoupdate.ToolsUpdateModeEnabled } - return c.printResponse(getResponse{ + return c.printToolsResponse(getResponse{ TargetVersion: find.AutoUpdate.ToolsVersion, Mode: mode, }) } -func (c *AutoUpdateCommand) setMode(ctx context.Context, client *authclient.Client, enabled bool) error { +func (c *AutoUpdateCommand) setToolsMode(ctx context.Context, client autoupdateClient, enabled bool) error { setMode := client.UpdateAutoUpdateConfig config, err := client.GetAutoUpdateConfig(ctx) if trace.IsNotFound(err) { @@ -235,7 +330,7 @@ func (c *AutoUpdateCommand) setMode(ctx context.Context, client *authclient.Clie return nil } -func (c *AutoUpdateCommand) setTargetVersion(ctx context.Context, client *authclient.Client) error { +func (c *AutoUpdateCommand) setToolsTargetVersion(ctx context.Context, client autoupdateClient) error { if _, err := semver.NewVersion(c.toolsTargetVersion); err != nil { return trace.WrapWithMessage(err, "not semantic version") } @@ -262,7 +357,7 @@ func (c *AutoUpdateCommand) setTargetVersion(ctx context.Context, client *authcl return nil } -func (c *AutoUpdateCommand) clearTargetVersion(ctx context.Context, client *authclient.Client) error { +func (c *AutoUpdateCommand) clearToolsTargetVersion(ctx context.Context, client autoupdateClient) error { version, err := client.GetAutoUpdateVersion(ctx) if trace.IsNotFound(err) { return nil @@ -279,7 +374,7 @@ func (c *AutoUpdateCommand) clearTargetVersion(ctx context.Context, client *auth return nil } -func (c *AutoUpdateCommand) printResponse(response getResponse) error { +func (c *AutoUpdateCommand) printToolsResponse(response getResponse) error { switch c.format { case teleport.JSON: if err := utils.WriteJSON(c.stdout, response); err != nil { diff --git a/tool/tctl/common/autoupdate_command_test.go b/tool/tctl/common/autoupdate_command_test.go index 31d2782fbc335..164943b08b8ec 100644 --- a/tool/tctl/common/autoupdate_command_test.go +++ b/tool/tctl/common/autoupdate_command_test.go @@ -22,12 +22,18 @@ import ( "bytes" "context" "testing" + "time" "github.com/gravitational/trace" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport/api/breaker" + autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" + "github.com/gravitational/teleport/api/types/autoupdate" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" @@ -116,3 +122,183 @@ func runAutoUpdateCommand(t *testing.T, client *authclient.Client, args []string }) return &stdoutBuff, err } + +type mockRolloutClient struct { + authclient.Client + mock.Mock +} + +func (m *mockRolloutClient) GetAutoUpdateAgentRollout(_ context.Context) (*autoupdatepb.AutoUpdateAgentRollout, error) { + args := m.Called() + return args.Get(0).(*autoupdatepb.AutoUpdateAgentRollout), args.Error(1) +} + +func TestAutoUpdateAgentStatusCommand(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + fixture *autoupdatepb.AutoUpdateAgentRollout + fixtureErr error + expectedOutput string + }{ + { + name: "no rollout", + fixture: nil, + fixtureErr: trace.NotFound("no rollout found"), + expectedOutput: "No active agent rollout (autoupdate_agent_rollout).\n", + }, + { + name: "rollout immediate schedule", + fixture: &autoupdatepb.AutoUpdateAgentRollout{ + Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{ + StartVersion: "1.2.3", + TargetVersion: "1.2.4", + Schedule: autoupdate.AgentsScheduleImmediate, + AutoupdateMode: autoupdate.AgentsUpdateModeEnabled, + }, + }, + expectedOutput: `Agent autoupdate mode: enabled +Start version: 1.2.3 +Target version: 1.2.4 +Schedule is immediate. Every group immediately updates to the target version. +`, + }, + { + name: "rollout regular schedule time-based", + fixture: &autoupdatepb.AutoUpdateAgentRollout{ + Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{ + StartVersion: "1.2.3", + TargetVersion: "1.2.4", + Schedule: autoupdate.AgentsScheduleRegular, + AutoupdateMode: autoupdate.AgentsUpdateModeEnabled, + Strategy: autoupdate.AgentsStrategyTimeBased, + MaintenanceWindowDuration: durationpb.New(1 * time.Hour), + }, + Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{ + Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{ + { + Name: "dev", + StartTime: timestamppb.New(time.Date(2025, 1, 15, 12, 00, 0, 0, time.UTC)), + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_DONE, + LastUpdateTime: nil, + LastUpdateReason: "outside_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 8, + }, + { + Name: "stage", + StartTime: timestamppb.New(time.Date(2025, 1, 15, 14, 00, 0, 0, time.UTC)), + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_ACTIVE, + LastUpdateReason: "in_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 14, + }, + { + Name: "prod", + StartTime: nil, + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED, + LastUpdateReason: "outside_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 18, + }, + }, + State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE, + StartTime: timestamppb.New(time.Date(2025, 1, 15, 2, 0, 0, 0, time.UTC)), + TimeOverride: nil, + }, + }, + expectedOutput: `Agent autoupdate mode: enabled +Rollout creation date: 2025-01-15 02:00:00 +Start version: 1.2.3 +Target version: 1.2.4 +Rollout state: Active +Strategy: time-based + +Group Name State Start Time State Reason +---------- --------- ------------------- -------------- +dev Done 2025-01-15 12:00:00 outside_window +stage Active 2025-01-15 14:00:00 in_window +prod Unstarted outside_window +`, + }, + { + name: "rollout regular schedule halt-on-error", + fixture: &autoupdatepb.AutoUpdateAgentRollout{ + Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{ + StartVersion: "1.2.3", + TargetVersion: "1.2.4", + Schedule: autoupdate.AgentsScheduleRegular, + AutoupdateMode: autoupdate.AgentsUpdateModeEnabled, + Strategy: autoupdate.AgentsStrategyHaltOnError, + }, + Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{ + Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{ + { + Name: "dev", + StartTime: timestamppb.New(time.Date(2025, 1, 15, 12, 00, 0, 0, time.UTC)), + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_DONE, + LastUpdateTime: nil, + LastUpdateReason: "outside_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 8, + }, + { + Name: "stage", + StartTime: timestamppb.New(time.Date(2025, 1, 15, 14, 00, 0, 0, time.UTC)), + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_ACTIVE, + LastUpdateReason: "in_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 14, + }, + { + Name: "prod", + StartTime: nil, + State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED, + LastUpdateReason: "outside_window", + ConfigDays: []string{"Mon", "Tue", "Wed", "Thu", "Fri"}, + ConfigStartHour: 18, + }, + }, + State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE, + StartTime: timestamppb.New(time.Date(2025, 1, 15, 2, 0, 0, 0, time.UTC)), + TimeOverride: nil, + }, + }, + expectedOutput: `Agent autoupdate mode: enabled +Rollout creation date: 2025-01-15 02:00:00 +Start version: 1.2.3 +Target version: 1.2.4 +Rollout state: Active +Strategy: halt-on-error + +Group Name State Start Time State Reason +---------- --------- ------------------- -------------- +dev Done 2025-01-15 12:00:00 outside_window +stage Active 2025-01-15 14:00:00 in_window +prod Unstarted outside_window +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test setup: create mock client and load fixtures. + clt := &mockRolloutClient{} + clt.On("GetAutoUpdateAgentRollout", mock.Anything).Return(tt.fixture, tt.fixtureErr).Once() + + // Test execution: run command. + output := &bytes.Buffer{} + cmd := AutoUpdateCommand{stdout: output} + err := cmd.agentsStatusCommand(ctx, clt) + require.NoError(t, err) + + // Test validation: check the command output. + require.Equal(t, tt.expectedOutput, output.String()) + + // Test validation: check that the mock received the expected calls. + clt.AssertExpectations(t) + }) + } + +}