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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
82 changes: 61 additions & 21 deletions internal/checks/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,38 +79,78 @@ 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,
Detail: fmt.Sprintf("already used by Docker container %q", owner),
}
}

// 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}
}

Expand Down
210 changes: 23 additions & 187 deletions internal/cmd/bootstrap.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
}
Expand All @@ -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() }
2 changes: 1 addition & 1 deletion internal/cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading