From 2b63f24136fbc4886c85bd22feb40d6adda490eb Mon Sep 17 00:00:00 2001 From: Vyncint Ng Date: Sun, 2 Aug 2026 21:05:18 +0700 Subject: [PATCH] feat: add update and cleanup lifecycle subcommands Mirror apple/container's update-container.sh / uninstall-container.sh so the whole stack can be updated or torn down with one command. `update` resolves the target release (latest, or --version), downloads it from GitHub, verifies the sha256 from checksums.txt, replaces the running binary in place (atomic rename, with a sudo-install fallback for a root-owned prefix), and re-runs setup so the service restarts on the new binary. --all also updates OpenShell (brew) and apple/container via its own updater; --no-setup replaces the binary only. `cleanup` layers like apple/container's uninstaller: the bare command removes only the driver service and gateway.env wiring; -d/--delete-data also removes the driver's state dir, socket dir, vmnet network and pulled images (-k/--keep-data is the default); --all also removes the prerequisites (brew uninstall openshell, then apple/container's own uninstaller, which needs sudo). uninstall becomes an alias for cleanup. The Homebrew probe, the apple/container uninstaller path, and the OpenShell var dir are injectable, so cleanup is unit-tested without touching real system paths; update's checksum verification and tar extraction are covered too. Signed-off-by: Vyncint Ng --- CHANGELOG.md | 11 + README.md | 25 +- .../cleanup.go | 56 +++ cmd/openshell-driver-applecontainer/main.go | 19 +- cmd/openshell-driver-applecontainer/setup.go | 18 +- cmd/openshell-driver-applecontainer/update.go | 332 ++++++++++++++++++ .../update_test.go | 103 ++++++ internal/hostsetup/cleanup_test.go | 194 ++++++++++ internal/hostsetup/setup.go | 183 +++++++++- 9 files changed, 908 insertions(+), 33 deletions(-) create mode 100644 cmd/openshell-driver-applecontainer/cleanup.go create mode 100644 cmd/openshell-driver-applecontainer/update.go create mode 100644 cmd/openshell-driver-applecontainer/update_test.go create mode 100644 internal/hostsetup/cleanup_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c1cf0..ebc5376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- `update` and `cleanup` subcommands for one-command lifecycle management, mirroring + apple/container's `update-container.sh` / `uninstall-container.sh`. `update` downloads the + latest release (or `--version vX.Y.Z`), verifies its checksum, replaces the binary in place, + and re-runs `setup`; `--all` also updates OpenShell (brew) and apple/container, `--no-setup` + replaces the binary only. `cleanup` layers like the apple/container uninstaller: the bare + command removes only the driver's service and gateway wiring, `-d`/`--delete-data` also removes + the driver's state, vmnet network and pulled images (`-k`/`--keep-data` is the default), and + `--all` also removes OpenShell and apple/container. `uninstall` is now an alias for `cleanup`. + ### Fixed - The `install.sh` one-line installer no longer aborts when the OpenShell installer it invokes diff --git a/README.md b/README.md index 6bdca55..326473d 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,33 @@ openshell sandbox delete demo Both services start at login and restart on failure — nothing to launch by hand, ever. `setup` is **idempotent**: re-run it any time (after an upgrade, after changing flags, or just -to repair the installation). `openshell-driver-applecontainer uninstall` removes everything -setup installed. +to repair the installation). To upgrade or remove the stack later, see +[Update and remove](#update-and-remove). The installer is non-interactive with `-y`, and takes `--no-setup`, `--version vX.Y.Z`, and `--prefix ` (or the `OSHL_AC_YES`, `OSHL_AC_VERSION`, `OSHL_AC_PREFIX` env vars). +### Update and remove + +Two commands manage the stack's lifecycle, mirroring apple/container's own +`update-container.sh` / `uninstall-container.sh`: + +```sh +openshell-driver-applecontainer update # update the driver to the latest release, then re-setup +openshell-driver-applecontainer update --all # also update OpenShell (brew) and apple/container +openshell-driver-applecontainer update --version vX.Y.Z # pin a specific driver release + +openshell-driver-applecontainer cleanup # remove the driver service + gateway wiring (data kept) +openshell-driver-applecontainer cleanup -d # also remove driver state, vmnet network and pulled images +openshell-driver-applecontainer cleanup --all -d # full teardown: also remove OpenShell and apple/container +``` + +`update` downloads the release, verifies its checksum, replaces the binary in place, and re-runs +`setup` so the service restarts on it (`--no-setup` skips that). `cleanup` layers like the +apple/container uninstaller: the bare command touches only the driver; `-d`/`--delete-data` also +removes its data (`-k`/`--keep-data` is the default); `--all` also removes the prerequisites +(apple/container's uninstaller needs `sudo`). `uninstall` remains as an alias for `cleanup`. + ### Manual install Prerequisites: an Apple-silicon Mac (macOS 26+), [apple/container](https://github.com/apple/container) diff --git a/cmd/openshell-driver-applecontainer/cleanup.go b/cmd/openshell-driver-applecontainer/cleanup.go new file mode 100644 index 0000000..3fc305b --- /dev/null +++ b/cmd/openshell-driver-applecontainer/cleanup.go @@ -0,0 +1,56 @@ +package main + +import ( + "flag" + "log/slog" + + "github.com/vyncint/openshell-driver-applecontainer/internal/backend" + "github.com/vyncint/openshell-driver-applecontainer/internal/config" + "github.com/vyncint/openshell-driver-applecontainer/internal/hostsetup" +) + +// runCleanup implements `openshell-driver-applecontainer cleanup`: reverse +// setup with apple/container-style layering. Bare command removes only the +// driver's service and gateway wiring; -d/--delete-data also removes the +// driver's data; --all also removes the OpenShell and apple/container +// prerequisites. +func runCleanup(args []string) int { + fs := flag.NewFlagSet("cleanup", flag.ContinueOnError) + keep := fs.Bool("keep-data", false, "keep the driver's data — state, network, images (the default)") + fs.BoolVar(keep, "k", false, "shorthand for --keep-data") + del := fs.Bool("delete-data", false, "also remove the driver's data: state, socket, vmnet network, pulled images") + fs.BoolVar(del, "d", false, "shorthand for --delete-data") + all := fs.Bool("all", false, "also remove the prerequisites: OpenShell (brew) and apple/container (its uninstaller)") + if err := fs.Parse(args); err != nil { + return 2 + } + if *keep && *del { + slog.Error("cleanup: pass at most one of -k/--keep-data and -d/--delete-data") + return 2 + } + + defaults, err := config.Parse(nil) + if err != nil { + slog.Error("resolve defaults", "err", err) + return 1 + } + log := newLogger("info") + s, err := hostsetup.New(backend.NewCLI(log), log) + if err != nil { + log.Error("cleanup failed", "err", err) + return 1 + } + if err := s.Cleanup(hostsetup.CleanupOptions{ + DeleteData: *del, + All: *all, + Network: defaults.Network, + Socket: defaults.Socket, + StateDir: defaults.StateDir, + DefaultImage: defaults.DefaultImage, + SupervisorImage: defaults.SupervisorImage, + }); err != nil { + log.Error("cleanup failed", "err", err) + return 1 + } + return 0 +} diff --git a/cmd/openshell-driver-applecontainer/main.go b/cmd/openshell-driver-applecontainer/main.go index 89a6319..335a516 100644 --- a/cmd/openshell-driver-applecontainer/main.go +++ b/cmd/openshell-driver-applecontainer/main.go @@ -41,6 +41,10 @@ func main() { return case "setup": os.Exit(runSetup(args[1:])) + case "update": + os.Exit(runUpdate(args[1:])) + case "cleanup": + os.Exit(runCleanup(args[1:])) case "uninstall": os.Exit(runUninstall(args[1:])) case "help", "--help", "-h": @@ -64,8 +68,19 @@ Usage: vmnet network and gateway certificate, and pre-pulls images. Idempotent — re-run any time to repair the installation. - openshell-driver-applecontainer uninstall - Removes the services and configuration that setup installed. + openshell-driver-applecontainer update [--version vX.Y.Z] [--all] [--no-setup] + Updates the driver to the latest release (verifying its checksum) + and re-runs setup so the service restarts on the new binary. --all + also updates the prerequisites (OpenShell via brew, apple/container + via its own updater). --no-setup replaces the binary only. + + openshell-driver-applecontainer cleanup [-d | -k] [--all] + Reverses setup. By default removes only the driver's service and + gateway wiring (data kept). -d/--delete-data also removes the + driver's state, vmnet network and pulled images; -k/--keep-data is + the default. --all also removes the prerequisites (OpenShell via + brew, apple/container via its own uninstaller, which needs sudo). + The "uninstall" subcommand is a backward-compatible alias. openshell-driver-applecontainer [flags] Runs the driver in the foreground (development). See -h of the diff --git a/cmd/openshell-driver-applecontainer/setup.go b/cmd/openshell-driver-applecontainer/setup.go index 3772ece..37a0ac4 100644 --- a/cmd/openshell-driver-applecontainer/setup.go +++ b/cmd/openshell-driver-applecontainer/setup.go @@ -54,17 +54,9 @@ func runSetup(args []string) int { return 0 } -// runUninstall implements `openshell-driver-applecontainer uninstall`. -func runUninstall(_ []string) int { - log := newLogger("info") - s, err := hostsetup.New(backend.NewCLI(log), log) - if err != nil { - log.Error("uninstall failed", "err", err) - return 1 - } - if err := s.Uninstall(); err != nil { - log.Error("uninstall failed", "err", err) - return 1 - } - return 0 +// runUninstall implements `openshell-driver-applecontainer uninstall`, a +// backward-compatible alias for `cleanup` (driver only, data kept). Any +// cleanup flags (-d/--delete-data, --all) still work through it. +func runUninstall(args []string) int { + return runCleanup(args) } diff --git a/cmd/openshell-driver-applecontainer/update.go b/cmd/openshell-driver-applecontainer/update.go new file mode 100644 index 0000000..56e7fd7 --- /dev/null +++ b/cmd/openshell-driver-applecontainer/update.go @@ -0,0 +1,332 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + updateRepo = "vyncint/openshell-driver-applecontainer" + updateBinaryName = "openshell-driver-applecontainer" + // maxArchiveBytes caps how much we read from a release archive, so a + // corrupt or hostile download cannot exhaust memory/disk. + maxArchiveBytes = 200 << 20 // 200 MiB +) + +// runUpdate implements `openshell-driver-applecontainer update`: replace this +// binary with a newer release (verifying its checksum) and re-run setup so the +// service restarts on it. With --all it also updates the prerequisites. +func runUpdate(args []string) int { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + targetVersion := fs.String("version", "", "install a specific release (e.g. v0.2.4); default: latest") + noSetup := fs.Bool("no-setup", false, "replace the binary but do not re-run setup") + all := fs.Bool("all", false, "also update the prerequisites: OpenShell (brew) and apple/container") + if err := fs.Parse(args); err != nil { + return 2 + } + log := newLogger("info") + + want := *targetVersion + if want == "" { + latest, err := latestReleaseTag(updateRepo) + if err != nil { + log.Error("update: could not determine the latest release; pass --version", "err", err) + return 1 + } + want = latest + } + + log.Info("update: driver", "current", version, "target", want) + if want == version { + log.Info("update: already on the requested version; re-applying setup", "version", version) + } else if err := selfUpdate(log, want); err != nil { + log.Error("update failed", "err", err) + return 1 + } + + if *all { + updatePrerequisites(log) + } + + if *noSetup { + log.Info("update: skipping setup (--no-setup); run `" + updateBinaryName + " setup` to restart the service") + return 0 + } + self, err := currentBinaryPath() + if err != nil { + log.Error("update: locate binary for re-setup", "err", err) + return 1 + } + log.Info("update: re-running setup to restart the service on the new binary") + if err := streamCmd(self, "setup"); err != nil { + log.Error("update: setup after update failed; run `"+updateBinaryName+" setup` yourself", "err", err) + return 1 + } + return 0 +} + +// selfUpdate downloads release `version`, verifies its checksum, and replaces +// the running binary in place. +func selfUpdate(log *slog.Logger, version string) error { + tmp, err := os.MkdirTemp("", "oshl-ac-update-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmp) }() + + archive := releaseArchiveName(version) + base := fmt.Sprintf("https://github.com/%s/releases/download/%s", updateRepo, version) + archivePath := filepath.Join(tmp, archive) + + log.Info("update: downloading", "archive", archive) + if err := downloadTo(base+"/"+archive, archivePath); err != nil { + return fmt.Errorf("download %s: %w", archive, err) + } + sums, err := httpGetBytes(base + "/checksums.txt") + if err != nil { + return fmt.Errorf("download checksums: %w", err) + } + if err := verifyChecksum(archivePath, sums, archive); err != nil { + return err + } + log.Info("update: checksum verified") + + binPath, err := currentBinaryPath() + if err != nil { + return err + } + extracted := filepath.Join(tmp, updateBinaryName) + if err := extractBinaryFromTarGz(archivePath, updateBinaryName, extracted); err != nil { + return err + } + if err := replaceBinary(binPath, extracted); err != nil { + return fmt.Errorf("replace %s: %w", binPath, err) + } + // The release binary is unsigned; clear quarantine so it runs. + _ = exec.Command("xattr", "-d", "com.apple.quarantine", binPath).Run() + return nil +} + +// releaseArchiveName is the goreleaser asset name for a darwin/arm64 build. +func releaseArchiveName(version string) string { + return fmt.Sprintf("%s_%s_darwin_arm64.tar.gz", updateBinaryName, strings.TrimPrefix(version, "v")) +} + +// verifyChecksum confirms archivePath matches its sha256 line in a +// `sha256sum`-format checksums file (each line " "). +func verifyChecksum(archivePath string, checksums []byte, archiveName string) error { + var want string + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == archiveName { + want = strings.ToLower(fields[0]) + break + } + } + if want == "" { + return fmt.Errorf("no checksum listed for %s", archiveName) + } + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + h := sha256.New() + if _, err := io.Copy(h, io.LimitReader(f, maxArchiveBytes)); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", archiveName, got, want) + } + return nil +} + +// extractBinaryFromTarGz writes the archive member whose base name is +// binaryName to dest (mode 0755). +func extractBinaryFromTarGz(archivePath, binaryName, dest string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gunzip: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return fmt.Errorf("%s not found in archive", binaryName) + } + if err != nil { + return err + } + if hdr.Typeflag != tar.TypeReg || filepath.Base(hdr.Name) != binaryName { + continue + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) // #nosec G302 -- the extracted driver binary must carry the exec bit + if err != nil { + return err + } + if _, err := io.Copy(out, io.LimitReader(tr, maxArchiveBytes)); err != nil { + _ = out.Close() + return err + } + return out.Close() + } +} + +// replaceBinary atomically swaps target for newBin. It first tries a rename +// within target's directory (works while the old binary is still running on +// macOS); if that directory is not writable it falls back to `sudo install`, +// attached to the terminal so it can prompt for a password. +func replaceBinary(target, newBin string) error { + dir := filepath.Dir(target) + if tmp, err := os.CreateTemp(dir, ".oshl-ac-update-*"); err == nil { + tmpName := tmp.Name() + _ = tmp.Close() + if copyErr := copyFile(newBin, tmpName, 0o755); copyErr == nil { + if renErr := os.Rename(tmpName, target); renErr == nil { + return nil + } + } + _ = os.Remove(tmpName) + } + // Not writable (e.g. a root-owned prefix): use sudo. install(1) sets the + // mode and works across filesystems. + return streamCmd("sudo", "install", "-m", "0755", newBin, target) +} + +// updatePrerequisites updates OpenShell (brew) and apple/container (via its +// own installed updater). Best-effort and terminal-attached (brew output, +// sudo prompts). +func updatePrerequisites(log *slog.Logger) { + if _, err := exec.LookPath("brew"); err == nil { + log.Info("update: upgrading OpenShell (brew)") + if err := streamCmd("brew", "upgrade", "openshell"); err != nil { + log.Warn("brew upgrade openshell failed (it may already be current)", "err", err) + } + } + const acUpdater = "/usr/local/bin/update-container.sh" + if _, err := os.Stat(acUpdater); err == nil { + log.Info("update: updating apple/container (its updater needs sudo)") + if err := streamCmd(acUpdater); err != nil { + log.Warn("apple/container updater failed", "err", err) + } + } +} + +// currentBinaryPath is the real (symlink-resolved) path of the running binary. +func currentBinaryPath() (string, error) { + self, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(self); err == nil { + return resolved, nil + } + return self, nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func streamCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin + return cmd.Run() +} + +// --- GitHub release lookup / download --- + +var httpClient = &http.Client{Timeout: 5 * time.Minute} + +func latestReleaseTag(repo string) (string, error) { + body, err := httpGetBytes("https://api.github.com/repos/" + repo + "/releases/latest") + if err != nil { + return "", err + } + var rel struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &rel); err != nil { + return "", fmt.Errorf("parse release metadata: %w", err) + } + if rel.TagName == "" { + return "", fmt.Errorf("no tag_name in latest release") + } + return rel.TagName, nil +} + +func httpGetBytes(url string) ([]byte, error) { + resp, err := httpGet(url) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + return io.ReadAll(io.LimitReader(resp.Body, maxArchiveBytes)) +} + +func downloadTo(url, dest string) error { + resp, err := httpGet(url) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + out, err := os.Create(dest) + if err != nil { + return err + } + if _, err := io.Copy(out, io.LimitReader(resp.Body, maxArchiveBytes)); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func httpGet(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", updateBinaryName) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + return resp, nil +} diff --git a/cmd/openshell-driver-applecontainer/update_test.go b/cmd/openshell-driver-applecontainer/update_test.go new file mode 100644 index 0000000..9a85bc1 --- /dev/null +++ b/cmd/openshell-driver-applecontainer/update_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func TestReleaseArchiveName(t *testing.T) { + want := "openshell-driver-applecontainer_0.2.4_darwin_arm64.tar.gz" + if got := releaseArchiveName("v0.2.4"); got != want { + t.Errorf("with v prefix: got %q, want %q", got, want) + } + if got := releaseArchiveName("0.2.4"); got != want { + t.Errorf("without v prefix: got %q, want %q", got, want) + } +} + +// makeTarGz writes a gzipped tar of name->content and returns its path. +func makeTarGz(t *testing.T, dir string, entries map[string][]byte) string { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range entries { + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "archive.tar.gz") + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtractBinaryFromTarGz(t *testing.T) { + dir := t.TempDir() + want := []byte("\x7fELF fake binary body") + archive := makeTarGz(t, dir, map[string][]byte{ + "README.md": []byte("docs"), + updateBinaryName: want, + }) + + dest := filepath.Join(dir, "out") + if err := extractBinaryFromTarGz(archive, updateBinaryName, dest); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Error("extracted content mismatch") + } + if info, _ := os.Stat(dest); info.Mode().Perm() != 0o755 { + t.Errorf("mode = %o, want 755", info.Mode().Perm()) + } +} + +func TestExtractBinaryMissing(t *testing.T) { + dir := t.TempDir() + archive := makeTarGz(t, dir, map[string][]byte{"other": []byte("x")}) + if err := extractBinaryFromTarGz(archive, updateBinaryName, filepath.Join(dir, "out")); err == nil { + t.Error("expected an error when the binary is absent from the archive") + } +} + +func TestVerifyChecksum(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "app.tar.gz") + body := []byte("release payload") + if err := os.WriteFile(archive, body, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + checksums := []byte("deadbeef other.tar.gz\n" + hex.EncodeToString(sum[:]) + " app.tar.gz\n") + + if err := verifyChecksum(archive, checksums, "app.tar.gz"); err != nil { + t.Errorf("a valid checksum should pass: %v", err) + } + if err := verifyChecksum(archive, []byte("00 app.tar.gz\n"), "app.tar.gz"); err == nil { + t.Error("expected a mismatch error") + } + if err := verifyChecksum(archive, checksums, "missing.tar.gz"); err == nil { + t.Error("expected an error when the archive is not listed") + } +} diff --git a/internal/hostsetup/cleanup_test.go b/internal/hostsetup/cleanup_test.go new file mode 100644 index 0000000..f3d1e58 --- /dev/null +++ b/internal/hostsetup/cleanup_test.go @@ -0,0 +1,194 @@ +package hostsetup + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +// cmdRec records every Exec/ExecStream invocation without running anything, so +// cleanup's shell-outs can be asserted safely and deterministically. +type cmdRec struct{ calls []string } + +func (r *cmdRec) exec(name string, args ...string) (string, error) { + r.calls = append(r.calls, name+" "+strings.Join(args, " ")) + return "", nil +} +func (r *cmdRec) stream(name string, args ...string) error { + r.calls = append(r.calls, name+" "+strings.Join(args, " ")) + return nil +} +func (r *cmdRec) ran(substr string) bool { + for _, c := range r.calls { + if strings.Contains(c, substr) { + return true + } + } + return false +} + +func newCleanupSetup(t *testing.T, rec *cmdRec, hasOpenShell bool) *Setup { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", "") // force the Home-based config dir + home := t.TempDir() + s := &Setup{ + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + Home: home, + UID: 501, + Exec: rec.exec, + ExecStream: rec.stream, + HasOpenShell: func() bool { return hasOpenShell }, + // No real system paths: apple/container step is exercised via a temp + // stub; the OpenShell var dir is disabled. + OpenShellVarDir: "", + } + // A plist and a managed gateway.env so the removal paths run. + if err := os.MkdirAll(filepath.Dir(s.agentPlistPath()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.agentPlistPath(), []byte("plist"), 0o600); err != nil { + t.Fatal(err) + } + envPath := filepath.Join(s.configDir(), "gateway.env") + if err := os.MkdirAll(filepath.Dir(envPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(envPath, []byte(UpsertManagedBlock("", []string{"OPENSHELL_DRIVERS=x"})), 0o600); err != nil { + t.Fatal(err) + } + return s +} + +// Bare cleanup removes only the driver service + gateway wiring; it must not +// touch data or prerequisites. +func TestCleanupKeepDataDriverOnly(t *testing.T) { + rec := &cmdRec{} + s := newCleanupSetup(t, rec, true) + + if err := s.Cleanup(CleanupOptions{ + Network: "oshl", Socket: filepath.Join(t.TempDir(), "sock", "driver.sock"), + StateDir: t.TempDir(), DefaultImage: "base:latest", SupervisorImage: "sup:0.0.96", + }); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(s.agentPlistPath()); !os.IsNotExist(err) { + t.Errorf("plist should be removed, err=%v", err) + } + if !rec.ran("launchctl bootout") { + t.Error("expected launchctl bootout") + } + if !rec.ran("brew services stop openshell") { + t.Error("expected the gateway to be stopped") + } + for _, forbidden := range []string{"container image rm", "container network rm", "brew uninstall", "uninstall-container"} { + if rec.ran(forbidden) { + t.Errorf("keep-data/driver-only cleanup must not run %q; calls=%v", forbidden, rec.calls) + } + } +} + +// -d removes the driver's own data (state dir, socket dir, images, network) +// but not the prerequisites. +func TestCleanupDeleteDataRemovesDriverData(t *testing.T) { + rec := &cmdRec{} + s := newCleanupSetup(t, rec, true) + + stateDir := t.TempDir() + sockDir := t.TempDir() + socket := filepath.Join(sockDir, "driver.sock") + if err := os.WriteFile(socket, nil, 0o600); err != nil { + t.Fatal(err) + } + + if err := s.Cleanup(CleanupOptions{ + DeleteData: true, + Network: "oshl", Socket: socket, StateDir: stateDir, + DefaultImage: "ghcr.io/x/base:latest", SupervisorImage: "ghcr.io/x/sup:0.0.96", + }); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stateDir); !os.IsNotExist(err) { + t.Errorf("state dir should be removed, err=%v", err) + } + if _, err := os.Stat(sockDir); !os.IsNotExist(err) { + t.Errorf("socket dir should be removed, err=%v", err) + } + for _, want := range []string{ + "container image rm ghcr.io/x/base:latest", + "container image rm ghcr.io/x/sup:0.0.96", + "container network rm oshl", + } { + if !rec.ran(want) { + t.Errorf("expected %q; calls=%v", want, rec.calls) + } + } + if rec.ran("brew uninstall") { + t.Error("-d without --all must not uninstall OpenShell") + } +} + +// --all removes the prerequisites: brew uninstall + apple/container's own +// uninstaller (with the -k/-d flag matching the data choice). +func TestCleanupAllRemovesPrerequisites(t *testing.T) { + rec := &cmdRec{} + s := newCleanupSetup(t, rec, true) + // Stub apple/container's uninstaller so the Stat check passes. + acStub := filepath.Join(t.TempDir(), "uninstall-container.sh") + if err := os.WriteFile(acStub, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + s.ACUninstaller = acStub + + if err := s.Cleanup(CleanupOptions{All: true, Network: "oshl"}); err != nil { + t.Fatal(err) + } + if !rec.ran("brew uninstall openshell") { + t.Errorf("expected brew uninstall openshell; calls=%v", rec.calls) + } + if !rec.ran("container system stop") { + t.Errorf("expected the runtime to be stopped before uninstall; calls=%v", rec.calls) + } + if !rec.ran(acStub + " -k") { + t.Errorf("expected apple/container uninstaller with -k (keep data); calls=%v", rec.calls) + } +} + +// --all -d passes -d to apple/container's uninstaller. +func TestCleanupAllDeleteDataPassesDeleteFlag(t *testing.T) { + rec := &cmdRec{} + s := newCleanupSetup(t, rec, true) + acStub := filepath.Join(t.TempDir(), "uninstall-container.sh") + if err := os.WriteFile(acStub, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + s.ACUninstaller = acStub + + if err := s.Cleanup(CleanupOptions{All: true, DeleteData: true, Network: "oshl", StateDir: t.TempDir()}); err != nil { + t.Fatal(err) + } + if !rec.ran(acStub + " -d") { + t.Errorf("expected apple/container uninstaller with -d (delete data); calls=%v", rec.calls) + } +} + +// With OpenShell absent, cleanup skips the gateway/brew steps but still +// removes the driver service. +func TestCleanupWithoutOpenShell(t *testing.T) { + rec := &cmdRec{} + s := newCleanupSetup(t, rec, false) + + if err := s.Cleanup(CleanupOptions{}); err != nil { + t.Fatal(err) + } + if rec.ran("brew") { + t.Errorf("no brew calls expected when OpenShell is absent; calls=%v", rec.calls) + } + if !rec.ran("launchctl bootout") { + t.Error("driver service should still be removed") + } +} diff --git a/internal/hostsetup/setup.go b/internal/hostsetup/setup.go index 6da73f4..1fc8464 100644 --- a/internal/hostsetup/setup.go +++ b/internal/hostsetup/setup.go @@ -37,6 +37,15 @@ type Setup struct { BinPath string Exec func(name string, args ...string) (string, error) ExecStream func(name string, args ...string) error + // HasOpenShell probes whether the OpenShell Homebrew package is present. + // nil uses the real check; tests inject a stub. + HasOpenShell func() bool + // ACUninstaller is apple/container's own uninstaller script, run under + // `cleanup --all`. OpenShellVarDir is OpenShell's Homebrew var directory, + // removed under `cleanup --all -d`. Both default to their real host paths + // in New(); tests point them at temp dirs (or "" to skip). + ACUninstaller string + OpenShellVarDir string // readyTimeout bounds how long to wait for the driver socket after a // (re)start; overridden in tests. readyTimeout time.Duration @@ -56,12 +65,14 @@ func New(rt backend.Runtime, log *slog.Logger) (*Setup, error) { bin = resolved } return &Setup{ - RT: rt, - Log: log, - Home: home, - UID: os.Getuid(), - BinPath: bin, - readyTimeout: 15 * time.Second, + RT: rt, + Log: log, + Home: home, + UID: os.Getuid(), + BinPath: bin, + ACUninstaller: "/usr/local/bin/uninstall-container.sh", + OpenShellVarDir: "/opt/homebrew/var/openshell", + readyTimeout: 15 * time.Second, Exec: func(name string, args ...string) (string, error) { out, err := exec.Command(name, args...).CombinedOutput() return string(out), err @@ -152,9 +163,36 @@ func (s *Setup) Run(ctx context.Context, opts Options) error { return nil } -// Uninstall removes what setup installed (certificates, network, and images -// are left in place — they are harmless and expensive to recreate). -func (s *Setup) Uninstall() error { +// CleanupOptions configures a cleanup run. The zero value reproduces the +// historical `uninstall`: remove the driver service and its gateway wiring +// but keep all data and prerequisites. +type CleanupOptions struct { + // DeleteData also removes the driver's own data — its state directory, + // socket directory, the vmnet network, and the pulled sandbox/supervisor + // images. This is the "-d" (vs "-k") distinction from apple/container's + // uninstaller. + DeleteData bool + // All also removes the prerequisites the driver sits on: the OpenShell + // Homebrew package and apple/container (via its own installed + // uninstaller). Off by default — a plain cleanup only touches the driver. + All bool + + // The following are resolved from config and consulted only when + // DeleteData is set. + Network string + Socket string + StateDir string + DefaultImage string + SupervisorImage string +} + +// Cleanup reverses setup. With the zero-value options it removes the driver +// launchd service and its gateway.env wiring and stops the gateway, leaving +// data and prerequisites untouched (the historical `uninstall`). DeleteData +// additionally removes the driver's own data; All additionally removes +// OpenShell and apple/container. +func (s *Setup) Cleanup(opts CleanupOptions) error { + // 1. Driver launchd service. target := fmt.Sprintf("gui/%d/%s", s.UID, AgentLabel) if out, err := s.Exec("launchctl", "bootout", target); err != nil { s.Log.Debug("launchctl bootout", "out", out, "err", err) @@ -162,8 +200,9 @@ func (s *Setup) Uninstall() error { if err := os.Remove(s.agentPlistPath()); err != nil && !errors.Is(err, os.ErrNotExist) { return err } - s.Log.Info("uninstall: driver service removed") + s.Log.Info("cleanup: driver service removed") + // 2. gateway.env managed block (unmanaged lines are left in place). envPath := filepath.Join(s.configDir(), "gateway.env") if data, err := os.ReadFile(envPath); err == nil { rest := RemoveManagedBlock(string(data)) @@ -172,19 +211,121 @@ func (s *Setup) Uninstall() error { } else if err := os.WriteFile(envPath, []byte(rest), 0o600); err != nil { return err } - s.Log.Info("uninstall: gateway service configuration removed", "file", envPath) + s.Log.Info("cleanup: gateway service configuration removed", "file", envPath) } - if s.brewHasOpenShell() { - s.Log.Info("uninstall: stopping the gateway service (it has no compute driver configured anymore)") + // 3. Stop the gateway — with the driver gone it has no compute backend. + // (When All uninstalls OpenShell below, `brew uninstall` stops it too; + // stopping here first keeps a plain cleanup tidy.) + if s.openShellInstalled() { + s.Log.Info("cleanup: stopping the gateway service") if err := s.ExecStream("brew", "services", "stop", "openshell"); err != nil { s.Log.Warn("brew services stop openshell failed", "err", err) } } - s.Log.Info("uninstall: done (certificates, vmnet network and images were kept)") + + // 4. Driver-owned data. + if opts.DeleteData { + s.deleteDriverData(opts) + } + + // 5. Prerequisites. + if opts.All { + s.removePrerequisites(opts) + } + + switch { + case opts.DeleteData && opts.All: + s.Log.Info("cleanup: done (full teardown — driver, its data, OpenShell and apple/container)") + case opts.DeleteData: + s.Log.Info("cleanup: done (driver and its data removed; OpenShell and apple/container kept)") + case opts.All: + s.Log.Info("cleanup: done (driver, OpenShell and apple/container removed; driver data kept)") + default: + s.Log.Info("cleanup: done (driver removed; data, network, images and prerequisites kept)") + } return nil } +// deleteDriverData removes the driver's own state, socket directory, pulled +// images and vmnet network. Best-effort: a missing target is not an error. +func (s *Setup) deleteDriverData(opts CleanupOptions) { + if opts.StateDir != "" { + if err := os.RemoveAll(opts.StateDir); err != nil { + s.Log.Warn("cleanup: remove state dir", "dir", opts.StateDir, "err", err) + } else { + s.Log.Info("cleanup: removed driver state", "dir", opts.StateDir) + } + } + if opts.Socket != "" { + sockDir := filepath.Dir(opts.Socket) + if err := os.RemoveAll(sockDir); err != nil { + s.Log.Warn("cleanup: remove socket dir", "dir", sockDir, "err", err) + } + } + for _, ref := range []string{opts.DefaultImage, opts.SupervisorImage} { + if ref == "" { + continue + } + if out, err := s.Exec("container", "image", "rm", ref); err != nil { + s.Log.Debug("cleanup: remove image (may not be present)", "image", ref, "out", out, "err", err) + } else { + s.Log.Info("cleanup: removed image", "image", ref) + } + } + if opts.Network != "" { + if out, err := s.Exec("container", "network", "rm", opts.Network); err != nil { + s.Log.Debug("cleanup: remove network (may not be present)", "network", opts.Network, "out", out, "err", err) + } else { + s.Log.Info("cleanup: removed vmnet network", "network", opts.Network) + } + } +} + +// removePrerequisites uninstalls OpenShell (Homebrew) and apple/container (via +// its own installed uninstaller). apple/container's uninstaller needs sudo, so +// it runs attached to the terminal to prompt for a password. +func (s *Setup) removePrerequisites(opts CleanupOptions) { + if s.openShellInstalled() { + s.Log.Info("cleanup: uninstalling OpenShell (brew)") + if err := s.ExecStream("brew", "uninstall", "openshell"); err != nil { + s.Log.Warn("brew uninstall openshell failed", "err", err) + } + } + if opts.DeleteData { + // brew uninstall leaves OpenShell's user config (CLI gateway + // registrations) and its var dir (TLS/logs) behind. + dirs := []string{s.configDir()} + if s.OpenShellVarDir != "" { + dirs = append(dirs, s.OpenShellVarDir) + } + for _, dir := range dirs { + if err := os.RemoveAll(dir); err != nil { + s.Log.Warn("cleanup: remove OpenShell data", "dir", dir, "err", err) + } + } + } + if s.ACUninstaller == "" { + return + } + if _, err := os.Stat(s.ACUninstaller); err != nil { + s.Log.Info("cleanup: apple/container uninstaller not found; skipping it", "path", s.ACUninstaller) + return + } + // The uninstaller refuses to run while the runtime service is up. + if err := s.ExecStream("container", "system", "stop"); err != nil { + s.Log.Debug("container system stop", "err", err) + } + dataFlag := "-k" + if opts.DeleteData { + dataFlag = "-d" + } + s.Log.Info("cleanup: uninstalling apple/container (its uninstaller needs sudo)", "data", dataFlag) + if err := s.ExecStream(s.ACUninstaller, dataFlag); err != nil { + s.Log.Warn("apple/container uninstaller failed", "err", err) + } +} + func (s *Setup) ensureNetwork(ctx context.Context, name string) (string, error) { find := func() (string, error) { networks, err := s.RT.Networks(ctx) @@ -331,7 +472,8 @@ func (s *Setup) installAgent(tlsDir, socket string) error { return nil } -func (s *Setup) brewHasOpenShell() bool { +// brewHasOpenShell reports whether the OpenShell Homebrew package is installed. +func brewHasOpenShell() bool { if _, err := exec.LookPath("brew"); err != nil { return false } @@ -339,8 +481,17 @@ func (s *Setup) brewHasOpenShell() bool { return err == nil } +// openShellInstalled is the probe used throughout setup/cleanup; it honors an +// injected HasOpenShell stub and otherwise runs the real check. +func (s *Setup) openShellInstalled() bool { + if s.HasOpenShell != nil { + return s.HasOpenShell() + } + return brewHasOpenShell() +} + func (s *Setup) restartGatewayService() { - if !s.brewHasOpenShell() { + if !s.openShellInstalled() { s.Log.Warn("setup: Homebrew OpenShell service not found; start the gateway manually", "hint", "openshell-gateway (it reads "+filepath.Join(s.configDir(), "gateway.env")+" via the service wrapper only; pass the equivalent flags when running by hand)") return