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