|
| 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 | +} |
0 commit comments