Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Runs go vet/build/test on every push and pull request -- unlike
# release.yml (which only triggers on a "v*" tag, i.e. right before
# publishing), this catches a broken commit on every push/PR, not just the
# one that happens to get tagged.
name: ci

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
# Same pinned-SHA policy as release.yml: floating tags like @v4 are
# mutable, so third-party actions are pinned to a specific commit.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.22"

- name: Vet
run: go vet ./...

# Cross-compile every release target, same as release.yml's build
# step -- catches a GOOS/GOARCH-specific compile error (e.g. a
# windows-only or darwin-only file) on every push, not just at
# release time.
- name: Build
env:
CGO_ENABLED: "0"
run: |
GOOS=windows GOARCH=amd64 go build -o /dev/null ./cmd/versola
GOOS=darwin GOARCH=amd64 go build -o /dev/null ./cmd/versola
GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/versola
GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/versola

- name: Test
run: go test ./...
52 changes: 40 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ versola doctor
versola bootstrap local <version>
versola status
versola down
versola uninstall
```

If you built from source instead, run the binary directly from where you
Expand All @@ -109,22 +110,26 @@ built it:

### `doctor`

Checks this machine has what Versola needs, without installing or changing
anything:
Checks this machine has what Versola needs. It never installs anything on
its own — if Docker isn't found at all, the most it does is offer to open
an install page in your browser, and only after you confirm:
- Docker daemon reachable (not just `docker` on PATH)
- Docker Compose v2 plugin present
- Port 8080 free on localhost
- Port 2821 free on localhost
- Docker has enough memory allocated (~4 GiB — three JVMs + Postgres)
- Enough free disk space (~4 GiB, for pulling Versola's images)

Exits non-zero if any check fails. `bootstrap` runs the same checks itself
before doing anything, so running `doctor` first is optional but gives you
the same picture ahead of time.

Known gap: if no Docker runtime is found, this doesn't yet offer a choice
between installing Docker Desktop or Colima on macOS — it just reports
"not reachable". Worth adding before this goes to users who don't already
have Docker set up.
If Docker isn't reachable, `doctor` tells the two possible reasons apart
and reacts differently: already installed but not running just gets a
"start it" hint (installing something wouldn't help); not installed at
all offers to open a download page — a choice between Docker Desktop and
Colima on macOS, Docker Desktop on Windows, the Docker Engine install
docs on Linux. It only ever opens a browser tab — never installs or
downloads anything itself.

### `bootstrap local <version>`

Expand All @@ -137,9 +142,14 @@ login on success.
`<version>` is a Versola release, which is tagged **without** a leading
`v` — so `versola bootstrap local 0.1.2`, not `v0.1.2`. (Don't confuse it
with versola-cli's own releases, which do use `v`.) Passing a version
that was never published fails with Docker's raw `manifest unknown`;
the available versions are the tags published for the `versola-tools`
package under the organization's GitHub packages.
that was never published fails with a clear error explaining that,
instead of Docker's raw `manifest unknown`; the available versions are
the tags published for the `versola-tools` package under the
organization's GitHub packages.

Once auth and edge are ready, this opens the admin console in your
default browser automatically. Pass `--no-browser` to skip that (e.g.
running headless over SSH).

### `status`

Expand All @@ -152,6 +162,23 @@ Stops the locally deployed stack. Pass `--volumes` to also delete the
Postgres data volume (kept by default, so a later `bootstrap` picks up
the same data).

### `uninstall`

Removes everything versola deployed locally: stops the stack and deletes
its Postgres volume, removes the `versola-*` images that were pulled, and
clears `~/.versola`. Prompts for confirmation first — pass `-y`/`--yes`
to skip that.

If a deployment was recorded but Docker isn't reachable to confirm it's
actually stopped, `~/.versola` is deliberately left in place rather than
cleared — deleting it there would remove the only way to properly stop
that deployment once Docker is reachable again.

Does **not** remove the `versola` binary itself or its PATH entry;
safely deleting a program's own running executable isn't portable across
platforms (Windows won't allow it at all). It prints the binary's path
so you can remove it yourself.

### `version`

Prints which versola-cli release this binary was built from, plus the Go
Expand Down Expand Up @@ -181,11 +208,12 @@ version it names is compiled into the binaries via ldflags, so

```
cmd/versola/ entry point
internal/cmd/ cobra commands (doctor, bootstrap, status, down, version)
internal/cmd/ cobra commands (doctor, bootstrap, status, down, uninstall, version)
internal/checks/ the actual check logic doctor (and bootstrap) run
internal/state/ locates ~/.versola/active, the on-disk state bootstrap writes
internal/wait/ polls a readiness endpoint until it answers 200 or times out
internal/browser/ opens a URL in the default browser — used by bootstrap (admin console) and doctor (install pages)
install.sh one-line installer for macOS/Linux
install.ps1 one-line installer for Windows
.github/workflows/ release automation (builds binaries + checksums on tag push)
.github/workflows/ ci.yml (vet/build/test on every push+PR), release.yml (builds binaries + checksums on tag push)
```
45 changes: 45 additions & 0 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Package browser opens a URL in the user's default browser. It's a thin
// OS-specific wrapper -- there's no cross-platform standard library way to
// do this, each OS has its own "open whatever handles this" command.
package browser

import (
"fmt"
"os/exec"
"runtime"
)

// Open launches the user's default browser at url. Failure here is never
// fatal to whatever called it — bootstrap succeeding shouldn't be
// conditional on a desktop environment being present to pop a window in
// (e.g. running over SSH), so callers should log and continue, not
// propagate this as a command failure.
func Open(url string) error {
var c *exec.Cmd
switch runtime.GOOS {
case "windows":
// Deliberately not "cmd /c start" -- cmd.exe re-parses whatever
// command line Go hands it, and Go only quotes an argument if it
// contains a space, so a URL with "&" (a cmd.exe command
// separator, and a very ordinary character in query strings)
// would silently get cut in half. rundll32's URL handler takes
// the URL as a single argument with no shell in between, so
// there's no re-parsing step for "&" or anything else to hit.
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
c = exec.Command("open", url)
case "linux":
c = exec.Command("xdg-open", url)
default:
return fmt.Errorf("don't know how to open a browser on %s", runtime.GOOS)
}
if err := c.Start(); err != nil {
return err
}
// Start() without a matching Wait() leaves an unreaped child on Unix
// until this process exits. That's harmless in practice here -- both
// callers open a browser as their last action before returning -- but
// reaping it properly costs nothing and doesn't make Open blocking.
go func() { _ = c.Wait() }()
return nil
}
111 changes: 107 additions & 4 deletions internal/cmd/bootstrap.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
package cmd

import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/versolauth/versola-cli/internal/browser"
"github.com/versolauth/versola-cli/internal/checks"
"github.com/versolauth/versola-cli/internal/state"
"github.com/versolauth/versola-cli/internal/wait"
)

var noBrowser bool

var bootstrapCmd = &cobra.Command{
Use: "bootstrap <target> <version>",
Short: "Deploy a specific version of Versola",
Expand All @@ -31,6 +38,10 @@ compose stack needs. See the project design doc, section 3.5.`,
RunE: runBootstrap,
}

func init() {
bootstrapCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "don't open the admin console in a browser once it's ready")
}

func runBootstrap(cmd *cobra.Command, args []string) error {
target, version := args[0], args[1]
if target != "local" {
Expand All @@ -41,7 +52,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
for _, r := range []checks.Result{
checks.DockerDaemon(),
checks.ComposePlugin(),
checks.PortFree(8080),
checks.PortFree(2821),
checks.DockerMemory(),
checks.DiskSpace(),
} {
Expand All @@ -59,7 +70,14 @@ func runBootstrap(cmd *cobra.Command, args []string) error {

fmt.Println("Generating configuration (versola-tools)...")
toolsImage := fmt.Sprintf("ghcr.io/versolauth/versola-tools:%s", version)
if err := runDocker("run", "--rm", "-v", dir+":/out", toolsImage); err != nil {
if err := pullAndRunTools(dir, toolsImage); err != nil {
if isManifestUnknown(err) {
return fmt.Errorf(`version %q of Versola doesn't exist (no "versola-tools" image published for it).

Versola releases are tagged WITHOUT a leading "v" (e.g. "0.1.2", not
"v0.1.2" — that "v" prefix is only used for versola-cli's own releases).
Check the available versions at https://github.com/orgs/versolauth/packages`, version)
}
return fmt.Errorf("versola-tools failed: %w", err)
}

Expand Down Expand Up @@ -97,14 +115,99 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
return fmt.Errorf("edge never became ready: %w", err)
}

fmt.Printf("\nVersola %s is running at http://localhost:8080\n", version)
adminURL := "http://localhost:2821/central/admin/"
fmt.Printf("\nVersola %s is running at http://localhost:2821\n", version)
fmt.Println("Login: admin / Admin1234!")

if !noBrowser {
// Best-effort only: bootstrap having succeeded shouldn't hinge on a
// desktop environment being around to pop a browser window in
// (e.g. running over SSH) — failure here is a note, not an error.
if err := browser.Open(adminURL); err != nil {
fmt.Printf("(couldn't open a browser automatically: %v — open %s yourself)\n", err, adminURL)
}
}
return nil
}

func runDocker(args ...string) error {
// dockerCmd builds a docker exec.Cmd with stdout already wired to the
// user's terminal. Stderr is left for the caller to set, since runDocker
// and pullAndRunTools each need it to go somewhere different.
func dockerCmd(args ...string) *exec.Cmd {
c := exec.Command("docker", args...)
c.Stdout = os.Stdout
return c
}

func runDocker(args ...string) error {
c := dockerCmd(args...)
c.Stderr = os.Stderr
return c.Run()
}

// manifestUnknownErr wraps a runDocker failure whose stderr contained
// Docker's "manifest unknown" text -- i.e. the image (or this tag of it)
// was never published, as opposed to some other failure (network, daemon
// down, etc.) that happens to also come back as a non-zero exit.
type manifestUnknownErr struct{ inner error }

func (e *manifestUnknownErr) Error() string { return e.inner.Error() }
func (e *manifestUnknownErr) Unwrap() error { return e.inner }

func isManifestUnknown(err error) bool {
var m *manifestUnknownErr
return errors.As(err, &m)
}

// pullAndRunTools runs the versola-tools image the same way runDocker does
// (stdout/stderr still streamed live to the user), but also tees stderr
// into a buffer so it can be inspected afterward -- specifically to
// recognize Docker's "manifest unknown" text and turn it into a clearer
// error in runBootstrap, without changing behavior for every other
// runDocker call site (compose up/down, uninstall's rmi, etc.) that has no
// need for this.
func pullAndRunTools(dir, image string) error {
// "manifest unknown" (when it happens at all) comes from Docker
// failing to resolve the image before any pull output follows, so a
// few KB is more than enough to catch it -- capped rather than a plain
// bytes.Buffer so a normal, successful pull's progress output (which
// can run to tens of KB across an image's layers) can't grow this
// unbounded in memory. os.Stderr (the other leg of the MultiWriter
// below) still gets every byte, same as a real terminal would.
stderrBuf := newCappedBuffer(8 * 1024)
c := dockerCmd("run", "--rm", "-v", dir+":/out", image)
c.Stderr = io.MultiWriter(os.Stderr, stderrBuf)

if err := c.Run(); err != nil {
if strings.Contains(stderrBuf.String(), "manifest unknown") {
return &manifestUnknownErr{inner: err}
}
return err
}
return nil
}

// cappedBuffer is a bytes.Buffer that silently stops accumulating past a
// fixed size. It still reports every byte as written (never a short
// write) so it's safe to use as one leg of an io.MultiWriter -- the other
// leg (os.Stderr here) keeps receiving the full, uncapped output.
type cappedBuffer struct {
buf bytes.Buffer
limit int
}

func newCappedBuffer(limit int) *cappedBuffer {
return &cappedBuffer{limit: limit}
}

func (c *cappedBuffer) Write(p []byte) (int, error) {
if remaining := c.limit - c.buf.Len(); remaining > 0 {
if remaining > len(p) {
remaining = len(p)
}
c.buf.Write(p[:remaining])
}
return len(p), nil
}

func (c *cappedBuffer) String() string { return c.buf.String() }
Loading
Loading