From 576407045db067fda8c240d1bcf0b932cbded047 Mon Sep 17 00:00:00 2001 From: Vadym Popov Date: Wed, 8 Jan 2025 08:46:37 -0800 Subject: [PATCH] Add client tools auto update tctl commands (#47692) * Add client tools auto update tctl commands * Always print version for watch command Restrict update empty target version Rename command to upsert * Add alias on/off for tools mode Rename update command to configure * Add semantic version validation * Drop watch command for autoupdate * Replace Upsert with Update/Create Add format option for output json/yaml * Change update message * Use get/set naming for client-tools * Add mode to response * Change sub-command help messages Leave only aliases for enabled/disabled * Reorganize tctl commands to have commands not required auth client * Propagate insecure flag with global config to commands by context * Fix autoupdate command without auth client * Change commands to enable/disable/target * Add retry in case of the parallel request * Add more than one retry Code review changes * Update tool/tctl/common/autoupdate_command.go Co-authored-by: rosstimothy <39066650+rosstimothy@users.noreply.github.com> --------- Co-authored-by: rosstimothy <39066650+rosstimothy@users.noreply.github.com> --- tool/tctl/common/autoupdate_command.go | 296 ++++++++++++++++++++ tool/tctl/common/autoupdate_command_test.go | 118 ++++++++ tool/tctl/common/cmds.go | 1 + tool/tctl/common/helpers_test.go | 3 +- 4 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 tool/tctl/common/autoupdate_command.go create mode 100644 tool/tctl/common/autoupdate_command_test.go diff --git a/tool/tctl/common/autoupdate_command.go b/tool/tctl/common/autoupdate_command.go new file mode 100644 index 0000000000000..c089010c091f4 --- /dev/null +++ b/tool/tctl/common/autoupdate_command.go @@ -0,0 +1,296 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package common + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/alecthomas/kingpin/v2" + "github.com/coreos/go-semver/semver" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + "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/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" +) + +// maxRetries is the default number of RPC call retries to prevent parallel create/update errors. +const maxRetries = 3 + +// AutoUpdateCommand implements the `tctl autoupdate` command for managing +// autoupdate process for tools and agents. +type AutoUpdateCommand struct { + app *kingpin.Application + ccf *tctlcfg.GlobalCLIFlags + + targetCmd *kingpin.CmdClause + enableCmd *kingpin.CmdClause + disableCmd *kingpin.CmdClause + statusCmd *kingpin.CmdClause + + toolsTargetVersion string + proxy string + format string + + clear bool + + // stdout allows to switch standard output source for resource command. Used in tests. + stdout io.Writer +} + +// Initialize allows AutoUpdateCommand to plug itself into the CLI parser. +func (c *AutoUpdateCommand) Initialize(app *kingpin.Application, ccf *tctlcfg.GlobalCLIFlags, _ *servicecfg.Config) { + c.app = app + c.ccf = ccf + autoUpdateCmd := app.Command("autoupdate", "Manage auto update configuration.") + + 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.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.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) + + if c.stdout == nil { + c.stdout = os.Stdout + } +} + +// 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 + switch { + case cmd == c.targetCmd.FullCommand(): + commandFunc = c.TargetVersion + case cmd == c.enableCmd.FullCommand(): + commandFunc = c.SetModeCommand(true) + case cmd == c.disableCmd.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) + return true, trace.Wrap(err) + default: + return false, nil + } + + client, closeFn, err := clientFunc(ctx) + if err != nil { + return false, trace.Wrap(err) + } + err = commandFunc(ctx, client) + closeFn(ctx) + + return true, trace.Wrap(err) +} + +// TargetVersion creates or updates AutoUpdateVersion resource with client tools target version. +func (c *AutoUpdateCommand) TargetVersion(ctx context.Context, client *authclient.Client) error { + var err error + switch { + case c.clear: + err = c.clearTargetVersion(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) + if err == nil { + break + } + if !trace.IsNotFound(err) && !trace.IsAlreadyExists(err) { + return trace.Wrap(err) + } + } + } + return trace.Wrap(err) +} + +// 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 { + // 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) + if err == nil { + break + } + if !trace.IsNotFound(err) && !trace.IsAlreadyExists(err) { + return trace.Wrap(err) + } + } + return nil + } +} + +// getResponse is structure for formatting the client tools auto update response. +type getResponse struct { + Mode string `json:"mode"` + 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 { + var response getResponse + config, err := client.GetAutoUpdateConfig(ctx) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + if config != nil && config.Spec.Tools != nil { + response.Mode = config.Spec.Tools.Mode + } + + version, err := client.GetAutoUpdateVersion(ctx) + if err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + if version != nil && version.Spec.Tools != nil { + response.TargetVersion = version.Spec.Tools.TargetVersion + } + + return c.printResponse(response) +} + +// StatusByProxy makes request to `webapi/find` endpoint to fetch tools auto update version and mode +// without authentication. +func (c *AutoUpdateCommand) StatusByProxy(ctx context.Context) error { + find, err := webclient.Find(&webclient.Config{ + Context: ctx, + ProxyAddr: c.proxy, + Insecure: c.ccf.Insecure, + }) + if err != nil { + return trace.Wrap(err) + } + mode := autoupdate.ToolsUpdateModeDisabled + if find.AutoUpdate.ToolsAutoUpdate { + mode = autoupdate.ToolsUpdateModeEnabled + } + return c.printResponse(getResponse{ + TargetVersion: find.AutoUpdate.ToolsVersion, + Mode: mode, + }) +} + +func (c *AutoUpdateCommand) setMode(ctx context.Context, client *authclient.Client, enabled bool) error { + setMode := client.UpdateAutoUpdateConfig + config, err := client.GetAutoUpdateConfig(ctx) + if trace.IsNotFound(err) { + if config, err = autoupdate.NewAutoUpdateConfig(&autoupdatev1pb.AutoUpdateConfigSpec{}); err != nil { + return trace.Wrap(err) + } + setMode = client.CreateAutoUpdateConfig + } else if err != nil { + return trace.Wrap(err) + } + + if config.Spec.Tools == nil { + config.Spec.Tools = &autoupdatev1pb.AutoUpdateConfigSpecTools{} + } + + config.Spec.Tools.Mode = autoupdate.ToolsUpdateModeDisabled + if enabled { + config.Spec.Tools.Mode = autoupdate.ToolsUpdateModeEnabled + } + if _, err := setMode(ctx, config); err != nil { + return trace.Wrap(err) + } + fmt.Fprintln(c.stdout, "client tools auto update mode has been changed") + + return nil +} + +func (c *AutoUpdateCommand) setTargetVersion(ctx context.Context, client *authclient.Client) error { + if _, err := semver.NewVersion(c.toolsTargetVersion); err != nil { + return trace.WrapWithMessage(err, "not semantic version") + } + setTargetVersion := client.UpdateAutoUpdateVersion + version, err := client.GetAutoUpdateVersion(ctx) + if trace.IsNotFound(err) { + if version, err = autoupdate.NewAutoUpdateVersion(&autoupdatev1pb.AutoUpdateVersionSpec{}); err != nil { + return trace.Wrap(err) + } + setTargetVersion = client.CreateAutoUpdateVersion + } else if err != nil { + return trace.Wrap(err) + } + if version.Spec.Tools == nil { + version.Spec.Tools = &autoupdatev1pb.AutoUpdateVersionSpecTools{} + } + if version.Spec.Tools.TargetVersion != c.toolsTargetVersion { + version.Spec.Tools.TargetVersion = c.toolsTargetVersion + if _, err := setTargetVersion(ctx, version); err != nil { + return trace.Wrap(err) + } + fmt.Fprintln(c.stdout, "client tools auto update target version has been set") + } + return nil +} + +func (c *AutoUpdateCommand) clearTargetVersion(ctx context.Context, client *authclient.Client) error { + version, err := client.GetAutoUpdateVersion(ctx) + if trace.IsNotFound(err) { + return nil + } else if err != nil { + return trace.Wrap(err) + } + if version.Spec.Tools != nil { + version.Spec.Tools = nil + if _, err := client.UpdateAutoUpdateVersion(ctx, version); err != nil { + return trace.Wrap(err) + } + fmt.Fprintln(c.stdout, "client tools auto update target version has been cleared") + } + return nil +} + +func (c *AutoUpdateCommand) printResponse(response getResponse) error { + switch c.format { + case teleport.JSON: + if err := utils.WriteJSON(c.stdout, response); err != nil { + return trace.Wrap(err) + } + case teleport.YAML: + if err := utils.WriteYAML(c.stdout, response); err != nil { + return trace.Wrap(err) + } + default: + return trace.BadParameter("unsupported output format %s, supported values are %s and %s", c.format, teleport.JSON, teleport.YAML) + } + return nil +} diff --git a/tool/tctl/common/autoupdate_command_test.go b/tool/tctl/common/autoupdate_command_test.go new file mode 100644 index 0000000000000..31d2782fbc335 --- /dev/null +++ b/tool/tctl/common/autoupdate_command_test.go @@ -0,0 +1,118 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package common + +import ( + "bytes" + "context" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/breaker" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +// TestClientToolsAutoUpdateCommands verifies all commands related to client auto updates, by +// enabling/disabling auto update, setting the target version and retrieve it. +func TestClientToolsAutoUpdateCommands(t *testing.T) { + ctx := context.Background() + log := utils.NewSlogLoggerForTests() + process := testenv.MakeTestServer(t, testenv.WithLogger(log)) + authClient := testenv.MakeDefaultAuthClient(t, process) + + // Check that AutoUpdateConfig and AutoUpdateVersion are not created. + _, err := authClient.GetAutoUpdateConfig(ctx) + require.True(t, trace.IsNotFound(err)) + _, err = authClient.GetAutoUpdateVersion(ctx) + require.True(t, trace.IsNotFound(err)) + + // Enable client tools auto updates to check that AutoUpdateConfig resource is modified. + _, err = runAutoUpdateCommand(t, authClient, []string{"client-tools", "enable"}) + require.NoError(t, err) + + config, err := authClient.GetAutoUpdateConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "enabled", config.Spec.Tools.Mode) + + // Disable client tools auto updates to check that AutoUpdateConfig resource is modified. + _, err = runAutoUpdateCommand(t, authClient, []string{"client-tools", "disable"}) + require.NoError(t, err) + + config, err = authClient.GetAutoUpdateConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "disabled", config.Spec.Tools.Mode) + + // Set target version for client tools auto updates. + _, err = runAutoUpdateCommand(t, authClient, []string{"client-tools", "target", "1.2.3"}) + require.NoError(t, err) + + version, err := authClient.GetAutoUpdateVersion(ctx) + require.NoError(t, err) + assert.Equal(t, "1.2.3", version.Spec.Tools.TargetVersion) + + getBuf, err := runAutoUpdateCommand(t, authClient, []string{"client-tools", "status", "--format=json"}) + require.NoError(t, err) + response := mustDecodeJSON[getResponse](t, getBuf) + assert.Equal(t, "1.2.3", response.TargetVersion) + assert.Equal(t, "disabled", response.Mode) + + // Make same request with proxy flag to read command expecting the same + // response from `webapi/find` endpoint. + proxy, err := process.ProxyWebAddr() + require.NoError(t, err) + getProxyBuf, err := runAutoUpdateCommand(t, authClient, []string{"client-tools", "status", "--proxy=" + proxy.Addr, "--format=json"}) + require.NoError(t, err) + response = mustDecodeJSON[getResponse](t, getProxyBuf) + assert.Equal(t, "1.2.3", response.TargetVersion) + assert.Equal(t, "disabled", response.Mode) + + // Set clear flag for the target version update to check that it is going to be reset. + _, err = runAutoUpdateCommand(t, authClient, []string{"client-tools", "target", "--clear"}) + require.NoError(t, err) + version, err = authClient.GetAutoUpdateVersion(ctx) + require.NoError(t, err) + assert.Nil(t, version.Spec.Tools) +} + +func runAutoUpdateCommand(t *testing.T, client *authclient.Client, args []string) (*bytes.Buffer, error) { + var stdoutBuff bytes.Buffer + command := &AutoUpdateCommand{ + stdout: &stdoutBuff, + } + + cfg := servicecfg.MakeDefaultConfig() + cfg.CircuitBreakerConfig = breaker.NoopBreakerConfig() + app := utils.InitCLIParser("tctl", GlobalHelpString) + command.Initialize(app, &tctlcfg.GlobalCLIFlags{Insecure: true}, cfg) + + selectedCmd, err := app.Parse(append([]string{"autoupdate"}, args...)) + require.NoError(t, err) + + _, err = command.TryRun(context.Background(), selectedCmd, func(ctx context.Context) (*authclient.Client, func(context.Context), error) { + return client, func(context.Context) {}, nil + }) + return &stdoutBuff, err +} diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 4b9745ac38a10..2cd7b7a579802 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -66,5 +66,6 @@ func Commands() []CLICommand { &webauthnwinCommand{}, &touchIDCommand{}, &TerraformCommand{}, + &AutoUpdateCommand{}, } } diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index d9649391427a7..0cf773852c96f 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -75,8 +75,7 @@ func runCommand(t *testing.T, client *authclient.Client, cmd cliCommand, args [] selectedCmd, err := app.Parse(args) require.NoError(t, err) - ctx := context.Background() - _, err = cmd.TryRun(ctx, selectedCmd, func(ctx context.Context) (*authclient.Client, func(context.Context), error) { + _, err = cmd.TryRun(context.Background(), selectedCmd, func(ctx context.Context) (*authclient.Client, func(context.Context), error) { return client, func(context.Context) {}, nil }) return err