diff --git a/cli/commands/server_install.go b/cli/commands/server_install.go index dea12b864..1dd998599 100644 --- a/cli/commands/server_install.go +++ b/cli/commands/server_install.go @@ -24,6 +24,7 @@ import ( "gopkg.in/yaml.v3" "miren.dev/runtime/clientconfig" "miren.dev/runtime/pkg/registration" + "miren.dev/runtime/pkg/release" "miren.dev/runtime/version" ) @@ -529,6 +530,18 @@ WantedBy=multi-user.target } } + // Point the documented on-$PATH CLI at the managed release binary so it + // stays in sync with the server through future upgrades. Best-effort: the + // install already succeeded, so a symlink failure is a warning. + switch err := release.EnsurePathSymlink(releaseBinPath, release.SystemCLIPath); { + case errors.Is(err, release.ErrPathManagedElsewhere): + ctx.Warn("Left %s alone, it looks managed by another tool (e.g. Homebrew); it may not track the server", release.SystemCLIPath) + case err != nil: + ctx.Warn("Failed to link %s to %s: %v", release.SystemCLIPath, releaseBinPath, err) + default: + ctx.Completed("Linked %s -> %s", release.SystemCLIPath, releaseBinPath) + } + // Print helpful next steps fmt.Println() ctx.Info("Installation complete!") diff --git a/pkg/release/manager.go b/pkg/release/manager.go index 2cac39d59..86d4219e0 100644 --- a/pkg/release/manager.go +++ b/pkg/release/manager.go @@ -2,6 +2,7 @@ package release import ( "context" + "errors" "fmt" "os" "os/exec" @@ -30,6 +31,11 @@ type ManagerOptions struct { SkipHealthCheck bool // AutoRollback enables automatic rollback on health check failure AutoRollback bool + // PathSymlink, when non-empty, is a location on $PATH that is kept as a + // symlink to InstallPath after a successful server/runner upgrade so the + // documented CLI stays in sync with the managed binary. Empty disables it + // (e.g. for user/CLI-only upgrades). + PathSymlink string } // DefaultManagerOptions returns default manager options @@ -42,6 +48,7 @@ func DefaultManagerOptions() ManagerOptions { HealthTimeout: 60 * time.Second, SkipHealthCheck: false, AutoRollback: true, + PathSymlink: SystemCLIPath, } } @@ -152,10 +159,33 @@ func (m *Manager) UpgradeServer(ctx context.Context, artifact Artifact) error { fmt.Printf("Service is healthy\n") } + // Keep the documented on-$PATH CLI pointed at the binary we just installed + // and verified. Best-effort: the upgrade itself already succeeded, so a + // symlink failure is a warning rather than a hard error (and never triggers + // a rollback of a healthy server). + m.ensurePathSymlink() + fmt.Printf("Upgrade successful!\n") return nil } +// ensurePathSymlink points m.opts.PathSymlink at the managed InstallPath when a +// PathSymlink is configured. Failures are logged, not returned, so they don't +// undo an otherwise-successful upgrade. +func (m *Manager) ensurePathSymlink() { + if m.opts.PathSymlink == "" { + return + } + switch err := EnsurePathSymlink(m.opts.InstallPath, m.opts.PathSymlink); { + case errors.Is(err, ErrPathManagedElsewhere): + fmt.Fprintf(os.Stderr, "Note: leaving %s alone, it looks managed by another tool (e.g. Homebrew); the CLI on your $PATH may not track the server.\n", m.opts.PathSymlink) + case err != nil: + fmt.Fprintf(os.Stderr, "Warning: failed to update %s symlink: %v\n", m.opts.PathSymlink, err) + default: + fmt.Printf("Updated %s -> %s\n", m.opts.PathSymlink, m.opts.InstallPath) + } +} + // RestartAndVerify restarts the systemd service and verifies its health // without performing any download or installation. Used by the drift-recovery // path when the running daemon is stale relative to the on-disk binary (e.g., diff --git a/pkg/release/symlink.go b/pkg/release/symlink.go new file mode 100644 index 000000000..868ba35c6 --- /dev/null +++ b/pkg/release/symlink.go @@ -0,0 +1,98 @@ +package release + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SystemCLIPath is the on-$PATH location where miren maintains a symlink to the +// managed server binary. It matches the documented install location, so a CLI +// obtained by following the install docs stays in lockstep with the server that +// miren manages after every upgrade instead of silently drifting. +const SystemCLIPath = "/usr/local/bin/miren" + +// ErrPathManagedElsewhere is returned by EnsurePathSymlink when linkPath is a +// symlink that some other tool clearly owns (e.g. a Homebrew Cask links its +// binaries into the Caskroom). Rather than hijack it, we leave it untouched; +// callers can surface a note that the on-$PATH CLI may not track the server. +var ErrPathManagedElsewhere = errors.New("path is a symlink managed by another tool") + +// EnsurePathSymlink makes linkPath an up-to-date symlink pointing at target. +// +// It is idempotent and self-correcting: if linkPath is already the correct +// symlink it does nothing; if it is a stale regular file (e.g. a binary an +// installer copied there) or a symlink that already points into the managed +// release directory, it is atomically replaced. The swap is done by creating the +// new symlink under a temporary name in the same directory and renaming it over +// linkPath, so a miren process currently executing via linkPath keeps its +// already-mapped inode on Linux. +// +// If linkPath is a symlink pointing somewhere outside the managed release +// directory, it is assumed to belong to another package manager and is left +// alone, returning ErrPathManagedElsewhere. +func EnsurePathSymlink(target, linkPath string) error { + // Fast path: linkPath is already the symlink we want. + if current, err := os.Readlink(linkPath); err == nil && current == target { + return nil + } + + // Don't hijack a symlink another tool owns. We take over a plain file (the + // tarball/terraform bootstrap copy), a missing path, or a symlink that + // already points into the managed release directory. A symlink pointing + // elsewhere is left as-is so we don't corrupt another package manager's + // bookkeeping. + if info, err := os.Lstat(linkPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + if !symlinkPointsInto(linkPath, filepath.Dir(target)) { + return ErrPathManagedElsewhere + } + } + + linkDir := filepath.Dir(linkPath) + if err := os.MkdirAll(linkDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s for CLI symlink: %w", linkDir, err) + } + + // Stage the new symlink under a unique temp name in the same directory so the + // final swap onto linkPath is an atomic rename. + tmp, err := os.CreateTemp(linkDir, ".miren-symlink-*") + if err != nil { + return fmt.Errorf("failed to create temp file for CLI symlink: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + // CreateTemp leaves a regular file behind; remove it so os.Symlink can claim + // the path. + if err := os.Remove(tmpPath); err != nil { + return fmt.Errorf("failed to prepare temp path for CLI symlink: %w", err) + } + + if err := os.Symlink(target, tmpPath); err != nil { + return fmt.Errorf("failed to create CLI symlink: %w", err) + } + + if err := os.Rename(tmpPath, linkPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to install CLI symlink at %s: %w", linkPath, err) + } + + return nil +} + +// symlinkPointsInto reports whether linkPath is a symlink whose target resolves +// to dir or something beneath it. Relative targets are resolved against the +// link's own directory. +func symlinkPointsInto(linkPath, dir string) bool { + tgt, err := os.Readlink(linkPath) + if err != nil { + return false + } + if !filepath.IsAbs(tgt) { + tgt = filepath.Join(filepath.Dir(linkPath), tgt) + } + tgt = filepath.Clean(tgt) + dir = filepath.Clean(dir) + return tgt == dir || strings.HasPrefix(tgt, dir+string(os.PathSeparator)) +} diff --git a/pkg/release/symlink_test.go b/pkg/release/symlink_test.go new file mode 100644 index 000000000..6c45b463c --- /dev/null +++ b/pkg/release/symlink_test.go @@ -0,0 +1,135 @@ +package release + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// assertSymlinkTo fails the test unless linkPath is a symlink pointing at target +// whose contents resolve to the target file. +func assertSymlinkTo(t *testing.T, linkPath, target string, wantContent []byte) { + t.Helper() + + got, err := os.Readlink(linkPath) + if err != nil { + t.Fatalf("expected %s to be a symlink: %v", linkPath, err) + } + if got != target { + t.Fatalf("symlink target mismatch: got %s, want %s", got, target) + } + + // Reading through the link should resolve to the target's contents. + content, err := os.ReadFile(linkPath) + if err != nil { + t.Fatalf("failed to read through symlink: %v", err) + } + if string(content) != string(wantContent) { + t.Fatalf("content through symlink mismatch: got %q, want %q", content, wantContent) + } +} + +func TestEnsurePathSymlink(t *testing.T) { + targetContent := []byte("managed binary") + + setup := func(t *testing.T) (target, link string) { + t.Helper() + dir := t.TempDir() + target = filepath.Join(dir, "release", "miren") + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + if err := os.WriteFile(target, targetContent, 0755); err != nil { + t.Fatalf("failed to create target binary: %v", err) + } + link = filepath.Join(dir, "bin", "miren") + return target, link + } + + t.Run("creates when absent", func(t *testing.T) { + target, link := setup(t) + if err := EnsurePathSymlink(target, link); err != nil { + t.Fatalf("EnsurePathSymlink failed: %v", err) + } + assertSymlinkTo(t, link, target, targetContent) + }) + + t.Run("replaces stale regular file", func(t *testing.T) { + target, link := setup(t) + if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil { + t.Fatalf("failed to create link dir: %v", err) + } + // Simulate the stale binary an installer copied onto $PATH. + if err := os.WriteFile(link, []byte("stale binary"), 0755); err != nil { + t.Fatalf("failed to create stale file: %v", err) + } + if err := EnsurePathSymlink(target, link); err != nil { + t.Fatalf("EnsurePathSymlink failed: %v", err) + } + assertSymlinkTo(t, link, target, targetContent) + }) + + t.Run("refreshes stale symlink within the managed dir", func(t *testing.T) { + target, link := setup(t) + if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil { + t.Fatalf("failed to create link dir: %v", err) + } + // A symlink pointing at another file inside the managed release dir is + // ours to repoint (e.g. left over from a previous layout). + other := filepath.Join(filepath.Dir(target), "miren.old") + if err := os.WriteFile(other, []byte("old binary"), 0755); err != nil { + t.Fatalf("failed to create other target: %v", err) + } + if err := os.Symlink(other, link); err != nil { + t.Fatalf("failed to create initial symlink: %v", err) + } + if err := EnsurePathSymlink(target, link); err != nil { + t.Fatalf("EnsurePathSymlink failed: %v", err) + } + assertSymlinkTo(t, link, target, targetContent) + }) + + t.Run("leaves a foreign symlink untouched", func(t *testing.T) { + target, link := setup(t) + if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil { + t.Fatalf("failed to create link dir: %v", err) + } + // Simulate a Homebrew Cask symlink: points outside the managed release + // dir, into a Caskroom. We must not hijack it. + caskroom := filepath.Join(filepath.Dir(filepath.Dir(target)), "Caskroom", "miren") + if err := os.MkdirAll(filepath.Dir(caskroom), 0755); err != nil { + t.Fatalf("failed to create caskroom dir: %v", err) + } + if err := os.WriteFile(caskroom, []byte("brew binary"), 0755); err != nil { + t.Fatalf("failed to create caskroom binary: %v", err) + } + if err := os.Symlink(caskroom, link); err != nil { + t.Fatalf("failed to create foreign symlink: %v", err) + } + + err := EnsurePathSymlink(target, link) + if !errors.Is(err, ErrPathManagedElsewhere) { + t.Fatalf("expected ErrPathManagedElsewhere, got %v", err) + } + // The foreign symlink must be left exactly as it was. + got, err := os.Readlink(link) + if err != nil { + t.Fatalf("foreign symlink should still exist: %v", err) + } + if got != caskroom { + t.Fatalf("foreign symlink was modified: got %s, want %s", got, caskroom) + } + }) + + t.Run("idempotent when already correct", func(t *testing.T) { + target, link := setup(t) + if err := EnsurePathSymlink(target, link); err != nil { + t.Fatalf("first EnsurePathSymlink failed: %v", err) + } + if err := EnsurePathSymlink(target, link); err != nil { + t.Fatalf("second EnsurePathSymlink failed: %v", err) + } + assertSymlinkTo(t, link, target, targetContent) + }) +}