diff --git a/go.mod b/go.mod index 865f9bb..4232565 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/versolauth/versola-cli -go 1.22 +go 1.25.0 require github.com/spf13/cobra v1.8.1 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index 912390a..6443fac 100644 --- a/go.sum +++ b/go.sum @@ -6,5 +6,9 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/checks/checks.go b/internal/checks/checks.go index 3eff3e9..38b06a3 100644 --- a/internal/checks/checks.go +++ b/internal/checks/checks.go @@ -79,31 +79,36 @@ func ComposePlugin() Result { // PortFree checks whether a port is free for Versola's gateway to bind. // It checks two independent things, because they can disagree: // -// 1. A raw OS-level bind attempt. Catches non-Docker processes (a local +// 1. Docker's own published-port bookkeeping, via `docker ps`. Checked +// first, and separately from (2) below — confirmed by hand while +// testing this command: on Windows with Docker Desktop's WSL2 backend, +// a container can hold a published port (docker itself refuses to +// reuse it, with "port is already allocated") while a plain +// `net.Listen` on that same port from the host still succeeds. +// Docker's port forwarding there isn't always a literal OS socket a +// bind attempt collides with, so relying on (2) alone produced a +// false "free" against a container that was actually running and +// holding the port. +// 2. A raw OS-level bind attempt. Catches non-Docker processes (a local // dev server, IIS, etc.) holding the port. -// 2. Docker's own published-port bookkeeping, via `docker ps`. This is -// necessary in addition to (1) — confirmed by hand while testing this -// command: on Windows with Docker Desktop's WSL2 backend, a container -// can hold a published port (docker itself refuses to reuse it, with -// "port is already allocated") while a plain `net.Listen` on that same -// port from the host still succeeds. Docker's port forwarding there -// isn't always a literal OS socket a bind attempt collides with, so -// relying on (1) alone produced a false "free" against a container -// that was actually running and holding the port. // -// A busy port is the most common reason a fresh bootstrap fails on -// someone's machine — see the port 5432 conflict found during the -// project's manual test. -func PortFree(port int) Result { +// ownContainer names the one Docker container it's fine for this port to +// already belong to — the compose file's fixed project name (see +// compose.fragment.yml.template's `name:`) means Up will update or +// restart that container in place on a second `configure`/`up`, not +// conflict with it, so finding it here isn't a real problem the way any +// other owner is. Without this, a redeploy while the previous one was +// still running would fail this check even though it would have worked +// fine. +// +// A busy port held by something else is the most common reason a fresh +// bootstrap fails on someone's machine — see the port 5432 conflict found +// during the project's manual test. +func PortFree(port int, ownContainer string) Result { name := fmt.Sprintf("Port %d free", port) - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - return Result{Name: name, OK: false, Detail: "already in use"} - } - _ = ln.Close() - - if owner, used := dockerPortInUse(port); used { + owner, used := dockerPortInUse(port) + if used && owner != ownContainer { return Result{ Name: name, OK: false, @@ -111,6 +116,41 @@ func PortFree(port int) Result { } } + // Still attempt the raw bind even when Docker's own bookkeeping says + // our own container holds this port (used && owner == ownContainer), + // but only on Windows: Docker Desktop's WSL2 backend can leave `docker + // ps` still reporting a container's port as published after a backend + // restart, while the WSL2-side forwarding process behind it has + // actually died — the OS-level port is genuinely free again at that + // point, and another host process can grab it before Docker restores + // the forward on the next compose up/restart. + // + // This is safe to interpret as "something else must be squatting on + // it" specifically on Windows/WSL2, where a live forward normally lets + // a plain net.Listen from the host succeed anyway (confirmed by hand). + // On native Docker (Linux, and Docker Desktop for Mac's vpnkit, which + // doesn't share WSL2's failure mode), Compose's own publish mechanism + // routinely DOES hold the actual OS-level port itself for a running + // container — every ordinary redeploy of our own, healthy nginx would + // then fail this raw bind and get misreported as a conflict, which is + // worse than the rare WSL2 case this exists to catch. + if used && owner == ownContainer && runtime.GOOS != "windows" { + return Result{Name: name, OK: true} + } + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + if used { + return Result{ + Name: name, + OK: false, + Detail: fmt.Sprintf("already in use — Docker still reports %q as the owner, but something else is holding the port at the OS level (possibly stale after a Docker Desktop/WSL2 restart)", ownContainer), + } + } + return Result{Name: name, OK: false, Detail: "already in use"} + } + _ = ln.Close() + return Result{Name: name, OK: true} } diff --git a/internal/cmd/bootstrap.go b/internal/cmd/bootstrap.go index 29a1172..44aebfd 100644 --- a/internal/cmd/bootstrap.go +++ b/internal/cmd/bootstrap.go @@ -1,22 +1,9 @@ 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" + "github.com/versolauth/versola-cli/internal/deploy" ) var noBrowser bool @@ -29,11 +16,21 @@ var bootstrapCmd = &cobra.Command{ Currently supported: versola bootstrap local 0.1.1 + versola bootstrap vps 0.1.1 + +vps deploys to the one real VPS this is configured for (see deploy.md) — +it requires OpenBao credentials to already be stored for it first (see +"versola secrets login vps"), and asks for confirmation before it touches +the live database. This CLI doesn't know Versola's own topology (ports, service names, config schema) — that lives entirely in the versioned "versola-tools" image, which this command pulls and runs to generate everything the -compose stack needs. See the project design doc, section 3.5.`, +compose stack needs. See the project design doc, section 3.5. + +Internally this is two steps — prepare the deployment, then start it — +which is what a deployment onto a server will need to be able to run +separately, with a database migration between them. See internal/deploy.`, Args: cobra.ExactArgs(2), RunE: runBootstrap, } @@ -42,181 +39,20 @@ func init() { bootstrapCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "don't open the admin console in a browser once it's ready") } +// runBootstrap deploys in one go, which is all this command has ever +// done and all it should keep doing: it's what the README, the install +// scripts and everyone's muscle memory point at. +// +// The steps it calls are separate functions rather than one, because the +// server deployment this is being prepared for has to be able to stop +// between them. Nothing about that is visible here yet — deliberately, so +// that this rearrangement can be verified by a local deployment behaving +// exactly as it did before. func runBootstrap(cmd *cobra.Command, args []string) error { target, version := args[0], args[1] - if target != "local" { - return fmt.Errorf(`unsupported target %q — only "local" is supported today`, target) - } - - fmt.Println("Checking prerequisites...") - for _, r := range []checks.Result{ - checks.DockerDaemon(), - checks.ComposePlugin(), - checks.PortFree(2821), - checks.DockerMemory(), - checks.DiskSpace(), - } { - fmt.Println(r.String()) - if !r.OK { - return fmt.Errorf("prerequisite check failed — run `versola doctor` for details") - } - } - - fmt.Printf("\nPreparing Versola %s...\n", version) - dir, err := state.Prepare(version) - if err != nil { - return err - } - - fmt.Println("Generating configuration (versola-tools)...") - toolsImage := fmt.Sprintf("ghcr.io/versolauth/versola-tools:%s", version) - 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) - } - - composePath := filepath.Join(dir, "compose.yml") - if err := os.Rename(filepath.Join(dir, "compose.fragment.yml"), composePath); err != nil { - return fmt.Errorf("couldn't finalize compose file: %w", err) - } - - // Postgres and central go up first, on their own — auth/edge's own - // startup fails fatally if central isn't reachable yet, and confirmed - // by hand that Docker's restart policy does NOT recover from that - // failure mode (the process hangs rather than exiting). See the - // comment on auth's depends_on in - // versola-tools/compose.fragment.yml.template. - fmt.Println("\nStarting Postgres and central...") - if err := runDocker("compose", "-f", composePath, "up", "-d", "postgres", "central"); err != nil { - return fmt.Errorf("couldn't start postgres/central: %w", err) - } - - fmt.Println("Waiting for central to be ready...") - if err := wait.ForReady("http://localhost:8091/readiness", 60*time.Second); err != nil { - return fmt.Errorf("central never became ready: %w", err) - } - - fmt.Println("Starting auth, edge, and the gateway...") - if err := runDocker("compose", "-f", composePath, "up", "-d"); err != nil { - return fmt.Errorf("couldn't start the rest of the stack: %w", err) - } - - fmt.Println("Waiting for auth and edge to be ready...") - if err := wait.ForReady("http://localhost:8081/readiness", 60*time.Second); err != nil { - return fmt.Errorf("auth never became ready: %w", err) - } - if err := wait.ForReady("http://localhost:8096/readiness", 60*time.Second); err != nil { - return fmt.Errorf("edge never became ready: %w", err) - } - - 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 -} - -// 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) - // --platform linux/amd64: versola-tools, like the rest of the stack - // (see compose.fragment.yml.template's platform pins on central/auth/ - // edge/nginx in the versola repo), is only published for amd64. Without - // this, Docker has to guess whether to emulate on non-amd64 hosts (e.g. - // Apple Silicon Macs) -- it doesn't always guess right, and the failure - // mode when it doesn't is a raw "no matching manifest" error instead of - // a working, if slower, emulated container. Being explicit here makes - // bootstrap work the same way on arm64 as on amd64, without requiring - // DOCKER_DEFAULT_PLATFORM to be set in the environment first. - c := dockerCmd("run", "--rm", "--platform", "linux/amd64", "-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} - } + if _, err := deploy.Configure(target, version); err != nil { return err } - return nil + return deploy.Up(deploy.UpOptions{NoBrowser: noBrowser}) } - -// 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 ae9b3f6..6ed8301 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -35,7 +35,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { results := []checks.Result{ dockerDaemon, checks.ComposePlugin(), - checks.PortFree(2821), + checks.PortFree(2821, "versola-nginx"), checks.DockerMemory(), checks.DiskSpace(), } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 94f0401..fc022a8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -37,6 +37,7 @@ func init() { rootCmd.AddCommand(downCmd) rootCmd.AddCommand(uninstallCmd) rootCmd.AddCommand(upgradeCmd) + rootCmd.AddCommand(secretsCmd) rootCmd.AddCommand(versionCmd) // Make "versola --version" print exactly what "versola version" diff --git a/internal/cmd/secrets.go b/internal/cmd/secrets.go new file mode 100644 index 0000000..85ccf4b --- /dev/null +++ b/internal/cmd/secrets.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/versolauth/versola-cli/internal/openbao" +) + +// secretsCmd is a parent for subcommands, not runnable itself — cobra +// prints its own help (the subcommand list) when invoked with no +// subcommand, which is what we want here. +var secretsCmd = &cobra.Command{ + Use: "secrets", + Short: "Manage this machine's access to OpenBao", +} + +var secretsLoginCmd = &cobra.Command{ + Use: "login
", + Short: "Store this machine's AppRole credentials for a target's OpenBao", + Long: `login stores the AppRole credentials whoever administers OpenBao for + handed you, so later commands (configure, migrate, "secrets +test") can authenticate without asking again. + +
is where OpenBao listens, e.g. http://localhost:8200 for +local. and come from the AppRole your OpenBao +administrator created — see develop.md's OpenBao section for how +that's set up. secret-id is asked for as a separate prompt, not a +fourth positional argument: it's effectively this AppRole's password, +and a positional arg would land it in the invoking shell's history and, +while this command runs, in anything that can read this process's +argument list (e.g. "ps"). A prompt avoids both. + +Credentials are stored per target in ~/.versola/openbao/.json +(not under ~/.versola/active, which only ever describes the single +deployment currently configured) — logging into a different target +later doesn't discard this one's access.`, + Args: cobra.ExactArgs(3), + RunE: runSecretsLogin, +} + +func runSecretsLogin(cmd *cobra.Command, args []string) error { + target, address, roleID := args[0], args[1], args[2] + + secretID, err := readSecretID() + if err != nil { + return err + } + if secretID == "" { + return fmt.Errorf("secret ID is required") + } + + creds := &openbao.Credentials{ + Address: address, + RoleID: roleID, + SecretID: secretID, + } + + // Fail before saving anything that doesn't actually work — a stored + // credential that turns out to be wrong is a more confusing failure + // mode (surfaces later, inside configure/migrate) than rejecting it + // here, at the moment it was typed in. + fmt.Printf("Verifying credentials against %s...\n", address) + if err := openbao.NewClient(creds).Login(context.Background()); err != nil { + return fmt.Errorf("couldn't log in with these credentials: %w", err) + } + + if err := openbao.SaveCredentials(target, creds); err != nil { + return err + } + fmt.Printf("Stored OpenBao credentials for %q.\n", target) + return nil +} + +// readSecretID prompts for the AppRole secret ID -- this machine's +// equivalent of a password for whichever OpenBao target it's logging +// into. Masked (no terminal echo) whenever stdin is a real terminal, so +// it doesn't end up sitting in scrollback or a session recording the way +// a plain readLine() would -- the same exposure moving it out of a +// positional argument already avoided for shell history and "ps". +// +// Falls back to a plain, visible read when stdin isn't a terminal (a +// scripted/CI invocation piping the value in) -- term.ReadPassword +// requires a real TTY and errors otherwise, and a script feeding this in +// isn't the case this masking exists for anyway. +func readSecretID() (string, error) { + fmt.Print("Secret ID: ") + if !stdinIsInteractive() { + return readLine(), nil + } + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("couldn't read secret ID: %w", err) + } + return strings.TrimSpace(string(b)), nil +} + +var secretsTestCmd = &cobra.Command{ + Use: "test ", + Short: "Verify this machine's stored OpenBao credentials for a target", + Long: `test logs into OpenBao using the AppRole credentials stored for + (see "versola secrets login"), then writes and reads back a +scratch value to confirm both the credentials and the KV v2 engine +are set up correctly. + +This doesn't touch any real service's secrets — it writes to +versola//_secrets-test, a path nothing else reads.`, + Args: cobra.ExactArgs(1), + RunE: runSecretsTest, +} + +func runSecretsTest(cmd *cobra.Command, args []string) error { + target := args[0] + + creds, err := openbao.LoadCredentials(target) + if err != nil { + if errors.Is(err, openbao.ErrNoCredentials) { + return fmt.Errorf("no OpenBao credentials stored for %q — run `versola secrets login %s
` first (it prompts for the secret ID separately)", target, target) + } + return err + } + + client := openbao.NewClient(creds) + ctx := context.Background() + + fmt.Printf("Logging into OpenBao at %s...\n", creds.Address) + if err := client.Login(ctx); err != nil { + return err + } + fmt.Println(" ok") + + path := openbao.SecretPath(target, "_secrets-test") + want := map[string]string{"probe": fmt.Sprintf("versola-cli round-trip at %s", time.Now().UTC().Format(time.RFC3339))} + + fmt.Println("Writing a scratch value...") + if err := client.WriteSecret(ctx, path, want); err != nil { + return err + } + fmt.Println(" ok") + + fmt.Println("Reading it back...") + got, ok, err := client.ReadSecret(ctx, path) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("wrote a value but couldn't read it back") + } + if got["probe"] != want["probe"] { + return fmt.Errorf("read back a different value than was written") + } + fmt.Println(" ok") + + fmt.Printf("\nOpenBao credentials for %q are working.\n", target) + return nil +} + +func init() { + secretsCmd.AddCommand(secretsLoginCmd) + secretsCmd.AddCommand(secretsTestCmd) +} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index a4d77f6..6b22675 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -31,8 +31,15 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } - if v := state.Version(); v != "" { - fmt.Printf("Deployed version: %s\n\n", v) + // Read the whole state record rather than just the version: a + // deployment now has a target, and on a server it will matter which + // one this is. Errors are deliberately ignored -- a compose file + // exists (checked above), so docker still has something useful to say + // about it even if the record next to it is missing or unreadable, + // and refusing to print any status at all over that would be worse + // than printing it without a header. + if st, err := state.Load(); err == nil { + fmt.Printf("Deployed version: %s (target: %s)\n\n", st.Version, st.Target) } c := exec.Command("docker", "compose", "-f", composePath, "ps") diff --git a/internal/cmd/uninstall.go b/internal/cmd/uninstall.go index 2448c6f..b3e868c 100644 --- a/internal/cmd/uninstall.go +++ b/internal/cmd/uninstall.go @@ -4,12 +4,12 @@ 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/docker" "github.com/versolauth/versola-cli/internal/state" ) @@ -20,12 +20,17 @@ var uninstallCmd = &cobra.Command{ Short: "Stop the stack and remove its data, images, and local state", Long: `uninstall stops the locally deployed Versola stack (including its Postgres data volume), removes the Docker images versola pulled, and -clears ~/.versola. +clears ~/.versola/active. + +~/.versola/openbao is left untouched -- it holds AppRole credentials for +every target this machine has logged into (see "versola secrets login"), +and uninstalling one target's stack shouldn't discard another target's +access. 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 +actually stopped, ~/.versola/active 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 @@ -45,6 +50,15 @@ func runUninstall(cmd *cobra.Command, args []string) error { return err } + // Needed below to decide whether it's safe to also remove OpenBao's + // data volume (see the comment on that) -- errors here are treated the + // same way ComposeFile already treats them internally: no state means + // nothing to know a target for, not a reason to fail uninstall itself. + var target string + if st, loadErr := state.Load(); loadErr == nil { + target = st.Target + } + // 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 @@ -69,21 +83,26 @@ func runUninstall(cmd *cobra.Command, args []string) error { } } + // state.Dir() returns .../.versola/active -- that's what gets removed + // below, NOT its parent .../.versola. .../.versola/openbao also lives + // under there, holding AppRole credentials for every target this + // machine has ever logged into (see openbao.Credentials' own comment: + // configuring local after vps, or back again, must not discard either + // target's access) -- deleting the whole parent would wipe every + // target's credentials just because the active one is being torn + // down, defeating that entirely. Removing only "active" leaves + // credentials exactly where "versola secrets login" put them. 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) + _, statErr := os.Stat(stateDir) 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) + return fmt.Errorf("couldn't check %s: %w", stateDir, statErr) } dirExists := statErr == nil @@ -112,7 +131,11 @@ func runUninstall(cmd *cobra.Command, args []string) error { fmt.Println("This will remove:") if stopStack { - fmt.Println(" - the running Versola stack and its Postgres data volume") + if target == "local" { + fmt.Println(" - the running Versola stack, its Postgres data volume, and OpenBao's secrets volume") + } else { + fmt.Println(" - the running Versola stack (OpenBao's secrets volume is left in place — see below)") + } } else if deployed { fmt.Println(" - recorded deployment state (Docker isn't reachable, so it can't be confirmed as stopped)") } @@ -125,9 +148,9 @@ func runUninstall(cmd *cobra.Command, args []string) error { } 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) + fmt.Printf(" - (keeping %s — Docker isn't reachable, so there's no way to confirm the stack is actually stopped)\n", stateDir) } else { - fmt.Printf(" - %s\n", versolaDir) + fmt.Printf(" - %s\n", stateDir) } } @@ -138,16 +161,63 @@ func runUninstall(cmd *cobra.Command, args []string) error { if stopStack { fmt.Println("Stopping stack and removing volumes...") - if err := runDocker("compose", "-f", composePath, "down", "--volumes"); err != nil { + if err := docker.Run("compose", "-f", composePath, "down", "--volumes"); err != nil { return fmt.Errorf("docker compose down failed: %w", err) } + + // `down --volumes` only removes volumes Compose itself owns -- + // versola-openbao-file is declared `external: true` (see + // compose.fragment.yml.template's comment on why: so it survives + // being reconfigured into a fresh bundle directory), which means + // Compose never removes it either, uninstall included. Left alone, + // a later fresh install would silently reuse this "uninstalled" + // deployment's OpenBao data and every secret already resolved into + // it -- surprising for a command whose whole job is a clean slate. + // + // Only for local: this is the same volume name a vps deployment's + // OpenBao uses, and unlike local's throwaway dev secrets, vps's are + // the real ones (AppRole credentials, resolved Postgres password, + // etc.) -- deleting those should be a deliberate decision someone + // makes on purpose, not a side effect of running this general + // cleanup command against the wrong target by mistake. + if target == "local" { + fmt.Println("Removing OpenBao's data volume...") + if err := docker.Run("volume", "rm", "versola-openbao-file"); err != nil { + // Not fatal -- same reasoning as the image removal loop + // below: it might already be gone, or held by something else. + fmt.Printf(" (couldn't remove versola-openbao-file: %v)\n", err) + } + } else if target == "vps" { + fmt.Println("Leaving OpenBao's data volume in place (vps target — remove it yourself with `docker volume rm versola-openbao-file` if you really mean to discard it).") + } } 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.") } + // Independent of everything above: Configure starts versola-openbao + // under its fixed container_name *before* anything gets recorded to + // state.json (deliberately -- see state.Finalize's comment, and the + // develop.md step that expects the very first `configure vps` to fail + // once OpenBao is up but before credentials exist). If that run never + // reaches a successful Configure, this container ends up running with + // no compose.yml or state.json anywhere pointing at it -- deployed + // stays false, stopStack above never runs, and this uninstall would + // otherwise silently leave it running forever. Only the container is + // touched here, never its volume: an orphan like this could belong to + // either target, and guessing which one owns the volume is exactly + // the kind of decision uninstall shouldn't make on someone's behalf. + if dockerUp { + if running, err := docker.IsRunning("versola-openbao"); err == nil && running { + fmt.Println("Found an OpenBao container not tied to any tracked deployment (likely left over from an incomplete configure) — stopping it...") + if err := docker.Run("rm", "-f", "versola-openbao"); err != nil { + fmt.Printf(" (couldn't remove versola-openbao: %v)\n", err) + } + } + } + for _, img := range images { fmt.Printf("Removing image %s...\n", img) - if err := runDocker("rmi", img); err != nil { + if err := docker.Run("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. @@ -156,9 +226,9 @@ func runUninstall(cmd *cobra.Command, args []string) error { } 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.Printf("Removing %s...\n", stateDir) + if err := os.RemoveAll(stateDir); err != nil { + return fmt.Errorf("couldn't remove %s: %w", stateDir, err) } } diff --git a/internal/deploy/configure.go b/internal/deploy/configure.go new file mode 100644 index 0000000..fac856b --- /dev/null +++ b/internal/deploy/configure.go @@ -0,0 +1,186 @@ +// Package deploy holds the individual steps a deployment is made of. +// +// Deploying used to be one function: check the machine, generate the +// configs, start everything, in a single pass with no way to stop in +// between. That works locally, where the CLI owns the database it just +// created. It doesn't work on a server, where the database already exists +// and is someone else's responsibility, and where "change the schema" has +// to be a decision someone makes on purpose rather than a side effect of +// starting a service. +// +// So the steps live here as separate functions with the deployment +// directory (see internal/state) as the handoff between them: Configure +// writes it, the later steps read it. What the CLI exposes as commands is +// a separate question, answered in internal/cmd. +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/versolauth/versola-cli/internal/checks" + "github.com/versolauth/versola-cli/internal/docker" + "github.com/versolauth/versola-cli/internal/state" + "github.com/versolauth/versola-cli/internal/wait" +) + +// Configure prepares a deployment without starting any of it: it checks +// the machine, clears out any previous deployment, and asks the versioned +// versola-tools image to generate this release's configs and compose file +// into ~/.versola/active. +// +// It returns the deployment directory. +// +// Nothing is running when this returns, on purpose. Configure describes a +// deployment; the steps after it act on that description. +func Configure(target, version string) (string, error) { + if target != "local" && target != "vps" { + return "", fmt.Errorf(`unsupported target %q — only "local" and "vps" are supported today`, target) + } + + fmt.Println("Checking prerequisites...") + checksToRun := []checks.Result{ + checks.DockerDaemon(), + checks.ComposePlugin(), + checks.DockerMemory(), + checks.DiskSpace(), + } + // Port 2821 is nginx's — local-only, checked here for the same reason + // as the readiness URLs in up.go (a local-deployment fact still + // hardcoded in this CLI rather than coming from the bundle + // versola-tools generates). vps has no nginx service in its compose + // file at all (see compose.fragment.vps.yml.template's comment) — the + // VPS's real, native nginx already has that port, and that's expected, + // not something to fail a prerequisite check over. + // + // "versola-nginx" is this deployment's own gateway from a previous + // run, if there was one — see PortFree's own comment for why that's + // fine, not a real conflict (the compose file's fixed `name:` means Up + // updates/restarts it in place rather than clashing with it). + if target == "local" { + checksToRun = append(checksToRun, checks.PortFree(2821, "versola-nginx")) + } + for _, r := range checksToRun { + fmt.Println(r.String()) + if !r.OK { + return "", fmt.Errorf("prerequisite check failed — run `versola doctor` for details") + } + } + + fmt.Printf("\nPreparing Versola %s...\n", version) + dir, err := state.Prepare() + if err != nil { + return "", err + } + + fmt.Println("Generating configuration (versola-tools)...") + if err := pullAndRunTools(dir, ToolsImage(version), target); 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) + } + + // The candidates versola-tools just wrote (*.generated-secrets.env) + // can become real, live secret values the first time resolveSecrets + // below runs against an empty OpenBao path -- tightened here, + // immediately after they're written, rather than only once each is + // successfully resolved (resolveServiceSecrets removes each file it + // finishes with, but that's no help for whichever ones it never gets + // to). That gap matters more than it sounds like it would: the + // documented first-ever `configure vps` is EXPECTED to fail right + // after this point, before OpenBao is set up at all (see develop.md's + // OpenBao section) -- these files sit in the bundle directory for as + // long as the one-time setup takes, on a shared machine where "just a + // Windows dev box" isn't the threat model. + restrictGeneratedSecretsPerms(dir) + + // OpenBao has to actually be up before secrets can be resolved against + // it — Up (which otherwise starts the whole stack, openbao included) + // hasn't run yet at this point, so it's started here instead. Starting + // an already-running openbao is a no-op for compose, so this is safe + // whether or not a previous configure already left it running. + // + // The compose file is still named "compose.fragment.yml" at this point + // (see the rename below) — fine here, this only needs one service out + // of it, and up.go's own docker volume create call is idempotent, so + // doing it again there once Up runs doesn't conflict with this one. + fragmentPath := filepath.Join(dir, "compose.fragment.yml") + if err := docker.Run("volume", "create", "versola-openbao-file"); err != nil { + return "", fmt.Errorf("couldn't create the openbao-file volume: %w", err) + } + + // A previous configure's compose project can still have openbao + // running under container_name "versola-openbao" — every service in + // compose.fragment.yml.template has a fixed container_name, and Docker + // refuses to create a second container under a name that's already + // taken, even from an unrelated compose project (a fresh bundle + // directory, per state.Prepare, is a fresh project as far as Compose + // is concerned). openbao is the one service here it's actually correct + // to leave alone if that's the situation: unlike postgres/auth/ + // central/edge, it doesn't get reconfigured by this run — the whole + // point of resolving secrets against it is that it already has the + // values from before. + running, err := docker.IsRunning("versola-openbao") + if err != nil { + return "", err + } + if running { + fmt.Println("OpenBao is already running.") + } else { + fmt.Println("Starting OpenBao...") + if err := docker.Run("compose", "-f", fragmentPath, "up", "-d", "openbao"); err != nil { + return "", fmt.Errorf("couldn't start OpenBao: %w", err) + } + } + if err := wait.ForReachable("http://localhost:8200/v1/sys/health", 30*time.Second); err != nil { + return "", fmt.Errorf("OpenBao never came up: %w", err) + } + + // versola-tools writes auth.conf/central.conf/edge.conf with each + // secret field as a ${?VAR} placeholder rather than a literal value + // (see gen-env.scala's secretField) — this resolves each one against + // OpenBao (reusing what a previous configure already stored there, + // generating and storing anything new) and writes the + // .secrets.env files the compose file's env_file: entries + // expect to already exist by the time Up runs it. + // + // If OpenBao is sealed (every fresh container start comes up sealed, + // even with its data intact on the persistent volume — see + // openbao.hcl.template's comment), this fails with whatever error + // OpenBao's own API returns, which already says "sealed" plainly. + // Unsealing isn't automated: it needs the unseal key generated when + // OpenBao was first initialized, which nothing this CLI holds — see + // develop.md's OpenBao section for the manual `bao operator unseal` + // step. + fmt.Println("Resolving secrets (OpenBao)...") + if err := resolveSecrets(dir, target); err != nil { + return "", err + } + + // versola-tools writes the compose file under a "fragment" name and + // this step renames it, so a half-written or failed generation never + // leaves behind something the later steps would happily treat as a + // complete deployment. + composePath := filepath.Join(dir, "compose.yml") + if err := os.Rename(filepath.Join(dir, "compose.fragment.yml"), composePath); err != nil { + return "", fmt.Errorf("couldn't finalize compose file: %w", err) + } + + // Only now -- everything above has actually succeeded -- does this + // deployment become the one status/down/uninstall/up see. See + // state.Finalize's own comment for why that ordering matters: it's + // what keeps a failed redeploy from costing this machine its record + // of whatever deployment was still running before this call started. + if err := state.Finalize(target, version, dir); err != nil { + return "", fmt.Errorf("couldn't record this deployment: %w", err) + } + + return dir, nil +} diff --git a/internal/deploy/secrets.go b/internal/deploy/secrets.go new file mode 100644 index 0000000..a46fdfe --- /dev/null +++ b/internal/deploy/secrets.go @@ -0,0 +1,194 @@ +package deploy + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/versolauth/versola-cli/internal/openbao" +) + +// secretServices lists the deployments resolveSecrets handles, matching +// the *.generated-secrets.env files gen-env.scala writes (see its +// writeGeneratedSecrets) and the env_file: entries in +// compose.fragment.yml.template. +var secretServices = []string{"auth", "central", "edge"} + +// restrictGeneratedSecretsPerms tightens every *.generated-secrets.env +// file in dir to 0600, right after versola-tools writes them and before +// resolveSecrets gets a chance to run (which can fail, or not run at all +// yet -- see Configure's own comment on why that gap matters). Whichever +// of these resolveServiceSecrets later resolves successfully, it removes +// outright; this is what protects the ones still sitting there if that +// never happens. +// +// Best-effort, not fatal: pullAndRunTools runs the tools container +// without --user, so on a Linux host these files land owned by whatever +// user the image runs as (root, with no USER directive in +// Dockerfile.tools) -- not the CLI's own, unprivileged user. chmod on a +// file this process doesn't own fails with "operation not permitted" +// regardless of the directory's own permissions, which the CLI does own +// (state.Prepare created it). Treating that as fatal would abort every +// `configure vps` before secret resolution even starts on exactly the +// machine this matters most for. Deletion doesn't have this problem -- +// removing a file only needs write access to its directory, not +// ownership of the file itself -- so resolveServiceSecrets' cleanup on +// the happy path is unaffected either way. +func restrictGeneratedSecretsPerms(dir string) { + for _, service := range secretServices { + path := filepath.Join(dir, service+".generated-secrets.env") + if err := os.Chmod(path, 0o600); err != nil { + fmt.Printf(" (couldn't restrict permissions on %s: %v)\n", path, err) + } + } +} + +// resolveSecrets turns each .generated-secrets.env file +// versola-tools wrote into dir into a .secrets.env file Compose +// actually loads into the container -- substituting OpenBao's existing +// value for any key that's already there instead of the freshly +// generated candidate next to it, and storing the candidate in OpenBao +// for any key that isn't there yet. +// +// This has to run after versola-tools (it reads what that wrote) and +// before Up starts anything (compose.fragment.yml.template's env_file: +// entries expect these files to already exist) -- Configure is where +// both of those are true. +func resolveSecrets(dir, target string) error { + creds, err := openbao.LoadCredentials(target) + if err != nil { + if errors.Is(err, openbao.ErrNoCredentials) { + return fmt.Errorf("no OpenBao credentials stored for %q — run `versola secrets login %s
` first (it prompts for the secret ID separately)", target, target) + } + return err + } + + client := openbao.NewClient(creds) + ctx := context.Background() + if err := client.Login(ctx); err != nil { + return err + } + + for _, service := range secretServices { + if err := resolveServiceSecrets(ctx, client, dir, target, service); err != nil { + return fmt.Errorf("couldn't resolve secrets for %s: %w", service, err) + } + } + return nil +} + +func resolveServiceSecrets(ctx context.Context, client *openbao.Client, dir, target, service string) error { + candidates, err := readDotenv(filepath.Join(dir, service+".generated-secrets.env")) + if err != nil { + return err + } + + path := openbao.SecretPath(target, service) + existing, found, err := client.ReadSecret(ctx, path) + if err != nil { + return fmt.Errorf("couldn't read existing secrets from OpenBao: %w", err) + } + + // Starts as a copy of whatever's already stored, not empty -- the + // write below is a full replace (OpenBao's KV v2 "put", not a merge), + // so anything already at this path that isn't also touched by the + // loop after this has to already be in final or it's gone for good. + // Every key this run's candidates file could ever contain currently + // also has an entry in existing once seeded by hand (see develop.md's + // vps seeding section) or written by a previous run, but that's an + // invariant of what gen-env.scala happens to generate today, not + // something this function can rely on staying true — starting from + // existing instead of from candidates means it doesn't have to. + final := make(map[string]string, len(existing)+len(candidates)) + if found { + for key, v := range existing { + final[key] = v + } + } + + wroteAnyNew := false + for key, candidate := range candidates { + if _, has := final[key]; has { + continue + } + // Not in OpenBao yet -- this run's freshly generated candidate + // becomes the real value from here on. + final[key] = candidate + wroteAnyNew = true + } + + if wroteAnyNew { + if err := client.WriteSecret(ctx, path, final); err != nil { + return fmt.Errorf("couldn't store new secrets in OpenBao: %w", err) + } + } + + if err := writeDotenv(filepath.Join(dir, service+".secrets.env"), final); err != nil { + return err + } + + // The generated-secrets.env candidates versola-tools wrote are secret + // material too -- a candidate becomes the real, live value the first + // time this ever runs against an empty OpenBao path (see the loop + // above) -- but unlike *.secrets.env (written 0600 by writeDotenv) + // they land in this bundle directory at whatever ordinary permissions + // the tools container's own write left them at, readable by any other + // local user on a shared machine (most relevant on vps, not a single- + // user Windows dev box). Nothing reads this file again after this + // point -- only *.secrets.env is referenced by + // compose.fragment.yml.template's env_file: entries -- so removing it + // outright closes that gap more simply than chmod'ing it consistently + // across the platforms this runs on (Windows for docker-local, Linux + // for vps). + candidatesPath := filepath.Join(dir, service+".generated-secrets.env") + if err := os.Remove(candidatesPath); err != nil { + return fmt.Errorf("couldn't remove %s: %w", candidatesPath, err) + } + return nil +} + +func readDotenv(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("couldn't read %s: %w", path, err) + } + defer f.Close() + + result := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + // Cut at the first "=" only, not every one: values here can be + // standard (not URL-safe) base64, which pads with trailing "=" + // characters. The key itself never contains one. + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("couldn't parse %s: malformed line %q", path, line) + } + result[key] = value + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("couldn't read %s: %w", path, err) + } + return result, nil +} + +func writeDotenv(path string, values map[string]string) error { + var b strings.Builder + for key, value := range values { + fmt.Fprintf(&b, "%s=%s\n", key, value) + } + // 0o600, not the 0o644 most files this CLI writes into the bundle + // directory use: this one holds real secret values. + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + return fmt.Errorf("couldn't write %s: %w", path, err) + } + return nil +} diff --git a/internal/deploy/tools.go b/internal/deploy/tools.go new file mode 100644 index 0000000..aeea580 --- /dev/null +++ b/internal/deploy/tools.go @@ -0,0 +1,114 @@ +package deploy + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + + "github.com/versolauth/versola-cli/internal/docker" +) + +// ToolsImage returns the versola-tools image for a given Versola release. +// This is the one image this CLI names by hand; everything else it runs +// comes out of the compose file that image generates. +func ToolsImage(version string) string { + return "ghcr.io/versolauth/versola-tools:" + version +} + +// manifestUnknownErr wraps a docker 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) +} + +// toolsTarget maps this CLI's target names to the TARGET value +// versola-tools' entrypoint.sh expects (see its own comment on TARGET) -- +// the two aren't spelled the same because "docker-local" is a fact about +// how the local stack runs (bridge-network Docker containers) that +// predates "local" vs "vps" existing as CLI target names at all, and +// renaming gen-env.scala's branch to match now would just be churn for +// no benefit. +func toolsTarget(target string) string { + if target == "vps" { + return "vps" + } + return "docker-local" +} + +// pullAndRunTools runs the versola-tools image the same way docker.Run +// 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 Configure, without changing behavior for every other +// docker call site (compose up/down, uninstall's rmi, etc.) that has no +// need for this. +func pullAndRunTools(dir, image, target 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) + // --platform linux/amd64: versola-tools, like the rest of the stack + // (see compose.fragment.yml.template's platform pins on central/auth/ + // edge/nginx in the versola repo), is only published for amd64. Without + // this, Docker has to guess whether to emulate on non-amd64 hosts (e.g. + // Apple Silicon Macs) -- it doesn't always guess right, and the failure + // mode when it doesn't is a raw "no matching manifest" error instead of + // a working, if slower, emulated container. Being explicit here makes + // configure work the same way on arm64 as on amd64, without requiring + // DOCKER_DEFAULT_PLATFORM to be set in the environment first. + // + // -e TARGET=...: tells entrypoint.sh which of gen-env.scala's + // non-interactive branches to generate and which compose fragment to + // emit (see its own comment on TARGET) -- without this it always + // defaults to docker-local, which was fine back when "local" was the + // only target this CLI supported at all. + c := docker.Cmd("run", "--rm", "--platform", "linux/amd64", "-e", "TARGET="+toolsTarget(target), "-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/deploy/up.go b/internal/deploy/up.go new file mode 100644 index 0000000..bd3c260 --- /dev/null +++ b/internal/deploy/up.go @@ -0,0 +1,201 @@ +package deploy + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/versolauth/versola-cli/internal/browser" + "github.com/versolauth/versola-cli/internal/docker" + "github.com/versolauth/versola-cli/internal/state" + "github.com/versolauth/versola-cli/internal/wait" +) + +// UpOptions are the choices Up leaves to the caller. They're a struct +// rather than plain parameters because the list only grows from here (a +// deployment that skips the browser today will also want to skip parts of +// the stack, pick a target, and so on), and a growing list of bare +// booleans at a call site stops being readable very quickly. +type UpOptions struct { + // NoBrowser suppresses opening the admin console once it's ready. + NoBrowser bool +} + +// Up starts the stack that Configure prepared and waits until it's +// actually serving traffic, not merely started. +// +// It reads what to start from the deployment directory rather than taking +// a version argument: by this point the version is a property of what's +// on disk, and re-stating it here would make it possible to ask for one +// version and get another. +func Up(opts UpOptions) error { + st, err := state.Load() + if err != nil { + if errors.Is(err, state.ErrNotConfigured) { + return fmt.Errorf("nothing has been configured yet — run `versola bootstrap local ` first") + } + return err + } + + composePath, exists, err := state.ComposeFile() + if err != nil { + return err + } + if !exists { + // State exists but the compose file doesn't: configure didn't + // finish. Say so plainly rather than letting docker compose fail + // on a missing file, which reads like a bug in the CLI. + return fmt.Errorf("the deployment in ~/.versola/active is incomplete (no compose file) — configure it again") + } + dir := filepath.Dir(composePath) + isVps := st.Target == "vps" + + // vps is the one target where this touches a real, shared database — + // central runs migrations against it as a side effect of starting up + // (see the comment on state.State.MigratedAt: there's no separate + // migrate step yet to stop and review first). Everything before this + // point (Configure, OpenBao, secret resolution) is safe to redo; this + // is the last point it's still safe to back out of before that + // changes. + if isVps { + if err := confirmVpsDeploy(st.Version); err != nil { + return err + } + } + + // The compose file declares this volume `external: true` (see the + // comment on it in compose.fragment.yml.template) so OpenBao's storage + // survives being reconfigured into a fresh bundle directory each run — + // but `external: true` also means compose refuses to start anything + // that mounts it until it already exists. `docker volume create` is a + // no-op if it's already there, so this is safe to run on every `up`, + // not just the first one. + if err := docker.Run("volume", "create", "versola-openbao-file"); err != nil { + return fmt.Errorf("couldn't create the openbao-file volume: %w", err) + } + + // Postgres and central go up first, on their own — auth/edge's own + // startup fails fatally if central isn't reachable yet, and confirmed + // by hand that Docker's restart policy does NOT recover from that + // failure mode (the process hangs rather than exiting). See the + // comment on auth's depends_on in + // versola-tools/compose.fragment.yml.template. vps has no postgres + // service at all — its Postgres is native on the host (see + // compose.fragment.vps.yml.template's comment), not something this + // compose file starts. + if isVps { + fmt.Println("\nStarting central...") + if err := docker.Run("compose", "-f", composePath, "up", "-d", "central"); err != nil { + return fmt.Errorf("couldn't start central: %w", err) + } + } else { + fmt.Println("\nStarting Postgres and central...") + if err := docker.Run("compose", "-f", composePath, "up", "-d", "postgres", "central"); err != nil { + return fmt.Errorf("couldn't start postgres/central: %w", err) + } + } + + // These URLs, like the service names above and the port checked in + // Configure, are still hardcoded here. They're deployment facts that + // by rights belong in the bundle versola-tools generates -- this CLI + // is meant not to know Versola's topology, so that one build of it can + // deploy any release (design doc §3.5). Hardcoding them was tolerable + // while "local" was the only target; vps happens to use the exact + // same ports (see deploy.md's table), which is the only reason this + // hasn't forced the issue yet. + fmt.Println("Waiting for central to be ready...") + if err := wait.ForReady("http://localhost:8091/readiness", 60*time.Second); err != nil { + return fmt.Errorf("central never became ready: %w", err) + } + + // Named explicitly, not a bare "up -d" ("start everything not already + // running under this project") -- openbao is deliberately left out of + // that: Configure starts it (or leaves an already-running one alone — + // see the comment there) under whatever compose project happened to + // start it, which may not be this bundle's. A bare "up -d" here would + // see openbao defined in this project's compose file but not part of + // this project, and try to create a second container under its fixed + // container_name, which Docker refuses. vps has no nginx/gateway + // service — its public nginx is native on the VPS, deployed by a + // separate pipeline (see compose.fragment.vps.yml.template's comment). + if isVps { + fmt.Println("Starting auth and edge...") + if err := docker.Run("compose", "-f", composePath, "up", "-d", "auth", "edge"); err != nil { + return fmt.Errorf("couldn't start auth/edge: %w", err) + } + } else { + fmt.Println("Starting auth, edge, and the gateway...") + if err := docker.Run("compose", "-f", composePath, "up", "-d", "auth", "edge", "nginx"); err != nil { + return fmt.Errorf("couldn't start the rest of the stack: %w", err) + } + } + + fmt.Println("Waiting for auth and edge to be ready...") + if err := wait.ForReady("http://localhost:8081/readiness", 60*time.Second); err != nil { + return fmt.Errorf("auth never became ready: %w", err) + } + if err := wait.ForReady("http://localhost:8096/readiness", 60*time.Second); err != nil { + return fmt.Errorf("edge never became ready: %w", err) + } + + if isVps { + // id.versola.kz, like the rest of vps's addressing (see + // gen-env.scala's vps branch), is a fact about the one real VPS + // this deploys to, not a general "vps" concept — hardcoded here + // for the same reason it's hardcoded there. + fmt.Printf("\nVersola %s is running at https://id.versola.kz\n", st.Version) + // vps doesn't use a fixed literal password the way local's + // "Admin1234!" is — it's a real, standing admin credential Configure + // resolved against OpenBao (see gen-env.scala's + // bootstrapPasswordDefault), not a throwaway dev one. Deliberately + // NOT echoed to stdout here the way local's fixed password is below: + // unlike local, this runs on every redeploy of the same target, not + // just the first, and a real production credential printed to a + // terminal on every run (often over SSH, sometimes logged or + // recorded) is needless repeated exposure for something that's + // already sitting in auth.secrets.env for whoever's actually + // authorized to read it. + fmt.Printf("Login: admin / (see ADMIN_BOOTSTRAP_PASSWORD in %s)\n", filepath.Join(dir, "auth.secrets.env")) + // No browser.Open here, unlike local below: this CLI runs on the + // VPS itself (typically over SSH), not on the operator's own + // desktop -- popping a browser window on the server wouldn't + // reach anyone. + return nil + } + + adminURL := "http://localhost:2821/central/admin/" + fmt.Printf("\nVersola %s is running at http://localhost:2821\n", st.Version) + fmt.Println("Login: admin / Admin1234!") + + if !opts.NoBrowser { + // Best-effort only: the deployment 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 +} + +// confirmVpsDeploy asks for an explicit go-ahead before Up touches the +// real VPS deployment — see the comment on its call site. There's no +// separate migrate step yet to review before this (see +// state.State.MigratedAt's comment), so this is the only checkpoint +// before central runs migrations against the live database as a side +// effect of starting. +func confirmVpsDeploy(version string) error { + fmt.Printf("\nThis will deploy Versola %s to the VPS, including running database migrations against the live database.\n", version) + fmt.Print("Continue? [y/N]: ") + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + line = strings.TrimSpace(strings.ToLower(line)) + if line != "y" && line != "yes" { + return fmt.Errorf("aborted") + } + return nil +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..0ef7a2f --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,61 @@ +// Package docker shells out to the docker CLI. +// +// It exists so that the several places that need to run docker (deploying +// a stack, stopping it, removing images) agree on one thing: docker's own +// output goes straight to the user's terminal, unbuffered. A `docker +// compose pull` of a few hundred megabytes takes a while, and a caller +// that captured that output instead of streaming it would look frozen for +// the entire time. +// +// Nothing here knows anything about Versola itself -- no service names, no +// ports, no image list. This package takes whatever arguments it's given +// and runs them. Which containers exist and how they're wired together +// comes from the versioned versola-tools image instead (see +// internal/deploy), which is what lets one build of this CLI deploy +// several different releases of Versola. +package docker + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// Cmd builds a docker exec.Cmd with stdout already wired to the user's +// terminal. Stderr is deliberately left unset for the caller to fill in: +// Run below just points it at os.Stderr, but deploy's versola-tools +// runner also needs to inspect what was written to it, and the two can't +// share one default. +func Cmd(args ...string) *exec.Cmd { + c := exec.Command("docker", args...) + c.Stdout = os.Stdout + return c +} + +// Run runs docker with both output streams going to the user's terminal, +// and returns whatever error the process exited with. +func Run(args ...string) error { + c := Cmd(args...) + c.Stderr = os.Stderr + return c.Run() +} + +// IsRunning reports whether a container with this name exists and is +// currently running. A container that doesn't exist at all is reported as +// not running rather than as an error — every other place in this CLI +// that checks for something possibly not being there yet (state.Load, +// state.ComposeFile) treats "doesn't exist" as a plain negative, not a +// failure, and callers of this function want the same thing: "is it +// already up, or do I need to start it" doesn't care which kind of "no" +// it gets. +func IsRunning(name string) (bool, error) { + out, err := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", name).Output() + if err != nil { + if _, ok := err.(*exec.ExitError); ok { + return false, nil + } + return false, fmt.Errorf("couldn't check whether %s is running: %w", name, err) + } + return strings.TrimSpace(string(out)) == "true", nil +} diff --git a/internal/openbao/client.go b/internal/openbao/client.go new file mode 100644 index 0000000..5a9a92f --- /dev/null +++ b/internal/openbao/client.go @@ -0,0 +1,176 @@ +package openbao + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// kvMount is the KV v2 secrets engine mount path this CLI expects to +// already be enabled at (see develop.md's OpenBao setup section) — +// OpenBao's own default name when a KV v2 engine is enabled without +// specifying -path. +const kvMount = "secret" + +// Client talks to one target's OpenBao server, authenticated via AppRole. +// +// Not safe for concurrent use by multiple goroutines — this CLI never +// needs that, every command that touches secrets does so from a single +// goroutine — but is meant to be reused across multiple reads/writes +// within one command: call Login once, then ReadSecret/WriteSecret as +// needed, rather than logging in again for each. +type Client struct { + address string + roleID string + secretID string + http *http.Client + token string +} + +// NewClient builds a Client for the given credentials. It does not contact +// the server — call Login before ReadSecret/WriteSecret. +func NewClient(creds *Credentials) *Client { + return &Client{ + address: creds.Address, + roleID: creds.RoleID, + secretID: creds.SecretID, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Login authenticates via AppRole and stores the resulting client token +// for subsequent calls. +func (c *Client) Login(ctx context.Context) error { + reqBody, err := json.Marshal(map[string]string{ + "role_id": c.roleID, + "secret_id": c.secretID, + }) + if err != nil { + return fmt.Errorf("couldn't encode AppRole login request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.address+"/v1/auth/approle/login", bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("couldn't build AppRole login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("couldn't reach OpenBao at %s: %w", c.address, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("couldn't read OpenBao's login response: %w", err) + } + if resp.StatusCode != http.StatusOK { + // Deliberately not including reqBody in this error — it holds + // secretID. + return fmt.Errorf("OpenBao login failed (%s): %s", resp.Status, string(body)) + } + + var loginResp struct { + Auth struct { + ClientToken string `json:"client_token"` + } `json:"auth"` + } + if err := json.Unmarshal(body, &loginResp); err != nil { + return fmt.Errorf("couldn't parse OpenBao's login response: %w", err) + } + if loginResp.Auth.ClientToken == "" { + return fmt.Errorf("OpenBao's login response had no client token") + } + c.token = loginResp.Auth.ClientToken + return nil +} + +// SecretPath builds the logical (not HTTP) path for a service's secrets +// under a target, e.g. SecretPath("local", "central") = "versola/local/central". +// One path per service per target: each holds every secret field that +// service's config needs as a flat set of key-value pairs. +func SecretPath(target, service string) string { + return fmt.Sprintf("versola/%s/%s", target, service) +} + +// ReadSecret fetches the fields stored at path (see SecretPath), or +// ok=false if nothing has been written there yet — callers use that to +// tell "generate this for the first time" apart from a real error. +func (c *Client) ReadSecret(ctx context.Context, path string) (data map[string]string, ok bool, err error) { + url := fmt.Sprintf("%s/v1/%s/data/%s", c.address, kvMount, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, fmt.Errorf("couldn't build OpenBao read request: %w", err) + } + req.Header.Set("X-Vault-Token", c.token) + + resp, err := c.http.Do(req) + if err != nil { + return nil, false, fmt.Errorf("couldn't reach OpenBao at %s: %w", c.address, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, false, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, fmt.Errorf("couldn't read OpenBao's response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("OpenBao read failed (%s): %s", resp.Status, string(body)) + } + + var readResp struct { + Data struct { + Data map[string]string `json:"data"` + } `json:"data"` + } + if err := json.Unmarshal(body, &readResp); err != nil { + return nil, false, fmt.Errorf("couldn't parse OpenBao's response: %w", err) + } + // A path whose every version has been deleted (not just never written) + // also 200s, with an empty inner "data" — treat that the same as 404, + // since either way there's nothing to read. + if len(readResp.Data.Data) == 0 { + return nil, false, nil + } + return readResp.Data.Data, true, nil +} + +// WriteSecret stores data at path (see SecretPath), creating a new +// version if something is already there. +func (c *Client) WriteSecret(ctx context.Context, path string, data map[string]string) error { + reqBody, err := json.Marshal(map[string]any{"data": data}) + if err != nil { + return fmt.Errorf("couldn't encode OpenBao write request: %w", err) + } + + url := fmt.Sprintf("%s/v1/%s/data/%s", c.address, kvMount, path) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("couldn't build OpenBao write request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Vault-Token", c.token) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("couldn't reach OpenBao at %s: %w", c.address, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Deliberately not including reqBody in this error — it holds the + // secret values being written. + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("OpenBao write failed (%s): %s", resp.Status, string(body)) + } + return nil +} diff --git a/internal/openbao/credentials.go b/internal/openbao/credentials.go new file mode 100644 index 0000000..250c827 --- /dev/null +++ b/internal/openbao/credentials.go @@ -0,0 +1,129 @@ +// Package openbao is a thin client for reading and writing secrets in the +// OpenBao instance a deployment target uses, plus the AppRole credentials +// that authenticate to it. +// +// Only what versola-cli actually needs is implemented here: AppRole login +// and KV v2 get/put against a single, already-enabled secrets engine. This +// is not a general-purpose OpenBao/Vault SDK, and deliberately doesn't +// depend on one — see go.mod's comment-free dependency list, which this +// isn't meant to be the thing that breaks. +package openbao + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// Credentials are what this machine uses to authenticate to one target's +// OpenBao server. Kept separate from state.State: state.go's own comment +// says only one deployment is ever "active" at a time, but OpenBao +// credentials for a target need to outlive that — configuring local after +// having configured vps (or back again) must not discard either target's +// access. +type Credentials struct { + // Address is this target's OpenBao server, e.g. "http://localhost:8200" + // for local. Stored per-target rather than derived from the target + // name: local and vps are different OpenBao servers with nothing in + // common to compute one address from the other. + Address string `json:"address"` + + // RoleID identifies the AppRole this CLI logs in as. Not secret — safe + // to store here in plain JSON, same as a username. + RoleID string `json:"roleId"` + + // SecretID is the AppRole's password. Whoever administers OpenBao + // hands this out once, out of band; this file is where it lives + // afterward. Never logged, never included in any error message this + // package returns. + SecretID string `json:"secretId"` +} + +// ErrNoCredentials means this target has no stored OpenBao credentials +// yet — distinguished from other read failures the same way +// state.ErrNotConfigured is, so callers can print a hint instead of a raw +// file-not-found error. +var ErrNoCredentials = errors.New("no OpenBao credentials stored for this target") + +// credentialsDir is ~/.versola/openbao — deliberately not under +// state.Dir() (~/.versola/active), for the reason Credentials' own comment +// gives. +func credentialsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("couldn't find home directory: %w", err) + } + return filepath.Join(home, ".versola", "openbao"), nil +} + +// validTargets are the only target names deploy.Configure supports. +// Enforced here too, not just there: target reaches a filesystem path +// (credentialsPath, below) directly, and `versola secrets login +// ...` is a CLI entry point deploy.Configure's own check never sees. +// Without this, a typo'd or malicious target like "../active/state" would +// let credentialsPath escape ~/.versola/openbao entirely and overwrite an +// unrelated file -- state.json, in that example -- instead of writing a +// credentials file. +var validTargets = map[string]bool{"local": true, "vps": true} + +func credentialsPath(target string) (string, error) { + if !validTargets[target] { + return "", fmt.Errorf(`unsupported target %q — only "local" and "vps" are supported`, target) + } + dir, err := credentialsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, target+".json"), nil +} + +// LoadCredentials reads the stored AppRole credentials for target. +func LoadCredentials(target string) (*Credentials, error) { + path, err := credentialsPath(target) + if err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNoCredentials + } + return nil, fmt.Errorf("couldn't read OpenBao credentials: %w", err) + } + var c Credentials + if err := json.Unmarshal(b, &c); err != nil { + return nil, fmt.Errorf("couldn't parse %s: %w", path, err) + } + return &c, nil +} + +// SaveCredentials stores AppRole credentials for target, creating +// ~/.versola/openbao if this is the first target configured. +func SaveCredentials(target string, c *Credentials) error { + dir, err := credentialsDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("couldn't create %s: %w", dir, err) + } + + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("couldn't encode OpenBao credentials: %w", err) + } + b = append(b, '\n') + + path, err := credentialsPath(target) + if err != nil { + return err + } + // 0o600, not the 0o644 state.go uses for state.json: this file holds + // SecretID, which state.json never holds anything equivalent to. + if err := os.WriteFile(path, b, 0o600); err != nil { + return fmt.Errorf("couldn't write %s: %w", path, err) + } + return nil +} diff --git a/internal/state/state.go b/internal/state/state.go index b54ce5d..57cc284 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,23 +1,130 @@ -// Package state locates the on-disk directory that "bootstrap" writes to -// and that "status"/"down" read from: the generated compose file, the -// configs versola-tools produced, and which version is currently active. +// Package state locates and reads the on-disk directory describing the +// deployment this machine currently has: the compose file, the configs +// versola-tools produced, and a small record of what was deployed and how +// far along it got. // -// There's only ever one active local deployment at a time — running -// bootstrap again overwrites it. This mirrors how bootstrap itself works -// today (docker compose up on a fixed project), and keeps status/down -// simple: they don't need to track multiple deployments, just read -// whatever bootstrap last wrote. +// There's only ever one active deployment at a time — configuring again +// overwrites it. That keeps the commands that read this directory +// (status/down/uninstall) simple: they don't track multiple deployments, +// they read whatever was last written. +// +// The record itself lives in state.json rather than being inferred from +// which files happen to exist. Deploying is no longer one command: the +// configs can exist while the database hasn't been migrated yet, and both +// can be true while nothing is running. "Which of those steps have +// happened" is a real question that only an explicit record can answer. package state import ( + "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" + "time" ) -// Dir returns ~/.versola/active, the directory for the currently -// deployed stack. +// SchemaVersion is the layout version of state.json as written by this +// build of the CLI. It's recorded in the file so a future CLI reading an +// older deployment's state can tell what it's looking at instead of +// guessing from which fields happen to be present. +// +// Version 0 is reserved for deployments made before state.json existed at +// all, which recorded only a bare "version" file — see Load. +const SchemaVersion = 1 + +const stateFileName = "state.json" + +// legacyVersionFileName is what deployments made by earlier builds of +// this CLI wrote instead of state.json. It still gets read (see Load) +// because `versola upgrade` replaces the binary in place: someone can +// deploy with an old CLI, upgrade, and then run status against state that +// predates this file format. Refusing to read it would turn a working +// deployment into an invisible one. +const legacyVersionFileName = "version" + +// ErrNotConfigured means nothing has been deployed on this machine yet — +// no state.json, and no legacy version file either. +var ErrNotConfigured = errors.New("no deployment configured yet") + +// State is what the CLI records about the current deployment. +type State struct { + SchemaVersion int `json:"schemaVersion"` + + // Target is where this deployment is going: "local" today, "vps" + // later. It's recorded rather than re-asked because every step after + // the first needs it, and a deployment silently switching target + // halfway through would be a very confusing failure. + Target string `json:"target"` + + // Version is the Versola release being deployed, exactly as it's + // tagged in the registry (no leading "v" — see bootstrap's error + // message about that). + Version string `json:"version"` + + ConfiguredAt time.Time `json:"configuredAt"` + + // MigratedAt records when the database migrations for this + // deployment were last applied, or nil if they haven't been. A + // pointer rather than a zero time.Time so "not migrated" is + // unambiguous in the JSON (the field is simply absent) instead of + // being represented by year 1. + // + // Nothing sets this yet: migrations currently run inside central's + // own startup, so there's no separate step to record. It's here now + // because `versola migrate` is what this whole split is for, and + // having the field from the start means the first deployment made by + // a CLI that does record it isn't a special case. + MigratedAt *time.Time `json:"migratedAt,omitempty"` + + // BundleDir is the name (not a full path — join it onto Dir()) of the + // subdirectory holding the compose file and configs versola-tools + // generated for this deployment. Empty means "Dir() itself", which is + // how a pre-BundleDir deployment (see loadLegacy) is represented. + // + // This exists because of a bug observed in the wild on Docker Desktop + // for Windows: bind-mounting the *same path* into a container twice — + // once per `configure` run, since Prepare used to wipe and recreate + // one fixed directory every time — could make a freshly-started + // container's own `cp` fail with "File exists" for a file `ls` in that + // same container shows does not exist. Reproduced with the directory + // completely removed (not just emptied) between runs, so it isn't + // leftover files on the Windows side; something in Docker Desktop's + // file-sharing layer was reusing stale metadata keyed by path. A path + // that has never been mounted before doesn't have this problem, so + // Prepare below gives every deployment its own directory name instead + // of ever reusing one. + // + // A new directory each run does NOT mean a new Compose *project* each + // run, despite Compose's own default of deriving the project name from + // the compose file's parent directory -- the generated compose file + // sets `name:` explicitly (see compose.fragment.yml.template) for + // exactly this reason: every service in it has a fixed container_name, + // and Docker refuses to create one under a name that already exists + // under a *different* project. Without a fixed name, a second + // `configure`/`up` while the previous run's containers were still up + // would fail on container-name conflicts instead of updating them in + // place, and once BundleDir moves on, nothing would even know the old + // containers exist anymore to stop them. + BundleDir string `json:"bundleDir,omitempty"` +} + +// bundlePath resolves where this state's compose file and configs +// actually live, honoring BundleDir when set. +func (s *State) bundlePath() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + if s.BundleDir == "" { + return dir, nil + } + return filepath.Join(dir, s.BundleDir), nil +} + +// Dir returns ~/.versola/active, the directory for the current +// deployment. func Dir() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -26,38 +133,224 @@ func Dir() (string, error) { return filepath.Join(home, ".versola", "active"), nil } -// Prepare clears out ~/.versola/active (if it exists from a previous -// bootstrap) and recreates it, ready for versola-tools to write into. -// Clearing it first matters: a previous run's compose.yml/*.conf files -// must not linger and get silently reused if this run's versola-tools -// container fails partway through — bootstrap should fail loudly instead -// of quietly deploying a stale or half-written config. -func Prepare(version string) (string, error) { +// Prepare creates a fresh, empty bundle directory under ~/.versola/active +// for versola-tools to write this deployment's configs and compose file +// into, and returns its path. +// +// Deliberately does NOT touch state.json or any previous deployment's +// bundle directory -- only Finalize does that, once versola-tools and +// secret resolution have both actually succeeded (see its own comment). +// An earlier version of this function cleared the whole ~/.versola/active +// directory up front instead, on the theory that a previous run's +// state.json shouldn't linger if this run fails before writing its own. +// In practice that traded a small problem for a much bigger one: a +// redeploy that fails partway through (an image pull that times out, an +// OpenBao that's still sealed, ...) cost this machine its only record of +// whatever deployment was still actually running -- status/down/uninstall +// would all report "nothing deployed" against containers that were very +// much still up, most consequentially on vps, where those containers are +// serving real traffic. +// +// The bundle directory itself is deliberately never reused across calls — +// see the comment on State.BundleDir for the Docker Desktop bug this +// sidesteps. Its name only has to be unique on this machine, not +// globally, so a nanosecond timestamp is enough; this CLI never runs two +// configure calls concurrently against the same deployment. +func Prepare() (string, error) { dir, err := Dir() if err != nil { return "", err } - if err := os.RemoveAll(dir); err != nil { - return "", fmt.Errorf("couldn't clear %s: %w", dir, err) + + // 0700, not 0755: versola-tools writes secret candidate material + // straight into this directory (*.generated-secrets.env -- see + // resolveServiceSecrets in deploy/secrets.go), sometimes owned by + // root rather than this process (the tools container runs without + // --user, so on Linux its writes land as whatever user the image + // runs as). chmod'ing those individual files after the fact only + // works when this process actually owns them, which it may not -- + // restricting the directory itself sidesteps that entirely: Linux + // gates access to a file by whether the *directory* grants + // read+execute, not by the file's own owner or mode, so nothing + // outside this process (and root, which bypasses permission checks + // entirely -- exactly what the tools container needs to write here in + // the first place) can read anything inside regardless of who ends up + // owning any individual file. + bundleDir := filepath.Join(dir, fmt.Sprintf("bundle-%d", time.Now().UnixNano())) + if err := os.MkdirAll(bundleDir, 0o700); err != nil { + return "", fmt.Errorf("couldn't create %s: %w", bundleDir, err) } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("couldn't create %s: %w", dir, err) + return bundleDir, nil +} + +// Finalize makes the deployment Prepare just built (at bundleDir) the +// active one: it writes state.json describing it, then -- only once that +// succeeds -- removes whatever the previous deployment's bundle directory +// was, if any. +// +// Doing the write first is what actually closes the gap described on +// Prepare: from the moment this is called, state.json only ever points at +// a bundle directory that either fully exists (the new one, once Finalize +// returns) or, if removing the old one happens to fail, still fully +// existed a moment ago -- never at a bundle directory that's only +// half-written, and never at nothing. +// +// bundleDir is the full path Prepare returned; only its base name ends up +// stored (see State.BundleDir's own comment on why). +func Finalize(target, version, bundleDir string) error { + dir, err := Dir() + if err != nil { + return err } - if err := os.WriteFile(filepath.Join(dir, "version"), []byte(version), 0o644); err != nil { - return "", fmt.Errorf("couldn't write version file: %w", err) + + // Loaded before state.json is overwritten below, and deliberately + // tolerant of any error here (including ErrNotConfigured, the common + // first-deployment case) -- there being no previous deployment to + // clean up afterward isn't this function's problem to report, just + // something to skip. + prev, prevErr := Load() + + s := &State{ + SchemaVersion: SchemaVersion, + Target: target, + Version: version, + ConfiguredAt: time.Now().UTC(), + BundleDir: filepath.Base(bundleDir), + } + if err := s.Save(); err != nil { + return err } - return dir, nil + + // prev.BundleDir == "" is the legacy, pre-BundleDir layout (see + // loadLegacy) where files sit directly in ~/.versola/active itself, + // not a subdirectory of it -- bundlePath() would resolve that to dir + // itself, and removing dir here would take the brand new bundle this + // call just made active down with it. Nothing to clean up from that + // layout anyway (just a stray "version" file), so skip it rather than + // special-case it. + if prevErr == nil && prev.BundleDir != "" && prev.BundleDir != s.BundleDir { + oldBundleDir := filepath.Join(dir, prev.BundleDir) + if err := os.RemoveAll(oldBundleDir); err != nil { + // Not fatal -- state.json above already points at the new, + // complete deployment, so a leftover old bundle directory is + // wasted disk, not a correctness problem the way losing track + // of state.json would have been. + fmt.Printf("(couldn't remove the previous deployment's files at %s: %v — safe to delete by hand)\n", oldBundleDir, err) + } + } + return nil } -// ComposeFile returns the path to the compose file bootstrap generates, -// 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) { +// Save writes the state record, replacing whatever was there. +func (s *State) Save() error { + dir, err := Dir() + if err != nil { + return err + } + // MarshalIndent, not Marshal: this file is small, is read by people + // when something has gone wrong, and gets diffed in bug reports. + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("couldn't encode deployment state: %w", err) + } + b = append(b, '\n') + path := filepath.Join(dir, stateFileName) + + // Written to a temp file and renamed into place rather than a direct + // os.WriteFile -- WriteFile truncates the destination before writing + // a single byte of the new content, so a failure partway through (a + // full disk, most plausibly) would leave state.json empty or + // half-written instead of unchanged. That defeats the exact thing + // Finalize calls this for: Configure's earlier steps having failed + // must never cost this machine its record of whatever deployment was + // still running before this call started. Rename is atomic on both + // POSIX and Windows (as long as the temp file is on the same volume, + // which it is here -- same directory), so this can only ever leave + // the OLD state.json in place or the fully-written NEW one, never + // something in between. + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return fmt.Errorf("couldn't write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("couldn't finalize %s: %w", path, err) + } + return nil +} + +// Load reads the current deployment's state. +// +// It returns ErrNotConfigured if nothing has been deployed yet, so +// callers can tell "nothing here" apart from "something here but +// unreadable" — the second is a real problem worth reporting, the first +// usually just means printing a hint about which command to run first. +// Match it with errors.Is rather than ==, so that a future caller that +// wraps it keeps working. +func Load() (*State, error) { dir, err := Dir() if err != nil { + return nil, err + } + + b, err := os.ReadFile(filepath.Join(dir, stateFileName)) + if err != nil { + if os.IsNotExist(err) { + return loadLegacy(dir) + } + return nil, fmt.Errorf("couldn't read deployment state: %w", err) + } + + var s State + if err := json.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("couldn't parse %s: %w", filepath.Join(dir, stateFileName), err) + } + return &s, nil +} + +// loadLegacy reads a pre-state.json deployment, which recorded nothing +// but the version string in a file of its own. Anything that file didn't +// record is filled in with what was true of every deployment that could +// have written it: the only target that existed then was "local", and +// there was no separate migration step, so ConfiguredAt/MigratedAt stay +// zero rather than being invented. +func loadLegacy(dir string) (*State, error) { + b, err := os.ReadFile(filepath.Join(dir, legacyVersionFileName)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotConfigured + } + return nil, fmt.Errorf("couldn't read deployment state: %w", err) + } + return &State{ + SchemaVersion: 0, + Target: "local", + Version: strings.TrimSpace(string(b)), + }, nil +} + +// ComposeFile returns the path to the compose file configure generates, +// and whether it currently exists. It existing is how status/down/ +// uninstall tell whether anything has been deployed yet. +// +// The path is resolved through the current state record (see +// State.BundleDir) rather than assumed to sit directly in Dir(), since a +// deployment's configs no longer necessarily live at a fixed location. +func ComposeFile() (path string, exists bool, err error) { + s, err := Load() + if err != nil { + if errors.Is(err, ErrNotConfigured) { + // Nothing recorded at all -- same as "the file isn't there" + // from the caller's point of view, not a real error. + return "", false, nil + } return "", false, err } - path = filepath.Join(dir, "compose.yml") + + bundleDir, err := s.bundlePath() + if err != nil { + return "", false, err + } + path = filepath.Join(bundleDir, "compose.yml") if _, statErr := os.Stat(path); statErr != nil { if os.IsNotExist(statErr) { return path, false, nil @@ -71,18 +364,3 @@ func ComposeFile() (path string, exists bool, err error) { } return path, true, nil } - -// Version returns the version string bootstrap recorded for the active -// deployment, or "" if there isn't one (nothing deployed, or bootstrap -// hasn't been implemented yet). -func Version() string { - dir, err := Dir() - if err != nil { - return "" - } - b, err := os.ReadFile(filepath.Join(dir, "version")) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} diff --git a/internal/wait/wait.go b/internal/wait/wait.go index 154dc77..d9a49f7 100644 --- a/internal/wait/wait.go +++ b/internal/wait/wait.go @@ -32,3 +32,31 @@ func ForReady(url string, timeout time.Duration) error { time.Sleep(1 * time.Second) } } + +// ForReachable polls url every second until it answers with *any* HTTP +// response, or returns an error once timeout has elapsed without that +// happening. +// +// This exists separately from ForReady because not every service this +// CLI waits on treats "200" as "up" — OpenBao's health endpoint, for one, +// answers with different status codes for sealed/uninitialized/standby, +// all of which still mean the server itself is up and worth talking to +// (ForReachable is for confirming that much; whether it's sealed is the +// caller's problem to detect from there, not this function's). +func ForReachable(url string, timeout time.Duration) error { + client := &http.Client{Timeout: 3 * time.Second} + deadline := time.Now().Add(timeout) + + for { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + return nil + } + + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s waiting for %s to answer at all: %w", timeout, url, err) + } + time.Sleep(1 * time.Second) + } +}