diff --git a/lib/autoupdate/agent/config.go b/lib/autoupdate/agent/config.go new file mode 100644 index 0000000000000..334d1089ab7f4 --- /dev/null +++ b/lib/autoupdate/agent/config.go @@ -0,0 +1,116 @@ +/* + * Teleport + * Copyright (C) 2024 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 agent + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "text/template" + + "github.com/google/renameio/v2" + "github.com/gravitational/trace" +) + +const ( + updateServiceTemplate = `# teleport-update +[Unit] +Description=Teleport auto-update service + +[Service] +Type=oneshot +ExecStart={{.LinkDir}}/bin/teleport-update update +` + updateTimerTemplate = `# teleport-update +[Unit] +Description=Teleport auto-update timer unit + +[Timer] +OnActiveSec=1m +OnUnitActiveSec=5m +RandomizedDelaySec=1m + +[Install] +WantedBy=teleport.service +` +) + +// Setup installs service and timer files for the teleport-update binary. +// Afterwords, Setup reloads systemd and enables the timer with --now. +func Setup(ctx context.Context, log *slog.Logger, linkDir, dataDir string) error { + err := writeConfigFiles(linkDir, dataDir) + if err != nil { + return trace.Errorf("failed to write teleport-update systemd config files: %w", err) + } + svc := &SystemdService{ + ServiceName: "teleport-update.timer", + Log: log, + } + if err := svc.Sync(ctx); err != nil { + return trace.Errorf("failed to sync systemd config: %w", err) + } + if err := svc.Enable(ctx, true); err != nil { + return trace.Errorf("failed to enable teleport-update systemd timer: %w", err) + } + return nil +} + +func writeConfigFiles(linkDir, dataDir string) error { + servicePath := filepath.Join(linkDir, serviceDir, updateServiceName) + err := writeTemplate(servicePath, updateServiceTemplate, linkDir, dataDir) + if err != nil { + return trace.Wrap(err) + } + timerPath := filepath.Join(linkDir, serviceDir, updateTimerName) + err = writeTemplate(timerPath, updateTimerTemplate, linkDir, dataDir) + if err != nil { + return trace.Wrap(err) + } + return nil +} + +func writeTemplate(path, t, linkDir, dataDir string) error { + dir, file := filepath.Split(path) + if err := os.MkdirAll(dir, systemDirMode); err != nil { + return trace.Wrap(err) + } + opts := []renameio.Option{ + renameio.WithPermissions(configFileMode), + renameio.WithExistingPermissions(), + } + f, err := renameio.NewPendingFile(path, opts...) + if err != nil { + return trace.Wrap(err) + } + defer f.Cleanup() + + tmpl, err := template.New(file).Parse(t) + if err != nil { + return trace.Wrap(err) + } + err = tmpl.Execute(f, struct { + LinkDir string + DataDir string + }{linkDir, dataDir}) + if err != nil { + return trace.Wrap(err) + } + return trace.Wrap(f.CloseAtomicallyReplace()) +} diff --git a/lib/autoupdate/agent/config_test.go b/lib/autoupdate/agent/config_test.go new file mode 100644 index 0000000000000..16cbdb5374fb6 --- /dev/null +++ b/lib/autoupdate/agent/config_test.go @@ -0,0 +1,65 @@ +/* + * Teleport + * Copyright (C) 2024 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 agent + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + libdefaults "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/utils/golden" +) + +func TestWriteConfigFiles(t *testing.T) { + t.Parallel() + linkDir := t.TempDir() + dataDir := t.TempDir() + err := writeConfigFiles(linkDir, dataDir) + require.NoError(t, err) + + for _, p := range []string{ + filepath.Join(linkDir, serviceDir, updateServiceName), + filepath.Join(linkDir, serviceDir, updateTimerName), + } { + t.Run(filepath.Base(p), func(t *testing.T) { + data, err := os.ReadFile(p) + require.NoError(t, err) + data = replaceValues(data, map[string]string{ + DefaultLinkDir: linkDir, + libdefaults.DataDir: dataDir, + }) + if golden.ShouldSet() { + golden.Set(t, data) + } + require.Equal(t, string(golden.Get(t)), string(data)) + }) + } +} + +func replaceValues(data []byte, m map[string]string) []byte { + for k, v := range m { + data = bytes.ReplaceAll(data, []byte(v), + []byte(k)) + } + return data +} diff --git a/lib/autoupdate/agent/installer.go b/lib/autoupdate/agent/installer.go index 957e90779c2ab..f03401063a3b8 100644 --- a/lib/autoupdate/agent/installer.go +++ b/lib/autoupdate/agent/installer.go @@ -55,11 +55,15 @@ const ( systemDirMode = 0755 ) -var ( +const ( // serviceDir contains the relative path to the Teleport SystemD service dir. - serviceDir = filepath.Join("lib", "systemd", "system") + serviceDir = "lib/systemd/system" // serviceName contains the name of the Teleport SystemD service file. serviceName = "teleport.service" + // updateServiceName contains the name of the Teleport Update Systemd service + updateServiceName = "teleport-update.service" + // updateTimerName contains the name of the Teleport Update Systemd timer + updateTimerName = "teleport-update.timer" ) // LocalInstaller manages the creation and removal of installations @@ -431,7 +435,7 @@ func (li *LocalInstaller) Link(ctx context.Context, version string) (revert func return revert, nil } -// LinkSystem links the system (package) version into the system LinkBinDir and LinkServiceDir. +// LinkSystem links the system (package) version into LinkBinDir and LinkServiceDir. // The revert function restores the previous linking. // See Installer interface for additional specs. func (li *LocalInstaller) LinkSystem(ctx context.Context) (revert func(context.Context) bool, err error) { @@ -539,7 +543,7 @@ func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcDir string) dst := filepath.Join(li.LinkServiceDir, serviceName) orig, err := forceCopy(dst, src, maxServiceFileSize) if err != nil && !errors.Is(err, os.ErrExist) { - return revert, trace.Errorf("failed to create file for %s: %w", serviceName, err) + return revert, trace.Errorf("failed to write file %s: %w", serviceName, err) } if orig != nil { revertFiles = append(revertFiles, *orig) @@ -782,13 +786,5 @@ func (li *LocalInstaller) isLinked(versionDir string) (bool, error) { return true, nil } } - linkData, err := readFileN(filepath.Join(li.LinkServiceDir, serviceName), maxServiceFileSize) - if err != nil { - return false, nil - } - versionData, err := readFileN(filepath.Join(versionDir, serviceDir, serviceName), maxServiceFileSize) - if err != nil { - return false, nil - } - return bytes.Equal(linkData, versionData), nil + return false, nil } diff --git a/lib/autoupdate/agent/process.go b/lib/autoupdate/agent/process.go index 2de3d8d0d746c..ed210cae99ed0 100644 --- a/lib/autoupdate/agent/process.go +++ b/lib/autoupdate/agent/process.go @@ -252,6 +252,24 @@ func (s SystemdService) Sync(ctx context.Context) error { if code != 0 { return trace.Errorf("unable to reload systemd configuration") } + s.Log.InfoContext(ctx, "Systemd configuration synced.", unitKey, s.ServiceName) + return nil +} + +// Enable the systemd service. +func (s SystemdService) Enable(ctx context.Context, now bool) error { + if err := s.checkSystem(ctx); err != nil { + return trace.Wrap(err) + } + args := []string{"enable", s.ServiceName} + if now { + args = append(args, "--now") + } + code := s.systemctl(ctx, slog.LevelError, args...) + if code != 0 { + return trace.Errorf("unable to enable systemd service") + } + s.Log.InfoContext(ctx, "Service enabled.", unitKey, s.ServiceName) return nil } diff --git a/lib/autoupdate/agent/testdata/TestUpdater_Update/sync_fails.golden b/lib/autoupdate/agent/testdata/TestUpdater_Update/setup_fails.golden similarity index 100% rename from lib/autoupdate/agent/testdata/TestUpdater_Update/sync_fails.golden rename to lib/autoupdate/agent/testdata/TestUpdater_Update/setup_fails.golden diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden new file mode 100644 index 0000000000000..185b4f07a1aa9 --- /dev/null +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden @@ -0,0 +1,7 @@ +# teleport-update +[Unit] +Description=Teleport auto-update service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/teleport-update update diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden new file mode 100644 index 0000000000000..acca095d9825f --- /dev/null +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden @@ -0,0 +1,11 @@ +# teleport-update +[Unit] +Description=Teleport auto-update timer unit + +[Timer] +OnActiveSec=1m +OnUnitActiveSec=5m +RandomizedDelaySec=1m + +[Install] +WantedBy=teleport.service diff --git a/lib/autoupdate/agent/updater.go b/lib/autoupdate/agent/updater.go index 5d82017998263..8fa5d34c246c2 100644 --- a/lib/autoupdate/agent/updater.go +++ b/lib/autoupdate/agent/updater.go @@ -27,7 +27,9 @@ import ( "log/slog" "net/http" "os" + "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -36,6 +38,7 @@ import ( "gopkg.in/yaml.v3" "github.com/gravitational/teleport/api/client/webclient" + "github.com/gravitational/teleport/api/constants" libdefaults "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/modules" libutils "github.com/gravitational/teleport/lib/utils" @@ -46,6 +49,10 @@ const ( DefaultLinkDir = "/usr/local" // DefaultSystemDir is the location where packaged Teleport binaries and services are installed. DefaultSystemDir = "/usr/local/teleport-system" + // VersionsDirName specifies the name of the subdirectory inside the Teleport data dir for storing Teleport versions. + VersionsDirName = "versions" + // BinaryName specifies the name of the updater binary. + BinaryName = "teleport-update" ) const ( @@ -136,16 +143,20 @@ func NewLocalUpdater(cfg LocalUpdaterConfig) (*Updater, error) { if cfg.SystemDir == "" { cfg.SystemDir = DefaultSystemDir } - if cfg.VersionsDir == "" { - cfg.VersionsDir = filepath.Join(libdefaults.DataDir, "versions") + if cfg.DataDir == "" { + cfg.DataDir = libdefaults.DataDir + } + installDir := filepath.Join(cfg.DataDir, VersionsDirName) + if err := os.MkdirAll(installDir, systemDirMode); err != nil { + return nil, trace.Errorf("failed to create install directory: %w", err) } return &Updater{ Log: cfg.Log, Pool: certPool, InsecureSkipVerify: cfg.InsecureSkipVerify, - ConfigPath: filepath.Join(cfg.VersionsDir, updateConfigName), + ConfigPath: filepath.Join(installDir, updateConfigName), Installer: &LocalInstaller{ - InstallDir: cfg.VersionsDir, + InstallDir: installDir, LinkBinDir: filepath.Join(cfg.LinkDir, "bin"), // For backwards-compatibility with symlinks created by package-based installs, we always // link into /lib/systemd/system, even though, e.g., /usr/local/lib/systemd/system would work. @@ -162,6 +173,28 @@ func NewLocalUpdater(cfg LocalUpdaterConfig) (*Updater, error) { PIDPath: "/run/teleport.pid", Log: cfg.Log, }, + Setup: func(ctx context.Context) error { + name := filepath.Join(cfg.LinkDir, "bin", BinaryName) + if cfg.SelfSetup && runtime.GOOS == constants.LinuxOS { + name = "/proc/self/exe" + } + cmd := exec.CommandContext(ctx, name, + "--data-dir", cfg.DataDir, + "--link-dir", cfg.LinkDir, + "setup") + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cfg.Log.InfoContext(ctx, "Executing new teleport-update binary to update configuration.") + defer cfg.Log.InfoContext(ctx, "Finished executing new teleport-update binary.") + err := cmd.Run() + if cmd.ProcessState.ExitCode() == CodeNotSupported { + return ErrNotSupported + } + return trace.Wrap(err) + }, + Revert: func(ctx context.Context) error { + return trace.Wrap(Setup(ctx, cfg.Log, cfg.LinkDir, cfg.DataDir)) + }, }, nil } @@ -175,12 +208,14 @@ type LocalUpdaterConfig struct { // DownloadTimeout is a timeout for file download requests. // Defaults to no timeout. DownloadTimeout time.Duration - // VersionsDir for installing Teleport (usually /var/lib/teleport/versions). - VersionsDir string + // DataDir for Teleport (usually /var/lib/teleport). + DataDir string // LinkDir for installing Teleport (usually /usr/local). LinkDir string // SystemDir for package-installed Teleport installations (usually /usr/local/teleport-system). SystemDir string + // SelfSetup mode for using the current version of the teleport-update to setup the update service. + SelfSetup bool } // Updater implements the agent-local logic for Teleport agent auto-updates. @@ -197,6 +232,10 @@ type Updater struct { Installer Installer // Process manages a running instance of Teleport. Process Process + // Setup installs the Teleport updater service using the linked installation. + Setup func(ctx context.Context) error + // Revert installs the Teleport updater service using the running installation. + Revert func(ctx context.Context) error } // Installer provides an API for installing Teleport agents. @@ -237,6 +276,11 @@ var ( ErrNotSupported = errors.New("not supported on this platform") ) +const ( + // CodeNotSupported is returned when the operation is not supported on the platform. + CodeNotSupported = 3 +) + // Process provides an API for interacting with a running Teleport process. type Process interface { // Reload must reload the Teleport process as gracefully as possible. @@ -337,6 +381,8 @@ func (u *Updater) Enable(ctx context.Context, override OverrideConfig) error { return trace.Errorf("agent version not available from Teleport cluster") } + u.Log.InfoContext(ctx, "Initiating initial update.", targetVersionKey, targetVersion, activeVersionKey, cfg.Status.ActiveVersion) + if err := u.update(ctx, cfg, targetVersion, flags); err != nil { return trace.Wrap(err) } @@ -477,7 +523,7 @@ func (u *Updater) update(ctx context.Context, cfg *UpdateConfig, targetVersion s } } - // Install the desired version (or validate existing installation) + // Install and link the desired version (or validate existing installation) template := cfg.Spec.URLTemplate if template == "" { @@ -487,6 +533,12 @@ func (u *Updater) update(ctx context.Context, cfg *UpdateConfig, targetVersion s if err != nil { return trace.Errorf("failed to install: %w", err) } + + // TODO(sclevine): if the target version has fewer binaries, this will + // leave old binaries linked. This may prevent the installation from + // being removed. To fix this, we should look for orphaned binaries + // and remove them, or alternatively, attempt to remove extra versions. + revert, err := u.Installer.Link(ctx, targetVersion) if err != nil { return trace.Errorf("failed to link: %w", err) @@ -495,22 +547,31 @@ func (u *Updater) update(ctx context.Context, cfg *UpdateConfig, targetVersion s // If we fail to revert after this point, the next update/enable will // fix the link to restore the active version. - // Sync process configuration after linking. - - if err := u.Process.Sync(ctx); err != nil { - if errors.Is(err, context.Canceled) { - return trace.Errorf("sync canceled") + revertConfig := func(ctx context.Context) bool { + if ok := revert(ctx); !ok { + u.Log.ErrorContext(ctx, "Failed to revert Teleport symlinks. Installation likely broken.") + return false } + if err := u.Revert(ctx); err != nil { + u.Log.ErrorContext(ctx, "Failed to revert configuration after failed restart.", errorKey, err) + return false + } + return true + } + + // Setup teleport-updater configuration and sync systemd. + + err = u.Setup(ctx) + if errors.Is(err, ErrNotSupported) { + u.Log.WarnContext(ctx, "Not syncing systemd configuration because systemd is not running.") + } else if errors.Is(err, context.Canceled) { + return trace.Errorf("sync canceled") + } else if err != nil { // If sync fails, we may have left the host in a bad state, so we revert linking and re-Sync. u.Log.ErrorContext(ctx, "Reverting symlinks due to invalid configuration.") - if ok := revert(ctx); !ok { - u.Log.ErrorContext(ctx, "Failed to revert Teleport symlinks. Installation likely broken.") - } else if err := u.Process.Sync(ctx); err != nil { - u.Log.ErrorContext(ctx, "Failed to sync configuration after failed restart.", errorKey, err) - } else { + if ok := revertConfig(ctx); ok { u.Log.WarnContext(ctx, "Teleport updater encountered a configuration error and successfully reverted the installation.") } - return trace.Errorf("failed to validate configuration for new version %q of Teleport: %w", targetVersion, err) } @@ -518,22 +579,23 @@ func (u *Updater) update(ctx context.Context, cfg *UpdateConfig, targetVersion s if cfg.Status.ActiveVersion != targetVersion { u.Log.InfoContext(ctx, "Target version successfully installed.", targetVersionKey, targetVersion) - if err := u.Process.Reload(ctx); err != nil && !errors.Is(err, ErrNotNeeded) { - if errors.Is(err, context.Canceled) { - return trace.Errorf("reload canceled") - } - // If reloading Teleport at the new version fails, revert, resync, and reload. + err = u.Process.Reload(ctx) + if errors.Is(err, context.Canceled) { + return trace.Errorf("reload canceled") + } + if err != nil && + !errors.Is(err, ErrNotNeeded) && // no output if restart not needed + !errors.Is(err, ErrNotSupported) { // already logged above for Sync + + // If reloading Teleport at the new version fails, revert and reload. u.Log.ErrorContext(ctx, "Reverting symlinks due to failed restart.") - if ok := revert(ctx); !ok { - u.Log.ErrorContext(ctx, "Failed to revert Teleport symlinks to older version. Installation likely broken.") - } else if err := u.Process.Sync(ctx); err != nil { - u.Log.ErrorContext(ctx, "Invalid configuration found after reverting Teleport to older version. Installation likely broken.", errorKey, err) - } else if err := u.Process.Reload(ctx); err != nil && !errors.Is(err, ErrNotNeeded) { - u.Log.ErrorContext(ctx, "Failed to revert Teleport to older version. Installation likely broken.", errorKey, err) - } else { - u.Log.WarnContext(ctx, "Teleport updater encountered an error during the update and successfully reverted the installation.") + if ok := revertConfig(ctx); ok { + if err := u.Process.Reload(ctx); err != nil && !errors.Is(err, ErrNotNeeded) { + u.Log.ErrorContext(ctx, "Failed to reload Teleport after reverting. Installation likely broken.", errorKey, err) + } else { + u.Log.WarnContext(ctx, "Teleport updater encountered a configuration error and successfully reverted the installation.") + } } - return trace.Errorf("failed to start new version %q of Teleport: %w", targetVersion, err) } cfg.Status.BackupVersion = cfg.Status.ActiveVersion @@ -554,7 +616,6 @@ func (u *Updater) update(ctx context.Context, cfg *UpdateConfig, targetVersion s if n := len(versions); n > 2 { u.Log.WarnContext(ctx, "More than 2 versions of Teleport installed. Version directory may need cleanup to save space.", "count", n) } - return nil } @@ -609,7 +670,11 @@ func validateConfigSpec(spec *UpdateSpec, override OverrideConfig) error { if override.Group != "" { spec.Group = override.Group } - if override.URLTemplate != "" { + switch override.URLTemplate { + case "": + case "default": + spec.URLTemplate = "" + default: spec.URLTemplate = override.URLTemplate } if spec.URLTemplate != "" && @@ -620,7 +685,7 @@ func validateConfigSpec(spec *UpdateSpec, override OverrideConfig) error { } // LinkPackage creates links from the system (package) installation of Teleport, if they are needed. -// LinkPackage returns nils and warns if an auto-updates version is already linked, but auto-updates is disabled. +// LinkPackage returns nil and warns if an auto-updates version is already linked, but auto-updates is disabled. // LinkPackage returns an error only if an unknown version of Teleport is present (e.g., manually copied files). // This function is idempotent. func (u *Updater) LinkPackage(ctx context.Context) error { diff --git a/lib/autoupdate/agent/updater_test.go b/lib/autoupdate/agent/updater_test.go index 1197ac3d5a795..01e6e4980f5de 100644 --- a/lib/autoupdate/agent/updater_test.go +++ b/lib/autoupdate/agent/updater_test.go @@ -83,7 +83,13 @@ func TestUpdater_Disable(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, "update.yaml") + cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + + updater, err := NewLocalUpdater(LocalUpdaterConfig{ + InsecureSkipVerify: true, + DataDir: dir, + }) + require.NoError(t, err) // Create config file only if provided in test case if tt.cfg != nil { @@ -92,11 +98,7 @@ func TestUpdater_Disable(t *testing.T) { err = os.WriteFile(cfgPath, b, 0600) require.NoError(t, err) } - updater, err := NewLocalUpdater(LocalUpdaterConfig{ - InsecureSkipVerify: true, - VersionsDir: dir, - }) - require.NoError(t, err) + err = updater.Disable(context.Background()) if tt.errMatch != "" { require.Error(t, err) @@ -131,7 +133,7 @@ func TestUpdater_Update(t *testing.T) { flags InstallFlags inWindow bool installErr error - syncErr error + setupErr error reloadErr error removedVersion string @@ -139,9 +141,9 @@ func TestUpdater_Update(t *testing.T) { installedTemplate string linkedVersion string requestGroup string - syncCalls int reloadCalls int revertCalls int + setupCalls int errMatch string }{ { @@ -164,8 +166,8 @@ func TestUpdater_Update(t *testing.T) { installedTemplate: "https://example.com", linkedVersion: "16.3.0", requestGroup: "group", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "updates disabled during window", @@ -293,8 +295,8 @@ func TestUpdater_Update(t *testing.T) { installedTemplate: "https://example.com", linkedVersion: "16.3.0", removedVersion: "backup-version", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "backup version kept when no change", @@ -336,8 +338,8 @@ func TestUpdater_Update(t *testing.T) { installedTemplate: "https://example.com", linkedVersion: "16.3.0", removedVersion: "backup-version", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "invalid metadata", @@ -345,7 +347,7 @@ func TestUpdater_Update(t *testing.T) { errMatch: "invalid", }, { - name: "sync fails", + name: "setup fails", cfg: &UpdateConfig{ Version: updateConfigVersion, Kind: updateConfigKind, @@ -359,16 +361,16 @@ func TestUpdater_Update(t *testing.T) { }, }, inWindow: true, - syncErr: errors.New("sync error"), + setupErr: errors.New("setup error"), installedVersion: "16.3.0", installedTemplate: "https://example.com", linkedVersion: "16.3.0", removedVersion: "backup-version", - syncCalls: 2, reloadCalls: 0, revertCalls: 1, - errMatch: "sync error", + setupCalls: 1, + errMatch: "setup error", }, { name: "reload fails", @@ -391,9 +393,9 @@ func TestUpdater_Update(t *testing.T) { installedTemplate: "https://example.com", linkedVersion: "16.3.0", removedVersion: "backup-version", - syncCalls: 2, reloadCalls: 2, revertCalls: 1, + setupCalls: 1, errMatch: "reload error", }, } @@ -419,7 +421,13 @@ func TestUpdater_Update(t *testing.T) { t.Cleanup(server.Close) dir := t.TempDir() - cfgPath := filepath.Join(dir, "update.yaml") + cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + + updater, err := NewLocalUpdater(LocalUpdaterConfig{ + InsecureSkipVerify: true, + DataDir: dir, + }) + require.NoError(t, err) // Create config file only if provided in test case if tt.cfg != nil { @@ -430,19 +438,16 @@ func TestUpdater_Update(t *testing.T) { require.NoError(t, err) } - updater, err := NewLocalUpdater(LocalUpdaterConfig{ - InsecureSkipVerify: true, - VersionsDir: dir, - }) - require.NoError(t, err) - var ( installedVersion string installedTemplate string linkedVersion string removedVersion string installedFlags InstallFlags - revertCalls int + revertFuncCalls int + setupCalls int + revertSetupCalls int + reloadCalls int ) updater.Installer = &testInstaller{ FuncInstall: func(_ context.Context, version, template string, flags InstallFlags) error { @@ -454,7 +459,7 @@ func TestUpdater_Update(t *testing.T) { FuncLink: func(_ context.Context, version string) (revert func(context.Context) bool, err error) { linkedVersion = version return func(_ context.Context) bool { - revertCalls++ + revertFuncCalls++ return true }, nil }, @@ -466,20 +471,20 @@ func TestUpdater_Update(t *testing.T) { return nil }, } - var ( - syncCalls int - reloadCalls int - ) updater.Process = &testProcess{ - FuncSync: func(_ context.Context) error { - syncCalls++ - return tt.syncErr - }, FuncReload: func(_ context.Context) error { reloadCalls++ return tt.reloadErr }, } + updater.Setup = func(_ context.Context) error { + setupCalls++ + return tt.setupErr + } + updater.Revert = func(_ context.Context) error { + revertSetupCalls++ + return nil + } ctx := context.Background() err = updater.Update(ctx) @@ -495,9 +500,10 @@ func TestUpdater_Update(t *testing.T) { require.Equal(t, tt.removedVersion, removedVersion) require.Equal(t, tt.flags, installedFlags) require.Equal(t, tt.requestGroup, requestedGroup) - require.Equal(t, tt.syncCalls, syncCalls) require.Equal(t, tt.reloadCalls, reloadCalls) - require.Equal(t, tt.revertCalls, revertCalls) + require.Equal(t, tt.revertCalls, revertSetupCalls) + require.Equal(t, tt.revertCalls, revertFuncCalls) + require.Equal(t, tt.setupCalls, setupCalls) if tt.cfg == nil { _, err := os.Stat(cfgPath) @@ -594,7 +600,13 @@ func TestUpdater_LinkPackage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, "update.yaml") + cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + + updater, err := NewLocalUpdater(LocalUpdaterConfig{ + InsecureSkipVerify: true, + DataDir: dir, + }) + require.NoError(t, err) // Create config file only if provided in test case if tt.cfg != nil { @@ -604,12 +616,6 @@ func TestUpdater_LinkPackage(t *testing.T) { require.NoError(t, err) } - updater, err := NewLocalUpdater(LocalUpdaterConfig{ - InsecureSkipVerify: true, - VersionsDir: dir, - }) - require.NoError(t, err) - var tryLinkSystemCalls int updater.Installer = &testInstaller{ FuncTryLinkSystem: func(_ context.Context) error { @@ -648,7 +654,7 @@ func TestUpdater_Enable(t *testing.T) { userCfg OverrideConfig flags InstallFlags installErr error - syncErr error + setupErr error reloadErr error removedVersion string @@ -656,9 +662,9 @@ func TestUpdater_Enable(t *testing.T) { installedTemplate string linkedVersion string requestGroup string - syncCalls int reloadCalls int revertCalls int + setupCalls int errMatch string }{ { @@ -679,8 +685,8 @@ func TestUpdater_Enable(t *testing.T) { installedTemplate: "https://example.com", linkedVersion: "16.3.0", requestGroup: "group", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "config from user", @@ -704,8 +710,8 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "new-version", installedTemplate: "https://example.com/new", linkedVersion: "new-version", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "already enabled", @@ -723,8 +729,8 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "insecure URL", @@ -764,8 +770,8 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 1, reloadCalls: 0, + setupCalls: 1, }, { name: "backup version removed on install", @@ -782,8 +788,8 @@ func TestUpdater_Enable(t *testing.T) { installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", removedVersion: "backup-version", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "backup version kept for validation", @@ -800,8 +806,8 @@ func TestUpdater_Enable(t *testing.T) { installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", removedVersion: "", - syncCalls: 1, reloadCalls: 0, + setupCalls: 1, }, { name: "config does not exist", @@ -809,8 +815,8 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "FIPS and Enterprise flags", @@ -818,8 +824,8 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 1, reloadCalls: 1, + setupCalls: 1, }, { name: "invalid metadata", @@ -827,16 +833,16 @@ func TestUpdater_Enable(t *testing.T) { errMatch: "invalid", }, { - name: "sync fails", - syncErr: errors.New("sync error"), + name: "setup fails", + setupErr: errors.New("setup error"), installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 2, reloadCalls: 0, revertCalls: 1, - errMatch: "sync error", + setupCalls: 1, + errMatch: "setup error", }, { name: "reload fails", @@ -845,9 +851,9 @@ func TestUpdater_Enable(t *testing.T) { installedVersion: "16.3.0", installedTemplate: cdnURITemplate, linkedVersion: "16.3.0", - syncCalls: 2, reloadCalls: 2, revertCalls: 1, + setupCalls: 1, errMatch: "reload error", }, } @@ -855,7 +861,13 @@ func TestUpdater_Enable(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, "update.yaml") + cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + + updater, err := NewLocalUpdater(LocalUpdaterConfig{ + InsecureSkipVerify: true, + DataDir: dir, + }) + require.NoError(t, err) // Create config file only if provided in test case if tt.cfg != nil { @@ -886,19 +898,16 @@ func TestUpdater_Enable(t *testing.T) { tt.userCfg.Proxy = strings.TrimPrefix(server.URL, "https://") } - updater, err := NewLocalUpdater(LocalUpdaterConfig{ - InsecureSkipVerify: true, - VersionsDir: dir, - }) - require.NoError(t, err) - var ( installedVersion string installedTemplate string linkedVersion string removedVersion string installedFlags InstallFlags - revertCalls int + revertFuncCalls int + reloadCalls int + setupCalls int + revertSetupCalls int ) updater.Installer = &testInstaller{ FuncInstall: func(_ context.Context, version, template string, flags InstallFlags) error { @@ -910,7 +919,7 @@ func TestUpdater_Enable(t *testing.T) { FuncLink: func(_ context.Context, version string) (revert func(context.Context) bool, err error) { linkedVersion = version return func(_ context.Context) bool { - revertCalls++ + revertFuncCalls++ return true }, nil }, @@ -922,20 +931,20 @@ func TestUpdater_Enable(t *testing.T) { return nil }, } - var ( - syncCalls int - reloadCalls int - ) updater.Process = &testProcess{ - FuncSync: func(_ context.Context) error { - syncCalls++ - return tt.syncErr - }, FuncReload: func(_ context.Context) error { reloadCalls++ return tt.reloadErr }, } + updater.Setup = func(_ context.Context) error { + setupCalls++ + return tt.setupErr + } + updater.Revert = func(_ context.Context) error { + revertSetupCalls++ + return nil + } ctx := context.Background() err = updater.Enable(ctx, tt.userCfg) @@ -951,9 +960,10 @@ func TestUpdater_Enable(t *testing.T) { require.Equal(t, tt.removedVersion, removedVersion) require.Equal(t, tt.flags, installedFlags) require.Equal(t, tt.requestGroup, requestedGroup) - require.Equal(t, tt.syncCalls, syncCalls) require.Equal(t, tt.reloadCalls, reloadCalls) - require.Equal(t, tt.revertCalls, revertCalls) + require.Equal(t, tt.revertCalls, revertSetupCalls) + require.Equal(t, tt.revertCalls, revertFuncCalls) + require.Equal(t, tt.setupCalls, setupCalls) if tt.cfg == nil && err != nil { _, err := os.Stat(cfgPath) diff --git a/tool/teleport-update/main.go b/tool/teleport-update/main.go index d559ad3e75cdd..1db37feae4954 100644 --- a/tool/teleport-update/main.go +++ b/tool/teleport-update/main.go @@ -21,6 +21,7 @@ package main import ( "context" "errors" + "fmt" "log/slog" "os" "os/signal" @@ -58,10 +59,8 @@ const ( ) const ( - // versionsDirName specifies the name of the subdirectory inside of the Teleport data dir for storing Teleport versions. - versionsDirName = "versions" - // lockFileName specifies the name of the file inside versionsDirName containing the flock lock preventing concurrent updater execution. - lockFileName = ".lock" + // lockFileName specifies the name of the file containing the flock lock preventing concurrent updater execution. + lockFileName = ".update-lock" ) var plog = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentUpdater) @@ -84,6 +83,8 @@ type cliConfig struct { DataDir string // LinkDir for linking binaries and systemd services LinkDir string + // SelfSetup mode for using the current version of the teleport-update to setup the update service. + SelfSetup bool } func Run(args []string) error { @@ -91,7 +92,7 @@ func Run(args []string) error { ctx := context.Background() ctx, _ = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - app := libutils.InitCLIParser("teleport-update", appHelp).Interspersed(false) + app := libutils.InitCLIParser(autoupdate.BinaryName, appHelp).Interspersed(false) app.Flag("debug", "Verbose logging to stdout."). Short('d').BoolVar(&ccfg.Debug) app.Flag("data-dir", "Teleport data directory. Access to this directory should be limited."). @@ -103,7 +104,7 @@ func Run(args []string) error { app.HelpFlag.Short('h') - versionCmd := app.Command("version", "Print the version of your teleport-updater binary.") + versionCmd := app.Command("version", fmt.Sprintf("Print the version of your %s binary.", autoupdate.BinaryName)) enableCmd := app.Command("enable", "Enable agent auto-updates and perform initial update.") enableCmd.Flag("proxy", "Address of the Teleport Proxy."). @@ -114,13 +115,20 @@ func Run(args []string) error { Short('t').Envar(templateEnvVar).StringVar(&ccfg.URLTemplate) enableCmd.Flag("force-version", "Force the provided version instead of querying it from the Teleport cluster."). Short('f').Envar(updateVersionEnvVar).Hidden().StringVar(&ccfg.ForceVersion) + enableCmd.Flag("self-setup", "Use the current teleport-update binary to create systemd service config for auto-updates."). + Short('s').Hidden().BoolVar(&ccfg.SelfSetup) // TODO(sclevine): add force-fips and force-enterprise as hidden flags disableCmd := app.Command("disable", "Disable agent auto-updates.") updateCmd := app.Command("update", "Update agent to the latest version, if a new version is available.") + updateCmd.Flag("self-setup", "Use the current teleport-update binary to create systemd service config for auto-updates."). + Short('s').Hidden().BoolVar(&ccfg.SelfSetup) - linkCmd := app.Command("link", "Link the system installation of Teleport from the Teleport package, if auto-updates is disabled.") + linkCmd := app.Command("link-package", "Link the system installation of Teleport from the Teleport package, if auto-updates is disabled.") + + setupCmd := app.Command("setup", "Write configuration files that run the update subcommand on a timer."). + Hidden() libutils.UpdateAppUsageTemplate(app, args) command, err := app.Parse(args) @@ -143,6 +151,8 @@ func Run(args []string) error { err = cmdUpdate(ctx, &ccfg) case linkCmd.FullCommand(): err = cmdLink(ctx, &ccfg) + case setupCmd.FullCommand(): + err = cmdSetup(ctx, &ccfg) case versionCmd.FullCommand(): modules.GetModules().PrintVersion() default: @@ -172,12 +182,17 @@ func setupLogger(debug bool, format string) error { // cmdDisable disables updates. func cmdDisable(ctx context.Context, ccfg *cliConfig) error { - versionsDir := filepath.Join(ccfg.DataDir, versionsDirName) - if err := os.MkdirAll(versionsDir, 0755); err != nil { - return trace.Errorf("failed to create versions directory: %w", err) + updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ + DataDir: ccfg.DataDir, + LinkDir: ccfg.LinkDir, + SystemDir: autoupdate.DefaultSystemDir, + SelfSetup: ccfg.SelfSetup, + Log: plog, + }) + if err != nil { + return trace.Errorf("failed to setup updater: %w", err) } - - unlock, err := libutils.FSWriteLock(filepath.Join(versionsDir, lockFileName)) + unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -186,15 +201,6 @@ func cmdDisable(ctx context.Context, ccfg *cliConfig) error { plog.DebugContext(ctx, "Failed to close lock file", "error", err) } }() - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - VersionsDir: versionsDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - Log: plog, - }) - if err != nil { - return trace.Errorf("failed to setup updater: %w", err) - } if err := updater.Disable(ctx); err != nil { return trace.Wrap(err) } @@ -203,13 +209,19 @@ func cmdDisable(ctx context.Context, ccfg *cliConfig) error { // cmdEnable enables updates and triggers an initial update. func cmdEnable(ctx context.Context, ccfg *cliConfig) error { - versionsDir := filepath.Join(ccfg.DataDir, versionsDirName) - if err := os.MkdirAll(versionsDir, 0755); err != nil { - return trace.Errorf("failed to create versions directory: %w", err) + updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ + DataDir: ccfg.DataDir, + LinkDir: ccfg.LinkDir, + SystemDir: autoupdate.DefaultSystemDir, + SelfSetup: ccfg.SelfSetup, + Log: plog, + }) + if err != nil { + return trace.Errorf("failed to setup updater: %w", err) } // Ensure enable can't run concurrently. - unlock, err := libutils.FSWriteLock(filepath.Join(versionsDir, lockFileName)) + unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -218,16 +230,6 @@ func cmdEnable(ctx context.Context, ccfg *cliConfig) error { plog.DebugContext(ctx, "Failed to close lock file", "error", err) } }() - - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - VersionsDir: versionsDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - Log: plog, - }) - if err != nil { - return trace.Errorf("failed to setup updater: %w", err) - } if err := updater.Enable(ctx, ccfg.OverrideConfig); err != nil { return trace.Wrap(err) } @@ -236,13 +238,18 @@ func cmdEnable(ctx context.Context, ccfg *cliConfig) error { // cmdUpdate updates Teleport to the version specified by cluster reachable at the proxy address. func cmdUpdate(ctx context.Context, ccfg *cliConfig) error { - versionsDir := filepath.Join(ccfg.DataDir, versionsDirName) - if err := os.MkdirAll(versionsDir, 0755); err != nil { - return trace.Errorf("failed to create versions directory: %w", err) + updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ + DataDir: ccfg.DataDir, + LinkDir: ccfg.LinkDir, + SystemDir: autoupdate.DefaultSystemDir, + SelfSetup: ccfg.SelfSetup, + Log: plog, + }) + if err != nil { + return trace.Errorf("failed to setup updater: %w", err) } - // Ensure update can't run concurrently. - unlock, err := libutils.FSWriteLock(filepath.Join(versionsDir, lockFileName)) + unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -252,15 +259,6 @@ func cmdUpdate(ctx context.Context, ccfg *cliConfig) error { } }() - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - VersionsDir: versionsDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - Log: plog, - }) - if err != nil { - return trace.Errorf("failed to setup updater: %w", err) - } if err := updater.Update(ctx); err != nil { return trace.Wrap(err) } @@ -269,10 +267,19 @@ func cmdUpdate(ctx context.Context, ccfg *cliConfig) error { // cmdLink creates system package links if no version is linked and auto-updates is disabled. func cmdLink(ctx context.Context, ccfg *cliConfig) error { - versionsDir := filepath.Join(ccfg.DataDir, versionsDirName) + updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ + DataDir: ccfg.DataDir, + LinkDir: ccfg.LinkDir, + SystemDir: autoupdate.DefaultSystemDir, + SelfSetup: ccfg.SelfSetup, + Log: plog, + }) + if err != nil { + return trace.Errorf("failed to setup updater: %w", err) + } // Skip operation and warn if the updater is currently running. - unlock, err := libutils.FSTryReadLock(filepath.Join(versionsDir, lockFileName)) + unlock, err := libutils.FSTryReadLock(filepath.Join(ccfg.DataDir, lockFileName)) if errors.Is(err, libutils.ErrUnsuccessfulLockTry) { plog.WarnContext(ctx, "Updater is currently running. Skipping package linking.") return nil @@ -285,17 +292,22 @@ func cmdLink(ctx context.Context, ccfg *cliConfig) error { plog.DebugContext(ctx, "Failed to close lock file", "error", err) } }() - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - VersionsDir: versionsDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - Log: plog, - }) - if err != nil { - return trace.Errorf("failed to setup updater: %w", err) - } + if err := updater.LinkPackage(ctx); err != nil { return trace.Wrap(err) } return nil } + +// cmdSetup writes configuration files that are needed to run teleport-update update. +func cmdSetup(ctx context.Context, ccfg *cliConfig) error { + err := autoupdate.Setup(ctx, plog, ccfg.LinkDir, ccfg.DataDir) + if errors.Is(err, autoupdate.ErrNotSupported) { + plog.WarnContext(ctx, "Not enabling systemd service because systemd is not running.") + os.Exit(autoupdate.CodeNotSupported) + } + if err != nil { + return trace.Errorf("failed to setup teleport-update service: %w", err) + } + return nil +}