Skip to content

Commit 8d00a70

Browse files
authored
Merge pull request #146 from labstack/fix/host-prerequisites
fix(preflight): assert one host prerequisite set at every gate
2 parents 9932613 + c275a26 commit 8d00a70

16 files changed

Lines changed: 694 additions & 59 deletions

‎cmd/ob/preflight.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ func addPreflightCommand(root *cobra.Command, g *globalFlags) {
1818
Use: "preflight",
1919
Short: "ask the server whether this project could be deployed (changes nothing)",
2020
Long: "Render the project locally, then ask the server what would stand in the way:\n" +
21-
"a missing container runtime, a missing or incompatible Docker Buildx image\n" +
22-
"resolver, a base path this account cannot write, a derived name already held\n" +
23-
"by something Onebox does not own, or a missing ingress network.\n\n" +
21+
"a missing container runtime, a missing Docker Compose plugin, a missing or\n" +
22+
"incompatible Docker Buildx image resolver, a base path this account cannot\n" +
23+
"write, a derived name already held by something Onebox does not own, or a\n" +
24+
"missing ingress network.\n\n" +
25+
"The host prerequisites are the same set `ob bootstrap` and every deploy\n" +
26+
"assert, so a host this command accepts is one they accept too.\n\n" +
2427
"Every problem is reported at once rather than the first one, and nothing is\n" +
2528
"created, renamed or removed.",
2629
RunE: func(cmd *cobra.Command, _ []string) error {

‎docs/product.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ generated runtime, and any host proxy or supporting services selected in the
3131
project. It does not silently claim infrastructure or protection it has not
3232
actually established.
3333

34+
The container runtime is on the user's side of that line. Docker Engine, the
35+
Compose plugin and Buildx are user-supplied and user-versioned; Onebox verifies
36+
them and refuses with a named remedy rather than installing them. The rule this
37+
follows is general: a component is installed by the product that owns its version
38+
lifecycle, and a component the product needs but does not own is verified, never
39+
implicitly installed. Owning Docker's version would mean a distribution matrix,
40+
CVE response, and an upgrade path on a host Onebox otherwise does not manage. An
41+
operator who wants a pinned installer run inside the lock, fence and journal
42+
boundary declares it as a bootstrap hook.
43+
3444
Owned application containers have one visible grammar:
3545
`<app>-<component>-<replica>`, with a one-based replica ordinal that is never
3646
omitted. The managed host proxy is `onebox-proxy`. These names are generated

‎internal/app/errors.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ var errorCodes = map[string]string{
9393
"render_failed": "the runtime could not be rendered",
9494
"server_unreachable": "the server could not be reached",
9595
"preflight_env_incomplete": "an environment file is missing keys the project requires",
96+
"host_prerequisite_unmet": "host software a deploy needs is missing or unusable",
9697

9798
// Ejection.
9899
"eject_destination_exists": "the ejection destination already exists",

‎internal/app/preflight.go‎

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package app
33
import (
44
"bytes"
55
"context"
6-
"errors"
76
"fmt"
87
"os"
98
"path/filepath"
@@ -78,42 +77,24 @@ func (r *Resolved) Preflight(ctx context.Context, run Runner) (*Report, error) {
7877
n := p.NamesFor(r.Env)
7978
report := &Report{Env: r.Env}
8079

81-
// 1. The container runtime. Everything else is meaningless without it, so a
82-
// failure here short-circuits rather than producing a cascade.
83-
res, err := run.Run(ctx, "docker version --format '{{.Server.Version}}'")
80+
// 1. The host prerequisites Onebox requires and never installs: the
81+
// container runtime, the Compose plugin, and a Buildx that can resolve an
82+
// image digest. One shared assertion, so this report and the refusals raised
83+
// by bootstrap and the deploy step cannot describe different sets.
84+
prerequisites, err := CheckHostPrerequisites(ctx, run)
8485
if err != nil {
8586
return nil, errf("server_unreachable", "", "ob doctor",
8687
"cannot reach the server: %v", err)
8788
}
88-
if res.ExitCode != 0 {
89-
report.Checks = append(report.Checks, Check{
90-
Name: "container runtime",
91-
Detail: strings.TrimSpace(firstLine(res.Stderr)),
92-
Remedy: "install Docker on the server, or grant this account permission to use it",
93-
})
94-
return report, nil
95-
}
96-
report.Checks = append(report.Checks, Check{
97-
Name: "container runtime", OK: true,
98-
Detail: "docker " + strings.TrimSpace(res.Stdout),
99-
})
100-
101-
// 2. The image resolver. This only reads the local help output: using an
102-
// image as the probe would spend registry quota before planning begins.
103-
buildxDetail, err := CheckBuildxDigestSupport(ctx, run)
104-
if err != nil {
105-
var capabilityErr *BuildxCapabilityError
106-
if !errors.As(err, &capabilityErr) {
107-
return nil, errf("server_unreachable", "", "ob doctor",
108-
"cannot verify Docker Buildx on the server: %v", err)
89+
report.Checks = append(report.Checks, prerequisites...)
90+
// A runtime failure short-circuits. Everything below asks the runtime
91+
// something, so continuing produces a cascade rather than a diagnosis. The
92+
// test is by identity rather than by position: if the prerequisite order
93+
// ever changes, a positional check would short-circuit on the wrong one.
94+
for _, prerequisite := range prerequisites {
95+
if prerequisite.Name == PrerequisiteRuntime && !prerequisite.OK {
96+
return report, nil
10997
}
110-
report.Checks = append(report.Checks, Check{
111-
Name: "image resolver", Detail: capabilityErr.Error(), Remedy: BuildxRemedy,
112-
})
113-
} else {
114-
report.Checks = append(report.Checks, Check{
115-
Name: "image resolver", OK: true, Detail: buildxDetail,
116-
})
11798
}
11899

119100
// 3. The base path. Checked without creating anything: preflight that

‎internal/app/prerequisites.go‎

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// The host software Onebox requires and deliberately never installs. Declaring
11+
// the set in one place is what keeps the three gates that assert it —
12+
// `ob bootstrap`, `ob preflight` and the deploy preflight step — from each
13+
// checking a different subset, which let a host pass bootstrap and fail two
14+
// commands later. `ob doctor` is deliberately not among them: it reports local
15+
// runner provenance and contacts no server.
16+
const (
17+
dockerVersionCommand = "docker version --format '{{.Server.Version}}'"
18+
composeVersionCommand = "docker compose version --short"
19+
buildxVersionCommand = "docker buildx version"
20+
)
21+
22+
// Prerequisite names are stable: they appear in `ob preflight` output and in
23+
// the refusals bootstrap and the deploy step raise, so an operator reading any
24+
// of them sees the same vocabulary.
25+
const (
26+
PrerequisiteRuntime = "container runtime"
27+
PrerequisiteCompose = "compose plugin"
28+
PrerequisiteResolver = "image resolver"
29+
)
30+
31+
// A refusal that names no command is a dead end at the worst moment: first
32+
// contact with a fresh host. Each remedy is an action, and the three causes a
33+
// failing `docker version` actually has are distinguished, because "install
34+
// Docker" is the wrong advice for the common case where Docker is installed and
35+
// the deploy account simply cannot reach its socket.
36+
const (
37+
prerequisiteDocs = "https://onebox.run/start/install"
38+
39+
runtimeAbsentRemedy = "install Docker Engine, the Compose plugin and Buildx on the server (" + prerequisiteDocs + "), then rerun ob preflight"
40+
runtimeDeniedRemedy = "add the deploy account to the docker group on the server and reconnect so the new membership applies, then rerun ob preflight"
41+
runtimeUnreachableRemedy = "start the Docker daemon on the server, then rerun ob preflight"
42+
composeRemedy = "install the Docker Compose plugin on the server (" + prerequisiteDocs + "), then rerun ob preflight"
43+
)
44+
45+
// runResultDetail is what the command itself said. Stderr is preferred and
46+
// stdout is the fallback, because some clients report a failure on stdout — but
47+
// the join has to be trimmed before the first line is taken, or an empty stderr
48+
// leaves a leading newline and firstLine returns "", discarding the very
49+
// fallback this exists for. An empty detail is worse than verbose: it strands
50+
// the refusal with no reason, and for the runtime it also loses the cause that
51+
// selects the remedy.
52+
//
53+
// It takes the streams rather than the result value so this file keeps naming
54+
// no transport type, the same way buildx.go does: the package's purity test
55+
// permits exactly one file to import the transport, and this is not it.
56+
func runResultDetail(stderr, stdout, command string, exitCode int) string {
57+
detail := strings.TrimSpace(firstLine(strings.TrimSpace(stderr + "\n" + stdout)))
58+
if detail != "" {
59+
return detail
60+
}
61+
return fmt.Sprintf("%s exited with status %d", command, exitCode)
62+
}
63+
64+
// runtimeRemedyFor reads the cause out of what the runtime said. Docker reports
65+
// all three through the same non-zero exit, and only the text separates them.
66+
func runtimeRemedyFor(detail string) string {
67+
lowered := strings.ToLower(detail)
68+
switch {
69+
case strings.Contains(lowered, "permission denied"):
70+
return runtimeDeniedRemedy
71+
case strings.Contains(lowered, "cannot connect to the docker daemon"),
72+
strings.Contains(lowered, "is the docker daemon running"):
73+
return runtimeUnreachableRemedy
74+
default:
75+
return runtimeAbsentRemedy
76+
}
77+
}
78+
79+
// CheckHostPrerequisites asks the server for every piece of host software a
80+
// deploy needs, and reports each as a Check rather than as an error, so a
81+
// caller sees the whole set at once.
82+
//
83+
// An error is returned only when the server cannot be reached at all, and the
84+
// checks answered before that point come back with it rather than being
85+
// discarded: a connection that drops on the third probe has not invalidated the
86+
// first two. A container runtime that is absent or unusable short-circuits the
87+
// rest — without it the remaining answers are noise, not diagnosis.
88+
//
89+
// Nothing here mutates, and nothing contacts a registry.
90+
func CheckHostPrerequisites(ctx context.Context, run Runner) ([]Check, error) {
91+
return checkHostPrerequisites(ctx, run, true)
92+
}
93+
94+
// checkHostPrerequisites takes reportVersions because only a caller rendering a
95+
// report reads the version of a prerequisite that passed. The refusing path
96+
// discards a satisfied check's detail entirely, so fetching the Buildx version
97+
// for it spends an SSH round trip on a string nobody sees — on every deploy.
98+
// A failing check always gets the version, which is where it earns its cost.
99+
func checkHostPrerequisites(ctx context.Context, run Runner, reportVersions bool) ([]Check, error) {
100+
res, err := run.Run(ctx, dockerVersionCommand)
101+
if err != nil {
102+
return nil, err
103+
}
104+
if res.ExitCode != 0 {
105+
detail := runResultDetail(res.Stderr, res.Stdout, dockerVersionCommand, res.ExitCode)
106+
return []Check{{
107+
Name: PrerequisiteRuntime,
108+
Detail: detail,
109+
Remedy: runtimeRemedyFor(detail),
110+
}}, nil
111+
}
112+
checks := []Check{{
113+
Name: PrerequisiteRuntime, OK: true,
114+
Detail: "docker " + strings.TrimSpace(res.Stdout),
115+
}}
116+
117+
// Compose is what actually applies a release. It was previously asserted
118+
// only by the deploy step, so `ob preflight` reported a host ready that
119+
// `ob deploy` then refused.
120+
res, err = run.Run(ctx, composeVersionCommand)
121+
if err != nil {
122+
return checks, err
123+
}
124+
if res.ExitCode != 0 {
125+
checks = append(checks, Check{
126+
Name: PrerequisiteCompose,
127+
Detail: runResultDetail(res.Stderr, res.Stdout, composeVersionCommand, res.ExitCode),
128+
Remedy: composeRemedy,
129+
})
130+
} else {
131+
checks = append(checks, Check{
132+
Name: PrerequisiteCompose, OK: true,
133+
Detail: "compose " + strings.TrimSpace(res.Stdout),
134+
})
135+
}
136+
137+
detail, err := CheckBuildxDigestSupport(ctx, run)
138+
if err != nil {
139+
var capabilityErr *BuildxCapabilityError
140+
if !errors.As(err, &capabilityErr) {
141+
return checks, err
142+
}
143+
return append(checks, Check{
144+
Name: PrerequisiteResolver,
145+
Detail: withBuildxVersion(ctx, run, capabilityErr.Error()),
146+
Remedy: BuildxRemedy,
147+
}), nil
148+
}
149+
if reportVersions {
150+
detail = withBuildxVersion(ctx, run, detail)
151+
}
152+
return append(checks, Check{
153+
Name: PrerequisiteResolver, OK: true, Detail: detail,
154+
}), nil
155+
}
156+
157+
// withBuildxVersion appends the client version to a capability result. The
158+
// capability probe is the authority — a client that advertises `--format` and
159+
// ignores it passes a version comparison — but the version is what makes a bug
160+
// report actionable, so both are reported.
161+
func withBuildxVersion(ctx context.Context, run Runner, detail string) string {
162+
res, err := run.Run(ctx, buildxVersionCommand)
163+
if err != nil || res.ExitCode != 0 {
164+
return detail
165+
}
166+
version := strings.TrimSpace(firstLine(res.Stdout))
167+
if version == "" {
168+
return detail
169+
}
170+
return fmt.Sprintf("%s (%s)", detail, version)
171+
}
172+
173+
// RequireHostPrerequisites is the refusing form of CheckHostPrerequisites, for
174+
// bootstrap and the deploy preflight step, which stop at the first problem
175+
// instead of rendering a report.
176+
//
177+
// The refusal is typed rather than prose, so a structured caller reads a code
178+
// and a command rather than parsing a sentence, and `ob preflight` is the
179+
// honest next step: it is read-only and reports every unmet prerequisite at
180+
// once, where this path stops at the first. It is never circular — the only
181+
// callers are bootstrap and the deploy step, not `ob preflight` itself.
182+
func RequireHostPrerequisites(ctx context.Context, run Runner) error {
183+
checks, err := checkHostPrerequisites(ctx, run, false)
184+
if err != nil {
185+
return err
186+
}
187+
for _, check := range checks {
188+
if check.OK {
189+
continue
190+
}
191+
// Every failing check carries a remedy — that is the point of the
192+
// remedy constants — so this needs no branch for the empty case. A
193+
// check added without one would produce a dangling em dash, which a
194+
// test asserting every remedy names an action catches.
195+
return errf("host_prerequisite_unmet", "", "ob preflight",
196+
"%s unavailable: %s — %s", check.Name, check.Detail, check.Remedy)
197+
}
198+
return nil
199+
}
200+
201+
// HostPrerequisiteRefusal restates an unmet prerequisite with the caller's own
202+
// framing, as one typed error rather than prose wrapped around a rendered one.
203+
// Wrapping with %w renders `Error()`, which carries the code, so the caller's
204+
// sentence ended up with `host_prerequisite_unmet:` buried in the middle of it.
205+
// Every typed failure prints its code first; this keeps that shape.
206+
func HostPrerequisiteRefusal(format string, args ...any) error {
207+
return errf("host_prerequisite_unmet", "", "ob preflight", format, args...)
208+
}

0 commit comments

Comments
 (0)