Skip to content

Commit ab790e0

Browse files
aledbfclaude
andcommitted
feat(doctor): comandos check/setup de diagnóstico del host
Añade `devcontainer check` (diagnóstico) y `devcontainer setup` (remediación) para verificar que el host está configurado antes de provisionar contenedores, evitando errores crípticos a mitad de un build. `check` sondea daemon Docker (fatal), buildx, exportación de cache de build (--cache-to/--output/--platform), Compose v2 y espacio en disco; imprime una tabla ✔/⚠/✖ con remediaciones, soporta --json, persiste el resultado en $XDG_STATE_HOME/devcontainer/check.json y sale !=0 si hay un fallo duro. `setup` reutiliza el diagnóstico y aplica las remediaciones seguras (crea y selecciona un builder buildx docker-container para habilitar la exportación de cache sin tocar /etc/docker/daemon.json ni sudo); las que requieren gestor de paquetes o sudo se reportan como pasos manuales. Soporta --dry-run y --json. Es distinto de `set-up` (con guion), que configura un contenedor existente. up/build consultan el state file y emiten un aviso no bloqueante en stderr SÓLO en TTY interactivo (nunca perturba la salida capturada del harness de paridad ni de scripts). Toda la lógica corre tras el seam exec.Runner y se cubre con tests unitarios (sin oráculo TS). Se registran ambos comandos en el inventario de flags para mantener verde TestFlagInventoryParity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f195136 commit ab790e0

13 files changed

Lines changed: 1260 additions & 0 deletions

File tree

docs/migration/cli-flags-inventory.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,36 @@ commands:
10001000
choices: [info, debug, trace]
10011001
default: info
10021002

1003+
# Go-only host diagnostics (no TS oracle). `check` diagnoses and persists;
1004+
# `setup` applies the safe remediations. Covered by unit tests, not the parity
1005+
# matrix. Note: `setup` (host config) is distinct from `set-up` (container).
1006+
check:
1007+
description: "Diagnose whether the host is configured to run dev containers"
1008+
handler: checkHandler
1009+
flags:
1010+
json:
1011+
type: boolean
1012+
default: false
1013+
description: "Output the report as JSON."
1014+
docker-path:
1015+
type: string
1016+
description: "Docker CLI path."
1017+
setup:
1018+
description: "Configure the host system to run dev containers"
1019+
handler: setupHandler
1020+
flags:
1021+
json:
1022+
type: boolean
1023+
default: false
1024+
description: "Output the result as JSON."
1025+
dry-run:
1026+
type: boolean
1027+
default: false
1028+
description: "Report what would be changed without changing anything."
1029+
docker-path:
1030+
type: string
1031+
description: "Docker CLI path."
1032+
10031033
# =============================================================================
10041034
# RESUMEN JSON OUTPUT ENVELOPES
10051035
# =============================================================================

internal/cli/build.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error {
131131
}
132132
}
133133

134+
// Non-blocking hint (interactive TTY only) if the host was never checked or a
135+
// previous `devcontainer check` found a failing configuration.
136+
warnUncheckedHost(out)
137+
134138
// TS folds the deprecated --experimental-frozen-lockfile into --frozen-lockfile
135139
// (effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile).
136140
opts.experimentalFrozenLockfile = opts.experimentalFrozenLockfile || opts.frozenLockfile

internal/cli/check.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"text/tabwriter"
10+
11+
"github.com/devcontainers/cli/internal/doctor"
12+
coreerrors "github.com/devcontainers/cli/internal/errors"
13+
"github.com/devcontainers/cli/internal/product"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
type checkOpts struct {
18+
json bool
19+
dockerPath string
20+
}
21+
22+
// newCheckCmd builds `devcontainer check`: it diagnoses the host's Docker /
23+
// BuildKit environment, prints a ✔/⚠/✖ table with remediation hints, persists
24+
// the result to the state file, and exits non-zero when a hard check fails.
25+
func newCheckCmd() *cobra.Command {
26+
var opts checkOpts
27+
cmd := &cobra.Command{
28+
Use: "check",
29+
Short: "Diagnose whether the host is configured to run dev containers",
30+
Long: "Check probes the local Docker/BuildKit environment (daemon, buildx, build-cache " +
31+
"export, Compose v2, free disk) and reports what works and what needs attention. " +
32+
"The result is saved so `up`/`build` can warn about a misconfigured host.",
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
return runCheck(cmd.Context(), outputFor(cmd), &opts)
35+
},
36+
}
37+
f := cmd.Flags()
38+
f.BoolVar(&opts.json, "json", false, "Output the report as JSON.")
39+
f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.")
40+
return cmd
41+
}
42+
43+
func runCheck(ctx context.Context, out Output, opts *checkOpts) error {
44+
env := &doctor.Env{DockerPath: opts.dockerPath, CLIVersion: product.GetConfig().Version}
45+
report := doctor.Run(ctx, env)
46+
47+
// Persist best-effort: a diagnostics run must not fail because state could
48+
// not be written (read-only HOME, etc.). Surface it as a note in text mode.
49+
saveErr := doctor.Save(report)
50+
51+
if opts.json {
52+
if err := writeJSON(out.Stdout(), report); err != nil {
53+
return err
54+
}
55+
} else {
56+
writeReportTable(out.Stdout(), report)
57+
if saveErr != nil {
58+
fmt.Fprintf(out.Stderr(), "note: could not save check state: %v\n", saveErr)
59+
}
60+
}
61+
62+
if report.Overall == doctor.StatusFail {
63+
return &coreerrors.ExitCodeError{Code: 1}
64+
}
65+
return nil
66+
}
67+
68+
type setupOpts struct {
69+
json bool
70+
dryRun bool
71+
dockerPath string
72+
}
73+
74+
// newSetupCmd builds `devcontainer setup`: it runs the same diagnostics and then
75+
// applies the automatic remediations (creating a cache-capable buildx builder),
76+
// reporting manual steps for anything it cannot fix without sudo.
77+
//
78+
// Note: this is distinct from `set-up` (hyphenated), which sets up an existing
79+
// container as a dev container.
80+
func newSetupCmd() *cobra.Command {
81+
var opts setupOpts
82+
cmd := &cobra.Command{
83+
Use: "setup",
84+
Short: "Configure the host system to run dev containers",
85+
Long: "Setup runs the same diagnostics as `check` and applies the fixes it safely can " +
86+
"(e.g. creating a docker-container buildx builder so build-cache export works). " +
87+
"Fixes that need a package manager or sudo are reported as manual steps.",
88+
RunE: func(cmd *cobra.Command, args []string) error {
89+
return runSetup(cmd.Context(), outputFor(cmd), &opts)
90+
},
91+
}
92+
f := cmd.Flags()
93+
f.BoolVar(&opts.json, "json", false, "Output the result as JSON.")
94+
f.BoolVar(&opts.dryRun, "dry-run", false, "Report what would be changed without changing anything.")
95+
f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.")
96+
return cmd
97+
}
98+
99+
func runSetup(ctx context.Context, out Output, opts *setupOpts) error {
100+
env := &doctor.Env{DockerPath: opts.dockerPath, CLIVersion: product.GetConfig().Version}
101+
report := doctor.Run(ctx, env)
102+
actions := doctor.Setup(ctx, env, report, opts.dryRun)
103+
104+
// Re-run diagnostics after remediation so the persisted state reflects the
105+
// fixed system (skip when nothing was applied or in dry-run).
106+
final := report
107+
if !opts.dryRun && appliedAny(actions) {
108+
final = doctor.Run(ctx, env)
109+
}
110+
saveErr := doctor.Save(final)
111+
112+
if opts.json {
113+
payload := struct {
114+
DryRun bool `json:"dry_run"`
115+
Actions []doctor.Action `json:"actions"`
116+
Report doctor.Report `json:"report"`
117+
}{opts.dryRun, actions, final}
118+
if err := writeJSON(out.Stdout(), payload); err != nil {
119+
return err
120+
}
121+
} else {
122+
writeSetupActions(out.Stdout(), actions, opts.dryRun)
123+
fmt.Fprintln(out.Stdout())
124+
writeReportTable(out.Stdout(), final)
125+
if saveErr != nil {
126+
fmt.Fprintf(out.Stderr(), "note: could not save check state: %v\n", saveErr)
127+
}
128+
}
129+
130+
if !doctor.SetupSucceeded(actions) {
131+
return &coreerrors.ExitCodeError{Code: 1}
132+
}
133+
return nil
134+
}
135+
136+
func appliedAny(actions []doctor.Action) bool {
137+
for _, a := range actions {
138+
if a.Applied {
139+
return true
140+
}
141+
}
142+
return false
143+
}
144+
145+
// writeReportTable renders the ✔/⚠/✖ table plus per-line remediation hints.
146+
func writeReportTable(w io.Writer, report doctor.Report) {
147+
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
148+
for _, r := range report.Results {
149+
fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Status.Symbol(), r.Name, r.Summary)
150+
}
151+
_ = tw.Flush()
152+
for _, r := range report.Results {
153+
if r.Remediation != "" {
154+
fmt.Fprintf(w, " → %s: %s\n", r.Name, r.Remediation)
155+
}
156+
}
157+
fmt.Fprintf(w, "\noverall: %s %s\n", report.Overall.Symbol(), report.Overall)
158+
}
159+
160+
// writeSetupActions renders the remediation steps taken (or planned in dry-run).
161+
func writeSetupActions(w io.Writer, actions []doctor.Action, dryRun bool) {
162+
if len(actions) == 0 {
163+
fmt.Fprintln(w, "Nothing to do — the host is already configured.")
164+
return
165+
}
166+
header := "Applied:"
167+
if dryRun {
168+
header = "Would apply (dry-run):"
169+
}
170+
fmt.Fprintln(w, header)
171+
for _, a := range actions {
172+
switch {
173+
case a.Err != "":
174+
fmt.Fprintf(w, " %s %s: %s (%s)\n", doctor.StatusFail.Symbol(), a.Name, a.Message, a.Err)
175+
case a.Applied:
176+
fmt.Fprintf(w, " %s %s: %s\n", doctor.StatusOK.Symbol(), a.Name, a.Message)
177+
default:
178+
fmt.Fprintf(w, " %s %s: %s\n", doctor.StatusWarn.Symbol(), a.Name, a.Message)
179+
}
180+
}
181+
}
182+
183+
func writeJSON(w io.Writer, v any) error {
184+
enc := json.NewEncoder(w)
185+
enc.SetIndent("", " ")
186+
return enc.Encode(v)
187+
}
188+
189+
// warnUncheckedHost emits a one-line, non-blocking hint on interactive stderr
190+
// when the host was never checked, or a previous `check` found a hard failure.
191+
// It is gated on a TTY so it never perturbs captured output (parity harness,
192+
// scripts) — only humans at a terminal see it.
193+
func warnUncheckedHost(out Output) {
194+
if !isTerminalWriter(out.Stderr()) {
195+
return
196+
}
197+
report, ok, err := doctor.Load()
198+
if err != nil {
199+
return
200+
}
201+
if !ok {
202+
fmt.Fprintln(out.Stderr(), "hint: host not yet checked — run `devcontainer check` to verify your Docker setup.")
203+
return
204+
}
205+
if report.Overall == doctor.StatusFail {
206+
fmt.Fprintln(out.Stderr(), "warning: `devcontainer check` reported a failing host configuration — run `devcontainer check` for details.")
207+
}
208+
}
209+
210+
// isTerminalWriter reports whether w is a character device (a real terminal).
211+
func isTerminalWriter(w io.Writer) bool {
212+
f, ok := w.(*os.File)
213+
if !ok {
214+
return false
215+
}
216+
st, err := f.Stat()
217+
if err != nil {
218+
return false
219+
}
220+
return st.Mode()&os.ModeCharDevice != 0
221+
}

internal/cli/check_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"strings"
7+
"testing"
8+
9+
"github.com/devcontainers/cli/internal/doctor"
10+
)
11+
12+
func sampleReport() doctor.Report {
13+
return doctor.Report{
14+
Overall: doctor.StatusWarn,
15+
Results: []doctor.Result{
16+
{Name: "docker-daemon", Status: doctor.StatusOK, Summary: "Docker daemon reachable (server 27.0)"},
17+
{Name: "build-cache-export", Status: doctor.StatusWarn, Summary: "cannot export cache", Remediation: "run `devcontainer setup`", Fixable: true},
18+
},
19+
}
20+
}
21+
22+
func TestWriteReportTable(t *testing.T) {
23+
var buf bytes.Buffer
24+
writeReportTable(&buf, sampleReport())
25+
out := buf.String()
26+
27+
for _, want := range []string{"✔", "docker-daemon", "⚠", "build-cache-export", "→ build-cache-export: run `devcontainer setup`", "overall: ⚠ warn"} {
28+
if !strings.Contains(out, want) {
29+
t.Errorf("table output missing %q\n---\n%s", want, out)
30+
}
31+
}
32+
}
33+
34+
func TestWriteSetupActions(t *testing.T) {
35+
var buf bytes.Buffer
36+
actions := []doctor.Action{
37+
{Name: "build-cache-export", Applied: true, Message: "created builder"},
38+
{Name: "compose-v2", Applied: false, Message: "manual action required: install compose"},
39+
{Name: "buildx", Err: "boom", Message: "tried"},
40+
}
41+
writeSetupActions(&buf, actions, false)
42+
out := buf.String()
43+
if !strings.Contains(out, "Applied:") {
44+
t.Errorf("missing header: %s", out)
45+
}
46+
if !strings.Contains(out, "✔ build-cache-export") || !strings.Contains(out, "✖ buildx") || !strings.Contains(out, "boom") {
47+
t.Errorf("action rendering wrong:\n%s", out)
48+
}
49+
50+
var dry bytes.Buffer
51+
writeSetupActions(&dry, actions, true)
52+
if !strings.Contains(dry.String(), "Would apply (dry-run):") {
53+
t.Errorf("dry-run header missing: %s", dry.String())
54+
}
55+
56+
var empty bytes.Buffer
57+
writeSetupActions(&empty, nil, false)
58+
if !strings.Contains(empty.String(), "Nothing to do") {
59+
t.Errorf("empty rendering missing: %s", empty.String())
60+
}
61+
}
62+
63+
// TestWarnUncheckedHostSilentOnNonTerminal proves the up/build hint never writes
64+
// to a captured (non-TTY) stderr — the invariant that keeps it out of the parity
65+
// harness / scripted output.
66+
func TestWarnUncheckedHostSilentOnNonTerminal(t *testing.T) {
67+
var stdout, stderr bytes.Buffer
68+
out := &bufOutput{stdout: &stdout, stderr: &stderr}
69+
warnUncheckedHost(out)
70+
if stderr.Len() != 0 || stdout.Len() != 0 {
71+
t.Fatalf("warnUncheckedHost wrote to non-terminal: stdout=%q stderr=%q", stdout.String(), stderr.String())
72+
}
73+
}
74+
75+
func TestIsTerminalWriterFalseForBuffer(t *testing.T) {
76+
if isTerminalWriter(&bytes.Buffer{}) {
77+
t.Fatal("bytes.Buffer must not be reported as a terminal")
78+
}
79+
}
80+
81+
// bufOutput adapts two buffers to the Output seam.
82+
type bufOutput struct{ stdout, stderr *bytes.Buffer }
83+
84+
func (b *bufOutput) Stdout() io.Writer { return b.stdout }
85+
func (b *bufOutput) Stderr() io.Writer { return b.stderr }

internal/cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ func NewRootCommand() *cobra.Command {
4646
newUpgradeCmd(),
4747
newFeaturesCmd(),
4848
newTemplatesCmd(),
49+
newCheckCmd(),
50+
newSetupCmd(),
4951
)
5052

5153
return root

internal/cli/up.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
184184
if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil {
185185
return writeValidationError(out, err.Error())
186186
}
187+
// Non-blocking hint (interactive TTY only) if the host was never checked or a
188+
// previous `devcontainer check` found a failing configuration.
189+
warnUncheckedHost(out)
187190
// 0.88: default --workspace-folder to the current directory when neither
188191
// --workspace-folder, --id-label nor --override-config is given.
189192
if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.overrideConfig == "" {

0 commit comments

Comments
 (0)