From a079deb0c64941a5764c123d4c6f4fd052082f20 Mon Sep 17 00:00:00 2001 From: Stephen Levine Date: Tue, 3 Dec 2024 16:58:23 -0500 Subject: [PATCH] [teleport-update] Isolated installation suffix (#49364) * namespacing * words * cli * fix * err * use structured logs consistently * comments * bugs * test * switch to new paths * test * adjust * reserved * cleanup * cleanup * docs * fix uninstall * test * simplify init * cleanup * namespace -> install-suffix * log --- lib/autoupdate/agent/installer.go | 97 ++++---- lib/autoupdate/agent/installer_test.go | 91 +++++--- lib/autoupdate/agent/setup.go | 207 ++++++++++++++++-- lib/autoupdate/agent/setup_test.go | 162 ++++++++++++-- .../service.golden} | 1 + .../timer.golden} | 1 + .../test_namespace/service.golden | 8 + .../test_namespace/timer.golden | 12 + lib/autoupdate/agent/updater.go | 60 ++--- lib/autoupdate/agent/updater_test.go | 48 ++-- tool/teleport-update/main.go | 202 +++++++++-------- 11 files changed, 613 insertions(+), 276 deletions(-) rename lib/autoupdate/agent/testdata/TestWriteConfigFiles/{teleport-update.service.golden => no_namespace/service.golden} (85%) rename lib/autoupdate/agent/testdata/TestWriteConfigFiles/{teleport-update.timer.golden => no_namespace/timer.golden} (87%) create mode 100644 lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/service.golden create mode 100644 lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/timer.golden diff --git a/lib/autoupdate/agent/installer.go b/lib/autoupdate/agent/installer.go index 84c41bd54f235..41db748a3c1b8 100644 --- a/lib/autoupdate/agent/installer.go +++ b/lib/autoupdate/agent/installer.go @@ -59,12 +59,8 @@ const ( const ( // serviceDir contains the relative path to the Teleport SystemD service dir. serviceDir = "lib/systemd/system" - // serviceName contains the name of the Teleport SystemD service file. + // serviceName contains the upstream 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 @@ -74,12 +70,12 @@ type LocalInstaller struct { InstallDir string // LinkBinDir contains symlinks to the linked installation's binaries. LinkBinDir string - // LinkServiceDir contains a copy of the linked installation's systemd service. - LinkServiceDir string + // CopyServiceFile contains a copy of the linked installation's systemd service. + CopyServiceFile string // SystemBinDir contains binaries for the system (packaged) install of Teleport. SystemBinDir string - // SystemServiceDir contains the systemd service file for the system (packaged) install of Teleport. - SystemServiceDir string + // SystemServiceFile contains the systemd service file for the system (packaged) install of Teleport. + SystemServiceFile string // HTTP is an HTTP client for downloading Teleport. HTTP *http.Client // Log contains a logger. @@ -88,6 +84,8 @@ type LocalInstaller struct { ReservedFreeTmpDisk uint64 // ReservedFreeInstallDisk is the amount of disk that must remain free in the install directory. ReservedFreeInstallDisk uint64 + // TransformService transforms the systemd service during copying. + TransformService func([]byte) []byte } // Remove a Teleport version directory from InstallDir. @@ -356,7 +354,7 @@ func (li *LocalInstaller) extract(ctx context.Context, dstDir string, src io.Rea } zr, err := gzip.NewReader(src) if err != nil { - return trace.Errorf("requires gzip-compressed body: %v", err) + return trace.Errorf("requires gzip-compressed body: %w", err) } li.Log.InfoContext(ctx, "Extracting Teleport tarball.", "path", dstDir, "size", max) @@ -417,7 +415,7 @@ func (li *LocalInstaller) List(ctx context.Context) (versions []string, err erro return versions, nil } -// Link the specified version into the system LinkBinDir and LinkServiceDir. +// Link the specified version into the system LinkBinDir and CopyServiceFile. // The revert function restores the previous linking. // See Installer interface for additional specs. func (li *LocalInstaller) Link(ctx context.Context, version string) (revert func(context.Context) bool, err error) { @@ -428,7 +426,7 @@ func (li *LocalInstaller) Link(ctx context.Context, version string) (revert func } revert, err = li.forceLinks(ctx, filepath.Join(versionDir, "bin"), - filepath.Join(versionDir, serviceDir), + filepath.Join(versionDir, serviceDir, serviceName), ) if err != nil { return revert, trace.Wrap(err) @@ -436,11 +434,11 @@ func (li *LocalInstaller) Link(ctx context.Context, version string) (revert func return revert, nil } -// LinkSystem links the system (package) version into LinkBinDir and LinkServiceDir. +// LinkSystem links the system (package) version into LinkBinDir and CopyServiceFile. // 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) { - revert, err = li.forceLinks(ctx, li.SystemBinDir, li.SystemServiceDir) + revert, err = li.forceLinks(ctx, li.SystemBinDir, li.SystemServiceFile) return revert, trace.Wrap(err) } @@ -454,7 +452,7 @@ func (li *LocalInstaller) TryLink(ctx context.Context, version string) error { } return trace.Wrap(li.tryLinks(ctx, filepath.Join(versionDir, "bin"), - filepath.Join(versionDir, serviceDir), + filepath.Join(versionDir, serviceDir, serviceName), )) } @@ -462,10 +460,10 @@ func (li *LocalInstaller) TryLink(ctx context.Context, version string) error { // no installation of Teleport is already linked or partially linked. // See Installer interface for additional specs. func (li *LocalInstaller) TryLinkSystem(ctx context.Context) error { - return trace.Wrap(li.tryLinks(ctx, li.SystemBinDir, li.SystemServiceDir)) + return trace.Wrap(li.tryLinks(ctx, li.SystemBinDir, li.SystemServiceFile)) } -// Unlink unlinks a version from LinkBinDir and LinkServiceDir. +// Unlink unlinks a version from LinkBinDir and CopyServiceFile. // See Installer interface for additional specs. func (li *LocalInstaller) Unlink(ctx context.Context, version string) error { versionDir, err := li.versionDir(version) @@ -474,14 +472,14 @@ func (li *LocalInstaller) Unlink(ctx context.Context, version string) error { } return trace.Wrap(li.removeLinks(ctx, filepath.Join(versionDir, "bin"), - filepath.Join(versionDir, serviceDir), + filepath.Join(versionDir, serviceDir, serviceName), )) } -// UnlinkSystem unlinks the system (package) version from LinkBinDir and LinkServiceDir. +// UnlinkSystem unlinks the system (package) version from LinkBinDir and CopyServiceFile. // See Installer interface for additional specs. func (li *LocalInstaller) UnlinkSystem(ctx context.Context) error { - return trace.Wrap(li.removeLinks(ctx, li.SystemBinDir, li.SystemServiceDir)) + return trace.Wrap(li.removeLinks(ctx, li.SystemBinDir, li.SystemServiceFile)) } // symlink from oldname to newname @@ -501,7 +499,7 @@ type smallFile struct { // forceLinks will revert any overridden links or files if it hits an error. // If successful, forceLinks may also be reverted after it returns by calling revert. // The revert function returns true if reverting succeeds. -func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcDir string) (revert func(context.Context) bool, err error) { +func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcPath string) (revert func(context.Context) bool, err error) { // setup revert function var ( revertLinks []symlink @@ -544,7 +542,7 @@ func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcDir string) if err != nil { return revert, trace.Wrap(err) } - err = os.MkdirAll(li.LinkServiceDir, systemDirMode) + err = os.MkdirAll(filepath.Dir(li.CopyServiceFile), systemDirMode) if err != nil { return revert, trace.Wrap(err) } @@ -580,11 +578,9 @@ func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcDir string) // create systemd service file - src := filepath.Join(svcDir, serviceName) - dst := filepath.Join(li.LinkServiceDir, serviceName) - orig, err := forceCopy(dst, src, maxServiceFileSize) + orig, err := li.forceCopyService(li.CopyServiceFile, svcPath, maxServiceFileSize) if err != nil && !errors.Is(err, os.ErrExist) { - return revert, trace.Errorf("failed to write file %s: %w", serviceName, err) + return revert, trace.Errorf("failed to copy service: %w", err) } if orig != nil { revertFiles = append(revertFiles, *orig) @@ -592,6 +588,17 @@ func (li *LocalInstaller) forceLinks(ctx context.Context, binDir, svcDir string) return revert, nil } +// forceCopyService uses forceCopy to copy a systemd service file from src to dst. +// The contents of both src and dst must be smaller than n. +// See forceCopy for more details. +func (li *LocalInstaller) forceCopyService(dst, src string, n int64) (orig *smallFile, err error) { + srcData, err := readFileN(src, n) + if err != nil { + return nil, trace.Wrap(err) + } + return forceCopy(dst, li.TransformService(srcData), n) +} + // forceLink attempts to create a symlink, atomically replacing an existing link if already present. // If a non-symlink file or directory exists in newname already, forceLink errors. // If the link is already present with the desired oldname, forceLink returns os.ErrExist. @@ -633,16 +640,12 @@ func isExecutable(path string) (bool, error) { fi.Mode()&0111 == 0111, nil } -// forceCopy atomically copies a file from src to dst, replacing an existing file at dst if needed. -// Both src and dst must be smaller than n. +// forceCopy atomically copies a file from srcData to dst, replacing an existing file at dst if needed. +// The contents of dst must be smaller than n. // forceCopy returns the original file path, mode, and contents as orig. -// If an irregular file, too large file, or directory exists in path already, forceCopy errors. +// If an irregular file, too large file, or directory exists in dst already, forceCopy errors. // If the file is already present with the desired contents, forceCopy returns os.ErrExist. -func forceCopy(dst, src string, n int64) (orig *smallFile, err error) { - srcData, err := readFileN(src, n) - if err != nil { - return nil, trace.Wrap(err) - } +func forceCopy(dst string, srcData []byte, n int64) (orig *smallFile, err error) { fi, err := os.Lstat(dst) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, trace.Wrap(err) @@ -681,7 +684,7 @@ func readFileN(name string, n int64) ([]byte, error) { return data, trace.Wrap(err) } -func (li *LocalInstaller) removeLinks(ctx context.Context, binDir, svcDir string) error { +func (li *LocalInstaller) removeLinks(ctx context.Context, binDir, svcPath string) error { removeService := false entries, err := os.ReadDir(binDir) if err != nil { @@ -720,26 +723,24 @@ func (li *LocalInstaller) removeLinks(ctx context.Context, binDir, svcDir string li.Log.DebugContext(ctx, "Teleport binary not unlinked. Skipping removal of teleport.service.") return nil } - src := filepath.Join(svcDir, serviceName) - srcBytes, err := readFileN(src, maxServiceFileSize) + srcBytes, err := readFileN(svcPath, maxServiceFileSize) if err != nil { return trace.Wrap(err) } - dst := filepath.Join(li.LinkServiceDir, serviceName) - dstBytes, err := readFileN(dst, maxServiceFileSize) + dstBytes, err := readFileN(li.CopyServiceFile, maxServiceFileSize) if errors.Is(err, os.ErrNotExist) { - li.Log.DebugContext(ctx, "Service not present.", "path", dst) + li.Log.DebugContext(ctx, "Service not present.", "path", li.CopyServiceFile) return nil } if err != nil { return trace.Wrap(err) } - if !bytes.Equal(srcBytes, dstBytes) { + if !bytes.Equal(li.TransformService(srcBytes), dstBytes) { li.Log.WarnContext(ctx, "Removed teleport binary link, but skipping removal of custom teleport.service: the service file does not match the reference file for this version. The file might have been manually edited.") return nil } - if err := os.Remove(dst); err != nil { - return trace.Errorf("error removing copy of %s: %w", filepath.Base(dst), err) + if err := os.Remove(li.CopyServiceFile); err != nil { + return trace.Errorf("error removing copy of %s: %w", filepath.Base(li.CopyServiceFile), err) } return nil } @@ -748,13 +749,13 @@ func (li *LocalInstaller) removeLinks(ctx context.Context, binDir, svcDir string // Existing links that point to files outside binDir or svcDir, as well as existing non-link files, will error. // tryLinks will not attempt to create any links if linking could result in an error. // However, concurrent changes to links may result in an error with partially-complete linking. -func (li *LocalInstaller) tryLinks(ctx context.Context, binDir, svcDir string) error { +func (li *LocalInstaller) tryLinks(ctx context.Context, binDir, svcPath string) error { // ensure target directories exist before trying to create links err := os.MkdirAll(li.LinkBinDir, systemDirMode) if err != nil { return trace.Wrap(err) } - err = os.MkdirAll(li.LinkServiceDir, systemDirMode) + err = os.MkdirAll(filepath.Dir(li.CopyServiceFile), systemDirMode) if err != nil { return trace.Wrap(err) } @@ -795,11 +796,9 @@ func (li *LocalInstaller) tryLinks(ctx context.Context, binDir, svcDir string) e } // if any binaries are linked from binDir, always link the service from svcDir - src := filepath.Join(svcDir, serviceName) - dst := filepath.Join(li.LinkServiceDir, serviceName) - _, err = forceCopy(dst, src, maxServiceFileSize) + _, err = li.forceCopyService(li.CopyServiceFile, svcPath, maxServiceFileSize) if err != nil && !errors.Is(err, os.ErrExist) { - return trace.Errorf("error writing %s: %w", serviceName, err) + return trace.Errorf("failed to copy service: %w", err) } return nil diff --git a/lib/autoupdate/agent/installer_test.go b/lib/autoupdate/agent/installer_test.go index 9b9c9b268490e..22c983fdbfeb0 100644 --- a/lib/autoupdate/agent/installer_test.go +++ b/lib/autoupdate/agent/installer_test.go @@ -204,8 +204,9 @@ func TestLocalInstaller_Link(t *testing.T) { existingLinks []string existingFiles []string - resultPaths []string - errMatch string + resultLinks []string + resultServices []string + errMatch string }{ { name: "present with new links", @@ -226,10 +227,12 @@ func TestLocalInstaller_Link(t *testing.T) { }, installFileMode: os.ModePerm, - resultPaths: []string{ + resultLinks: []string{ "bin/teleport", "bin/tsh", "bin/tbot", + }, + resultServices: []string{ "lib/systemd/system/teleport.service", }, }, @@ -281,10 +284,12 @@ func TestLocalInstaller_Link(t *testing.T) { "lib/systemd/system/teleport.service", }, - resultPaths: []string{ + resultLinks: []string{ "bin/teleport", "bin/tsh", "bin/tbot", + }, + resultServices: []string{ "lib/systemd/system/teleport.service", }, }, @@ -392,10 +397,13 @@ func TestLocalInstaller_Link(t *testing.T) { } installer := &LocalInstaller{ - InstallDir: versionsDir, - LinkBinDir: filepath.Join(linkDir, "bin"), - LinkServiceDir: filepath.Join(linkDir, serviceDir), - Log: slog.Default(), + InstallDir: versionsDir, + LinkBinDir: filepath.Join(linkDir, "bin"), + CopyServiceFile: filepath.Join(linkDir, serviceDir, serviceName), + Log: slog.Default(), + TransformService: func(b []byte) []byte { + return []byte("[transform]" + string(b)) + }, } ctx := context.Background() revert, err := installer.Link(ctx, version) @@ -423,11 +431,16 @@ func TestLocalInstaller_Link(t *testing.T) { require.NoError(t, err) // verify links - for _, link := range tt.resultPaths { + for _, link := range tt.resultLinks { v, err := os.ReadFile(filepath.Join(linkDir, link)) require.NoError(t, err) require.Equal(t, filepath.Base(link), string(v)) } + for _, svc := range tt.resultServices { + v, err := os.ReadFile(filepath.Join(linkDir, svc)) + require.NoError(t, err) + require.Equal(t, "[transform]"+filepath.Base(svc), string(v)) + } // verify manual revert ok := revert(ctx) @@ -459,8 +472,9 @@ func TestLocalInstaller_TryLink(t *testing.T) { existingLinks []string existingFiles []string - resultPaths []string - errMatch string + resultLinks []string + resultServices []string + errMatch string }{ { name: "present with new links", @@ -481,10 +495,12 @@ func TestLocalInstaller_TryLink(t *testing.T) { }, installFileMode: os.ModePerm, - resultPaths: []string{ + resultLinks: []string{ "bin/teleport", "bin/tsh", "bin/tbot", + }, + resultServices: []string{ "lib/systemd/system/teleport.service", }, }, @@ -634,10 +650,13 @@ func TestLocalInstaller_TryLink(t *testing.T) { } installer := &LocalInstaller{ - InstallDir: versionsDir, - LinkBinDir: filepath.Join(linkDir, "bin"), - LinkServiceDir: filepath.Join(linkDir, serviceDir), - Log: slog.Default(), + InstallDir: versionsDir, + LinkBinDir: filepath.Join(linkDir, "bin"), + CopyServiceFile: filepath.Join(linkDir, serviceDir, serviceName), + Log: slog.Default(), + TransformService: func(b []byte) []byte { + return []byte("[transform]" + string(b)) + }, } ctx := context.Background() err = installer.TryLink(ctx, version) @@ -661,11 +680,17 @@ func TestLocalInstaller_TryLink(t *testing.T) { require.NoError(t, err) // verify links - for _, link := range tt.resultPaths { + for _, link := range tt.resultLinks { v, err := os.ReadFile(filepath.Join(linkDir, link)) require.NoError(t, err) require.Equal(t, filepath.Base(link), string(v)) } + for _, svc := range tt.resultServices { + v, err := os.ReadFile(filepath.Join(linkDir, svc)) + require.NoError(t, err) + require.Equal(t, "[transform]"+filepath.Base(svc), string(v)) + } + }) } } @@ -773,10 +798,13 @@ func TestLocalInstaller_Remove(t *testing.T) { linkDir := t.TempDir() installer := &LocalInstaller{ - InstallDir: versionsDir, - LinkBinDir: filepath.Join(linkDir, "bin"), - LinkServiceDir: filepath.Join(linkDir, serviceDir), - Log: slog.Default(), + InstallDir: versionsDir, + LinkBinDir: filepath.Join(linkDir, "bin"), + CopyServiceFile: filepath.Join(linkDir, serviceDir, serviceName), + Log: slog.Default(), + TransformService: func(b []byte) []byte { + return []byte("[transform]" + string(b)) + }, } ctx := context.Background() @@ -821,7 +849,7 @@ func TestLocalInstaller_Unlink(t *testing.T) { {oldname: "bin/teleport", newname: "bin/teleport"}, {oldname: "bin/tsh", newname: "bin/tsh"}, }, - svcCopy: []byte("orig"), + svcCopy: []byte("[transform]orig"), }, { name: "different services", @@ -861,7 +889,7 @@ func TestLocalInstaller_Unlink(t *testing.T) { links: []symlink{ {oldname: "bin/tsh", newname: "bin/tsh"}, }, - svcCopy: []byte("orig"), + svcCopy: []byte("[transform]orig"), remaining: []string{servicePath}, }, { @@ -871,7 +899,7 @@ func TestLocalInstaller_Unlink(t *testing.T) { links: []symlink{ {oldname: "bin/teleport", newname: "bin/teleport"}, }, - svcCopy: []byte("orig"), + svcCopy: []byte("[transform]orig"), }, { name: "wrong teleport link", @@ -881,7 +909,7 @@ func TestLocalInstaller_Unlink(t *testing.T) { {oldname: "other", newname: "bin/teleport"}, {oldname: "bin/tsh", newname: "bin/tsh"}, }, - svcCopy: []byte("orig"), + svcCopy: []byte("[transform]orig"), remaining: []string{servicePath, "bin/teleport"}, }, { @@ -892,7 +920,7 @@ func TestLocalInstaller_Unlink(t *testing.T) { {oldname: "bin/teleport", newname: "bin/teleport"}, {oldname: "wrong", newname: "bin/tsh"}, }, - svcCopy: []byte("orig"), + svcCopy: []byte("[transform]orig"), remaining: []string{"bin/tsh"}, }, } @@ -944,10 +972,13 @@ func TestLocalInstaller_Unlink(t *testing.T) { } installer := &LocalInstaller{ - InstallDir: versionsDir, - LinkBinDir: filepath.Join(linkDir, "bin"), - LinkServiceDir: filepath.Join(linkDir, serviceDir), - Log: slog.Default(), + InstallDir: versionsDir, + LinkBinDir: filepath.Join(linkDir, "bin"), + CopyServiceFile: filepath.Join(linkDir, serviceDir, serviceName), + Log: slog.Default(), + TransformService: func(b []byte) []byte { + return []byte("[transform]" + string(b)) + }, } ctx := context.Background() err = installer.Unlink(ctx, version) diff --git a/lib/autoupdate/agent/setup.go b/lib/autoupdate/agent/setup.go index a8bf12a0afe0a..d180576c2a940 100644 --- a/lib/autoupdate/agent/setup.go +++ b/lib/autoupdate/agent/setup.go @@ -19,28 +19,45 @@ package agent import ( + "bytes" "context" "errors" "io/fs" "log/slog" "os" "path/filepath" + "regexp" "text/template" "github.com/google/renameio/v2" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/defaults" +) + +// Base paths for constructing namespaced directories. +const ( + teleportOptDir = "/opt/teleport" + versionsDirName = "versions" + systemdAdminDir = "/etc/systemd/system" + systemdPIDFile = "/run/teleport.pid" + defaultNamespace = "default" + systemNamespace = "system" + lockFileName = "update.lock" ) const ( updateServiceTemplate = `# teleport-update +# DO NOT EDIT THIS FILE [Unit] Description=Teleport auto-update service [Service] Type=oneshot -ExecStart={{.LinkDir}}/bin/teleport-update update +ExecStart={{.UpdaterCommand}} ` updateTimerTemplate = `# teleport-update +# DO NOT EDIT THIS FILE [Unit] Description=Teleport auto-update timer unit @@ -50,20 +67,124 @@ OnUnitActiveSec=5m RandomizedDelaySec=1m [Install] -WantedBy=teleport.service +WantedBy={{.TeleportService}} ` ) +// Namespace represents a namespace within various system paths for a isolated installation of Teleport. +type Namespace struct { + log *slog.Logger + // name of namespace + name string + // dataDir for Teleport + dataDir string + // linkDir for Teleport binaries (ns: /opt/teleport/myns/bin) + linkDir string + // versionsDir for Teleport versions (ns: /opt/teleport/myns/versions) + versionsDir string + // serviceFile for the Teleport systemd service (ns: /etc/systemd/system/teleport_myns.service) + serviceFile string + // configFile for Teleport config (ns: /opt/teleport/myns/etc/teleport.yaml) + configFile string + // pidFile for Teleport (ns: /run/teleport_myns.pid) + pidFile string + // updaterLockFile for locking the updater (ns: /opt/teleport/myns/update.lock) + updaterLockFile string + // updaterConfigFile for configuring updates (ns: /opt/teleport/myns/update.yaml) + updaterConfigFile string + // updaterBinFile for the updater when linked (linkDir + name) + updaterBinFile string + // updaterServiceFile is the systemd service path for the updater + updaterServiceFile string + // updaterTimerFile is the systemd timer path for the updater + updaterTimerFile string +} + +var alphanum = regexp.MustCompile("^[a-zA-Z0-9-]*$") + +// NewNamespace validates and returns a Namespace. +// Namespaces must be alphanumeric + `-`. +func NewNamespace(log *slog.Logger, name, dataDir, linkDir string) (*Namespace, error) { + if name == defaultNamespace || + name == systemNamespace { + return nil, trace.Errorf("namespace %q is reserved", name) + } + if !alphanum.MatchString(name) { + return nil, trace.Errorf("invalid namespace name %q, must be alphanumeric", name) + } + if name == "" { + if dataDir == "" { + dataDir = defaults.DataDir + } + if linkDir == "" { + linkDir = DefaultLinkDir + } + return &Namespace{ + log: log, + name: name, + dataDir: dataDir, + linkDir: linkDir, + versionsDir: filepath.Join(namespaceDir(name), versionsDirName), + serviceFile: filepath.Join("/", serviceDir, serviceName), + configFile: defaults.ConfigFilePath, + pidFile: systemdPIDFile, + updaterLockFile: filepath.Join(namespaceDir(name), lockFileName), + updaterConfigFile: filepath.Join(namespaceDir(name), updateConfigName), + updaterBinFile: filepath.Join(linkDir, BinaryName), + updaterServiceFile: filepath.Join(systemdAdminDir, BinaryName+".service"), + updaterTimerFile: filepath.Join(systemdAdminDir, BinaryName+".timer"), + }, nil + } + + prefix := "teleport_" + name + if dataDir == "" { + dataDir = filepath.Join(filepath.Dir(defaults.DataDir), prefix) + } + if linkDir == "" { + linkDir = filepath.Join(namespaceDir(name), "bin") + } + return &Namespace{ + log: log, + name: name, + dataDir: dataDir, + linkDir: linkDir, + versionsDir: filepath.Join(namespaceDir(name), versionsDirName), + serviceFile: filepath.Join(systemdAdminDir, prefix+".service"), + configFile: filepath.Join(filepath.Dir(defaults.ConfigFilePath), prefix+".yaml"), + pidFile: filepath.Join(filepath.Dir(systemdPIDFile), prefix+".pid"), + updaterLockFile: filepath.Join(namespaceDir(name), lockFileName), + updaterConfigFile: filepath.Join(namespaceDir(name), updateConfigName), + updaterBinFile: filepath.Join(linkDir, BinaryName), + updaterServiceFile: filepath.Join(systemdAdminDir, BinaryName+"_"+name+".service"), + updaterTimerFile: filepath.Join(systemdAdminDir, BinaryName+"_"+name+".timer"), + }, nil +} + +func namespaceDir(name string) string { + if name == "" { + name = defaultNamespace + } + return filepath.Join(teleportOptDir, name) +} + +// Init create the initial directory structure and returns the lockfile for a Namespace. +func (ns *Namespace) Init() (lockFile string, err error) { + if err := os.MkdirAll(ns.versionsDir, systemDirMode); err != nil { + return "", trace.Wrap(err) + } + return ns.updaterLockFile, nil +} + // 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) +func (ns *Namespace) Setup(ctx context.Context) error { + err := ns.writeConfigFiles() if err != nil { return trace.Errorf("failed to write teleport-update systemd config files: %w", err) } svc := &SystemdService{ - ServiceName: "teleport-update.timer", - Log: log, + ServiceName: filepath.Base(ns.updaterTimerFile), + Log: ns.log, } if err := svc.Sync(ctx); err != nil { return trace.Errorf("failed to sync systemd config: %w", err) @@ -75,46 +196,54 @@ func Setup(ctx context.Context, log *slog.Logger, linkDir, dataDir string) error } // Teardown removes all traces of the auto-updater, including its configuration. -func Teardown(ctx context.Context, log *slog.Logger, linkDir, dataDir string) error { +func (ns *Namespace) Teardown(ctx context.Context) error { svc := &SystemdService{ - ServiceName: "teleport-update.timer", - Log: log, + ServiceName: filepath.Base(ns.updaterTimerFile), + Log: ns.log, } if err := svc.Disable(ctx); err != nil { return trace.Errorf("failed to disable teleport-update systemd timer: %w", err) } - servicePath := filepath.Join(linkDir, serviceDir, updateServiceName) - if err := os.Remove(servicePath); err != nil && !errors.Is(err, fs.ErrNotExist) { + if err := os.Remove(ns.updaterServiceFile); err != nil && !errors.Is(err, fs.ErrNotExist) { return trace.Errorf("failed to remove teleport-update systemd service: %w", err) } - timerPath := filepath.Join(linkDir, serviceDir, updateTimerName) - if err := os.Remove(timerPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + if err := os.Remove(ns.updaterTimerFile); err != nil && !errors.Is(err, fs.ErrNotExist) { return trace.Errorf("failed to remove teleport-update systemd timer: %w", err) } if err := svc.Sync(ctx); err != nil { return trace.Errorf("failed to sync systemd config: %w", err) } - if err := os.RemoveAll(filepath.Join(dataDir, VersionsDirName)); err != nil { + if err := os.RemoveAll(ns.versionsDir); err != nil { return trace.Errorf("failed to remove versions directory: %w", err) } return nil } -func writeConfigFiles(linkDir, dataDir string) error { - servicePath := filepath.Join(linkDir, serviceDir, updateServiceName) - err := writeTemplate(servicePath, updateServiceTemplate, linkDir, dataDir) +func (ns *Namespace) writeConfigFiles() error { + var args string + if ns.name != "" { + args = " --install-suffix=" + ns.name + } + err := writeTemplate( + ns.updaterServiceFile, updateServiceTemplate, + struct{ UpdaterCommand string }{ + ns.updaterBinFile + args + " update", + }, + ) if err != nil { return trace.Wrap(err) } - timerPath := filepath.Join(linkDir, serviceDir, updateTimerName) - err = writeTemplate(timerPath, updateTimerTemplate, linkDir, dataDir) + err = writeTemplate( + ns.updaterTimerFile, updateTimerTemplate, + struct{ TeleportService string }{filepath.Base(ns.serviceFile)}, + ) if err != nil { return trace.Wrap(err) } return nil } -func writeTemplate(path, t, linkDir, dataDir string) error { +func writeTemplate(path, t string, values any) error { dir, file := filepath.Split(path) if err := os.MkdirAll(dir, systemDirMode); err != nil { return trace.Wrap(err) @@ -133,12 +262,42 @@ func writeTemplate(path, t, linkDir, dataDir string) error { if err != nil { return trace.Wrap(err) } - err = tmpl.Execute(f, struct { - LinkDir string - DataDir string - }{linkDir, dataDir}) + err = tmpl.Execute(f, values) if err != nil { return trace.Wrap(err) } return trace.Wrap(f.CloseAtomicallyReplace()) } + +// replaceTeleportService replaces the default paths in the Teleport service config with namespaced paths. +func (ns *Namespace) replaceTeleportService(cfg []byte) []byte { + for _, rep := range []struct { + old, new string + }{ + { + old: "/usr/local/bin/", + new: ns.linkDir + "/", + }, + { + old: "/etc/teleport.yaml", + new: ns.configFile, + }, + { + old: "/run/teleport.pid", + new: ns.pidFile, + }, + } { + cfg = bytes.ReplaceAll(cfg, []byte(rep.old), []byte(rep.new)) + } + return cfg +} + +func (ns *Namespace) LogWarning(ctx context.Context) { + ns.log.WarnContext(ctx, "Custom install suffix specified. Teleport data_dir must be configured in the config file.", + "data_dir", ns.dataDir, + "path", ns.linkDir, + "config", ns.configFile, + "service", filepath.Base(ns.serviceFile), + "pid", ns.pidFile, + ) +} diff --git a/lib/autoupdate/agent/setup_test.go b/lib/autoupdate/agent/setup_test.go index 16cbdb5374fb6..8892acb85e06c 100644 --- a/lib/autoupdate/agent/setup_test.go +++ b/lib/autoupdate/agent/setup_test.go @@ -20,46 +20,172 @@ package agent import ( "bytes" + "log/slog" "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) +func TestNewNamespace(t *testing.T) { + for _, p := range []struct { + name string + namespace string + linkDir string + dataDir string + errMatch string + ns *Namespace + }{ + { + name: "no namespace", + ns: &Namespace{ + dataDir: "/var/lib/teleport", + linkDir: "/usr/local/bin", + versionsDir: "/opt/teleport/default/versions", + serviceFile: "/lib/systemd/system/teleport.service", + configFile: "/etc/teleport.yaml", + pidFile: "/run/teleport.pid", + updaterLockFile: "/opt/teleport/default/update.lock", + updaterConfigFile: "/opt/teleport/default/update.yaml", + updaterBinFile: "/usr/local/bin/teleport-update", + updaterServiceFile: "/etc/systemd/system/teleport-update.service", + updaterTimerFile: "/etc/systemd/system/teleport-update.timer", + }, + }, + { + name: "no namespace with dirs", + linkDir: "/link", + dataDir: "/data", + ns: &Namespace{ + dataDir: "/data", + linkDir: "/link", + versionsDir: "/opt/teleport/default/versions", + serviceFile: "/lib/systemd/system/teleport.service", + configFile: "/etc/teleport.yaml", + pidFile: "/run/teleport.pid", + updaterLockFile: "/opt/teleport/default/update.lock", + updaterConfigFile: "/opt/teleport/default/update.yaml", + updaterBinFile: "/link/teleport-update", + updaterServiceFile: "/etc/systemd/system/teleport-update.service", + updaterTimerFile: "/etc/systemd/system/teleport-update.timer", + }, + }, + { + name: "test namespace", + namespace: "test", + ns: &Namespace{ + name: "test", + dataDir: "/var/lib/teleport_test", + linkDir: "/opt/teleport/test/bin", + versionsDir: "/opt/teleport/test/versions", + serviceFile: "/etc/systemd/system/teleport_test.service", + configFile: "/etc/teleport_test.yaml", + pidFile: "/run/teleport_test.pid", + updaterLockFile: "/opt/teleport/test/update.lock", + updaterConfigFile: "/opt/teleport/test/update.yaml", + updaterBinFile: "/opt/teleport/test/bin/teleport-update", + updaterServiceFile: "/etc/systemd/system/teleport-update_test.service", + updaterTimerFile: "/etc/systemd/system/teleport-update_test.timer", + }, + }, + { + name: "test namespace with dirs", + namespace: "test", + linkDir: "/link", + dataDir: "/data", + ns: &Namespace{ + name: "test", + dataDir: "/data", + linkDir: "/link", + versionsDir: "/opt/teleport/test/versions", + serviceFile: "/etc/systemd/system/teleport_test.service", + configFile: "/etc/teleport_test.yaml", + pidFile: "/run/teleport_test.pid", + updaterLockFile: "/opt/teleport/test/update.lock", + updaterConfigFile: "/opt/teleport/test/update.yaml", + updaterBinFile: "/link/teleport-update", + updaterServiceFile: "/etc/systemd/system/teleport-update_test.service", + updaterTimerFile: "/etc/systemd/system/teleport-update_test.timer", + }, + }, + { + name: "reserved default", + namespace: defaultNamespace, + errMatch: "reserved", + }, + { + name: "reserved system", + namespace: systemNamespace, + errMatch: "reserved", + }, + } { + t.Run(p.name, func(t *testing.T) { + log := slog.Default() + ns, err := NewNamespace(log, p.namespace, p.dataDir, p.linkDir) + if p.errMatch != "" { + require.Error(t, err) + require.Contains(t, err.Error(), p.errMatch) + return + } + require.NoError(t, err) + ns.log = nil + require.Equal(t, p.ns, ns) + }) + } +} - for _, p := range []string{ - filepath.Join(linkDir, serviceDir, updateServiceName), - filepath.Join(linkDir, serviceDir, updateTimerName), +func TestWriteConfigFiles(t *testing.T) { + for _, p := range []struct { + name string + namespace string + }{ + { + name: "no namespace", + }, + { + name: "test namespace", + namespace: "test", + }, } { - t.Run(filepath.Base(p), func(t *testing.T) { - data, err := os.ReadFile(p) + t.Run(p.name, func(t *testing.T) { + log := slog.Default() + linkDir := t.TempDir() + ns, err := NewNamespace(log, p.namespace, "", linkDir) + require.NoError(t, err) + ns.updaterServiceFile = filepath.Join(linkDir, serviceDir, filepath.Base(ns.updaterServiceFile)) + ns.updaterTimerFile = filepath.Join(linkDir, serviceDir, filepath.Base(ns.updaterTimerFile)) + err = ns.writeConfigFiles() + require.NoError(t, err) + + data, err := os.ReadFile(ns.updaterServiceFile) + require.NoError(t, err) + data = replaceValues(data, map[string]string{ + DefaultLinkDir: linkDir, + }) + if golden.ShouldSet() { + golden.SetNamed(t, "service", data) + } + require.Equal(t, string(golden.GetNamed(t, "service")), string(data)) + + data, err = os.ReadFile(ns.updaterTimerFile) require.NoError(t, err) data = replaceValues(data, map[string]string{ - DefaultLinkDir: linkDir, - libdefaults.DataDir: dataDir, + DefaultLinkDir: linkDir, }) if golden.ShouldSet() { - golden.Set(t, data) + golden.SetNamed(t, "timer", data) } - require.Equal(t, string(golden.Get(t)), string(data)) + require.Equal(t, string(golden.GetNamed(t, "timer")), string(data)) }) } } func replaceValues(data []byte, m map[string]string) []byte { for k, v := range m { - data = bytes.ReplaceAll(data, []byte(v), - []byte(k)) + data = bytes.ReplaceAll(data, []byte(v), []byte(k)) } return data } diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/service.golden similarity index 85% rename from lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden rename to lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/service.golden index 185b4f07a1aa9..6f9c2affce999 100644 --- a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.service.golden +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/service.golden @@ -1,4 +1,5 @@ # teleport-update +# DO NOT EDIT THIS FILE [Unit] Description=Teleport auto-update service diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/timer.golden similarity index 87% rename from lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden rename to lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/timer.golden index acca095d9825f..d14a43d679e53 100644 --- a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/teleport-update.timer.golden +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/no_namespace/timer.golden @@ -1,4 +1,5 @@ # teleport-update +# DO NOT EDIT THIS FILE [Unit] Description=Teleport auto-update timer unit diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/service.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/service.golden new file mode 100644 index 0000000000000..030c99fd644e4 --- /dev/null +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/service.golden @@ -0,0 +1,8 @@ +# teleport-update +# DO NOT EDIT THIS FILE +[Unit] +Description=Teleport auto-update service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/teleport-update --install-suffix=test update diff --git a/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/timer.golden b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/timer.golden new file mode 100644 index 0000000000000..f57a3c08055bc --- /dev/null +++ b/lib/autoupdate/agent/testdata/TestWriteConfigFiles/test_namespace/timer.golden @@ -0,0 +1,12 @@ +# teleport-update +# DO NOT EDIT THIS FILE +[Unit] +Description=Teleport auto-update timer unit + +[Timer] +OnActiveSec=1m +OnUnitActiveSec=5m +RandomizedDelaySec=1m + +[Install] +WantedBy=teleport_test.service diff --git a/lib/autoupdate/agent/updater.go b/lib/autoupdate/agent/updater.go index 9c79a49d85967..283017b47fb2b 100644 --- a/lib/autoupdate/agent/updater.go +++ b/lib/autoupdate/agent/updater.go @@ -43,16 +43,14 @@ import ( const ( // DefaultLinkDir is the default location where Teleport is linked. - 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" + DefaultLinkDir = "/usr/local/bin" // BinaryName specifies the name of the updater binary. BinaryName = "teleport-update" ) const ( + // defaultSystemDir is the location where packaged Teleport binaries and services are installed. + defaultSystemDir = "/opt/teleport/system" // cdnURITemplate is the default template for the Teleport tgz download. cdnURITemplate = "https://cdn.teleport.dev/teleport{{if .Enterprise}}-ent{{end}}-v{{.Version}}-{{.OS}}-{{.Arch}}{{if .FIPS}}-fips{{end}}-bin.tar.gz" // reservedFreeDisk is the minimum required free space left on disk during downloads. @@ -73,7 +71,7 @@ const ( // installations of the Teleport agent. // The AutoUpdater uses an HTTP client with sane defaults for downloads, and // will not fill disk to within 10 MB of available capacity. -func NewLocalUpdater(cfg LocalUpdaterConfig) (*Updater, error) { +func NewLocalUpdater(cfg LocalUpdaterConfig, ns *Namespace) (*Updater, error) { certPool, err := x509.SystemCertPool() if err != nil { return nil, trace.Wrap(err) @@ -93,50 +91,40 @@ func NewLocalUpdater(cfg LocalUpdaterConfig) (*Updater, error) { if cfg.Log == nil { cfg.Log = slog.Default() } - if cfg.LinkDir == "" { - cfg.LinkDir = DefaultLinkDir - } if cfg.SystemDir == "" { - cfg.SystemDir = DefaultSystemDir - } - 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) + cfg.SystemDir = defaultSystemDir } return &Updater{ Log: cfg.Log, Pool: certPool, InsecureSkipVerify: cfg.InsecureSkipVerify, - ConfigPath: filepath.Join(installDir, updateConfigName), + ConfigPath: ns.updaterConfigFile, Installer: &LocalInstaller{ - 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. - LinkServiceDir: filepath.Join("/", serviceDir), + InstallDir: ns.versionsDir, + LinkBinDir: ns.linkDir, + CopyServiceFile: ns.serviceFile, SystemBinDir: filepath.Join(cfg.SystemDir, "bin"), - SystemServiceDir: filepath.Join(cfg.SystemDir, serviceDir), + SystemServiceFile: filepath.Join(cfg.SystemDir, serviceDir, serviceName), HTTP: client, Log: cfg.Log, ReservedFreeTmpDisk: reservedFreeDisk, ReservedFreeInstallDisk: reservedFreeDisk, + TransformService: ns.replaceTeleportService, }, Process: &SystemdService{ - ServiceName: "teleport.service", - PIDPath: "/run/teleport.pid", + ServiceName: filepath.Base(ns.serviceFile), + PIDPath: ns.pidFile, Log: cfg.Log, }, Setup: func(ctx context.Context) error { - name := filepath.Join(cfg.LinkDir, "bin", BinaryName) + name := ns.updaterBinFile if cfg.SelfSetup && runtime.GOOS == constants.LinuxOS { name = "/proc/self/exe" } cmd := exec.CommandContext(ctx, name, - "--data-dir", cfg.DataDir, - "--link-dir", cfg.LinkDir, + "--data-dir", ns.dataDir, + "--link-dir", ns.linkDir, + "--install-suffix", ns.name, "setup") cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout @@ -148,12 +136,8 @@ func NewLocalUpdater(cfg LocalUpdaterConfig) (*Updater, error) { } return trace.Wrap(err) }, - Revert: func(ctx context.Context) error { - return trace.Wrap(Setup(ctx, cfg.Log, cfg.LinkDir, cfg.DataDir)) - }, - Teardown: func(ctx context.Context) error { - return trace.Wrap(Teardown(ctx, cfg.Log, cfg.LinkDir, cfg.DataDir)) - }, + Revert: ns.Setup, + Teardown: ns.Teardown, }, nil } @@ -167,11 +151,7 @@ type LocalUpdaterConfig struct { // DownloadTimeout is a timeout for file download requests. // Defaults to no timeout. DownloadTimeout time.Duration - // 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 for package-installed Teleport installations (usually /opt/teleport/system). SystemDir string // SelfSetup mode for using the current version of the teleport-update to setup the update service. SelfSetup bool diff --git a/lib/autoupdate/agent/updater_test.go b/lib/autoupdate/agent/updater_test.go index fb384cf737ca9..e4afd63740a76 100644 --- a/lib/autoupdate/agent/updater_test.go +++ b/lib/autoupdate/agent/updater_test.go @@ -84,12 +84,14 @@ 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, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case @@ -170,12 +172,14 @@ func TestUpdater_Unpin(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case @@ -543,12 +547,14 @@ func TestUpdater_Update(t *testing.T) { t.Cleanup(server.Close) dir := t.TempDir() - cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case @@ -759,12 +765,14 @@ 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, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case @@ -969,12 +977,14 @@ func TestUpdater_Remove(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case @@ -1298,12 +1308,14 @@ func TestUpdater_Install(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, VersionsDirName, "update.yaml") + cfgPath := filepath.Join(dir, updateConfigName) + ns := &Namespace{ + updaterConfigFile: cfgPath, + } updater, err := NewLocalUpdater(LocalUpdaterConfig{ InsecureSkipVerify: true, - DataDir: dir, - }) + }, ns) require.NoError(t, err) // Create config file only if provided in test case diff --git a/tool/teleport-update/main.go b/tool/teleport-update/main.go index e6702ae9f35c2..5d34457b8882b 100644 --- a/tool/teleport-update/main.go +++ b/tool/teleport-update/main.go @@ -25,7 +25,6 @@ import ( "log/slog" "os" "os/signal" - "path/filepath" "syscall" "github.com/gravitational/trace" @@ -41,8 +40,7 @@ import ( const appHelp = `Teleport Updater -The Teleport Updater updates the version a Teleport agent on a Linux server -that is being used as agent to provide connectivity to Teleport resources. +The Teleport Updater automatically updates a Teleport agent. The Teleport Updater supports upgrade schedules and automated rollbacks. @@ -59,22 +57,16 @@ const ( updateVersionEnvVar = "TELEPORT_UPDATE_VERSION" ) -const ( - // 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) func main() { - if err := Run(os.Args[1:]); err != nil { - libutils.FatalError(err) + if code := Run(os.Args[1:]); code != 0 { + os.Exit(code) } } type cliConfig struct { autoupdate.OverrideConfig - // Debug logs enabled Debug bool // LogFormat controls the format of logging. Can be either `json` or `text`. @@ -84,12 +76,18 @@ type cliConfig struct { DataDir string // LinkDir for linking binaries and systemd services LinkDir string + // InstallSuffix is the isolated suffix for the installation. + InstallSuffix string // SelfSetup mode for using the current version of the teleport-update to setup the update service. SelfSetup bool } -func Run(args []string) error { - var ccfg cliConfig +func Run(args []string) int { + var ( + ccfg cliConfig + userLinkDir bool + userDataDir bool + ) ctx := context.Background() ctx, _ = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) @@ -97,45 +95,47 @@ func Run(args []string) error { 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."). - Default(libdefaults.DataDir).StringVar(&ccfg.DataDir) + Default(libdefaults.DataDir).IsSetByUser(&userDataDir).StringVar(&ccfg.DataDir) app.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). Default(libutils.LogFormatText).EnumVar(&ccfg.LogFormat, libutils.LogFormatJSON, libutils.LogFormatText) - app.Flag("link-dir", "Directory to link the active Teleport installation into."). - Default(autoupdate.DefaultLinkDir).Hidden().StringVar(&ccfg.LinkDir) + app.Flag("install-suffix", "Suffix for creating an agent installation outside of the default $PATH. Note: this changes the default data directory."). + Short('n').StringVar(&ccfg.InstallSuffix) + app.Flag("link-dir", "Directory to link the active Teleport installation's binaries into."). + Default(autoupdate.DefaultLinkDir).IsSetByUser(&userLinkDir).Hidden().StringVar(&ccfg.LinkDir) app.HelpFlag.Short('h') 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 installation or update.") + enableCmd := app.Command("enable", "Enable agent auto-updates and perform initial installation or update. This creates a systemd timer that periodically runs the update subcommand.") enableCmd.Flag("proxy", "Address of the Teleport Proxy."). Short('p').Envar(proxyServerEnvVar).StringVar(&ccfg.Proxy) enableCmd.Flag("group", "Update group for this agent installation."). Short('g').Envar(updateGroupEnvVar).StringVar(&ccfg.Group) - enableCmd.Flag("template", "Go template used to override Teleport download URL."). + enableCmd.Flag("template", "Go template used to override the Teleport download URL."). Short('t').Envar(templateEnvVar).StringVar(&ccfg.URLTemplate) - enableCmd.Flag("force-version", "Force the provided version instead of querying it from the Teleport cluster."). + enableCmd.Flag("force-version", "Force the provided version instead of using the version provided by 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 - pinCmd := app.Command("pin", "Install Teleport and lock the updater on the installed version.") + pinCmd := app.Command("pin", "Install Teleport and lock the updater to the installed version.") pinCmd.Flag("proxy", "Address of the Teleport Proxy."). Short('p').Envar(proxyServerEnvVar).StringVar(&ccfg.Proxy) pinCmd.Flag("group", "Update group for this agent installation."). Short('g').Envar(updateGroupEnvVar).StringVar(&ccfg.Group) pinCmd.Flag("template", "Go template used to override Teleport download URL."). Short('t').Envar(templateEnvVar).StringVar(&ccfg.URLTemplate) - pinCmd.Flag("force-version", "Force the provided version instead of querying it from the Teleport cluster."). + pinCmd.Flag("force-version", "Force the provided version instead of using the version provided by the Teleport cluster."). Short('f').Envar(updateVersionEnvVar).StringVar(&ccfg.ForceVersion) pinCmd.Flag("self-setup", "Use the current teleport-update binary to create systemd service config for auto-updates."). Short('s').Hidden().BoolVar(&ccfg.SelfSetup) - disableCmd := app.Command("disable", "Disable agent auto-updates.") + disableCmd := app.Command("disable", "Disable agent auto-updates. Does not affect the active installation of Teleport.") unpinCmd := app.Command("unpin", "Unpin the current version, allowing it to be updated.") - updateCmd := app.Command("update", "Update agent to the latest version, if a new version is available.") + updateCmd := app.Command("update", "Update the 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) @@ -153,12 +153,23 @@ func Run(args []string) error { command, err := app.Parse(args) if err != nil { app.Usage(args) - return trace.Wrap(err) + libutils.FatalError(err) } + + // These have different defaults if --install-suffix is specified. + // If the user did not set them, let autoupdate.NewNamespace set them. + if !userDataDir { + ccfg.DataDir = "" + } + if !userLinkDir { + ccfg.LinkDir = "" + } + // Logging must be configured as early as possible to ensure all log // message are formatted correctly. if err := setupLogger(ccfg.Debug, ccfg.LogFormat); err != nil { - return trace.Errorf("failed to set up logger") + plog.ErrorContext(ctx, "Failed to set up logger.", "error", err) + return 1 } switch command { @@ -175,9 +186,9 @@ func Run(args []string) error { case updateCmd.FullCommand(): err = cmdUpdate(ctx, &ccfg) case linkCmd.FullCommand(): - err = cmdLink(ctx, &ccfg) + err = cmdLinkPackage(ctx, &ccfg) case unlinkCmd.FullCommand(): - err = cmdUnlink(ctx, &ccfg) + err = cmdUnlinkPackage(ctx, &ccfg) case setupCmd.FullCommand(): err = cmdSetup(ctx, &ccfg) case statusCmd.FullCommand(): @@ -190,8 +201,14 @@ func Run(args []string) error { // This should only happen when there's a missing switch case above. err = trace.Errorf("command %q not configured", command) } - - return err + if errors.Is(err, autoupdate.ErrNotSupported) { + return autoupdate.CodeNotSupported + } + if err != nil { + plog.ErrorContext(ctx, "Command failed.", "error", err) + return 1 + } + return 0 } func setupLogger(debug bool, format string) error { @@ -211,19 +228,41 @@ func setupLogger(debug bool, format string) error { return nil } -// cmdDisable disables updates. -func cmdDisable(ctx context.Context, ccfg *cliConfig) error { +func initConfig(ccfg *cliConfig) (updater *autoupdate.Updater, lockFile string, err error) { + ns, err := autoupdate.NewNamespace(plog, ccfg.InstallSuffix, ccfg.DataDir, ccfg.LinkDir) + if err != nil { + return nil, "", trace.Wrap(err) + } + lockFile, err = ns.Init() + if err != nil { + return nil, "", trace.Wrap(err) + } + updater, err = autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ + SelfSetup: ccfg.SelfSetup, + Log: plog, + }, ns) + return updater, lockFile, trace.Wrap(err) +} + +func statusConfig(ccfg *cliConfig) (*autoupdate.Updater, error) { + ns, err := autoupdate.NewNamespace(plog, ccfg.InstallSuffix, ccfg.DataDir, ccfg.LinkDir) + if err != nil { + return nil, trace.Wrap(err) + } updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, SelfSetup: ccfg.SelfSetup, Log: plog, - }) + }, ns) + return updater, trace.Wrap(err) +} + +// cmdDisable disables updates. +func cmdDisable(ctx context.Context, ccfg *cliConfig) error { + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } - unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSWriteLock(lockFile) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -240,17 +279,11 @@ func cmdDisable(ctx context.Context, ccfg *cliConfig) error { // cmdUnpin unpins the current version. func cmdUnpin(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to setup updater: %w", err) } - unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSWriteLock(lockFile) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -267,19 +300,20 @@ func cmdUnpin(ctx context.Context, ccfg *cliConfig) error { // cmdInstall installs Teleport and sets configuration. func cmdInstall(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) + if ccfg.InstallSuffix != "" { + ns, err := autoupdate.NewNamespace(plog, ccfg.InstallSuffix, ccfg.DataDir, ccfg.LinkDir) + if err != nil { + return trace.Wrap(err) + } + ns.LogWarning(ctx) + } + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } // Ensure enable can't run concurrently. - unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSWriteLock(lockFile) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -296,18 +330,12 @@ func cmdInstall(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 { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } // Ensure update can't run concurrently. - unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSWriteLock(lockFile) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) } @@ -323,21 +351,15 @@ func cmdUpdate(ctx context.Context, ccfg *cliConfig) error { return nil } -// cmdLink creates system package links if no version is linked and auto-updates is disabled. -func cmdLink(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) +// cmdLinkPackage creates system package links if no version is linked and auto-updates is disabled. +func cmdLinkPackage(ctx context.Context, ccfg *cliConfig) error { + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } // Skip operation and warn if the updater is currently running. - unlock, err := libutils.FSTryReadLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSTryReadLock(lockFile) if errors.Is(err, libutils.ErrUnsuccessfulLockTry) { plog.WarnContext(ctx, "Updater is currently running. Skipping package linking.") return nil @@ -357,21 +379,15 @@ func cmdLink(ctx context.Context, ccfg *cliConfig) error { return nil } -// cmdUnlink remove system package links. -func cmdUnlink(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) +// cmdUnlinkPackage remove system package links. +func cmdUnlinkPackage(ctx context.Context, ccfg *cliConfig) error { + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to setup updater: %w", err) } // Error if the updater is running. We could remove its links by accident. - unlock, err := libutils.FSTryWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSTryWriteLock(lockFile) if errors.Is(err, libutils.ErrUnsuccessfulLockTry) { return trace.Errorf("updater is currently running") } @@ -392,10 +408,14 @@ func cmdUnlink(ctx context.Context, ccfg *cliConfig) error { // 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) + ns, err := autoupdate.NewNamespace(plog, ccfg.InstallSuffix, ccfg.DataDir, ccfg.LinkDir) + if err != nil { + return trace.Wrap(err) + } + err = ns.Setup(ctx) if errors.Is(err, autoupdate.ErrNotSupported) { plog.WarnContext(ctx, "Not enabling systemd service because systemd is not running.") - os.Exit(autoupdate.CodeNotSupported) + return trace.Wrap(err) } if err != nil { return trace.Errorf("failed to setup teleport-update service: %w", err) @@ -405,13 +425,7 @@ func cmdSetup(ctx context.Context, ccfg *cliConfig) error { // cmdStatus displays auto-update status. func cmdStatus(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) + updater, err := statusConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } @@ -425,18 +439,12 @@ func cmdStatus(ctx context.Context, ccfg *cliConfig) error { // cmdUninstall removes the updater-managed install of Teleport and gracefully reverts back to the Teleport package. func cmdUninstall(ctx context.Context, ccfg *cliConfig) error { - updater, err := autoupdate.NewLocalUpdater(autoupdate.LocalUpdaterConfig{ - DataDir: ccfg.DataDir, - LinkDir: ccfg.LinkDir, - SystemDir: autoupdate.DefaultSystemDir, - SelfSetup: ccfg.SelfSetup, - Log: plog, - }) + updater, lockFile, err := initConfig(ccfg) if err != nil { return trace.Errorf("failed to initialize updater: %w", err) } // Ensure update can't run concurrently. - unlock, err := libutils.FSWriteLock(filepath.Join(ccfg.DataDir, lockFileName)) + unlock, err := libutils.FSWriteLock(lockFile) if err != nil { return trace.Errorf("failed to grab concurrent execution lock: %w", err) }