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
530 changes: 5 additions & 525 deletions cmd/devtool/main.go

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions cmd/devtool/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"errors"
"os"
"os/exec"
"strings"
"testing"
)

// TestMain runs the command itself, instead of the tests, in the child process
// TestTheCommandIsWired starts.
func TestMain(m *testing.M) {
if os.Getenv("DEVTOOL_RUN_MAIN") == "1" {
main()
}
os.Exit(m.Run())
}

// devtool runs this test binary as the devtool command, with args and stdin.
func devtool(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) {
t.Helper()
cmd := exec.Command(os.Args[0], args...)
// GOCOVERDIR keeps a binary built with coverage from warning on stderr that it
// has nowhere to write its counters.
cmd.Env = append(os.Environ(), "DEVTOOL_RUN_MAIN=1", "GOCOVERDIR="+t.TempDir())
cmd.Stdin = strings.NewReader(stdin)
var o, e strings.Builder
cmd.Stdout, cmd.Stderr = &o, &e
err := cmd.Run()
var exitErr *exec.ExitError
if err != nil && !errors.As(err, &exitErr) {
t.Fatal(err)
}
return cmd.ProcessState.ExitCode(), o.String(), e.String()
}

// TestTheCommandIsWired holds what main adds to devtools.Main: the process's
// arguments, standard streams and exit code.
func TestTheCommandIsWired(t *testing.T) {
if code, out, errOut := devtool(t, "internal/cli/exec.go\n", "parity-affected"); code != 0 || out != "exec\n" || errOut != "" {
t.Errorf("parity-affected: code=%d stdout=%q stderr=%q, want 0 \"exec\\n\" \"\"", code, out, errOut)
}
if code, out, errOut := devtool(t, "", "nope"); code != 2 || out != "" || errOut != "devtool: unknown command \"nope\"\n" {
t.Errorf("unknown command: code=%d stdout=%q stderr=%q, want 2", code, out, errOut)
}
}
96 changes: 96 additions & 0 deletions internal/cli/build_repo_held_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package cli

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/devcontainers/cli/internal/config"
)

// buildConfigNotFound runs build with args and returns the path its "config
// not found" error names.
func buildConfigNotFound(t *testing.T, args ...string) string {
t.Helper()
cmd := newBuildCmd()
cmd.SilenceUsage, cmd.SilenceErrors = true, true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(args)
_ = cmd.ExecuteContext(t.Context())
var got struct{ Outcome, Message string }
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("stdout is not one JSON result: %v\n%s", err, out.String())
}
if got.Outcome != "error" {
t.Fatalf("outcome = %q, want error", got.Outcome)
}
return got.Message
}

// build [path] builds the workspace the argument names, not the current
// directory; --workspace-folder wins over the argument.
func TestBuildPositionalPathIsTheWorkspace(t *testing.T) {
ws := t.TempDir()
t.Chdir(t.TempDir())
want := "Dev container config (" + filepath.Join(ws, ".devcontainer", "devcontainer.json") + ") not found."
if got := buildConfigNotFound(t, ws); got != want {
t.Errorf("message = %q, want %q", got, want)
}
if got := buildConfigNotFound(t, t.TempDir(), "--workspace-folder", ws); got != want {
t.Errorf("message with --workspace-folder = %q, want %q", got, want)
}
}

// A build target names the stage to build, and the Dockerfile is used as it
// is, with no stage name added.
func TestPrepareDockerfileBuildUsesTheConfiguredTarget(t *testing.T) {
dir := t.TempDir()
dockerfile := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(dockerfile, []byte("FROM alpine AS base\nFROM base\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.DevContainer{
ConfigFilePath: filepath.Join(dir, "devcontainer.json"),
Build: &config.Build{Dockerfile: "Dockerfile", Target: "base"},
}
prep, err := prepareDockerfileBuild(cfg)
if err != nil {
t.Fatal(err)
}
defer prep.Cleanup()
if prep.StageName != "base" || prep.Path != dockerfile {
t.Errorf("stage %q at %q, want base at %q", prep.StageName, prep.Path, dockerfile)
}
}

// Without a target, the final stage gets a name to build, in a Dockerfile
// written next to the user's, whether the Dockerfile is named under build or
// at the top level.
func TestPrepareDockerfileBuildNamesTheFinalStageWithoutATarget(t *testing.T) {
for name, cfg := range map[string]config.DevContainer{
"build": {Build: &config.Build{Dockerfile: "Dockerfile"}},
"top level": {DockerFile: "Dockerfile"},
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
dockerfile := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(dockerfile, []byte("FROM alpine\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg.ConfigFilePath = filepath.Join(dir, "devcontainer.json")
prep, err := prepareDockerfileBuild(&cfg)
if err != nil {
t.Fatal(err)
}
defer prep.Cleanup()
const stage = "dev_container_auto_added_stage_label"
if prep.StageName != stage || prep.Path != dockerfile+".devcontainer.build" {
t.Errorf("stage %q at %q, want %s at the written Dockerfile", prep.StageName, prep.Path, stage)
}
})
}
}
4 changes: 3 additions & 1 deletion internal/cli/cache_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string,
}
}

if lock, ok, _ := features.ReadLockfile(cfg.ConfigFilePath); ok && lock != nil {
// ReadLockfile's bool reports an empty marker file, which pins nothing; a
// lockfile with content comes back as a non-nil lock.
if lock, _, _ := features.ReadLockfile(cfg.ConfigFilePath); lock != nil {
if lockJSON, lerr := json.Marshal(lock); lerr == nil {
in.Lockfile = lockJSON
}
Expand Down
62 changes: 62 additions & 0 deletions internal/cli/cache_key_repo_held_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cli

import (
"os"
"path/filepath"
"testing"
)

// A committed features lockfile pins resolved digests, so changing it changes
// the cache key (docs/go-only-features.md, read-configuration --cache-key); an
// empty lockfile, which pins nothing, keys like no lockfile at all.
func TestComputeCacheKeyFoldsInTheLockfile(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "devcontainer.json")
lockPath := filepath.Join(dir, "devcontainer-lock.json")
key := func() string {
t.Helper()
k, err := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil)
if err != nil {
t.Fatal(err)
}
return k
}
none := key()

if err := os.WriteFile(lockPath, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if k := key(); k != none {
t.Error("an empty lockfile changed the key")
}

lock := func(digest string) string {
return `{"features":{"ghcr.io/o/f:1":{"version":"1.0.0","resolved":"ghcr.io/o/f@` + digest + `","integrity":"` + digest + `"}}}`
}
if err := os.WriteFile(lockPath, []byte(lock("sha256:aaa")), 0o644); err != nil {
t.Fatal(err)
}
pinnedA := key()
if pinnedA == none {
t.Error("a lockfile did not change the key")
}
if err := os.WriteFile(lockPath, []byte(lock("sha256:bbb")), 0o644); err != nil {
t.Fatal(err)
}
if key() == pinnedA {
t.Error("a lockfile pinning another digest kept the key")
}
}

// A proxy variable that is set but empty configures no proxy, so it keys like
// an unset one.
func TestComputeCacheKeyIgnoresEmptyProxy(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "devcontainer.json")
base, err := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil)
if err != nil {
t.Fatal(err)
}
if k, _ := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), map[string]string{"HTTPS_PROXY": ""}); k != base {
t.Error("an empty proxy variable changed the key")
}
}
4 changes: 3 additions & 1 deletion internal/cli/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ func writeSetupResult(out Output, opts *setupOpts, actions []doctor.Action, fina

// writeReportTable renders the ✔/⚠/✖ table plus per-line remediation hints.
func writeReportTable(w io.Writer, report doctor.Report) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
// mutate-exempt: a minwidth of 1 is below every cell's width plus the padding of 2, tabwidth counts only with '\t' padding, and flag 1 (FilterHTML) re-measures only HTML, which no status symbol or check name holds
const minwidth, tabwidth, flags = 0, 0, 0
tw := tabwriter.NewWriter(w, minwidth, tabwidth, 2, ' ', flags)
for _, r := range report.Results {
fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Status.Symbol(), r.Name, r.Summary)
}
Expand Down
81 changes: 81 additions & 0 deletions internal/cli/check_held_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -98,3 +100,82 @@ func TestWriteSetupResultText(t *testing.T) {
t.Errorf("stderr = %q, want no note when the state was saved", saved.err.String())
}
}

// The table pads its columns two spaces past their widest cell.
func TestWriteReportTableAlignsTheColumns(t *testing.T) {
var buf strings.Builder
writeReportTable(&buf, sampleReport())
want := "✔ docker-daemon Docker daemon reachable (server 27.0)\n" +
"⚠ build-cache-export cannot export cache\n" +
" → build-cache-export: run `devcontainer setup`\n" +
"\noverall: ⚠ warn\n"
if buf.String() != want {
t.Errorf("table =\n%s\nwant\n%s", buf.String(), want)
}
}

// runCheckWithoutDocker runs check against a docker CLI that does not exist,
// so the daemon check fails, with the check state under stateHome.
func runCheckWithoutDocker(t *testing.T, out Output, json bool, stateHome string) error {
t.Helper()
t.Setenv("XDG_STATE_HOME", stateHome)
return runCheck(t.Context(), out, &checkOpts{json: json, dockerPath: filepath.Join(t.TempDir(), "docker")})
}

// A failing host prints the table, saves the state and exits 1.
func TestRunCheckFailingHostExitsOne(t *testing.T) {
state := t.TempDir()
out := &captureOutput{}
err := runCheckWithoutDocker(t, out, false, state)
if exitCode(err) != 1 {
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
}
if s := out.out.String(); !strings.HasSuffix(s, "\noverall: ✖ fail\n") {
t.Errorf("stdout = %q, want the table of a failing host", s)
}
if out.err.Len() != 0 {
t.Errorf("stderr = %q, want nothing when the state was saved", out.err.String())
}
saved, ok, err := doctor.LoadFrom(filepath.Join(state, "devcontainer", "check.json"))
if err != nil || !ok || saved.Overall != doctor.StatusFail {
t.Errorf("saved state = %+v, %v, %v; want the failing report", saved, ok, err)
}
}

// A state that cannot be saved is a note on stderr, not a failure of its own.
func TestRunCheckNotesAStateItCouldNotSave(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(blocker, nil, 0o644); err != nil {
t.Fatal(err)
}
out := &captureOutput{}
err := runCheckWithoutDocker(t, out, false, blocker)
if exitCode(err) != 1 {
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
}
if s := out.err.String(); !strings.HasPrefix(s, "note: could not save check state: ") {
t.Errorf("stderr = %q, want the note", s)
}
}

// --json prints the report as JSON and still exits 1 for a failing host; a
// stdout that cannot be written fails with that error.
func TestRunCheckJSON(t *testing.T) {
out := &captureOutput{}
err := runCheckWithoutDocker(t, out, true, t.TempDir())
if exitCode(err) != 1 {
t.Fatalf("err = %v, want exit code 1 for a failing host", err)
}
var got doctor.Report
if err := json.Unmarshal(out.out.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, out.out.String())
}
if got.Overall != doctor.StatusFail || len(got.Results) == 0 {
t.Errorf("report = %+v, want a failing report", got)
}

err = runCheckWithoutDocker(t, &brokenStdout{}, true, t.TempDir())
if err == nil || !strings.Contains(err.Error(), "stdout closed") {
t.Fatalf("err = %v, want the write error", err)
}
}
Loading
Loading