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
2 changes: 1 addition & 1 deletion cmd/ob/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func addCommands(root *cobra.Command, g *globalFlags) {
bootstrapCmd := &cobra.Command{
Use: "bootstrap",
Short: "first contact: host setup, registry login, and supporting/data services",
Long: "Prepare a host: install what the deploy needs, create the layout, log in to\nregistries, start the proxy, and start supporting services.\n\nRun once per host before the first deploy. It is safe to run again — each\nstep converges rather than repeats. Application images, source and environment\npayloads are not required or staged; `ob deploy` binds and releases them.",
Long: "Prepare a host that has Docker: create the layout, log in to registries,\nstart the proxy, and start supporting services.\n\nOnebox never installs Docker implicitly. Install it with operator-managed\nprovisioning, or declare a remote bootstrap hook that installs a pinned runtime;\nthe hook runs inside the lock, fence, and journal boundary, and Docker is\nverified afterwards.\n\nRun once per host before the first deploy. It is safe to run again — each\nstep converges rather than repeats. Application images, source and environment\npayloads are not required or staged; `ob deploy` binds and releases them.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runMutation(cmd, g, onebox.ExecuteRequest{
Kind: onebox.KindBootstrap, BreakLock: bootstrapBreakLock,
Expand Down
30 changes: 13 additions & 17 deletions internal/engine/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,6 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) {
return err
}

// the runtime is ob's own precondition — the one universal piece of
// host provisioning; bootstrap provisions the runtime.
// Everything vendor-flavored (VPNs, NFS, kernel tuning) stays in the
// user's bootstrap hook.
if res, err := e.T.Run(ctx, "docker version -f '{{.Server.Version}}'"); err != nil {
return err
} else if res.ExitCode != 0 {
e.logf("bootstrap: no container runtime — installing docker (get.docker.com)")
ires, err := e.T.Run(ctx, "curl -fsSL https://get.docker.com | sh && systemctl enable --now docker")
if err != nil {
return err
}
if ires.ExitCode != 0 {
return fmt.Errorf("docker install failed: %s", strings.TrimSpace(ires.Stderr))
}
}

e.logf("bootstrap: base dirs")
p := release.PathsFor(e.names())
if res, err := e.T.Run(ctx, "mkdir -p "+q(p.Releases)); err != nil || res.ExitCode != 0 {
Expand Down Expand Up @@ -89,6 +72,19 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) {
return fmt.Errorf("bootstrap hook: %w", err)
}

// Docker is an explicit host prerequisite, never an implicit network
// installer. The authored hook runs first so an operator may deliberately
// provision a pinned runtime inside the lock, fence, and journal boundary.
if res, err := e.T.Run(ctx, "docker version -f '{{.Server.Version}}'"); err != nil {
return err
} else if res.ExitCode != 0 {
detail := strings.TrimSpace(res.Stderr)
if detail != "" {
detail = ": " + detail
}
return fmt.Errorf("container runtime unavailable after bootstrap hook; install Docker with operator-managed provisioning or configure a remote bootstrap hook that installs a pinned runtime%s", detail)
}

for _, name := range sortedNames(e.Spec.Registries) {
r, password := e.Spec.Registries[name], passwords[name]
if password == "" {
Expand Down
131 changes: 116 additions & 15 deletions internal/engine/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/labstack/onebox/internal/app"
"github.com/labstack/onebox/internal/transport"
Expand All @@ -25,8 +26,12 @@ func TestBootstrapSequence(t *testing.T) {
}
seq := strings.Join(f.Commands, "\n")
ordered := []string{
"mkdir -p", // dirs
"mkdir -p", // dirs
"> '/var/lib/ob/sample/lock'", // application lock
"> '/var/lib/ob/sample/fence'", // mutation fence
`"phase":"bootstrap","event":"start"`, // durable journal boundary
"apt-get install -y something-host-specific", // bootstrap hook
"docker version -f '{{.Server.Version}}'", // prerequisite after authored provisioning
"docker login 'ghcr.io' -u 'vishr' --password-stdin", // registry (stdin, quoted)
"docker compose -p 'ob_sample_postgres'", // services
}
Expand Down Expand Up @@ -102,41 +107,137 @@ func TestBootstrapStopsWhenJournalStartFails(t *testing.T) {
}
}

func TestBootstrapInstallsMissingRuntime(t *testing.T) {
func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
if err := os.Mkdir(binDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte("#!/bin/sh\n[ \"$1\" = version ] && printf '27.0.3\\n'\nexit 0\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))

entered := filepath.Join(dir, "hook-entered")
release := filepath.Join(dir, "release-hook")
runs := filepath.Join(dir, "hook-runs")
cfg := testConfig()
cfg.BasePath = filepath.Join(dir, "state")
cfg.Services = nil
cfg.Hooks["bootstrap"] = app.Command{Run: "printf x >> " + q(runs) + "; touch " + q(entered) + "; while [ ! -f " + q(release) + " ]; do sleep 0.01; done"}

first := New(cfg, testProject(t), transport.NewLocal(), Options{Out: &bytes.Buffer{}, Sleep: noSleep})
second := New(cfg, testProject(t), transport.NewLocal(), Options{Out: &bytes.Buffer{}, Sleep: noSleep})
firstDone := make(chan error, 1)
go func() {
firstDone <- first.Bootstrap(context.Background(), engineTestBootstrapReleaseID)
}()
defer func() { _ = os.WriteFile(release, nil, 0o600) }()

deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(entered); err == nil {
break
} else if !os.IsNotExist(err) {
t.Fatal(err)
}
if time.Now().After(deadline) {
t.Fatal("first bootstrap did not enter its hook")
}
time.Sleep(10 * time.Millisecond)
}

err := second.Bootstrap(context.Background(), engineTestDeployReleaseID)
if err == nil || !strings.Contains(err.Error(), "deploy lock held") {
t.Fatalf("concurrent bootstrap error = %v, want held lock", err)
}
body, readErr := os.ReadFile(runs)
if readErr != nil {
t.Fatal(readErr)
}
if string(body) != "x" {
t.Fatalf("bootstrap hooks ran concurrently: %q", body)
}

if err := os.WriteFile(release, nil, 0o600); err != nil {
t.Fatal(err)
}
select {
case err := <-firstDone:
if err != nil {
t.Fatalf("first bootstrap: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("first bootstrap did not finish")
}
}

func TestBootstrapRefusesMissingRuntimeWithoutImplicitInstaller(t *testing.T) {
f := happyFake()
base := f.Dynamic
f.Dynamic = func(cmd string) (transport.Result, bool) {
if strings.Contains(cmd, "docker version") {
return transport.Result{ExitCode: 127, Stderr: "docker: command not found"}, true
}
return base(cmd)
}
e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep})
err := e.Bootstrap(context.Background(), engineTestBootstrapReleaseID)
if err == nil || !strings.Contains(err.Error(), "container runtime unavailable after bootstrap hook") ||
!strings.Contains(err.Error(), "install Docker") || !strings.Contains(err.Error(), "remote bootstrap hook") {
t.Fatalf("missing runtime error = %v\n%s", err, strings.Join(f.Commands, "\n"))
}
seq := strings.Join(f.Commands, "\n")
for _, forbidden := range []string{"get.docker.com", "curl -fsSL", "systemctl enable", "apt-get install", "dnf install", "yum install", "apk add", "docker login", "docker compose", "mkdir -m 700"} {
if strings.Contains(seq, forbidden) {
t.Fatalf("bootstrap ran implicit installer %q:\n%s", forbidden, seq)
}
}
runtimeCheck := strings.Index(seq, "docker version")
if runtimeCheck < 0 {
t.Fatalf("bootstrap did not check the runtime:\n%s", seq)
}
for _, before := range []string{"> '/var/lib/ob/sample/lock'", "> '/var/lib/ob/sample/fence'", `"phase":"bootstrap","event":"start"`} {
if index := strings.Index(seq, before); index < 0 || index > runtimeCheck {
t.Fatalf("%q did not precede the runtime check:\n%s", before, seq)
}
}
}

func TestBootstrapHookMayProvisionPinnedRuntime(t *testing.T) {
f := happyFake()
base := f.Dynamic
installed := false
f.Dynamic = func(cmd string) (transport.Result, bool) {
if strings.Contains(cmd, "install-pinned-docker") {
installed = true
return transport.Result{}, true
}
if strings.Contains(cmd, "docker version") {
if !installed {
return transport.Result{ExitCode: 127, Stderr: "docker: command not found"}, true
}
return transport.Result{Stdout: "27.0.3\n"}, true
}
if strings.Contains(cmd, "get.docker.com") {
installed = true
return transport.Result{}, true
}
return base(cmd)
}
e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep})
cfg := testConfig()
cfg.Hooks["bootstrap"] = app.Command{Run: "install-pinned-docker"}
e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep})
if err := e.Bootstrap(context.Background(), engineTestBootstrapReleaseID); err != nil {
t.Fatalf("bootstrap with runtime install: %v\n%s", err, strings.Join(f.Commands, "\n"))
}
seq := strings.Join(f.Commands, "\n")
if !strings.Contains(seq, "get.docker.com") || !strings.Contains(seq, "systemctl enable --now docker") {
t.Fatalf("missing runtime install:\n%s", seq)
t.Fatalf("bootstrap after authored runtime provisioning: %v\n%s", err, strings.Join(f.Commands, "\n"))
}
}

func TestBootstrapSkipsInstallWhenRuntimePresent(t *testing.T) {
func TestBootstrapUsesPresentRuntimeWithoutInstaller(t *testing.T) {
f := happyFake()
e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep})
if err := e.Bootstrap(context.Background(), engineTestBootstrapReleaseID); err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(f.Commands, "\n"), "get.docker.com") {
t.Fatal("must not reinstall a present runtime")
seq := strings.Join(f.Commands, "\n")
if strings.Contains(seq, "get.docker.com") || strings.Contains(seq, "systemctl enable") {
t.Fatal("must not install a present runtime")
}
}

Expand Down
9 changes: 7 additions & 2 deletions site/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,13 @@ Global Flags:
## ob bootstrap

```
Prepare a host: install what the deploy needs, create the layout, log in to
registries, start the proxy, and start supporting services.
Prepare a host that has Docker: create the layout, log in to registries,
start the proxy, and start supporting services.

Onebox never installs Docker implicitly. Install it with operator-managed
provisioning, or declare a remote bootstrap hook that installs a pinned runtime;
the hook runs inside the lock, fence, and journal boundary, and Docker is
verified afterwards.

Run once per host before the first deploy. It is safe to run again — each
step converges rather than repeats. Application images, source and environment
Expand Down
24 changes: 21 additions & 3 deletions site/src/content/docs/start/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,27 @@ identity can be checked, and a dirty working tree has none.

## What the host needs

A Linux server you can reach over SSH, with a container runtime. There is no
Onebox agent to install on it — the CLI connects over SSH, and scheduled work
runs from host timers rather than a resident process.
A Linux server you can reach over SSH, with Docker available to the configured
SSH account. There is no Onebox agent to install on it — the CLI connects over
SSH, and scheduled work runs from host timers rather than a resident process.

Onebox does not download or run a Docker installer implicitly. Install Docker
through your normal operator-managed provisioning before `ob bootstrap`. If you
deliberately want Onebox to invoke a pinned or configuration-managed installer,
declare it as a remote bootstrap hook:

```yaml
hooks:
bootstrap:
run: /usr/local/sbin/install-pinned-docker
```

The remote hook runs after Onebox has acquired the application lock, written
the mutation fence, and started the bootstrap journal. Docker is checked after
the hook returns; if it is still unavailable, bootstrap stops with an actionable
error before registry login, proxy setup, services, or evidence publication.
A local bootstrap hook runs on the CLI machine and therefore cannot provision
the remote host.

`ob bootstrap` prepares the host. It is the one command that contacts and
changes a server before any application exists.
Expand Down