diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac928a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +# Runs go vet/build/test on every push and pull request -- unlike +# release.yml (which only triggers on a "v*" tag, i.e. right before +# publishing), this catches a broken commit on every push/PR, not just the +# one that happens to get tagged. +name: ci + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + # Same pinned-SHA policy as release.yml: floating tags like @v4 are + # mutable, so third-party actions are pinned to a specific commit. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.22" + + - name: Vet + run: go vet ./... + + # Cross-compile every release target, same as release.yml's build + # step -- catches a GOOS/GOARCH-specific compile error (e.g. a + # windows-only or darwin-only file) on every push, not just at + # release time. + - name: Build + env: + CGO_ENABLED: "0" + run: | + GOOS=windows GOARCH=amd64 go build -o /dev/null ./cmd/versola + GOOS=darwin GOARCH=amd64 go build -o /dev/null ./cmd/versola + GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/versola + GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/versola + + - name: Test + run: go test ./... diff --git a/README.md b/README.md index d6ee436..3e225e5 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ versola doctor versola bootstrap local versola status versola down +versola uninstall ``` If you built from source instead, run the binary directly from where you @@ -109,11 +110,12 @@ built it: ### `doctor` -Checks this machine has what Versola needs, without installing or changing -anything: +Checks this machine has what Versola needs. It never installs anything on +its own — if Docker isn't found at all, the most it does is offer to open +an install page in your browser, and only after you confirm: - Docker daemon reachable (not just `docker` on PATH) - Docker Compose v2 plugin present -- Port 8080 free on localhost +- Port 2821 free on localhost - Docker has enough memory allocated (~4 GiB — three JVMs + Postgres) - Enough free disk space (~4 GiB, for pulling Versola's images) @@ -121,10 +123,13 @@ Exits non-zero if any check fails. `bootstrap` runs the same checks itself before doing anything, so running `doctor` first is optional but gives you the same picture ahead of time. -Known gap: if no Docker runtime is found, this doesn't yet offer a choice -between installing Docker Desktop or Colima on macOS — it just reports -"not reachable". Worth adding before this goes to users who don't already -have Docker set up. +If Docker isn't reachable, `doctor` tells the two possible reasons apart +and reacts differently: already installed but not running just gets a +"start it" hint (installing something wouldn't help); not installed at +all offers to open a download page — a choice between Docker Desktop and +Colima on macOS, Docker Desktop on Windows, the Docker Engine install +docs on Linux. It only ever opens a browser tab — never installs or +downloads anything itself. ### `bootstrap local ` @@ -137,9 +142,14 @@ login on success. `` is a Versola release, which is tagged **without** a leading `v` — so `versola bootstrap local 0.1.2`, not `v0.1.2`. (Don't confuse it with versola-cli's own releases, which do use `v`.) Passing a version -that was never published fails with Docker's raw `manifest unknown`; -the available versions are the tags published for the `versola-tools` -package under the organization's GitHub packages. +that was never published fails with a clear error explaining that, +instead of Docker's raw `manifest unknown`; the available versions are +the tags published for the `versola-tools` package under the +organization's GitHub packages. + +Once auth and edge are ready, this opens the admin console in your +default browser automatically. Pass `--no-browser` to skip that (e.g. +running headless over SSH). ### `status` @@ -152,6 +162,23 @@ Stops the locally deployed stack. Pass `--volumes` to also delete the Postgres data volume (kept by default, so a later `bootstrap` picks up the same data). +### `uninstall` + +Removes everything versola deployed locally: stops the stack and deletes +its Postgres volume, removes the `versola-*` images that were pulled, and +clears `~/.versola`. Prompts for confirmation first — pass `-y`/`--yes` +to skip that. + +If a deployment was recorded but Docker isn't reachable to confirm it's +actually stopped, `~/.versola` is deliberately left in place rather than +cleared — deleting it there would remove the only way to properly stop +that deployment once Docker is reachable again. + +Does **not** remove the `versola` binary itself or its PATH entry; +safely deleting a program's own running executable isn't portable across +platforms (Windows won't allow it at all). It prints the binary's path +so you can remove it yourself. + ### `version` Prints which versola-cli release this binary was built from, plus the Go @@ -181,11 +208,12 @@ version it names is compiled into the binaries via ldflags, so ``` cmd/versola/ entry point -internal/cmd/ cobra commands (doctor, bootstrap, status, down, version) +internal/cmd/ cobra commands (doctor, bootstrap, status, down, uninstall, version) internal/checks/ the actual check logic doctor (and bootstrap) run internal/state/ locates ~/.versola/active, the on-disk state bootstrap writes internal/wait/ polls a readiness endpoint until it answers 200 or times out +internal/browser/ opens a URL in the default browser — used by bootstrap (admin console) and doctor (install pages) install.sh one-line installer for macOS/Linux install.ps1 one-line installer for Windows -.github/workflows/ release automation (builds binaries + checksums on tag push) +.github/workflows/ ci.yml (vet/build/test on every push+PR), release.yml (builds binaries + checksums on tag push) ``` diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..886191c --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,45 @@ +// Package browser opens a URL in the user's default browser. It's a thin +// OS-specific wrapper -- there's no cross-platform standard library way to +// do this, each OS has its own "open whatever handles this" command. +package browser + +import ( + "fmt" + "os/exec" + "runtime" +) + +// Open launches the user's default browser at url. Failure here is never +// fatal to whatever called it — bootstrap succeeding shouldn't be +// conditional on a desktop environment being present to pop a window in +// (e.g. running over SSH), so callers should log and continue, not +// propagate this as a command failure. +func Open(url string) error { + var c *exec.Cmd + switch runtime.GOOS { + case "windows": + // Deliberately not "cmd /c start" -- cmd.exe re-parses whatever + // command line Go hands it, and Go only quotes an argument if it + // contains a space, so a URL with "&" (a cmd.exe command + // separator, and a very ordinary character in query strings) + // would silently get cut in half. rundll32's URL handler takes + // the URL as a single argument with no shell in between, so + // there's no re-parsing step for "&" or anything else to hit. + c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + c = exec.Command("open", url) + case "linux": + c = exec.Command("xdg-open", url) + default: + return fmt.Errorf("don't know how to open a browser on %s", runtime.GOOS) + } + if err := c.Start(); err != nil { + return err + } + // Start() without a matching Wait() leaves an unreaped child on Unix + // until this process exits. That's harmless in practice here -- both + // callers open a browser as their last action before returning -- but + // reaping it properly costs nothing and doesn't make Open blocking. + go func() { _ = c.Wait() }() + return nil +} diff --git a/internal/cmd/bootstrap.go b/internal/cmd/bootstrap.go index 4047150..5d6e396 100644 --- a/internal/cmd/bootstrap.go +++ b/internal/cmd/bootstrap.go @@ -1,19 +1,26 @@ package cmd import ( + "bytes" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" + "strings" "time" "github.com/spf13/cobra" + "github.com/versolauth/versola-cli/internal/browser" "github.com/versolauth/versola-cli/internal/checks" "github.com/versolauth/versola-cli/internal/state" "github.com/versolauth/versola-cli/internal/wait" ) +var noBrowser bool + var bootstrapCmd = &cobra.Command{ Use: "bootstrap ", Short: "Deploy a specific version of Versola", @@ -31,6 +38,10 @@ compose stack needs. See the project design doc, section 3.5.`, RunE: runBootstrap, } +func init() { + bootstrapCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "don't open the admin console in a browser once it's ready") +} + func runBootstrap(cmd *cobra.Command, args []string) error { target, version := args[0], args[1] if target != "local" { @@ -41,7 +52,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { for _, r := range []checks.Result{ checks.DockerDaemon(), checks.ComposePlugin(), - checks.PortFree(8080), + checks.PortFree(2821), checks.DockerMemory(), checks.DiskSpace(), } { @@ -59,7 +70,14 @@ func runBootstrap(cmd *cobra.Command, args []string) error { fmt.Println("Generating configuration (versola-tools)...") toolsImage := fmt.Sprintf("ghcr.io/versolauth/versola-tools:%s", version) - if err := runDocker("run", "--rm", "-v", dir+":/out", toolsImage); err != nil { + if err := pullAndRunTools(dir, toolsImage); err != nil { + if isManifestUnknown(err) { + return fmt.Errorf(`version %q of Versola doesn't exist (no "versola-tools" image published for it). + +Versola releases are tagged WITHOUT a leading "v" (e.g. "0.1.2", not +"v0.1.2" — that "v" prefix is only used for versola-cli's own releases). +Check the available versions at https://github.com/orgs/versolauth/packages`, version) + } return fmt.Errorf("versola-tools failed: %w", err) } @@ -97,14 +115,99 @@ func runBootstrap(cmd *cobra.Command, args []string) error { return fmt.Errorf("edge never became ready: %w", err) } - fmt.Printf("\nVersola %s is running at http://localhost:8080\n", version) + adminURL := "http://localhost:2821/central/admin/" + fmt.Printf("\nVersola %s is running at http://localhost:2821\n", version) fmt.Println("Login: admin / Admin1234!") + + if !noBrowser { + // Best-effort only: bootstrap having succeeded shouldn't hinge on a + // desktop environment being around to pop a browser window in + // (e.g. running over SSH) — failure here is a note, not an error. + if err := browser.Open(adminURL); err != nil { + fmt.Printf("(couldn't open a browser automatically: %v — open %s yourself)\n", err, adminURL) + } + } return nil } -func runDocker(args ...string) error { +// dockerCmd builds a docker exec.Cmd with stdout already wired to the +// user's terminal. Stderr is left for the caller to set, since runDocker +// and pullAndRunTools each need it to go somewhere different. +func dockerCmd(args ...string) *exec.Cmd { c := exec.Command("docker", args...) c.Stdout = os.Stdout + return c +} + +func runDocker(args ...string) error { + c := dockerCmd(args...) c.Stderr = os.Stderr return c.Run() } + +// manifestUnknownErr wraps a runDocker failure whose stderr contained +// Docker's "manifest unknown" text -- i.e. the image (or this tag of it) +// was never published, as opposed to some other failure (network, daemon +// down, etc.) that happens to also come back as a non-zero exit. +type manifestUnknownErr struct{ inner error } + +func (e *manifestUnknownErr) Error() string { return e.inner.Error() } +func (e *manifestUnknownErr) Unwrap() error { return e.inner } + +func isManifestUnknown(err error) bool { + var m *manifestUnknownErr + return errors.As(err, &m) +} + +// pullAndRunTools runs the versola-tools image the same way runDocker does +// (stdout/stderr still streamed live to the user), but also tees stderr +// into a buffer so it can be inspected afterward -- specifically to +// recognize Docker's "manifest unknown" text and turn it into a clearer +// error in runBootstrap, without changing behavior for every other +// runDocker call site (compose up/down, uninstall's rmi, etc.) that has no +// need for this. +func pullAndRunTools(dir, image string) error { + // "manifest unknown" (when it happens at all) comes from Docker + // failing to resolve the image before any pull output follows, so a + // few KB is more than enough to catch it -- capped rather than a plain + // bytes.Buffer so a normal, successful pull's progress output (which + // can run to tens of KB across an image's layers) can't grow this + // unbounded in memory. os.Stderr (the other leg of the MultiWriter + // below) still gets every byte, same as a real terminal would. + stderrBuf := newCappedBuffer(8 * 1024) + c := dockerCmd("run", "--rm", "-v", dir+":/out", image) + c.Stderr = io.MultiWriter(os.Stderr, stderrBuf) + + if err := c.Run(); err != nil { + if strings.Contains(stderrBuf.String(), "manifest unknown") { + return &manifestUnknownErr{inner: err} + } + return err + } + return nil +} + +// cappedBuffer is a bytes.Buffer that silently stops accumulating past a +// fixed size. It still reports every byte as written (never a short +// write) so it's safe to use as one leg of an io.MultiWriter -- the other +// leg (os.Stderr here) keeps receiving the full, uncapped output. +type cappedBuffer struct { + buf bytes.Buffer + limit int +} + +func newCappedBuffer(limit int) *cappedBuffer { + return &cappedBuffer{limit: limit} +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + if remaining := c.limit - c.buf.Len(); remaining > 0 { + if remaining > len(p) { + remaining = len(p) + } + c.buf.Write(p[:remaining]) + } + return len(p), nil +} + +func (c *cappedBuffer) String() string { return c.buf.String() } diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 89cfd20..ae9b3f6 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -1,10 +1,18 @@ package cmd import ( + "bufio" + "context" "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" "github.com/spf13/cobra" + "github.com/versolauth/versola-cli/internal/browser" "github.com/versolauth/versola-cli/internal/checks" ) @@ -13,8 +21,9 @@ var doctorCmd = &cobra.Command{ Short: "Check that this machine has everything Versola needs", Long: `doctor checks this machine for the dependencies Versola needs to run locally: a reachable Docker daemon, the Docker Compose plugin, and a -free port for the gateway. It only reads state — it never installs or -changes anything. +free port for the gateway. It never installs anything on its own -- if +Docker isn't found at all, the most it does is offer to open an install +page in your browser, and only after you confirm. Run this before "versola bootstrap local " to see ahead of time what's missing.`, @@ -22,10 +31,11 @@ time what's missing.`, } func runDoctor(cmd *cobra.Command, args []string) error { + dockerDaemon := checks.DockerDaemon() results := []checks.Result{ - checks.DockerDaemon(), + dockerDaemon, checks.ComposePlugin(), - checks.PortFree(8080), + checks.PortFree(2821), checks.DockerMemory(), checks.DiskSpace(), } @@ -38,6 +48,10 @@ func runDoctor(cmd *cobra.Command, args []string) error { } } + if !dockerDaemon.OK { + offerDockerHelp() + } + fmt.Println() if failed == 0 { fmt.Println("All checks passed — ready for `versola bootstrap local `.") @@ -47,3 +61,153 @@ func runDoctor(cmd *cobra.Command, args []string) error { fmt.Printf("%d check(s) failed. Fix the issue(s) above before running bootstrap.\n", failed) return fmt.Errorf("doctor found %d problem(s)", failed) } + +// offerDockerHelp runs when the Docker daemon check failed. It distinguishes +// two different situations that look identical to DockerDaemon() but need +// different advice: +// +// - docker isn't on PATH at all -> nothing is installed, so this offers to +// open a download page (a choice of two, on macOS). +// - docker IS on PATH but the daemon didn't answer -> it's installed but +// not running, so "go install something" would be actively wrong advice; +// this just says to start it. +// +// Never installs or downloads anything itself — only opens a browser tab a +// human then acts on, consistent with every other check in this package +// only reading state, never changing it. +func offerDockerHelp() { + if path, err := exec.LookPath("docker"); err == nil { + fmt.Println() + fmt.Println(daemonUnreachableHint(path)) + return + } + + fmt.Println("\nDocker doesn't seem to be installed.") + + switch runtime.GOOS { + case "darwin": + offerChoice( + "Which would you like to install?", + [2]choiceOption{ + {"Docker Desktop", "the standard, full-featured option", "https://www.docker.com/products/docker-desktop/"}, + {"Colima", "lightweight, CLI-only, no GUI/license considerations", "https://github.com/abiosoft/colima#installation"}, + }, + ) + case "windows": + promptOpen("Open the Docker Desktop download page?", "https://www.docker.com/products/docker-desktop/") + case "linux": + promptOpen("Open the Docker Engine install instructions?", "https://docs.docker.com/engine/install/") + default: + fmt.Println("Install Docker for your platform: https://docs.docker.com/get-started/get-docker/") + } +} + +// daemonUnreachableHint runs `docker version` itself -- checks.DockerDaemon() +// already told us this failed, but not why, and "start Docker Desktop" is +// actively wrong advice for the most common Linux failure mode: the +// invoking user isn't in the `docker` group, so the daemon is running +// fine but the socket refuses this user specifically. That shows up as +// "permission denied" in docker's own output, which is what this checks +// for before falling back to the generic "it's not running" hint. +func daemonUnreachableHint(dockerPath string) string { + // Same 5s bound as checks.DockerDaemon() -- that check already failed + // within that window, so this diagnostic re-check shouldn't be able to + // hang longer than the thing it's explaining, e.g. against a + // misconfigured remote DOCKER_HOST that never answers. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, dockerPath, "version", "--format", "{{.Server.Version}}").CombinedOutput() + if strings.Contains(strings.ToLower(string(out)), "permission denied") { + return "Docker is installed, but this user doesn't have permission to talk to it " + + "(the daemon socket is normally root:docker, and group membership needs a fresh " + + "login to take effect). Try: sudo usermod -aG docker $USER, then log out and back " + + "in. See https://docs.docker.com/engine/install/linux-postinstall/" + } + return "Docker is installed but the daemon isn't responding — start Docker Desktop (or Colima, if that's what you use) and try again." +} + +type choiceOption struct { + name string + desc string + url string +} + +func offerChoice(prompt string, options [2]choiceOption) { + fmt.Println(prompt) + for i, o := range options { + fmt.Printf(" %d. %s — %s: %s\n", i+1, o.name, o.desc, o.url) + } + + // doctor is otherwise a pure read-only diagnostic -- it shouldn't hang + // waiting for a keypress that will never come when stdin isn't a + // terminal (a scripted/CI run, e.g.). Print the choices with their + // URLs and return instead of blocking on ReadString. + if !stdinIsInteractive() { + return + } + + fmt.Print("Open a download page in your browser? [1/2/N]: ") + + answer := readLine() + var chosen *choiceOption + switch answer { + case "1": + chosen = &options[0] + case "2": + chosen = &options[1] + default: + return + } + + if err := browser.Open(chosen.url); err != nil { + fmt.Printf("Couldn't open a browser: %v — visit %s yourself.\n", err, chosen.url) + return + } + fmt.Printf("Opened %s in your browser.\n", chosen.url) +} + +func promptOpen(prompt, url string) { + if !stdinIsInteractive() { + fmt.Printf("%s %s\n", prompt, url) + return + } + + fmt.Printf("%s [y/N]: ", prompt) + if !yes(readLine()) { + return + } + if err := browser.Open(url); err != nil { + fmt.Printf("Couldn't open a browser: %v — visit %s yourself.\n", err, url) + return + } + fmt.Printf("Opened %s in your browser.\n", url) +} + +// stdinIsInteractive reports whether stdin looks like a real terminal +// rather than a pipe, a redirected file, or /dev/null -- the standard +// character-device check, no extra dependency needed. Used to skip +// doctor's install-page prompts (offerChoice/promptOpen) when nothing is +// there to answer them, so a scripted or CI run of a nominally read-only +// diagnostic command can't hang waiting on a keypress that will never +// come. uninstall's confirm() deliberately doesn't use this -- it already +// has an explicit --yes flag as its non-interactive escape hatch, which a +// destructive command should require opting into rather than silently +// inferring from the environment. +func stdinIsInteractive() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func readLine() string { + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +func yes(answer string) bool { + answer = strings.ToLower(answer) + return answer == "y" || answer == "yes" +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8131243..f2f3b25 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -35,6 +35,7 @@ func init() { rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(downCmd) + rootCmd.AddCommand(uninstallCmd) rootCmd.AddCommand(versionCmd) // Make "versola --version" print exactly what "versola version" diff --git a/internal/cmd/uninstall.go b/internal/cmd/uninstall.go new file mode 100644 index 0000000..a86afce --- /dev/null +++ b/internal/cmd/uninstall.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/versolauth/versola-cli/internal/checks" + "github.com/versolauth/versola-cli/internal/state" +) + +var assumeYes bool + +var uninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Remove everything versola deployed locally", + Long: `uninstall stops the locally deployed Versola stack (including its +Postgres data volume), removes the Docker images versola pulled, and +clears ~/.versola. + +If a deployment was recorded but Docker isn't reachable to confirm it's +actually stopped, ~/.versola is deliberately left in place instead of +cleared -- deleting it there would destroy the only way to properly stop +that deployment later, if it turns out to still be running somewhere +uninstall couldn't reach. + +It does NOT remove the versola binary itself or its PATH entry — safely +deleting a program's own currently-running executable isn't portable +across platforms (Windows in particular won't allow it). uninstall prints +the binary's path so you can remove it yourself.`, + RunE: runUninstall, +} + +func init() { + uninstallCmd.Flags().BoolVarP(&assumeYes, "yes", "y", false, "don't prompt for confirmation") +} + +func runUninstall(cmd *cobra.Command, args []string) error { + composePath, deployed, err := state.ComposeFile() + if err != nil { + return err + } + + // If the daemon isn't reachable at all, there's nothing "docker compose + // down" could stop even if a deployment was recorded -- Docker being + // down means nothing is running, full stop. Skip that step instead of + // letting it hard-fail the whole command and leave ~/.versola behind. + dockerUp := checks.DockerDaemon().OK + stopStack := deployed && dockerUp + + // Only ask Docker what images exist if the daemon is actually + // reachable -- calling it while dockerUp is false would just fail with + // the same unreachable-daemon error `versola doctor` already covers. + // imagesUnknown tracks that we genuinely don't know either way, which + // is a different claim than "checked, found none" -- conflating the + // two either risks a false "nothing to remove" (images could still be + // sitting there) or silently reporting success without actually + // checking. + var images []string + imagesUnknown := !dockerUp + if dockerUp { + images, err = versolaImages() + if err != nil { + return err + } + } + + stateDir, err := state.Dir() + if err != nil { + return err + } + // state.Dir() returns .../.versola/active; uninstall clears the whole + // .versola directory, not just the active deployment, since nothing + // else is meant to live there. + versolaDir := filepath.Dir(stateDir) + _, statErr := os.Stat(versolaDir) + if statErr != nil && !os.IsNotExist(statErr) { + // Something other than "doesn't exist" -- e.g. a permissions + // error -- shouldn't be silently treated as "nothing here to + // remove"; that could leave real state behind while uninstall + // reports success. + return fmt.Errorf("couldn't check %s: %w", versolaDir, statErr) + } + dirExists := statErr == nil + + // A deployment was recorded but we couldn't confirm via Docker that + // it's actually stopped (stopStack is false while deployed is true). + // Deleting compose.yml here would destroy the only handle left to + // `docker compose down` it properly later -- if the stack is really + // still running somewhere reachable (e.g. this user just lacks + // permission to the daemon socket, which is a very different + // situation from "Docker isn't running at all"), that leaves + // containers and the Postgres volume orphaned with no record of what + // created them. Keep the directory in that case instead of guessing. + keepDirForLaterCleanup := deployed && !stopStack && dirExists + + if !deployed && !dirExists { + if imagesUnknown { + fmt.Println("No deployed stack and no local state, but Docker isn't reachable, so leftover versola images couldn't be checked.") + fmt.Println("Make sure Docker is running (see `versola doctor` if it isn't obvious why) and run `versola uninstall` again to check for those.") + return nil + } + if len(images) == 0 { + fmt.Println("Nothing to remove — no deployed stack, no versola images, no local state.") + return nil + } + } + + fmt.Println("This will remove:") + if stopStack { + fmt.Println(" - the running Versola stack and its Postgres data volume") + } else if deployed { + fmt.Println(" - recorded deployment state (Docker isn't reachable, so it can't be confirmed as stopped)") + } + if imagesUnknown { + fmt.Println(" - (couldn't check for versola images — Docker isn't reachable; run uninstall again once it is to catch any)") + } else { + for _, img := range images { + fmt.Printf(" - image %s\n", img) + } + } + if dirExists { + if keepDirForLaterCleanup { + fmt.Printf(" - (keeping %s — Docker isn't reachable, so there's no way to confirm the stack is actually stopped)\n", versolaDir) + } else { + fmt.Printf(" - %s\n", versolaDir) + } + } + + if !assumeYes && !confirm("Continue?") { + fmt.Println("Aborted.") + return nil + } + + if stopStack { + fmt.Println("Stopping stack and removing volumes...") + if err := runDocker("compose", "-f", composePath, "down", "--volumes"); err != nil { + return fmt.Errorf("docker compose down failed: %w", err) + } + } else if deployed { + fmt.Println("Docker isn't reachable — skipping docker compose down, and leaving ~/.versola in place so it can still be stopped properly once Docker's reachable again.") + } + + for _, img := range images { + fmt.Printf("Removing image %s...\n", img) + if err := runDocker("rmi", img); err != nil { + // Not fatal -- an image still referenced by something else, or + // one already removed by hand, shouldn't stop the rest of the + // cleanup. + fmt.Printf(" (couldn't remove %s: %v)\n", img, err) + } + } + + if dirExists && !keepDirForLaterCleanup { + fmt.Printf("Removing %s...\n", versolaDir) + if err := os.RemoveAll(versolaDir); err != nil { + return fmt.Errorf("couldn't remove %s: %w", versolaDir, err) + } + } + + fmt.Println("\nDone. The versola binary itself is untouched.") + if exe, err := os.Executable(); err == nil { + fmt.Printf("Delete it yourself if you're done with it: %s\n", exe) + } + return nil +} + +// versolaImages lists locally pulled images matching Versola's own +// repositories (versola-auth, versola-central, etc. under +// ghcr.io/versolauth/versola-*) — not every image on the machine, and not +// third-party images bootstrap also pulls (postgres) that the user almost +// certainly has other uses for. +// +// Callers should only invoke this once they know the Docker daemon is +// reachable (see runUninstall's dockerUp check) -- an unreachable daemon +// isn't this function's job to diagnose, and folding it into "no images +// found" here made that failure indistinguishable from any other real +// error `docker images` could return. +func versolaImages() ([]string, error) { + out, err := exec.Command("docker", "images", "--format", "{{.Repository}}:{{.Tag}}", + "--filter", "reference=ghcr.io/versolauth/versola-*").Output() + if err != nil { + // Output() populates ExitError.Stderr (since Stderr wasn't set to + // anything else above), but *exec.ExitError.Error() itself is just + // "exit status 1" -- the useful part is in that Stderr field, which + // gets lost if it's not pulled out and included explicitly here. + if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 { + return nil, fmt.Errorf("docker images failed: %s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("docker images failed: %w", err) + } + var images []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" { + images = append(images, line) + } + } + return images, nil +} + +// confirm prompts for a yes/no answer. readLine/yes live in doctor.go — +// shared here rather than duplicated, since this is the same "ask before +// doing something irreversible" pattern doctor's install prompts use. +func confirm(prompt string) bool { + fmt.Printf("%s [y/N] ", prompt) + return yes(readLine()) +} diff --git a/internal/state/state.go b/internal/state/state.go index 538b28b..b54ce5d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -50,8 +50,8 @@ func Prepare(version string) (string, error) { } // ComposeFile returns the path to the compose file bootstrap generates, -// and whether it currently exists. It existing is how status/down tell -// whether anything has been deployed yet. +// and whether it currently exists. It existing is how status/down/uninstall +// tell whether anything has been deployed yet. func ComposeFile() (path string, exists bool, err error) { dir, err := Dir() if err != nil { @@ -59,7 +59,15 @@ func ComposeFile() (path string, exists bool, err error) { } path = filepath.Join(dir, "compose.yml") if _, statErr := os.Stat(path); statErr != nil { - return path, false, nil + if os.IsNotExist(statErr) { + return path, false, nil + } + // Something other than "doesn't exist" -- e.g. a permissions + // error -- shouldn't be silently treated as "nothing deployed"; + // every caller (status, down, uninstall) already checks this + // error and would otherwise skip real work based on a false + // negative here. + return path, false, fmt.Errorf("couldn't check %s: %w", path, statErr) } return path, true, nil }