Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cli/commands/server_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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!")
Expand Down
30 changes: 30 additions & 0 deletions pkg/release/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package release

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -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
Expand All @@ -42,6 +48,7 @@ func DefaultManagerOptions() ManagerOptions {
HealthTimeout: 60 * time.Second,
SkipHealthCheck: false,
AutoRollback: true,
PathSymlink: SystemCLIPath,
}
}

Expand Down Expand Up @@ -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.,
Expand Down
98 changes: 98 additions & 0 deletions pkg/release/symlink.go
Original file line number Diff line number Diff line change
@@ -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))
}
135 changes: 135 additions & 0 deletions pkg/release/symlink_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading