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