From a55bc84a9befab4abe0637cc2b18d6854bf59839 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 21 Aug 2026 10:44:14 -0700 Subject: [PATCH] fix(bootstrap): remove implicit Docker install Keep operator-owned runtime provisioning inside the application lock, mutation fence, and journal boundary. Refuse to continue when Docker remains unavailable after the authored hook.\n\nCloses #84 --- cmd/ob/commands.go | 2 +- internal/engine/bootstrap.go | 30 +++--- internal/engine/bootstrap_test.go | 131 +++++++++++++++++++++--- site/src/content/docs/reference/cli.mdx | 9 +- site/src/content/docs/start/install.mdx | 24 ++++- 5 files changed, 158 insertions(+), 38 deletions(-) diff --git a/cmd/ob/commands.go b/cmd/ob/commands.go index a0eb02a9..70433b50 100644 --- a/cmd/ob/commands.go +++ b/cmd/ob/commands.go @@ -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, diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index 21e9cc22..3d854886 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -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 { @@ -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 == "" { diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index 7c0200f8..a5d9ba83 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/labstack/onebox/internal/app" "github.com/labstack/onebox/internal/transport" @@ -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 } @@ -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") } } diff --git a/site/src/content/docs/reference/cli.mdx b/site/src/content/docs/reference/cli.mdx index 1c2b855d..7ff049cb 100644 --- a/site/src/content/docs/reference/cli.mdx +++ b/site/src/content/docs/reference/cli.mdx @@ -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 diff --git a/site/src/content/docs/start/install.mdx b/site/src/content/docs/start/install.mdx index 53a2bb17..01ef37b4 100644 --- a/site/src/content/docs/start/install.mdx +++ b/site/src/content/docs/start/install.mdx @@ -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.