diff --git a/.github/deadcode-allow.txt b/.github/deadcode-allow.txt new file mode 100644 index 0000000..ed6e995 --- /dev/null +++ b/.github/deadcode-allow.txt @@ -0,0 +1,2 @@ +# Functions no binary reaches that are correct for ever, one ` # why` a line (go tool deadgate -allow). +# An entry that matches nothing fails, so the list only shrinks. diff --git a/.github/deadcode-pending.txt b/.github/deadcode-pending.txt new file mode 100644 index 0000000..c435dd0 --- /dev/null +++ b/.github/deadcode-pending.txt @@ -0,0 +1,2 @@ +# Functions no binary reaches whose deletion somebody owes, one ` # why` a line (go tool deadgate -pending). +# Printed on every green run; an entry that matches nothing fails. diff --git a/.github/testquality-allow.txt b/.github/testquality-allow.txt new file mode 100644 index 0000000..b156eaf --- /dev/null +++ b/.github/testquality-allow.txt @@ -0,0 +1,3 @@ +# Tests testquality reports that are kept on purpose, one : a line with why +# (go tool testquality -allow). An entry that matches nothing fails, so the list only shrinks. +github.com/devcontainers/cli/internal/exec:TestHelperProcess # the child process OSRunner tests exec; it asserts nothing by design, the parent tests do diff --git a/.github/workflows/go-cli.yml b/.github/workflows/go-cli.yml index 77e43aa..4ef2f28 100644 --- a/.github/workflows/go-cli.yml +++ b/.github/workflows/go-cli.yml @@ -19,11 +19,50 @@ on: permissions: contents: read +# A newer push to a branch makes the run before it moot, so that run is cancelled; never on main, +# whose every commit needs its answer. 3 of 11 push runs (2026-10-23..25) ran for a commit a newer +# push had replaced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} + +# The jobs that build and run containers land on spin-stack's docker8 fleet (spin-stack/ci: an +# ephemeral 8-vCPU EC2 instance per job, no nested virtualization). On GitHub's 4-vCPU runners a +# runtime parity shard took 10.7 min at p50 and 14.4 at p90, against 5.0 on Blacksmith's. A pull +# request from a fork runs on GitHub's runners instead: its code never reaches spin-stack's AWS +# account. Only a branch of this repository, which takes write access to push, runs on the fleet. + jobs: + # What a pull request changed, broken one small edit at a time (go-tools mutate): an + # edit no test refuses fails, so new code arrives with tests that hold it. + mutate: + if: github.event_name == 'pull_request' + runs-on: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'runs-on/fleet=docker8/env=production' || 'ubuntu-latest' }} + # 4 min on GitHub's runners for a diff's 200 mutations; a large diff's builds are many. + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + - name: What this change broke, and whether a test noticed + env: + BASE: origin/${{ github.base_ref }} + run: go tool task mutate + lint-and-test: runs-on: ubuntu-latest + # p90 3.2 min on GitHub's runners. + timeout-minutes: 15 permissions: contents: read + outputs: + # Where the jobs that build and run containers land (see the top of this file). + fleet: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'runs-on/fleet=docker8/env=production' || 'ubuntu-latest' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 @@ -51,7 +90,9 @@ jobs: if-no-files-found: error e2e: - runs-on: ubuntu-latest + runs-on: ${{ needs.lint-and-test.outputs.fleet }} + # p90 1.6 min on GitHub's runners. + timeout-minutes: 15 needs: lint-and-test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -75,7 +116,10 @@ jobs: # Hermetic parity gate: read-configuration golden comparison + the contract # lane of the parity matrix (flag validation / output contract, no Docker). parity: + # The contract and network lanes start no container: GitHub's runner is enough. runs-on: ubuntu-latest + # p90 2.0 min on GitHub's runners. + timeout-minutes: 10 needs: lint-and-test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -124,6 +168,7 @@ jobs: daily-changes: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 5 outputs: run: ${{ steps.check.outputs.run }} steps: @@ -139,7 +184,7 @@ jobs: # run with the plain binary: a fast gate (the full matrix is ~45 min). # daily — the FULL matrix with the instrumented binary, so one run gates parity # AND measures coverage; daily-changes keeps it off on quiet days. - # 4 shards across independent runners (each its own docker daemon) so the matrix + # 2 shards across independent runners (each its own docker daemon) so the matrix # never contends for one runner's CPU/disk/daemon. PARITY_SHARD_TOTAL MUST equal the # length of matrix.shard — parity_sharding_workflow_test.go enforces it so a mismatch # can't silently drop cases. @@ -153,18 +198,25 @@ jobs: && needs.lint-and-test.result == 'success' && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.daily-changes.outputs.run == 'true') - runs-on: ubuntu-latest + runs-on: ${{ needs.lint-and-test.outputs.fleet }} + # A shard took 14.4 min at p90 as one of four on GitHub's runners; one of two on them (a + # fork's pull request) takes about twice that. + timeout-minutes: 45 strategy: - fail-fast: false + # On a push or a pull request the first failing shard stops the other: what it would have + # found is in the next push's run. The daily run keeps going, since it also measures + # coverage. + fail-fast: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} matrix: - shard: [0, 1, 2, 3] + shard: [0, 1] env: PARITY_RUNTIME_TIMEOUT: "10m" - # Each shard owns ~a quarter of the selected cases; -parallel 2 per 2-core runner - # is the proven-safe load (per-case id-label / COMPOSE_PROJECT_NAME isolation). - PARITY_PARALLEL: "2" + # Each shard owns half of the selected cases. -parallel 2 was the proven load on a + # 4-vCPU runner (per-case id-label / COMPOSE_PROJECT_NAME isolation); docker8's 8 vCPU + # hold twice that. + PARITY_PARALLEL: ${{ needs.lint-and-test.outputs.fleet == 'ubuntu-latest' && '2' || '4' }} PARITY_SHARD_INDEX: ${{ matrix.shard }} - PARITY_SHARD_TOTAL: "4" + PARITY_SHARD_TOTAL: "2" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -220,7 +272,10 @@ jobs: parity-publish-full: needs: [lint-and-test, daily-changes] if: needs.daily-changes.outputs.run == 'true' - runs-on: ubuntu-latest + # Daily and dispatched only: this repository's own code. + runs-on: runs-on/fleet=docker8/env=production + # p90 3.3 min on GitHub's runners. + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -247,6 +302,7 @@ jobs: parity-runtime-gate: if: always() runs-on: ubuntu-latest + timeout-minutes: 10 needs: [lint-and-test, e2e, parity, parity-runtime, parity-publish-full] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -277,6 +333,7 @@ jobs: cross-compile: runs-on: ubuntu-latest + timeout-minutes: 10 needs: lint-and-test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4214675..f920616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,12 @@ jobs: # Runtime lane (real Docker containers). ubuntu-latest ships Docker. gate-runtime: - runs-on: ubuntu-latest + # A tag push or a dispatch: this repository's own code, so spin-stack's docker8 fleet (see + # go-cli.yml), where the whole runtime matrix is one job. + runs-on: runs-on/fleet=docker8/env=production + # e2e, the whole runtime matrix and publish parity in one job; the matrix alone is about 45 + # min on a 4-vCPU runner. + timeout-minutes: 90 needs: prepare steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -178,6 +183,9 @@ jobs: - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + # The released image's base, by digest from versions.yaml. + - run: go tool task release:env + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7 with: distribution: goreleaser diff --git a/.golangci.yml b/.golangci.yml index caff32e..845a12e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,8 +17,6 @@ linters: - fmt.Fprintln - (*github.com/spf13/pflag.FlagSet).MarkHidden - (*github.com/spf13/cobra.Command).MarkFlagRequired - - github.com/devcontainers/cli/internal/pfs.Remove - - github.com/devcontainers/cli/internal/pfs.MkdirAll # Best-effort metadata/config parsing: on failure the target stays zero # (an accepted fallback throughout); behavioral divergences are caught by # the parity matrix, not by these unchecked returns. diff --git a/.goreleaser.yml b/.goreleaser.yml index 7b3f32d..253fa5c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -73,6 +73,8 @@ dockers_v2: build_args: VERSION: "{{ .Version }}" REVISION: "{{ .FullCommit }}" + # The base image by digest, from versions.yaml (task release:env). + DISTROLESS_IMAGE: "{{ .Env.DISTROLESS_IMAGE }}" labels: org.opencontainers.image.title: devcontainer-cli org.opencontainers.image.source: https://github.com/spin-stack/devcontainer-cli diff --git a/Dockerfile b/Dockerfile index 8615129..4155fe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,12 @@ # context. For a local single-arch build: # mkdir -p linux/amd64 # CGO_ENABLED=0 go build -o linux/amd64/devcontainer ./cmd/devcontainer -# docker buildx build --platform linux/amd64 --load -t devcontainer-cli . -FROM gcr.io/distroless/static:nonroot +# docker buildx build --platform linux/amd64 --load $(go tool versions args distroless) -t devcontainer-cli . +# +# The base is pinned by digest in versions.yaml (distroless) and handed in: GoReleaser +# reads it from DISTROLESS_IMAGE, which `task release:env` exports. +ARG DISTROLESS_IMAGE +FROM ${DISTROLESS_IMAGE} # Version/revision are injected at build time. GoReleaser passes them via # build_args; a local build can pass --build-arg VERSION=... . diff --git a/Taskfile.yml b/Taskfile.yml index 2524a29..a20a805 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -75,7 +75,7 @@ tasks: test:integration: desc: Run local HTTP integration tests cmds: - - go test ./internal/httpx -run TestClient -count=1 {{.CLI_ARGS}} + - go test ./internal/httpx -run '^TestNewTransport' -count=1 {{.CLI_ARGS}} test:race: desc: Run hermetic unit tests under the race detector (needs cgo) @@ -85,9 +85,9 @@ tasks: - go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -race -count=1 {{.CLI_ARGS}} vuln: - desc: Scan module and code paths for known vulnerabilities (govulncheck) + desc: Fail on a vulnerability the code calls and that has a fix, the standard library's included (govulncheck through go-tools' vulngate) cmds: - - go run golang.org/x/vuln/cmd/govulncheck@latest ./... + - go tool vulngate ./... coverage: desc: Hermetic unit coverage — per-package table + enforce the total/package floors @@ -198,12 +198,42 @@ tasks: - go test ./internal/cli -run TestE2E -count=1 -timeout 5m -v {{.CLI_ARGS}} lint: - desc: Run Go static checks; hold every workflow step to one task, and every install to pnpm through Socket Firewall + desc: Run Go static checks and go-tools' gates - golangci-lint, goroutine lifetimes, tests that cannot fail, AGENTS.md's references, unreachable code, one task per workflow step, pnpm through Socket Firewall cmds: - go vet ./cmd/... ./internal/... + - task: lint:go + - go tool ctxlife + - go tool testquality -allow .github/testquality-allow.txt ./... + - go tool refs + - go tool mutate -stale-exempts + - task: deadcode - go tool taskgate - go tool sfw check + lint:go: + desc: golangci-lint with .golangci.yml, at the version versions.yaml pins + cmds: + # As this user, on this host's module and build caches; GOTOOLCHAIN=local reads the code + # with the Go the image carries. + - mkdir -p {{.ROOT_DIR}}/artifacts/golangci-lint + - >- + docker run --rm -u "$(id -u):$(id -g)" -v {{.ROOT_DIR}}:/work -w /work + -v "$(go env GOMODCACHE)":/go/pkg/mod -v "$(go env GOCACHE)":/cache/go-build + -e GOMODCACHE=/go/pkg/mod -e GOCACHE=/cache/go-build -e GOLANGCI_LINT_CACHE=/work/artifacts/golangci-lint + -e HOME=/tmp -e GOTOOLCHAIN=local + $(go tool versions ref golangci-lint) golangci-lint run ./cmd/... ./internal/... + + deadcode: + desc: Fail on a function the binaries link and never call, or a package none imports, unless .github/deadcode-allow.txt says why + cmds: + - go tool deadgate -deadcode "$(go tool -n deadcode)" -roots ./cmd/... -packages ./internal/... -allow .github/deadcode-allow.txt -pending .github/deadcode-pending.txt + + mutate: + desc: Break what the change against BASE touched and fail on an edit no test refuses (BASE=origin/main; on a pull request, what it merges into) + cmds: + # BASE comes from `task mutate BASE=...` or the environment (the workflow's). + - go tool mutate -base "{{.BASE | default "${BASE:-origin/main}"}}" -max 200 + spec:compliance: desc: Verify spec compliance — schema attribute coverage + normative behavior (merge/lifecycle/discovery), excludes Go-only additions cmds: @@ -251,6 +281,12 @@ tasks: cmds: - PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-network.json PARITY_STRICT=true PARITY_LANE=all PARITY_NETWORK_ONLY=true PARITY_SKIP_DOCKER=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 15m -v {{.CLI_ARGS}} + parity:differential: + desc: Compare read-configuration with the TS oracle over every upstream fixture (reference/src/test/configs); needs Docker and the network + deps: [build, reference:compile] + cmds: + - PARITY_DIFFERENTIAL=1 go test ./internal/cli -run TestDifferentialReadConfiguration -count=1 -timeout 30m -v {{.CLI_ARGS}} + parity:runtime: desc: Run the runtime parity matrix with Docker (set PARITY_COMMAND=up,build to scope it) deps: [build, reference:compile] @@ -357,48 +393,24 @@ tasks: preconditions: - sh: command -v goreleaser >/dev/null msg: goreleaser is required + env: + DISTROLESS_IMAGE: + sh: go tool versions ref distroless cmds: - goreleaser release --clean {{.CLI_ARGS}} + release:env: + desc: "CI-ONLY: export what GoReleaser reads from versions.yaml (DISTROLESS_IMAGE) to $GITHUB_ENV" + cmds: + - echo "DISTROLESS_IMAGE=$(go tool versions ref distroless)" >> "$GITHUB_ENV" + # The release workflow's own steps. What only the workflow knows - the dispatch input, the # prepare job's outputs, the published digest - comes in the environment. release:resolve: desc: "Resolve the version, tag and commit to release (to $GITHUB_OUTPUT, or the terminal): a dispatch's VERSION_INPUT or the next CalVer, else the pushed tag" cmds: # Outside CI there is no tag push, so it answers as a dispatch would: a dry run. - - | - set -euo pipefail - if [ "${GITHUB_EVENT_NAME:-workflow_dispatch}" = "workflow_dispatch" ]; then - VERSION="${VERSION_INPUT:-}" - VERSION="${VERSION#v}" - if [ -z "$VERSION" ]; then - # Auto CalVer: YYYYMMDD.. - DATE="$(date -u +%Y%m%d)" - N="$(git tag -l "v${DATE}.*" | wc -l | tr -d ' ')" - VERSION="${DATE}.$(printf '%02d' "$((N + 1))")" - fi - # Reject anything that is not a plain version token (defense in depth: - # this value later reaches git/docker command lines). - case "$VERSION" in - *[!0-9A-Za-z.+-]*|"") - echo "::error::invalid version '${VERSION}': use only [0-9A-Za-z.+-]"; exit 1 ;; - esac - TAG="v${VERSION}" - if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "::error::tag ${TAG} already exists — releases are immutable, bump the build number" - exit 1 - fi - SHA="$(git rev-parse HEAD)" - else - TAG="${GITHUB_REF_NAME}" - VERSION="${TAG#v}" - SHA="${GITHUB_SHA}" - fi - out="${GITHUB_OUTPUT:-/dev/stdout}" - echo "version=${VERSION}" >>"$out" - echo "tag=${TAG}" >>"$out" - echo "sha=${SHA}" >>"$out" - echo "Releasing ${TAG} (version ${VERSION}) from ${SHA}" >>"${GITHUB_STEP_SUMMARY:-/dev/stdout}" + - go run ./cmd/devtool release-resolve release:tag: desc: "CI-ONLY: create and push the annotated tag RELEASE_TAG for RELEASE_VERSION, as github-actions[bot]" @@ -460,100 +472,9 @@ tasks: metrics: desc: "Perf/distribution metrics (non-gating): Go startup vs Node oracle + artifact sizes -> artifacts/metrics.json" deps: [build] - vars: - ORACLE: reference/dist/spec-node/devContainersSpecCLI.js cmds: # Non-gating: never fail the caller. All measurement is best-effort. - - cmd: | - set -u - out={{.ROOT_DIR}}/artifacts/metrics.json - mkdir -p {{.ROOT_DIR}}/artifacts - bin={{.ROOT_DIR}}/{{.BINARY}} - oracle={{.ROOT_DIR}}/{{.ORACLE}} - runs="${METRICS_RUNS:-30}" - - # Portable byte size of a file (Linux stat -c; BSD/mac stat -f fallback). - fsize() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || echo null; } - - # Mean wall-clock ms for a command over N runs. - # Uses hyperfine when present (JSON mean, seconds->ms); otherwise a - # portable `date +%s%N` loop averaged over the runs. - have_hyperfine=0 - command -v hyperfine >/dev/null 2>&1 && have_hyperfine=1 - - time_cmd() { - # args: label, then the command - _label="$1"; shift - if [ "$have_hyperfine" = "1" ]; then - _hf=$(hyperfine --warmup 3 --runs "$runs" --shell=none --export-json /dev/stdout "$*" 2>/dev/null \ - | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);process.stdout.write(String(j.results[0].mean*1000))}catch(e){process.stdout.write("null")}})' 2>/dev/null) - [ -n "$_hf" ] && echo "$_hf" || echo null - else - # warmup - "$@" >/dev/null 2>&1 || true - _start=$(date +%s%N) - _i=0 - while [ "$_i" -lt "$runs" ]; do "$@" >/dev/null 2>&1 || true; _i=$((_i+1)); done - _end=$(date +%s%N) - # (end-start) ns / runs / 1e6 -> ms mean - awk -v s="$_start" -v e="$_end" -v n="$runs" 'BEGIN{printf "%.3f",(e-s)/n/1000000}' - fi - } - - echo "metrics: timing tool = $([ "$have_hyperfine" = 1 ] && echo hyperfine || echo date-loop), runs=$runs" - - go_version_ms=$(time_cmd go_version "$bin" --version) - - # read-configuration no-op against a minimal image-based workspace - # (variable substitution only; no Docker involved). - ws=$(mktemp -d) - mkdir -p "$ws/.devcontainer" - printf '{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}\n' > "$ws/.devcontainer/devcontainer.json" - go_readcfg_ms=$(time_cmd go_readcfg "$bin" read-configuration --workspace-folder "$ws") - - node_version_ms=null - if [ -f "$oracle" ] && command -v node >/dev/null 2>&1; then - node_version_ms=$(time_cmd node_version node "$oracle" --version) - else - echo "metrics: node oracle not found at $oracle (run 'task reference') — skipping Node A/B" - fi - rm -rf "$ws" - - # Sizes: local binary + cross-compiled dist binaries + gzip-compressed size. - bin_size=$(fsize "$bin") - la_bin=null; la_gz=null; lr_bin=null; lr_gz=null - if [ -f {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64 ]; then - la_bin=$(fsize {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64) - la_gz=$(gzip -c {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64 | wc -c | tr -d ' ') - fi - if [ -f {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64 ]; then - lr_bin=$(fsize {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64) - lr_gz=$(gzip -c {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64 | wc -c | tr -d ' ') - fi - [ "$la_bin" = null ] && echo "metrics: dist binaries absent — run 'task build:cross' for release sizes" - - cat > "$out" <>30) } - _ = run("sudo", append([]string{"rm", "-rf"}, bloat...)...) - _ = run("sudo", "docker", "image", "prune", "-af") - printDisk("disk after:") // Enable the containerd image store (build-cache export + --platform). tee := exec.Command("sudo", "tee", "/etc/docker/daemon.json") @@ -386,3 +396,141 @@ func dirEmpty(dir string) (bool, error) { } return len(entries) == 0, nil } + +// --- release --------------------------------------------------------------- + +// releaseResolve writes the version, tag and commit to release to $GITHUB_OUTPUT +// (or stdout), and a line to the job summary. What only the workflow knows comes +// in the environment: GITHUB_EVENT_NAME, VERSION_INPUT, GITHUB_REF_NAME, GITHUB_SHA. +func releaseResolve() error { + in := devtools.ReleaseInput{ + Event: os.Getenv("GITHUB_EVENT_NAME"), + VersionInput: os.Getenv("VERSION_INPUT"), + RefName: os.Getenv("GITHUB_REF_NAME"), + PushedSHA: os.Getenv("GITHUB_SHA"), + Today: time.Now().UTC().Format("20060102"), + TagExists: func(tag string) bool { + return exec.Command("git", "rev-parse", "-q", "--verify", "refs/tags/"+tag).Run() == nil + }, + } + if in.Event == "" || in.Event == "workflow_dispatch" { + tags, err := runOut("git", "tag", "-l", "v"+in.Today+".*") + if err != nil { + return fmt.Errorf("list tags: %w", err) + } + in.TagsToday = len(strings.Fields(tags)) + if in.HeadSHA, err = runOut("git", "rev-parse", "HEAD"); err != nil { + return fmt.Errorf("resolve HEAD: %w", err) + } + } + rel, err := devtools.ResolveRelease(in) + if err != nil { + return exitError{code: 1, msg: "::error::" + err.Error()} + } + if err := appendTo("GITHUB_OUTPUT", fmt.Sprintf("version=%s\ntag=%s\nsha=%s\n", rel.Version, rel.Tag, rel.SHA)); err != nil { + return err + } + return appendTo("GITHUB_STEP_SUMMARY", fmt.Sprintf("Releasing %s (version %s) from %s\n", rel.Tag, rel.Version, rel.SHA)) +} + +// appendTo appends s to the file the environment variable names, or writes it to +// stdout outside a workflow. +func appendTo(env, s string) error { + path := os.Getenv(env) + if path == "" { + _, err := fmt.Print(s) + return err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open %s: %w", env, err) + } + if _, err := f.WriteString(s); err != nil { + f.Close() + return fmt.Errorf("write %s: %w", env, err) + } + return f.Close() +} + +// --- metrics --------------------------------------------------------------- + +// metrics writes artifacts/metrics.json: startup time of the binary against the +// Node oracle (METRICS_RUNS runs, 30 by default) and the size of the binaries. +// Every measurement is best effort: one that cannot be taken is null. +// +// devtool metrics +func metrics(args []string) error { + if len(args) != 4 { + return fmt.Errorf("usage: devtool metrics ") + } + version, bin, oracle, out := args[0], args[1], args[2], args[3] + runs := 30 + if v := os.Getenv("METRICS_RUNS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("METRICS_RUNS %q: %w", v, err) + } + runs = n + } + quiet := func(name string, args ...string) func() { + return func() { _ = exec.Command(name, args...).Run() } + } + fmt.Printf("metrics: timing tool = go, runs=%d\n", runs) + m := devtools.Metrics{ + Version: version, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + TimingTool: "go", + Runs: runs, + } + m.StartupMs.GoVersion = new(devtools.MeanMillis(runs, quiet(bin, "--version"))) + + // read-configuration against a minimal image-based workspace: variable + // substitution only, no Docker involved. + ws, err := os.MkdirTemp("", "devcontainer-metrics-") + if err != nil { + return err + } + defer os.RemoveAll(ws) + cfg := filepath.Join(ws, ".devcontainer", "devcontainer.json") + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { + return err + } + if err := os.WriteFile(cfg, []byte(`{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}`+"\n"), 0o644); err != nil { + return err + } + m.StartupMs.GoReadConfiguration = new(devtools.MeanMillis(runs, quiet(bin, "read-configuration", "--workspace-folder", ws))) + + if _, err := os.Stat(oracle); err == nil { + if _, err := exec.LookPath("node"); err == nil { + m.StartupMs.NodeVersion = new(devtools.MeanMillis(runs, quiet("node", oracle, "--version"))) + } + } + if m.StartupMs.NodeVersion == nil { + fmt.Printf("metrics: node oracle not found at %s (run 'task reference') — skipping Node A/B\n", oracle) + } + + dist := filepath.Join(filepath.Dir(bin), "dist", filepath.Base(bin)) + m.SizesBytes = devtools.SizesBytes{ + LocalBinary: devtools.FileSize(bin), + LinuxAmd64Binary: devtools.FileSize(dist + "-linux-amd64"), + LinuxAmd64Gzip: devtools.GzipSize(dist + "-linux-amd64"), + LinuxArm64Binary: devtools.FileSize(dist + "-linux-arm64"), + LinuxArm64Gzip: devtools.GzipSize(dist + "-linux-arm64"), + } + if m.SizesBytes.LinuxAmd64Binary == nil { + fmt.Println("metrics: dist binaries absent — run 'task build:cross' for release sizes") + } + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return err + } + if err := os.WriteFile(out, append(data, '\n'), 0o644); err != nil { + return err + } + fmt.Printf("metrics: wrote %s\n%s\n", out, data) + return nil +} diff --git a/docs/DIVERGENCES.md b/docs/DIVERGENCES.md index 7e43a4e..0a580b0 100644 --- a/docs/DIVERGENCES.md +++ b/docs/DIVERGENCES.md @@ -89,6 +89,16 @@ touches a compared surface, reflected in the parity matrix. Known gaps that are deliberately not closed; each is a conscious trade-off, not an oversight. +- **Which of several failing parallel lifecycle commands is named.** When more than one + command of an object-form hook (`"postCreateCommand": {"a": ..., "b": ...}`) fails, + TS names the first failure in the object's key order (`Promise.allSettled` + + `find`); the command maps decode without key order here, so the first failure in + sorted order is named. With one failing command — the common case — the description + (`b of postCreateCommand from devcontainer.json failed.`) is identical. Keeping key + order would mean an ordered representation for every lifecycle command through the + config, metadata and merge; `TestHookErrorDescribesTheCommandAndItsOrigin` pins the + current choice. + - **Byte-for-byte tarball parity is unattainable** because of `mtime` differences; tarball contents are compared by parsed structure, not raw bytes. - **Cloud registry auth matrix is out of default CI scope.** The hermetic auth paths @@ -96,14 +106,15 @@ oversight. unit-tested; a real ACR (identity/refresh) / ECR / authenticated-GHCR matrix is secrets-gated and non-blocking. Credential helpers are Linux-only (`secretservice`/`pass`). -- **TS→Go metadata interop** (build with the TS oracle, read with Go) is skip-guarded in - the hermetic unit tests (which do not compile the oracle); it is exercised end-to-end by - the runtime lane's metadata cases (`container-metadata-success`, - `read-configuration.features-configuration`). The Go→Go round-trip and whitespace - invariance are unit-tested. +- **TS→Go metadata interop** is exercised by `TestMetadataInteropTSToGo`, which builds + the same configuration with the oracle and with this CLI and compares the merged + `devcontainer.metadata` labels; it runs when the oracle is compiled (`task reference`) + and Docker is available. The Go→Go round-trip and whitespace invariance are + unit-tested. - **Version banner** differs cosmetically: this CLI reports a git hash / CalVer, TS a semver, and the banner box width depends on the version length. Verbose commands (features-test / features-info) are compared via `exit_code` / stderr rather than the banner. - **Legacy Feature fallback via GitHub Releases** is not implemented; feature resolution - is OCI-first (the supported path for v2 features). + is OCI-first (the supported path for v2 features). A configuration that references one fails with the + resolution error (read-configuration included) instead of silently omitting the Feature. diff --git a/docs/migration.md b/docs/migration.md index d567717..806594c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -68,6 +68,25 @@ The Go-only commands and flags are additive. Existing workflows do not need to u them, but [Go-only features](go-only-features.md) describes the operational benefits available after migration. +## Upgrading from an earlier release of this CLI + +Releases that close a divergence from the official CLI can change behavior that +automation relied on. These changes match the official CLI, so a workflow that also +runs the official CLI is unaffected: + +- `features package`, `features publish`, and `templates publish` require the + `` folder argument. Earlier releases defaulted it to the current folder; + without it the command now exits 1 with `Not enough non-option arguments: got 0, + need at least 1`. Pass the folder explicitly, for example + `devcontainer features package . --output-folder output`. +- A configuration that references a legacy GitHub Releases Feature fails, in + `read-configuration` too, instead of silently leaving the Feature out. +- `up` with `--id-label` and no workspace folder or configuration fails with + `No dev container config and no workspace found.` instead of attaching to the + matching container. +- Features are staged under `/devcontainercli-/container-features/`, + one folder per build, and that folder is kept as the official CLI keeps it. + ## Rollback Keep the previous official CLI version available during rollout. Because project diff --git a/docs/parity/parity-matrix.yaml b/docs/parity/parity-matrix.yaml index 62b396c..0a9aa26 100644 --- a/docs/parity/parity-matrix.yaml +++ b/docs/parity/parity-matrix.yaml @@ -381,6 +381,481 @@ initial_cases: current_status: match notes: "publishes the template collection (alpine, cpp, mytemplate, node-mongo) to a LOCAL registry:3, one per side started in setup_cmd with host 'localhost' (not 127.0.0.1) so that the TS OCI client uses plain HTTP. The random host port is captured via echo REGISTRY=localhost:PORT (the harness reSetupEnv injects it into the ts_cmd env). stdout = result-map JSON { id: {publishedTags:[1,1.0,1.0.0,latest], digest, version} }; the tarball digest differs between sides (Go tar vs node-tar) but is scrubbed to sha256:, and the keys are ordered by normalizeValue. Each side uses its own fresh registry -> deterministic publishedTags." + - id: features-publish.legacy-ids-single-segment-namespace-success + lane: runtime + command: features-publish + priority: p1 + docker_required: true + setup_cmd: "REG_NAME=parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE}; docker rm -f $REG_NAME >/dev/null 2>&1; docker run -d --name $REG_NAME -p 127.0.0.1::5000 registry:3 >/dev/null; PORT=$(docker port $REG_NAME 5000/tcp | head -1 | sed s/.*://); curl -s --retry 30 --retry-delay 1 --retry-connrefused http://localhost:$PORT/v2/ >/dev/null 2>&1; echo REGISTRY=localhost:$PORT" + verify_cmd: "curl -s http://${REGISTRY}/v2/myorg/tags/list" + cleanup_cmd: "docker rm -f parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1 || true" + ts_cmd: "features publish reference/src/test/container-features/example-v2-features-sets/renaming-feature/src -r ${REGISTRY} -n myorg" + asserts: [exit_code, stdout_normalized] + class: features-runtime + current_status: match + notes: "renaming-feature publishes hello and new-color; new-color's legacyIds (color, old-color) are republished and reported inside the item as publishedLegacyIds (featuresCLI/publish.ts), not as items of their own. The one-segment namespace is a valid collection (getCollectionRef): verify_cmd lists the collection's tags (latest). Go used to report the aliases as items and to skip the collection metadata for a one-segment namespace." + + - id: features-publish.already-published-success + lane: runtime + command: features-publish + priority: p1 + docker_required: true + setup_cmd: "REG_NAME=parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE}; docker rm -f $REG_NAME >/dev/null 2>&1; docker run -d --name $REG_NAME -p 127.0.0.1::5000 registry:3 >/dev/null; PORT=$(docker port $REG_NAME 5000/tcp | head -1 | sed s/.*://); curl -s --retry 30 --retry-delay 1 --retry-connrefused http://localhost:$PORT/v2/ >/dev/null 2>&1; ${PARITY_CLI_TS} features publish reference/src/test/container-features/example-v2-features-sets/renaming-feature/src -r localhost:$PORT -n myorg >/dev/null 2>&1; echo REGISTRY=localhost:$PORT" + cleanup_cmd: "docker rm -f parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1 || true" + ts_cmd: "features publish reference/src/test/container-features/example-v2-features-sets/renaming-feature/src -r ${REGISTRY} -n myorg" + asserts: [exit_code, stdout_normalized] + class: features-runtime + current_status: match + notes: "setup_cmd publishes the collection once (with the oracle); publishing the same versions again skips each item, which TS reports as an empty object: {hello:{}, new-color:{}}. Go used to report {digest:'', publishedTags:[]}." + + - id: build.disallowed-feature-error + lane: runtime + command: build + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"ghcr.io/devcontainers/features/node:1\":{},\"ghcr.io/devcontainers/features/disallowed-feature:1\":{},\"ghcr.io/chrmarti/test-features/test-disallowed-feature\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "build --workspace-folder ${WORKSPACE}" + asserts: [exit_code, stdout_normalized] + class: build-runtime + current_status: match + compare_nulls: true + notes: "The containers.dev control manifest disallows ghcr.io/devcontainers/features/disallowed-feature. The first disallowed feature in the config's key order is reported (TS Object.keys), and build's error envelope carries only outcome/message/description (devContainersSpecCLI.ts build)." + + - id: up.disallowed-feature-error + lane: runtime + command: up + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"ghcr.io/devcontainers/features/node:1\":{},\"ghcr.io/devcontainers/features/disallowed-feature:1\":{},\"ghcr.io/chrmarti/test-features/test-disallowed-feature\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + compare_nulls: true + notes: "up reports disallowedFeatureId, didStopContainer (false: no container) and learnMoreUrl (disallowedFeatures.ts, doProvision). Go used to emit only the description, and to report a random one of two disallowed features." + + - id: up.disallowed-feature-stops-running-container + lane: runtime + command: up + priority: p1 + docker_required: true + network_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"ghcr.io/devcontainers/features/node:1\":{},\"ghcr.io/devcontainers/features/disallowed-feature:1\":{},\"ghcr.io/chrmarti/test-features/test-disallowed-feature\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; docker run -d --label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} alpine:3 sleep infinity >/dev/null; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker inspect -f '{{.State.Status}}' $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE})" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + compare_nulls: true + notes: "A running container with the workspace's id labels is stopped (docker stop -t 0), the description says so and didStopContainer is true; verify_cmd shows it exited. Go used to take the --id-label shortcut, reuse the container and report success without reading the config." + + - id: up.id-label-only-existing-container-error + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "docker run -d --label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} alpine:3 sleep infinity >/dev/null" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; true" + ts_cmd: "up --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "TS reads the config before looking for a container (configContainer.ts): with no workspace and no config it fails with 'No dev container config and no workspace found.' even though --id-label finds a container. Go used to reuse the container and report success with an empty remoteWorkspaceFolder." + + - id: up.config-without-workspace-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE; printf '%s' '{\"image\":\"alpine:3\"}' > $WORKSPACE/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker inspect -f 'mounts=[{{range .Mounts}}{{.Destination}} {{end}}] wd={{.Config.WorkingDir}}' $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE})" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --config ${WORKSPACE}/devcontainer.json --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "With --config and no --workspace-folder there is no workspace: nothing is mounted and remoteWorkspaceFolder is left out (getWorkspaceConfiguration with an undefined workspace). Go used to mount '' at /workspaces/. and report remoteWorkspaceFolder '/workspaces/.'." + + - id: up.feature-hook-failure-error + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeat; printf '%s' '{\"id\":\"myfeat\",\"version\":\"1.0.0\",\"postCreateCommand\":\"exit 3\"}' > $WORKSPACE/.devcontainer/myfeat/devcontainer-feature.json; printf '#!/bin/sh\necho installed\n' > $WORKSPACE/.devcontainer/myfeat/install.sh; chmod +x $WORKSPACE/.devcontainer/myfeat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./myfeat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "A Feature's failing postCreateCommand: description 'postCreateCommand from Feature ./myfeat failed.', message the command's error, and the containerId (injectHeadless.ts runLifecycleCommand, ContainerError message = originalError.message). Go used to say 'from devcontainer.json' and leave the container out." + + - id: up.parallel-hook-failure-error + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeat; printf '%s' '{\"image\":\"alpine:3\",\"postCreateCommand\":{\"a\":\"true\",\"b\":\"exit 4\"}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "An object postCreateCommand whose b command fails: 'b of postCreateCommand from devcontainer.json failed.'. Go used to drop the command's name." + + - id: run-user-commands.hook-failure-error + lane: runtime + command: run-user-commands + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeat; printf '%s' '{\"id\":\"myfeat\",\"version\":\"1.0.0\",\"postCreateCommand\":\"exit 3\"}' > $WORKSPACE/.devcontainer/myfeat/devcontainer-feature.json; printf '#!/bin/sh\necho installed\n' > $WORKSPACE/.devcontainer/myfeat/install.sh; chmod +x $WORKSPACE/.devcontainer/myfeat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./myfeat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; ${PARITY_CLI_TS} up --workspace-folder $WORKSPACE --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} --skip-post-create >/dev/null 2>&1; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "run-user-commands --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "The container was created with --skip-post-create, so postCreateCommand runs here and fails: TS run-user-commands reports only message and description, with the Feature origin. Go used to say 'from devcontainer.json'." + + - id: run-user-commands.after-failed-post-create-success + lane: runtime + command: run-user-commands + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeat; printf '%s' '{\"id\":\"myfeat\",\"version\":\"1.0.0\",\"postCreateCommand\":\"exit 3\"}' > $WORKSPACE/.devcontainer/myfeat/devcontainer-feature.json; printf '#!/bin/sh\necho installed\n' > $WORKSPACE/.devcontainer/myfeat/install.sh; chmod +x $WORKSPACE/.devcontainer/myfeat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./myfeat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; ${PARITY_CLI_TS} up --workspace-folder $WORKSPACE --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "run-user-commands --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "up already ran (and failed) postCreateCommand; its marker was written before it ran (updateMarkerFile), so run-user-commands does not run it again and succeeds with result done. Go used no markers in run-user-commands and failed again." + + - id: set-up.after-failed-post-create-success + lane: runtime + command: set-up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeat; printf '%s' '{\"id\":\"myfeat\",\"version\":\"1.0.0\",\"postCreateCommand\":\"exit 3\"}' > $WORKSPACE/.devcontainer/myfeat/devcontainer-feature.json; printf '#!/bin/sh\necho installed\n' > $WORKSPACE/.devcontainer/myfeat/install.sh; chmod +x $WORKSPACE/.devcontainer/myfeat/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./myfeat\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; ${PARITY_CLI_TS} up --workspace-folder $WORKSPACE --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; CONTAINER=$(docker ps -q --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}); echo CONTAINER=$CONTAINER; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "set-up --container-id ${CONTAINER}" + asserts: [exit_code, stdout_normalized] + class: lifecycle-runtime + current_status: match + notes: "set-up shares runLifecycleHooks with up: the marker up wrote keeps the failed postCreateCommand from running again. Go used no markers in set-up and reported 'An error occurred running user commands: ...'." + + - id: up.container-entrypoint-and-command-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"image\":\"alpine:3\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE})" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "The container's keep-alive script is TS spawnDevContainer's byte for byte: with no Feature entrypoint, an empty line stands between trap and exec. Go used to drop that line, so the containers' Cmd differed." + + - id: templates.publish-template-metadata-success + lane: runtime + command: templates + priority: p1 + docker_required: true + setup_cmd: "REG_NAME=parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE}; docker rm -f $REG_NAME >/dev/null 2>&1; docker run -d --name $REG_NAME -p 127.0.0.1::5000 registry:3 >/dev/null; PORT=$(docker port $REG_NAME 5000/tcp | head -1 | sed s/.*://); curl -s --retry 30 --retry-delay 1 --retry-connrefused http://localhost:$PORT/v2/ >/dev/null 2>&1; echo REGISTRY=localhost:$PORT" + verify_cmd: "for t in alpine node-mongo; do ${PARITY_CLI_TS} templates metadata ${REGISTRY}/octocat/templates/$t 2>/dev/null | tail -1 | node -e 'let s=\"\";process.stdin.on(\"data\",d=>s+=d).on(\"end\",()=>{const o=JSON.parse(s);o.files.sort();console.log(JSON.stringify(o))})'; done" + cleanup_cmd: "docker rm -f parity-reg-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1 || true" + ts_cmd: "templates publish reference/src/test/container-templates/example-templates-sets/simple/src -r ${REGISTRY} -n octocat/templates" + asserts: [exit_code, stdout_normalized] + class: templates-runtime + current_status: match + notes: "The published dev.containers.metadata annotation carries what TS addsAdditionalTemplateProps adds while packaging: type, files, fileCount and featureIds. verify_cmd reads each side's annotation with the oracle and sorts files: TS recursive-readdir lists them in the order their asynchronous stat calls complete, which is not defined. Go used to publish the template JSON without them." + + - id: features-package.legacy-ids-current-id-success + lane: contract + command: features-package + priority: p1 + docker_required: false + setup_cmd: "OUT=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $OUT; echo OUT=$OUT" + verify_cmd: "cat ${OUT}/devcontainer-collection.json; tar -xOf ${OUT}/devcontainer-feature-new-color.tgz ./devcontainer-feature.json" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "features package reference/src/test/container-features/example-v2-features-sets/renaming-feature/src -o ${OUT}" + asserts: [exit_code] + class: features-package + current_status: match + notes: "new-color lists legacyIds, so TS addsAdditionalFeatureProps adds currentId to its metadata and rewrites the packaged devcontainer-feature.json as JSON.stringify(…, null, 4). Go used to package the file untouched and leave currentId out." + + - id: features-package.parent-of-src-error + lane: contract + command: features-package + priority: p1 + docker_required: false + setup_cmd: "OUT=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $OUT; echo OUT=$OUT" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "features package reference/src/test/container-features/example-v2-features-sets/renaming-feature -o ${OUT}" + asserts: [exit_code, stderr_normalized] + class: features-package + current_status: match + notes: "TS does not descend into src/: the only entry, src, has no devcontainer-feature.json, so it is skipped with a warning and the package fails (exit 1). Go used to descend into src/ and succeed." + + - id: features-package.missing-install-sh-error + lane: contract + command: features-package + priority: p1 + docker_required: false + setup_cmd: "OUT=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $OUT; mkdir -p $OUT/src/a; printf '%s' '{\"id\":\"a\",\"version\":\"1.0.0\",\"name\":\"A\"}' > $OUT/src/a/devcontainer-feature.json; echo OUT=$OUT" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "features package ${OUT}/src -o ${OUT}/out" + asserts: [exit_code, stderr_normalized] + class: features-package + current_status: match + notes: "A Feature without install.sh fails the package: 'Feature a is missing an install.sh', then 'Failed to package features', exit 1. Go used to package it." + + - id: features-package.existing-output-error + lane: contract + command: features-package + priority: p1 + docker_required: false + setup_cmd: "OUT=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $OUT; mkdir -p $OUT/out; echo OUT=$OUT" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "features package reference/src/test/container-features/example-v2-features-sets/renaming-feature/src -o ${OUT}/out" + asserts: [exit_code, stderr_normalized] + class: features-package + current_status: match + notes: "An output folder that exists is an error unless -f is passed: '(!) ERR: Output directory ... already exists. Manually delete, or pass -f to continue.', exit 1. Go used to package into it." + + - id: features-package.missing-target + lane: contract + command: features-package + priority: p1 + docker_required: false + ts_cmd: "features package" + asserts: [exit_code, stderr_normalized] + class: required-args + current_status: match + notes: "TS declares as a required positional: yargs stops with 'Not enough non-option arguments: got 0, need at least 1' (exit 1). Go used to default it to the current folder." + + - id: features-publish.missing-target + lane: contract + command: features-publish + priority: p1 + docker_required: false + ts_cmd: "features publish -n x" + asserts: [exit_code, stderr_normalized] + class: required-args + current_status: match + notes: "TS declares as a required positional: yargs stops with 'Not enough non-option arguments: got 0, need at least 1' (exit 1). Go used to default it to the current folder." + + - id: templates.publish-missing-target + lane: contract + command: templates + priority: p1 + docker_required: false + ts_cmd: "templates publish -n x" + asserts: [exit_code, stderr_normalized] + class: required-args + current_status: match + notes: "TS declares as a required positional: yargs stops with 'Not enough non-option arguments: got 0, need at least 1' (exit 1). Go used to default it to the current folder." + + - id: up.compose-image-without-features-wraps-entrypoint-success + lane: runtime + command: up + priority: p1 + docker_required: true + ts_cmd: "up --workspace-folder src/test/configs/compose-image-without-features --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} --remove-existing-container" + verify_cmd: "docker inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' ${CONTAINER_ID}" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f src/test/configs/compose-image-without-features/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1 || true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "TS startContainer always writes the containerFeatures override (dockerCompose.ts generateFeaturesComposeOverrideContent), with or without Features: the service entrypoint becomes the keep-alive script followed by the image's entrypoint, and the compose command is kept. Go only wrote it with Features, so the container ran the bare image entrypoint." + + - id: up.compose-merged-runtime-properties-without-features-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"updateRemoteUserUID\":false,\"capAdd\":[\"SYS_PTRACE\"],\"securityOpt\":[\"seccomp=unconfined\"],\"privileged\":true,\"init\":true,\"containerUser\":\"nobody\",\"containerEnv\":{\"FOO\":\"a$b c\"},\"mounts\":[\"type=volume,source=paritydata,target=/data\"]}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker inspect -f '{{json .HostConfig.CapAdd}} {{json .HostConfig.SecurityOpt}} {{.HostConfig.Privileged}} {{json .HostConfig.Init}} {{.Config.User}} {{range .Mounts}}{{.Type}}:{{.Name}}:{{.Destination}} {{end}}' ${CONTAINER_ID}; docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${CONTAINER_ID} | grep '^FOO='" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 --volumes >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "Without Features TS still applies the merged capAdd, securityOpt, privileged, init, containerUser, containerEnv and mounts through the containerFeatures override (dockerCompose.ts generateFeaturesComposeOverrideContent). Go applied only containerEnv and mounts, in separate overrides." + + - id: up.compose-dockerfile-without-features-update-uid-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s\\n' 'FROM alpine:3' 'RUN adduser -D -u 4321 dev' > $WORKSPACE/.devcontainer/Dockerfile; printf '%s\\n' 'services:' ' app:' ' build: .' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"remoteUser\":\"dev\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "printf 'uid=%s ' \"$(docker exec ${CONTAINER_ID} id -u dev)\"; I=$(docker inspect -f '{{.Image}}' ${CONTAINER_ID}); test -n \"$(docker image inspect -f '{{index .Config.Labels \"devcontainer.metadata\"}}' $I)\" && printf 'image-metadata=ok'" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "TS builds a Dockerfile service through a build override even without Features (getImageBuildOptions adds the devcontainer.metadata label to the image) and then runs updateRemoteUserUID on the built image (dockerCompose.ts startContainer). Go built the service unchanged and never remapped the remote user's UID for compose." + + - id: up.compose-image-with-local-feature-image-name-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/myfeature; printf '%s\\n' 'services:' ' app:' ' image: alpine:3' ' command: sleep infinity' > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"id\":\"myfeature\",\"version\":\"1.0.0\",\"name\":\"myfeature\"}' > $WORKSPACE/.devcontainer/myfeature/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'touch /feature-marker' > $WORKSPACE/.devcontainer/myfeature/install.sh; chmod +x $WORKSPACE/.devcontainer/myfeature/install.sh; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"features\":{\"./myfeature\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --buildkit=never --skip-post-create --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker inspect -f '{{.Config.Image}}' ${CONTAINER_ID}; docker exec ${CONTAINER_ID} test -f /feature-marker && printf ok" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; rm -rf ${WORKSPACE}; true" + asserts: [exit_code, stdout_normalized] + class: up-runtime + current_status: match + notes: "An image-only service with Features is built by `docker compose build` through a build override that tags the folder image name (TS buildAndExtendDockerCompose: `image: getFolderImageName`, `FROM AS dev_container_auto_added_stage_label`, Features content from the temp image without BuildKit). Go built it with a separate docker build tagged -features. The workspace path is side-independent so both sides compute the same folder image name." + + - id: read-configuration.legacy-feature-id-error + lane: contract + command: read-configuration + priority: p1 + docker_required: false + ts_cmd: "read-configuration --workspace-folder reference/src/test/configs/image-with-local-feature --include-merged-configuration" + asserts: [exit_code, stderr_normalized] + class: features + current_status: match + notes: "A bare Feature id ('myfeature') is a legacy id TS rejects ('Legacy feature myfeature not supported...'), exit 1 with no result. Go used to drop the Feature error silently and print a configuration without the Feature." + + - id: up.update-uid-remote-user-from-image-metadata-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-uid-meta-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer; printf 'FROM debian:latest\\nRUN groupadd --gid 4321 foo && useradd --uid 1234 --gid 4321 --create-home foo\\nLABEL devcontainer.metadata=\\047[{\\042remoteUser\\042:\\042foo\\042}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '{\\042image\\042:\\042%s\\042}' $IMG > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker exec -u foo ${CONTAINER_ID} sh -c 'printf %s:%s $(id -u) $(id -g)'" + cleanup_cmd: "docker rm -f $(docker ps -aq --filter label=parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}) >/dev/null 2>&1; docker rmi -f parity-uid-meta-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + ts_cmd: "up --workspace-folder ${WORKSPACE} --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + asserts: [exit_code] + class: up-runtime + current_status: match + notes: "The remote user comes from the image's devcontainer.metadata label, not the config: TS updateRemoteUserUID decides on the merged configuration, so foo is remapped to the host UID/GID. Go used to read only the config's remoteUser and left foo at 1234:4321." + + - id: read-configuration.merged-dockerfile-registry-base-success + lane: runtime + command: read-configuration + priority: p1 + docker_required: true + network_required: true + serial: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; docker rmi -f mcr.microsoft.com/devcontainers/base:alpine-3.19 >/dev/null 2>&1; printf 'ARG VARIANT=3.18\\nFROM mcr.microsoft.com/devcontainers/base:alpine-${VARIANT}\\n' > $WORKSPACE/.devcontainer/Dockerfile; printf '%s' '{\"build\":{\"dockerfile\":\"Dockerfile\",\"args\":{\"VARIANT\":\"3.19\"}},\"remoteEnv\":{\"A\":\"b\"}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker image inspect mcr.microsoft.com/devcontainers/base:alpine-3.19 >/dev/null 2>&1 && echo base-image-pulled || echo base-image-not-pulled" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + asserts: [exit_code, stdout_normalized] + class: merged-configuration + current_status: match + notes: "Without a container, mergedConfiguration starts from the Dockerfile's base image (getImageBuildInfo -> internalGetImageBuildInfoFromDockerfile, imageMetadata.ts): the FROM is resolved with the build args (VARIANT=3.19) and the image, absent locally, is read from its registry (inspectDockerImage -> inspectImageInRegistry, utils.ts) without pulling it, so its devcontainer.metadata gives remoteUser vscode. The verify_cmd checks the image was not pulled. Go used to skip the base image and leave remoteUser out. serial: the image must stay absent locally." + + - id: read-configuration.merged-image-registry-features-success + lane: runtime + command: read-configuration + priority: p1 + docker_required: true + network_required: true + serial: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; docker rmi -f mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm >/dev/null 2>&1; printf '%s' '{\"image\":\"mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm\",\"features\":{\"ghcr.io/devcontainers/features/github-cli:1\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker image inspect mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm >/dev/null 2>&1 && echo image-pulled || echo image-not-pulled" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + asserts: [exit_code, stdout_normalized] + class: merged-configuration + current_status: match + notes: "An image config whose image is not present locally: getImageBuildInfoFromImage reads the config from the registry (image index entry for the host platform), so mergedConfiguration carries the image's remoteUser node and its customizations (dbaeumer.vscode-eslint) plus the Feature's metadata (getDevcontainerMetadata). Go used to inspect only the local image and lose the image metadata. serial: the image must stay absent locally." + + - id: read-configuration.merged-compose-build-registry-base-success + lane: runtime + command: read-configuration + priority: p1 + docker_required: true + network_required: true + serial: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; docker rmi -f mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm >/dev/null 2>&1; printf 'FROM mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm\\nUSER root\\n' > $WORKSPACE/.devcontainer/Dockerfile; printf 'services:\\n app:\\n build:\\n context: .\\n dockerfile: Dockerfile\\n command: sleep infinity\\n' > $WORKSPACE/.devcontainer/compose.yml; printf '%s' '{\"dockerComposeFile\":\"compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/workspace\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + verify_cmd: "docker image inspect mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm >/dev/null 2>&1 && echo base-image-pulled || echo base-image-not-pulled" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + asserts: [exit_code, stdout_normalized] + class: merged-configuration + current_status: match + notes: "A compose service built from a Dockerfile: getImageBuildInfo reads `docker compose config`, takes the service's build (getBuildInfoForService) and reads the Dockerfile's base image from its registry, so mergedConfiguration has remoteUser node and the image's customizations. Go used to skip compose configs. serial: the image must stay absent locally." + + - id: read-configuration.merged-dockerfile-not-found + lane: runtime + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"build\":{\"dockerfile\":\"missing.Dockerfile\"}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + asserts: [exit_code, stdout_normalized] + class: merged-configuration + current_status: match + notes: "getImageBuildInfo throws 'Dockerfile () not found.' when the configured Dockerfile is missing: exit 1 and no result on stdout. Go used to print a merged configuration without the image metadata." + + - id: read-configuration.merged-image-missing + lane: runtime + command: read-configuration + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer; printf '%s' '{\"remoteUser\":\"me\"}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-merged-configuration" + asserts: [exit_code, stdout_normalized] + class: merged-configuration + current_status: match + notes: "A config with no image, Dockerfile or compose file: getImageBuildInfo throws 'No image information specified in devcontainer.json.', exit 1 and no result on stdout. Go used to print a merged configuration." + + - id: build.features-staging-layout-success + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer/hello; printf 'FROM alpine:3\\nLABEL devcontainer.metadata=\\047[{\"remoteUser\":\"nobody\"}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '%s' '{\"id\":\"hello\",\"version\":\"1.0.0\",\"name\":\"Hello\",\"options\":{\"zeta\":{\"type\":\"string\",\"default\":\"z\"},\"greeting\":{\"type\":\"string\",\"default\":\"hi\"},\"alpha\":{\"type\":\"boolean\",\"default\":true}},\"containerEnv\":{\"HELLO_HOME\":\"/opt/hello\"}}' > $WORKSPACE/.devcontainer/hello/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'set -e' '{ pwd; ls -A; cat ../devcontainer-features.builtin.env; env | grep -E \"^(_CONTAINER_USER|_REMOTE_USER|_CONTAINER_USER_HOME|_REMOTE_USER_HOME|ZETA|GREETING|ALPHA|HELLO_HOME)=\" | sort; } > /feature-out.txt' > $WORKSPACE/.devcontainer/hello/install.sh; chmod +x $WORKSPACE/.devcontainer/hello/install.sh; printf '{\"image\":\"%s\",\"containerEnv\":{\"CFG_VAR\":\"a$b\"},\"features\":{\"./hello\":{\"greeting\":\"yo\"}}}' $IMG > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker run --rm ${IMAGE_NAME} sh -c 'cat /feature-out.txt; echo ---; ls -A /tmp/dev-container-features; cat /tmp/dev-container-features/devcontainer-features.builtin.env'; docker image inspect -f '{{.Config.User}} {{json .Config.Env}}' ${IMAGE_NAME}" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + asserts: [exit_code, stdout_normalized] + class: build-runtime + current_status: match + notes: "BuildKit Features build (TS getFeaturesBuildOptions + getFeatureLayers, containerFeaturesConfiguration.ts): the Feature is staged at its consecutiveId (hello_0), bind-mounted from the dev_containers_feature_content_source build context, copied to /tmp/dev-container-features/hello_0 and installed through devcontainer-features-install.sh, which sources ../devcontainer-features.builtin.env (_CONTAINER_USER/_REMOTE_USER plus the _*_HOME lines the first RUN appends) and devcontainer-features.env (options in devcontainer-feature.json order); the folder is removed afterwards, the builtin env stays. The Feature's containerEnv and the config's containerEnv are ENV lines and the image keeps its own user (USER $_DEV_CONTAINERS_IMAGE_USER). Go used to copy _dev_container_feature_0 to /tmp/build-features and run install.sh with inline env prefixes, so ../devcontainer-features.builtin.env did not exist." + + - id: build.features-staging-layout-no-buildkit-success + lane: runtime + command: build + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer/hello; printf 'FROM alpine:3\\nLABEL devcontainer.metadata=\\047[{\"remoteUser\":\"nobody\"}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '%s' '{\"id\":\"hello\",\"version\":\"1.0.0\",\"name\":\"Hello\",\"options\":{\"zeta\":{\"type\":\"string\",\"default\":\"z\"},\"greeting\":{\"type\":\"string\",\"default\":\"hi\"},\"alpha\":{\"type\":\"boolean\",\"default\":true}},\"containerEnv\":{\"HELLO_HOME\":\"/opt/hello\"}}' > $WORKSPACE/.devcontainer/hello/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'set -e' '{ pwd; ls -A; cat ../devcontainer-features.builtin.env; env | grep -E \"^(_CONTAINER_USER|_REMOTE_USER|_CONTAINER_USER_HOME|_REMOTE_USER_HOME|ZETA|GREETING|ALPHA|HELLO_HOME)=\" | sort; } > /feature-out.txt' > $WORKSPACE/.devcontainer/hello/install.sh; chmod +x $WORKSPACE/.devcontainer/hello/install.sh; printf '{\"image\":\"%s\",\"containerEnv\":{\"CFG_VAR\":\"a$b\"},\"features\":{\"./hello\":{\"greeting\":\"yo\"}}}' $IMG > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "build --workspace-folder ${WORKSPACE} --buildkit=never --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker run --rm ${IMAGE_NAME} sh -c 'cat /feature-out.txt; echo ---; ls -A /tmp/dev-container-features /tmp/dev-container-features/hello_0; cd /tmp/dev-container-features/hello_0 && cat devcontainer-features.env && echo && cat devcontainer-features-install.sh'; docker image inspect -f '{{.Config.User}} {{json .Config.Env}}' ${IMAGE_NAME}" + cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + asserts: [exit_code, stdout_normalized] + class: build-runtime + current_status: match + notes: "Without BuildKit, TS builds the dev_container_feature_content_temp image from the features folder and COPYs /tmp/build-features/hello_0 to /tmp/dev-container-features/hello_0 (kept in the image), so verify_cmd also compares the staged devcontainer-features.env and the devcontainer-features-install.sh banner (getFeatureInstallWrapperScript). Go used to copy from the build context to /tmp/build-features/_dev_container_feature_0." + + - id: up.compose-features-staging-layout-success + lane: runtime + command: up + priority: p1 + docker_required: true + setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer/hello; printf 'FROM alpine:3\\nLABEL devcontainer.metadata=\\047[{\"remoteUser\":\"nobody\"}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '%s' '{\"id\":\"hello\",\"version\":\"1.0.0\",\"name\":\"Hello\",\"options\":{\"zeta\":{\"type\":\"string\",\"default\":\"z\"},\"greeting\":{\"type\":\"string\",\"default\":\"hi\"},\"alpha\":{\"type\":\"boolean\",\"default\":true}},\"containerEnv\":{\"HELLO_HOME\":\"/opt/hello\"}}' > $WORKSPACE/.devcontainer/hello/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'set -e' '{ pwd; ls -A; cat ../devcontainer-features.builtin.env; env | grep -E \"^(_CONTAINER_USER|_REMOTE_USER|_CONTAINER_USER_HOME|_REMOTE_USER_HOME|ZETA|GREETING|ALPHA|HELLO_HOME)=\" | sort; } > /feature-out.txt' > $WORKSPACE/.devcontainer/hello/install.sh; chmod +x $WORKSPACE/.devcontainer/hello/install.sh; printf 'services:\\n app:\\n image: %s\\n command: sleep infinity\\n' $IMG > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"containerEnv\":{\"CFG_VAR\":\"a$b\"},\"features\":{\"./hello\":{\"greeting\":\"yo\"}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE" + ts_cmd: "up --workspace-folder ${WORKSPACE} --skip-post-create --update-remote-user-uid-default never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE}" + verify_cmd: "docker exec ${CONTAINER_ID} sh -c 'cat /feature-out.txt; echo ---; ls -A /tmp/dev-container-features; cat /tmp/dev-container-features/devcontainer-features.builtin.env'; docker image inspect -f '{{.Config.User}} {{json .Config.Env}}' $(docker inspect -f '{{.Image}}' ${CONTAINER_ID})" + cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; docker rmi -f parity-feat-base-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true" + asserts: [exit_code] + class: up-runtime + current_status: match + notes: "The compose service's Features Dockerfile (appended to `FROM AS dev_container_auto_added_stage_label`) has the same structure as the image build's: builtin env, consecutiveId folders, install wrapper, the Feature's containerEnv and, like TS getFeaturesBuildOptions, the config's containerEnv as ENV lines (CFG_VAR). Go used the _dev_container_feature_ layout and left the config's containerEnv out of the compose image." + - id: upgrade.write-lockfile-to-disk-success lane: runtime command: upgrade diff --git a/go.mod b/go.mod index db0c6e8..1c04109 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/devcontainers/cli go 1.27 +toolchain go1.27.3 + require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/iancoleman/orderedmap v0.3.0 @@ -12,6 +14,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5 github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/testcontainers/testcontainers-go v0.44.0 golang.org/x/crypto v0.58.0 @@ -142,7 +145,6 @@ require ( github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.10.2 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect - github.com/spin-stack/go-tools v0.0.0-20261024185510-dfcf2c10f30b // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.12.1 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect @@ -164,12 +166,16 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/mod v0.42.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect + golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect golang.org/x/term v0.47.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.52.0 // indirect + golang.org/x/vuln v1.9.0 // indirect google.golang.org/api v0.293.0 // indirect google.golang.org/genproto v0.0.0-20260724162435-b2f20204f0df // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect @@ -182,6 +188,14 @@ require ( tool ( github.com/go-task/task/v3/cmd/task + github.com/spin-stack/go-tools/cmd/ctxlife + github.com/spin-stack/go-tools/cmd/deadgate + github.com/spin-stack/go-tools/cmd/mutate + github.com/spin-stack/go-tools/cmd/refs github.com/spin-stack/go-tools/cmd/sfw github.com/spin-stack/go-tools/cmd/taskgate + github.com/spin-stack/go-tools/cmd/testquality + github.com/spin-stack/go-tools/cmd/versions + github.com/spin-stack/go-tools/cmd/vulngate + golang.org/x/tools/cmd/deadcode ) diff --git a/go.sum b/go.sum index 84c10bf..ea2970e 100644 --- a/go.sum +++ b/go.sum @@ -309,8 +309,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/spin-stack/go-tools v0.0.0-20261024185510-dfcf2c10f30b h1:NecYdTb3idSPRsLeC0ZlfkCUehR3N0mIvnD4IklltYc= -github.com/spin-stack/go-tools v0.0.0-20261024185510-dfcf2c10f30b/go.mod h1:AjnMnKNXxvNt1n0zTkHpABmBd+NnNBqdRMSwmYu885Y= +github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5 h1:/HuzuukMLQSVOvGKvgmbB2cv4Xx/nj9T048vykOwlOE= +github.com/spin-stack/go-tools v0.0.0-20261025134518-0ee222410bc5/go.mod h1:AjnMnKNXxvNt1n0zTkHpABmBd+NnNBqdRMSwmYu885Y= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= @@ -368,6 +368,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -379,12 +381,22 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e h1:P6NGsugec4oASJ+yKMNgTndeo0h2CbixZLvQIe9h5R4= +golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e/go.mod h1:GNu+nb+E5axfGmP+Uu3ZVQu5lE/Xk42MjZOHWUD94/o= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.9.0 h1:12VaqnI9kJfqwRoPptxoqsUhQtdfrSbdksT7X/KY/sc= +golang.org/x/vuln v1.9.0/go.mod h1:E/Frwm0xp49fTyBW+6CQHdkvobknbwUM49yEpMGUauw= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w= diff --git a/internal/cli/build.go b/internal/cli/build.go index 1cbdbb2..009aefd 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -115,17 +115,12 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { if opts.workspaceFolder == "" { opts.workspaceFolder, _ = os.Getwd() } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", opts.logLevel, []string{"info", "debug", "trace"}}, - {"log-format", opts.logFormat, []string{"text", "json"}}, - {"buildkit", opts.buildkit, []string{"auto", "never"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return writeValidationError(out, err.Error()) - } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + enumFlag{"buildkit", opts.buildkit, []string{"auto", "never"}}, + ); err != nil { + return writeValidationError(out, err.Error()) } // --no-lockfile is mutually exclusive with every frozen/write lockfile flag @@ -145,9 +140,7 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { // previous `devcontainer check` found a failing configuration. warnUncheckedHost(out) - // TS folds the deprecated --experimental-frozen-lockfile into --frozen-lockfile - // (effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile). - opts.experimentalFrozenLockfile = opts.experimentalFrozenLockfile || opts.frozenLockfile + opts.foldFrozenLockfile() // Resolve paths workspaceFolder := resolvePath(opts.workspaceFolder) @@ -178,15 +171,13 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { return writeErrorResult(out, err.Error()) } - if derr := enforceDisallowedFeatures(ctx, cfg, logger); derr != nil { - return writeErrorJSON(out, coreerrors.ToErrorOutput(derr)) + // TS build reports only the message and description of this error. + if derr := enforceDisallowedFeatures(ctx, cfg, opts.additionalFeatures, logger, nil); derr != nil { + return writeErrorJSON(out, coreerrors.ErrorOutput{Outcome: "error", Message: derr.Error(), Description: derr.Description}) } // Setup Docker clients - dockerClient := docker.NewClient(opts.dockerPath, nil, logger) - // Stream `docker build` progress live to the terminal (text mode only; under - // --log-format json a raw byte stream would corrupt the structured events). - dockerClient.ProgressWriter = progressWriter(opts.logFormat, os.Stderr) + dockerClient := newBuildDockerClient(opts.dockerPath, opts.logFormat, logger) engine, err := docker.NewEngineClient(logger) if err != nil { @@ -203,25 +194,8 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { useBuildx = bk.Available } - // Validate buildx-only flags - if (opts.platform != "" || opts.push) && !useBuildx { - return writeErrorResult(out, "--platform or --push require BuildKit enabled.") - } - if opts.output != "" && opts.push { - return writeErrorResult(out, "--push true cannot be used with --output.") - } - - // Validate compose-incompatible flags - if cfg.IsComposeConfig() { - if opts.platform != "" || opts.push { - return writeErrorResult(out, "--platform or --push not supported.") - } - if opts.output != "" { - return writeErrorResult(out, "--output not supported.") - } - if opts.cacheTo != "" { - return writeErrorResult(out, "--cache-to not supported.") - } + if msg := buildFlagConflict(opts, useBuildx, cfg.IsComposeConfig()); msg != "" { + return writeErrorResult(out, msg) } var imageNameResult []string @@ -244,13 +218,51 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { })) } - return writeSuccessJSON(out, map[string]interface{}{ + return writeSuccessJSON(out, map[string]any{ "outcome": "success", "imageName": imageNameResult, "ociAuthDiagnostics": ociAuthDiagnostics(ctx), }) } +// foldFrozenLockfile folds the deprecated --experimental-frozen-lockfile into +// --frozen-lockfile, as TS does (effectiveFrozenLockfile = frozenLockfile || +// experimentalFrozenLockfile); the Feature build reads the folded value. +func (o *buildOpts) foldFrozenLockfile() { + o.experimentalFrozenLockfile = o.experimentalFrozenLockfile || o.frozenLockfile +} + +// newBuildDockerClient is the Docker CLI client a build runs `docker build` +// with. It streams the build's progress live to stderr in text mode only: under +// --log-format json a raw byte stream would corrupt the structured events. +func newBuildDockerClient(dockerPath, logFormat string, logger log.Logger) *docker.Client { + c := docker.NewClient(dockerPath, nil, logger) + c.ProgressWriter = progressWriter(logFormat, os.Stderr) + return c +} + +// buildFlagConflict is the error a build reports for flags its builder or +// config cannot honour, or "" when there is none: --platform and --push need +// BuildKit, --push cannot go with --output, and a compose config takes none of +// --platform, --push, --output or --cache-to. +func buildFlagConflict(opts *buildOpts, useBuildx, compose bool) string { + switch { + case (opts.platform != "" || opts.push) && !useBuildx: + return "--platform or --push require BuildKit enabled." + case opts.output != "" && opts.push: + return "--push true cannot be used with --output." + case !compose: + return "" + case opts.platform != "" || opts.push: + return "--platform or --push not supported." + case opts.output != "": + return "--output not supported." + case opts.cacheTo != "": + return "--cache-to not supported." + } + return "" +} + // dockerfileBuildPrep holds the resolved Dockerfile inputs shared by the `up` // and `build` Dockerfile flows. type dockerfileBuildPrep struct { @@ -324,6 +336,13 @@ func prepareDockerfileBuild(cfg *config.DevContainer) (*dockerfileBuildPrep, err return prep, nil } +// buildDockerfile builds a Dockerfile config's image, then its Features on top. +// +// mutate-lane: TestParityMatrix (build.dockerfile-multi-image-success, +// build.subfolder-config-success, build.apply-build-options-success, +// build.output-oci-success, build.cache-to-local-success, +// build.platform-supported-dockerfile-success), also run by +// TestE2E_BuildUpExec_Dockerfile and TestE2E_SiblingDockerignore. func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, useBuildx bool) ([]string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts @@ -343,21 +362,11 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai // metadata from the published image label is preserved. baseLabels := engine.ImageLabelsEnsuringPresent(ctx, baseImage) baseMetadata := imagemeta.ReadMetadataFromLabels(baseLabels, logger) - metadata := append([]imagemeta.Entry{}, baseMetadata...) - // Preserve a `LABEL devcontainer.metadata` declared in the user's Dockerfile - // (#1225): the CLI stamps its own computed metadata label below, which would - // otherwise clobber the user's entries on the built image. - metadata = append(metadata, imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger)...) - if len(cfg.Features) == 0 { - metadata = append(metadata, configToMetadataEntry(cfg)) - } - metadataLabel := imagemeta.GenerateMetadataLabel(metadata) + stageMetadata := imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger) + metadataLabel := imagemeta.GenerateMetadataLabel(dockerfileImageMetadata(cfg, baseMetadata, stageMetadata)) // Determine image names (match TS getFolderImageName: vsc-{basename}-{hash}) - imageNames := opts.imageNames - if len(imageNames) == 0 { - imageNames = []string{folderImageName(resolvePath(opts.workspaceFolder))} - } + imageNames := imageNamesOr(opts.imageNames, folderImageName(resolvePath(opts.workspaceFolder))) // Build context contextPath := configDir @@ -371,16 +380,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai // Add metadata label allLabels := append(opts.labels, fmt.Sprintf("%s=%s", imagemeta.MetadataLabel, metadataLabel)) - // When features are present the base image is an intermediate build: it must - // land in the local image store so the feature build can reference it as - // $_DEV_CONTAINERS_BASE_IMAGE. Defer --output/--push to the feature build - // (below) — applying them here would export the base to a tarball/registry - // without loading it, breaking the feature stage. - hasFeatures := len(cfg.Features) > 0 - baseOutput, basePush := opts.output, opts.push - if hasFeatures { - baseOutput, basePush = "", false - } + baseOutput, basePush := dockerfileBaseExport(opts, cfg) cacheFrom := cacheFromForDockerfileBuild(opts.cacheFrom, cfg) // Bridge the CLI credential chain to the build subprocess (private base pull / @@ -398,11 +398,7 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai if secErr != nil { return nil, fmt.Errorf("read secrets file: %w", secErr) } - if useBuildx { - buildSecrets = secrets - } else if len(secrets) > 0 { - logger.Write("--secrets-file requires BuildKit (buildx); secrets ignored for the legacy builder", log.LevelWarning) - } + buildSecrets = buildxSecrets(secrets, useBuildx, logger) } buildOpts := docker.BuildOptions{ @@ -441,33 +437,21 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai // If config has features, extend the built image with features if len(cfg.Features) > 0 { baseImageName := imageNames[0] - return extendImageWithFeatures(ctx, logger, dockerClient, engine, baseImageName, cfg.Features, useBuildx, imageNames, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - NoCache: opts.noCache, - CacheFrom: opts.cacheFrom, - CacheTo: opts.cacheTo, - Labels: opts.labels, - Platform: opts.platform, - Push: opts.push, - Output: opts.output, - ContainerEnv: cfg.ContainerEnv, - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - SkipPersistCustoms: opts.skipPersistCustoms, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - AdditionalMetadata: []imagemeta.Entry{configToMetadataEntry(cfg)}, - }) + return extendImageWithFeatures(ctx, logger, dockerClient, engine, baseImageName, cfg.Features, useBuildx, imageNames, r.featureBuildOptions(cfg, &loadResult.HostSub, configToMetadataEntry(cfg))) } return imageNames, nil } +// buildImage produces an image config's image: the image itself, or a build on +// top of it for Features, --platform/--push/--output or --image-name. +// +// mutate-lane: TestParityMatrix (build.image-success, +// build.image-multi-image-success, build.image-metadata-label-success, +// build.image-extra-cache-from-success, build.no-cache-features-success, +// build.cache-to-local-image-success, +// build.platform-supported-image-with-features-success), also run by +// TestE2E_BuildUpExec_Image. func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, useBuildx bool) ([]string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts if cfg.Image == "" { @@ -476,27 +460,15 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, // Pre-validate features before pulling the image, so local feature errors // surface immediately instead of after an expensive pull. - if len(cfg.Features) > 0 { - for id := range cfg.Features { - srcType := features.ClassifyID(id) - if srcType == features.SourceLegacyShorthand { - name := id - if idx := strings.LastIndex(id, ":"); idx > 0 { - name = id[:idx] - } - if !features.IsKnownLegacyFeature(name) { - return nil, fmt.Errorf(msgLegacyFeature, id) - } - } - } + if err := checkLegacyFeatures(cfg.Features); err != nil { + return nil, err } logger.Write(fmt.Sprintf("Using image: %s", cfg.Image), log.LevelInfo) - imageNames := opts.imageNames - if len(imageNames) == 0 { - imageNames = []string{folderImageName(resolvePath(opts.workspaceFolder))} - } + folderImage := folderImageName(resolvePath(opts.workspaceFolder)) + imageNames := imageNamesOr(opts.imageNames, folderImage) + featureImageNames := imageNamesOr(opts.imageNames, folderImage+"-features") // Ensure image exists locally _, err := engine.InspectImage(ctx, cfg.Image) @@ -507,69 +479,13 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, } } - if len(cfg.Features) > 0 { - featureImageNames := opts.imageNames - if len(featureImageNames) == 0 { - featureImageNames = []string{folderImageName(resolvePath(opts.workspaceFolder)) + "-features"} - } - return extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, featureImageNames, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - NoCache: opts.noCache, - CacheFrom: opts.cacheFrom, - CacheTo: opts.cacheTo, - Labels: opts.labels, - Platform: opts.platform, - Push: opts.push, - Output: opts.output, - ContainerEnv: cfg.ContainerEnv, - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - SkipPersistCustoms: opts.skipPersistCustoms, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - }) - } + switch imageBuildFor(cfg, opts) { + case imageWithFeatures: + return extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, featureImageNames, r.featureBuildOptions(cfg, &loadResult.HostSub)) - // When --platform, --push, or --output is specified, route through the feature - // extension path to generate a proper Dockerfile for buildx. When features exist, - // extendImageWithFeatures handles everything. When no features, build a minimal - // Dockerfile directly (extendImageWithFeatures returns early with 0 features). - if opts.platform != "" || opts.push || opts.output != "" { - featureImageNames := opts.imageNames - if len(featureImageNames) == 0 { - featureImageNames = []string{folderImageName(resolvePath(opts.workspaceFolder)) + "-features"} - } - - if len(cfg.Features) > 0 { - return extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, featureImageNames, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - NoCache: opts.noCache, - CacheFrom: opts.cacheFrom, - CacheTo: opts.cacheTo, - Labels: opts.labels, - Platform: opts.platform, - Push: opts.push, - Output: opts.output, - ContainerEnv: cfg.ContainerEnv, - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - SkipPersistCustoms: opts.skipPersistCustoms, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - }) - } - - // 0 features + platform/push: build a minimal FROM Dockerfile with config metadata. + case imageExported: + // --platform, --push, or --output still need a build: a minimal FROM + // Dockerfile carrying the config metadata. metadata := []imagemeta.Entry{configToMetadataEntry(cfg)} metadataLabel := imagemeta.GenerateMetadataLabel(metadata) allLabels := append(opts.labels, fmt.Sprintf("%s=%s", imagemeta.MetadataLabel, metadataLabel)) @@ -612,10 +528,9 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, logger.Write(string(result.Stderr), log.LevelInfo) } return featureImageNames, nil - } - // No platform/push — use temp Dockerfile when imageNames require labeling - if len(opts.imageNames) > 0 { + case imageRelabeled: + // No platform/push — use temp Dockerfile when imageNames require labeling metadata := []imagemeta.Entry{configToMetadataEntry(cfg)} metadataLabel := imagemeta.GenerateMetadataLabel(metadata) allLabels := append(opts.labels, fmt.Sprintf("%s=%s", imagemeta.MetadataLabel, metadataLabel)) @@ -651,10 +566,96 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, } // No image names, no platform/push — return base image as-is - return imageNames, nil } +// imageBuild is what `build` makes of an image config. +type imageBuild int + +const ( + imageAsIs imageBuild = iota // the image itself, under the folder's image name + imageWithFeatures // a Feature build on top of the image + imageExported // a FROM build for --platform, --push or --output + imageRelabeled // a FROM build stamping the metadata label under --image-name +) + +// imageBuildFor is what `build` makes of the image config cfg: Features win, +// then --platform/--push/--output, then --image-name. +func imageBuildFor(cfg *config.DevContainer, opts *buildOpts) imageBuild { + switch { + case len(cfg.Features) > 0: + return imageWithFeatures + case opts.platform != "" || opts.push || opts.output != "": + return imageExported + case len(opts.imageNames) > 0: + return imageRelabeled + } + return imageAsIs +} + +// checkLegacyFeatures fails on a bare-id (legacy) Feature the CLI no longer +// maps to a published one; a tag (go:1) is not part of the name looked up. +func checkLegacyFeatures(feats map[string]any) error { + for id := range feats { + if features.ClassifyID(id) != features.SourceLegacyShorthand { + continue + } + name := id + if i := strings.LastIndexByte(id, ':'); i != -1 { + name = id[:i] + } + if !features.IsKnownLegacyFeature(name) { + return fmt.Errorf(msgLegacyFeature, id) + } + } + return nil +} + +// imageNamesOr is the --image-name values, or fallback when none was given. +func imageNamesOr(names []string, fallback string) []string { + if len(names) == 0 { + return []string{fallback} + } + return names +} + +// dockerfileImageMetadata is the metadata a Dockerfile build stamps on its +// image: the base image's, then a `LABEL devcontainer.metadata` the user's +// Dockerfile declares (#1225; the CLI's own label would otherwise clobber it), +// then the config's entry. With Features the Feature build appends that entry +// instead. +func dockerfileImageMetadata(cfg *config.DevContainer, base, stage []imagemeta.Entry) []imagemeta.Entry { + metadata := append(append([]imagemeta.Entry{}, base...), stage...) + if len(cfg.Features) == 0 { + metadata = append(metadata, configToMetadataEntry(cfg)) + } + return metadata +} + +// dockerfileBaseExport is the --output and --push the Dockerfile build applies. +// With Features the base image is an intermediate build: it must land in the +// local image store so the Feature build can reference it as +// $_DEV_CONTAINERS_BASE_IMAGE, so --output/--push are left to the Feature build; +// applying them here would export the base without loading it. +func dockerfileBaseExport(opts *buildOpts, cfg *config.DevContainer) (output string, push bool) { + if len(cfg.Features) > 0 { + return "", false + } + return opts.output, opts.push +} + +// buildxSecrets is the --secrets-file secrets passed to BuildKit. The legacy +// builder takes none, so without buildx they are dropped with a warning. +func buildxSecrets(secrets []string, useBuildx bool, logger log.Logger) []string { + if useBuildx { + return secrets + } + if len(secrets) > 0 { + logger.Write("--secrets-file requires BuildKit (buildx); secrets ignored for the legacy builder", log.LevelWarning) + } + return nil +} + // cacheFromForDockerfileBuild combines the --cache-from flag values with the // devcontainer.json build.cacheFrom, in that order — matching TS singleContainer, // which pushes the additional (flag) cache-froms first and then config.build.cacheFrom @@ -678,15 +679,6 @@ func buildArgsFromConfig(cfg *config.DevContainer) map[string]string { return cfg.Build.Args } -func dockerfileBuildMetadataEntries(cfg *config.DevContainer, baseLabels map[string]string, logger log.Logger) []imagemeta.Entry { - metadata := []imagemeta.Entry{} - if len(baseLabels) > 0 { - metadata = append(metadata, imagemeta.ReadMetadataFromLabels(baseLabels, logger)...) - } - metadata = append(metadata, configToMetadataEntry(cfg)) - return metadata -} - // --- Output helpers --- func resolvePath(p string) string { @@ -704,7 +696,7 @@ func buildOptionsFromConfig(cfg *config.DevContainer) []string { return cfg.Build.Options } -func writeSuccessJSON(out Output, data map[string]interface{}) error { +func writeSuccessJSON(out Output, data map[string]any) error { b, _ := json.Marshal(data) fmt.Fprintln(out.Stdout(), string(b)) return nil @@ -739,6 +731,15 @@ func isUserFacingBuildError(err error) bool { return strings.HasPrefix(msg, legacyFeaturePrefix) } +// buildCompose builds a compose config's service image, then its Features on +// top, or re-tags it to apply --label and --image-name. +// +// mutate-lane: TestParityMatrix (build.compose-dockerfile-multi-image-success, +// build.compose-image-without-features-minimal-success, +// build.compose-image-without-features-minimal-metadata-label-success, +// build.compose-image-with-features-success, +// build.compose-image-extra-cache-from-success), also run by +// TestE2E_ComposeBuildLabel. func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer, useBuildx bool) ([]string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts if cfg.Service == "" { @@ -769,10 +770,7 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer composeClient.UsesNewProjectNames(), ) - baseImageName := fmt.Sprintf("%s-%s", projectName, cfg.Service) - if imageName, ok := serviceConfig["image"].(string); ok && imageName != "" { - baseImageName = imageName - } + baseImageName := composeBaseImageName(projectName, cfg.Service, serviceConfig) _, hasBuild := serviceConfig["build"] if hasBuild { @@ -792,50 +790,13 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer } } } - imageNames := opts.imageNames - if len(imageNames) == 0 { - imageNames = []string{baseImageName + "-features"} - } - return extendImageWithFeatures(ctx, logger, dockerClient, engine, baseImageName, cfg.Features, useBuildx, imageNames, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - NoCache: opts.noCache, - CacheFrom: opts.cacheFrom, - CacheTo: opts.cacheTo, - Labels: opts.labels, - Platform: opts.platform, - Push: opts.push, - Output: opts.output, - ContainerEnv: cfg.ContainerEnv, - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - SkipPersistCustoms: opts.skipPersistCustoms, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - }) + imageNames := imageNamesOr(opts.imageNames, baseImageName+"-features") + return extendImageWithFeatures(ctx, logger, dockerClient, engine, baseImageName, cfg.Features, useBuildx, imageNames, r.featureBuildOptions(cfg, nil)) } imageName := baseImageName - // Apply image labels via a FROM re-tag. compose build itself takes no --label, - // so without this a --label with no --image-name is silently dropped - // (upstream #930). With --image-name we also stamp the devcontainer.metadata - // label onto the distinct output image; a bare re-tag (same name) carries only - // the user's --label. - if len(opts.imageNames) > 0 || len(opts.labels) > 0 { - tags := opts.imageNames - labels := append([]string{}, opts.labels...) - if len(tags) == 0 { - tags = []string{imageName} - } else { - metadata := []imagemeta.Entry{configToMetadataEntry(cfg)} - metadataLabel := imagemeta.GenerateMetadataLabel(metadata) - labels = append(labels, fmt.Sprintf("%s=%s", imagemeta.MetadataLabel, metadataLabel)) - } - + if tags, labels, ok := composeRetag(cfg, opts, imageName); ok { tmpDir, tmpErr := os.MkdirTemp("", "devcontainer-compose-image-build-") if tmpErr != nil { return nil, fmt.Errorf("create temp dir: %w", tmpErr) @@ -865,14 +826,71 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer return []string{imageName}, nil } -func composeServiceConfig(composeConfig map[string]interface{}, service string) map[string]interface{} { - services, ok := composeConfig["services"].(map[string]interface{}) +// composeBaseImageName is the image of the compose service: its `image`, or +// compose's own - name for a service that only builds. +func composeBaseImageName(projectName, service string, serviceConfig map[string]any) string { + if image, ok := serviceConfig["image"].(string); ok && image != "" { + return image + } + return fmt.Sprintf("%s-%s", projectName, service) +} + +// composeRetag is the FROM re-tag that applies image labels to a compose image, +// and ok is false when no flag asks for one. compose build itself takes no +// --label, so without it a --label with no --image-name is silently dropped +// (upstream #930). With --image-name the devcontainer.metadata label is stamped +// onto the distinct output image too; a bare re-tag under the same name carries +// only the user's --label. +func composeRetag(cfg *config.DevContainer, opts *buildOpts, imageName string) (tags, labels []string, ok bool) { + if len(opts.imageNames) == 0 && len(opts.labels) == 0 { + return nil, nil, false + } + labels = append([]string{}, opts.labels...) + if len(opts.imageNames) == 0 { + return []string{imageName}, labels, true + } + metadataLabel := imagemeta.GenerateMetadataLabel([]imagemeta.Entry{configToMetadataEntry(cfg)}) + return opts.imageNames, append(labels, fmt.Sprintf("%s=%s", imagemeta.MetadataLabel, metadataLabel)), true +} + +func composeServiceConfig(composeConfig map[string]any, service string) map[string]any { + services, ok := composeConfig["services"].(map[string]any) if !ok { return nil } - svc, ok := services[service].(map[string]interface{}) + svc, ok := services[service].(map[string]any) if !ok { return nil } return svc } + +// featureBuildOptions are the Feature install options every build path passes: +// the build flags and the config's Feature settings. hostSub resolves the +// Features' ${localEnv:…} references (nil leaves them as written); +// additionalMetadata is appended to the image's metadata label. +func (r *buildRunner) featureBuildOptions(cfg *config.DevContainer, hostSub *config.HostSubContext, additionalMetadata ...imagemeta.Entry) *FeatureBuildOptions { + opts := r.opts + return &FeatureBuildOptions{ + Secrets: buildSecretsFromFile(opts.secretsFile), + OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, + NoCache: opts.noCache, + CacheFrom: opts.cacheFrom, + CacheTo: opts.cacheTo, + Labels: opts.labels, + Platform: opts.platform, + Push: opts.push, + Output: opts.output, + ContainerEnv: cfg.ContainerEnv, + SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, + Lockfile: opts.experimentalLockfile, + FrozenLockfile: opts.experimentalFrozenLockfile, + NoLockfile: opts.noLockfile, + ConfigPath: cfg.ConfigFilePath, + LockfileExcludeIDs: opts.lockfileExcludeIDs, + HostSub: hostSub, + SkipPersistCustoms: opts.skipPersistCustoms, + FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), + AdditionalMetadata: additionalMetadata, + } +} diff --git a/internal/cli/build_auth.go b/internal/cli/build_auth.go index dbc9fae..f5ffa32 100644 --- a/internal/cli/build_auth.go +++ b/internal/cli/build_auth.go @@ -1,7 +1,6 @@ package cli import ( - "os" "strings" "github.com/devcontainers/cli/internal/log" @@ -15,14 +14,11 @@ import ( // build's ambient auth untouched. Errors are swallowed (best-effort): a build // that would have worked anonymously must not fail because auth bridging did. func bridgeBuildAuth(logger log.Logger, baseImage string, tags, cacheFrom []string, cacheTo string) (env []string, cleanup func()) { - noop := func() {} + // With no registry to bridge, ResolveBuildAuth resolves nothing and is not ok. regs := collectBuildRegistries(baseImage, tags, cacheFrom, cacheTo) - if len(regs) == 0 { - return nil, noop - } - dir, clean, ok, err := oci.ResolveBuildAuth(environMap(), regs, logger) + dir, clean, ok, err := oci.ResolveBuildAuth(osEnvMap(), regs, logger) if err != nil || !ok { - return nil, noop + return nil, func() {} } return []string{"DOCKER_CONFIG=" + dir}, clean } @@ -77,22 +73,10 @@ func registryCacheRef(spec string) string { if spec == "" || !strings.Contains(spec, "=") { return spec } - for _, part := range strings.Split(spec, ",") { - if strings.HasPrefix(part, "ref=") { - return strings.TrimPrefix(part, "ref=") + for part := range strings.SplitSeq(spec, ",") { + if after, ok := strings.CutPrefix(part, "ref="); ok { + return after } } return "" } - -// environMap snapshots the process environment as a map for the credential chain. -func environMap() map[string]string { - env := os.Environ() - m := make(map[string]string, len(env)) - for _, kv := range env { - if i := strings.IndexByte(kv, '='); i >= 0 { - m[kv[:i]] = kv[i+1:] - } - } - return m -} diff --git a/internal/cli/build_auth_held_test.go b/internal/cli/build_auth_held_test.go new file mode 100644 index 0000000..5a6be44 --- /dev/null +++ b/internal/cli/build_auth_held_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// isolateBuildAuth points the credential chain at an empty docker config, so +// only DEVCONTAINERS_OCI_AUTH can supply credentials. +func isolateBuildAuth(t *testing.T, ociAuth string) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_CONFIG", dir) + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("DEVCONTAINERS_OCI_AUTH", ociAuth) +} + +// A build whose registries have no credentials keeps its ambient auth: no +// DOCKER_CONFIG is handed to the build. +func TestBridgeBuildAuthWithoutCredentials(t *testing.T) { + isolateBuildAuth(t, "") + env, cleanup := bridgeBuildAuth(log.Null, "reg.example.com/base:1", nil, nil, "") + defer cleanup() + if env != nil { + t.Errorf("env = %q, want nil when nothing resolves", env) + } +} + +// Credentials that resolve are bridged through a temporary DOCKER_CONFIG. +func TestBridgeBuildAuthWithCredentials(t *testing.T) { + isolateBuildAuth(t, "reg.example.com|alice|secret") + env, cleanup := bridgeBuildAuth(log.Null, "reg.example.com/base:1", nil, nil, "") + defer cleanup() + if len(env) != 1 { + t.Fatalf("env = %q, want one DOCKER_CONFIG entry", env) + } + dir, ok := strings.CutPrefix(env[0], "DOCKER_CONFIG=") + if !ok { + t.Fatalf("env = %q, want DOCKER_CONFIG=", env) + } + if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil { + t.Errorf("bridged config missing: %v", err) + } +} diff --git a/internal/cli/build_held_test.go b/internal/cli/build_held_test.go new file mode 100644 index 0000000..a993468 --- /dev/null +++ b/internal/cli/build_held_test.go @@ -0,0 +1,269 @@ +package cli + +import ( + "bytes" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" +) + +func discardLogger() log.Logger { return log.New(log.Options{Writer: io.Discard}) } + +// The deprecated --experimental-frozen-lockfile and --frozen-lockfile both +// make the Feature build verify the lockfile, as TS effectiveFrozenLockfile. +func TestBuildFrozenLockfileReachesTheFeatureBuild(t *testing.T) { + for _, tc := range []struct { + name string + frozen, experimental bool + want bool + }{ + {"neither", false, false, false}, + {"frozen-lockfile", true, false, true}, + {"experimental-frozen-lockfile", false, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := &buildOpts{frozenLockfile: tc.frozen, experimentalFrozenLockfile: tc.experimental} + opts.foldFrozenLockfile() + got := (&buildRunner{opts: opts}).featureBuildOptions(&config.DevContainer{}, nil).FrozenLockfile + if got != tc.want { + t.Errorf("FrozenLockfile = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBuildDockerClientStreamsProgressOnlyInTextMode(t *testing.T) { + if w := newBuildDockerClient("", "text", discardLogger()).ProgressWriter; w != os.Stderr { + t.Errorf("text mode ProgressWriter = %v, want os.Stderr", w) + } + if w := newBuildDockerClient("", "json", discardLogger()).ProgressWriter; w != nil { + t.Errorf("json mode ProgressWriter = %v, want nil (a raw stream corrupts JSON events)", w) + } +} + +func TestBuildFlagConflict(t *testing.T) { + const ( + needsBuildKit = "--platform or --push require BuildKit enabled." + pushWithOutput = "--push true cannot be used with --output." + composePlatform = "--platform or --push not supported." + composeOutput = "--output not supported." + composeCacheTo = "--cache-to not supported." + ) + for _, tc := range []struct { + name string + opts buildOpts + useBuildx bool + compose bool + want string + }{ + {"no flags", buildOpts{}, false, false, ""}, + {"platform without buildkit", buildOpts{platform: "linux/amd64"}, false, false, needsBuildKit}, + {"push without buildkit", buildOpts{push: true}, false, false, needsBuildKit}, + {"platform with buildkit", buildOpts{platform: "linux/amd64"}, true, false, ""}, + {"push with buildkit", buildOpts{push: true}, true, false, ""}, + {"output without buildkit", buildOpts{output: "type=oci,dest=x.tar"}, false, false, ""}, + {"push and output", buildOpts{push: true, output: "type=oci,dest=x.tar"}, true, false, pushWithOutput}, + {"cache-to on a single container", buildOpts{cacheTo: "type=local,dest=x"}, false, false, ""}, + {"compose without flags", buildOpts{}, true, true, ""}, + {"compose platform", buildOpts{platform: "linux/amd64"}, true, true, composePlatform}, + {"compose push", buildOpts{push: true}, true, true, composePlatform}, + {"compose output", buildOpts{output: "type=local,dest=x"}, false, true, composeOutput}, + {"compose cache-to", buildOpts{cacheTo: "type=local,dest=x"}, false, true, composeCacheTo}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := buildFlagConflict(&tc.opts, tc.useBuildx, tc.compose); got != tc.want { + t.Errorf("buildFlagConflict = %q, want %q", got, tc.want) + } + }) + } +} + +func TestImageBuildFor(t *testing.T) { + oneFeature := map[string]any{"ghcr.io/devcontainers/features/go:1": map[string]any{}} + for _, tc := range []struct { + name string + features map[string]any + opts buildOpts + want imageBuild + }{ + {"plain image", nil, buildOpts{}, imageAsIs}, + {"features win over platform", oneFeature, buildOpts{platform: "linux/amd64", imageNames: []string{"a"}}, imageWithFeatures}, + {"platform", nil, buildOpts{platform: "linux/amd64"}, imageExported}, + {"push", nil, buildOpts{push: true}, imageExported}, + {"output wins over image name", nil, buildOpts{output: "type=oci,dest=x.tar", imageNames: []string{"a"}}, imageExported}, + {"image name", nil, buildOpts{imageNames: []string{"a"}}, imageRelabeled}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := imageBuildFor(&config.DevContainer{Features: tc.features}, &tc.opts); got != tc.want { + t.Errorf("imageBuildFor = %d, want %d", got, tc.want) + } + }) + } +} + +func TestCheckLegacyFeatures(t *testing.T) { + for _, id := range []string{"node", "node:1", "golang", "ghcr.io/devcontainers/features/node:1", "./local-feature", "owner/repo/feature"} { + if err := checkLegacyFeatures(map[string]any{id: true}); err != nil { + t.Errorf("%s: unexpected error %v", id, err) + } + } + for _, id := range []string{"not-a-feature", "not-a-feature:1"} { + err := checkLegacyFeatures(map[string]any{id: true}) + if err == nil || !strings.Contains(err.Error(), "Legacy feature '"+id+"' not supported") { + t.Errorf("%s: error = %v, want the legacy feature error naming it", id, err) + } + } +} + +func TestImageNamesOr(t *testing.T) { + if got := imageNamesOr(nil, "vsc-x"); !slices.Equal(got, []string{"vsc-x"}) { + t.Errorf("no names: %v, want the fallback", got) + } + for _, names := range [][]string{{"a"}, {"a", "b"}} { + if got := imageNamesOr(names, "vsc-x"); !slices.Equal(got, names) { + t.Errorf("%v: got %v, want the given names", names, got) + } + } +} + +func TestDockerfileImageMetadata(t *testing.T) { + base := []imagemeta.Entry{{ID: "base"}} + stage := []imagemeta.Entry{{ID: "stage"}} + ids := func(entries []imagemeta.Entry) []string { + var out []string + for _, e := range entries { + out = append(out, e.ID+"/"+e.RemoteUser) + } + return out + } + + cfg := &config.DevContainer{RemoteUser: "dev"} + if got, want := ids(dockerfileImageMetadata(cfg, base, stage)), []string{"base/", "stage/", "/dev"}; !slices.Equal(got, want) { + t.Errorf("without features: %v, want %v", got, want) + } + + cfg.Features = map[string]any{"ghcr.io/devcontainers/features/go:1": true} + if got, want := ids(dockerfileImageMetadata(cfg, base, stage)), []string{"base/", "stage/"}; !slices.Equal(got, want) { + t.Errorf("with features (the Feature build adds the config entry): %v, want %v", got, want) + } +} + +func TestDockerfileBaseExport(t *testing.T) { + opts := &buildOpts{output: "type=oci,dest=x.tar", push: true} + if output, push := dockerfileBaseExport(opts, &config.DevContainer{}); output != opts.output || !push { + t.Errorf("without features: (%q, %v), want the flags", output, push) + } + withFeature := &config.DevContainer{Features: map[string]any{"ghcr.io/devcontainers/features/go:1": true}} + if output, push := dockerfileBaseExport(opts, withFeature); output != "" || push { + t.Errorf("with features: (%q, %v), want them left to the Feature build", output, push) + } +} + +func TestBuildxSecrets(t *testing.T) { + secrets := []string{"TOKEN=x"} + var logged bytes.Buffer + logger := log.New(log.Options{Writer: &logged, Level: log.LevelTrace}) + + if got := buildxSecrets(secrets, true, logger); !slices.Equal(got, secrets) { + t.Errorf("buildx: %v, want %v", got, secrets) + } + if got := buildxSecrets(nil, false, logger); got != nil || logged.Len() != 0 { + t.Errorf("legacy builder, no secrets: %v, logged %q; want nothing", got, logged.String()) + } + if got := buildxSecrets(secrets, false, logger); got != nil { + t.Errorf("legacy builder: %v, want the secrets dropped", got) + } + if !strings.Contains(logged.String(), "--secrets-file requires BuildKit") { + t.Errorf("log = %q, want the dropped-secrets warning", logged.String()) + } +} + +func TestComposeBaseImageName(t *testing.T) { + for _, tc := range []struct { + name string + service map[string]any + want string + }{ + {"service image", map[string]any{"image": "alpine:3"}, "alpine:3"}, + {"empty image", map[string]any{"image": ""}, "proj-app"}, + {"build only", map[string]any{"build": map[string]any{}}, "proj-app"}, + {"no service config", nil, "proj-app"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := composeBaseImageName("proj", "app", tc.service); got != tc.want { + t.Errorf("composeBaseImageName = %q, want %q", got, tc.want) + } + }) + } +} + +func TestComposeRetag(t *testing.T) { + cfg := &config.DevContainer{RemoteUser: "dev"} + isMetadata := func(l string) bool { return strings.HasPrefix(l, imagemeta.MetadataLabel+"=") } + + if _, _, ok := composeRetag(cfg, &buildOpts{}, "proj-app"); ok { + t.Error("no --label or --image-name: want no re-tag") + } + + tags, labels, ok := composeRetag(cfg, &buildOpts{labels: []string{"a=1"}}, "proj-app") + if !ok || !slices.Equal(tags, []string{"proj-app"}) || !slices.Equal(labels, []string{"a=1"}) { + t.Errorf("--label only: (%v, %v, %v), want the service image re-tagged with the user's label", tags, labels, ok) + } + + tags, labels, ok = composeRetag(cfg, &buildOpts{imageNames: []string{"out:1"}}, "proj-app") + if !ok || !slices.Equal(tags, []string{"out:1"}) || len(labels) != 1 || !isMetadata(labels[0]) || !strings.Contains(labels[0], `"remoteUser":"dev"`) { + t.Errorf("--image-name only: (%v, %v, %v), want the name with the config's metadata label", tags, labels, ok) + } + + tags, labels, ok = composeRetag(cfg, &buildOpts{imageNames: []string{"out:1", "out:2"}, labels: []string{"a=1"}}, "proj-app") + if !ok || !slices.Equal(tags, []string{"out:1", "out:2"}) || len(labels) != 2 || labels[0] != "a=1" || !isMetadata(labels[1]) { + t.Errorf("both: (%v, %v, %v), want the names, the user's label then the metadata label", tags, labels, ok) + } +} + +// --buildkit never skips BuildKit detection, so the build runs as if buildx +// were absent even when the Docker CLI has it. The fake --docker-path reports +// buildx, and both cases stop at the flag check before any Docker build. +func TestRunBuildBuildKitNeverIgnoresBuildx(t *testing.T) { + bin := t.TempDir() + docker := filepath.Join(bin, "docker") + if err := os.WriteFile(docker, []byte("#!/bin/sh\necho 'github.com/docker/buildx v0.20.0'\n"), 0o755); err != nil { + t.Fatal(err) + } + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, ".devcontainer.json"), []byte(`{"image":"alpine:3"}`), 0o644); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + buildkit string + want string + }{ + {"auto", "--push true cannot be used with --output."}, + {"never", "--platform or --push require BuildKit enabled."}, + } { + t.Run(tc.buildkit, func(t *testing.T) { + out := &captureOutput{} + err := runBuild(t.Context(), out, &buildOpts{ + workspaceFolder: ws, + dockerPath: docker, + logLevel: "info", + logFormat: "json", + buildkit: tc.buildkit, + push: true, + output: "type=oci,dest=out.tar", + }) + asExitCode(t, err) + if !strings.Contains(out.out.String(), tc.want) { + t.Errorf("stdout = %q, want %q", out.out.String(), tc.want) + } + }) + } +} diff --git a/internal/cli/build_nodocker_held_test.go b/internal/cli/build_nodocker_held_test.go new file mode 100644 index 0000000..7083a41 --- /dev/null +++ b/internal/cli/build_nodocker_held_test.go @@ -0,0 +1,43 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A build whose image or Dockerfile step fails before any Docker call reports +// the failure on stdout and exits 1, never the success envelope: a legacy +// Feature id as the bare message, any other failure as a container error. +func TestRunBuildReportsAFailedBuildStep(t *testing.T) { + for name, tc := range map[string]struct { + config string + want string + }{ + "legacy feature": {`{"image":"alpine:3","features":{"not-a-known-legacy-feature":{}}}`, `"message":"Legacy feature 'not-a-known-legacy-feature' not supported.`}, + "missing dockerfile": {`{"build":{"dockerfile":"missing.Dockerfile"}}`, "An error occurred building the container"}, + } { + t.Run(name, func(t *testing.T) { + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, ".devcontainer.json"), []byte(tc.config), 0o644); err != nil { + t.Fatal(err) + } + out := &captureOutput{} + err := runBuild(t.Context(), out, &buildOpts{ + workspaceFolder: ws, + dockerPath: filepath.Join(t.TempDir(), "no-docker"), + logLevel: "info", + logFormat: "json", + buildkit: "never", + }) + if ece := asExitCode(t, err); ece.Code != 1 { + t.Errorf("exit code = %d, want 1", ece.Code) + } + stdout := out.out.String() + if !strings.Contains(stdout, `"outcome":"error"`) || !strings.Contains(stdout, tc.want) { + t.Errorf("stdout = %q, want an error envelope with %q", stdout, tc.want) + } + }) + } +} diff --git a/internal/cli/check.go b/internal/cli/check.go index 35fd495..accfd7b 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "slices" "text/tabwriter" "github.com/devcontainers/cli/internal/doctor" @@ -104,11 +105,22 @@ func runSetup(ctx context.Context, out Output, opts *setupOpts) error { // Re-run diagnostics after remediation so the persisted state reflects the // fixed system (skip when nothing was applied or in dry-run). final := report - if !opts.dryRun && appliedAny(actions) { + if setupNeedsRecheck(opts.dryRun, actions) { final = doctor.Run(ctx, env) } - saveErr := doctor.Save(final) + return writeSetupResult(out, opts, actions, final, doctor.Save(final)) +} + +// setupNeedsRecheck reports whether setup changed the system, so the +// diagnostics must run again before their state is saved and shown. +func setupNeedsRecheck(dryRun bool, actions []doctor.Action) bool { + return !dryRun && slices.ContainsFunc(actions, func(a doctor.Action) bool { return a.Applied }) +} +// writeSetupResult prints what setup did and the final report, as JSON or as +// text (with a note when the check state could not be saved), and fails the +// command with exit code 1 when a remediation failed. +func writeSetupResult(out Output, opts *setupOpts, actions []doctor.Action, final doctor.Report, saveErr error) error { if opts.json { payload := struct { DryRun bool `json:"dry_run"` @@ -133,15 +145,6 @@ func runSetup(ctx context.Context, out Output, opts *setupOpts) error { return nil } -func appliedAny(actions []doctor.Action) bool { - for _, a := range actions { - if a.Applied { - return true - } - } - return false -} - // 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) @@ -208,7 +211,7 @@ func warnUncheckedHost(out Output) { } // isTerminalWriter reports whether w is a character device (a real terminal). -func isTerminalWriter(w io.Writer) bool { +func isTerminalWriter(w any) bool { f, ok := w.(*os.File) if !ok { return false diff --git a/internal/cli/check_held_test.go b/internal/cli/check_held_test.go new file mode 100644 index 0000000..00ecbfb --- /dev/null +++ b/internal/cli/check_held_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/doctor" +) + +// brokenStdout is an Output whose stdout refuses every write. +type brokenStdout struct{ captureOutput } + +type refusingWriter struct{} + +func (refusingWriter) Write([]byte) (int, error) { return 0, errors.New("stdout closed") } + +func (b *brokenStdout) Stdout() io.Writer { return refusingWriter{} } + +// Setup checks the system again only when it changed something for real: not +// in a dry run, and not when every action was a manual hint. +func TestSetupNeedsRecheck(t *testing.T) { + applied := []doctor.Action{{Name: "manual"}, {Name: "builder", Applied: true}} + manual := []doctor.Action{{Name: "manual"}} + cases := []struct { + name string + dryRun bool + actions []doctor.Action + want bool + }{ + {"applied", false, applied, true}, + {"dry run", true, applied, false}, + {"nothing applied", false, manual, false}, + {"no actions", false, nil, false}, + } + for _, c := range cases { + if got := setupNeedsRecheck(c.dryRun, c.actions); got != c.want { + t.Errorf("%s: setupNeedsRecheck = %v, want %v", c.name, got, c.want) + } + } +} + +// --json prints the dry-run flag, the actions and the report as one document, +// and a failed remediation still exits 1 after printing it. +func TestWriteSetupResultJSON(t *testing.T) { + out := &captureOutput{} + actions := []doctor.Action{{Name: "buildx", Err: "boom"}} + err := writeSetupResult(out, &setupOpts{json: true, dryRun: true}, actions, sampleReport(), nil) + if exitCode(err) != 1 { + t.Fatalf("err = %v, want exit code 1 for a failed remediation", err) + } + var got struct { + DryRun bool `json:"dry_run"` + Actions []doctor.Action `json:"actions"` + Report doctor.Report `json:"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.DryRun || len(got.Actions) != 1 || got.Actions[0].Err != "boom" || len(got.Report.Results) != 2 { + t.Errorf("payload = %+v", got) + } + if out.err.Len() != 0 { + t.Errorf("stderr = %q, want nothing in JSON mode", out.err.String()) + } +} + +// A stdout that cannot be written fails the command with that error. +func TestWriteSetupResultJSONWriteError(t *testing.T) { + err := writeSetupResult(&brokenStdout{}, &setupOpts{json: true}, nil, sampleReport(), nil) + if err == nil || !strings.Contains(err.Error(), "stdout closed") { + t.Fatalf("err = %v, want the write error", err) + } +} + +// Text mode prints the actions and the table, notes on stderr a state that +// could not be saved, and succeeds when no remediation failed. +func TestWriteSetupResultText(t *testing.T) { + out := &captureOutput{} + actions := []doctor.Action{{Name: "build-cache-export", Applied: true, Message: "created builder"}} + if err := writeSetupResult(out, &setupOpts{}, actions, sampleReport(), errors.New("read-only home")); err != nil { + t.Fatalf("err = %v, want success", err) + } + if s := out.out.String(); !strings.Contains(s, "✔ build-cache-export") || !strings.Contains(s, "overall: ⚠ warn") { + t.Errorf("stdout = %q", s) + } + if got := out.err.String(); got != "note: could not save check state: read-only home\n" { + t.Errorf("stderr = %q", got) + } + + saved := &captureOutput{} + if err := writeSetupResult(saved, &setupOpts{}, actions, sampleReport(), nil); err != nil { + t.Fatal(err) + } + if saved.err.Len() != 0 { + t.Errorf("stderr = %q, want no note when the state was saved", saved.err.String()) + } +} diff --git a/internal/cli/check_terminal_held_test.go b/internal/cli/check_terminal_held_test.go new file mode 100644 index 0000000..c0909e1 --- /dev/null +++ b/internal/cli/check_terminal_held_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +// A character device, as a terminal is, counts as a terminal writer; a regular +// file does not. +func TestIsTerminalWriterChecksTheFileMode(t *testing.T) { + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer devNull.Close() + if !isTerminalWriter(devNull) { + t.Errorf("%s is a character device: want a terminal writer", os.DevNull) + } + regular, err := os.Create(filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatal(err) + } + defer regular.Close() + if isTerminalWriter(regular) { + t.Error("a regular file is not a terminal writer") + } +} diff --git a/internal/cli/cli_parity_test.go b/internal/cli/cli_parity_test.go index ca47e4e..3fddf05 100644 --- a/internal/cli/cli_parity_test.go +++ b/internal/cli/cli_parity_test.go @@ -28,7 +28,7 @@ func TestBuildCommand_HelpOutput(t *testing.T) { // readConfigurationResult runs `read-configuration` against a fixture and returns // the parsed JSON output (skipping when the fixture is absent). -func readConfigurationResult(t *testing.T, fixtureName string) map[string]interface{} { +func readConfigurationResult(t *testing.T, fixtureName string) map[string]any { t.Helper() fixtureDir := filepath.Join("..", "..", "src", "test", "configs", fixtureName) if _, err := os.Stat(fixtureDir); os.IsNotExist(err) { @@ -49,7 +49,7 @@ func readConfigurationResult(t *testing.T, fixtureName string) map[string]interf var buf bytes.Buffer buf.ReadFrom(r) - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &result); err != nil { t.Fatalf("invalid JSON for %s: %v", fixtureName, err) } @@ -59,19 +59,19 @@ func readConfigurationResult(t *testing.T, fixtureName string) map[string]interf func TestReadConfiguration(t *testing.T) { // A compose config must expose its service; a Dockerfile config must expose // dockerFile or build (and a build target when the fixture has one). - composeService := func(service string) func(*testing.T, map[string]interface{}) { - return func(t *testing.T, result map[string]interface{}) { - cfg, _ := result["configuration"].(map[string]interface{}) + composeService := func(service string) func(*testing.T, map[string]any) { + return func(t *testing.T, result map[string]any) { + cfg, _ := result["configuration"].(map[string]any) if cfg["service"] != service { t.Errorf("service = %v, want %q", cfg["service"], service) } } } - dockerfile := func(hasTarget bool) func(*testing.T, map[string]interface{}) { - return func(t *testing.T, result map[string]interface{}) { - cfg, _ := result["configuration"].(map[string]interface{}) + dockerfile := func(hasTarget bool) func(*testing.T, map[string]any) { + return func(t *testing.T, result map[string]any) { + cfg, _ := result["configuration"].(map[string]any) _, hasDockerFile := cfg["dockerFile"] - build, hasBuild := cfg["build"].(map[string]interface{}) + build, hasBuild := cfg["build"].(map[string]any) if !hasDockerFile && !hasBuild { t.Error("expected dockerFile or build property") } @@ -86,18 +86,18 @@ func TestReadConfiguration(t *testing.T) { tests := []struct { name string fixture string - check func(t *testing.T, result map[string]interface{}) + check func(t *testing.T, result map[string]any) }{ - {"with features", "image-with-features", func(t *testing.T, result map[string]interface{}) { - cfg, _ := result["configuration"].(map[string]interface{}) - features, _ := cfg["features"].(map[string]interface{}) + {"with features", "image-with-features", func(t *testing.T, result map[string]any) { + cfg, _ := result["configuration"].(map[string]any) + features, _ := cfg["features"].(map[string]any) if len(features) == 0 { t.Error("expected features in configuration") } }}, - {"variable substitution", "image", func(t *testing.T, result map[string]interface{}) { - cfg, _ := result["configuration"].(map[string]interface{}) - remoteEnv, _ := cfg["remoteEnv"].(map[string]interface{}) + {"variable substitution", "image", func(t *testing.T, result map[string]any) { + cfg, _ := result["configuration"].(map[string]any) + remoteEnv, _ := cfg["remoteEnv"].(map[string]any) localPath, ok := remoteEnv["LOCAL_PATH"] if !ok { t.Skip("LOCAL_PATH not in remoteEnv") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9f82e99..ccda926 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -9,8 +9,6 @@ import ( "testing" "github.com/devcontainers/cli/internal/config" - "github.com/devcontainers/cli/internal/imagemeta" - "github.com/devcontainers/cli/internal/log" ) func TestRootCommand(t *testing.T) { @@ -61,15 +59,15 @@ func TestReadConfigurationFixtures(t *testing.T) { tests := []struct { name string fixture string - check func(t *testing.T, cfg map[string]interface{}) + check func(t *testing.T, cfg map[string]any) }{ - {"image", "image", func(t *testing.T, cfg map[string]interface{}) { + {"image", "image", func(t *testing.T, cfg map[string]any) { if cfg["image"] != "ubuntu:latest" { t.Errorf("image = %v, want 'ubuntu:latest'", cfg["image"]) } }}, - {"dockerfile", "dockerfile-without-features", func(t *testing.T, cfg map[string]interface{}) { - build, ok := cfg["build"].(map[string]interface{}) + {"dockerfile", "dockerfile-without-features", func(t *testing.T, cfg map[string]any) { + build, ok := cfg["build"].(map[string]any) if !ok { t.Fatal("expected 'build' in Dockerfile config") } @@ -77,7 +75,7 @@ func TestReadConfigurationFixtures(t *testing.T) { t.Errorf("dockerfile = %v", build["dockerfile"]) } }}, - {"compose", "compose-image-without-features", func(t *testing.T, cfg map[string]interface{}) { + {"compose", "compose-image-without-features", func(t *testing.T, cfg map[string]any) { if cfg["service"] != "app" { t.Errorf("service = %v, want 'app'", cfg["service"]) } @@ -87,7 +85,7 @@ func TestReadConfigurationFixtures(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := readConfigurationResult(t, tt.fixture) - cfg, ok := result["configuration"].(map[string]interface{}) + cfg, ok := result["configuration"].(map[string]any) if !ok { t.Fatal("missing/invalid 'configuration' in output") } @@ -146,7 +144,7 @@ func TestReadConfiguration_AllFixtures(t *testing.T) { continue } - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal([]byte(output), &result); err != nil { t.Errorf("FAIL %s: invalid JSON: %v", entry.Name(), err) continue @@ -164,9 +162,9 @@ func TestReadConfiguration_AllFixtures(t *testing.T) { } func TestComposeServiceConfig(t *testing.T) { - cfg := map[string]interface{}{ - "services": map[string]interface{}{ - "app": map[string]interface{}{ + cfg := map[string]any{ + "services": map[string]any{ + "app": map[string]any{ "image": "example/app:latest", }, }, @@ -202,7 +200,7 @@ func TestConfigToMetadataEntry_IncludesMountsAndUIDSetting(t *testing.T) { if entry.Mounts[0] != "type=volume,target=/data" { t.Fatalf("mounts[0] = %v", entry.Mounts[0]) } - second, ok := entry.Mounts[1].(map[string]interface{}) + second, ok := entry.Mounts[1].(map[string]any) if !ok { t.Fatalf("mounts[1] type = %T", entry.Mounts[1]) } @@ -211,33 +209,6 @@ func TestConfigToMetadataEntry_IncludesMountsAndUIDSetting(t *testing.T) { } } -func TestDockerfileBuildMetadataEntries_PreservesBaseAndConfigMetadata(t *testing.T) { - cfg := &config.DevContainer{ - PostCreateCommand: lifecycleCommand("touch /tmp/postCreateCommand.testmarker"), - PostStartCommand: lifecycleCommand("touch /tmp/postStartCommand.testmarker"), - PostAttachCommand: lifecycleCommand("touch /tmp/postAttachCommand.testmarker"), - } - - baseLabel := `[{"remoteUser":"node"}]` - entries := dockerfileBuildMetadataEntries(cfg, map[string]string{ - "devcontainer.metadata": baseLabel, - }, log.Null) - merged := imagemeta.MergeConfiguration(entries) - - if merged.RemoteUser != "node" { - t.Fatalf("remoteUser = %q, want %q", merged.RemoteUser, "node") - } - if len(merged.PostCreateCommands) != 1 { - t.Fatalf("postCreateCommands len = %d, want 1", len(merged.PostCreateCommands)) - } - if len(merged.PostStartCommands) != 1 { - t.Fatalf("postStartCommands len = %d, want 1", len(merged.PostStartCommands)) - } - if len(merged.PostAttachCommands) != 1 { - t.Fatalf("postAttachCommands len = %d, want 1", len(merged.PostAttachCommands)) - } -} - func mountString(spec string) config.MountOrString { var value config.MountOrString payload, _ := json.Marshal(spec) @@ -251,10 +222,3 @@ func mountObject(mount config.Mount) config.MountOrString { _ = value.UnmarshalJSON(payload) return value } - -func lifecycleCommand(command string) config.LifecycleCommand { - var value config.LifecycleCommand - payload, _ := json.Marshal(command) - _ = value.UnmarshalJSON(payload) - return value -} diff --git a/internal/cli/collection_commands.go b/internal/cli/collection_commands.go index 9295922..65c4fa8 100644 --- a/internal/cli/collection_commands.go +++ b/internal/cli/collection_commands.go @@ -6,10 +6,12 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" + "slices" "strconv" "strings" @@ -17,7 +19,6 @@ import ( "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" "github.com/devcontainers/cli/internal/oci" - "github.com/devcontainers/cli/internal/pfs" "github.com/iancoleman/orderedmap" "github.com/spf13/cobra" ) @@ -32,14 +33,11 @@ func realFeaturesPackageCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "package [target]", + Use: "package ", Short: "Package Features", - Args: cobra.MaximumNArgs(1), + Args: requireTarget, RunE: func(cmd *cobra.Command, args []string) error { - target := "." - if len(args) > 0 { - target = args[0] - } + target := args[0] return packageCollection(resolvePath(target), resolvePath(outputFolder), "feature", forceClean, logLevel) }, } @@ -53,6 +51,15 @@ func realFeaturesPackageCmd() *cobra.Command { // --- Features publish --- +// requireTarget is yargs' check for a required positional: TS declares +// `package ` and `publish `, and stops when it is missing. +func requireTarget(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("Not enough non-option arguments: got %d, need at least 1", len(args)) + } + return cobra.MaximumNArgs(1)(nil, args) +} + func realFeaturesPublishCmd() *cobra.Command { var ( registry string @@ -61,14 +68,11 @@ func realFeaturesPublishCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "publish [target]", + Use: "publish ", Short: "Package and publish Features", - Args: cobra.MaximumNArgs(1), + Args: requireTarget, RunE: func(cmd *cobra.Command, args []string) error { - target := "." - if len(args) > 0 { - target = args[0] - } + target := args[0] return publishCollection(cmd.Context(), resolvePath(target), registry, namespace, "feature", logLevel) }, } @@ -90,14 +94,11 @@ func realTemplatesPublishCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "publish [target]", + Use: "publish ", Short: "Package and publish templates", - Args: cobra.MaximumNArgs(1), + Args: requireTarget, RunE: func(cmd *cobra.Command, args []string) error { - target := "." - if len(args) > 0 { - target = args[0] - } + target := args[0] return publishCollection(cmd.Context(), resolvePath(target), registry, namespace, "template", logLevel) }, } @@ -267,158 +268,6 @@ func realFeaturesTestCmd() *cobra.Command { // --- Shared implementation --- -func packageCollection(targetFolder, outputDir, collectionType string, forceClean bool, logLevelStr string) error { - // TS parity: --log-level is a yargs choice; reject out-of-range instead of - // silently defaulting to Info (collectionCommonUtils/package.ts:18). - if err := validateEnum("log-level", logLevelStr, []string{"info", "debug", "trace"}); err != nil { - return err - } - logger := log.New(log.Options{ - Level: log.ParseLevel(logLevelStr), - Format: "text", - Writer: os.Stderr, - }) - - if forceClean { - pfs.Remove(outputDir, true) - } - pfs.MkdirAll(outputDir) - - metadataFile := fmt.Sprintf("devcontainer-%s.json", collectionType) - - // TS dispatch (prepPackageCommand → isSingle = isLocalFile(target/devcontainer-.json)): - // when the target folder itself holds the metadata file it is a SINGLE - // Feature/Template, so package it directly (packageSingleFeatureOrTemplate) - // instead of scanning for sub-folders — a single-feature folder has none, which - // would otherwise yield an empty collection and a wrong "Failed to package". - if singleMeta := filepath.Join(targetFolder, metadataFile); pfs.IsFile(singleMeta) { - return packageSingleItem(targetFolder, outputDir, collectionType, singleMeta, logger) - } - - srcDir := filepath.Join(targetFolder, "src") - if !pfs.IsDir(srcDir) { - srcDir = targetFolder - } - - entries, err := os.ReadDir(srcDir) - if err != nil { - return fmt.Errorf("read source directory: %w", err) - } - - // orderedmap preserves each metadata file's key order so the emitted - // collection matches the TS CLI (which spreads the parsed JSON verbatim) rather - // than the alphabetical order a plain Go map would produce. - var collection []*orderedmap.OrderedMap - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - featureDir := filepath.Join(srcDir, entry.Name()) - metaPath := filepath.Join(featureDir, metadataFile) - if !pfs.IsFile(metaPath) { - continue - } - - data, _ := os.ReadFile(metaPath) - std, stripErr := jsonc.StripComments(data) - if stripErr != nil { - continue - } - meta := orderedmap.New() - if err := json.Unmarshal(std, meta); err != nil { - continue - } - // TS skips (with an error) any feature/template missing id, version or name. - if !hasNonEmptyString(meta, "id") || !hasNonEmptyString(meta, "version") || !hasNonEmptyString(meta, "name") { - logger.Write(fmt.Sprintf("%s '%s' is missing one of the following required properties in its %s: 'id', 'version', 'name'.", collectionType, entry.Name(), metadataFile), log.LevelError) - continue - } - - // Create tarball - archiveName := fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, entry.Name()) - archivePath := filepath.Join(outputDir, archiveName) - if err := createTarArchive(archivePath, featureDir); err != nil { - return fmt.Errorf("package %s: %w", entry.Name(), err) - } - - logger.Write(fmt.Sprintf("Packaged %s → %s", entry.Name(), archiveName), log.LevelInfo) - collection = append(collection, meta) - } - - if len(collection) == 0 { - logger.Write(fmt.Sprintf("Failed to package %ss", collectionType), log.LevelError) - return &coreerrors.ExitCodeError{Code: 1} - } - - if err := writeCollectionMetadata(outputDir, collectionType, collection); err != nil { - return err - } - - logger.Write(fmt.Sprintf("Packaged %d %s(s) to %s", len(collection), collectionType, outputDir), log.LevelInfo) - return nil -} - -// packageSingleItem packages a lone Feature/Template folder (one that directly -// contains devcontainer-.json + install.sh), mirroring TS's -// packageSingleFeatureOrTemplate: it emits devcontainer--.tgz plus a -// one-element devcontainer-collection.json. -func packageSingleItem(targetFolder, outputDir, collectionType, metaPath string, logger log.Logger) error { - metadataFile := fmt.Sprintf("devcontainer-%s.json", collectionType) - - data, _ := os.ReadFile(metaPath) - std, stripErr := jsonc.StripComments(data) - meta := orderedmap.New() - if stripErr != nil || json.Unmarshal(std, meta) != nil { - logger.Write(fmt.Sprintf("Failed to package %ss", collectionType), log.LevelError) - return &coreerrors.ExitCodeError{Code: 1} - } - // TS skips (with an error) a single feature/template missing id, version or name. - // The message omits the folder name (unlike the collection path). - if !hasNonEmptyString(meta, "id") || !hasNonEmptyString(meta, "version") || !hasNonEmptyString(meta, "name") { - logger.Write(fmt.Sprintf("%s is missing one of the following required properties in its %s: 'id', 'version', 'name'.", collectionType, metadataFile), log.LevelError) - return &coreerrors.ExitCodeError{Code: 1} - } - - idVal, _ := meta.Get("id") - id, _ := idVal.(string) - - archiveName := fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, id) - archivePath := filepath.Join(outputDir, archiveName) - if err := createTarArchive(archivePath, targetFolder); err != nil { - return fmt.Errorf("package %s: %w", id, err) - } - logger.Write(fmt.Sprintf("Packaged %s '%s'", collectionType, id), log.LevelInfo) - - if err := writeCollectionMetadata(outputDir, collectionType, []*orderedmap.OrderedMap{meta}); err != nil { - return err - } - - logger.Write(fmt.Sprintf("Packaged %d %s(s) to %s", 1, collectionType, outputDir), log.LevelInfo) - return nil -} - -// writeCollectionMetadata writes devcontainer-collection.json: sourceInformation -// first, then the features/templates array — JSON.stringify(collection, null, 4) -// in TS, so 4-space indentation. Returns an error instead of swallowing a -// marshal/write failure: a full disk or read-only output would otherwise yield an -// incomplete package that the command still reports as a success. -func writeCollectionMetadata(outputDir, collectionType string, collection []*orderedmap.OrderedMap) error { - collMeta := orderedmap.New() - collMeta.Set("sourceInformation", map[string]string{"source": "devcontainer-cli"}) - collMeta.Set(fmt.Sprintf("%ss", collectionType), collection) - collData, err := json.MarshalIndent(collMeta, "", " ") - if err != nil { - return fmt.Errorf("serialize collection metadata: %w", err) - } - collPath := filepath.Join(outputDir, "devcontainer-collection.json") - if err := os.WriteFile(collPath, collData, 0644); err != nil { - return fmt.Errorf("write collection metadata: %w", err) - } - return nil -} - // publishCollection wires the real OCI registry and process stdout, then // delegates to publishCollectionWith. Tests call publishCollectionWith directly // with a fake oci.Registry and a capturing Output to drive the partial-publish @@ -431,7 +280,7 @@ func publishCollection(ctx context.Context, targetFolder, registry, namespace, c } // TS parity: --log-level is a yargs choice; reject out-of-range instead of // silently defaulting to Info (collectionCommonUtils/publish.ts:14). - if err := validateEnum("log-level", logLevelStr, []string{"info", "debug", "trace"}); err != nil { + if err := validateEnum("log-level", logLevelStr, logLevels); err != nil { return err } logger := log.New(log.Options{ @@ -447,7 +296,7 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta logger := log.New(log.Options{ Level: log.ParseLevel(logLevelStr), Format: "text", - Writer: os.Stderr, + Writer: out.Stderr(), }) // Package first. A failed MkdirTemp would otherwise leave tmpDir="" and scatter @@ -459,6 +308,10 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta defer os.RemoveAll(tmpDir) if err := packageCollection(targetFolder, tmpDir, collectionType, true, logLevelStr); err != nil { + // TS features publish adds this line when packaging failed (featuresCLI/publish.ts). + if _, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok && collectionType == "feature" { + logger.Write("(!) ERR: Failed to fetch devcontainer-collection.json", log.LevelError) + } return err } @@ -471,114 +324,147 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta return fmt.Errorf("read collection metadata: %w", err) } - var collMeta map[string]interface{} + var collMeta map[string]any json.Unmarshal(collData, &collMeta) - // Get items from collection + // Get items from collection. The ordered copy keeps each item's key order for + // its dev.containers.metadata annotation, which TS writes as JSON.stringify(f). itemsKey := fmt.Sprintf("%ss", collectionType) - items, _ := collMeta[itemsKey].([]interface{}) + items, _ := collMeta[itemsKey].([]any) + orderedColl := orderedmap.New() + _ = json.Unmarshal(collData, orderedColl) + orderedItemsVal, _ := orderedColl.Get(itemsKey) + orderedItems, _ := orderedItemsVal.([]any) ociClient := reg + failed := func(msg string) error { + logger.Write(msg, log.LevelError) + return &coreerrors.ExitCodeError{Code: 1} + } + kind := strings.ToUpper(collectionType[:1]) + collectionType[1:] - result := make(map[string]interface{}) - failures := 0 - - // publishOne pushes an item under the forward-only semantic tags derived from - // what is already published, skipping when the exact version already exists. - publishOne := func(resource, archivePath, version string, annotations map[string]string) (*oci.PushResult, bool) { - ref, err := oci.ParseRef(resource) - if err != nil { - logger.Write(fmt.Sprintf("(!) ERR: could not parse %q: %v", resource, err), log.LevelError) - failures++ - return nil, false - } - // The repository is empty on first publish (404). Any OTHER error — auth, - // network, a 5xx — must abort: treating it as "no tags yet" would republish - // mobile aliases (latest, 1, 1.2) off an empty list and could clobber them. + // publishOne is TS doPublishCommand: the published tags and digest, an empty + // result when the version is already published, or an error that ends the + // command (TS exits 1, before printing any result). + publishOne := func(ref *oci.Ref, archivePath, version string, annotations map[string]string) (*oci.PushResult, error) { + logger.Write("Fetching published versions...", log.LevelInfo) published, tagsErr := ociClient.GetPublishedTagsContext(ctx, ref) if tagsErr != nil && !oci.IsNotFound(tagsErr) { - logger.Write(fmt.Sprintf("(!) ERR: could not list existing tags for %q: %v", resource, tagsErr), log.LevelError) - failures++ - return nil, false + logger.Write(fmt.Sprintf("(!) ERR: Could not fetch published tags for '%s/%s' : %v ", ref.Namespace, ref.ID, tagsErr), log.LevelError) + return nil, tagsErr } - tags, skip, tagErr := oci.SemanticTags(version, published) - if tagErr != nil { - logger.Write(fmt.Sprintf("(!) ERR: %v, skipping...", tagErr), log.LevelError) - failures++ - return nil, false + if slices.Contains(published, version) { + logger.Write(fmt.Sprintf("(!) WARNING: Version %s already exists, skipping %s...", version, version), log.LevelWarning) + return &oci.PushResult{}, nil } - if skip { - logger.Write(fmt.Sprintf("(!) WARNING: Version %s already exists, skipping %s...", version, resource), log.LevelWarning) - return &oci.PushResult{PublishedTags: []string{}}, true + tags, _, tagErr := oci.SemanticTags(version, published) + if tagErr != nil { + return nil, failed(fmt.Sprintf("(!) ERR: Version %s is not a valid semantic version, skipping %s...", version, version)) } + logger.Write(fmt.Sprintf("Publishing tags: %s...", strings.Join(tags, ",")), log.LevelInfo) pushResult, err := ociClient.PushArtifact(ctx, ref, archivePath, tags, collectionType, annotations) if err != nil { - logger.Write(fmt.Sprintf("(!) ERR: Failed to publish %s: %v", resource, err), log.LevelError) - failures++ - return nil, false + logger.Write(fmt.Sprintf("(!) ERR: Failed to publish %s: '%s'", collectionType, ref.Resource), log.LevelError) + return nil, err } - pushResult.Version = version // TS reports the published version in the result - return pushResult, true + logger.Write(fmt.Sprintf("Published %s: '%s'", collectionType, ref.ID), log.LevelInfo) + return pushResult, nil } - for _, item := range items { - meta, ok := item.(map[string]interface{}) + result := make(map[string]publishItemResult) + for i, item := range items { + meta, ok := item.(map[string]any) if !ok { continue } id, _ := meta["id"].(string) version, _ := meta["version"].(string) - if id == "" || version == "" { - logger.Write(fmt.Sprintf("Skipping %s with no id or version", collectionType), log.LevelWarning) + logger.Write(fmt.Sprintf("Processing %s: %s...", collectionType, id), log.LevelInfo) + if version == "" { + logger.Write(fmt.Sprintf("(!) WARNING: Version does not exist, skipping %s...", id), log.LevelWarning) continue } - - archiveName := fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, id) - archivePath := filepath.Join(tmpDir, archiveName) - annotations := map[string]string{ - "dev.containers.metadata": string(mustJSON(meta)), + resource := fmt.Sprintf("%s/%s/%s", registry, namespace, id) + ref, err := oci.ParseRef(resource) + if err != nil { + return failed(fmt.Sprintf("(!) Could not parse provided %s identifier: '%s'", kind, resource)) } - - if pushResult, ok := publishOne(fmt.Sprintf("%s/%s/%s", registry, namespace, id), archivePath, version, annotations); ok { - result[id] = pushResult + archivePath := filepath.Join(tmpDir, fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, id)) + var annotated any = meta + if i < len(orderedItems) { + annotated = orderedItems[i] } + annotations := map[string]string{"dev.containers.metadata": string(jsStringifyCompact(annotated))} - // Republish under legacyIds (aliases), matching TS behavior. - if collectionType == "feature" { - if legacyIds, ok := meta["legacyIds"].([]interface{}); ok { - for _, lid := range legacyIds { - legacyID, ok := lid.(string) - if !ok || legacyID == "" { - continue - } - logger.Write(fmt.Sprintf("Publishing legacy alias %s for %s...", legacyID, id), log.LevelInfo) - if lr, ok := publishOne(fmt.Sprintf("%s/%s/%s", registry, namespace, legacyID), archivePath, version, annotations); ok { - result[legacyID] = lr + pushResult, err := publishOne(ref, archivePath, version, annotations) + if err != nil { + if _, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok { + return err + } + return failed(fmt.Sprintf("(!) ERR: Failed to publish '%s'", resource)) + } + isPublished := pushResult.Digest != "" && len(pushResult.PublishedTags) > 0 + var this publishItemResult + if isPublished { + this = publishItemResult{PublishedTags: pushResult.PublishedTags, Digest: pushResult.Digest, Version: version} + } + + // TS republishes a Feature under its legacyIds only when this version was + // published, and reports the aliases it published inside the item. + legacyIDs, _ := meta["legacyIds"].([]any) + if collectionType == "feature" && isPublished && len(legacyIDs) > 0 { + logger.Write(fmt.Sprintf("Processing legacyIds for '%s'...", id), log.LevelInfo) + for _, lid := range legacyIDs { + legacyID, _ := lid.(string) + logger.Write(fmt.Sprintf("Processing legacyId: '%s'...", legacyID), log.LevelInfo) + legacyResource := fmt.Sprintf("%s/%s/%s", registry, namespace, legacyID) + legacyRef, err := oci.ParseRef(legacyResource) + if err != nil { + return failed(fmt.Sprintf("(!) Could not parse provided Feature identifier: '%s'", legacyResource)) + } + lr, err := publishOne(legacyRef, archivePath, version, annotations) + if err != nil { + if _, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok { + return err } + return failed(fmt.Sprintf("(!) ERR: Failed to publish '%s'", legacyResource)) + } + if lr.Digest != "" && len(lr.PublishedTags) > 0 { + this.PublishedLegacyIDs = append(this.PublishedLegacyIDs, legacyID) } } } + result[id] = this } - // Publish the collection metadata for the namespace so the collection's - // items are discoverable (containers.dev, `devcontainer outdated`). - collectionResource := fmt.Sprintf("%s/%s", registry, namespace) - if collRef, refErr := oci.ParseRef(collectionResource); refErr == nil { - if _, err := ociClient.PushCollectionMetadata(ctx, collRef, collMetaPath); err != nil { - logger.Write(fmt.Sprintf("(!) ERR: Failed to publish collection metadata: %v", err), log.LevelError) - failures++ - } + // Publish the collection metadata for the namespace (TS getCollectionRef + + // doPublishMetadata) so the collection's items are discoverable. + collRef, err := oci.ParseCollectionRef(registry, namespace) + if err != nil { + return failed(fmt.Sprintf("(!) Could not parse provided collection identifier with registry '%s' and namespace '%s'", registry, namespace)) } + logger.Write("Publishing collection metadata...", log.LevelInfo) + if _, err := ociClient.PushCollectionMetadata(ctx, collRef.Ref(), collMetaPath); err != nil { + logger.Write("(!) ERR: Failed to publish collection metadata: devcontainer-collection.json", log.LevelError) + return failed(fmt.Sprintf("(!) ERR: Failed to publish '%s/%s'", collRef.Registry, collRef.Path)) + } + logger.Write("Published collection metadata.", log.LevelInfo) data, _ := json.Marshal(result) fmt.Fprintln(out.Stdout(), string(data)) - - if failures > 0 { - return fmt.Errorf("%d %s publish operation(s) failed", failures, collectionType) - } return nil } +// publishItemResult is one item of TS publish's result: empty when nothing was +// published (the version already existed), else the tags, digest and version, +// and for a Feature the legacy ids it was also published under. +type publishItemResult struct { + PublishedTags []string `json:"publishedTags,omitempty"` + Digest string `json:"digest,omitempty"` + Version string `json:"version,omitempty"` + PublishedLegacyIDs []string `json:"publishedLegacyIds,omitempty"` +} + // README templates match the TS FEATURES_README_TEMPLATE / TEMPLATE_README_TEMPLATE // byte-for-byte. They live as real files (leading newline, blank lines, double space // before "Add" all preserved) so they stay readable and diffable instead of being @@ -654,7 +540,7 @@ func generateDocs(projectFolder, registry, namespace, githubOwner, githubRepo, c logger.Write(fmt.Sprintf("Failed to parse %s: %v", jsonPath, stripErr), log.LevelError) continue } - var meta map[string]interface{} + var meta map[string]any if err := json.Unmarshal(std, &meta); err != nil { logger.Write(fmt.Sprintf("Failed to parse %s: %v", jsonPath, err), log.LevelError) continue @@ -673,7 +559,7 @@ func generateDocs(projectFolder, registry, namespace, githubOwner, githubRepo, c version := "latest" if v, _ := meta["version"].(string); v != "" { - version = strings.SplitN(v, ".", 2)[0] + version, _, _ = strings.Cut(v, ".") } notes := "" @@ -723,12 +609,12 @@ func renderDocsTemplate(template string, values map[string]string) string { // generateOptionsMarkdown renders the options table with columns and row order // matching TS: "| Options Id | Description | Type | Default Value |" in the JSON's // insertion order. Returns "" only when the metadata has no "options" key. -func generateOptionsMarkdown(std []byte, meta map[string]interface{}) string { +func generateOptionsMarkdown(std []byte, meta map[string]any) string { optsVal, hasOptions := meta["options"] if !hasOptions { return "" } - optsMap, _ := optsVal.(map[string]interface{}) + optsMap, _ := optsVal.(map[string]any) var optionsRaw json.RawMessage var top map[string]json.RawMessage @@ -738,7 +624,7 @@ func generateOptionsMarkdown(std []byte, meta map[string]interface{}) string { var rows []string for _, k := range orderedObjectKeys(optionsRaw) { - ov, _ := optsMap[k].(map[string]interface{}) + ov, _ := optsMap[k].(map[string]any) desc := jsTruthyString(ov["description"], "-") typ := jsTruthyString(ov["type"], "-") rows = append(rows, fmt.Sprintf("| %s | %s | %s | %s |", k, desc, typ, defaultCell(ov))) @@ -746,10 +632,10 @@ func generateOptionsMarkdown(std []byte, meta map[string]interface{}) string { return "## Options\n\n| Options Id | Description | Type | Default Value |\n|-----|-----|-----|-----|\n" + strings.Join(rows, "\n") } -func generateCustomizationsMarkdown(meta map[string]interface{}) string { - cust, _ := meta["customizations"].(map[string]interface{}) - vscode, _ := cust["vscode"].(map[string]interface{}) - exts, _ := vscode["extensions"].([]interface{}) +func generateCustomizationsMarkdown(meta map[string]any) string { + cust, _ := meta["customizations"].(map[string]any) + vscode, _ := cust["vscode"].(map[string]any) + exts, _ := vscode["extensions"].([]any) if len(exts) == 0 { return "" } @@ -762,9 +648,9 @@ func generateCustomizationsMarkdown(meta map[string]interface{}) string { return "\n## Customizations\n\n### VS Code Extensions\n\n" + strings.Join(lines, "\n") + "\n" } -func generateDocsHeader(meta map[string]interface{}) string { +func generateDocsHeader(meta map[string]any) string { deprecated, _ := meta["deprecated"].(bool) - legacy, _ := meta["legacyIds"].([]interface{}) + legacy, _ := meta["legacyIds"].([]any) if !deprecated && len(legacy) == 0 { return "" } @@ -784,20 +670,9 @@ func generateDocsHeader(meta map[string]interface{}) string { return h } -// hasNonEmptyString reports whether an orderedmap has a non-empty string at key, -// mirroring the JS truthiness check (`!metadata.id`). -func hasNonEmptyString(m *orderedmap.OrderedMap, key string) bool { - v, ok := m.Get(key) - if !ok { - return false - } - s, ok := v.(string) - return ok && s != "" -} - // jsTruthyString mirrors JS `value || fallback` for string cells: an empty or // non-string value falls back. -func jsTruthyString(v interface{}, fallback string) string { +func jsTruthyString(v any, fallback string) string { if s, ok := v.(string); ok && s != "" { return s } @@ -807,7 +682,7 @@ func jsTruthyString(v interface{}, fallback string) string { // defaultCell mirrors TS `val.default !== ” ? val.default : '-'`: an empty string // becomes "-", an absent default renders "undefined", everything else prints as JS // would interpolate it. -func defaultCell(ov map[string]interface{}) string { +func defaultCell(ov map[string]any) string { d, ok := ov["default"] if !ok { return "undefined" @@ -820,12 +695,9 @@ func defaultCell(ov map[string]interface{}) string { } switch x := d.(type) { case bool: - if x { - return "true" - } - return "false" + return strconv.FormatBool(x) case float64: - return strconv.FormatFloat(x, 'f', -1, 64) + return strconv.FormatFloat(x, 'f', -1, 64) // mutate-exempt: any negative precision is the shortest representation case nil: return "null" default: @@ -956,8 +828,3 @@ func createTarArchive(archivePath, sourceDir string) error { } return nil } - -func mustJSON(v interface{}) []byte { - data, _ := json.Marshal(v) - return data -} diff --git a/internal/cli/collection_commands_docs_held_test.go b/internal/cli/collection_commands_docs_held_test.go new file mode 100644 index 0000000..8b60276 --- /dev/null +++ b/internal/cli/collection_commands_docs_held_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateDocsHeader(t *testing.T) { + const note = "### **IMPORTANT NOTE**\n" + const deprecated = "- **This Feature is deprecated, and will no longer receive any further updates/support.**\n" + cases := []struct { + name string + meta map[string]any + want string + }{ + {"current", map[string]any{"id": "x"}, ""}, + {"deprecated", map[string]any{"deprecated": true}, note + deprecated}, + {"one legacy id", map[string]any{"legacyIds": []any{"old"}}, note + "- **Ids used to publish this Feature in the past - 'old'**\n"}, + {"both", map[string]any{"deprecated": true, "legacyIds": []any{"a", "b"}}, note + deprecated + "- **Ids used to publish this Feature in the past - 'a', 'b'**\n"}, + } + for _, c := range cases { + if got := generateDocsHeader(c.meta); got != c.want { + t.Errorf("%s: header = %q, want %q", c.name, got, c.want) + } + } +} + +func TestGenerateCustomizationsMarkdown(t *testing.T) { + if got := generateCustomizationsMarkdown(map[string]any{}); got != "" { + t.Errorf("no extensions: %q, want empty", got) + } + meta := map[string]any{"customizations": map[string]any{"vscode": map[string]any{"extensions": []any{"golang.go"}}}} + want := "\n## Customizations\n\n### VS Code Extensions\n\n- `golang.go`\n" + if got := generateCustomizationsMarkdown(meta); got != want { + t.Errorf("customizations = %q, want %q", got, want) + } +} + +func TestOptionCells(t *testing.T) { + if got := jsTruthyString("", "-"); got != "-" { + t.Errorf("empty string cell = %q, want the fallback", got) + } + if got := jsTruthyString(3.0, "-"); got != "-" { + t.Errorf("non-string cell = %q, want the fallback", got) + } + if got := jsTruthyString("text", "-"); got != "text" { + t.Errorf("string cell = %q", got) + } + defaults := []struct { + ov map[string]any + want string + }{ + {map[string]any{}, "undefined"}, + {map[string]any{"default": ""}, "-"}, + {map[string]any{"default": "x"}, "x"}, + {map[string]any{"default": 1.5}, "1.5"}, + {map[string]any{"default": false}, "false"}, + {map[string]any{"default": nil}, "null"}, + } + for _, d := range defaults { + if got := defaultCell(d.ov); got != d.want { + t.Errorf("defaultCell(%v) = %q, want %q", d.ov, got, d.want) + } + } +} + +// generate-docs skips dot folders, appends NOTES.md, links the metadata on +// GitHub only when both owner and repo are given, and puts a deprecated +// Feature's note before the README. +func TestGenerateDocsReadmeParts(t *testing.T) { + base := t.TempDir() + writeFeature(t, base, ".hidden", `{"id":"hidden","version":"1.0.0"}`) + writeFeature(t, base, "old", `{"id":"old","version":"2.1.0","deprecated":true}`) + if err := os.WriteFile(filepath.Join(base, "old", "NOTES.md"), []byte("Read these notes."), 0o644); err != nil { + t.Fatal(err) + } + if err := generateDocs(base, "ghcr.io", "o/r", "owner", "", "feature", "info"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(base, ".hidden", "README.md")); !os.IsNotExist(err) { + t.Errorf("a dot folder was documented (stat err %v)", err) + } + b, err := os.ReadFile(filepath.Join(base, "old", "README.md")) + if err != nil { + t.Fatal(err) + } + readme := string(b) + if !strings.HasPrefix(readme, "### **IMPORTANT NOTE**\n- **This Feature is deprecated") { + t.Errorf("README does not start with the deprecation note:\n%s", readme) + } + if !strings.Contains(readme, "Read these notes.") { + t.Errorf("NOTES.md missing:\n%s", readme) + } + if strings.Contains(readme, "https://github.com/") { + t.Errorf("GitHub link without a repo:\n%s", readme) + } + if !strings.Contains(readme, `"ghcr.io/o/r/old:2": {}`) { + t.Errorf("usage does not pin the major version:\n%s", readme) + } +} diff --git a/internal/cli/collection_commands_held_test.go b/internal/cli/collection_commands_held_test.go new file mode 100644 index 0000000..fb7c31b --- /dev/null +++ b/internal/cli/collection_commands_held_test.go @@ -0,0 +1,221 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/oci" + "github.com/spf13/cobra" +) + +// pushStub is a registry whose pushes answer what the test sets per resource +// (by default every requested tag under a digest), recording each push's +// annotations. +type pushStub struct { + fakeRegistry + results map[string]*oci.PushResult + annotations map[string]string // resource -> dev.containers.metadata +} + +func newPushStub() *pushStub { + return &pushStub{ + fakeRegistry: fakeRegistry{published: map[string][]string{}, failIDs: map[string]bool{}}, + results: map[string]*oci.PushResult{}, + annotations: map[string]string{}, + } +} + +func (s *pushStub) PushArtifact(_ context.Context, ref *oci.Ref, _ string, tags []string, _ string, annotations map[string]string) (*oci.PushResult, error) { + s.pushed = append(s.pushed, ref.Resource) + s.annotations[ref.Resource] = annotations["dev.containers.metadata"] + if r, ok := s.results[ref.Resource]; ok { + return r, nil + } + return &oci.PushResult{Digest: "sha256:d1", PublishedTags: tags}, nil +} + +// writeCollectionFeature writes //devcontainer-feature.json with the +// given metadata and an install.sh. +func writeCollectionFeature(t *testing.T, src, id, metadata string) { + t.Helper() + writeFeature(t, src, id, metadata) + if err := os.WriteFile(filepath.Join(src, id, "install.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func publishResult(t *testing.T, out *captureOutput) map[string]publishItemResult { + t.Helper() + var got map[string]publishItemResult + if err := json.Unmarshal(out.out.Bytes(), &got); err != nil { + t.Fatalf("stdout is not the publish result: %v\n%s", err, out.out.String()) + } + return got +} + +func TestRequireTarget(t *testing.T) { + if err := requireTarget(nil, nil); err == nil || err.Error() != "Not enough non-option arguments: got 0, need at least 1" { + t.Errorf("no target: err = %v", err) + } + if err := requireTarget(nil, []string{"src"}); err != nil { + t.Errorf("one target: err = %v", err) + } + if err := requireTarget(nil, []string{"a", "b"}); err == nil { + t.Error("two targets: want an error") + } +} + +// package packages the folder its one argument names. +func TestFeaturesPackageCommandPackagesTarget(t *testing.T) { + src := t.TempDir() + writeCollectionFeature(t, src, "tool", `{"id":"tool","version":"1.0.0","name":"Tool"}`) + outDir := filepath.Join(t.TempDir(), "out") + cmd := realFeaturesPackageCmd() + cmd.SetArgs([]string{src, "--output-folder", outDir}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(outDir, "devcontainer-feature-tool.tgz")); err != nil { + t.Errorf("archive missing: %v", err) + } +} + +// publish checks --namespace before it reaches a registry, for both +// collection types, and validates --log-level next. +func TestPublishCommandsRequireNamespace(t *testing.T) { + for name, cmd := range map[string]func() *cobra.Command{"features": realFeaturesPublishCmd, "templates": realTemplatesPublishCmd} { + c := cmd() + c.SetArgs([]string{t.TempDir()}) + c.SetOut(&strings.Builder{}) + c.SetErr(&strings.Builder{}) + if err := c.Execute(); err == nil || err.Error() != "Missing required argument: namespace" { + t.Errorf("%s publish: err = %v, want the missing namespace", name, err) + } + } + err := publishCollection(t.Context(), t.TempDir(), "ghcr.io", "me/f", "feature", "loud") + if err == nil || !strings.Contains(err.Error(), "log-level") { + t.Errorf("err = %v, want the invalid log-level", err) + } + missing := filepath.Join(t.TempDir(), "missing") + err = publishCollection(t.Context(), missing, "ghcr.io", "me/f", "feature", "info") + if err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Errorf("err = %v, want packaging to fail on the missing target", err) + } +} + +// A Feature collection that cannot be packaged adds TS's collection error line; +// a Template collection does not. +func TestPublishCollectionPackagingFailureLine(t *testing.T) { + for _, c := range []struct { + kind string + want bool + }{{"feature", true}, {"template", false}} { + empty := t.TempDir() + out := &captureOutput{} + err := publishCollectionWith(t.Context(), out, newPushStub(), empty, "ghcr.io", "me/c", c.kind, "info") + if exitCode(err) != 1 { + t.Fatalf("%s: err = %v, want exit 1", c.kind, err) + } + if got := strings.Contains(out.err.String(), "(!) ERR: Failed to fetch devcontainer-collection.json"); got != c.want { + t.Errorf("%s: collection error line printed = %v, want %v\n%s", c.kind, got, c.want, out.err.String()) + } + } +} + +// An item whose resource is not a valid reference stops publish, naming the +// collection type as TS does. +func TestPublishCollectionUnparsableResource(t *testing.T) { + src := t.TempDir() + writeCollectionFeature(t, src, "tool", `{"id":"tool","version":"1.0.0","name":"Tool"}`) + out := &captureOutput{} + err := publishCollectionWith(t.Context(), out, newPushStub(), src, "ghcr.io", "", "feature", "info") + if exitCode(err) != 1 { + t.Fatalf("err = %v, want exit 1", err) + } + if !strings.Contains(out.err.String(), "(!) Could not parse provided Feature identifier: 'ghcr.io//tool'") { + t.Errorf("stderr = %q", out.err.String()) + } +} + +// The dev.containers.metadata annotation keeps the metadata's own key order. +func TestPublishCollectionAnnotationKeepsKeyOrder(t *testing.T) { + src := t.TempDir() + writeCollectionFeature(t, src, "tool", `{"version":"1.0.0","name":"Tool","id":"tool"}`) + reg := newPushStub() + if err := publishCollectionWith(t.Context(), &captureOutput{}, reg, src, "ghcr.io", "me/f", "feature", "info"); err != nil { + t.Fatal(err) + } + if got, want := reg.annotations["ghcr.io/me/f/tool"], `{"version":"1.0.0","name":"Tool","id":"tool"}`; got != want { + t.Errorf("annotation = %s, want %s", got, want) + } +} + +// An item counts as published only with both a digest and at least one tag; +// otherwise its result is empty and its legacy ids are not published. A +// published Feature with a single legacy id republishes it. +func TestPublishCollectionPublishedNeedsDigestAndTags(t *testing.T) { + src := t.TempDir() + writeCollectionFeature(t, src, "a", `{"id":"a","version":"1.0.0","name":"A","legacyIds":["old-a"]}`) + writeCollectionFeature(t, src, "b", `{"id":"b","version":"1.0.0","name":"B"}`) + writeCollectionFeature(t, src, "c", `{"id":"c","version":"1.0.0","name":"C","legacyIds":["old-c"]}`) + reg := newPushStub() + reg.results["ghcr.io/me/f/a"] = &oci.PushResult{Digest: "", PublishedTags: []string{"1"}} + reg.results["ghcr.io/me/f/b"] = &oci.PushResult{Digest: "sha256:b", PublishedTags: nil} + reg.results["ghcr.io/me/f/c"] = &oci.PushResult{Digest: "sha256:c", PublishedTags: []string{"1.0.0"}} + out := &captureOutput{} + if err := publishCollectionWith(t.Context(), out, reg, src, "ghcr.io", "me/f", "feature", "info"); err != nil { + t.Fatal(err) + } + got := publishResult(t, out) + if got["a"].Digest != "" || got["b"].Digest != "" { + t.Errorf("result = %+v, want a and b empty", got) + } + if c := got["c"]; c.Digest != "sha256:c" || c.Version != "1.0.0" || len(c.PublishedTags) != 1 { + t.Errorf("c = %+v, want published with one tag", c) + } + if ids := got["c"].PublishedLegacyIDs; len(ids) != 1 || ids[0] != "old-c" { + t.Errorf("c publishedLegacyIds = %q, want its one legacy id", ids) + } + for _, r := range reg.pushed { + if strings.HasSuffix(r, "/old-a") { + t.Errorf("pushed %v: legacy id of an unpublished item", reg.pushed) + } + } +} + +// A published Feature is republished under each legacy id; the result lists +// the ids that were published (digest and a tag), not the others. +func TestPublishCollectionLegacyIDs(t *testing.T) { + src := t.TempDir() + writeCollectionFeature(t, src, "tool", `{"id":"tool","version":"1.0.0","name":"Tool","legacyIds":["old1","old2","old3"]}`) + reg := newPushStub() + reg.results["ghcr.io/me/f/old2"] = &oci.PushResult{Digest: "", PublishedTags: []string{"1"}} + reg.results["ghcr.io/me/f/old3"] = &oci.PushResult{Digest: "sha256:o3", PublishedTags: nil} + reg.results["ghcr.io/me/f/old1"] = &oci.PushResult{Digest: "sha256:o1", PublishedTags: []string{"1.0.0"}} + out := &captureOutput{} + if err := publishCollectionWith(t.Context(), out, reg, src, "ghcr.io", "me/f", "feature", "info"); err != nil { + t.Fatal(err) + } + if got := publishResult(t, out)["tool"].PublishedLegacyIDs; len(got) != 1 || got[0] != "old1" { + t.Errorf("publishedLegacyIds = %q, want [old1]", got) + } +} + +// Templates have no legacy ids to republish, even when the metadata lists some. +func TestPublishCollectionTemplateIgnoresLegacyIDs(t *testing.T) { + src := t.TempDir() + dir := filepath.Join(src, "tmpl") + writeFile(t, filepath.Join(dir, "devcontainer-template.json"), `{"id":"tmpl","version":"1.0.0","name":"T","legacyIds":["old"]}`, 0o644) + writeFile(t, filepath.Join(dir, ".devcontainer.json"), `{"image":"alpine"}`, 0o644) + reg := newPushStub() + if err := publishCollectionWith(t.Context(), &captureOutput{}, reg, src, "ghcr.io", "me/t", "template", "info"); err != nil { + t.Fatal(err) + } + if len(reg.pushed) != 1 || reg.pushed[0] != "ghcr.io/me/t/tmpl" { + t.Errorf("pushed = %v, want only the template", reg.pushed) + } +} diff --git a/internal/cli/collection_commands_test.go b/internal/cli/collection_commands_test.go index 0fd5af1..45ee34c 100644 --- a/internal/cli/collection_commands_test.go +++ b/internal/cli/collection_commands_test.go @@ -130,24 +130,6 @@ func TestGenerateDocs_IgnoresTopLevelFiles(t *testing.T) { } } -// TestWriteCollectionMetadata_WriteError locks the fix that a failed write of -// devcontainer-collection.json surfaces as an error instead of a false success. -func TestWriteCollectionMetadata_WriteError(t *testing.T) { - // Writing into a directory that does not exist fails; the error must propagate. - missing := filepath.Join(t.TempDir(), "does-not-exist") - if err := writeCollectionMetadata(missing, "feature", nil); err == nil { - t.Fatal("expected an error writing into a missing directory, got nil") - } - // The happy path still writes the file. - ok := t.TempDir() - if err := writeCollectionMetadata(ok, "feature", nil); err != nil { - t.Fatalf("unexpected error on the happy path: %v", err) - } - if _, err := os.Stat(filepath.Join(ok, "devcontainer-collection.json")); err != nil { - t.Errorf("collection metadata not written: %v", err) - } -} - func TestGenerateDocs_SkipsMissingMetadata(t *testing.T) { base := t.TempDir() // A directory without a devcontainer-feature.json must be skipped, not error. diff --git a/internal/cli/collection_package.go b/internal/cli/collection_package.go new file mode 100644 index 0000000..d446da6 --- /dev/null +++ b/internal/cli/collection_package.go @@ -0,0 +1,462 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + + coreerrors "github.com/devcontainers/cli/internal/errors" + "github.com/devcontainers/cli/internal/jsonc" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" + "github.com/devcontainers/cli/internal/pfs" + "github.com/iancoleman/orderedmap" +) + +// packageCollection packages the Features or Templates at targetFolder into +// outputDir, as TS prepPackageCommand + doFeaturesPackageCommand/packageTemplates +// do: a folder holding devcontainer-.json is a single item, any other is a +// collection whose sub-folders are the items. Each item is copied, its metadata +// completed (currentId, or a Template's type/files/fileCount/featureIds), and +// tarred; devcontainer-collection.json lists their metadata. +func packageCollection(targetFolder, outputDir, collectionType string, forceClean bool, logLevelStr string) error { + // TS parity: --log-level is a yargs choice; reject out-of-range instead of + // silently defaulting to Info (collectionCommonUtils/package.ts:18). + if err := validateEnum("log-level", logLevelStr, logLevels); err != nil { + return err + } + logger := log.New(log.Options{Level: log.ParseLevel(logLevelStr), Format: "text", Writer: os.Stderr}) + + if !pfs.IsDir(targetFolder) { + return fmt.Errorf("Target folder '%s' does not exist", targetFolder) + } + if pfs.IsDir(outputDir) { + if !forceClean { + logger.Write(fmt.Sprintf("(!) ERR: Output directory '%s' already exists. Manually delete, or pass '-f' to continue.", outputDir), log.LevelError) + return &coreerrors.ExitCodeError{Code: 1} + } + if err := os.RemoveAll(outputDir); err != nil { + return fmt.Errorf("clean output directory: %w", err) + } + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + var metadata []*orderedmap.OrderedMap + if pfs.IsFile(filepath.Join(targetFolder, "devcontainer-"+collectionType+".json")) { + logger.Write(fmt.Sprintf("Packaging single %s...", collectionType), log.LevelInfo) + metadata = packageSingleItem(logger, targetFolder, outputDir, collectionType) + } else { + logger.Write(fmt.Sprintf("Packaging %s collection...", collectionType), log.LevelInfo) + metadata = packageCollectionItems(logger, targetFolder, outputDir, collectionType) + } + if metadata == nil { + logger.Write(fmt.Sprintf("Failed to package %ss", collectionType), log.LevelError) + return &coreerrors.ExitCodeError{Code: 1} + } + return writeCollectionMetadata(outputDir, collectionType, metadata) +} + +// packageSingleItem is TS packageSingleFeatureOrTemplate: the archive is named +// by the item's id. nil means the item could not be packaged (reported). +func packageSingleItem(logger log.Logger, targetFolder, outputDir, collectionType string) []*orderedmap.OrderedMap { + metadataFile := "devcontainer-" + collectionType + ".json" + tmp, err := copyToTemp(targetFolder) + if err != nil { + logger.Write(fmt.Sprintf("(!) ERR: %v", err), log.LevelError) + return nil + } + defer os.RemoveAll(tmp) + + jsonPath := filepath.Join(tmp, metadataFile) + if !completeItemMetadata(logger, tmp, jsonPath, collectionType) { + return nil + } + meta, err := readOrderedJSONC(jsonPath) + if err != nil || !jsTruthy(meta, "id") || !jsTruthy(meta, "version") || !jsTruthy(meta, "name") { + logger.Write(fmt.Sprintf("%s is missing one of the following required properties in its %s: 'id', 'version', 'name'.", collectionType, metadataFile), log.LevelError) + return nil + } + id, _ := meta.Get("id") + archiveName := fmt.Sprintf("devcontainer-%s-%v.tgz", collectionType, id) + if err := createTarArchive(filepath.Join(outputDir, archiveName), tmp); err != nil { + logger.Write(fmt.Sprintf("(!) ERR: package %v: %v", id, err), log.LevelError) + return nil + } + logger.Write(fmt.Sprintf("Packaged %s '%v'", collectionType, id), log.LevelInfo) + return []*orderedmap.OrderedMap{meta} +} + +// packageCollectionItems is TS packageCollection: every entry of the folder, in +// name order, except those starting with '.'; one without a metadata file is +// skipped with a warning, while a Feature without install.sh, a Template whose +// metadata cannot be completed, or an item missing id/version/name fails the +// whole package. nil means nothing was packaged. +func packageCollectionItems(logger log.Logger, srcFolder, outputDir, collectionType string) []*orderedmap.OrderedMap { + metadataFile := "devcontainer-" + collectionType + ".json" + entries, err := os.ReadDir(srcFolder) + if err != nil { + logger.Write(fmt.Sprintf("(!) ERR: %v", err), log.LevelError) + return nil + } + var metadata []*orderedmap.OrderedMap + for _, entry := range entries { + c := entry.Name() + logger.Write(fmt.Sprintf("Processing %s: %s...", collectionType, c), log.LevelInfo) + if strings.HasPrefix(c, ".") { + continue + } + folder := filepath.Join(srcFolder, c) + if !pfs.IsFile(filepath.Join(folder, metadataFile)) { + logger.Write(fmt.Sprintf("(!) WARNING: %s '%s' is missing a %s. Skipping... ", collectionType, c, metadataFile), log.LevelWarning) + continue + } + meta, ok := packageCollectionItem(logger, folder, c, outputDir, collectionType) + if !ok { + return nil + } + metadata = append(metadata, meta) + } + if len(metadata) == 0 { + return nil + } + logger.Write(fmt.Sprintf("Packaged %d %ss!", len(metadata), collectionType), log.LevelInfo) + return metadata +} + +func packageCollectionItem(logger log.Logger, folder, name, outputDir, collectionType string) (*orderedmap.OrderedMap, bool) { + metadataFile := "devcontainer-" + collectionType + ".json" + tmp, err := copyToTemp(folder) + if err != nil { + logger.Write(fmt.Sprintf("(!) ERR: %v", err), log.LevelError) + return nil, false + } + defer os.RemoveAll(tmp) + + if collectionType == "feature" && !pfs.IsFile(filepath.Join(tmp, "install.sh")) { + logger.Write(fmt.Sprintf("Feature '%s' is missing an install.sh", name), log.LevelError) + return nil, false + } + jsonPath := filepath.Join(tmp, metadataFile) + if !completeItemMetadata(logger, tmp, jsonPath, collectionType) { + return nil, false + } + if err := createTarArchive(filepath.Join(outputDir, fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, name)), tmp); err != nil { + logger.Write(fmt.Sprintf("(!) ERR: package %s: %v", name, err), log.LevelError) + return nil, false + } + meta, err := readOrderedJSONC(jsonPath) + if err != nil || !jsTruthy(meta, "id") || !jsTruthy(meta, "version") || !jsTruthy(meta, "name") { + logger.Write(fmt.Sprintf("%s '%s' is missing one of the following required properties in its %s: 'id', 'version', 'name'.", collectionType, name, metadataFile), log.LevelError) + return nil, false + } + return meta, true +} + +// completeItemMetadata rewrites the copied item's metadata file as TS does +// before tarring it: a Feature with legacyIds gets currentId; a Template gets +// type, files, fileCount and featureIds. +func completeItemMetadata(logger log.Logger, srcFolder, jsonPath, collectionType string) bool { + switch collectionType { + case "feature": + addFeatureCurrentID(logger, jsonPath) + return true + case "template": + return addTemplateProps(logger, srcFolder, jsonPath) + } + return true +} + +// addFeatureCurrentID is TS addsAdditionalFeatureProps: a Feature that lists +// legacyIds records its id as currentId, so a reference by an old id resolves +// to the renamed Feature. +func addFeatureCurrentID(logger log.Logger, jsonPath string) { + meta, err := readOrderedJSONC(jsonPath) + if err != nil { + return + } + legacy, _ := meta.Get("legacyIds") + if ids, ok := legacy.([]any); !ok || len(ids) == 0 { + return + } + id, _ := meta.Get("id") + meta.Set("currentId", id) + logger.Write(fmt.Sprintf("Programmatically adding currentId:%v...", id), log.LevelTrace) + writeJSStringified(jsonPath, meta) +} + +// addTemplateProps is TS addsAdditionalTemplateProps: it records the Template's +// type (from its devcontainer.json), its files and their count, and the +// resources of the Features it configures, and narrows an optionalPaths "dir/*" +// whose folder holds a single file to that file. +func addTemplateProps(logger log.Logger, srcFolder, jsonPath string) bool { + devcontainerPath := filepath.Join(srcFolder, ".devcontainer.json") + if !pfs.IsFile(devcontainerPath) { + devcontainerPath = filepath.Join(srcFolder, ".devcontainer", "devcontainer.json") + } + if !pfs.IsFile(devcontainerPath) { + logger.Write("Template is missing a devcontainer.json", log.LevelError) + return false + } + var config map[string]any + if data, err := os.ReadFile(devcontainerPath); err == nil { + _ = jsonc.Unmarshal(data, &config) + } + var templateType string + build, _ := config["build"].(map[string]any) + _, hasBuild := build["dockerfile"] + switch { + case hasKey(config, "image"): + templateType = "image" + case hasKey(config, "dockerFile") || hasBuild: + templateType = "dockerfile" + case hasKey(config, "dockerComposeFile"): + templateType = "dockerCompose" + default: + logger.Write(fmt.Sprintf("Dev container config (%s) is missing one of \"image\", \"dockerFile\" or \"dockerComposeFile\" properties.", devcontainerPath), log.LevelError) + return false + } + + files := templateFiles(srcFolder, "") + + data, err := readOrderedJSONC(jsonPath) + if err != nil { + data = orderedmap.New() + } + featureIDs := []any{} + if feats, ok := config["features"].(map[string]any); ok { + for _, id := range jsonc.MemberNames(mustRead(devcontainerPath), "features") { + if _, ok := feats[id]; !ok { + continue + } + if ref, err := oci.ParseRef(id); err == nil { + featureIDs = append(featureIDs, ref.Resource) + } + } + } + data.Set("type", templateType) + data.Set("files", files) + data.Set("fileCount", len(files)) + data.Set("featureIds", featureIDs) + + if raw, ok := data.Get("optionalPaths"); ok { + if paths, ok := raw.([]any); ok { + for i, p := range paths { + optPath, _ := p.(string) + if !strings.HasSuffix(optPath, "/*") || len(optPath) < 3 { + continue + } + dir := optPath[:len(optPath)-2] + var inDir []string + for _, f := range files { + if strings.HasPrefix(f.(string), dir) { + inDir = append(inDir, f.(string)) + } + } + logger.Write(fmt.Sprintf("Given optionalPath starting with '%s' has %d files", dir, len(inDir)), log.LevelTrace) + if len(inDir) == 1 && pfs.IsFile(filepath.Join(srcFolder, inDir[0])) { + paths[i] = inDir[0] + } + } + data.Set("optionalPaths", paths) + } + } + writeJSStringified(jsonPath, data) + return true +} + +// templateFiles lists the files under dir, relative to it: a folder's own +// files, by name, before the contents of its sub-folders, in name order. TS +// recursive-readdir appends each entry when its asynchronous stat completes, so +// its order is not defined; this is a deterministic order with the same set. +func templateFiles(root, rel string) []any { + entries, err := os.ReadDir(filepath.Join(root, rel)) + if err != nil { + return nil + } + var files, nested []any + for _, e := range entries { + p := path.Join(rel, e.Name()) + if e.IsDir() { + nested = append(nested, templateFiles(root, p)...) + } else { + files = append(files, p) + } + } + return append(files, nested...) +} + +// writeCollectionMetadata writes devcontainer-collection.json as TS does +// (JSON.stringify(collection, null, 4)). Returns an error instead of swallowing +// a write failure: a full disk or read-only output would otherwise yield an +// incomplete package that the command still reports as a success. +func writeCollectionMetadata(outputDir, collectionType string, collection []*orderedmap.OrderedMap) error { + items := make([]any, len(collection)) + for i, m := range collection { + items[i] = m + } + collMeta := orderedmap.New() + collMeta.Set("sourceInformation", map[string]any{"source": "devcontainer-cli"}) + collMeta.Set(collectionType+"s", items) + if err := os.WriteFile(filepath.Join(outputDir, "devcontainer-collection.json"), jsStringify(collMeta), 0o644); err != nil { + return fmt.Errorf("write collection metadata: %w", err) + } + return nil +} + +func writeJSStringified(path string, v any) { + _ = os.WriteFile(path, jsStringify(v), 0o644) +} + +// jsStringify is JSON.stringify(v, null, 4): keys in insertion order, four-space +// indentation, no trailing newline, and '<', '>' and '&' left as they are. +func jsStringify(v any) []byte { + var b bytes.Buffer + writeJS(&b, v, "") + return b.Bytes() +} + +// jsStringifyCompact is JSON.stringify(v): no whitespace, keys in insertion +// order, and '<', '>' and '&' left as they are. +func jsStringifyCompact(v any) []byte { + var b bytes.Buffer + _ = json.Compact(&b, jsStringify(v)) + return b.Bytes() +} + +func writeJS(b *bytes.Buffer, v any, indent string) { + inner := indent + " " + switch t := v.(type) { + case orderedmap.OrderedMap: + writeJS(b, &t, indent) + case *orderedmap.OrderedMap: + keys := t.Keys() + if len(keys) == 0 { + b.WriteString("{}") + return + } + b.WriteString("{\n") + for i, k := range keys { + val, _ := t.Get(k) + b.WriteString(inner) + writeJSString(b, k) + b.WriteString(": ") + writeJS(b, val, inner) + if i < len(keys)-1 { + b.WriteByte(',') + } + b.WriteByte('\n') + } + b.WriteString(indent + "}") + case map[string]any: + om := orderedmap.New() + for _, k := range slices.Sorted(maps.Keys(t)) { + om.Set(k, t[k]) + } + writeJS(b, om, indent) + case []any: + if len(t) == 0 { + b.WriteString("[]") + return + } + b.WriteString("[\n") + for i, e := range t { + b.WriteString(inner) + writeJS(b, e, inner) + if i < len(t)-1 { + b.WriteByte(',') + } + b.WriteByte('\n') + } + b.WriteString(indent + "]") + case string: + writeJSString(b, t) + case float64: + b.WriteString(strconv.FormatFloat(t, 'f', -1, 64)) // mutate-exempt: any negative precision is the shortest representation + case int: + b.WriteString(strconv.Itoa(t)) + case bool: + b.WriteString(strconv.FormatBool(t)) + case nil: + b.WriteString("null") + default: + data, _ := json.Marshal(t) + b.Write(data) + } +} + +func writeJSString(b *bytes.Buffer, s string) { + var sb bytes.Buffer + enc := json.NewEncoder(&sb) + enc.SetEscapeHTML(false) + _ = enc.Encode(s) + b.Write(bytes.TrimSuffix(sb.Bytes(), []byte("\n"))) +} + +func readOrderedJSONC(path string) (*orderedmap.OrderedMap, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + std, err := jsonc.StripComments(data) + if err != nil { + return nil, err + } + m := orderedmap.New() + if err := json.Unmarshal(std, m); err != nil { + return nil, err + } + return m, nil +} + +func mustRead(path string) []byte { + data, _ := os.ReadFile(path) + return data +} + +func hasKey(m map[string]any, key string) bool { + _, ok := m[key] + return ok +} + +// jsTruthy is JS truthiness of metadata[key]: a missing key, null, false, 0 and +// "" are falsy, as `!metadata.id` reads them. +func jsTruthy(m *orderedmap.OrderedMap, key string) bool { + v, ok := m.Get(key) + if !ok { + return false + } + switch t := v.(type) { + case nil: + return false + case string: + return t != "" + case bool: + return t + case float64: + return t != 0 + } + return true +} + +// copyToTemp copies folder to a fresh temporary directory, as TS cpDirectoryLocal +// does before completing the metadata, so the source is left untouched. +func copyToTemp(folder string) (string, error) { + tmp, err := os.MkdirTemp("", "devcontainer-package-") + if err != nil { + return "", err + } + if err := copyDir(folder, tmp); err != nil { + os.RemoveAll(tmp) + return "", fmt.Errorf("copy %s: %w", folder, err) + } + return tmp, nil +} diff --git a/internal/cli/collection_package_held_test.go b/internal/cli/collection_package_held_test.go new file mode 100644 index 0000000..2164b0d --- /dev/null +++ b/internal/cli/collection_package_held_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" +) + +// A folder holding devcontainer-feature.json is packaged as one Feature, its +// archive named by its id; a name that is a non-zero number is truthy, as in JS. +func TestPackageSingleFeature(t *testing.T) { + src := t.TempDir() + writeFile(t, filepath.Join(src, "devcontainer-feature.json"), `{"id":"solo","version":"1.0.0","name":1}`, 0o644) + writeFile(t, filepath.Join(src, "install.sh"), "#!/bin/sh\n", 0o755) + out := filepath.Join(t.TempDir(), "out") + if err := packageCollection(src, out, "feature", false, "info"); err != nil { + t.Fatalf("package: %v", err) + } + if got := tarFile(t, filepath.Join(out, "devcontainer-feature-solo.tgz"), "./install.sh"); got != "#!/bin/sh\n" { + t.Errorf("install.sh in archive = %q", got) + } + if got := readCollection(t, out); !strings.Contains(got, `"id": "solo"`) { + t.Errorf("collection:\n%s", got) + } +} + +// A single item, or a collection item, missing (or with a falsy) id, version or +// name, or with metadata that does not parse, fails the package with exit 1. +func TestPackageRequiredProperties(t *testing.T) { + bad := map[string]string{ + "no id": `{"version":"1.0.0","name":"N"}`, + "no version": `{"id":"x","name":"N"}`, + "no name": `{"id":"x","version":"1.0.0"}`, + "zero name": `{"id":"x","version":"1.0.0","name":0}`, + "not parsing": `{"id":"x",`, + } + for name, meta := range bad { + single := t.TempDir() + writeFile(t, filepath.Join(single, "devcontainer-feature.json"), meta, 0o644) + writeFile(t, filepath.Join(single, "install.sh"), "#!/bin/sh\n", 0o755) + if err := packageCollection(single, filepath.Join(t.TempDir(), "out"), "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("single, %s: err = %v, want exit 1", name, err) + } + + coll := t.TempDir() + writeFile(t, filepath.Join(coll, "x", "devcontainer-feature.json"), meta, 0o644) + writeFile(t, filepath.Join(coll, "x", "install.sh"), "#!/bin/sh\n", 0o755) + if err := packageCollection(coll, filepath.Join(t.TempDir(), "out"), "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("collection, %s: err = %v, want exit 1", name, err) + } + } +} + +// An empty legacyIds list adds no currentId. +func TestPackageEmptyLegacyIDsAddsNoCurrentID(t *testing.T) { + src := t.TempDir() + writeFile(t, filepath.Join(src, "a", "devcontainer-feature.json"), `{"id":"a","version":"1.0.0","name":"A","legacyIds":[]}`, 0o644) + writeFile(t, filepath.Join(src, "a", "install.sh"), "#!/bin/sh\n", 0o755) + out := filepath.Join(t.TempDir(), "out") + if err := packageCollection(src, out, "feature", false, "info"); err != nil { + t.Fatal(err) + } + if got := readCollection(t, out); strings.Contains(got, "currentId") { + t.Errorf("currentId added for empty legacyIds:\n%s", got) + } +} + +// Only an optionalPaths entry of the form "/*" (at least one character of +// dir) is narrowed to its folder's single file; anything else is kept as given. +func TestPackageTemplateOptionalPathsShape(t *testing.T) { + src := t.TempDir() + tpl := filepath.Join(src, "tpl") + writeFile(t, filepath.Join(tpl, "devcontainer-template.json"), `{"id":"tpl","version":"1.0.0","name":"T","optionalPaths":["z/*","abcd"]}`, 0o644) + writeFile(t, filepath.Join(tpl, ".devcontainer.json"), `{"image":"x"}`, 0o644) + writeFile(t, filepath.Join(tpl, "z", "only.txt"), "x\n", 0o644) + writeFile(t, filepath.Join(tpl, "abc.txt"), "x\n", 0o644) + out := filepath.Join(t.TempDir(), "out") + if err := packageCollection(src, out, "template", false, "info"); err != nil { + t.Fatal(err) + } + want := `"optionalPaths": [ + "z/only.txt", + "abcd" + ]` + if got := readCollection(t, out); !strings.Contains(got, want) { + t.Errorf("collection lacks %s:\n%s", want, got) + } +} + diff --git a/internal/cli/collection_package_nodocker_held_test.go b/internal/cli/collection_package_nodocker_held_test.go new file mode 100644 index 0000000..a2812dd --- /dev/null +++ b/internal/cli/collection_package_nodocker_held_test.go @@ -0,0 +1,55 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// A Template is a dockerfile Template when its devcontainer.json names the +// legacy dockerFile or a build.dockerfile, either one alone (TS +// isDockerFileConfig); image wins over both, and a config with none of the +// three is refused. +func TestAddTemplatePropsType(t *testing.T) { + for name, tc := range map[string]struct { + config, want string + }{ + "image": {`{"image":"img","dockerFile":"Dockerfile"}`, "image"}, + "legacy dockerFile": {`{"dockerFile":"Dockerfile"}`, "dockerfile"}, + "build.dockerfile": {`{"build":{"dockerfile":"Dockerfile"}}`, "dockerfile"}, + "compose": {`{"dockerComposeFile":"compose.yml","service":"app"}`, "dockerCompose"}, + "build without dockerfile": {`{"build":{"context":"."}}`, ""}, + } { + t.Run(name, func(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, ".devcontainer.json"), []byte(tc.config), 0o644); err != nil { + t.Fatal(err) + } + jsonPath := filepath.Join(src, "devcontainer-template.json") + if err := os.WriteFile(jsonPath, []byte(`{"id":"t"}`), 0o644); err != nil { + t.Fatal(err) + } + ok := addTemplateProps(log.Null, src, jsonPath) + if ok != (tc.want != "") { + t.Fatalf("addTemplateProps = %v, want %v", ok, tc.want != "") + } + if !ok { + return + } + data, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + var got struct{ Type string } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Type != tc.want { + t.Errorf("type = %q, want %q", got.Type, tc.want) + } + }) + } +} diff --git a/internal/cli/collection_package_test.go b/internal/cli/collection_package_test.go new file mode 100644 index 0000000..b9e6d18 --- /dev/null +++ b/internal/cli/collection_package_test.go @@ -0,0 +1,225 @@ +package cli + +import ( + "archive/tar" + "compress/gzip" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + coreerrors "github.com/devcontainers/cli/internal/errors" +) + +func writeFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func readCollection(t *testing.T, outDir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(outDir, "devcontainer-collection.json")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// tarFile returns a file's content from a packaged .tgz. +func tarFile(t *testing.T, archive, name string) string { + t.Helper() + f, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + // createTarArchive writes a plain tar, as node-tar does without gzip. + f.Seek(0, io.SeekStart) + return tarEntry(t, tar.NewReader(f), name) + } + return tarEntry(t, tar.NewReader(gz), name) +} + +func tarEntry(t *testing.T, tr *tar.Reader, name string) string { + t.Helper() + for { + h, err := tr.Next() + if err != nil { + t.Fatalf("%s not in archive: %v", name, err) + } + if h.Name == name { + b, _ := io.ReadAll(tr) + return string(b) + } + } +} + +func exitCode(err error) int { + if e, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok { + return e.Code + } + return -1 +} + +// A collection is the folder's entries in name order, without those starting +// with '.'; one without devcontainer-feature.json is skipped (TS warns). A +// Feature with legacyIds gets currentId, in the collection and in the packaged +// devcontainer-feature.json, written as JSON.stringify(…, null, 4) (no HTML +// escaping of '&'). +func TestPackageFeatureCollectionAsTS(t *testing.T) { + src := t.TempDir() + writeFile(t, filepath.Join(src, "b", "devcontainer-feature.json"), `{"id":"b","version":"1.0.0","name":"B & co", /* jsonc */ "legacyIds":["old-b"]}`, 0o644) + writeFile(t, filepath.Join(src, "b", "install.sh"), "#!/bin/sh\n", 0o755) + writeFile(t, filepath.Join(src, "a", "devcontainer-feature.json"), `{"id":"a","version":"1.0.0","name":"A"}`, 0o644) + writeFile(t, filepath.Join(src, "a", "install.sh"), "#!/bin/sh\n", 0o755) + writeFile(t, filepath.Join(src, ".hidden", "devcontainer-feature.json"), `{"id":"hidden","version":"1.0.0","name":"H"}`, 0o644) + writeFile(t, filepath.Join(src, "no-metadata", "install.sh"), "#!/bin/sh\n", 0o755) + out := filepath.Join(t.TempDir(), "out") + + if err := packageCollection(src, out, "feature", false, "info"); err != nil { + t.Fatalf("package: %v", err) + } + want := `{ + "sourceInformation": { + "source": "devcontainer-cli" + }, + "features": [ + { + "id": "a", + "version": "1.0.0", + "name": "A" + }, + { + "id": "b", + "version": "1.0.0", + "name": "B & co", + "legacyIds": [ + "old-b" + ], + "currentId": "b" + } + ] +}` + if got := readCollection(t, out); got != want { + t.Errorf("collection:\n%s\nwant:\n%s", got, want) + } + if got := tarFile(t, filepath.Join(out, "devcontainer-feature-b.tgz"), "./devcontainer-feature.json"); !strings.Contains(got, `"currentId": "b"`) { + t.Errorf("packaged devcontainer-feature.json lacks currentId:\n%s", got) + } + if _, err := os.Stat(filepath.Join(out, "devcontainer-feature-hidden.tgz")); err == nil { + t.Error("a folder starting with '.' was packaged") + } + // The source is left as it was: the metadata is completed in a copy. + if data, _ := os.ReadFile(filepath.Join(src, "b", "devcontainer-feature.json")); strings.Contains(string(data), "currentId") { + t.Error("the source devcontainer-feature.json was rewritten") + } +} + +// What fails a TS package fails it here, with exit code 1: a Feature without +// install.sh, an item missing id/version/name (TS returns, it does not skip it), +// a collection with nothing to package - including a parent folder whose items +// are under src/, which TS does not descend into - and an output folder that +// exists without -f. A target that does not exist is an error. +func TestPackageFailuresAsTS(t *testing.T) { + newOut := func() string { return filepath.Join(t.TempDir(), "out") } + + noInstall := t.TempDir() + writeFile(t, filepath.Join(noInstall, "a", "devcontainer-feature.json"), `{"id":"a","version":"1.0.0","name":"A"}`, 0o644) + if err := packageCollection(noInstall, newOut(), "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("missing install.sh: err = %v, want exit 1", err) + } + + noName := t.TempDir() + writeFile(t, filepath.Join(noName, "a", "devcontainer-feature.json"), `{"id":"a","version":"1.0.0"}`, 0o644) + writeFile(t, filepath.Join(noName, "a", "install.sh"), "#!/bin/sh\n", 0o755) + writeFile(t, filepath.Join(noName, "b", "devcontainer-feature.json"), `{"id":"b","version":"1.0.0","name":"B"}`, 0o644) + writeFile(t, filepath.Join(noName, "b", "install.sh"), "#!/bin/sh\n", 0o755) + if err := packageCollection(noName, newOut(), "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("missing name: err = %v, want exit 1", err) + } + + parent := t.TempDir() + writeFile(t, filepath.Join(parent, "src", "a", "devcontainer-feature.json"), `{"id":"a","version":"1.0.0","name":"A"}`, 0o644) + writeFile(t, filepath.Join(parent, "src", "a", "install.sh"), "#!/bin/sh\n", 0o755) + if err := packageCollection(parent, newOut(), "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("parent of src: err = %v, want exit 1 (no descent into src/)", err) + } + + existing := t.TempDir() + if err := packageCollection(filepath.Join(parent, "src"), existing, "feature", false, "info"); exitCode(err) != 1 { + t.Errorf("existing output without -f: err = %v, want exit 1", err) + } + if err := packageCollection(filepath.Join(parent, "src"), existing, "feature", true, "info"); err != nil { + t.Errorf("existing output with -f: %v", err) + } + + missing := filepath.Join(t.TempDir(), "nope") + if err := packageCollection(missing, newOut(), "feature", false, "info"); err == nil || err.Error() != "Target folder '"+missing+"' does not exist" { + t.Errorf("missing target: err = %v", err) + } +} + +// A Template's metadata gains type, files (a folder's own files before its +// sub-folders'), fileCount and featureIds (the resources of its OCI Features, in +// the config's order), and an optionalPaths "dir/*" whose folder holds one file +// names that file - TS addsAdditionalTemplateProps. +func TestPackageTemplateAddsTSProps(t *testing.T) { + src := t.TempDir() + tpl := filepath.Join(src, "tpl") + writeFile(t, filepath.Join(tpl, "devcontainer-template.json"), `{"id":"tpl","version":"1.0.0","name":"T","optionalPaths":[".github/*","docs/*"]}`, 0o644) + writeFile(t, filepath.Join(tpl, ".devcontainer", "devcontainer.json"), `{"image":"x","features":{"ghcr.io/Owner/Features/z:1":{},"./local":{},"ghcr.io/owner/features/a":{}}}`, 0o644) + writeFile(t, filepath.Join(tpl, ".github", "workflow.yml"), "on: push\n", 0o644) + writeFile(t, filepath.Join(tpl, "docs", "a.md"), "a\n", 0o644) + writeFile(t, filepath.Join(tpl, "docs", "b.md"), "b\n", 0o644) + writeFile(t, filepath.Join(tpl, "README.md"), "r\n", 0o644) + out := filepath.Join(t.TempDir(), "out") + + if err := packageCollection(src, out, "template", false, "info"); err != nil { + t.Fatalf("package: %v", err) + } + got := readCollection(t, out) + for _, want := range []string{ + `"type": "image"`, + `"fileCount": 6`, + `"featureIds": [ + "ghcr.io/owner/features/z", + "ghcr.io/owner/features/a" + ]`, + `"optionalPaths": [ + ".github/workflow.yml", + "docs/*" + ]`, + } { + if !strings.Contains(got, want) { + t.Errorf("collection lacks %s:\n%s", want, got) + } + } + files := templateFiles(tpl, "") + want := []any{"README.md", "devcontainer-template.json", ".devcontainer/devcontainer.json", ".github/workflow.yml", "docs/a.md", "docs/b.md"} + if !slices.Equal(files, want) { + t.Errorf("files = %v, want %v", files, want) + } + + noConfig := t.TempDir() + writeFile(t, filepath.Join(noConfig, "tpl", "devcontainer-template.json"), `{"id":"tpl","version":"1.0.0","name":"T"}`, 0o644) + if err := packageCollection(noConfig, filepath.Join(t.TempDir(), "out"), "template", false, "info"); exitCode(err) != 1 { + t.Errorf("template without devcontainer.json: err = %v, want exit 1", err) + } +} + +// writeCollectionMetadata surfaces a failed write instead of a false success. +func TestWriteCollectionMetadata_WriteError(t *testing.T) { + if err := writeCollectionMetadata(filepath.Join(t.TempDir(), "does-not-exist"), "feature", nil); err == nil { + t.Fatal("expected an error writing into a missing directory") + } +} diff --git a/internal/cli/collection_publish_partial_test.go b/internal/cli/collection_publish_partial_test.go index 274d021..432a7e1 100644 --- a/internal/cli/collection_publish_partial_test.go +++ b/internal/cli/collection_publish_partial_test.go @@ -2,10 +2,14 @@ package cli import ( "encoding/json" + "errors" + "fmt" + "os" "path/filepath" "strings" "testing" + coreerrors "github.com/devcontainers/cli/internal/errors" "oras.land/oras-go/v2/registry/remote/errcode" ) @@ -27,9 +31,9 @@ func TestPublishCollection_AbortsOnTagListError(t *testing.T) { } out := &captureOutput{} - err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info") - if err == nil || !strings.Contains(err.Error(), "publish operation(s) failed") { - t.Fatalf("err = %v, want a publish failure from the tag-list error", err) + err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info") + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 { + t.Fatalf("err = %v, want exit code 1 from the tag-list error (TS getPublishedTags -> process.exit(1))", err) } // The artifact must NOT have been pushed — we aborted before computing tags. if len(reg.pushed) != 0 { @@ -54,7 +58,7 @@ func TestPublishCollection_TreatsNotFoundAsFirstPublish(t *testing.T) { } out := &captureOutput{} - if err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info"); err != nil { + if err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info"); err != nil { t.Fatalf("404 tag listing should be a clean first publish, got: %v", err) } if len(reg.pushed) != 1 || !strings.HasSuffix(reg.pushed[0], "/good") { @@ -62,13 +66,11 @@ func TestPublishCollection_TreatsNotFoundAsFirstPublish(t *testing.T) { } } -// TestPublishCollection_CollectionMetadataFailureCounts complements the -// item-level partial-failure test (seams_test.go): here every ITEM publishes -// fine but the SEPARATE collection-metadata push fails. That failure must be -// counted and surfaced as the command error, while the successfully published -// items still appear in the result JSON. This exercises the distinct -// "collection-metadata push" failure path, separate from item-level failures. -func TestPublishCollection_CollectionMetadataFailureCounts(t *testing.T) { +// TestPublishCollection_CollectionMetadataFailureExits: every item publishes but +// the collection-metadata push fails, and publish exits 1 without printing the +// result, as TS doPublishMetadata's caller does ("(!) ERR: Failed to publish +// '/'", process.exit(1) before console.log). +func TestPublishCollection_CollectionMetadataFailureExits(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") writeSeamFeature(t, src, "good", "1.0.0") @@ -80,30 +82,58 @@ func TestPublishCollection_CollectionMetadataFailureCounts(t *testing.T) { } out := &captureOutput{} - err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info") - if err == nil || !strings.Contains(err.Error(), "publish operation(s) failed") { - t.Fatalf("err = %v, want a partial-publish failure from the collection push", err) + err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info") + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 { + t.Fatalf("err = %v, want exit code 1 from the collection push", err) } if reg.collectionPush != 1 { t.Fatalf("collection metadata push attempts = %d, want 1", reg.collectionPush) } - // The item still published and is reported despite the collection failure. if len(reg.pushed) != 1 || !strings.HasSuffix(reg.pushed[0], "/good") { t.Fatalf("pushed = %v, want [.../good]", reg.pushed) } - var result map[string]any - if jerr := json.Unmarshal(out.out.Bytes(), &result); jerr != nil { - t.Fatalf("stdout not JSON: %v", jerr) + if out.out.Len() != 0 { + t.Fatalf("stdout = %q, want no result after the collection push failed", out.out.String()) } - if _, ok := result["good"]; !ok { - t.Fatalf("result missing 'good': %v", result) +} + +// A namespace of one segment is a valid collection (TS getCollectionRef takes +// the namespace as the whole path), so its metadata is published to +// /. Go used to parse it as an item reference, fail, and +// skip the collection metadata silently. +func TestPublishCollection_SingleSegmentNamespacePublishesMetadata(t *testing.T) { + target := t.TempDir() + writeSeamFeature(t, filepath.Join(target, "src"), "good", "1.0.0") + reg := &fakeRegistry{published: map[string][]string{}, failIDs: map[string]bool{}} + out := &captureOutput{} + if err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "myorg", "feature", "info"); err != nil { + t.Fatalf("publish failed: %v", err) + } + if reg.collectionPush != 1 || reg.collectionRef != "ghcr.io/myorg" { + t.Fatalf("collection pushes = %d to %q, want 1 to ghcr.io/myorg", reg.collectionPush, reg.collectionRef) + } +} + +// A namespace that is no valid repository path cannot name a collection: TS +// exits 1 with "(!) Could not parse provided collection identifier". +func TestPublishCollection_InvalidCollectionNamespaceExits(t *testing.T) { + target := t.TempDir() + writeSeamFeature(t, filepath.Join(target, "src"), "good", "1.0.0") + reg := &fakeRegistry{published: map[string][]string{}, failIDs: map[string]bool{}} + out := &captureOutput{} + err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features/", "feature", "info") + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 { + t.Fatalf("err = %v, want exit code 1", err) + } + if reg.collectionPush != 0 || out.out.Len() != 0 { + t.Fatalf("collection pushes = %d, stdout = %q: nothing is published for an unparsable collection", reg.collectionPush, out.out.String()) } } // TestPublishCollection_SkipsAlreadyPublishedVersion covers the skip branch: // when the exact version already exists in the registry, publishOne must NOT push // the artifact again, must NOT count a failure, and must still report the item -// (with empty tags), matching the TS CLI's "already exists, skipping" path. +// (as {}), matching the TS CLI's "already exists, skipping" path. func TestPublishCollection_SkipsAlreadyPublishedVersion(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") @@ -116,25 +146,16 @@ func TestPublishCollection_SkipsAlreadyPublishedVersion(t *testing.T) { } out := &captureOutput{} - if err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info"); err != nil { + if err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info"); err != nil { t.Fatalf("skip path should not error: %v", err) } if len(reg.pushed) != 0 { t.Fatalf("artifact was pushed despite the version already existing: %v", reg.pushed) } - var result map[string]struct { - PublishedTags []string `json:"publishedTags"` - } - if err := json.Unmarshal(out.out.Bytes(), &result); err != nil { - t.Fatalf("stdout not JSON: %v", err) - } - good, ok := result["good"] - if !ok { - t.Fatalf("skipped item should still be reported: %v", result) - } - if len(good.PublishedTags) != 0 { - t.Fatalf("skipped item should report no published tags, got %v", good.PublishedTags) + // TS reports an item it skipped as an empty object. + if got := strings.TrimSpace(out.out.String()); got != `{"good":{}}` { + t.Fatalf("stdout = %s, want {\"good\":{}}", got) } } @@ -145,6 +166,7 @@ func TestPublishCollection_RepublishesLegacyIds(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") writeFeature(t, src, "modern", `{"id":"modern","version":"2.0.0","name":"Modern","legacyIds":["old-a","old-b"]}`) + os.WriteFile(filepath.Join(src, "modern", "install.sh"), []byte("#!/bin/sh\n"), 0o755) reg := &fakeRegistry{ published: map[string][]string{}, @@ -153,7 +175,7 @@ func TestPublishCollection_RepublishesLegacyIds(t *testing.T) { } out := &captureOutput{} - if err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info"); err != nil { + if err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info"); err != nil { t.Fatalf("publish failed: %v", err) } @@ -171,14 +193,15 @@ func TestPublishCollection_RepublishesLegacyIds(t *testing.T) { t.Errorf("pushed %d resources, want %d: %v", len(reg.pushed), len(want), reg.pushed) } - // The result JSON is keyed by both the modern id and each legacy alias. - var result map[string]any + // TS reports the aliases inside the item as publishedLegacyIds, not as items. + var result map[string]map[string]any if err := json.Unmarshal(out.out.Bytes(), &result); err != nil { t.Fatalf("stdout not JSON: %v", err) } - for _, id := range []string{"modern", "old-a", "old-b"} { - if _, ok := result[id]; !ok { - t.Errorf("result missing %q: %v", id, result) - } + if len(result) != 1 { + t.Fatalf("result = %v, want only the modern id", result) + } + if got := fmt.Sprint(result["modern"]["publishedLegacyIds"]); got != "[old-a old-b]" { + t.Errorf("publishedLegacyIds = %s, want [old-a old-b]", got) } } diff --git a/internal/cli/compose_override.go b/internal/cli/compose_override.go new file mode 100644 index 0000000..1e35295 --- /dev/null +++ b/internal/cli/compose_override.go @@ -0,0 +1,357 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "regexp" + "slices" + "strings" + + "github.com/devcontainers/cli/internal/imagemeta" +) + +// Prefixes of the compose override files the TS CLI writes to the persisted +// folder (dockerCompose.ts startContainer). They are recorded in the container's +// com.docker.compose.project.config_files label and restored by prefix. +const ( + composeBuildOverridePrefix = "docker-compose.devcontainer.build" + composeFeaturesOverridePrefix = "docker-compose.devcontainer.containerFeatures" +) + +// composeVersionLineRe is TS readVersionPrefix's /^\s*(version:.*)$/m. +var composeVersionLineRe = regexp.MustCompile(`(?m)^\s*(version:.*)$`) + +// readComposeVersionPrefix returns the first compose file's `version:` line +// followed by a blank line, or "" (TS readVersionPrefix). +func readComposeVersionPrefix(composeFiles []string) (string, error) { + if len(composeFiles) == 0 { + return "", nil + } + data, err := os.ReadFile(composeFiles[0]) + if err != nil { + return "", fmt.Errorf("read compose file: %w", err) + } + m := composeVersionLineRe.FindSubmatch(data) + if m == nil { + return "", nil + } + return string(m[1]) + "\n\n", nil +} + +// composeMount is the subset of the TS Mount the compose override uses. +type composeMount struct { + Type string + Source string + Target string + External bool + // ReadOnly renders the volume with ":ro". TS convertMountToVolume drops + // readonly; Go keeps it (upstream #881, see TestMetadataMounts_ReadonlyObject). + ReadOnly bool +} + +// composeVolumeSpec is TS convertMountToVolume ("[source:]target"), plus ":ro" +// for a read-only mount (upstream #881). +func composeVolumeSpec(m composeMount) string { + volume := m.Target + if m.Source != "" { + volume = m.Source + ":" + m.Target + } + if m.ReadOnly { + volume += ":ro" + } + return volume +} + +// composeMountsFromMetadata converts merged-config mounts (strings or objects) +// and --mount values the way TS generateFeaturesComposeOverrideContent does: +// strings go through parseMount (`key=value` pairs, src/dst/destination +// normalized), objects are taken as they are. ${devcontainerId} was already +// substituted by resolve. +func composeMountsFromMetadata(entries []any, resolve func(string) (string, error)) ([]composeMount, error) { + mounts := make([]composeMount, 0, len(entries)) + for _, entry := range entries { + var m composeMount + switch raw := entry.(type) { + case string: + spec, err := resolve(raw) + if err != nil { + return nil, err + } + m = parseComposeMountString(spec) + case map[string]any: + str := func(k string) (string, error) { + v, _ := raw[k].(string) + return resolve(v) + } + var err error + m.Type, _ = raw["type"].(string) + if m.Source, err = str("source"); err != nil { + return nil, err + } + if m.Target, err = str("target"); err != nil { + return nil, err + } + m.External, _ = raw["external"].(bool) + m.ReadOnly, _ = raw["readonly"].(bool) + default: + return nil, fmt.Errorf("unsupported mount metadata type %T", entry) + } + mounts = append(mounts, m) + } + return mounts, nil +} + +// parseComposeMountString is TS parseMount: comma-separated key=value pairs with +// src → source and dst/destination → target. +func parseComposeMountString(spec string) composeMount { + var m composeMount + for part := range strings.SplitSeq(spec, ",") { + key, value, _ := strings.Cut(part, "=") + switch key { + case "type": + m.Type = value + case "source", "src": + m.Source = value + case "target", "dst", "destination": + m.Target = value + case "external": + m.External = value == "true" + case "readonly", "ro": + m.ReadOnly = value == "" || value == "true" || value == "1" + } + } + return m +} + +// composeFeaturesOverride holds the inputs of TS generateFeaturesComposeOverrideContent. +type composeFeaturesOverride struct { + VersionPrefix string + Service string + UpdatedImageName string + OriginalImageName string + Merged *imagemeta.MergedConfig + // ServiceEntrypoint/ServiceCommand are the compose service's entrypoint and + // command (nil when the service does not set them). + ServiceEntrypoint []string + ServiceCommand []string + // ImageEntrypoint/ImageCmd are the image's Config.Entrypoint/Cmd. + ImageEntrypoint []string + ImageCmd []string + Labels []string + Mounts []composeMount + GPU bool +} + +// jsonString is JSON.stringify for a string (no HTML escaping). +func jsonString(v any) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimSuffix(buf.String(), "\n") +} + +// escapeComposeDollar is TS's `.replace(/\$/g, '$$$$')`: $ → $$ so compose does +// not interpolate it. +func escapeComposeDollar(s string) string { + return strings.ReplaceAll(s, "$", "$$") +} + +// generateComposeFeaturesOverride renders the YAML of the compose override that +// configures the dev container service, byte for byte like TS +// generateFeaturesComposeOverrideContent (dockerCompose.ts). The entrypoint is a +// multi-line double-quoted YAML scalar on purpose: YAML folds each line break +// into a space, which is the exact script the TS CLI runs. +func generateComposeFeaturesOverride(in composeFeaturesOverride) string { + merged := in.Merged + if merged == nil { + merged = &imagemeta.MergedConfig{} + } + overrideCommand := merged.OverrideCommand != nil && *merged.OverrideCommand + + userEntrypoint := []string{} + userCommand := []string{} + if !overrideCommand { + if in.ServiceEntrypoint != nil { + userEntrypoint = in.ServiceEntrypoint // $ already escaped. + } else { + for _, c := range in.ImageEntrypoint { + userEntrypoint = append(userEntrypoint, escapeComposeDollar(c)) + } + } + switch { + case in.ServiceCommand != nil: + userCommand = in.ServiceCommand // $ already escaped. + case in.ServiceEntrypoint != nil: + // Ignore image CMD per docker-compose.yml spec. + default: + for _, c := range in.ImageCmd { + userCommand = append(userCommand, escapeComposeDollar(c)) + } + } + } + // TS emits `command:` unless userCommand is the compose command itself. + emitCommand := overrideCommand || in.ServiceCommand == nil + + var b strings.Builder + b.WriteString(in.VersionPrefix) + b.WriteString("services:\n") + fmt.Fprintf(&b, " '%s':", in.Service) + if in.UpdatedImageName != in.OriginalImageName { + fmt.Fprintf(&b, "\n image: %s", in.UpdatedImageName) + } + b.WriteString("\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\n") + b.WriteString("trap \\\"exit 0\\\" 15\\n\n") + b.WriteString(strings.Join(merged.Entrypoints, "\\n\n")) + b.WriteString("\\n\n") + b.WriteString("exec \\\"$$@\\\"\\n\n") + b.WriteString("while sleep 1 & wait $$!; do :; done\", \"-\"") + for _, a := range userEntrypoint { + b.WriteString(", " + jsonString(a)) + } + b.WriteString("]") + if emitCommand { + b.WriteString("\n command: " + jsonString(userCommand)) + } + if merged.Init != nil && *merged.Init { + b.WriteString("\n init: true") + } + if merged.ContainerUser != "" { + b.WriteString("\n user: " + merged.ContainerUser) + } + if len(merged.ContainerEnv) > 0 { + b.WriteString("\n environment:") + keys := make([]string, 0, len(merged.ContainerEnv)) + for k := range merged.ContainerEnv { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + v := strings.ReplaceAll(merged.ContainerEnv[k], "\n", `\n`) + v = escapeComposeDollar(v) + v = strings.ReplaceAll(v, "'", "''") + fmt.Fprintf(&b, "\n - '%s=%s'", k, v) + } + } + if merged.Privileged != nil && *merged.Privileged { + b.WriteString("\n privileged: true") + } + if len(merged.CapAdd) > 0 { + b.WriteString("\n cap_add:") + for _, c := range merged.CapAdd { + b.WriteString("\n - " + c) + } + } + if len(merged.SecurityOpt) > 0 { + b.WriteString("\n security_opt:") + for _, s := range merged.SecurityOpt { + b.WriteString("\n - " + s) + } + } + if len(in.Labels) > 0 { + b.WriteString("\n labels:") + for _, l := range in.Labels { + fmt.Fprintf(&b, "\n - '%s'", strings.ReplaceAll(escapeComposeDollar(l), "'", "''")) + } + } + if len(in.Mounts) > 0 { + b.WriteString("\n volumes:") + for _, m := range in.Mounts { + b.WriteString("\n - " + composeVolumeSpec(m)) + } + } + if in.GPU { + b.WriteString("\n deploy:\n resources:\n reservations:\n devices:\n - capabilities: [gpu]") + } + var named []composeMount + for _, m := range in.Mounts { + if m.Type == "volume" && m.Source != "" { + named = append(named, m) + } + } + if len(named) > 0 { + b.WriteString("\nvolumes:") + for _, m := range named { + fmt.Fprintf(&b, "\n \n %s:", m.Source) + if m.External { + b.WriteString("\n external: true") + } + } + } + b.WriteString("\n") + return b.String() +} + +// composeBuildOverride holds the inputs of the build override TS +// buildAndExtendDockerCompose writes. +type composeBuildOverride struct { + VersionPrefix string + Service string + // Image is the `image:` set for an image-only service ("" for none). + Image string + Dockerfile string + // Target is the overridden build target ("" when the service sets none). + Target string + // Context is the build context set when the service has none ("" for none). + Context string + BuildKitInlineCache bool + BuildArgs [][2]string + AdditionalContexts [][2]string + CacheFrom []string +} + +// buildContent is TS buildOverrideContent; empty when there is no feature build. +func (in composeBuildOverride) buildContent() string { + if in.Dockerfile == "" { + return "" + } + var b strings.Builder + if in.Image != "" { + fmt.Fprintf(&b, " image: %s\n", in.Image) + } + b.WriteString(" build:\n") + fmt.Fprintf(&b, " dockerfile: %s\n", in.Dockerfile) + if in.Target != "" { + fmt.Fprintf(&b, " target: %s\n", in.Target) + } + if in.Context != "" { + fmt.Fprintf(&b, " context: %s\n", in.Context) + } + if len(in.BuildArgs) > 0 || in.BuildKitInlineCache { + b.WriteString(" args:\n") + if in.BuildKitInlineCache { + b.WriteString(" - BUILDKIT_INLINE_CACHE=1\n") + } + for _, a := range in.BuildArgs { + fmt.Fprintf(&b, " - %s=%s\n", a[0], a[1]) + } + } + if len(in.AdditionalContexts) > 0 { + b.WriteString(" additional_contexts:\n") + for _, c := range in.AdditionalContexts { + fmt.Fprintf(&b, " - %s=%s\n", c[0], c[1]) + } + } + return b.String() +} + +// generateComposeBuildOverride renders the build override YAML like TS +// buildAndExtendDockerCompose, or "" when TS writes no build override (no +// feature build and no --cache-from). +func generateComposeBuildOverride(in composeBuildOverride) string { + content := in.buildContent() + if content == "" && len(in.CacheFrom) == 0 { + return "" + } + cacheFrom := "" + if len(in.CacheFrom) > 0 { + items := make([]string, len(in.CacheFrom)) + for i, c := range in.CacheFrom { + items[i] = " - " + c + "\n" + } + cacheFrom = " cache_from:\n" + strings.Join(items, "\n") + } + return in.VersionPrefix + "services:\n " + in.Service + ":\n" + strings.TrimRight(content, " \t\r\n") + "\n" + cacheFrom + "\n" +} diff --git a/internal/cli/compose_override_held_test.go b/internal/cli/compose_override_held_test.go new file mode 100644 index 0000000..36096cb --- /dev/null +++ b/internal/cli/compose_override_held_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devcontainers/cli/internal/imagemeta" +) + +// TS parseMount keeps every key=value pair; the boolean ones are true only for +// the spellings TS accepts. +func TestParseComposeMountStringBooleans(t *testing.T) { + tests := []struct { + spec string + want composeMount + }{ + {"type=volume,source=v,target=/v,external=true", composeMount{Type: "volume", Source: "v", Target: "/v", External: true}}, + {"type=volume,source=v,target=/v,external=false", composeMount{Type: "volume", Source: "v", Target: "/v"}}, + {"source=/h,destination=/c,ro", composeMount{Source: "/h", Target: "/c", ReadOnly: true}}, + {"source=/h,target=/c,readonly=true", composeMount{Source: "/h", Target: "/c", ReadOnly: true}}, + {"source=/h,target=/c,ro=1", composeMount{Source: "/h", Target: "/c", ReadOnly: true}}, + {"source=/h,target=/c,readonly=false", composeMount{Source: "/h", Target: "/c"}}, + } + for _, tt := range tests { + if got := parseComposeMountString(tt.spec); got != tt.want { + t.Errorf("parseComposeMountString(%q) = %+v, want %+v", tt.spec, got, tt.want) + } + } +} + +// A project with a single compose file still carries its version line. +func TestReadComposeVersionPrefixSingleFile(t *testing.T) { + f := filepath.Join(t.TempDir(), "docker-compose.yml") + if err := os.WriteFile(f, []byte("version: '3'\nservices: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := readComposeVersionPrefix([]string{f}) + if err != nil || got != "version: '3'\n\n" { + t.Errorf("readComposeVersionPrefix = %q, %v; want %q", got, err, "version: '3'\n\n") + } + if got, err := readComposeVersionPrefix(nil); err != nil || got != "" { + t.Errorf("no compose files: %q, %v", got, err) + } + if _, err := readComposeVersionPrefix([]string{filepath.Join(t.TempDir(), "missing.yml")}); err == nil { + t.Error("missing compose file: want an error") + } +} + +// One entry is enough for each list section of the override to be written. +func TestGenerateComposeFeaturesOverrideSingleEntries(t *testing.T) { + got := generateComposeFeaturesOverride(composeFeaturesOverride{ + Service: "app", + UpdatedImageName: "img", + OriginalImageName: "img", + Merged: &imagemeta.MergedConfig{ + ContainerEnv: map[string]string{"A": "1"}, + CapAdd: []string{"SYS_PTRACE"}, + SecurityOpt: []string{"seccomp=unconfined"}, + }, + Labels: []string{"l=1"}, + Mounts: []composeMount{{Type: "volume", Source: "vol", Target: "/v"}}, + }) + want := "services:\n 'app':\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\ntrap \\\"exit 0\\\" 15\\n\n\\n\nexec \\\"$$@\\\"\\n\nwhile sleep 1 & wait $$!; do :; done\", \"-\"]\n command: []\n environment:\n - 'A=1'\n cap_add:\n - SYS_PTRACE\n security_opt:\n - seccomp=unconfined\n labels:\n - 'l=1'\n volumes:\n - vol:/v\nvolumes:\n \n vol:\n" + if got != want { + t.Errorf("override mismatch\ngot: %q\nwant: %q", got, want) + } +} + +// A single --cache-from still gets its cache_from section, even with no +// Features build. +func TestGenerateComposeBuildOverrideSingleCacheFrom(t *testing.T) { + got := generateComposeBuildOverride(composeBuildOverride{Service: "app", CacheFrom: []string{"reg/img:cache"}}) + if want := "services:\n app:\n\n cache_from:\n - reg/img:cache\n\n"; got != want { + t.Errorf("build override = %q, want %q", got, want) + } +} diff --git a/internal/cli/compose_override_test.go b/internal/cli/compose_override_test.go new file mode 100644 index 0000000..c703b30 --- /dev/null +++ b/internal/cli/compose_override_test.go @@ -0,0 +1,256 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/imagemeta" +) + +// The expected strings below are the output of the TS +// generateFeaturesComposeOverrideContent template (dockerCompose.ts) for the +// same inputs, rendered with node from a verbatim copy of the template. +func TestGenerateComposeFeaturesOverrideMatchesTS(t *testing.T) { + boolp := func(b bool) *bool { return &b } + tests := []struct { + name string + in composeFeaturesOverride + want string + }{ + { + // compose-image-without-features: no Features, yet the entrypoint is + // wrapped and the image's entrypoint kept (the compose command is + // left alone, so no command: line). + name: "image without features", + in: composeFeaturesOverride{ + VersionPrefix: "version: '3.8'\n\n", + Service: "app", + UpdatedImageName: "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16-bullseye", + OriginalImageName: "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16-bullseye", + Merged: &imagemeta.MergedConfig{Init: boolp(false), Privileged: boolp(false)}, + ServiceCommand: []string{"sleep", "infinity"}, + ImageEntrypoint: []string{"docker-entrypoint.sh"}, + ImageCmd: []string{"node"}, + Labels: []string{"devcontainer.local_folder=/ws", `devcontainer.metadata=[{"remoteUser":"node"}]`}, + }, + want: "version: '3.8'\n\nservices:\n 'app':\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\ntrap \\\"exit 0\\\" 15\\n\n\\n\nexec \\\"$$@\\\"\\n\nwhile sleep 1 & wait $$!; do :; done\", \"-\", \"docker-entrypoint.sh\"]\n labels:\n - 'devcontainer.local_folder=/ws'\n - 'devcontainer.metadata=[{\"remoteUser\":\"node\"}]'\n", + }, + { + name: "every property", + in: composeFeaturesOverride{ + Service: "dev", + UpdatedImageName: "vsc-ws-abc-uid", + OriginalImageName: "vsc-ws-abc", + Merged: &imagemeta.MergedConfig{ + Entrypoints: []string{"/usr/local/share/docker-init.sh", "/usr/local/share/ssh-init.sh"}, + OverrideCommand: boolp(true), + Init: boolp(true), + Privileged: boolp(true), + ContainerUser: "vscode", + ContainerEnv: map[string]string{"A": "x$y", "B": "line1\nline2", "C": "it's"}, + CapAdd: []string{"SYS_PTRACE", "NET_ADMIN"}, + SecurityOpt: []string{"seccomp=unconfined"}, + }, + ServiceEntrypoint: []string{"/ep.sh"}, + ServiceCommand: []string{"run"}, + ImageEntrypoint: []string{"x"}, + ImageCmd: []string{"y"}, + Labels: []string{"devcontainer.local_folder=/ws", "x=a$b'c"}, + Mounts: []composeMount{ + {Type: "bind", Source: "/host", Target: "/c"}, + {Type: "volume", Source: "vol1", Target: "/v1"}, + {Type: "volume", Source: "ext", Target: "/v2", External: true}, + {Type: "tmpfs", Target: "/t"}, + }, + GPU: true, + }, + want: "services:\n 'dev':\n image: vsc-ws-abc-uid\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\ntrap \\\"exit 0\\\" 15\\n\n/usr/local/share/docker-init.sh\\n\n/usr/local/share/ssh-init.sh\\n\nexec \\\"$$@\\\"\\n\nwhile sleep 1 & wait $$!; do :; done\", \"-\"]\n command: []\n init: true\n user: vscode\n environment:\n - 'A=x$$y'\n - 'B=line1\\nline2'\n - 'C=it''s'\n privileged: true\n cap_add:\n - SYS_PTRACE\n - NET_ADMIN\n security_opt:\n - seccomp=unconfined\n labels:\n - 'devcontainer.local_folder=/ws'\n - 'x=a$$b''c'\n volumes:\n - /host:/c\n - vol1:/v1\n - ext:/v2\n - /t\n deploy:\n resources:\n reservations:\n devices:\n - capabilities: [gpu]\nvolumes:\n \n vol1:\n \n ext:\n external: true\n", + }, + { + // A compose entrypoint without a command: the image CMD is ignored and + // an empty command is set. + name: "compose entrypoint without command", + in: composeFeaturesOverride{ + Service: "app", + UpdatedImageName: "img", + OriginalImageName: "img", + Merged: &imagemeta.MergedConfig{OverrideCommand: boolp(false)}, + ServiceEntrypoint: []string{"/ep.sh", "a$$b"}, + ImageEntrypoint: []string{"x"}, + ImageCmd: []string{"y"}, + }, + want: "services:\n 'app':\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\ntrap \\\"exit 0\\\" 15\\n\n\\n\nexec \\\"$$@\\\"\\n\nwhile sleep 1 & wait $$!; do :; done\", \"-\", \"/ep.sh\", \"a$$b\"]\n command: []\n", + }, + { + // Neither entrypoint nor command in compose: the image's are kept, + // with $ escaped for compose interpolation. + name: "image entrypoint and cmd", + in: composeFeaturesOverride{ + Service: "app", + UpdatedImageName: "img", + OriginalImageName: "img", + Merged: &imagemeta.MergedConfig{}, + ImageEntrypoint: []string{"/bin/e$1"}, + ImageCmd: []string{"echo", "$HOME"}, + }, + want: "services:\n 'app':\n entrypoint: [\"/bin/sh\", \"-c\", \"echo Container started\\n\ntrap \\\"exit 0\\\" 15\\n\n\\n\nexec \\\"$$@\\\"\\n\nwhile sleep 1 & wait $$!; do :; done\", \"-\", \"/bin/e$$1\"]\n command: [\"echo\",\"$$HOME\"]\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := generateComposeFeaturesOverride(tt.in); got != tt.want { + t.Errorf("override mismatch\ngot: %q\nwant: %q", got, tt.want) + } + }) + } +} + +func TestGenerateComposeBuildOverride(t *testing.T) { + tests := []struct { + name string + in composeBuildOverride + want string + }{ + { + name: "nothing to override", + in: composeBuildOverride{Service: "app"}, + want: "", + }, + { + // Image-only service with Features (TS buildAndExtendDockerCompose): + // image retag, empty context, BuildKit inline cache and the Features + // content as an additional build context. + name: "image service with build contexts", + in: composeBuildOverride{ + VersionPrefix: "version: '3.8'\n\n", + Service: "app", + Image: "vsc-ws-abc", + Dockerfile: "/tmp/f/Dockerfile-with-features", + Context: "/tmp/p/empty-folder", + BuildKitInlineCache: true, + BuildArgs: [][2]string{{"_DEV_CONTAINERS_BASE_IMAGE", "dev_container_auto_added_stage_label"}}, + AdditionalContexts: [][2]string{{"dev_containers_feature_content_source", "/tmp/f"}}, + }, + want: "version: '3.8'\n\nservices:\n app:\n image: vsc-ws-abc\n build:\n dockerfile: /tmp/f/Dockerfile-with-features\n context: /tmp/p/empty-folder\n args:\n - BUILDKIT_INLINE_CACHE=1\n - _DEV_CONTAINERS_BASE_IMAGE=dev_container_auto_added_stage_label\n additional_contexts:\n - dev_containers_feature_content_source=/tmp/f\n\n", + }, + { + name: "dockerfile service with target and cache-from", + in: composeBuildOverride{ + Service: "app", + Dockerfile: "/tmp/f/Dockerfile-with-features", + Target: "dev_containers_target_stage", + BuildArgs: [][2]string{{"_DEV_CONTAINERS_BASE_IMAGE", "base"}}, + CacheFrom: []string{"a", "b"}, + }, + want: "services:\n app:\n build:\n dockerfile: /tmp/f/Dockerfile-with-features\n target: dev_containers_target_stage\n args:\n - _DEV_CONTAINERS_BASE_IMAGE=base\n cache_from:\n - a\n\n - b\n\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := generateComposeBuildOverride(tt.in); got != tt.want { + t.Errorf("build override mismatch\ngot: %q\nwant: %q", got, tt.want) + } + }) + } +} + +func TestComposeMountsFromMetadata(t *testing.T) { + identity := func(s string) (string, error) { return s, nil } + got, err := composeMountsFromMetadata([]any{ + "type=volume,src=data,dst=/data", + "source=/h,target=/c,type=bind,readonly", + map[string]any{"type": "volume", "source": "ext", "target": "/e", "external": true}, + }, identity) + if err != nil { + t.Fatal(err) + } + want := []composeMount{ + {Type: "volume", Source: "data", Target: "/data"}, + {Type: "bind", Source: "/h", Target: "/c", ReadOnly: true}, + {Type: "volume", Source: "ext", Target: "/e", External: true}, + } + if len(got) != len(want) { + t.Fatalf("got %d mounts, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("mount %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestReadComposeVersionPrefix(t *testing.T) { + dir := t.TempDir() + withVersion := filepath.Join(dir, "a.yml") + if err := os.WriteFile(withVersion, []byte("# c\n version: '3.8'\nservices: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + without := filepath.Join(dir, "b.yml") + if err := os.WriteFile(without, []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if got, _ := readComposeVersionPrefix([]string{withVersion, without}); got != "version: '3.8'\n\n" { + t.Errorf("got %q", got) + } + if got, _ := readComposeVersionPrefix([]string{without}); got != "" { + t.Errorf("got %q", got) + } +} + +// The compose Features Dockerfile is appended to the service's Dockerfile, so +// the global ARG must be in the prefix (before the service's first FROM), and +// the Features' content comes from the additional build context or from the +// temp content image (TS getFeaturesBuildOptions). +func TestComposeFeaturesBuild(t *testing.T) { + fss := []*features.Set{{Features: []features.Feature{{ID: "hello", ConsecutiveId: "hello_0", Value: true}}}} + const arg = "ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + + temp := imagemeta.GenerateFeaturesBuildOptions(imagemeta.FeaturesBuildParams{BaseName: "dev_container_auto_added_stage_label", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root"}) + if temp.DockerfilePrefixContent != "\n"+arg { + t.Errorf("prefix = %q, want %q", temp.DockerfilePrefixContent, "\n"+arg) + } + for _, want := range []string{ + "FROM dev_container_feature_content_temp as dev_containers_feature_content_source\n", + "FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\n", + "COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/hello_0 /tmp/dev-container-features/hello_0\n", + } { + if !strings.Contains(temp.DockerfileContent, want) { + t.Errorf("content missing %q:\n%s", want, temp.DockerfileContent) + } + } + if len(temp.BuildKitContexts) != 0 { + t.Errorf("temp image build: build contexts set: %v", temp.BuildKitContexts) + } + if temp.BuildArgs["_DEV_CONTAINERS_BASE_IMAGE"] != "dev_container_auto_added_stage_label" { + t.Errorf("build args = %v", temp.BuildArgs) + } + + ctx := imagemeta.GenerateFeaturesBuildOptions(imagemeta.FeaturesBuildParams{BaseName: "base", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true, Syntax: "docker/dockerfile:1.4"}) + if ctx.DockerfilePrefixContent != "# syntax=docker/dockerfile:1.4\n"+arg { + t.Errorf("prefix = %q", ctx.DockerfilePrefixContent) + } + if strings.Contains(ctx.DockerfileContent, "AS dev_containers_feature_content_source") || + !strings.Contains(ctx.DockerfileContent, "RUN --mount=type=bind,from=dev_containers_feature_content_source,source=hello_0,target=/tmp/build-features-src/hello_0 \\\n") { + t.Errorf("content:\n%s", ctx.DockerfileContent) + } + if ctx.BuildKitContexts["dev_containers_feature_content_source"] != "." { + t.Errorf("build contexts = %v", ctx.BuildKitContexts) + } + + // No Features: only the metadata label, byte for byte TS getImageBuildOptions + // (its prefix indents the ARG; the label is getDevcontainerMetadataLabel's). + label := imagemeta.GenerateComposeLabelBuild("base", []imagemeta.Entry{{RemoteUser: "dev"}}, "") + const tsArg = " ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + if label.DockerfilePrefixContent != "\n"+tsArg { + t.Errorf("label-only prefix = %q", label.DockerfilePrefixContent) + } + if syntaxed := imagemeta.GenerateComposeLabelBuild("base", nil, "docker/dockerfile:1.4"); syntaxed.DockerfilePrefixContent != "# syntax=docker/dockerfile:1.4\n"+tsArg { + t.Errorf("label-only prefix with syntax = %q", syntaxed.DockerfilePrefixContent) + } + if want := "\nFROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\nLABEL devcontainer.metadata=\"[ \\\n{\\\"remoteUser\\\":\\\"dev\\\"} \\\n]\"\n"; label.DockerfileContent != want { + t.Errorf("label-only content = %q, want %q", label.DockerfileContent, want) + } +} diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go new file mode 100644 index 0000000..9ee5ace --- /dev/null +++ b/internal/cli/compose_up.go @@ -0,0 +1,692 @@ +package cli + +import ( + "context" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" +) + +// composeAutoAddedStageLabel is the stage name TS buildAndExtendDockerCompose +// gives the service's final stage (or an image-only service's FROM). +const composeAutoAddedStageLabel = "dev_container_auto_added_stage_label" + +// composeSyntaxLineRe is TS's /^\s*#\s*syntax\s*=.*[\r\n]/ (start of file only). +var composeSyntaxLineRe = regexp.MustCompile(`^\s*#\s*syntax\s*=.*[\r\n]`) + +// composeStart holds what startComposeContainer needs (TS startContainer). +type composeStart struct { + cfg *config.DevContainer + loadResult *config.LoadResult + workspaceFolder string + idLabels []string + useBuildx bool + projectName string + composeFiles []string + composeConfig map[string]any + client *docker.ComposeClient + // container is the existing (stopped) service container, nil for none. + container *container.InspectResponse +} + +// persistedFolder is TS common.persistedFolder: --user-data-folder, or +// the CLI cache folder as a fallback. +func (r *upRunner) persistedFolder() string { + if r.opts.userDataFolder != "" { + return r.opts.userDataFolder + } + return cacheFolder() +} + +// composeService returns the service's entry of the `docker compose config` output. +func composeService(composeConfig map[string]any, service string) (map[string]any, bool) { + services, _ := composeConfig["services"].(map[string]any) + svc, ok := services[service].(map[string]any) + return svc, ok +} + +// composeStringList reads a compose entrypoint/command: nil when unset, the list +// as is, or a string split like a shell would (TS shellQuote.parse). +func composeStringList(v any) []string { + switch t := v.(type) { + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + out = append(out, fmt.Sprint(e)) + } + return out + case string: + return splitShellWords(t) + } + return nil +} + +// splitShellWords splits s into words honoring single/double quotes and +// backslash escapes, the subset of shell-quote parsing compose values need. +func splitShellWords(s string) []string { + words := []string{} + var cur strings.Builder + inWord, quote := false, rune(0) + for i := 0; i < len(s); i++ { + c := rune(s[i]) + switch { + case quote != 0: + if c == quote { + quote = 0 + } else if c == '\\' && quote == '"' && i+1 < len(s) { + i++ + cur.WriteByte(s[i]) + } else { + cur.WriteRune(c) + } + case c == '\'' || c == '"': + quote, inWord = c, true + case c == '\\' && i+1 < len(s): + i++ + cur.WriteByte(s[i]) + inWord = true + case c == ' ' || c == '\t' || c == '\n': + if inWord { + words = append(words, cur.String()) + cur.Reset() + inWord = false + } + default: + cur.WriteRune(c) + inWord = true + } + } + if inWord { + words = append(words, cur.String()) + } + return words +} + +// newRandomUUID returns a random (version 4) UUID string. +func newRandomUUID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] &= 0x0f + b[6] |= 0x40 // version 4 + b[8] &= 0x3f + b[8] |= 0x80 // RFC 4122 variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +// persistedComposeFile is TS checkForPersistedFile's result. +type persistedComposeFile struct { + foundLabel, fileExists bool + file string +} + +func (r *upRunner) checkPersistedComposeFile(files []string, prefix string) persistedComposeFile { + for _, f := range files { + if !strings.Contains(f, prefix) { + continue + } + if st, err := os.Stat(f); err == nil && st.Mode().IsRegular() { + r.log.Write(fmt.Sprintf("Restoring %s from persisted storage", f), log.LevelInfo) + return persistedComposeFile{foundLabel: true, fileExists: true, file: f} + } + r.log.Write(fmt.Sprintf("Expected %s to exist, but it did not", f), log.LevelError) + return persistedComposeFile{foundLabel: true, file: f} + } + r.log.Write(fmt.Sprintf("Expected to find a docker-compose file prefixed with %s, but did not.", prefix), log.LevelError) + return persistedComposeFile{} +} + +// startComposeContainer ports TS startContainer (dockerCompose.ts): restore the +// override files a stopped container was created with, or (re)generate the build +// and container overrides — always, with or without Features — then `compose up`. +func (r *upRunner) startComposeContainer(ctx context.Context, s composeStart) (string, error) { + logger, opts := r.log, r.opts + cfg := s.cfg + files := slices.Clone(s.composeFiles) + persisted := r.persistedFolder() + + svc, _ := composeService(s.composeConfig, cfg.Service) + svcImage, _ := svc["image"].(string) + svcUser, _ := svc["user"].(string) + originalImageName := svcImage + if originalImageName == "" { + originalImageName = s.client.DefaultImageName(s.projectName, cfg.Service) + } + + hasLabels := s.container != nil && s.container.Config != nil && s.container.Config.Labels != nil + logger.Write(fmt.Sprintf("PersistedPath=%s, ContainerHasLabels=%t", persisted, hasLabels), log.LevelInfo) + restored := false + if hasLabels { + configFiles, ok := s.container.Config.Labels["com.docker.compose.project.config_files"] + logger.Write(fmt.Sprintf("Container was created with these config files: %s", configFiles), log.LevelInfo) + var labelFiles []string + if ok { + labelFiles = strings.Split(configFiles, ",") + } + buildFile := r.checkPersistedComposeFile(labelFiles, composeBuildOverridePrefix) + startFile := r.checkPersistedComposeFile(labelFiles, composeFeaturesOverridePrefix) + if (buildFile.fileExists || !buildFile.foundLabel) && startFile.fileExists { + restored = true + if buildFile.fileExists { + files = append(files, buildFile.file) + } + files = append(files, startFile.file) + } + } + + if s.container == nil || !restored { + // With an existing container only recreate the override files; skip the build. + noBuild := s.container != nil + versionPrefix, err := readComposeVersionPrefix(s.composeFiles) + if err != nil { + return "", err + } + ext, err := r.buildAndExtendCompose(ctx, s, persisted, versionPrefix, noBuild) + if err != nil { + return "", err + } + files = append(files, ext.overrideFiles...) + + currentImageName := originalImageName + if ext.overrideImageName != "" { + currentImageName = ext.overrideImageName + } + var cached *image.InspectResponse + imageDetails := func() (image.InspectResponse, error) { + if cached != nil { + return *cached, nil + } + info, err := r.inspectImagePulling(ctx, currentImageName) + if err != nil { + return info, err + } + cached = &info + return info, nil + } + merged := imagemeta.MergeConfiguration(ext.imageMetadata) + updatedImageName := currentImageName + if !noBuild { + if updatedImageName, err = r.updateRemoteUserUID(ctx, merged, currentImageName, imageDetails, svcUser, s.workspaceFolder, persisted); err != nil { + return "", err + } + } + + labels := append(slices.Clone(s.idLabels), ext.labels...) + overridePath, err := r.writeComposeFeaturesOverride(ctx, s, updatedImageName, currentImageName, merged, versionPrefix, imageDetails, svc, labels, persisted) + if err != nil { + return "", err + } + files = append(files, overridePath) + } + + noRecreate := s.container != nil || opts.expectExistingContainer + if err := s.client.Up(ctx, files, "", nil, s.projectName, composeServicesFilter(cfg), noRecreate); err != nil { + return "", fmt.Errorf("compose up: %w", err) + } + + ids, err := r.engine.ListContainers(ctx, true, []string{ + "com.docker.compose.project=" + s.projectName, + "com.docker.compose.service=" + cfg.Service, + }) + if err != nil || len(ids) == 0 { + return "", fmt.Errorf("could not find container for service %q in project %q", cfg.Service, s.projectName) + } + return ids[0], nil +} + +// composeServicesFilter is the services `compose build` and `compose up` are +// given, as in TS: none (the whole project) without runServices, otherwise +// runServices plus the dev container service. +func composeServicesFilter(cfg *config.DevContainer) []string { + if len(cfg.RunServices) == 0 { + return nil + } + services := slices.Clone(cfg.RunServices) + if !slices.Contains(services, cfg.Service) { + services = append(services, cfg.Service) + } + return services +} + +// inspectImagePulling is TS inspectDockerImage(..., pullImageOnError=true). +func (r *upRunner) inspectImagePulling(ctx context.Context, ref string) (image.InspectResponse, error) { + info, err := r.engine.InspectImage(ctx, ref) + if err == nil { + return info, nil + } + if pullErr := r.engine.PullImage(ctx, ref); pullErr != nil { + return info, fmt.Errorf("inspect image %q: %w (pull: %v)", ref, err, pullErr) + } + return r.engine.InspectImage(ctx, ref) +} + +// composeExtendResult is what TS buildAndExtendDockerCompose returns. +type composeExtendResult struct { + imageMetadata []imagemeta.Entry + overrideFiles []string + overrideImageName string + // labels are "key=value" container labels (the metadata label for an + // image-only service without Features). + labels []string +} + +// composeFeatureSets fetches the configured Features the way +// extendImageWithFeatures does (lockfile pinning/writing, host substitutions), +// returning nil when none are configured. The returned folder (TS dstFolder) +// holds each Feature at its consecutiveId; it is kept because the compose build +// override references it (TS keeps its features folder too). +func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult) ([]*features.Set, string, error) { + opts := r.opts + var lockfile *features.Lockfile + if cfg.ConfigFilePath != "" && !opts.noLockfile { + lockfile, _, _ = features.ReadLockfile(cfg.ConfigFilePath) + } + res, err := fetchFeatureSetsWithOrder(ctx, r.log, cfg.Features, filepath.Dir(cfg.ConfigFilePath), opts.skipFeatureAutoMapping, lockfile, cfg.OverrideFeatureInstallOrder) + if err != nil { + return nil, "", err + } + if res == nil { + return nil, "", nil + } + if err := substituteFeatureHostVars(res.FeatureSets, loadResult.HostSub); err != nil { + return nil, "", err + } + if (opts.experimentalLockfile || opts.experimentalFrozenLockfile) && cfg.ConfigFilePath != "" { + lf := features.GenerateLockfile(&features.Config{FeatureSets: res.FeatureSets}, opts.lockfileExcludeIDs) + if err := features.WriteLockfile(cfg.ConfigFilePath, lf, features.WriteOptions{Frozen: opts.experimentalFrozenLockfile, Force: opts.experimentalLockfile}); err != nil { + return nil, "", fmt.Errorf("lockfile: %w", err) + } + } + return res.FeatureSets, res.TmpDir, nil +} + +// dockerfileSupportsBuildContexts is TS supportsBuildContexts: truthy for a +// docker/dockerfile frontend >= 1.4 (or untagged/latest/labs) and for an +// unknown frontend. +func dockerfileSupportsBuildContexts(df *docker.Dockerfile) bool { + if df == nil { + return false + } + version := df.Preamble.Version + if version == "" { + return df.Preamble.Directives["syntax"] != "" + } + m := regexp.MustCompile(`^\d+(\.\d+){0,2}`).FindString(version) + if m == "" { + return true + } + parts := strings.Split(m, ".") + major, _ := strconv.Atoi(parts[0]) + if major != 1 { + return major > 1 + } + if len(parts) == 1 { + return true // "1" intersects >=1.4 + } + minor, _ := strconv.Atoi(parts[1]) + return minor >= 4 +} + +// buildAndExtendCompose ports TS buildAndExtendDockerCompose: compute the +// service's image metadata, generate the Features (or metadata-only) Dockerfile +// and the build override, and run `docker compose build` unless noBuild. +func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, persisted, versionPrefix string, noBuild bool) (*composeExtendResult, error) { + logger, opts := r.log, r.opts + cfg := s.cfg + svcBuild := parseComposeServiceBuild(s.composeConfig, cfg.Service) + svc, _ := composeService(s.composeConfig, cfg.Service) + svcUser, _ := svc["user"].(string) + + baseName := composeAutoAddedStageLabel + dockerfile := "" + var parsed *docker.Dockerfile + var baseInfo image.InspectResponse + originalDockerfilePath := "" + if svcBuild.HasBuild { + buildContext := svcBuild.Context + if buildContext == "" { + buildContext = filepath.Dir(s.composeFiles[0]) + } + dockerfilePath := svcBuild.Dockerfile + if dockerfilePath == "" { + dockerfilePath = "Dockerfile" + } + if !filepath.IsAbs(dockerfilePath) { + dockerfilePath = filepath.Join(buildContext, dockerfilePath) + } + originalDockerfilePath = dockerfilePath + raw, err := os.ReadFile(dockerfilePath) + if err != nil { + return nil, fmt.Errorf("read Dockerfile for compose service: %w", err) + } + dockerfile = string(raw) + if svcBuild.Target != "" { + baseName = svcBuild.Target + } else { + name, modified := docker.EnsureFinalStageName(dockerfile, baseName) + baseName = name + if modified != "" { + dockerfile = modified + } + } + parsed = docker.ExtractDockerfile(string(raw)) + if baseImage := docker.FindBaseImage(parsed, svcBuild.Args, svcBuild.Target); baseImage != "" { + info, err := r.inspectImagePulling(ctx, baseImage) + if err != nil { + return nil, err + } + baseInfo = info + } + } else { + if svcBuild.Image == "" { + return nil, fmt.Errorf("compose service %q has neither an image nor a build", cfg.Service) + } + info, err := r.inspectImagePulling(ctx, svcBuild.Image) + if err != nil { + return nil, err + } + baseInfo = info + } + imageUser := "root" + var baseEntries []imagemeta.Entry + if baseInfo.Config != nil { + if baseInfo.Config.User != "" { + imageUser = baseInfo.Config.User + } + baseEntries = imagemeta.ReadMetadataFromLabels(baseInfo.Config.Labels, logger) + } + + featureSets, featuresFolder, err := r.composeFeatureSets(ctx, cfg, s.loadResult) + if err != nil { + return nil, err + } + + configEntry := configMetadataEntry(cfg, opts.omitConfigRemoteEnvFromMeta) + metadata := slices.Clone(baseEntries) + for _, fs := range featureSets { + metadata = append(metadata, featureMetadataEntry(fs, false)) + } + metadata = append(metadata, configEntry) + + result := &composeExtendResult{imageMetadata: metadata} + userSyntax := "" + if parsed != nil { + userSyntax = parsed.Preamble.Directives["syntax"] + } + + var info *imagemeta.ExtendImageBuildInfo + dstFolder := featuresFolder + switch { + case len(featureSets) == 0 && !svcBuild.HasBuild: + // TS getExtendImageBuildInfo: no Features and an image (no Dockerfile) → + // only the metadata label, added to the container. + result.labels = []string{imagemeta.MetadataLabel + "=" + imagemeta.GenerateMetadataLabel(metadata)} + case len(featureSets) == 0: + // TS getImageBuildOptions: extend the Dockerfile with the metadata label. + syntax := userSyntax + if opts.omitSyntaxDirective { + syntax = "" + } + dstFolder = filepath.Join(cacheFolder(), "container-features", fmt.Sprintf("%s-%d", cliVersion(), time.Now().UnixMilli())) + if err := os.MkdirAll(dstFolder, 0o755); err != nil { + return nil, fmt.Errorf("create features folder: %w", err) + } + info = imagemeta.GenerateComposeLabelBuild(baseName, metadata, syntax) + default: + // TS findContainerUsers over the image + Features + config metadata. + containerUser, remoteUser := findContainerUsers(metadata, svcUser, imageUser) + // TS: BuildKit build contexts need Compose >= 2.17.0 (the CLI variant is + // always Docker: Podman is unsupported, see docs/DIVERGENCES.md). + useContexts := s.useBuildx && s.client.SupportsAdditionalContexts() + syntax := "" + switch { + case opts.omitSyntaxDirective: + case engineAtLeast23(ctx, r.engine): + syntax = userSyntax + case useContexts && !dockerfileSupportsBuildContexts(parsed): + syntax = "docker/dockerfile:1.4" + default: + syntax = userSyntax + } + if err := stageFeatureBuildFiles(featuresFolder, featureSets, containerUser, remoteUser); err != nil { + return nil, err + } + info = imagemeta.GenerateFeaturesBuildOptions(imagemeta.FeaturesBuildParams{ + BaseName: baseName, + FeatureSets: featureSets, + Metadata: metadata, + ContainerUser: containerUser, + RemoteUser: remoteUser, + ImageUser: imageUser, + UseBuildKitContexts: useContexts, + Syntax: syntax, + ConfigContainerEnv: cfg.ContainerEnv, + }) + if !useContexts { + if err := buildFeatureContentImage(ctx, r.docker, featuresFolder); err != nil { + return nil, err + } + } + } + + override := composeBuildOverride{VersionPrefix: versionPrefix, Service: cfg.Service, CacheFrom: opts.cacheFrom} + if info != nil { + // Avoid retagging a previously pulled image. + if !svcBuild.HasBuild { + result.overrideImageName = folderImageName(s.workspaceFolder) + override.Image = result.overrideImageName + } + if dockerfile == "" { + dockerfile = fmt.Sprintf("FROM %s AS %s\n", svcBuild.Image, baseName) + } + // The prefix may declare a '# syntax' line, so strip the service's own. + if loc := composeSyntaxLineRe.FindStringIndex(dockerfile); loc != nil { + dockerfile = dockerfile[loc[1]:] + } + final := info.DockerfilePrefixContent + dockerfile + "\n" + info.DockerfileContent + finalPath := filepath.Join(dstFolder, "Dockerfile-with-features") + if err := os.WriteFile(finalPath, []byte(final), 0o644); err != nil { + return nil, fmt.Errorf("write compose Dockerfile: %w", err) + } + if originalDockerfilePath != "" { + // Keep the service Dockerfile's sibling .dockerignore (upstream #969). + copyDockerignore(originalDockerfilePath, finalPath) + } + override.Dockerfile = finalPath + if svcBuild.Target != "" { + override.Target = info.OverrideTarget + } + if !svcBuild.HasBuild { + emptyDir := filepath.Join(persisted, "empty-folder") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + return nil, fmt.Errorf("create empty context folder: %w", err) + } + override.Context = emptyDir + } + override.BuildKitInlineCache = s.useBuildx + for _, k := range sortedStringKeys(info.BuildArgs) { + override.BuildArgs = append(override.BuildArgs, [2]string{k, info.BuildArgs[k]}) + } + for _, k := range sortedStringKeys(info.BuildKitContexts) { + v := info.BuildKitContexts[k] + if v == "." { + v = dstFolder + } + override.AdditionalContexts = append(override.AdditionalContexts, [2]string{k, v}) + } + } + + if content := generateComposeBuildOverride(override); content != "" { + dir := filepath.Join(persisted, "docker-compose") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create compose override folder: %w", err) + } + path := filepath.Join(dir, fmt.Sprintf("%s-%d.yml", composeBuildOverridePrefix, time.Now().UnixMilli())) + logger.Write("Docker Compose override file for building image:\n"+content, log.LevelInfo) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return nil, fmt.Errorf("write compose build override: %w", err) + } + result.overrideFiles = append(result.overrideFiles, path) + } + + if !noBuild { + files := append(slices.Clone(s.composeFiles), result.overrideFiles...) + if err := s.client.Build(ctx, files, "", []string{"--project-name", s.projectName}, composeServicesFilter(cfg), opts.buildNoCache); err != nil { + return nil, fmt.Errorf("An error occurred building the Docker Compose images: %w", err) + } + } + return result, nil +} + +// updateRemoteUserUID ports TS updateRemoteUserUID for a compose service +// (dockerCompose.ts startContainer → containerFeatures.ts): rebuild the image +// with the remote user's UID/GID set to the host user's. +func (r *upRunner) updateRemoteUserUID(ctx context.Context, merged *imagemeta.MergedConfig, imageName string, imageDetails func() (image.InspectResponse, error), svcUser, workspaceFolder, persisted string) (string, error) { + opts := r.opts + enabled := opts.updateRemoteUserUIDDefault == "on" + if merged.UpdateRemoteUserUID != nil { + enabled = *merged.UpdateRemoteUserUID + } + if opts.updateRemoteUserUIDDefault == "never" || !enabled || runtime.GOOS != "linux" { + return imageName, nil + } + details, err := imageDetails() + if err != nil { + return "", err + } + imageUser := "root" + if details.Config != nil && details.Config.User != "" { + imageUser = details.Config.User + } + remoteUser := merged.RemoteUser + if remoteUser == "" { + remoteUser = svcUser + } + if remoteUser == "" { + remoteUser = imageUser + } + if remoteUser == "root" || regexp.MustCompile(`^\d+$`).MatchString(remoteUser) { + return imageName, nil + } + folderImage := folderImageName(workspaceFolder) + fixedImageName := folderImage + "-uid" + if strings.HasPrefix(imageName, folderImage) { + fixedImageName = imageName + "-uid" + } + var platform []string + for _, p := range []string{details.Os, details.Architecture, details.Variant} { + if p != "" { + platform = append(platform, p) + } + } + emptyDir := filepath.Join(persisted, "empty-folder") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + return "", fmt.Errorf("create empty context folder: %w", err) + } + if err := docker.BuildUIDImage(ctx, r.docker, docker.UIDUpdate{ + BaseImage: imageName, + FixedImage: fixedImageName, + RemoteUser: remoteUser, + ImageUser: imageUser, + Platform: strings.Join(platform, "/"), + UID: os.Getuid(), + GID: os.Getgid(), + ContextDir: emptyDir, + }); err != nil { + return "", err + } + return fixedImageName, nil +} + +// writeComposeFeaturesOverride ports TS writeFeaturesComposeOverrideFile: render +// the container override and persist it under /docker-compose. +func (r *upRunner) writeComposeFeaturesOverride(ctx context.Context, s composeStart, updatedImageName, originalImageName string, merged *imagemeta.MergedConfig, versionPrefix string, imageDetails func() (image.InspectResponse, error), svc map[string]any, labels []string, persisted string) (string, error) { + logger, opts, cfg := r.log, r.opts, s.cfg + + devcontainerID := config.ComputeDevContainerID(config.EnvMap(s.idLabels)) + resolve := func(v string) (string, error) { + resolved, err := config.NewVariableResolver().Resolve(config.SubstitutionContext{DevContainerID: devcontainerID}, config.PhaseIdentity, v) + if err != nil { + return "", err + } + str, ok := resolved.(string) + if !ok { + return "", fmt.Errorf("identity substitution returned %T for string", resolved) + } + return str, nil + } + mountEntries := slices.Clone(merged.Mounts) + for _, m := range opts.mounts { + mountEntries = append(mountEntries, m) + } + mounts, err := composeMountsFromMetadata(mountEntries, resolve) + if err != nil { + return "", fmt.Errorf("resolve compose mounts: %w", err) + } + + in := composeFeaturesOverride{ + VersionPrefix: versionPrefix, + Service: cfg.Service, + UpdatedImageName: updatedImageName, + OriginalImageName: originalImageName, + Merged: merged, + ServiceEntrypoint: composeStringList(svc["entrypoint"]), + ServiceCommand: composeStringList(svc["command"]), + Labels: labels, + Mounts: mounts, + } + overrideCommand := merged.OverrideCommand != nil && *merged.OverrideCommand + if !overrideCommand && (in.ServiceEntrypoint == nil || in.ServiceCommand == nil) { + details, err := imageDetails() + if err != nil { + return "", err + } + if details.Config != nil { + in.ImageEntrypoint = details.Config.Entrypoint + in.ImageCmd = details.Config.Cmd + } + } + if cfg.HostRequirements != nil && gpuRequested(cfg.HostRequirements.GPU) { + in.GPU = checkGPUAvailability(ctx, opts.gpuAvailability, r.docker) + if !in.GPU && strings.TrimSpace(string(cfg.HostRequirements.GPU)) != `"optional"` { + logger.Write(`No GPU support found yet a GPU was required - consider marking it as "optional"`, log.LevelWarning) + } + } + + content := generateComposeFeaturesOverride(in) + logger.Write("Docker Compose override file for creating container:\n"+content, log.LevelInfo) + name := fmt.Sprintf("%s-%d-%s.yml", composeFeaturesOverridePrefix, time.Now().UnixMilli(), newRandomUUID()) + dir := filepath.Join(persisted, "docker-compose") + logger.Write(fmt.Sprintf("Writing %s to %s", name, dir), log.LevelInfo) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create compose override folder: %w", err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", fmt.Errorf("write compose features override: %w", err) + } + return path, nil +} + +func sortedStringKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go new file mode 100644 index 0000000..7d34ab9 --- /dev/null +++ b/internal/cli/compose_up_held_test.go @@ -0,0 +1,1230 @@ +package cli + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + dockerspec "github.com/moby/docker-image-spec/specs-go/v1" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" + mobyclient "github.com/moby/moby/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// A compose entrypoint/command string is split like shell-quote parses it. +func TestSplitShellWords(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"", []string{}}, + {"a b\tc\nd", []string{"a", "b", "c", "d"}}, + {`'a b' c`, []string{"a b", "c"}}, + {`"ab" cd`, []string{"ab", "cd"}}, + {`"ab"cd`, []string{"abcd"}}, + {`"" x`, []string{"", "x"}}, + {`"x \"y\" z"`, []string{`x "y" z`}}, + {`'a\b'`, []string{`a\b`}}, + {`a\ b`, []string{"a b"}}, + {`"x\y`, []string{"xy"}}, + {`"a\`, []string{`a\`}}, + {`a\`, []string{`a\`}}, + {`x\y`, []string{"xy"}}, + } + for _, tt := range tests { + if got := splitShellWords(tt.in); !slices.Equal(got, tt.want) { + t.Errorf("splitShellWords(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestComposeStringList(t *testing.T) { + if got := composeStringList([]any{"a", 1}); !slices.Equal(got, []string{"a", "1"}) { + t.Errorf("list: %q", got) + } + if got := composeStringList("sh -c 'echo hi'"); !slices.Equal(got, []string{"sh", "-c", "echo hi"}) { + t.Errorf("string: %q", got) + } + if got := composeStringList(nil); got != nil { + t.Errorf("unset: %q, want nil", got) + } +} + +func TestSortedStringKeys(t *testing.T) { + if got := sortedStringKeys(map[string]string{"b": "2", "a": "1"}); !slices.Equal(got, []string{"a", "b"}) { + t.Errorf("sortedStringKeys = %q", got) + } +} + +// TS supportsBuildContexts: docker/dockerfile >= 1.4, an unversioned or +// non-numeric tag, or a frontend that is not docker/dockerfile. +func TestDockerfileSupportsBuildContexts(t *testing.T) { + df := func(version, syntax string) *docker.Dockerfile { + return &docker.Dockerfile{Preamble: docker.Preamble{Version: version, Directives: map[string]string{"syntax": syntax}}} + } + tests := []struct { + name string + df *docker.Dockerfile + want bool + }{ + {"no Dockerfile", nil, false}, + {"no syntax", df("", ""), false}, + {"other frontend", df("", "example.com/frontend"), true}, + {"labs", df("labs", "docker/dockerfile:labs"), true}, + {"1", df("1", "docker/dockerfile:1"), true}, + {"1.3", df("1.3", "docker/dockerfile:1.3"), false}, + {"1.3-labs", df("1.3-labs", "docker/dockerfile:1.3-labs"), false}, + {"1.4", df("1.4", "docker/dockerfile:1.4"), true}, + {"1.4.0", df("1.4.0", "docker/dockerfile:1.4.0"), true}, + {"1.10", df("1.10", "docker/dockerfile:1.10"), true}, + {"2", df("2", "docker/dockerfile:2"), true}, + {"2.0", df("2.0", "docker/dockerfile:2.0"), true}, + {"0.9", df("0.9", "docker/dockerfile:0.9"), false}, + } + for _, tt := range tests { + if got := dockerfileSupportsBuildContexts(tt.df); got != tt.want { + t.Errorf("%s: dockerfileSupportsBuildContexts = %t, want %t", tt.name, got, tt.want) + } + } +} + +// The override file name carries a random version 4 UUID. +func TestNewRandomUUID(t *testing.T) { + re := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + seen := map[string]bool{} + for range 64 { + u := newRandomUUID() + if !re.MatchString(u) { + t.Fatalf("newRandomUUID() = %q, not a version 4 UUID", u) + } + seen[u] = true + } + if len(seen) != 64 { + t.Errorf("64 UUIDs, %d distinct", len(seen)) + } +} + +// TS checkForPersistedFile: the label names the file, and it is restored only +// when it is still a regular file. +func TestCheckPersistedComposeFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, composeBuildOverridePrefix+"-1.yml") + if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + asDir := filepath.Join(dir, composeFeaturesOverridePrefix+"-2.yml") + if err := os.Mkdir(asDir, 0o755); err != nil { + t.Fatal(err) + } + r := &upRunner{log: log.New(log.Options{Writer: io.Discard})} + other := filepath.Join(dir, "docker-compose.yml") + tests := []struct { + name string + files []string + prefix string + want persistedComposeFile + }{ + {"present", []string{other, file}, composeBuildOverridePrefix, persistedComposeFile{foundLabel: true, fileExists: true, file: file}}, + {"a directory", []string{other, asDir}, composeFeaturesOverridePrefix, persistedComposeFile{foundLabel: true, file: asDir}}, + {"gone", []string{filepath.Join(dir, composeFeaturesOverridePrefix+"-3.yml")}, composeFeaturesOverridePrefix, persistedComposeFile{foundLabel: true, file: filepath.Join(dir, composeFeaturesOverridePrefix+"-3.yml")}}, + {"not in the label", []string{other}, composeBuildOverridePrefix, persistedComposeFile{}}, + } + for _, tt := range tests { + if got := r.checkPersistedComposeFile(tt.files, tt.prefix); got != tt.want { + t.Errorf("%s: %+v, want %+v", tt.name, got, tt.want) + } + } +} + +// composeFakeEngine is the Docker Engine API the compose flow talks to: images +// present locally, images a pull brings in, the daemon version and the +// containers a label lookup finds. A call to any other API method panics. +type composeFakeEngine struct { + docker.API + images map[string]image.InspectResponse + registry map[string]image.InspectResponse + inspects []string + pulls []string + version string + containers []string + listErr error +} + +func (f *composeFakeEngine) ImageInspect(_ context.Context, ref string, _ ...mobyclient.ImageInspectOption) (mobyclient.ImageInspectResult, error) { + f.inspects = append(f.inspects, ref) + if img, ok := f.images[ref]; ok { + return mobyclient.ImageInspectResult{InspectResponse: img}, nil + } + return mobyclient.ImageInspectResult{}, errors.New("No such image: " + ref) +} + +func (f *composeFakeEngine) ImagePull(_ context.Context, ref string, _ mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) { + f.pulls = append(f.pulls, ref) + img, ok := f.registry[ref] + if !ok { + return nil, errors.New("pull access denied for " + ref) + } + f.images[ref] = img + return composeFakePull{}, nil +} + +func (f *composeFakeEngine) ServerVersion(context.Context, mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) { + return mobyclient.ServerVersionResult{Version: f.version}, nil +} + +func (f *composeFakeEngine) ContainerList(context.Context, mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) { + var res mobyclient.ContainerListResult + for _, id := range f.containers { + res.Items = append(res.Items, container.Summary{ID: id}) + } + return res, f.listErr +} + +type composeFakePull struct{ mobyclient.ImagePullResponse } + +func (composeFakePull) Read([]byte) (int, error) { return 0, io.EOF } +func (composeFakePull) Close() error { return nil } + +// composeFakeRunner records the docker and docker compose processes the flow +// starts; one whose command line contains failOn exits 1. +type composeFakeRunner struct { + calls [][]string + failOn string +} + +func (f *composeFakeRunner) Run(_ context.Context, _ io.Writer, name string, args ...string) ([]byte, []byte, int, error) { + f.calls = append(f.calls, append([]string{name}, args...)) + if f.failOn != "" && strings.Contains(strings.Join(args, " "), f.failOn) { + return nil, []byte("failed"), 1, nil + } + return nil, nil, 0, nil +} + +// compose returns the arguments of each `docker compose ... ...` run. +func (f *composeFakeRunner) compose(sub string) [][]string { + var out [][]string + for _, c := range f.calls { + if len(c) > 1 && c[1] == "compose" && slices.Contains(c, sub) { + out = append(out, c[2:]) + } + } + return out +} + +// dockerBuilds returns the arguments of each `docker build` run. +func (f *composeFakeRunner) dockerBuilds() [][]string { + var out [][]string + for _, c := range f.calls { + if len(c) > 1 && c[1] != "compose" && slices.Contains(c, "build") { + out = append(out, c[1:]) + } + } + return out +} + +// composeHarness is a compose project on disk (.devcontainer with a compose +// file) and an upRunner whose docker, compose and engine are fakes. +type composeHarness struct { + t *testing.T + r *upRunner + engine *composeFakeEngine + run *composeFakeRunner + logs *bytes.Buffer + ws, dc string + compose string + persisted string +} + +const composeTestBaseMetadata = `[{"remoteUser":"node"}]` + +func newComposeHarness(t *testing.T) *composeHarness { + t.Helper() + t.Setenv("TMPDIR", t.TempDir()) + ws := t.TempDir() + dc := filepath.Join(ws, ".devcontainer") + if err := os.MkdirAll(dc, 0o755); err != nil { + t.Fatal(err) + } + compose := filepath.Join(dc, "docker-compose.yml") + if err := os.WriteFile(compose, []byte("services:\n app:\n image: alpine:3\n"), 0o644); err != nil { + t.Fatal(err) + } + logs := &bytes.Buffer{} + logger := log.New(log.Options{Level: log.LevelTrace, Format: "text", Writer: logs}) + engine := &composeFakeEngine{ + images: map[string]image.InspectResponse{ + "alpine:3": { + Os: "linux", Architecture: "amd64", + Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{ + Labels: map[string]string{imagemeta.MetadataLabel: composeTestBaseMetadata}, + Entrypoint: []string{"docker-entrypoint.sh"}, + Cmd: []string{"sh"}, + }}, + }, + }, + registry: map[string]image.InspectResponse{}, + version: "24.0.0", + containers: []string{"cid1"}, + } + run := &composeFakeRunner{} + persisted := filepath.Join(t.TempDir(), "persisted") + return &composeHarness{ + t: t, + ws: ws, dc: dc, compose: compose, persisted: persisted, + engine: engine, run: run, logs: logs, + r: &upRunner{ + log: logger, + docker: &docker.Client{DockerPath: "docker", Log: logger, Runner: run}, + engine: &docker.EngineClient{API: engine, Log: logger}, + opts: &upOpts{userDataFolder: persisted, updateRemoteUserUIDDefault: "off", gpuAvailability: "none"}, + }, + } +} + +// start is the composeStart for service app of the given compose config. +func (h *composeHarness) start(cfg *config.DevContainer, svc map[string]any) composeStart { + if cfg.Service == "" { + cfg.Service = "app" + } + if cfg.ConfigFilePath == "" { + cfg.ConfigFilePath = filepath.Join(h.dc, "devcontainer.json") + } + return composeStart{ + cfg: cfg, + loadResult: &config.LoadResult{}, + workspaceFolder: h.ws, + idLabels: []string{"devcontainer.local_folder=" + h.ws}, + projectName: "proj", + composeFiles: []string{h.compose}, + composeConfig: map[string]any{"services": map[string]any{cfg.Service: svc}}, + client: &docker.ComposeClient{Command: []string{"docker", "compose"}, Version: []int{2, 20, 0}, Log: h.r.log, Runner: h.run}, + } +} + +func (h *composeHarness) write(rel, content string) string { + h.t.Helper() + p := filepath.Join(h.dc, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + h.t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + h.t.Fatal(err) + } + return p +} + +// localFeature writes the Feature ./hello next to devcontainer.json. +func (h *composeHarness) localFeature() map[string]any { + h.write("hello/devcontainer-feature.json", `{"id":"hello","version":"1.0.0","name":"Hello","containerEnv":{"HELLO":"${localEnv:HELLO_SRC}"}}`) + h.write("hello/install.sh", "#!/bin/sh\n") + return map[string]any{"./hello": map[string]any{}} +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func overrideFiles(t *testing.T, persisted string) []string { + t.Helper() + files, err := filepath.Glob(filepath.Join(persisted, "docker-compose", "*.yml")) + if err != nil { + t.Fatal(err) + } + return files +} + +func TestComposeServicesFilter(t *testing.T) { + tests := []struct { + run []string + want []string + }{ + {nil, nil}, + {[]string{"db"}, []string{"db", "app"}}, + {[]string{"app", "db"}, []string{"app", "db"}}, + } + for _, tt := range tests { + cfg := &config.DevContainer{Service: "app", RunServices: tt.run} + if got := composeServicesFilter(cfg); !slices.Equal(got, tt.want) { + t.Errorf("runServices %q: %q, want %q", tt.run, got, tt.want) + } + } +} + +// TS inspectDockerImage with pullImageOnError: a missing image is pulled once +// and inspected again; a failed pull is the error. +func TestInspectImagePulling(t *testing.T) { + h := newComposeHarness(t) + ctx := context.Background() + if _, err := h.r.inspectImagePulling(ctx, "alpine:3"); err != nil || len(h.engine.pulls) != 0 { + t.Fatalf("present image: err = %v, pulls = %q", err, h.engine.pulls) + } + h.engine.registry["node:22"] = image.InspectResponse{ID: "sha256:node"} + info, err := h.r.inspectImagePulling(ctx, "node:22") + if err != nil || info.ID != "sha256:node" || !slices.Equal(h.engine.pulls, []string{"node:22"}) { + t.Fatalf("pulled image: %q, %v, pulls = %q", info.ID, err, h.engine.pulls) + } + if _, err := h.r.inspectImagePulling(ctx, "nowhere:1"); err == nil || !strings.Contains(err.Error(), "pull access denied for nowhere:1") { + t.Errorf("unpullable image: err = %v", err) + } +} + +// TS updateRemoteUserUID: the image's user is the remote user when nothing +// names another, and the UID image is built for the image's platform. +func TestUpdateRemoteUserUIDImageUserAndPlatform(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("updateRemoteUserUID only runs on Linux") + } + tests := []struct { + name string + details image.InspectResponse + want string + platform string + }{ + {"image user", image.InspectResponse{Os: "linux", Architecture: "arm64", Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{User: "vscode"}}}, "-uid", "--platform linux/arm64 "}, + {"variant", image.InspectResponse{Os: "linux", Architecture: "arm", Variant: "v7", Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{User: "vscode"}}}, "-uid", "--platform linux/arm/v7 "}, + {"image runs as root", image.InspectResponse{Os: "linux", Config: &dockerspec.DockerOCIImageConfig{}}, "", ""}, + {"no image config", image.InspectResponse{Os: "linux"}, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.updateRemoteUserUIDDefault = "on" + details := func() (image.InspectResponse, error) { return tt.details, nil } + got, err := h.r.updateRemoteUserUID(context.Background(), &imagemeta.MergedConfig{}, "img", details, "", h.ws, h.persisted) + if err != nil { + t.Fatal(err) + } + builds := h.run.dockerBuilds() + if tt.want == "" { + if got != "img" || len(builds) != 0 { + t.Errorf("got %q after %d builds, want img unchanged", got, len(builds)) + } + return + } + if want := folderImageName(h.ws) + tt.want; got != want { + t.Errorf("got %q, want %q", got, want) + } + if len(builds) != 1 { + t.Fatalf("%d builds, want 1", len(builds)) + } + line := strings.Join(builds[0], " ") + " " + for _, want := range []string{tt.platform, "REMOTE_USER=vscode ", "IMAGE_USER=vscode "} { + if !strings.Contains(line, want) { + t.Errorf("build %q lacks %q", line, want) + } + } + }) + } +} + +// The container override: mounts with ${devcontainerId} resolved plus the +// --mount values, the image's entrypoint/command where compose sets none, the +// labels, and the GPU request. +func TestWriteComposeFeaturesOverride(t *testing.T) { + ctx := context.Background() + boolp := func(b bool) *bool { return &b } + unexpected := func() (image.InspectResponse, error) { + return image.InspectResponse{}, errors.New("image details read although compose sets entrypoint and command") + } + t.Run("mounts and image entrypoint", func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.mounts = []string{"type=bind,source=/host,target=/cli"} + svc := map[string]any{"image": "alpine:3", "command": []any{"sleep", "infinity"}} + s := h.start(&config.DevContainer{}, svc) + merged := &imagemeta.MergedConfig{Mounts: []any{"type=volume,source=${devcontainerId}-data,target=/data"}} + calls := 0 + details := func() (image.InspectResponse, error) { calls++; return h.engine.images["alpine:3"], nil } + path, err := h.r.writeComposeFeaturesOverride(ctx, s, "alpine:3", "alpine:3", merged, "version: '3'\n\n", details, svc, []string{"a=b"}, h.persisted) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(path) != filepath.Join(h.persisted, "docker-compose") || + !regexp.MustCompile(`^docker-compose\.devcontainer\.containerFeatures-\d+-[0-9a-f-]{36}\.yml$`).MatchString(filepath.Base(path)) { + t.Errorf("override path %q", path) + } + content := readFile(t, path) + id := config.ComputeDevContainerID(config.EnvMap(s.idLabels)) + for _, want := range []string{ + "version: '3'\n\nservices:\n 'app':\n", + `, "-", "docker-entrypoint.sh"]`, + "\n labels:\n - 'a=b'", + "\n volumes:\n - " + id + "-data:/data\n - /host:/cli\n", + } { + if !strings.Contains(content, want) { + t.Errorf("override lacks %q:\n%s", want, content) + } + } + if strings.Contains(content, "command:") || strings.Contains(content, "deploy:") { + t.Errorf("override sets the compose command or a GPU:\n%s", content) + } + if calls != 1 { + t.Errorf("image details read %d times, want 1", calls) + } + }) + tests := []struct { + name string + svc map[string]any + merged *imagemeta.MergedConfig + details func() (image.InspectResponse, error) + want string + }{ + {"image entrypoint and command", map[string]any{}, &imagemeta.MergedConfig{}, nil, `"-", "docker-entrypoint.sh"]` + "\n command: [\"sh\"]\n"}, + {"compose entrypoint and command", map[string]any{"entrypoint": "/ep.sh", "command": "run"}, &imagemeta.MergedConfig{}, unexpected, `"-", "/ep.sh"]` + "\n"}, + {"overrideCommand", map[string]any{}, &imagemeta.MergedConfig{OverrideCommand: boolp(true)}, unexpected, `"-"]` + "\n command: []\n"}, + {"image without config", map[string]any{}, &imagemeta.MergedConfig{}, func() (image.InspectResponse, error) { return image.InspectResponse{}, nil }, `"-"]` + "\n command: []\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + details := tt.details + if details == nil { + details = func() (image.InspectResponse, error) { return h.engine.images["alpine:3"], nil } + } + s := h.start(&config.DevContainer{}, tt.svc) + path, err := h.r.writeComposeFeaturesOverride(ctx, s, "img", "img", tt.merged, "", details, tt.svc, nil, h.persisted) + if err != nil { + t.Fatal(err) + } + if content := readFile(t, path); !strings.HasSuffix(content, tt.want) { + t.Errorf("override does not end with %q:\n%s", tt.want, content) + } + }) + } + t.Run("image details fail", func(t *testing.T) { + h := newComposeHarness(t) + s := h.start(&config.DevContainer{}, map[string]any{}) + failing := func() (image.InspectResponse, error) { return image.InspectResponse{}, errors.New("no such image") } + if _, err := h.r.writeComposeFeaturesOverride(ctx, s, "img", "img", &imagemeta.MergedConfig{}, "", failing, map[string]any{}, nil, h.persisted); err == nil || err.Error() != "no such image" { + t.Errorf("err = %v, want the image details error", err) + } + }) + t.Run("persisted folder unusable", func(t *testing.T) { + h := newComposeHarness(t) + if err := os.WriteFile(h.persisted, nil, 0o644); err != nil { + t.Fatal(err) + } + s := h.start(&config.DevContainer{}, map[string]any{}) + if _, err := h.r.writeComposeFeaturesOverride(ctx, s, "img", "img", &imagemeta.MergedConfig{OverrideCommand: boolp(true)}, "", unexpected, map[string]any{}, nil, h.persisted); err == nil || !strings.Contains(err.Error(), "create compose override folder") { + t.Errorf("err = %v", err) + } + }) + gpus := []struct { + name, gpu, availability string + deploy, warning bool + }{ + {"no host requirements", "", "all", false, false}, + {"gpu false", "false", "all", false, false}, + {"gpu available", "true", "all", true, false}, + {"gpu required, none available", "true", "none", false, true}, + {"gpu optional, none available", `"optional"`, "none", false, false}, + } + for _, tt := range gpus { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.gpuAvailability = tt.availability + cfg := &config.DevContainer{} + if tt.gpu != "" { + cfg.HostRequirements = &config.HostRequirements{GPU: json.RawMessage(tt.gpu)} + } + s := h.start(cfg, map[string]any{}) + path, err := h.r.writeComposeFeaturesOverride(ctx, s, "img", "img", &imagemeta.MergedConfig{OverrideCommand: boolp(true)}, "", unexpected, map[string]any{}, nil, h.persisted) + if err != nil { + t.Fatal(err) + } + if got := strings.Contains(readFile(t, path), "\n deploy:\n"); got != tt.deploy { + t.Errorf("deploy section = %t, want %t", got, tt.deploy) + } + if got := strings.Contains(h.logs.String(), "No GPU support found yet a GPU was required"); got != tt.warning { + t.Errorf("GPU warning = %t, want %t", got, tt.warning) + } + }) + } +} + +// featureTarball is a gzipped Feature tarball holding id. +func featureTarball(t *testing.T, id string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, body := range map[string]string{ + "devcontainer-feature.json": `{"id":"` + id + `","version":"1.0.0","name":"` + id + `"}`, + "install.sh": "#!/bin/sh\n", + } { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// The compose flow fetches Features like extendImageWithFeatures: host +// variables substituted, the lockfile read unless --no-lockfile and written +// only when asked. +func TestComposeFeatureSets(t *testing.T) { + ctx := context.Background() + lr := &config.LoadResult{HostSub: config.HostSubContext{Platform: "linux", Env: map[string]string{"HELLO_SRC": "from-host"}}} + t.Run("no features", func(t *testing.T) { + h := newComposeHarness(t) + sets, dir, err := h.r.composeFeatureSets(ctx, &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json")}, lr) + if sets != nil || dir != "" || err != nil { + t.Errorf("got %v, %q, %v; want nothing", sets, dir, err) + } + }) + t.Run("local feature", func(t *testing.T) { + h := newComposeHarness(t) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + sets, dir, err := h.r.composeFeatureSets(ctx, cfg, lr) + if err != nil { + t.Fatal(err) + } + if len(sets) != 1 || len(sets[0].Features) != 1 || sets[0].Features[0].ID != "hello" { + t.Fatalf("sets = %+v", sets) + } + if got := sets[0].Features[0].ContainerEnv["HELLO"]; got != "from-host" { + t.Errorf("containerEnv HELLO = %q, want the host variable substituted", got) + } + if st, err := os.Stat(dir); err != nil || !st.IsDir() { + t.Errorf("features folder %q: %v", dir, err) + } + if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); !os.IsNotExist(err) { + t.Errorf("lockfile written without --experimental-lockfile: %v", err) + } + }) + t.Run("missing feature", func(t *testing.T) { + h := newComposeHarness(t) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: map[string]any{"./nope": map[string]any{}}} + if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err == nil { + t.Error("want an error for a Feature folder that does not exist") + } + }) + t.Run("experimental lockfile", func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.experimentalLockfile = true + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(h.dc, "devcontainer-lock.json")); err != nil { + t.Errorf("lockfile not written: %v", err) + } + }) + t.Run("frozen lockfile out of date", func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.experimentalFrozenLockfile = true + h.write("devcontainer-lock.json", `{"features":{"stale":{}}}`) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err == nil || !strings.HasPrefix(err.Error(), "lockfile: ") { + t.Errorf("err = %v, want the lockfile error", err) + } + }) + t.Run("existing lockfile left alone", func(t *testing.T) { + h := newComposeHarness(t) + lock := h.write("devcontainer-lock.json", `{"features":{"stale":{}}}`) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: h.localFeature()} + if _, _, err := h.r.composeFeatureSets(ctx, cfg, lr); err != nil { + t.Fatal(err) + } + if got := readFile(t, lock); got != `{"features":{"stale":{}}}` { + t.Errorf("lockfile rewritten without a lockfile flag: %s", got) + } + }) + t.Run("lockfile pins a tarball", func(t *testing.T) { + tgz := featureTarball(t, "tarred") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(tgz) })) + defer srv.Close() + uri := srv.URL + "/devcontainer-feature-tarred.tgz" + for _, noLockfile := range []bool{false, true} { + h := newComposeHarness(t) + h.r.opts.noLockfile = noLockfile + h.write("devcontainer-lock.json", `{"features":{"`+uri+`":{"version":"1.0.0","resolved":"`+uri+`","integrity":"sha256:`+strings.Repeat("0", 64)+`"}}}`) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(h.dc, "devcontainer.json"), Features: map[string]any{uri: map[string]any{}}} + _, _, err := h.r.composeFeatureSets(ctx, cfg, lr) + if mismatch := err != nil && strings.Contains(err.Error(), "Digest did not match"); mismatch == noLockfile { + t.Errorf("noLockfile=%t: err = %v", noLockfile, err) + } + } + }) +} + +// overrideDockerfile is the `dockerfile:` a build override sets. +func overrideDockerfile(t *testing.T, override string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^ dockerfile: (.*)$`).FindStringSubmatch(override) + if m == nil { + t.Fatalf("build override sets no dockerfile:\n%s", override) + } + return m[1] +} + +// A Dockerfile service without Features: TS getImageBuildOptions extends the +// service's Dockerfile with the metadata label, and compose builds it. +func TestBuildAndExtendComposeDockerfileService(t *testing.T) { + h := newComposeHarness(t) + h.write("Dockerfile", "# syntax=docker/dockerfile:1.4\nFROM alpine:3\nRUN true\n") + h.write("Dockerfile.dockerignore", "secret\n") + // No context: the compose file's folder. + s := h.start(&config.DevContainer{}, map[string]any{"build": map[string]any{}}) + s.useBuildx = true + res, err := h.r.buildAndExtendCompose(context.Background(), s, h.persisted, "", false) + if err != nil { + t.Fatal(err) + } + if len(res.overrideFiles) != 1 { + t.Fatalf("override files = %q", res.overrideFiles) + } + override := readFile(t, res.overrideFiles[0]) + final := overrideDockerfile(t, override) + if filepath.Base(final) != "Dockerfile-with-features" || !strings.HasPrefix(final, filepath.Join(cacheFolder(), "container-features")+string(filepath.Separator)) { + t.Errorf("final Dockerfile %q", final) + } + if want := "services:\n app:\n build:\n dockerfile: " + final + "\n args:\n - BUILDKIT_INLINE_CACHE=1\n - _DEV_CONTAINERS_BASE_IMAGE=dev_container_auto_added_stage_label\n\n"; override != want { + t.Errorf("build override\ngot: %q\nwant: %q", override, want) + } + content := readFile(t, final) + if want := "# syntax=docker/dockerfile:1.4\n ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\nFROM alpine:3 AS dev_container_auto_added_stage_label\nRUN true\n\n\nFROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\nLABEL devcontainer.metadata="; !strings.HasPrefix(content, want) || strings.Count(content, "# syntax") != 1 { + t.Errorf("final Dockerfile\ngot: %q\nwant: %q...", content, want) + } + if got := readFile(t, final+".dockerignore"); got != "secret\n" { + t.Errorf("sibling .dockerignore = %q", got) + } + if len(res.imageMetadata) != 2 || res.imageMetadata[0].RemoteUser != "node" { + t.Errorf("image metadata = %+v, want the base image's then the config's", res.imageMetadata) + } + if res.labels != nil || res.overrideImageName != "" { + t.Errorf("labels %q, override image %q; want none", res.labels, res.overrideImageName) + } + if got, want := h.run.compose("build"), [][]string{{"-f", h.compose, "-f", res.overrideFiles[0], "--project-name", "proj", "build"}}; !slices.EqualFunc(got, want, slices.Equal) { + t.Errorf("compose build runs %q, want %q", got, want) + } +} + +// The service's target is replaced by the extended stage, and its Dockerfile +// path resolves against the build context unless absolute. +func TestBuildAndExtendComposeTargetAndDockerfilePath(t *testing.T) { + const df = "# syntax=docker/dockerfile:1.4\nFROM alpine:3 AS base\nFROM base AS dev\nFROM base AS other\n" + for _, absolute := range []bool{false, true} { + h := newComposeHarness(t) + h.r.opts.omitSyntaxDirective = true + path := h.write("sub/Dockerfile", df) + build := map[string]any{"context": h.dc, "dockerfile": "sub/Dockerfile", "target": "dev"} + if absolute { + build = map[string]any{"context": h.ws, "dockerfile": path, "target": "dev"} + } + s := h.start(&config.DevContainer{}, map[string]any{"build": build}) + res, err := h.r.buildAndExtendCompose(context.Background(), s, h.persisted, "", true) + if err != nil { + t.Fatalf("absolute=%t: %v", absolute, err) + } + override := readFile(t, res.overrideFiles[0]) + for _, want := range []string{" target: dev_containers_target_stage\n", " - _DEV_CONTAINERS_BASE_IMAGE=dev\n"} { + if !strings.Contains(override, want) { + t.Errorf("absolute=%t: override lacks %q:\n%s", absolute, want, override) + } + } + if strings.Contains(override, "BUILDKIT_INLINE_CACHE") { + t.Errorf("absolute=%t: inline cache without buildx:\n%s", absolute, override) + } + content := readFile(t, overrideDockerfile(t, override)) + if !strings.HasPrefix(content, "\n ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\nFROM alpine:3 AS base\nFROM base AS dev\nFROM base AS other\n") { + t.Errorf("absolute=%t: final Dockerfile %q", absolute, content) + } + if !slices.Contains(h.engine.inspects, "alpine:3") || len(h.run.calls) != 0 { + t.Errorf("absolute=%t: inspected %q, ran %q", absolute, h.engine.inspects, h.run.calls) + } + } +} + +// An image service without Features gets the metadata label on the container, +// and a build override only for --cache-from. +func TestBuildAndExtendComposeImageService(t *testing.T) { + ctx := context.Background() + t.Run("label only", func(t *testing.T) { + h := newComposeHarness(t) + s := h.start(&config.DevContainer{}, map[string]any{"image": "alpine:3"}) + res, err := h.r.buildAndExtendCompose(ctx, s, h.persisted, "", false) + if err != nil { + t.Fatal(err) + } + if want := []string{imagemeta.MetadataLabel + "=" + imagemeta.GenerateMetadataLabel(res.imageMetadata)}; !slices.Equal(res.labels, want) { + t.Errorf("labels = %q, want %q", res.labels, want) + } + if len(res.overrideFiles) != 0 || len(overrideFiles(t, h.persisted)) != 0 { + t.Errorf("override files written: %q", res.overrideFiles) + } + if got, want := h.run.compose("build"), [][]string{{"-f", h.compose, "--project-name", "proj", "build"}}; !slices.EqualFunc(got, want, slices.Equal) { + t.Errorf("compose build runs %q, want %q", got, want) + } + }) + t.Run("cache-from, runServices, no cache", func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.cacheFrom = []string{"c1"} + h.r.opts.buildNoCache = true + s := h.start(&config.DevContainer{RunServices: []string{"db"}}, map[string]any{"image": "alpine:3"}) + res, err := h.r.buildAndExtendCompose(ctx, s, h.persisted, "", false) + if err != nil { + t.Fatal(err) + } + if len(res.overrideFiles) != 1 || filepath.Dir(res.overrideFiles[0]) != filepath.Join(h.persisted, "docker-compose") { + t.Fatalf("override files = %q", res.overrideFiles) + } + if got := readFile(t, res.overrideFiles[0]); got != "services:\n app:\n\n cache_from:\n - c1\n\n" { + t.Errorf("build override = %q", got) + } + if got, want := h.run.compose("build"), [][]string{{"-f", h.compose, "-f", res.overrideFiles[0], "--project-name", "proj", "build", "--no-cache", "db", "app"}}; !slices.EqualFunc(got, want, slices.Equal) { + t.Errorf("compose build runs %q, want %q", got, want) + } + }) + t.Run("no build", func(t *testing.T) { + h := newComposeHarness(t) + s := h.start(&config.DevContainer{}, map[string]any{"image": "alpine:3"}) + if _, err := h.r.buildAndExtendCompose(ctx, s, h.persisted, "", true); err != nil || len(h.run.calls) != 0 { + t.Errorf("err = %v, ran %q", err, h.run.calls) + } + }) +} + +// An image service with Features is built by compose from an override that +// retags the folder image, gives an empty context and takes the Features' +// content from a build context or from the temp content image. +func TestBuildAndExtendComposeImageServiceWithFeatures(t *testing.T) { + tests := []struct { + name string + buildx bool + compose []int + engine string + omitSyntax bool + contexts bool + syntax string + contentImage bool + }{ + {"no buildx", false, []int{2, 20, 0}, "20.10.0", false, false, "", true}, + {"build contexts, old engine", true, []int{2, 20, 0}, "20.10.0", false, true, "docker/dockerfile:1.4", false}, + {"build contexts, engine 23+", true, []int{2, 20, 0}, "24.0.0", false, true, "", false}, + {"build contexts, syntax omitted", true, []int{2, 20, 0}, "20.10.0", true, true, "", false}, + {"compose without build contexts", true, []int{2, 16, 0}, "20.10.0", false, false, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + h.engine.version = tt.engine + h.engine.images["alpine:3"].Config.User = "vscode" + h.r.opts.omitSyntaxDirective = tt.omitSyntax + s := h.start(&config.DevContainer{Features: h.localFeature()}, map[string]any{"image": "alpine:3"}) + s.useBuildx = tt.buildx + s.client.Version = tt.compose + res, err := h.r.buildAndExtendCompose(context.Background(), s, h.persisted, "", true) + if err != nil { + t.Fatal(err) + } + if want := folderImageName(h.ws); res.overrideImageName != want { + t.Errorf("override image = %q, want %q", res.overrideImageName, want) + } + if len(res.imageMetadata) != 3 { + t.Errorf("image metadata = %+v, want the image's, the Feature's and the config's", res.imageMetadata) + } + override := readFile(t, res.overrideFiles[0]) + final := overrideDockerfile(t, override) + featuresFolder := filepath.Dir(final) + emptyDir := filepath.Join(h.persisted, "empty-folder") + for _, want := range []string{ + "\n image: " + folderImageName(h.ws) + "\n", + "\n context: " + emptyDir + "\n", + "\n - _DEV_CONTAINERS_IMAGE_USER=vscode\n", + } { + if !strings.Contains(override, want) { + t.Errorf("override lacks %q:\n%s", want, override) + } + } + if st, err := os.Stat(emptyDir); err != nil || !st.IsDir() { + t.Errorf("empty context folder: %v", err) + } + wantContexts := " additional_contexts:\n - dev_containers_feature_content_source=" + featuresFolder + "\n" + if got := strings.Contains(override, wantContexts); got != tt.contexts { + t.Errorf("additional contexts = %t, want %t:\n%s", got, tt.contexts, override) + } + if got := strings.Contains(override, "BUILDKIT_INLINE_CACHE=1"); got != tt.buildx { + t.Errorf("inline cache = %t, want %t", got, tt.buildx) + } + content := readFile(t, final) + if !strings.Contains(content, "FROM alpine:3 AS dev_container_auto_added_stage_label\n") { + t.Errorf("final Dockerfile lacks the service image stage:\n%s", content) + } + wantPrefix := "\nARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + if tt.syntax != "" { + wantPrefix = "# syntax=" + tt.syntax + wantPrefix + } + if !strings.HasPrefix(content, wantPrefix) { + t.Errorf("final Dockerfile does not start with %q:\n%s", wantPrefix, content) + } + if got := len(h.run.dockerBuilds()) == 1; got != tt.contentImage { + t.Errorf("content image built = %t, want %t (%q)", got, tt.contentImage, h.run.calls) + } + }) + } +} + +// With build contexts, a Dockerfile service keeps its own syntax only when its +// frontend supports them (TS supportsBuildContexts). +func TestBuildAndExtendComposeDockerfileWithFeaturesSyntax(t *testing.T) { + for user, want := range map[string]string{"docker/dockerfile:1.3": "docker/dockerfile:1.4", "docker/dockerfile:1.5": "docker/dockerfile:1.5"} { + h := newComposeHarness(t) + h.engine.version = "20.10.0" + h.write("Dockerfile", "# syntax="+user+"\nFROM alpine:3\n") + s := h.start(&config.DevContainer{Features: h.localFeature()}, map[string]any{"build": map[string]any{"context": h.dc, "target": ""}}) + s.useBuildx = true + res, err := h.r.buildAndExtendCompose(context.Background(), s, h.persisted, "", true) + if err != nil { + t.Fatal(err) + } + override := readFile(t, res.overrideFiles[0]) + if strings.Contains(override, "image:") || strings.Contains(override, "context:") || res.overrideImageName != "" { + t.Errorf("%s: a Dockerfile service keeps its image and context:\n%s", user, override) + } + content := readFile(t, overrideDockerfile(t, override)) + if !strings.HasPrefix(content, "# syntax="+want+"\n") || strings.Count(content, "# syntax") != 1 { + t.Errorf("%s: final Dockerfile does not start with syntax %s:\n%s", user, want, content) + } + } +} + +func TestBuildAndExtendComposeErrors(t *testing.T) { + tests := []struct { + name string + svc map[string]any + setup func(h *composeHarness, cfg *config.DevContainer) + wantErr string + }{ + {"no Dockerfile", map[string]any{"build": map[string]any{}}, nil, "read Dockerfile for compose service"}, + {"neither image nor build", map[string]any{}, nil, `compose service "app" has neither an image nor a build`}, + {"image not pullable", map[string]any{"image": "nowhere:1"}, nil, "pull access denied for nowhere:1"}, + {"base image not pullable", map[string]any{"build": map[string]any{}}, func(h *composeHarness, _ *config.DevContainer) { + h.write("Dockerfile", "FROM nowhere:1\n") + }, "pull access denied for nowhere:1"}, + {"missing Feature", map[string]any{"image": "alpine:3"}, func(_ *composeHarness, cfg *config.DevContainer) { + cfg.Features = map[string]any{"./nope": map[string]any{}} + }, "nope"}, + {"compose build fails", map[string]any{"image": "alpine:3"}, func(h *composeHarness, _ *config.DevContainer) { + h.run.failOn = "build" + }, "An error occurred building the Docker Compose images"}, + {"content image fails", map[string]any{"image": "alpine:3"}, func(h *composeHarness, cfg *config.DevContainer) { + cfg.Features = h.localFeature() + h.run.failOn = "Dockerfile.buildContent" + }, "build feature content image"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + cfg := &config.DevContainer{} + if tt.setup != nil { + tt.setup(h, cfg) + } + s := h.start(cfg, tt.svc) + if _, err := h.r.buildAndExtendCompose(context.Background(), s, h.persisted, "", false); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want %q", err, tt.wantErr) + } + }) + } +} + +// A new container: compose builds the service, the container override is +// written and `compose up` creates the project with it. +func TestStartComposeContainerCreates(t *testing.T) { + h := newComposeHarness(t) + svc := map[string]any{"image": "alpine:3", "command": []any{"sleep", "infinity"}} + id, err := h.r.startComposeContainer(context.Background(), h.start(&config.DevContainer{}, svc)) + if err != nil || id != "cid1" { + t.Fatalf("got %q, %v", id, err) + } + if n := len(h.run.compose("build")); n != 1 { + t.Errorf("%d compose builds, want 1", n) + } + files := overrideFiles(t, h.persisted) + if len(files) != 1 || !strings.HasPrefix(filepath.Base(files[0]), composeFeaturesOverridePrefix) { + t.Fatalf("override files = %q", files) + } + if got, want := h.run.compose("up"), [][]string{{"-f", h.compose, "-f", files[0], "--project-name", "proj", "up", "-d"}}; !slices.EqualFunc(got, want, slices.Equal) { + t.Errorf("compose up runs %q, want %q", got, want) + } + override := readFile(t, files[0]) + for _, want := range []string{`"-", "docker-entrypoint.sh"]`, "\n - 'devcontainer.local_folder=" + h.ws + "'\n - 'devcontainer.metadata="} { + if !strings.Contains(override, want) { + t.Errorf("override lacks %q:\n%s", want, override) + } + } + if strings.Contains(override, "image:") { + t.Errorf("override retags an unchanged image:\n%s", override) + } + if !strings.Contains(h.logs.String(), "ContainerHasLabels=false") { + t.Errorf("log lacks ContainerHasLabels=false:\n%s", h.logs.String()) + } +} + +// A stopped container is started with the override files it was created with +// when they are still there; otherwise they are written again, without a build. +func TestStartComposeContainerRestoresOverrides(t *testing.T) { + tests := []struct { + name string + build string // "": not in the label, "gone": in the label but deleted + start string + config bool + labels bool + restored bool + hasLabels bool + }{ + {"both present", "present", "present", true, true, true, true}, + {"no build override", "", "present", true, true, true, true}, + {"build override gone", "gone", "present", true, true, false, true}, + {"container override gone", "present", "gone", true, true, false, true}, + {"no container override", "present", "", true, true, false, true}, + {"no labels", "present", "present", true, false, false, false}, + {"no config", "present", "present", false, false, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.updateRemoteUserUIDDefault = "on" + dir := filepath.Join(h.persisted, "docker-compose") + label := []string{h.compose} + var want []string + for _, f := range []struct{ state, prefix string }{{tt.build, composeBuildOverridePrefix}, {tt.start, composeFeaturesOverridePrefix}} { + if f.state == "" { + continue + } + path := filepath.Join(dir, f.prefix+"-1.yml") + label = append(label, path) + if f.state == "present" { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + want = append(want, path) + } + } + before := overrideFiles(t, h.persisted) + s := h.start(&config.DevContainer{}, map[string]any{"image": "alpine:3", "entrypoint": "/ep.sh", "command": "run"}) + s.container = &container.InspectResponse{} + if tt.config { + s.container.Config = &container.Config{} + if tt.labels { + s.container.Config.Labels = map[string]string{"com.docker.compose.project.config_files": strings.Join(label, ",")} + } + } + if _, err := h.r.startComposeContainer(context.Background(), s); err != nil { + t.Fatal(err) + } + ups := h.run.compose("up") + if len(ups) != 1 { + t.Fatalf("compose up runs %q", ups) + } + up := ups[0] + if len(h.run.compose("build")) != 0 || len(h.run.dockerBuilds()) != 0 { + t.Errorf("an existing container's image was built: %q", h.run.calls) + } + var written []string + for _, f := range overrideFiles(t, h.persisted) { + if !slices.Contains(before, f) { + written = append(written, f) + } + } + files := want + if !tt.restored { + files = written + if len(written) != 1 { + t.Errorf("override files written: %q, want the container override again", written) + } + } else if len(written) != 0 { + t.Errorf("override files written: %q, want the persisted ones restored", written) + } + wantUp := []string{"-f", h.compose} + for _, f := range files { + wantUp = append(wantUp, "-f", f) + } + wantUp = append(wantUp, "--project-name", "proj", "up", "-d", "--no-recreate") + if !slices.Equal(up, wantUp) { + t.Errorf("compose up %q, want %q", up, wantUp) + } + if want := "ContainerHasLabels=" + map[bool]string{true: "true", false: "false"}[tt.hasLabels]; !strings.Contains(h.logs.String(), want) { + t.Errorf("log lacks %s", want) + } + }) + } +} + +func TestStartComposeContainerUpOptions(t *testing.T) { + tests := []struct { + name string + run []string + existing bool + tail []string + }{ + {"runServices", []string{"db"}, false, []string{"up", "-d", "db", "app"}}, + {"expected existing container", nil, true, []string{"up", "-d", "--no-recreate"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + h.r.opts.expectExistingContainer = tt.existing + s := h.start(&config.DevContainer{RunServices: tt.run}, map[string]any{"image": "alpine:3", "entrypoint": "/ep.sh", "command": "run"}) + if _, err := h.r.startComposeContainer(context.Background(), s); err != nil { + t.Fatal(err) + } + ups := h.run.compose("up") + if len(ups) != 1 || !slices.Equal(ups[0][len(ups[0])-len(tt.tail):], tt.tail) { + t.Errorf("compose up runs %q, want one ending in %q", ups, tt.tail) + } + }) + } +} + +func TestStartComposeContainerErrors(t *testing.T) { + tests := []struct { + name string + svc map[string]any + setup func(h *composeHarness, s *composeStart) + wantErr string + }{ + {"compose up fails", nil, func(h *composeHarness, _ *composeStart) { h.run.failOn = "up -d" }, "compose up: compose up failed (exit 1)"}, + {"no container", nil, func(h *composeHarness, _ *composeStart) { h.engine.containers = nil }, `could not find container for service "app" in project "proj"`}, + {"container lookup fails", nil, func(h *composeHarness, _ *composeStart) { h.engine.listErr = errors.New("daemon gone") }, `could not find container for service "app" in project "proj"`}, + {"compose file unreadable", nil, func(h *composeHarness, s *composeStart) { + s.composeFiles = []string{filepath.Join(h.dc, "missing.yml")} + }, "read compose file"}, + {"image not pullable", map[string]any{"image": "nowhere:1", "entrypoint": "/ep.sh", "command": "run"}, nil, "pull access denied for nowhere:1"}, + {"built image missing", map[string]any{"build": map[string]any{}}, func(h *composeHarness, _ *composeStart) { + h.write("Dockerfile", "FROM alpine:3\n") + }, "pull access denied for proj-app"}, + {"persisted folder unusable", nil, func(h *composeHarness, _ *composeStart) { + if err := os.WriteFile(h.persisted, nil, 0o644); err != nil { + h.t.Fatal(err) + } + }, "create compose override folder"}, + } + if runtime.GOOS == "linux" { + tests = append(tests, struct { + name string + svc map[string]any + setup func(h *composeHarness, s *composeStart) + wantErr string + }{"UID image build fails", map[string]any{"build": map[string]any{}}, func(h *composeHarness, _ *composeStart) { + h.write("Dockerfile", "FROM alpine:3\n") + h.engine.images["proj-app"] = image.InspectResponse{Os: "linux"} + h.r.opts.updateRemoteUserUIDDefault = "on" + h.run.failOn = "updateUID" + }, "updateUID build failed"}) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newComposeHarness(t) + svc := tt.svc + if svc == nil { + svc = map[string]any{"image": "alpine:3", "entrypoint": "/ep.sh", "command": "run"} + } + s := h.start(&config.DevContainer{}, svc) + if tt.setup != nil { + tt.setup(h, &s) + } + if id, err := h.r.startComposeContainer(context.Background(), s); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("got %q, %v; want an error with %q", id, err, tt.wantErr) + } + }) + } +} + +// A service compose builds runs the image compose names it (TS +// getDefaultImageName), with the remote user's UID updated on top: the +// override retags the service to the UID image. +func TestStartComposeContainerBuildServiceUpdatesUID(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("updateRemoteUserUID only runs on Linux") + } + h := newComposeHarness(t) + h.r.opts.updateRemoteUserUIDDefault = "on" + h.write("Dockerfile", "FROM alpine:3\n") + h.engine.images["proj-app"] = image.InspectResponse{Os: "linux", Architecture: "amd64", Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{Cmd: []string{"sh"}}}} + if _, err := h.r.startComposeContainer(context.Background(), h.start(&config.DevContainer{}, map[string]any{"build": map[string]any{}})); err != nil { + t.Fatal(err) + } + files := overrideFiles(t, h.persisted) + var override string + for _, f := range files { + if strings.HasPrefix(filepath.Base(f), composeFeaturesOverridePrefix) { + override = readFile(t, f) + } + } + fixed := folderImageName(h.ws) + "-uid" + if !strings.Contains(override, "\n image: "+fixed+"\n") || !strings.Contains(override, "\n command: [\"sh\"]\n") { + t.Errorf("container override does not run %s with the image's command:\n%s", fixed, override) + } + inspected := 0 + for _, ref := range h.engine.inspects { + if ref == "proj-app" { + inspected++ + } + } + if inspected != 1 { + t.Errorf("built image inspected %d times, want once (%q)", inspected, h.engine.inspects) + } + if builds := h.run.dockerBuilds(); len(builds) != 1 || !strings.Contains(strings.Join(builds[0], " "), "BASE_IMAGE=proj-app") { + t.Errorf("docker builds %q, want the UID image on proj-app", builds) + } +} + +// An image service with Features runs the folder image compose built, so its +// entrypoint is the one the container override wraps. +func TestStartComposeContainerImageServiceWithFeatures(t *testing.T) { + h := newComposeHarness(t) + folder := folderImageName(h.ws) + h.engine.images[folder] = image.InspectResponse{Os: "linux", Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{Entrypoint: []string{"/feature-ep.sh"}}}} + s := h.start(&config.DevContainer{Features: h.localFeature()}, map[string]any{"image": "alpine:3", "command": "run"}) + if _, err := h.r.startComposeContainer(context.Background(), s); err != nil { + t.Fatal(err) + } + var override string + for _, f := range overrideFiles(t, h.persisted) { + if strings.HasPrefix(filepath.Base(f), composeFeaturesOverridePrefix) { + override = readFile(t, f) + } + } + // The build override already tags the service with the folder image. + if !strings.Contains(override, `"-", "/feature-ep.sh"]`) || strings.Contains(override, "image:") { + t.Errorf("container override does not wrap the folder image's entrypoint:\n%s", override) + } + if ups := h.run.compose("up"); len(ups) != 1 || len(ups[0]) != 10 { + t.Errorf("compose up %q, want the compose file and both overrides", ups) + } +} diff --git a/internal/cli/differential_test.go b/internal/cli/differential_test.go new file mode 100644 index 0000000..11cf574 --- /dev/null +++ b/internal/cli/differential_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "testing" +) + +// differentialKnown lists the TS fixtures whose read-configuration output still +// differs from the oracle's, each with the reason. A fixture listed here that no +// longer differs fails the test, so the list only shrinks. +var differentialKnown = map[string]string{ + // docs/DIVERGENCES.md: Features backed by GitHub Releases (v1, owner/repo/id) + // are not implemented; the configuration fails instead of resolving them. + "dockerfile-with-syntax": "legacy GitHub Release Feature (accepted limitation)", + "dockerfile-with-target": "legacy GitHub Release Feature (accepted limitation)", + "compose-Dockerfile-with-features": "legacy GitHub Release Feature (accepted limitation)", + "compose-Dockerfile-with-target": "legacy GitHub Release Feature (accepted limitation)", + "compose-image-with-features": "legacy GitHub Release Feature (accepted limitation)", +} + +// TestDifferentialReadConfiguration runs read-configuration with the merged and +// Features configuration through this CLI and through the TS oracle for every +// configuration under reference/src/test/configs, and fails on any difference in +// exit code or JSON output, normalized only for the workspace path and the +// version-and-time segment of the features folder. Hand-written +// parity cases cover what someone thought of; this covers every fixture upstream +// tests with. It needs the compiled oracle, Docker and the network, so it runs +// when PARITY_DIFFERENTIAL is set (task parity:differential). +func TestDifferentialReadConfiguration(t *testing.T) { + if os.Getenv("PARITY_DIFFERENTIAL") == "" { + t.Skip("set PARITY_DIFFERENTIAL=1 (task parity:differential): needs the oracle, Docker and the network") + } + repoRoot := findRepoRoot(t) + oracle := filepath.Join(repoRoot, "reference", "devcontainer.js") + if _, err := os.Stat(filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js")); err != nil { + t.Fatalf("TS reference oracle not compiled (task reference): %v", err) + } + goCLI := os.Getenv("PARITY_CLI_GO") + if goCLI == "" { + goCLI = filepath.Join(repoRoot, "devcontainer") + } + + fixtures := differentialFixtures(t, filepath.Join(repoRoot, "reference", "src", "test", "configs")) + for name := range differentialKnown { + if !slices.Contains(fixtures, name) { + t.Errorf("differentialKnown lists %q, which is no fixture", name) + } + } + for _, name := range fixtures { + t.Run(name, func(t *testing.T) { + src := filepath.Join(repoRoot, "reference", "src", "test", "configs", name) + tsCode, tsOut := runDifferential(t, src, "node", oracle) + goCode, goOut := runDifferential(t, src, goCLI) + same := tsCode == goCode && reflect.DeepEqual(tsOut, goOut) + reason, known := differentialKnown[name] + switch { + case same && known: + t.Errorf("%s no longer differs (%s): remove it from differentialKnown", name, reason) + case !same && known: + t.Skipf("known difference: %s", reason) + case tsCode != goCode: + t.Errorf("exit code: TS=%d Go=%d\n--- TS\n%s\n--- Go\n%s", tsCode, goCode, pretty(tsOut), pretty(goOut)) + case !same: + t.Errorf("output differs\n--- TS\n%s\n--- Go\n%s", pretty(tsOut), pretty(goOut)) + } + }) + } +} + +// differentialFixtures are the folders holding a devcontainer configuration +// (.devcontainer/devcontainer.json or .devcontainer.json), as paths relative to +// root. +func differentialFixtures(t *testing.T, root string) []string { + t.Helper() + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + for _, cfg := range []string{".devcontainer/devcontainer.json", ".devcontainer.json"} { + if _, err := os.Stat(filepath.Join(root, e.Name(), cfg)); err == nil { + out = append(out, e.Name()) + break + } + } + } + return out +} + +// runDifferential copies the fixture to a fresh folder (the CLIs may write a +// lockfile), runs read-configuration there, and returns the exit code and the +// last stdout line decoded, with the workspace path replaced by and the +// features folder's - segment by -. +func runDifferential(t *testing.T, fixture string, cli ...string) (int, any) { + t.Helper() + ws := filepath.Join(t.TempDir(), "ws") + if err := copyDir(fixture, ws); err != nil { + t.Fatal(err) + } + args := append(cli[1:], "read-configuration", "--workspace-folder", ws, + "--include-merged-configuration", "--include-features-configuration") + cmd := exec.CommandContext(t.Context(), cli[0], args...) + cmd.Dir = findRepoRoot(t) + var stdout bytes.Buffer + cmd.Stdout = &stdout + err := cmd.Run() + code := 0 + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("run %v: %v", cli, err) + } + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + var out any + if last := lines[len(lines)-1]; last != "" { + normalized := featuresFolderRe.ReplaceAllString(strings.ReplaceAll(last, ws, ""), "${1}-") + if err := json.Unmarshal([]byte(normalized), &out); err != nil { + out = last + } + } + return code, out +} + +// featuresFolderRe matches the features folder TS createFeaturesTempFolder +// names /container-features/-: the +// timestamp differs between runs, and the version differs by design (the TS +// package version vs this CLI's CalVer). +var featuresFolderRe = regexp.MustCompile(`(/container-features/)[^/"]+-\d+`) + +func pretty(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) +} diff --git a/internal/cli/disallowed.go b/internal/cli/disallowed.go index 017df4d..693f70a 100644 --- a/internal/cli/disallowed.go +++ b/internal/cli/disallowed.go @@ -4,15 +4,21 @@ import ( "context" "errors" "fmt" + "maps" + "net/http" "os" "os/user" "path/filepath" "runtime" + "slices" + "time" "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" coreerrors "github.com/devcontainers/cli/internal/errors" "github.com/devcontainers/cli/internal/features" "github.com/devcontainers/cli/internal/httpx" + "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" ) @@ -28,38 +34,112 @@ func cacheFolder() string { return filepath.Join(os.TempDir(), base) } -// enforceDisallowedFeatures blocks any configured feature that the containers.dev -// control manifest reports as problematic — mirroring the TS ensureNoDisallowedFeatures, -// which runs on every config read. Go had the machinery (GetControlManifest / -// EnsureNoDisallowedFeatures) but never invoked it, so the security blocklist was -// not enforced. Returns a ContainerError envelope for the first disallowed feature. +// enforceDisallowedFeatures blocks a configured feature that the containers.dev +// control manifest reports as problematic, as TS ensureNoDisallowedFeatures does +// on every config read. The first disallowed feature in the order the JSON lists +// them is reported. stopRunning, when given (up, with its id labels), stops the +// workspace's container if it is running, and the error says so; build passes +// nil, as TS build passes no id labels. // -// cfg.Features is expected to already include any --additional-features (merged in -// by mergeAdditionalFeatures), so both are covered by checking it. -func enforceDisallowedFeatures(ctx context.Context, cfg *config.DevContainer, logger log.Logger) error { +// cfg.Features is expected to already include any --additional-features (merged +// in by mergeAdditionalFeatures), which TS lists after the config's own. +func enforceDisallowedFeatures(ctx context.Context, cfg *config.DevContainer, additionalFeatures string, logger log.Logger, stopRunning func(context.Context) bool) *coreerrors.ContainerError { if cfg == nil || len(cfg.Features) == 0 { return nil } cf := cacheFolder() _ = os.MkdirAll(cf, 0o755) - manifest := features.GetControlManifest(ctx, cf, httpx.New(cliVersion()), logger) + manifest := features.GetControlManifest(ctx, cf, controlManifestClient(), logger) if manifest == nil { return nil } - err := features.EnsureNoDisallowedFeatures(manifest, cfg.Features, nil) - if err == nil { + dfe, ok := errors.AsType[*features.DisallowedFeatureError](features.EnsureNoDisallowedFeatures(manifest, featureIDsInOrder(cfg, additionalFeatures))) + if !ok { return nil } - var dfe *features.DisallowedFeatureError - if errors.As(err, &dfe) { - desc := fmt.Sprintf("Cannot use the '%s' Feature since it was reported to be problematic. Please remove this Feature from your configuration and rebuild any dev container using it before continuing.", dfe.FeatureID) - if dfe.DocumentationURL != "" { - desc += fmt.Sprintf(" See %s to learn more.", dfe.DocumentationURL) + stopped := stopRunning != nil && stopRunning(ctx) + desc := fmt.Sprintf("Cannot use the '%s' Feature since it was reported to be problematic. Please remove this Feature from your configuration and rebuild any dev container using it before continuing.", dfe.FeatureID) + if stopped { + desc += " The existing dev container was stopped." + } + if dfe.DocumentationURL != "" { + desc += fmt.Sprintf(" See %s to learn more.", dfe.DocumentationURL) + } + return &coreerrors.ContainerError{ + Description: desc, + DisallowedFeatureID: dfe.FeatureID, + DidStopContainer: &stopped, + LearnMoreURL: dfe.DocumentationURL, + } +} + +// featureIDsInOrder lists cfg.Features as TS Object.keys({...config.features, +// ...additionalFeatures}) does: the config file's features in document order, +// then the additional features in theirs. A key neither document lists (an +// --override-config one) follows, sorted. +func featureIDsInOrder(cfg *config.DevContainer, additionalFeatures string) []string { + var ids []string + seen := map[string]bool{} + add := func(names []string) { + for _, id := range names { + if _, ok := cfg.Features[id]; ok && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + if data, err := os.ReadFile(cfg.ConfigFilePath); err == nil { + add(jsonc.MemberNames(data, "features")) + } + if additionalFeatures != "" { + add(jsonc.MemberNames([]byte(`{"features":`+additionalFeatures+`}`), "features")) + } + add(slices.Sorted(maps.Keys(cfg.Features))) + return ids +} + +// stopRunningContainer is TS ensureNoDisallowedFeatures' stop: the workspace's +// container, found by its id labels, is stopped with no grace period when it is +// running. It reports whether it stopped one. +func stopRunningContainer(engine *docker.EngineClient, idLabels []string) func(context.Context) bool { + return func(ctx context.Context) bool { + ids, err := engine.ListContainers(ctx, true, idLabels) + if err != nil || len(ids) == 0 { + return false } - // TS's error envelope for this path emits only outcome/message/description - // (the disallowedFeatureId/learnMoreUrl live in the internal ContainerError - // data, not the serialized output), so we mirror that and set Description only. - return &coreerrors.ContainerError{Description: desc} + inspect, err := engine.InspectContainer(ctx, ids[0]) + if err != nil || inspect.State == nil || !inspect.State.Running { + return false + } + return engine.StopContainerNow(ctx, ids[0]) == nil + } +} + +// logFeatureAdvisories warns about the fetched OCI features with an advisory +// for their version, as TS generateFeaturesConfig does after fetching them +// (logFeatureAdvisories). The control manifest is read only when there is an +// OCI feature to check. +func logFeatureAdvisories(ctx context.Context, logger log.Logger, sets []*features.Set) { + if !slices.ContainsFunc(sets, func(s *features.Set) bool { + _, ok := s.SourceInfo.(*features.OCISource) + return ok + }) { + return } - return err + cf := cacheFolder() + _ = os.MkdirAll(cf, 0o755) + writeFeatureAdvisories(logger, features.GetControlManifest(ctx, cf, controlManifestClient(), logger), sets) +} + +func writeFeatureAdvisories(logger log.Logger, manifest *features.ControlManifest, sets []*features.Set) { + warn := func(s string) { logger.Write(s, log.LevelWarning) } + if text := features.FormatFeatureAdvisories(features.FeatureAdvisories(manifest, sets, warn)); text != "" { + warn(text) + } +} + +// controlManifestClient is the proxy- and CA-aware client for the control +// manifest, with the 30-second timeout the CLI always gave that request. +func controlManifestClient() *http.Client { + return &http.Client{Transport: httpx.NewTransport(), Timeout: 30 * time.Second} } diff --git a/internal/cli/disallowed_held_test.go b/internal/cli/disallowed_held_test.go new file mode 100644 index 0000000..da8fda0 --- /dev/null +++ b/internal/cli/disallowed_held_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "errors" + "os" + "reflect" + "testing" + + "github.com/moby/moby/api/types/container" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" +) + +// The workspace's container is stopped, with no grace period, only when it is +// found and running; the result says whether it was stopped. +func TestStopRunningContainer(t *testing.T) { + running := container.InspectResponse{State: &container.State{Running: true}} + exited := container.InspectResponse{State: &container.State{Running: false}} + for name, tc := range map[string]struct { + api *fakeDockerAPI + want bool + wantStopped []string + }{ + "running": {api: &fakeDockerAPI{containers: []string{"c1"}, inspect: map[string]container.InspectResponse{"c1": running}}, want: true, wantStopped: []string{"c1"}}, + "first of several": {api: &fakeDockerAPI{containers: []string{"c1", "c2"}, inspect: map[string]container.InspectResponse{"c1": running, "c2": exited}}, want: true, wantStopped: []string{"c1"}}, + "none": {api: &fakeDockerAPI{}}, + "list fails": {api: &fakeDockerAPI{containers: []string{"c1"}, inspect: map[string]container.InspectResponse{"c1": running}, listErr: errors.New("down")}}, + "inspect fails": {api: &fakeDockerAPI{containers: []string{"c1"}, inspect: map[string]container.InspectResponse{"c1": running}, inspectErr: errors.New("gone")}}, + "no state": {api: &fakeDockerAPI{containers: []string{"c1"}, inspect: map[string]container.InspectResponse{"c1": {}}}}, + "not running": {api: &fakeDockerAPI{containers: []string{"c1", "c2"}, inspect: map[string]container.InspectResponse{"c1": exited, "c2": running}}}, + "stop fails": {api: &fakeDockerAPI{containers: []string{"c1"}, inspect: map[string]container.InspectResponse{"c1": running}, stopErr: errors.New("busy")}, wantStopped: []string{"c1"}}, + } { + t.Run(name, func(t *testing.T) { + got := stopRunningContainer(&docker.EngineClient{API: tc.api}, []string{"devcontainer.local_folder=/w"})(t.Context()) + if got != tc.want || !reflect.DeepEqual(tc.api.stopped, tc.wantStopped) { + t.Errorf("stopped = %v (%v), want %v (%v)", got, tc.api.stopped, tc.want, tc.wantStopped) + } + for _, timeout := range tc.api.stopTimeouts { + if timeout != 0 { + t.Errorf("stop timeout = %d, want 0", timeout) + } + } + }) + } +} + +// Without Features there is nothing to check, and the control manifest is +// neither fetched nor cached. +func TestEnforceDisallowedFeaturesWithoutFeaturesFetchesNothing(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + for name, cfg := range map[string]*config.DevContainer{"no config": nil, "no features": {Features: map[string]any{}}} { + if err := enforceDisallowedFeatures(t.Context(), cfg, "", log.Null, nil); err != nil { + t.Errorf("%s: err = %v", name, err) + } + } + if _, err := os.Stat(cacheFolder()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("cache folder %s: %v, want it not created", cacheFolder(), err) + } +} diff --git a/internal/cli/disallowed_test.go b/internal/cli/disallowed_test.go new file mode 100644 index 0000000..10c3e11 --- /dev/null +++ b/internal/cli/disallowed_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/devcontainers/cli/internal/config" + coreerrors "github.com/devcontainers/cli/internal/errors" + "github.com/devcontainers/cli/internal/log" +) + +// TS reports the first disallowed feature of Object.keys({...config.features, +// ...additionalFeatures}): the config file's order, then the additional +// features'. Go iterated a map, so with two disallowed features the reported id +// was random. +func TestFeatureIDsInOrderFollowsTheDocuments(t *testing.T) { + path := filepath.Join(t.TempDir(), "devcontainer.json") + os.WriteFile(path, []byte(`{"image":"x","features":{"z/b:1":{},"a/c:1":{}, /* jsonc */ "m/d:1":{}}}`), 0o644) + cfg := &config.DevContainer{ConfigFilePath: path, Features: map[string]any{ + "z/b:1": map[string]any{}, "a/c:1": map[string]any{}, "m/d:1": map[string]any{}, + "y/add:1": true, "b/add:1": true, // from --additional-features + "o/override:1": true, // in neither document + }} + got := featureIDsInOrder(cfg, `{"y/add:1": true, "b/add:1": true, "z/b:1": false}`) + want := []string{"z/b:1", "a/c:1", "m/d:1", "y/add:1", "b/add:1", "o/override:1"} + if !slices.Equal(got, want) { + t.Errorf("featureIDsInOrder = %v, want %v", got, want) + } +} + +// up reports the disallowed feature with TS's fields: the feature id, whether it +// stopped the running container (false included, as TS emits the boolean), and +// the documentation URL, and the description says the container was stopped. +func TestEnforceDisallowedFeaturesReportsTSFields(t *testing.T) { + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + if err := os.MkdirAll(cacheFolder(), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"disallowedFeatures":[{"featureIdPrefix":"ghcr.io/o/bad","documentationURL":"https://doc"}],"featureAdvisories":[]}` + if err := os.WriteFile(filepath.Join(cacheFolder(), "control-manifest.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(tmp, "devcontainer.json") + os.WriteFile(cfgPath, []byte(`{"features":{"ghcr.io/o/good:1":{},"ghcr.io/o/bad:2":{},"ghcr.io/o/bad/x:1":{}}}`), 0o644) + cfg := &config.DevContainer{ConfigFilePath: cfgPath, Features: map[string]any{ + "ghcr.io/o/good:1": map[string]any{}, "ghcr.io/o/bad:2": map[string]any{}, "ghcr.io/o/bad/x:1": map[string]any{}, + }} + + stopped := enforceDisallowedFeatures(t.Context(), cfg, "", log.Null, func(_ context.Context) bool { return true }) + b, _ := json.Marshal(coreerrors.ToErrorOutput(stopped)) + desc := "Cannot use the 'ghcr.io/o/bad:2' Feature since it was reported to be problematic. Please remove this Feature from your configuration and rebuild any dev container using it before continuing. The existing dev container was stopped. See https://doc to learn more." + want, _ := json.Marshal(map[string]any{"outcome": "error", "message": desc, "description": desc, + "disallowedFeatureId": "ghcr.io/o/bad:2", "didStopContainer": true, "learnMoreUrl": "https://doc"}) + if !jsonEqual(b, want) { + t.Errorf("stopped:\n got %s\nwant %s", b, want) + } + + notStopped := enforceDisallowedFeatures(t.Context(), cfg, "", log.Null, nil) + out := coreerrors.ToErrorOutput(notStopped) + if out.DidStopContainer == nil || *out.DidStopContainer { + t.Errorf("didStopContainer = %v, want false present", out.DidStopContainer) + } +} + +func jsonEqual(a, b []byte) bool { + var x, y any + return json.Unmarshal(a, &x) == nil && json.Unmarshal(b, &y) == nil && reflect.DeepEqual(x, y) +} diff --git a/internal/cli/e2e_fixes_test.go b/internal/cli/e2e_fixes_test.go index 7865551..08fdc4e 100644 --- a/internal/cli/e2e_fixes_test.go +++ b/internal/cli/e2e_fixes_test.go @@ -141,7 +141,7 @@ func TestE2E_ComposeBuildLabel(t *testing.T) { buildOut := runCLI(t, "build", "--workspace-folder", ws, "--label", "e2e.compose.label=applied") assertOutcome(t, buildOut, "success") - names, _ := parseJSON(t, buildOut)["imageName"].([]interface{}) + names, _ := parseJSON(t, buildOut)["imageName"].([]any) if len(names) == 0 { t.Fatal("no imageName in build output") } diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go index 3b00ad3..f2b2636 100644 --- a/internal/cli/e2e_test.go +++ b/internal/cli/e2e_test.go @@ -100,11 +100,11 @@ func TestE2E_BuildUpExec_Dockerfile(t *testing.T) { func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { fixtures := []struct { name string - checkFn func(t *testing.T, cfg map[string]interface{}) + checkFn func(t *testing.T, cfg map[string]any) }{ { "image", - func(t *testing.T, cfg map[string]interface{}) { + func(t *testing.T, cfg map[string]any) { if cfg["image"] != "ubuntu:latest" { t.Errorf("image = %v", cfg["image"]) } @@ -112,8 +112,8 @@ func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { }, { "dockerfile-without-features", - func(t *testing.T, cfg map[string]interface{}) { - build, ok := cfg["build"].(map[string]interface{}) + func(t *testing.T, cfg map[string]any) { + build, ok := cfg["build"].(map[string]any) if !ok { t.Fatal("expected build property") } @@ -124,7 +124,7 @@ func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { }, { "compose-image-without-features", - func(t *testing.T, cfg map[string]interface{}) { + func(t *testing.T, cfg map[string]any) { if cfg["service"] != "app" { t.Errorf("service = %v", cfg["service"]) } @@ -141,7 +141,7 @@ func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { out := runCLI(t, "read-configuration", "--workspace-folder", fixture) result := parseJSON(t, out) - cfg, ok := result["configuration"].(map[string]interface{}) + cfg, ok := result["configuration"].(map[string]any) if !ok { t.Fatal("no configuration in output") } @@ -171,9 +171,9 @@ func runCLI(t *testing.T, args ...string) string { return strings.TrimSpace(buf.String()) } -func parseJSON(t *testing.T, s string) map[string]interface{} { +func parseJSON(t *testing.T, s string) map[string]any { t.Helper() - var result map[string]interface{} + var result map[string]any if err := json.Unmarshal([]byte(s), &result); err != nil { t.Fatalf("invalid JSON: %v\nOutput: %s", err, s) } diff --git a/internal/cli/env.go b/internal/cli/env.go index 38a2024..026dec7 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -2,36 +2,16 @@ package cli import ( "os" - "strings" "github.com/devcontainers/cli/internal/config" ) -func resolveContainerVariables(containerEnv map[string]string, value interface{}) (interface{}, error) { +func resolveContainerVariables(containerEnv map[string]string, value any) (any, error) { return config.NewVariableResolver().AfterContainer(config.SubstitutionContext{ - HostSubContext: config.HostSubContext{Platform: "linux"}, - ContainerEnv: containerEnv, + Platform: "linux", + ContainerEnv: containerEnv, }, value) } // osEnvMap returns the current process environment as a map. -func osEnvMap() map[string]string { - env := make(map[string]string) - for _, e := range os.Environ() { - if i := strings.IndexByte(e, '='); i >= 0 { - env[e[:i]] = e[i+1:] - } - } - return env -} - -// envSliceToMap converts Docker's []string{"KEY=VALUE"} to map[string]string. -func envSliceToMap(envs []string) map[string]string { - m := make(map[string]string) - for _, e := range envs { - if i := strings.IndexByte(e, '='); i >= 0 { - m[e[:i]] = e[i+1:] - } - } - return m -} +func osEnvMap() map[string]string { return config.EnvMap(os.Environ()) } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 6a262c7..3595e5d 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -1,9 +1,12 @@ package cli import ( + "cmp" "context" "encoding/json" "fmt" + "io" + "maps" "os" "os/exec" "path/filepath" @@ -17,6 +20,7 @@ import ( "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/lifecycle" "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" "github.com/spf13/cobra" ) @@ -68,19 +72,6 @@ func newExecCmd() *cobra.Command { return err } - opts.workspaceFolder, _ = cmd.Flags().GetString("workspace-folder") - opts.configPath, _ = cmd.Flags().GetString("config") - opts.overrideConfig, _ = cmd.Flags().GetString("override-config") - opts.dockerPath, _ = cmd.Flags().GetString("docker-path") - opts.containerID, _ = cmd.Flags().GetString("container-id") - opts.idLabels, _ = cmd.Flags().GetStringArray("id-label") - opts.logLevel, _ = cmd.Flags().GetString("log-level") - opts.logFormat, _ = cmd.Flags().GetString("log-format") - opts.remoteEnvs, _ = cmd.Flags().GetStringArray("remote-env") - opts.defaultUserEnvProbe, _ = cmd.Flags().GetString("default-user-env-probe") - opts.terminalColumns, _ = cmd.Flags().GetInt("terminal-columns") - opts.terminalRows, _ = cmd.Flags().GetInt("terminal-rows") - if len(cmdArgs) == 0 { return fmt.Errorf("exec requires at least 1 arg") } @@ -89,21 +80,21 @@ func newExecCmd() *cobra.Command { } f := cmd.Flags() - f.String("workspace-folder", "", "Workspace folder path.") - f.String("config", "", "devcontainer.json path.") - f.String("override-config", "", "Override config path.") - f.String("docker-path", "", "Docker CLI path.") - f.String("container-id", "", "Container ID.") - f.StringArray("id-label", nil, "Id label(s).") - f.String("log-level", "info", "Log level.") - f.String("log-format", "text", "Log format.") - f.StringArray("remote-env", nil, "Remote env vars.") + f.StringVar(&opts.workspaceFolder, "workspace-folder", "", "Workspace folder path.") + f.StringVar(&opts.configPath, "config", "", "devcontainer.json path.") + f.StringVar(&opts.overrideConfig, "override-config", "", "Override config path.") + f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.") + f.StringVar(&opts.containerID, "container-id", "", "Container ID.") + f.StringArrayVar(&opts.idLabels, "id-label", nil, "Id label(s).") + f.StringVar(&opts.logLevel, "log-level", "info", "Log level.") + f.StringVar(&opts.logFormat, "log-format", "text", "Log format.") + f.StringArrayVar(&opts.remoteEnvs, "remote-env", nil, "Remote env vars.") f.String("docker-compose-path", "", "") f.String("container-data-folder", "", "") f.String("container-system-data-folder", "", "") f.Bool("mount-workspace-git-root", true, "") f.Bool("mount-git-worktree-common-dir", false, "") - f.String("default-user-env-probe", "loginInteractiveShell", "") + f.StringVar(&opts.defaultUserEnvProbe, "default-user-env-probe", "loginInteractiveShell", "") f.Bool("skip-feature-auto-mapping", false, "") _ = f.MarkHidden("skip-feature-auto-mapping") // hidden testing flag (TS parity) f.String("user-data-folder", "", "") @@ -114,6 +105,15 @@ func newExecCmd() *cobra.Command { return cmd } +// runExec runs a command in a running dev container through `docker exec`. +// What it decides is in the helpers below; this is their wiring to the +// configuration, the engine and the docker CLI, which the parity lane runs with +// the built binary against real containers: +// +// mutate-lane: TestParityMatrix (exec.workspace-image-success, +// exec.workspace-compose-success, exec.container-config-success, +// exec.container-metadata-success, exec.workspace-image-json-raw-output-success, +// exec.workspace-image-nonzero-exit, exec.container-not-found). func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { if err := validateIDLabels(opts.idLabels); err != nil { return err @@ -122,19 +122,14 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { return err } // 0.88: default --workspace-folder to cwd when no --container-id/--id-label/--workspace-folder. - if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" { + if execDefaultsToCwd(opts) { opts.workspaceFolder, _ = os.Getwd() } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", opts.logLevel, []string{"info", "debug", "trace"}}, - {"log-format", opts.logFormat, []string{"text", "json"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return err - } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + ); err != nil { + return err } if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { return err @@ -147,26 +142,20 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { defer closeLog() logger := log.New(log.Options{ - Version: cliVersion(), - Level: log.ParseLevel(opts.logLevel), - Format: opts.logFormat, - Writer: logDst, - Dimensions: logDimensions(opts.terminalColumns, opts.terminalRows), + Version: cliVersion(), + Level: log.ParseLevel(opts.logLevel), + Format: opts.logFormat, + Writer: logDst, }) // 1. If workspace-folder provided without container-id, validate config exists early var loadResult *config.LoadResult if opts.workspaceFolder != "" { - ws := resolvePath(opts.workspaceFolder) - cp := opts.configPath - if cp != "" && !filepath.IsAbs(cp) { - cwd, _ := os.Getwd() - cp = filepath.Join(cwd, cp) - } + cwd, _ := os.Getwd() var loadErr error - loadResult, loadErr = config.LoadDevContainerConfig(ws, cp, "") - if loadErr != nil && opts.containerID == "" { - return loadErr + loadResult, loadErr = config.LoadDevContainerConfig(resolvePath(opts.workspaceFolder), execConfigPath(opts.configPath, cwd), "") + if err := execConfigError(loadErr, opts.containerID); err != nil { + return err } } @@ -195,155 +184,173 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { if err != nil { return fmt.Errorf("Dev container not found.") } - if inspect.State == nil || !inspect.State.Running { + if !containerRunning(inspect.State) { return fmt.Errorf("Dev container is not running.") } + containerCfg := cmp.Or(inspect.Config, &container.Config{}) - // 4. Get containerEnv from running container - var containerEnv map[string]string - if inspect.Config != nil { - containerEnv = envSliceToMap(inspect.Config.Env) - } else { - containerEnv = map[string]string{} + // 4-6. The container's env, and its image metadata from the labels. + containerEnv := config.EnvMap(containerCfg.Env) + merged := imagemeta.MergeConfiguration(imagemeta.ReadMetadataFromLabels(containerCfg.Labels, logger)) + var cfg *config.DevContainer + if loadResult != nil { + cfg = loadResult.Config } + // The environment is probed as the image metadata's user (else the + // container's, else root); the configuration's remoteUser applies after. + remoteUser := cmp.Or(merged.RemoteUser, containerCfg.User, "root") - // 5. Determine remoteUser: container default → metadata → config - remoteUser := "" - if inspect.Config != nil { - remoteUser = inspect.Config.User + // 7-8. Probe the user environment, then merge and substitute it. + probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, remoteUser, execProbeStrategy(opts.defaultUserEnvProbe, cfg), "") + remoteUser = execRemoteUser(remoteUser, cfg) + resolvedRemoteEnv, err := execRemoteEnv(probed, merged.RemoteEnv, cfg, containerEnv) + if err != nil { + return err } - if remoteUser == "" { - remoteUser = "root" + + // 9-11. Build the docker exec args. + remoteWorkspace := "" + if loadResult != nil && loadResult.WorkspaceConfig != nil { + remoteWorkspace = loadResult.WorkspaceConfig.WorkspaceFolder } + // Only allocate a TTY for a real interactive terminal. Forcing `-t` for + // --log-format json with a piped stdin (exactly how the VS Code extension + // invokes exec) makes docker fail with "cannot attach stdin to a + // TTY-enabled container". The TS CLI only uses a PTY when isTTY is true. + isTTY := isTerminalWriter(os.Stdin) && isTerminalWriter(os.Stdout) + dockerArgs := execDockerArgs(isTTY, remoteUser, remoteWorkspace, resolvedRemoteEnv, opts.remoteEnvs, containerID, cmdArgs) - // 6. Read image metadata from container labels - entries := imagemeta.ReadMetadataFromLabels(inspect.Config.Labels, logger) - merged := imagemeta.MergeConfiguration(entries) + execCmd := exec.Command(cmp.Or(opts.dockerPath, "docker"), dockerArgs...) + execCmd.Stdin = os.Stdin + execCmd.Stdout, execCmd.Stderr = execOutput(opts.logFormat, logger) + return execRunError(execCmd.Run()) +} - // Use remoteUser from metadata (overrides container default) - if merged.RemoteUser != "" { - remoteUser = merged.RemoteUser +// execDefaultsToCwd reports whether exec has nothing to find the container by, +// so the current directory is the workspace folder. +func execDefaultsToCwd(opts *execOpts) bool { + return opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" +} + +// execConfigPath resolves --config against the current directory. +func execConfigPath(configPath, cwd string) string { + if configPath != "" && !filepath.IsAbs(configPath) { + return filepath.Join(cwd, configPath) } + return configPath +} - // 7. Probe user environment, then merge: probed → metadata → config → CLI (highest priority) - probeStrategy := lifecycle.UserEnvProbeStrategy(opts.defaultUserEnvProbe) - if loadResult != nil && loadResult.Config.UserEnvProbe != "" { - probeStrategy = lifecycle.UserEnvProbeStrategy(loadResult.Config.UserEnvProbe) +// execConfigError is the configuration error that stops exec: with +// --container-id the container is known, and a configuration that does not +// load only loses its settings. +func execConfigError(loadErr error, containerID string) error { + if loadErr != nil && containerID == "" { + return loadErr } + return nil +} - resolvedRemoteEnv := map[string]string{} +// containerRunning reports whether an inspected container is running. +func containerRunning(state *container.State) bool { + return state != nil && state.Running +} - // Probed env (lowest priority) - if probeStrategy != lifecycle.ProbeNone { - probeServer, probeErr := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger) - if probeErr == nil { - probedEnv, _ := lifecycle.ProbeRemoteEnv(logger, probeServer, probeStrategy, remoteUser) - probeServer.Close() - for k, v := range probedEnv { - resolvedRemoteEnv[k] = v - } - } +// execRemoteUser is the user exec runs as: the configuration's remoteUser, +// else the user the environment was probed as. +func execRemoteUser(probeUser string, cfg *config.DevContainer) string { + if cfg != nil && cfg.RemoteUser != "" { + return cfg.RemoteUser } + return probeUser +} - // From merged metadata - for k, v := range merged.RemoteEnv { - if v != nil { - resolvedRemoteEnv[k] = *v - } +// execProbeStrategy is the configuration's userEnvProbe, else the flag's. +func execProbeStrategy(flag string, cfg *config.DevContainer) lifecycle.UserEnvProbeStrategy { + if cfg != nil && cfg.UserEnvProbe != "" { + return lifecycle.UserEnvProbeStrategy(cfg.UserEnvProbe) } + return lifecycle.UserEnvProbeStrategy(flag) +} - // From config (overrides metadata) - if loadResult != nil { - cfg := loadResult.Config - if cfg.RemoteUser != "" { - remoteUser = cfg.RemoteUser - } - for k, v := range cfg.RemoteEnv { +// execRemoteEnv merges the remote environment, each layer over the one before: +// the probed user environment, the image metadata's remoteEnv, the +// configuration's; a null value leaves the layer below. ${containerEnv:X} is +// then substituted from the container's environment. +func execRemoteEnv(probed map[string]string, metadata map[string]*string, cfg *config.DevContainer, containerEnv map[string]string) (map[string]string, error) { + resolved := map[string]string{} + maps.Copy(resolved, probed) + layers := []map[string]*string{metadata} + if cfg != nil { + layers = append(layers, cfg.RemoteEnv) + } + for _, layer := range layers { + for k, v := range layer { if v != nil { - resolvedRemoteEnv[k] = *v + resolved[k] = *v } } } - - // 8. Substitute ${containerEnv:X} in resolved remoteEnv - for k, v := range resolvedRemoteEnv { + for k, v := range resolved { substituted, err := resolveContainerVariables(containerEnv, v) if err != nil { - return fmt.Errorf("resolve container variables: %w", err) + return nil, fmt.Errorf("resolve container variables: %w", err) } if s, ok := substituted.(string); ok { - resolvedRemoteEnv[k] = s + resolved[k] = s } } + return resolved, nil +} - // 9. Build docker exec args - dockerArgs := []string{"exec"} - - // Only allocate a TTY for a real interactive terminal. Forcing `-t` for - // --log-format json with a piped stdin (exactly how the VS Code extension - // invokes exec) makes docker fail with "cannot attach stdin to a - // TTY-enabled container". The TS CLI only uses a PTY when isTTY is true. - isTTY := isTerminal(os.Stdin) && isTerminal(os.Stdout) - if isTTY { - dockerArgs = append(dockerArgs, "-it") - } else { - dockerArgs = append(dockerArgs, "-i") +// execDockerArgs is the `docker exec` command line: interactive, with a TTY +// when exec runs in one, as the remote user, in the container's workspace +// folder (remoteCwd, as TS containerProperties.remoteWorkspaceFolder) when +// known, with the resolved remote environment and then the --remote-env flags, +// which docker applies last so they win. +func execDockerArgs(tty bool, remoteUser, remoteWorkspace string, remoteEnv map[string]string, cliEnvs []string, containerID string, cmdArgs []string) []string { + args := []string{"exec", "-i"} + if tty { + args[1] = "-it" } - if remoteUser != "" { - dockerArgs = append(dockerArgs, "-u", remoteUser) + args = append(args, "-u", remoteUser) } - - // Run in the container's workspace folder (remoteCwd), matching the TS CLI - // (containerProperties.remoteWorkspaceFolder). Without this, commands run in - // the image WORKDIR instead of the workspace. - if loadResult != nil && loadResult.WorkspaceConfig != nil && loadResult.WorkspaceConfig.WorkspaceFolder != "" { - dockerArgs = append(dockerArgs, "-w", loadResult.WorkspaceConfig.WorkspaceFolder) + if remoteWorkspace != "" { + args = append(args, "-w", remoteWorkspace) } - - // 10. Add resolved remoteEnv as -e flags (config+metadata, substituted) - for k, v := range resolvedRemoteEnv { - dockerArgs = append(dockerArgs, "-e", fmt.Sprintf("%s=%s", k, v)) + for k, v := range remoteEnv { + args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) } - - // 11. Add CLI --remote-env flags (these override everything) - for _, re := range opts.remoteEnvs { - dockerArgs = append(dockerArgs, "-e", re) - } - - dockerArgs = append(dockerArgs, containerID) - dockerArgs = append(dockerArgs, cmdArgs...) - - dockerPath := opts.dockerPath - if dockerPath == "" { - dockerPath = "docker" + for _, re := range cliEnvs { + args = append(args, "-e", re) } + args = append(args, containerID) + return append(args, cmdArgs...) +} - execCmd := exec.Command(dockerPath, dockerArgs...) - execCmd.Stdin = os.Stdin - if opts.logFormat == "json" { - // In JSON mode the command's output is emitted as `raw` log events on the - // log stream (not written to stdout), matching the TS CLI so the JSON - // consumer (e.g. the VS Code extension) gets a clean event stream and an - // empty stdout. Both the command's stdout and stderr become raw events. +// execOutput is where the command's output goes. In JSON mode it is emitted as +// `raw` log events on the log stream (not written to stdout), matching the TS +// CLI so the JSON consumer (e.g. the VS Code extension) gets a clean event +// stream and an empty stdout; both streams become raw events. Otherwise it +// passes through: docker exec -it inherits the terminal when the CLI runs in one. +func execOutput(logFormat string, logger log.Logger) (stdout, stderr io.Writer) { + if logFormat == "json" { rw := &rawLogWriter{logger: logger} - execCmd.Stdout = rw - execCmd.Stderr = rw - } else { - // Text/interactive mode: pass through directly. docker exec -it inherits - // the terminal when the CLI itself runs under a TTY. - execCmd.Stdout = os.Stdout - execCmd.Stderr = os.Stderr + return rw, rw } + return os.Stdout, os.Stderr +} - err = execCmd.Run() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return &coreerrors.ExitCodeError{Code: execExitCode(exitErr), Err: err} - } - return &coreerrors.ExitCodeError{Code: 1, Err: err} +// execRunError is exec's result for how `docker exec` ended: its exit code (a +// signal as 128+N), or 1 when it could not run. +func execRunError(err error) error { + if err == nil { + return nil } - return nil + if exitErr, ok := err.(*exec.ExitError); ok { + return &coreerrors.ExitCodeError{Code: execExitCode(exitErr), Err: err} + } + return &coreerrors.ExitCodeError{Code: 1, Err: err} } // execExitCode derives the process exit code for the 128+N contract. @@ -419,14 +426,6 @@ func findComposeContainer(ctx context.Context, engine *docker.EngineClient, opts return "" } -func isTerminal(f *os.File) bool { - info, err := f.Stat() - if err != nil { - return false - } - return (info.Mode() & os.ModeCharDevice) != 0 -} - // splitExecArgs separates devcontainer flags from the command to execute. // Matches yargs halt-at-non-option: stops at the first non-flag argument. func splitExecArgs(args []string) (flags []string, cmd []string) { diff --git a/internal/cli/exec_held_test.go b/internal/cli/exec_held_test.go new file mode 100644 index 0000000..8c5ad48 --- /dev/null +++ b/internal/cli/exec_held_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/lifecycle" + "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" +) + +func runExecCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := newExecCmd() + var out strings.Builder + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +// Help before the command prints the usage; no command is an error; a command +// reaches exec, whose flag validation runs first. +func TestExecCommandArguments(t *testing.T) { + for _, h := range []string{"-h", "--help"} { + out, err := runExecCommand(t, h) + if err != nil || !strings.Contains(out, "Usage:") { + t.Errorf("%s: err = %v, output = %q", h, err, out) + } + } + if _, err := runExecCommand(t, "--workspace-folder", t.TempDir()); err == nil || err.Error() != "exec requires at least 1 arg" { + t.Errorf("no command: err = %v", err) + } + if _, err := runExecCommand(t, "--log-level", "loud", "echo"); err == nil || !strings.Contains(err.Error(), "log-level") { + t.Errorf("one-word command: err = %v, want the log-level validation", err) + } +} + +func TestExecDefaultsToCwd(t *testing.T) { + cases := []struct { + opts execOpts + want bool + }{ + {execOpts{}, true}, + {execOpts{workspaceFolder: "ws"}, false}, + {execOpts{idLabels: []string{"a=b"}}, false}, + {execOpts{containerID: "c"}, false}, + } + for _, c := range cases { + if got := execDefaultsToCwd(&c.opts); got != c.want { + t.Errorf("execDefaultsToCwd(%+v) = %v, want %v", c.opts, got, c.want) + } + } +} + +func TestExecConfigPath(t *testing.T) { + abs := filepath.Join(t.TempDir(), "c.json") + for in, want := range map[string]string{"": "", "rel/c.json": "/work/rel/c.json", abs: abs} { + if got := execConfigPath(in, "/work"); got != want { + t.Errorf("execConfigPath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestExecConfigError(t *testing.T) { + loadErr := errors.New("no config") + if err := execConfigError(loadErr, ""); err != loadErr { + t.Errorf("without a container id: err = %v, want the load error", err) + } + if err := execConfigError(loadErr, "c"); err != nil { + t.Errorf("with a container id: err = %v, want none", err) + } + if err := execConfigError(nil, ""); err != nil { + t.Errorf("loaded: err = %v", err) + } +} + +func TestContainerRunning(t *testing.T) { + if containerRunning(nil) || containerRunning(&container.State{}) || !containerRunning(&container.State{Running: true}) { + t.Error("containerRunning must hold only for a running state") + } +} + +func TestExecRemoteUserAndProbe(t *testing.T) { + if got := execRemoteUser("node", nil); got != "node" { + t.Errorf("no config: %q", got) + } + if got := execRemoteUser("node", &config.DevContainer{}); got != "node" { + t.Errorf("config without remoteUser: %q", got) + } + if got := execRemoteUser("node", &config.DevContainer{RemoteUser: "vscode"}); got != "vscode" { + t.Errorf("config remoteUser: %q", got) + } + if got := execProbeStrategy("loginShell", nil); got != lifecycle.UserEnvProbeStrategy("loginShell") { + t.Errorf("no config: %q", got) + } + if got := execProbeStrategy("loginShell", &config.DevContainer{}); got != lifecycle.UserEnvProbeStrategy("loginShell") { + t.Errorf("config without userEnvProbe: %q", got) + } + if got := execProbeStrategy("loginShell", &config.DevContainer{UserEnvProbe: "none"}); got != lifecycle.UserEnvProbeStrategy("none") { + t.Errorf("config userEnvProbe: %q", got) + } +} + +func TestExecRemoteEnv(t *testing.T) { + str := func(s string) *string { return &s } + probed := map[string]string{"A": "probed", "B": "probed", "C": "probed"} + metadata := map[string]*string{"B": str("meta"), "C": nil, "D": str("${containerEnv:HOME}/bin")} + cfg := &config.DevContainer{RemoteEnv: map[string]*string{"C": str("config"), "B": nil}} + got, err := execRemoteEnv(probed, metadata, cfg, map[string]string{"HOME": "/root"}) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"A": "probed", "B": "meta", "C": "config", "D": "/root/bin"} + if len(got) != len(want) { + t.Errorf("env = %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %q, want %q", k, got[k], v) + } + } + + if got, err := execRemoteEnv(nil, metadata, nil, nil); err != nil || got["B"] != "meta" || len(got) != 2 { + t.Errorf("without a config: env = %v, err = %v", got, err) + } + if _, err := execRemoteEnv(nil, map[string]*string{"X": str("${containerEnv}")}, nil, nil); err == nil { + t.Error("an unresolvable ${containerEnv}: want an error") + } +} + +func TestExecDockerArgs(t *testing.T) { + got := execDockerArgs(false, "node", "/workspaces/app", map[string]string{"K": "v"}, []string{"K=cli"}, "cid", []string{"echo", "hi"}) + want := []string{"exec", "-i", "-u", "node", "-w", "/workspaces/app", "-e", "K=v", "-e", "K=cli", "cid", "echo", "hi"} + if !slices.Equal(got, want) { + t.Errorf("args = %q, want %q", got, want) + } + got = execDockerArgs(true, "", "", nil, nil, "cid", []string{"sh"}) + if want := []string{"exec", "-it", "cid", "sh"}; !slices.Equal(got, want) { + t.Errorf("args = %q, want %q", got, want) + } +} + +func TestExecOutput(t *testing.T) { + stdout, stderr := execOutput("json", log.Null) + if rw, ok := stdout.(*rawLogWriter); !ok || stderr != stdout || rw.logger == nil { + t.Errorf("json: stdout %T, stderr %T, want one raw log writer for both", stdout, stderr) + } + stdout, stderr = execOutput("text", log.Null) + if stdout != os.Stdout || stderr != os.Stderr { + t.Errorf("text: output must pass through to the process streams") + } +} + +func TestExecRunError(t *testing.T) { + if err := execRunError(nil); err != nil { + t.Errorf("success: %v", err) + } + runErr := exec.Command("sh", "-c", "exit 3").Run() + if got := exitCode(execRunError(runErr)); got != 3 { + t.Errorf("exit 3: code = %d", got) + } + if got := exitCode(execRunError(errors.New("docker: not found"))); got != 1 { + t.Errorf("could not run: code = %d, want 1", got) + } +} diff --git a/internal/cli/feature_advisories_test.go b/internal/cli/feature_advisories_test.go new file mode 100644 index 0000000..46bcb8e --- /dev/null +++ b/internal/cli/feature_advisories_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/log" +) + +// A fetched feature with an active advisory gets TS's FEATURE ADVISORIES +// warning in the log; one without gets nothing. Go had the advisory matcher but +// never called it, so the warning TS writes on every features config was lost. +func TestWriteFeatureAdvisoriesWarnsOnTheLog(t *testing.T) { + manifest := &features.ControlManifest{FeatureAdvisories: []features.Advisory{{ + FeatureID: "ghcr.io/o/f/a", IntroducedInVersion: "1.0.0", FixedInVersion: "1.2.0", Description: "a broke", + }}} + set := func(version string) []*features.Set { + return []*features.Set{{ + SourceInfo: &features.OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: "a"}, + Features: []features.Feature{{Version: version}}, + }} + } + var buf bytes.Buffer + writeFeatureAdvisories(log.New(log.Options{Level: log.LevelInfo, Format: "text", Writer: &buf}), manifest, set("1.1.0")) + if out := buf.String(); !strings.Contains(out, "FEATURE ADVISORIES:\n- ghcr.io/o/f/a:1.1.0:\n - a broke (introduced in 1.0.0, fixed in 1.2.0)") { + t.Errorf("log lacks the advisory warning:\n%s", out) + } + buf.Reset() + writeFeatureAdvisories(log.New(log.Options{Level: log.LevelInfo, Format: "text", Writer: &buf}), manifest, set("1.2.0")) + if buf.Len() != 0 { + t.Errorf("fixed version logged %q", buf.String()) + } +} diff --git a/internal/cli/feature_deps.go b/internal/cli/feature_deps.go index f0e6ab9..9f2a55b 100644 --- a/internal/cli/feature_deps.go +++ b/internal/cli/feature_deps.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -22,7 +23,7 @@ func renderDependencyMermaid(client oci.Registry, logger log.Logger, roots []str for _, r := range roots { userFeatures = append(userFeatures, features.DevContainerFeature{ UserFeatureID: r, - Options: map[string]interface{}{}, + Options: map[string]any{}, }) } graph, err := features.BuildDependencyGraph(logger, newMetadataProcessFeature(client, logger, "", nil), userFeatures, nil, nil) @@ -64,7 +65,10 @@ func newMetadataProcessFeature(client oci.Registry, logger log.Logger, basePath }, nil case features.SourceDirectTarball: - meta, err := readTarballFeatureMetadata(logger, id) + meta, err := readTarballFeatureMetadata(logger, id, lockfile) + if _, ok := errors.AsType[*digestMismatchError](err); ok { + return nil, err + } if err != nil { return nil, fmt.Errorf("ERR: Feature '%s' could not be processed. %w", id, err) } @@ -81,8 +85,8 @@ func newMetadataProcessFeature(client oci.Registry, logger log.Logger, basePath return nil, fmt.Errorf("ERR: Feature '%s' could not be processed. %w", id, err) } if lockfile != nil { - if entry, ok := lockfile.Features[id]; ok && entry.Integrity != "" { - if pinned, perr := oci.ParseRef(ref.Resource + "@" + entry.Integrity); perr == nil { + if integrity := lockfile.Features[id].Integrity; integrity != "" { + if pinned, perr := oci.ParseRef(ref.Resource + "@" + integrity); perr == nil { ref = pinned } } @@ -133,23 +137,30 @@ func readOCIFeatureMetadata(client oci.Registry, ref *oci.Ref, manifest *oci.Man if err != nil { return features.Feature{} } + meta, _ := featureMetadataFromTarball(blobData) + return meta +} + +// featureMetadataFromTarball extracts a Feature tarball and reads its +// devcontainer-feature.json; a tarball without one has empty metadata. +func featureMetadataFromTarball(blob []byte) (features.Feature, error) { tmp, err := os.MkdirTemp("", "devcontainer-feature-meta-") if err != nil { - return features.Feature{} + return features.Feature{}, err } defer os.RemoveAll(tmp) tgz := filepath.Join(tmp, "feature.tgz") - if err := os.WriteFile(tgz, blobData, 0644); err != nil { - return features.Feature{} + if err := os.WriteFile(tgz, blob, 0o644); err != nil { + return features.Feature{}, err } if err := extractTarGz(tgz, tmp); err != nil { - return features.Feature{} + return features.Feature{}, err } var meta features.Feature if data, err := os.ReadFile(filepath.Join(tmp, "devcontainer-feature.json")); err == nil { json.Unmarshal(data, &meta) } - return meta + return meta, nil } func ociAnnotationID(manifest *oci.ManifestContainer) string { @@ -178,26 +189,16 @@ func readLocalFeatureMetadata(dir string) (features.Feature, error) { return meta, nil } -func readTarballFeatureMetadata(logger log.Logger, tarballURL string) (features.Feature, error) { - blobData, _, err := downloadFeatureTarball(context.Background(), logger, tarballURL, osEnvMap()) - if err != nil { - return features.Feature{}, err - } - tmp, err := os.MkdirTemp("", "devcontainer-feature-meta-") +// readTarballFeatureMetadata downloads a direct tarball, checks it against the +// lockfile as TS getTgzFeatureMetadata does while building the graph, and reads +// its devcontainer-feature.json. +func readTarballFeatureMetadata(logger log.Logger, tarballURL string, lockfile *features.Lockfile) (features.Feature, error) { + blobData, digest, err := downloadFeatureTarball(context.Background(), logger, tarballURL, osEnvMap()) if err != nil { return features.Feature{}, err } - defer os.RemoveAll(tmp) - tgz := filepath.Join(tmp, "feature.tgz") - if err := os.WriteFile(tgz, blobData, 0644); err != nil { + if err := verifyTarballDigest(lockfile, tarballURL, digest); err != nil { return features.Feature{}, err } - if err := extractTarGz(tgz, tmp); err != nil { - return features.Feature{}, err - } - var meta features.Feature - if data, err := os.ReadFile(filepath.Join(tmp, "devcontainer-feature.json")); err == nil { - json.Unmarshal(data, &meta) - } - return meta, nil + return featureMetadataFromTarball(blobData) } diff --git a/internal/cli/feature_deps_held_test.go b/internal/cli/feature_deps_held_test.go new file mode 100644 index 0000000..faa61d4 --- /dev/null +++ b/internal/cli/feature_deps_held_test.go @@ -0,0 +1,177 @@ +package cli + +import ( + "net/http" + "reflect" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" +) + +func resolveMetadata(t *testing.T, reg oci.Registry, basePath string, lockfile *features.Lockfile, id string, opts any) (*features.Set, error) { + t.Helper() + return newMetadataProcessFeature(reg, log.Null, basePath, lockfile)(&features.FNode{UserFeatureID: id, Options: opts}) +} + +// A local Feature's metadata is its devcontainer-feature.json, its id the +// user's reference when the file declares none, and its value the options. +func TestMetadataProcessFeatureLocal(t *testing.T) { + base := t.TempDir() + writeFeature(t, base, "named", `{"id":"declared","dependsOn":{"./other":{}}}`) + writeFeature(t, base, "anonymous", `{"version":"1.0.0"}`) + opts := map[string]any{"flag": true} + + set, err := resolveMetadata(t, nil, base, nil, "./named", opts) + if err != nil { + t.Fatal(err) + } + if f := set.Features[0]; f.ID != "declared" || !reflect.DeepEqual(f.Value, opts) || f.DependsOn["./other"] == nil { + t.Errorf("feature = %+v, want id declared, the options and the dependsOn", f) + } + set, err = resolveMetadata(t, nil, base, nil, "./anonymous", nil) + if err != nil { + t.Fatal(err) + } + if got := set.Features[0].ID; got != "./anonymous" { + t.Errorf("id = %q, want the reference ./anonymous", got) + } + if _, err := resolveMetadata(t, nil, base, nil, "./missing", nil); err == nil || !strings.HasPrefix(err.Error(), "ERR: Feature './missing' could not be processed.") { + t.Errorf("missing local Feature: err = %v", err) + } +} + +// A direct tarball's metadata is the devcontainer-feature.json inside it. +func TestMetadataProcessFeatureTarball(t *testing.T) { + opts := map[string]any{"v": "1"} + uri := serveBytes(t, featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb","installsAfter":["x"]}`}), http.StatusOK) + "/f.tgz" + set, err := resolveMetadata(t, nil, "", nil, uri, opts) + if err != nil { + t.Fatal(err) + } + if f := set.Features[0]; f.ID != "tb" || !reflect.DeepEqual(f.InstallsAfter, []string{"x"}) || !reflect.DeepEqual(f.Value, opts) { + t.Errorf("feature = %+v", f) + } + missing := serveBytes(t, nil, http.StatusNotFound) + "/f.tgz" + if _, err := resolveMetadata(t, nil, "", nil, missing, nil); err == nil || !strings.HasPrefix(err.Error(), "ERR: Feature '"+missing+"' could not be processed.") { + t.Errorf("unreachable tarball: err = %v", err) + } + notTar := serveBytes(t, []byte("not a tarball"), http.StatusOK) + "/f.tgz" + if _, err := resolveMetadata(t, nil, "", nil, notTar, nil); err == nil { + t.Error("a tarball that does not extract must fail") + } +} + +// An OCI Feature's metadata is the manifest annotation, else (absent or empty) +// the devcontainer-feature.json of its first layer; its id is the annotation's, +// else the reference's. +func TestMetadataProcessFeatureOCI(t *testing.T) { + const id = "ghcr.io/owner/coll/hello:1" + layer := map[string]string{"devcontainer-feature.json": `{"id":"from-layer","dependsOn":{"ghcr.io/owner/coll/layer-dep:1":{}}}`} + annotation := `{"id":"from-annotation","dependsOn":{"ghcr.io/owner/coll/annotation-dep:1":{}}}` + empty := "" + opts := map[string]any{"o": "v"} + + for name, tc := range map[string]struct { + annotation *string + wantID, wantDep string + }{ + "annotation": {&annotation, "from-annotation", "ghcr.io/owner/coll/annotation-dep:1"}, + "no annotation": {nil, "hello", "ghcr.io/owner/coll/layer-dep:1"}, + "empty annotation": {&empty, "hello", "ghcr.io/owner/coll/layer-dep:1"}, + } { + t.Run(name, func(t *testing.T) { + reg := newOCIFeatureRegistry() + manifest := reg.add(t, "ghcr.io/owner/coll/hello:1", layer, tc.annotation) + set, err := resolveMetadata(t, reg, "", nil, id, opts) + if err != nil { + t.Fatal(err) + } + f := set.Features[0] + if f.ID != tc.wantID || f.DependsOn[tc.wantDep] == nil || len(f.DependsOn) != 1 || !reflect.DeepEqual(f.Value, opts) { + t.Errorf("feature = %+v, want id %q depending on %q", f, tc.wantID, tc.wantDep) + } + if set.ComputedDigest != manifest.ContentDigest || set.SourceInfo.(*features.OCISource).Resource != "ghcr.io/owner/coll/hello" { + t.Errorf("set = %+v", set) + } + }) + } + + t.Run("layer unavailable", func(t *testing.T) { + reg := newOCIFeatureRegistry() + reg.manifests["ghcr.io/owner/coll/hello:1"] = &oci.ManifestContainer{Manifest: &oci.Manifest{}} + m := reg.add(t, "ghcr.io/owner/coll/other:1", layer, nil) + reg.manifests["ghcr.io/owner/coll/broken:1"] = &oci.ManifestContainer{Manifest: &oci.Manifest{Layers: []oci.Layer{{Digest: "sha256:" + strings.Repeat("e", 64)}, m.Manifest.Layers[0]}}} + for _, ref := range []string{"ghcr.io/owner/coll/hello:1", "ghcr.io/owner/coll/broken:1"} { + set, err := resolveMetadata(t, reg, "", nil, ref, nil) + if err != nil { + t.Fatal(err) + } + if f := set.Features[0]; len(f.DependsOn) != 0 { + t.Errorf("%s: dependsOn = %v, want none without a readable first layer", ref, f.DependsOn) + } + } + }) + + t.Run("pinned by the lockfile", func(t *testing.T) { + digest := "sha256:" + strings.Repeat("cd", 32) + reg := newOCIFeatureRegistry() + reg.add(t, "ghcr.io/owner/coll/hello@"+digest, layer, nil) + lf := &features.Lockfile{Features: map[string]features.LockfileEntry{id: {Integrity: digest}}} + if _, err := resolveMetadata(t, reg, "", lf, id, nil); err != nil { + t.Fatal(err) + } + if want := []string{"ghcr.io/owner/coll/hello@" + digest}; !reflect.DeepEqual(reg.fetched, want) { + t.Errorf("fetched %v, want %v", reg.fetched, want) + } + }) + + for name, tc := range map[string]struct { + id string + setup func(r *ociFeatureRegistry) + want string + }{ + "unparseable reference": {id: ".bad/owner/hello", want: "ERR: Feature '.bad/owner/hello' could not be processed."}, + "manifest unavailable": {id: id, want: "You may not have permission to access this Feature"}, + "manifest empty": {id: id, setup: func(r *ociFeatureRegistry) { + r.manifests["ghcr.io/owner/coll/hello:1"] = &oci.ManifestContainer{} + }, want: "You may not have permission to access this Feature"}, + } { + t.Run(name, func(t *testing.T) { + reg := newOCIFeatureRegistry() + if tc.setup != nil { + tc.setup(reg) + } + if _, err := resolveMetadata(t, reg, "", nil, tc.id, nil); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +// `features info` draws the root and its dependencies; a graph that cannot be +// built is an empty flowchart. +func TestRenderDependencyMermaid(t *testing.T) { + reg := newOCIFeatureRegistry() + root := `{"id":"root","dependsOn":{"ghcr.io/owner/coll/dep:1":{}}}` + reg.add(t, "ghcr.io/owner/coll/root:1", map[string]string{"devcontainer-feature.json": root}, &root) + reg.add(t, "ghcr.io/owner/coll/dep:1", map[string]string{"devcontainer-feature.json": `{"id":"dep"}`}, nil) + + got := renderDependencyMermaid(reg, log.Null, []string{"ghcr.io/owner/coll/root:1"}) + if !strings.HasPrefix(got, "flowchart\n") || !strings.Contains(got, "[ghcr.io/owner/coll/root:1
") || !strings.Contains(got, "--> ") || !strings.Contains(got, "[ghcr.io/owner/coll/dep:1
") { + t.Errorf("mermaid = %q, want the root with an edge to its dependency", got) + } + if got := renderDependencyMermaid(reg, log.Null, []string{"ghcr.io/owner/coll/missing:1"}); got != "flowchart\n" { + t.Errorf("unresolvable root: mermaid = %q, want an empty flowchart", got) + } +} + +// A tarball without devcontainer-feature.json has empty metadata, not an error. +func TestFeatureMetadataFromTarballWithoutMetadata(t *testing.T) { + meta, err := featureMetadataFromTarball(featureTgzBytes(t, map[string]string{"install.sh": "#!/bin/sh\n"})) + if err != nil || !reflect.DeepEqual(meta, features.Feature{}) { + t.Fatalf("meta = %+v, err = %v; want empty metadata", meta, err) + } +} diff --git a/internal/cli/feature_hostsub_test.go b/internal/cli/feature_hostsub_test.go index 2a959d4..cc0f150 100644 --- a/internal/cli/feature_hostsub_test.go +++ b/internal/cli/feature_hostsub_test.go @@ -18,7 +18,7 @@ func TestSubstituteFeatureHostVars(t *testing.T) { sets := []*features.Set{{ Features: []features.Feature{{ ContainerEnv: map[string]string{"CACHE": "${localEnv:HOME}/.cache", "WS": "${localWorkspaceFolder}/x"}, - Mounts: []interface{}{"source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind"}, + Mounts: []any{"source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind"}, }}, }} diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index c791d51..f58c6c7 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -9,12 +9,16 @@ import ( "encoding/json" "fmt" "io" + "maps" "net/http" "net/url" "os" "path" "path/filepath" + "regexp" + "slices" "sort" + "strconv" "strings" "time" @@ -26,7 +30,7 @@ import ( "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" "github.com/devcontainers/cli/internal/oci" - "github.com/devcontainers/cli/internal/pfs" + mobyclient "github.com/moby/moby/client" ) // FeatureBuildOptions holds extra build options to thread through feature install. @@ -69,27 +73,37 @@ type FeatureBuildOptions struct { OverrideFeatureInstallOrder []string } -// extendImageWithFeatures fetches features from OCI, generates the extended -// Dockerfile, and builds the image with features installed. -// fetchFeatureResult holds fetched features and their temp directory. +// fetchFeatureResult holds the fetched features and their features folder. type fetchFeatureResult struct { FeatureSets []*features.Set - TmpDir string // caller must os.RemoveAll when done + // TmpDir is the features folder (TS dstFolder): + // /container-features/-, holding each Feature + // at its consecutiveId. Like TS, the build paths keep it. + TmpDir string } -// fetchFeatureSets fetches features and returns them in install order. reg is the -// registry seam; pass nil for the default OCI client. -func fetchFeatureSets(ctx context.Context, logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile) (*fetchFeatureResult, error) { - return fetchFeatureSetsWithOrder(ctx, logger, reg, featuresCfg, featuresBasePath, skipAutoMapping, lockfile, nil) +// fetchFeatureSets fetches features, without a lockfile or an install-order +// override, and returns them in install order. +func fetchFeatureSets(ctx context.Context, logger log.Logger, featuresCfg map[string]any, featuresBasePath string, skipAutoMapping bool) (*fetchFeatureResult, error) { + return fetchFeatureSetsWithOrder(ctx, logger, featuresCfg, featuresBasePath, skipAutoMapping, nil, nil) +} + +// createFeaturesFolder creates the folder the Features are staged in, TS +// createFeaturesTempFolder: /container-features/-. +func createFeaturesFolder() (string, error) { + dir := filepath.Join(cacheFolder(), "container-features", fmt.Sprintf("%s-%d", cliVersion(), time.Now().UnixMilli())) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create features folder: %w", err) + } + return dir, nil } // fetchFeatureSetsWithOrder resolves the feature dependency graph through the // unified features.BuildDependencyGraph builder, applying // overrideFeatureInstallOrder and returning the FeatureSets in install -// order. Each returned Set's content is staged under the returned TmpDir -// at _dev_container_feature_, matching the generated -// Dockerfile's COPY paths. -func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile, overrideOrder []string) (*fetchFeatureResult, error) { +// order. Each returned Set's content is staged in the features folder at its +// consecutiveId, `_`, as TS fetchFeatures does. +func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, featuresCfg map[string]any, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile, overrideOrder []string) (*fetchFeatureResult, error) { if len(featuresCfg) == 0 { return nil, nil } @@ -102,14 +116,11 @@ func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, reg oci.R logger.Write(fmt.Sprintf("Installing %d feature(s)...", len(userFeatures)), log.LevelInfo) - ociClient := reg - if ociClient == nil { - ociClient = newOCIClient(ctx, logger) - } + ociClient := newOCIClient(ctx, logger) - tmpDir, err := os.MkdirTemp("", "devcontainer-features-") + dstFolder, err := createFeaturesFolder() if err != nil { - return nil, fmt.Errorf("create temp dir: %w", err) + return nil, err } // processFeature is the injected seam (TS processFeature): given a node's @@ -117,9 +128,9 @@ func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, reg oci.R // (annotation-first, blob-fallback for OCI). Each call stages content in its // own directory; the final install-order index dictates the real directory // name, applied later by realignFeatureDirs. - stageIdx := 0 + var stageIdx int nextStageDir := func() string { - dir := filepath.Join(tmpDir, fmt.Sprintf("_dev_container_feature_stage_%d", stageIdx)) + dir := filepath.Join(dstFolder, fmt.Sprintf("%s%d", featureStagePrefix, stageIdx)) stageIdx++ return dir } @@ -130,35 +141,40 @@ func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, reg oci.R graph, err := features.BuildDependencyGraph(logger, processFeature, userFeatures, overrideOrder, lockfile) if err != nil { - os.RemoveAll(tmpDir) + os.RemoveAll(dstFolder) return nil, err } if len(graph) == 0 { - os.RemoveAll(tmpDir) + os.RemoveAll(dstFolder) return nil, nil } - orderedSets, err := features.ComputeInstallationOrder(logger, graph, nil) + orderedSets, err := features.ComputeInstallationOrder(logger, graph) if err != nil { - os.RemoveAll(tmpDir) + os.RemoveAll(dstFolder) return nil, fmt.Errorf("compute Feature install order: %w", err) } - if err := realignFeatureDirs(tmpDir, orderedSets); err != nil { - os.RemoveAll(tmpDir) + if err := realignFeatureDirs(dstFolder, orderedSets); err != nil { + os.RemoveAll(dstFolder) return nil, err } logger.Write(fmt.Sprintf("Install order: %s", featureSetIDs(orderedSets)), log.LevelTrace) - return &fetchFeatureResult{FeatureSets: orderedSets, TmpDir: tmpDir}, nil + logFeatureAdvisories(ctx, logger, orderedSets) + return &fetchFeatureResult{FeatureSets: orderedSets, TmpDir: dstFolder}, nil } +// featureStagePrefix names the folders a Feature is fetched into before its +// install-order index, and so its consecutiveId, is known. +const featureStagePrefix = ".stage-" + // featureFromMetadata builds a features.Feature for a local or direct-tarball // feature from its parsed devcontainer-feature.json (m), overriding the identity // and staging fields the caller controls. OCI features build their Feature // separately (they omit lifecycle commands here and merge annotation metadata // afterwards), so this helper covers only the two identical local/tarball paths. -func featureFromMetadata(m features.Feature, id string, opts interface{}, cachePath string) features.Feature { +func featureFromMetadata(m features.Feature, id string, opts any, cachePath string) features.Feature { return features.Feature{ ID: id, Version: m.Version, @@ -208,6 +224,7 @@ func processInstallFeature( if srcType == features.SourceLegacyShorthand { name := id + // mutate-exempt: idx > 1 differs only for a colon at index 1, which leaves a one-character name, and no legacy Feature has one: both bounds reject the id with the same error if idx := strings.LastIndex(id, ":"); idx > 0 { name = id[:idx] } @@ -252,7 +269,16 @@ func processInstallFeature( return nil, fmt.Errorf("parse local feature %q metadata: %w", id, err) } - feat := featureFromMetadata(m, id, opts, featureDir) + // TS: the Feature's id is the folder name and its name the user's + // reference, until devcontainer-feature.json's id and name replace them. + featID := m.ID + if featID == "" { + featID = filepath.Base(id) + } + feat := featureFromMetadata(m, featID, opts, featureDir) + if feat.Name == "" { + feat.Name = id + } return &features.Set{ SourceInfo: &features.LocalSource{LocalPath: id, ResolvedPath: resolvedPath, UserID: id}, Features: []features.Feature{feat}, @@ -261,22 +287,18 @@ func processInstallFeature( case features.SourceDirectTarball: featureDir := nextStageDir() - pfs.MkdirAll(featureDir) + os.MkdirAll(featureDir, 0o755) logger.Write(fmt.Sprintf("Fetching feature tarball %s...", id), log.LevelInfo) blobData, computedDigest, dlErr := downloadFeatureTarball(context.Background(), logger, id, osEnvMap()) if dlErr != nil { return nil, fmt.Errorf("ERR: Feature '%s' could not be processed. %w", id, dlErr) } - // Verify integrity against the lockfile pin when present (TS parity: it - // rejects a tarball whose digest does not match the expected one). - if lockfile != nil { - if entry, ok := lockfile.Features[id]; ok && entry.Integrity != "" && entry.Integrity != computedDigest { - return nil, fmt.Errorf("ERR: Feature '%s' tarball digest %s did not match lockfile integrity %s", id, computedDigest, entry.Integrity) - } + if err := verifyTarballDigest(lockfile, id, computedDigest); err != nil { + return nil, err } tgzPath := filepath.Join(featureDir, "feature.tgz") - if err := pfs.WriteFile(tgzPath, blobData); err != nil { + if err := os.WriteFile(tgzPath, blobData, 0o644); err != nil { return nil, fmt.Errorf("write feature tarball: %w", err) } if err := extractTarGz(tgzPath, featureDir); err != nil { @@ -289,10 +311,13 @@ func processInstallFeature( } featID := m.ID if featID == "" { - featID = filepath.Base(featureDir) + featID = tarballFeatureID(id) } feat := featureFromMetadata(m, featID, opts, featureDir) + if feat.Name == "" { + feat.Name = id + } return &features.Set{ SourceInfo: &features.TarballSource{TarballURI: id, UserID: id}, Features: []features.Feature{feat}, @@ -312,8 +337,8 @@ func processInstallFeature( // Pin to the digest recorded in the lockfile (reproducible resolution), // matching the TS CLI which fetches the manifest by the lockfile integrity. if lockfile != nil { - if entry, ok := lockfile.Features[id]; ok && entry.Integrity != "" { - if pinned, perr := oci.ParseRef(ref.Resource + "@" + entry.Integrity); perr == nil { + if integrity := lockfile.Features[id].Integrity; integrity != "" { + if pinned, perr := oci.ParseRef(ref.Resource + "@" + integrity); perr == nil { ref = pinned } } @@ -336,9 +361,9 @@ func processInstallFeature( } featureDir := nextStageDir() - pfs.MkdirAll(featureDir) + os.MkdirAll(featureDir, 0o755) tgzPath := filepath.Join(featureDir, "feature.tgz") - if err := pfs.WriteFile(tgzPath, blobData); err != nil { + if err := os.WriteFile(tgzPath, blobData, 0o644); err != nil { return nil, fmt.Errorf("write feature tarball: %w", err) } if err := extractTarGz(tgzPath, featureDir); err != nil { @@ -379,10 +404,8 @@ func processInstallFeature( feat.Version = ref.Tag } - userIDNoVer := id - if idx := strings.LastIndex(id, ":"); idx > 0 { - userIDNoVer = id[:idx] - } + userIDNoVer := featureIDWithoutVersion(id) + owner, _, _ := strings.Cut(ref.Namespace, "/") set := &features.Set{ SourceInfo: &features.OCISource{ @@ -393,7 +416,7 @@ func processInstallFeature( UserFeatureIdWithoutVersion: userIDNoVer, FeatureRef: map[string]string{ "id": ref.ID, "namespace": ref.Namespace, - "owner": strings.SplitN(ref.Namespace, "/", 2)[0], + "owner": owner, "path": ref.Namespace + "/" + ref.ID, "registry": ref.Registry, "scheme": ref.Scheme(), @@ -430,44 +453,145 @@ func processInstallFeature( } } -// realignFeatureDirs renames each ordered Set's staging directory to -// _dev_container_feature_ under tmpDir, so the generated -// Dockerfile's `COPY _dev_container_feature_` resolves to the right content. -// Orphaned staging directories (duplicates and soft-dependency probes that are -// not installed) are removed so they don't bloat the build context. -func realignFeatureDirs(tmpDir string, sets []*features.Set) error { - keep := make(map[string]bool, len(sets)) +// featureIDWithoutVersion is TS getFeatureIdWithoutVersion: the reference +// without its last `:tag` or `@digest` (a registry port is kept). +func featureIDWithoutVersion(id string) string { + if loc := featureVersionDelimiter.FindStringIndex(id); loc != nil { + return id[:loc[0]] + } + return id +} + +var featureVersionDelimiter = regexp.MustCompile(`[:@][^/]*$`) + +// tarballFeatureID is the id TS processFeatureIdentifier derives from a direct +// tarball's name, devcontainer-feature-.tgz, used when the Feature's +// devcontainer-feature.json declares none. +func tarballFeatureID(uri string) string { + name := uri + if u, err := url.Parse(uri); err == nil { + name = u.Path + } + name = path.Base(name) + if m := regexp.MustCompile(`devcontainer-feature-(.*)\.tgz`).FindStringSubmatch(name); m != nil { + return m[1] + } + return name +} + +// realignFeatureDirs moves each ordered Set's staging directory to +// /, consecutiveId being TS's +// `${feature.id}_${installOrderIndex}`, so the generated Dockerfile's +// references resolve to the right content. Orphaned staging directories +// (duplicates and soft-dependency probes that are not installed) are removed so +// they don't bloat the build context. +func realignFeatureDirs(dstFolder string, sets []*features.Set) error { for i, set := range sets { if len(set.Features) == 0 || set.Features[0].CachePath == "" { continue } + consecutiveID := fmt.Sprintf("%s_%d", set.Features[0].ID, i) src := set.Features[0].CachePath - dst := filepath.Join(tmpDir, fmt.Sprintf("_dev_container_feature_%d", i)) + dst := filepath.Join(dstFolder, consecutiveID) if src != dst { os.RemoveAll(dst) if err := os.Rename(src, dst); err != nil { if cpErr := copyDir(src, dst); cpErr != nil { - return fmt.Errorf("stage feature %d: %w", i, cpErr) + return fmt.Errorf("stage feature %s: %w", consecutiveID, cpErr) } os.RemoveAll(src) } set.Features[0].CachePath = dst } - // consecutiveId is the TS `${feature.id}_${idx}` identity (NOT the staging - // directory name, which stays _dev_container_feature_ for the COPY). - set.Features[0].ConsecutiveId = fmt.Sprintf("%s_%d", set.Features[0].ID, i) - keep[dst] = true + set.Features[0].ConsecutiveId = consecutiveID } // Remove any leftover staging directories. - entries, _ := os.ReadDir(tmpDir) + entries, _ := os.ReadDir(dstFolder) for _, e := range entries { - if e.IsDir() && strings.HasPrefix(e.Name(), "_dev_container_feature_stage_") { - os.RemoveAll(filepath.Join(tmpDir, e.Name())) + if e.IsDir() && strings.HasPrefix(e.Name(), featureStagePrefix) { + os.RemoveAll(filepath.Join(dstFolder, e.Name())) } } return nil } +// stageFeatureBuildFiles writes the files the features Dockerfile refers to, as +// TS getFeaturesBuildOptions does: devcontainer-features.builtin.env in the +// features folder, and devcontainer-features.env plus the +// devcontainer-features-install.sh wrapper in each Feature's folder. The option +// order, deprecation and currentId come from the Feature's staged +// devcontainer-feature.json, which TS spreads into the Feature. +func stageFeatureBuildFiles(dstFolder string, sets []*features.Set, containerUser, remoteUser string) error { + if err := os.WriteFile(filepath.Join(dstFolder, imagemeta.FeaturesBuiltinEnvFile), []byte(imagemeta.FeaturesBuiltinEnvContent(containerUser, remoteUser)), 0o644); err != nil { + return fmt.Errorf("write Features builtin env: %w", err) + } + for _, set := range sets { + for _, f := range set.Features { + if f.CachePath == "" { + continue + } + data, err := os.ReadFile(filepath.Join(f.CachePath, "devcontainer-feature.json")) + if err != nil { + return fmt.Errorf("read Feature %s metadata: %w", f.ID, err) + } + std, err := jsonc.StripComments(data) + if err != nil { + return fmt.Errorf("parse Feature %s metadata: %w", f.ID, err) + } + info, err := imagemeta.ParseFeatureFileInfo(std) + if err != nil { + return fmt.Errorf("parse Feature %s metadata: %w", f.ID, err) + } + vars := imagemeta.FeatureEnvVariables(f, info) + if err := os.WriteFile(filepath.Join(f.CachePath, imagemeta.FeatureEnvFile), []byte(strings.Join(vars, "\n")), 0o644); err != nil { + return fmt.Errorf("write Feature %s env: %w", f.ID, err) + } + wrapper := imagemeta.FeatureInstallWrapperScript(f, set, info, vars) + if err := os.WriteFile(filepath.Join(f.CachePath, imagemeta.FeatureInstallWrapperFile), []byte(wrapper), 0o644); err != nil { + return fmt.Errorf("write Feature %s install wrapper: %w", f.ID, err) + } + } + } + return nil +} + +// buildFeatureContentImage builds the temporary image holding the Features' +// content for a build without BuildKit build contexts (TS +// getFeaturesBuildOptions, non-BuildKit branch). +func buildFeatureContentImage(ctx context.Context, dockerClient *docker.Client, folder string) error { + dockerfile := filepath.Join(folder, "Dockerfile.buildContent") + if err := os.WriteFile(dockerfile, []byte("\n\tFROM scratch\n\tCOPY . /tmp/build-features/\n\t"), 0o644); err != nil { + return fmt.Errorf("write feature content Dockerfile: %w", err) + } + res, err := dockerClient.Build(ctx, docker.BuildOptions{ + Dockerfile: dockerfile, + ContextPath: folder, + Tags: []string{imagemeta.ComposeFeatureContentImage}, + }) + if err != nil { + return fmt.Errorf("build feature content image: %w", err) + } + if res.ExitCode != 0 { + return fmt.Errorf("build feature content image failed (exit %d): %s", res.ExitCode, string(res.Stderr)) + } + return nil +} + +// engineAtLeast23 reports whether the daemon is Docker Engine >= 23 (TS +// skipDefaultSyntax: BuildKit's built-in frontend supports build contexts). +func engineAtLeast23(ctx context.Context, engine *docker.EngineClient) bool { + if engine == nil || engine.API == nil { + return false + } + ver, err := engine.API.ServerVersion(ctx, mobyclient.ServerVersionOptions{}) + if err != nil { + return false + } + majorText, _, _ := strings.Cut(ver.Version, ".") + major, _ := strconv.Atoi(majorText) + return major >= 23 +} + // extendImageWithFeatures fetches features from OCI, generates the extended // Dockerfile, and builds the image with features installed. // Returns the new image name. @@ -496,7 +620,7 @@ func substituteFeatureHostVars(sets []*features.Set, hs config.HostSubContext) e if err != nil { return fmt.Errorf("resolve Feature %s mounts: %w", f.ID, err) } - if m, ok := resolved.([]interface{}); ok { + if m, ok := resolved.([]any); ok { f.Mounts = m } } @@ -511,26 +635,17 @@ func extendImageWithFeatures( dockerClient *docker.Client, engine *docker.EngineClient, baseImage string, - featuresCfg map[string]interface{}, + featuresCfg map[string]any, useBuildx bool, imageNames []string, fbOpts *FeatureBuildOptions, ) ([]string, error) { - skipAutoMap := fbOpts != nil && fbOpts.SkipFeatureAutoMapping - featuresBasePath := "" - if fbOpts != nil { - featuresBasePath = fbOpts.FeaturesBasePath - } // Read the lockfile (if any) to pin feature resolution to recorded digests. var lockfile *features.Lockfile - if fbOpts != nil && fbOpts.ConfigPath != "" && !fbOpts.NoLockfile { + if fbOpts.ConfigPath != "" && !fbOpts.NoLockfile { lockfile, _, _ = features.ReadLockfile(fbOpts.ConfigPath) } - var overrideOrder []string - if fbOpts != nil { - overrideOrder = fbOpts.OverrideFeatureInstallOrder - } - result, err := fetchFeatureSetsWithOrder(ctx, logger, nil, featuresCfg, featuresBasePath, skipAutoMap, lockfile, overrideOrder) + result, err := fetchFeatureSetsWithOrder(ctx, logger, featuresCfg, fbOpts.FeaturesBasePath, fbOpts.SkipFeatureAutoMapping, lockfile, fbOpts.OverrideFeatureInstallOrder) if err != nil { return nil, err } @@ -538,12 +653,12 @@ func extendImageWithFeatures( return imageNames, nil } featureSets := result.FeatureSets + // The features folder is kept, as TS keeps it. tmpDir := result.TmpDir - defer os.RemoveAll(tmpDir) // Resolve ${localEnv:…} / ${localWorkspaceFolder} / … in each Feature's // containerEnv and mounts, like the config's own values (upstream #308). - if fbOpts != nil && fbOpts.HostSub != nil { + if fbOpts.HostSub != nil { if err := substituteFeatureHostVars(featureSets, *fbOpts.HostSub); err != nil { return nil, err } @@ -552,7 +667,7 @@ func extendImageWithFeatures( // Write/validate the features lockfile when requested (opt-in via the // --experimental-lockfile / --experimental-frozen-lockfile flags). Writing // records the resolved digests for reproducibility; frozen aborts on drift. - if fbOpts != nil && (fbOpts.Lockfile || fbOpts.FrozenLockfile) && fbOpts.ConfigPath != "" { + if (fbOpts.Lockfile || fbOpts.FrozenLockfile) && fbOpts.ConfigPath != "" { lf := features.GenerateLockfile(&features.Config{FeatureSets: featureSets}, fbOpts.LockfileExcludeIDs) if err := features.WriteLockfile(fbOpts.ConfigPath, lf, features.WriteOptions{Frozen: fbOpts.FrozenLockfile, Force: fbOpts.Lockfile}); err != nil { return nil, fmt.Errorf("lockfile: %w", err) @@ -569,56 +684,55 @@ func extendImageWithFeatures( metadata = append(metadata, baseEntries...) } for _, fs := range featureSets { - skipPersistCustoms := fbOpts != nil && fbOpts.SkipPersistCustoms - metadata = append(metadata, featureMetadataEntry(fs, skipPersistCustoms)) - } - if fbOpts != nil && len(fbOpts.AdditionalMetadata) > 0 { - metadata = append(metadata, fbOpts.AdditionalMetadata...) - } - - // Resolve containerUser/remoteUser matching TS findContainerUsers(): - // metadata containerUser → image Config.User → "root" - // metadata remoteUser → containerUser - containerUser := "root" - remoteUser := "root" - if baseImageInfo.Config != nil { - if baseImageInfo.Config.User != "" { - containerUser = baseImageInfo.Config.User - } - if len(baseEntries) > 0 { - baseMerged := imagemeta.MergeConfiguration(baseEntries) - if baseMerged.ContainerUser != "" { - containerUser = baseMerged.ContainerUser - } - if baseMerged.RemoteUser != "" { - remoteUser = baseMerged.RemoteUser - } - } - } - if remoteUser == "root" { - remoteUser = containerUser + metadata = append(metadata, featureMetadataEntry(fs, fbOpts.SkipPersistCustoms)) } + metadata = append(metadata, fbOpts.AdditionalMetadata...) - // Collect config-level containerEnv if provided - var configContainerEnv map[string]string - if fbOpts != nil { - configContainerEnv = fbOpts.ContainerEnv + // TS findContainerUsers over the image, Features and config metadata. + imageUser := "root" + if baseImageInfo.Config != nil && baseImageInfo.Config.User != "" { + imageUser = baseImageInfo.Config.User } + containerUser, remoteUser := findContainerUsers(metadata, "", imageUser) // Build secrets are mounted into each feature-install RUN, but only when the // build uses buildx (RUN --mount=type=secret + --secret both require BuildKit). var secretIDs []string - if useBuildx && fbOpts != nil { + if useBuildx { secretIDs = buildSecretIDs(fbOpts.Secrets) } - buildInfo := imagemeta.GenerateExtendImageBuild( - baseImage, featureSets, metadata, containerUser, remoteUser, useBuildx, configContainerEnv, secretIDs, - ) + if err := stageFeatureBuildFiles(tmpDir, featureSets, containerUser, remoteUser); err != nil { + return nil, err + } + // TS dockerfilePrefixContent for an image (no Dockerfile of the user's): + // Docker >= 23 needs no syntax directive; an older one needs + // docker/dockerfile:1.4 for the build contexts. + syntax := "" + if useBuildx && !engineAtLeast23(ctx, engine) { + syntax = "docker/dockerfile:1.4" + } + buildInfo := imagemeta.GenerateFeaturesBuildOptions(imagemeta.FeaturesBuildParams{ + BaseName: baseImage, + FeatureSets: featureSets, + Metadata: metadata, + ContainerUser: containerUser, + RemoteUser: remoteUser, + ImageUser: imageUser, + UseBuildKitContexts: useBuildx, + Syntax: syntax, + ConfigContainerEnv: fbOpts.ContainerEnv, + SecretIDs: secretIDs, + }) + if !useBuildx { + if err := buildFeatureContentImage(ctx, dockerClient, tmpDir); err != nil { + return nil, err + } + } // Write the Dockerfile dockerfilePath := filepath.Join(tmpDir, "Dockerfile.extended") content := buildInfo.DockerfilePrefixContent + buildInfo.DockerfileContent - if err := pfs.WriteFile(dockerfilePath, []byte(content)); err != nil { + if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil { return nil, fmt.Errorf("write extended Dockerfile: %w", err) } @@ -644,19 +758,17 @@ func extendImageWithFeatures( Target: buildInfo.OverrideTarget, BuildArgs: buildInfo.BuildArgs, UseBuildx: useBuildx, - } - if fbOpts != nil { - buildOpts.NoCache = fbOpts.NoCache - buildOpts.CacheFrom = fbOpts.CacheFrom - buildOpts.CacheTo = fbOpts.CacheTo - buildOpts.Labels = fbOpts.Labels - buildOpts.Platform = fbOpts.Platform - buildOpts.Push = fbOpts.Push - buildOpts.Output = fbOpts.Output - buildOpts.Secrets = fbOpts.Secrets + NoCache: fbOpts.NoCache, + CacheFrom: fbOpts.CacheFrom, + CacheTo: fbOpts.CacheTo, + Labels: fbOpts.Labels, + Platform: fbOpts.Platform, + Push: fbOpts.Push, + Output: fbOpts.Output, + Secrets: fbOpts.Secrets, } - // Add BuildKit contexts — resolve relative paths to tmpDir + // Add BuildKit contexts — "." is the features folder for ctx, ctxPath := range buildInfo.BuildKitContexts { if ctxPath == "." { ctxPath = tmpDir @@ -695,17 +807,40 @@ func extendImageWithFeatures( logger.Write(string(buildResult.Stderr), log.LevelInfo) - if len(imageNames) > 0 { - return imageNames, nil + return tags, nil +} + +// findContainerUsers is TS findContainerUsers: the last containerUser of the +// metadata, else the compose service's user, else the image's user; the last +// remoteUser, else the containerUser. +func findContainerUsers(metadata []imagemeta.Entry, composeServiceUser, imageUser string) (containerUser, remoteUser string) { + for _, e := range slices.Backward(metadata) { + if containerUser == "" && e.ContainerUser != "" { + containerUser = e.ContainerUser + } + if remoteUser == "" && e.RemoteUser != "" { + remoteUser = e.RemoteUser + } + } + if containerUser == "" { + containerUser = composeServiceUser + } + if containerUser == "" { + containerUser = imageUser } - return []string{outputName}, nil + if remoteUser == "" { + remoteUser = containerUser + } + return containerUser, remoteUser } func featureMetadataEntry(fs *features.Set, skipPersistCustoms bool) imagemeta.Entry { feat := fs.Features[0] + // TS getDevcontainerMetadata: the entry's id is the user's reference + // (sourceInformation.userFeatureId) for every source type. id := feat.ID - if src, ok := fs.SourceInfo.(*features.OCISource); ok && src.UserID != "" { - id = src.UserID + if fs.SourceInfo != nil && fs.SourceInfo.UserFeatureID() != "" { + id = fs.SourceInfo.UserFeatureID() } // Note: a feature's containerEnv is NOT recorded in the metadata label — it // is baked into the image as ENV during the feature build. Recording it would @@ -724,18 +859,12 @@ func featureMetadataEntry(fs *features.Set, skipPersistCustoms bool) imagemeta.E if !skipPersistCustoms { entry.Customizations = feat.Customizations } - if feat.OnCreateCommand != nil { - entry.OnCreateCommand = feat.OnCreateCommand - } - if feat.PostCreateCommand != nil { - entry.PostCreateCommand = feat.PostCreateCommand - } - if feat.PostStartCommand != nil { - entry.PostStartCommand = feat.PostStartCommand - } - if feat.PostAttachCommand != nil { - entry.PostAttachCommand = feat.PostAttachCommand - } + // TS pickFeatureLifecycleHookProperties: every hook a Feature declares. + entry.OnCreateCommand = feat.OnCreateCommand + entry.UpdateContentCommand = feat.UpdateContentCommand + entry.PostCreateCommand = feat.PostCreateCommand + entry.PostStartCommand = feat.PostStartCommand + entry.PostAttachCommand = feat.PostAttachCommand return entry } @@ -825,13 +954,32 @@ func extractTarGz(archivePath, destDir string) error { } // extractUserOptions pulls out the user-provided options from the feature value. -func extractUserOptions(v interface{}) map[string]interface{} { - if m, ok := v.(map[string]interface{}); ok { +func extractUserOptions(v any) map[string]any { + if m, ok := v.(map[string]any); ok { return m } return nil } +// verifyTarballDigest rejects a direct tarball whose digest is not the one the +// lockfile records for its URI, with TS fetchContentsAtTarballUri's error, which +// reaches the user unwrapped. +func verifyTarballDigest(lockfile *features.Lockfile, uri, computedDigest string) error { + if lockfile == nil { + return nil + } + if want := lockfile.Features[uri].Integrity; want != "" && want != computedDigest { + return &digestMismatchError{uri: uri} + } + return nil +} + +type digestMismatchError struct{ uri string } + +func (e *digestMismatchError) Error() string { + return fmt.Sprintf("Digest did not match for %s.", e.uri) +} + // downloadFeatureTarball fetches a Feature published as a direct HTTP(S) tarball, // matching the TS CLI's request/getRequestHeaders behavior instead of a bare GET: // - sends a "devcontainer" User-Agent; @@ -867,14 +1015,31 @@ func downloadFeatureTarball(ctx context.Context, logger log.Logger, tarballURL s if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, "", fmt.Errorf("download %s: HTTP %d", tarballURL, resp.StatusCode) } - data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20)) // 512 MiB cap + data, err := readCapped(resp.Body, maxFeatureTarballBytes) if err != nil { - return nil, "", err + return nil, "", fmt.Errorf("download %s: %w", tarballURL, err) } sum := sha256.Sum256(data) return data, "sha256:" + hex.EncodeToString(sum[:]), nil } +// maxFeatureTarballBytes bounds a direct Feature tarball download. +const maxFeatureTarballBytes = 512 << 20 + +// readCapped reads r to the end, failing rather than truncating when it holds +// more than max bytes: a cut tarball would be extracted and its digest checked +// against the lockfile as if it were whole. +func readCapped(r io.Reader, max int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, max+1)) // mutate-exempt: any limit past max reads more than max from a body over the cap, so max+2 fails exactly where max+1 does + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("larger than the %d MiB a Feature tarball may be", max>>20) + } + return data, nil +} + // featureRequestHeaders mirrors the TS getRequestHeaders for a direct-tarball // source: always a "devcontainer" User-Agent, plus a bearer GITHUB_TOKEN when the // tarball is hosted on GitHub. Redirects to a signed CDN URL drop the header — @@ -912,12 +1077,10 @@ func isPlainHTTPURL(u string) bool { // addFeatureOption returns the feature options with key=true added, preserving // any existing options. Used to fold gradle/maven/jupyterlab into java/python. -func addFeatureOption(opts interface{}, key string) map[string]interface{} { - m := map[string]interface{}{} - if existing, ok := opts.(map[string]interface{}); ok { - for k, v := range existing { - m[k] = v - } +func addFeatureOption(opts any, key string) map[string]any { + m := map[string]any{} + if existing, ok := opts.(map[string]any); ok { + maps.Copy(m, existing) } m[key] = true return m diff --git a/internal/cli/feature_install_held_test.go b/internal/cli/feature_install_held_test.go new file mode 100644 index 0000000..c50dbcc --- /dev/null +++ b/internal/cli/feature_install_held_test.go @@ -0,0 +1,877 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" + + dockerspec "github.com/moby/docker-image-spec/specs-go/v1" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" + mobyclient "github.com/moby/moby/client" + + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" +) + +// featureTgzBytes is a gzipped Feature tarball holding the given files. +func featureTgzBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + var entries [][2]string + for name, content := range files { + entries = append(entries, [2]string{name, content}) + } + data, err := os.ReadFile(writeTarGz(t, entries)) + if err != nil { + t.Fatal(err) + } + return data +} + +func sha256Digest(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// ociFeatureRegistry is an oci.Registry serving Feature manifests and layers +// from memory, keyed by ":" or "@". It records +// every manifest reference asked for. +type ociFeatureRegistry struct { + fakeRegistry + manifests map[string]*oci.ManifestContainer + blobs map[string][]byte + blobErr error + fetched []string +} + +func newOCIFeatureRegistry() *ociFeatureRegistry { + return &ociFeatureRegistry{manifests: map[string]*oci.ManifestContainer{}, blobs: map[string][]byte{}} +} + +func ociRefKey(ref *oci.Ref) string { + if ref.Digest != "" { + return ref.Resource + "@" + ref.Digest + } + return ref.Resource + ":" + ref.Tag +} + +// add serves a Feature at key whose single layer holds files; annotation, when +// not nil, is the manifest's dev.containers.metadata. +func (r *ociFeatureRegistry) add(t *testing.T, key string, files map[string]string, annotation *string) *oci.ManifestContainer { + t.Helper() + blob := featureTgzBytes(t, files) + layer := sha256Digest(blob) + r.blobs[layer] = blob + m := &oci.ManifestContainer{ + Manifest: &oci.Manifest{Layers: []oci.Layer{{Digest: layer}}}, + ContentDigest: sha256Digest([]byte(key)), + } + if annotation != nil { + m.Manifest.Annotations = map[string]string{"dev.containers.metadata": *annotation} + } + r.manifests[key] = m + return m +} + +func (r *ociFeatureRegistry) FetchManifest(ref *oci.Ref, _ string) (*oci.ManifestContainer, error) { + key := ociRefKey(ref) + r.fetched = append(r.fetched, key) + if m, ok := r.manifests[key]; ok { + return m, nil + } + return nil, fmt.Errorf("manifest unknown: %s", key) +} + +func (r *ociFeatureRegistry) FetchBlob(_ *oci.Ref, digest string) ([]byte, error) { + if r.blobErr != nil { + return nil, r.blobErr + } + if b, ok := r.blobs[digest]; ok { + return b, nil + } + return nil, fmt.Errorf("blob unknown: %s", digest) +} + +// stageDirs hands out fresh staging folders under a temp dir, as +// fetchFeatureSetsWithOrder's nextStageDir does. +func stageDirs(t *testing.T) func() string { + base := t.TempDir() + n := 0 + return func() string { + n++ + return filepath.Join(base, fmt.Sprintf("stage-%d", n)) + } +} + +func installFeature(t *testing.T, reg oci.Registry, id string, opts any, lockfile *features.Lockfile) (*features.Set, error) { + t.Helper() + return processInstallFeature(log.Null, reg, &features.FNode{UserFeatureID: id, Options: opts}, t.TempDir(), false, lockfile, stageDirs(t)) +} + +// An OCI Feature's identity, version and staged content come from the +// reference and the layer's devcontainer-feature.json; the manifest's +// dev.containers.metadata annotation, when there is one, replaces every +// dependency and container property the layer declared. +func TestProcessInstallFeatureOCI(t *testing.T) { + const id = "ghcr.io/owner/coll/hello:1" + blobMeta := `{"id":"hello","name":"Hello","options":{"blob":{}},"dependsOn":{"blob/dep":{}},"installsAfter":["blob"], + "legacyIds":["blob"],"init":false,"privileged":false,"capAdd":["BLOB"],"securityOpt":["blob"],"entrypoint":"/blob", + "mounts":["blob"],"customizations":{"blob":{}}}` + annotation := `{"legacyIds":["old-hello"],"dependsOn":{"ghcr.io/owner/coll/dep:1":{}},"installsAfter":["ghcr.io/owner/coll/after"], + "options":{"greeting":{"type":"string"}},"init":true,"privileged":true,"capAdd":["SYS_PTRACE"],"securityOpt":["seccomp=unconfined"], + "entrypoint":"/ann","mounts":["source=a,target=/a,type=volume"],"customizations":{"vscode":{"extensions":["x"]}}}` + + t.Run("annotation replaces the layer's properties", func(t *testing.T) { + reg := newOCIFeatureRegistry() + manifest := reg.add(t, "ghcr.io/owner/coll/hello:1", map[string]string{"devcontainer-feature.json": blobMeta, "install.sh": "#!/bin/sh\n"}, &annotation) + set, err := installFeature(t, reg, id, map[string]any{"greeting": "hi"}, nil) + if err != nil { + t.Fatal(err) + } + if len(set.Features) != 1 { + t.Fatalf("features = %d, want 1", len(set.Features)) + } + var want features.Feature + if err := json.Unmarshal([]byte(annotation), &want); err != nil { + t.Fatal(err) + } + got := set.Features[0] + for name, pair := range map[string][2]any{ + "legacyIds": {got.LegacyIds, want.LegacyIds}, + "dependsOn": {got.DependsOn, want.DependsOn}, + "installsAfter": {got.InstallsAfter, want.InstallsAfter}, + "options": {got.Options, want.Options}, + "init": {got.Init, want.Init}, + "privileged": {got.Privileged, want.Privileged}, + "capAdd": {got.CapAdd, want.CapAdd}, + "securityOpt": {got.SecurityOpt, want.SecurityOpt}, + "entrypoint": {got.Entrypoint, want.Entrypoint}, + "mounts": {got.Mounts, want.Mounts}, + "customizations": {got.Customizations, want.Customizations}, + } { + if !reflect.DeepEqual(pair[0], pair[1]) { + t.Errorf("%s = %#v, want the annotation's %#v", name, pair[0], pair[1]) + } + } + if got.ID != "hello" || got.Name != "Hello" || got.Version != "1" { + t.Errorf("id/name/version = %q/%q/%q, want hello/Hello/1 (the tag, as the layer has no version)", got.ID, got.Name, got.Version) + } + if _, err := os.Stat(filepath.Join(got.CachePath, "install.sh")); err != nil { + t.Errorf("content not staged: %v", err) + } + src, ok := set.SourceInfo.(*features.OCISource) + if !ok { + t.Fatalf("source = %T", set.SourceInfo) + } + ref := src.FeatureRef.(map[string]string) + if ref["owner"] != "owner" || ref["path"] != "owner/coll/hello" || ref["tag"] != "1" { + t.Errorf("featureRef = %v", ref) + } + if src.UserFeatureIdWithoutVersion != "ghcr.io/owner/coll/hello" || set.ComputedDigest != manifest.ContentDigest { + t.Errorf("userFeatureIdWithoutVersion = %q, computedDigest = %q", src.UserFeatureIdWithoutVersion, set.ComputedDigest) + } + }) + + t.Run("without annotation the layer's metadata stands", func(t *testing.T) { + reg := newOCIFeatureRegistry() + reg.add(t, "ghcr.io/owner/coll/hello:1", map[string]string{"devcontainer-feature.json": `{"id":"hello","version":"2.3.0","capAdd":["BLOB"]}`}, nil) + set, err := installFeature(t, reg, id, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := set.Features[0]; got.Version != "2.3.0" || !reflect.DeepEqual(got.CapAdd, []string{"BLOB"}) { + t.Errorf("version = %q capAdd = %v, want the layer's 2.3.0 [BLOB]", got.Version, got.CapAdd) + } + }) +} + +// A lockfile entry with an integrity pins the manifest fetch to that digest; +// an entry without one, or for another Feature, leaves the tag. +func TestProcessInstallFeatureOCIPinsToTheLockfile(t *testing.T) { + const id = "ghcr.io/owner/coll/hello:1" + digest := "sha256:" + strings.Repeat("cd", 32) + files := map[string]string{"devcontainer-feature.json": `{"id":"hello"}`} + reg := newOCIFeatureRegistry() + reg.add(t, "ghcr.io/owner/coll/hello@"+digest, files, nil) + reg.add(t, "ghcr.io/owner/coll/hello:1", files, nil) + + for name, tc := range map[string]struct { + lockfile *features.Lockfile + want string + }{ + "pinned": {&features.Lockfile{Features: map[string]features.LockfileEntry{id: {Integrity: digest}}}, "ghcr.io/owner/coll/hello@" + digest}, + "no integrity": {&features.Lockfile{Features: map[string]features.LockfileEntry{id: {Version: "1.0.0"}}}, "ghcr.io/owner/coll/hello:1"}, + "other entry": {&features.Lockfile{Features: map[string]features.LockfileEntry{"ghcr.io/x/y/z:1": {Integrity: digest}}}, "ghcr.io/owner/coll/hello:1"}, + } { + t.Run(name, func(t *testing.T) { + reg.fetched = nil + if _, err := installFeature(t, reg, id, nil, tc.lockfile); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reg.fetched, []string{tc.want}) { + t.Errorf("fetched %v, want %v", reg.fetched, []string{tc.want}) + } + }) + } +} + +// A known legacy shorthand with a version resolves to its registry Feature. +func TestProcessInstallFeatureLegacyShorthandWithVersion(t *testing.T) { + reg := newOCIFeatureRegistry() + reg.add(t, "ghcr.io/devcontainers/features/node:1", map[string]string{"devcontainer-feature.json": `{"id":"node"}`}, nil) + set, err := installFeature(t, reg, "node:1", nil, nil) + if err != nil { + t.Fatal(err) + } + if got := set.SourceInfo.(*features.OCISource).Resource; got != "ghcr.io/devcontainers/features/node" { + t.Errorf("resource = %q", got) + } +} + +// Every way an OCI Feature can fail to be fetched or staged is an error naming +// the Feature. +func TestProcessInstallFeatureOCIErrors(t *testing.T) { + const id = "ghcr.io/owner/coll/hello:1" + good := map[string]string{"devcontainer-feature.json": `{"id":"hello"}`} + blocker := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocker, nil, 0o644); err != nil { + t.Fatal(err) + } + for name, tc := range map[string]struct { + id string + setup func(r *ociFeatureRegistry) + stage func() string + wantErr string + }{ + "unparseable reference": {id: ".bad/owner/hello", wantErr: "ERR: Feature '.bad/owner/hello' could not be processed."}, + "manifest unavailable": {id: "ghcr.io/owner/coll/missing:1", wantErr: "You may not have permission to access this Feature"}, + "no layers": {setup: func(r *ociFeatureRegistry) { + r.manifests["ghcr.io/owner/coll/hello:1"] = &oci.ManifestContainer{Manifest: &oci.Manifest{}} + }, wantErr: "The Feature manifest has no layers."}, + "blob unavailable": {setup: func(r *ociFeatureRegistry) { + r.add(t, "ghcr.io/owner/coll/hello:1", good, nil) + r.blobErr = errors.New("boom") + }, wantErr: `fetch feature "ghcr.io/owner/coll/hello:1" blob: boom`}, + "blob not a tarball": {setup: func(r *ociFeatureRegistry) { + m := r.add(t, "ghcr.io/owner/coll/hello:1", good, nil) + r.blobs[m.Manifest.Layers[0].Digest] = []byte("not a tarball") + }, wantErr: `extract feature "ghcr.io/owner/coll/hello:1"`}, + "staging folder not writable": {setup: func(r *ociFeatureRegistry) { + r.add(t, "ghcr.io/owner/coll/hello:1", good, nil) + }, stage: func() string { return filepath.Join(blocker, "stage") }, wantErr: "write feature tarball"}, + } { + t.Run(name, func(t *testing.T) { + reg := newOCIFeatureRegistry() + if tc.setup != nil { + tc.setup(reg) + } + featureID := tc.id + if featureID == "" { + featureID = id + } + stage := tc.stage + if stage == nil { + stage = stageDirs(t) + } + _, err := processInstallFeature(log.Null, reg, &features.FNode{UserFeatureID: featureID}, t.TempDir(), false, nil, stage) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + }) + } +} + +// serveBytes serves body at every path and returns the server's URL. +func serveBytes(t *testing.T, body []byte, status int) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// A direct-tarball Feature takes its id from devcontainer-feature.json, else +// from the tarball's devcontainer-feature-.tgz name; its name, when the file +// has none, is the user's reference; its digest is recorded for the lockfile. +func TestProcessInstallFeatureTarball(t *testing.T) { + for name, tc := range map[string]struct { + files map[string]string + path string + wantID, wantName string + }{ + "metadata id, no name": {files: map[string]string{"devcontainer-feature.json": `{"id":"meta-id"}`}, path: "/devcontainer-feature-file-id.tgz", wantID: "meta-id"}, + "metadata name kept": {files: map[string]string{"devcontainer-feature.json": `{"id":"meta-id","name":"Pretty"}`}, path: "/f.tgz", wantID: "meta-id", wantName: "Pretty"}, + "no metadata": {files: map[string]string{"install.sh": "#!/bin/sh\n"}, path: "/devcontainer-feature-file-id.tgz", wantID: "file-id"}, + } { + t.Run(name, func(t *testing.T) { + blob := featureTgzBytes(t, tc.files) + uri := serveBytes(t, blob, http.StatusOK) + tc.path + set, err := installFeature(t, nil, uri, nil, nil) + if err != nil { + t.Fatal(err) + } + wantName := tc.wantName + if wantName == "" { + wantName = uri + } + f := set.Features[0] + if f.ID != tc.wantID || f.Name != wantName { + t.Errorf("id/name = %q/%q, want %q/%q", f.ID, f.Name, tc.wantID, wantName) + } + if set.ComputedDigest != sha256Digest(blob) { + t.Errorf("computedDigest = %q, want %q", set.ComputedDigest, sha256Digest(blob)) + } + }) + } +} + +func TestProcessInstallFeatureTarballErrors(t *testing.T) { + blob := featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"x"}`}) + blocker := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocker, nil, 0o644); err != nil { + t.Fatal(err) + } + + t.Run("download fails", func(t *testing.T) { + uri := serveBytes(t, nil, http.StatusNotFound) + "/f.tgz" + _, err := installFeature(t, nil, uri, nil, nil) + if err == nil || !strings.HasPrefix(err.Error(), "ERR: Feature '"+uri+"' could not be processed.") { + t.Fatalf("err = %v", err) + } + }) + t.Run("digest differs from the lockfile", func(t *testing.T) { + uri := serveBytes(t, blob, http.StatusOK) + "/f.tgz" + lf := &features.Lockfile{Features: map[string]features.LockfileEntry{uri: {Integrity: "sha256:" + strings.Repeat("0", 64)}}} + _, err := installFeature(t, nil, uri, nil, lf) + if _, ok := errors.AsType[*digestMismatchError](err); !ok { + t.Fatalf("err = %v, want a digest mismatch", err) + } + }) + t.Run("not a tarball", func(t *testing.T) { + uri := serveBytes(t, []byte("not a tarball"), http.StatusOK) + "/f.tgz" + _, err := installFeature(t, nil, uri, nil, nil) + if err == nil || !strings.Contains(err.Error(), "extract feature") { + t.Fatalf("err = %v", err) + } + }) + t.Run("staging folder not writable", func(t *testing.T) { + uri := serveBytes(t, blob, http.StatusOK) + "/f.tgz" + _, err := processInstallFeature(log.Null, nil, &features.FNode{UserFeatureID: uri}, t.TempDir(), false, nil, func() string { return filepath.Join(blocker, "stage") }) + if err == nil || !strings.Contains(err.Error(), "write feature tarball") { + t.Fatalf("err = %v", err) + } + }) +} + +// The id of a tarball comes from its URL's path: a fragment or query is not +// part of the name. +func TestTarballFeatureIDUsesTheURLPath(t *testing.T) { + for uri, want := range map[string]string{ + "https://example.com/r/devcontainer-feature-go.tgz#frag/x": "go", + "https://example.com/r/plain.tgz": "plain.tgz", + "https://[::1/devcontainer-feature-bad.tgz": "bad", + } { + if got := tarballFeatureID(uri); got != want { + t.Errorf("tarballFeatureID(%q) = %q, want %q", uri, got, want) + } + } +} + +// Root Features are resolved in sorted order, so of two broken ones the error +// always names the same. +func TestFetchFeatureSetsReportsTheFirstSortedFailure(t *testing.T) { + for range 5 { + _, err := fetchFeatureSets(t.Context(), log.Null, map[string]any{"./b-missing": true, "./a-missing": true, "./c-missing": true}, t.TempDir(), false) + if err == nil || !strings.Contains(err.Error(), "'./a-missing'") { + t.Fatalf("err = %v, want the failure of ./a-missing", err) + } + } +} + +// A staged Feature whose folder cannot be moved or copied fails the staging. +func TestRealignFeatureDirsFailsOnMissingStage(t *testing.T) { + dst := t.TempDir() + set := &features.Set{Features: []features.Feature{{ID: "x", CachePath: filepath.Join(dst, ".stage-gone")}}} + err := realignFeatureDirs(dst, []*features.Set{set}) + if err == nil || !strings.Contains(err.Error(), "stage feature x_0") { + t.Fatalf("err = %v", err) + } +} + +func TestFindContainerUsers(t *testing.T) { + for name, tc := range map[string]struct { + metadata []imagemeta.Entry + compose, image string + wantContainer, wantR string + }{ + "image user": {image: "img", wantContainer: "img", wantR: "img"}, + "compose over image": {compose: "svc", image: "img", wantContainer: "svc", wantR: "svc"}, + "last metadata wins": {metadata: []imagemeta.Entry{{ContainerUser: "a", RemoteUser: "ra"}, {ContainerUser: "b"}, {RemoteUser: "rc"}, {}}, compose: "svc", image: "img", wantContainer: "b", wantR: "rc"}, + "remote from container": {metadata: []imagemeta.Entry{{ContainerUser: "a"}}, image: "img", wantContainer: "a", wantR: "a"}, + } { + t.Run(name, func(t *testing.T) { + c, r := findContainerUsers(tc.metadata, tc.compose, tc.image) + if c != tc.wantContainer || r != tc.wantR { + t.Errorf("users = %q/%q, want %q/%q", c, r, tc.wantContainer, tc.wantR) + } + }) + } +} + +// fakeDockerAPI is a docker.API whose image, version and container calls answer +// from its fields; any other call panics through the nil embedded interface. +type fakeDockerAPI struct { + docker.API + version string + versionErr error + imageConfig *dockerspec.DockerOCIImageConfig + containers []string + listErr error + inspect map[string]container.InspectResponse + inspectErr error + stopErr error + stopped []string + stopTimeouts []int +} + +func (f *fakeDockerAPI) ServerVersion(context.Context, mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) { + return mobyclient.ServerVersionResult{Version: f.version}, f.versionErr +} + +func (f *fakeDockerAPI) ImageInspect(context.Context, string, ...mobyclient.ImageInspectOption) (mobyclient.ImageInspectResult, error) { + return mobyclient.ImageInspectResult{InspectResponse: image.InspectResponse{Config: f.imageConfig}}, nil +} + +func (f *fakeDockerAPI) ContainerList(context.Context, mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) { + var items []container.Summary + for _, id := range f.containers { + items = append(items, container.Summary{ID: id}) + } + return mobyclient.ContainerListResult{Items: items}, f.listErr +} + +func (f *fakeDockerAPI) ContainerInspect(_ context.Context, id string, _ mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) { + return mobyclient.ContainerInspectResult{Container: f.inspect[id]}, f.inspectErr +} + +func (f *fakeDockerAPI) ContainerStop(_ context.Context, id string, opts mobyclient.ContainerStopOptions) (mobyclient.ContainerStopResult, error) { + f.stopped = append(f.stopped, id) + if opts.Timeout != nil { + f.stopTimeouts = append(f.stopTimeouts, *opts.Timeout) + } + return mobyclient.ContainerStopResult{}, f.stopErr +} + +func TestEngineAtLeast23(t *testing.T) { + if engineAtLeast23(t.Context(), nil) || engineAtLeast23(t.Context(), &docker.EngineClient{}) { + t.Error("no engine must not count as >= 23") + } + if engineAtLeast23(t.Context(), &docker.EngineClient{API: &fakeDockerAPI{version: "25.0.0", versionErr: errors.New("down")}}) { + t.Error("an unreachable daemon must not count as >= 23") + } + for version, want := range map[string]bool{"23.0.1": true, "24.0.7": true, "22.06.2": false, "20.10.7": false, "": false} { + if got := engineAtLeast23(t.Context(), &docker.EngineClient{API: &fakeDockerAPI{version: version}}); got != want { + t.Errorf("engineAtLeast23(%q) = %v, want %v", version, got, want) + } + } +} + +// dockerCall is one recorded docker CLI invocation. +type dockerCall []string + +// fakeDockerRunner is an exec.Runner standing in for the docker CLI: reply +// answers each invocation, and every invocation is recorded. +type fakeDockerRunner struct { + calls []dockerCall + reply func(args []string) (stdout string, code int, err error) +} + +func (f *fakeDockerRunner) Run(_ context.Context, _ io.Writer, _ string, args ...string) ([]byte, []byte, int, error) { + f.calls = append(f.calls, append(dockerCall{}, args...)) + if f.reply == nil { + return nil, nil, 0, nil + } + out, code, err := f.reply(args) + return []byte(out), []byte("stderr of " + strings.Join(args, " ")), code, err +} + +func fakeDockerClient(r *fakeDockerRunner) *docker.Client { + return &docker.Client{DockerPath: "docker", Log: log.Null, Runner: r} +} + +func TestBuildFeatureContentImage(t *testing.T) { + t.Run("builds the content image from the folder", func(t *testing.T) { + folder := t.TempDir() + r := &fakeDockerRunner{} + if err := buildFeatureContentImage(t.Context(), fakeDockerClient(r), folder); err != nil { + t.Fatal(err) + } + dockerfile := filepath.Join(folder, "Dockerfile.buildContent") + want := dockerCall{"build", "-f", dockerfile, "-t", imagemeta.ComposeFeatureContentImage, folder} + if len(r.calls) != 1 || !reflect.DeepEqual(r.calls[0], want) { + t.Fatalf("calls = %v, want [%v]", r.calls, want) + } + data, err := os.ReadFile(dockerfile) + if err != nil || !strings.Contains(string(data), "COPY . /tmp/build-features/") { + t.Errorf("Dockerfile = %q, %v", data, err) + } + }) + for name, tc := range map[string]struct { + folder func(t *testing.T) string + reply func([]string) (string, int, error) + wantErr string + }{ + "folder missing": {folder: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, wantErr: "write feature content Dockerfile"}, + "docker fails to run": {reply: func([]string) (string, int, error) { return "", 0, errors.New("no docker") }, wantErr: "build feature content image: exec docker: no docker"}, + "build fails": {reply: func([]string) (string, int, error) { return "", 2, nil }, wantErr: "build feature content image failed (exit 2): stderr of build"}, + } { + t.Run(name, func(t *testing.T) { + folder := t.TempDir() + if tc.folder != nil { + folder = tc.folder(t) + } + err := buildFeatureContentImage(t.Context(), fakeDockerClient(&fakeDockerRunner{reply: tc.reply}), folder) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + }) + } +} + +// extendRun is one extendImageWithFeatures call over a local Feature with a +// fake docker CLI and engine. +type extendRun struct { + useBuildx bool + imageNames []string + imageConfig *dockerspec.DockerOCIImageConfig + reply func([]string) (string, int, error) + fbOpts FeatureBuildOptions + features map[string]any + engine string // the daemon's version, 25.0.0 when empty +} + +type extendResult struct { + names []string + err error + calls []dockerCall + build dockerCall // the extended image's build + features string // the features folder +} + +func (e extendRun) run(t *testing.T) extendResult { + t.Helper() + base := t.TempDir() + writeLocalFeature(t, base, "hello", nil) + r := &fakeDockerRunner{reply: e.reply} + opts := e.fbOpts + opts.FeaturesBasePath = base + cfg := e.features + if cfg == nil { + cfg = map[string]any{"./hello": map[string]any{}} + } + version := e.engine + if version == "" { + version = "25.0.0" + } + names, err := extendImageWithFeatures(t.Context(), log.Null, fakeDockerClient(r), &docker.EngineClient{API: &fakeDockerAPI{version: version, imageConfig: e.imageConfig}}, + "base:latest", cfg, e.useBuildx, e.imageNames, &opts) + res := extendResult{names: names, err: err, calls: r.calls} + for _, c := range r.calls { + if slices.ContainsFunc(c, func(a string) bool { return strings.HasSuffix(a, "Dockerfile.extended") }) { + res.build = c + res.features = c[len(c)-1] + t.Cleanup(func() { os.RemoveAll(res.features) }) + } + } + return res +} + +// tagsOf lists the -t values of a build. +func tagsOf(c dockerCall) []string { + var tags []string + for i, a := range c { + if a == "-t" && i+1 < len(c) { + tags = append(tags, c[i+1]) + } + } + return tags +} + +// The extended image is tagged with every given image name, else +// -features, and the names it reports are those tags. +func TestExtendImageWithFeaturesTags(t *testing.T) { + for name, tc := range map[string]struct { + names, want []string + }{ + "no names": {nil, []string{"base:latest-features"}}, + "one name": {[]string{"one"}, []string{"one"}}, + "two names": {[]string{"one", "two"}, []string{"one", "two"}}, + } { + t.Run(name, func(t *testing.T) { + res := extendRun{imageNames: tc.names}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + if !reflect.DeepEqual(tagsOf(res.build), tc.want) || !reflect.DeepEqual(res.names, tc.want) { + t.Errorf("tags = %v, names = %v, want %v", tagsOf(res.build), res.names, tc.want) + } + }) + } +} + +// Without metadata naming a user, the Features install as the image's user, +// root when the image declares none. +func TestExtendImageWithFeaturesUsesTheImageUser(t *testing.T) { + for name, tc := range map[string]struct { + config *dockerspec.DockerOCIImageConfig + want string + }{ + "no config": {nil, "root"}, + "no user": {&dockerspec.DockerOCIImageConfig{}, "root"}, + "image's user": {func() *dockerspec.DockerOCIImageConfig { + c := &dockerspec.DockerOCIImageConfig{} + c.User = "app" + return c + }(), "app"}, + } { + t.Run(name, func(t *testing.T) { + res := extendRun{imageConfig: tc.config}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + data, err := os.ReadFile(filepath.Join(res.features, imagemeta.FeaturesBuiltinEnvFile)) + if err != nil { + t.Fatal(err) + } + if want := "_CONTAINER_USER=" + tc.want + "\n"; !strings.Contains(string(data), want) { + t.Errorf("builtin env = %q, want %q", data, want) + } + }) + } +} + +// The classic builder needs the Features' content image built first, and its +// failure stops the build. +func TestExtendImageWithFeaturesContentImage(t *testing.T) { + res := extendRun{}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + if len(res.calls) != 2 || !slices.Contains(res.calls[0], imagemeta.ComposeFeatureContentImage) { + t.Fatalf("calls = %v, want the content image build then the extended build", res.calls) + } + failed := extendRun{reply: func(args []string) (string, int, error) { + if slices.Contains(args, imagemeta.ComposeFeatureContentImage) { + return "", 1, nil + } + return "", 0, nil + }}.run(t) + if failed.err == nil || !strings.Contains(failed.err.Error(), "build feature content image failed") || failed.build != nil { + t.Fatalf("err = %v, extended build = %v; want the content image failure and no extended build", failed.err, failed.build) + } +} + +// A buildx builder that cannot see local images (docker-container or remote +// driver) is replaced by the context's docker-driver builder for the Features +// build; a docker-driver builder is kept. +func TestExtendImageWithFeaturesSwitchesToADockerDriverBuilder(t *testing.T) { + for driver, want := range map[string]bool{"docker-container": true, "remote": true, "docker": false} { + t.Run(driver, func(t *testing.T) { + reply := func(args []string) (string, int, error) { + switch strings.Join(args, " ") { + case "buildx inspect": + return "Name: active\nDriver: " + driver + "\n", 0, nil + case "context inspect --format {{.Name}}": + return "default\n", 0, nil + case "buildx ls": + return "NAME/NODE DRIVER/ENDPOINT\nactive " + driver + "\ndefault docker\n", 0, nil + } + return "", 0, nil + } + res := extendRun{useBuildx: true, reply: reply}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + i := slices.Index(res.build, "--builder") + if (i >= 0) != want || (want && res.build[i+1] != "default") { + t.Errorf("build = %v, want --builder default: %v", res.build, want) + } + }) + } +} + +// --experimental-lockfile writes the lockfile; --experimental-frozen-lockfile +// fails when the resolved Features differ from it. An existing lockfile pins +// the Features it records. +func TestExtendImageWithFeaturesLockfile(t *testing.T) { + t.Run("written", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + res := extendRun{fbOpts: FeatureBuildOptions{Lockfile: true, ConfigPath: configPath}}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + if _, err := os.Stat(features.LockfilePath(configPath)); err != nil { + t.Errorf("lockfile not written: %v", err) + } + }) + t.Run("left alone without the flags", func(t *testing.T) { + t.Chdir(t.TempDir()) + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + if err := os.WriteFile(features.LockfilePath(configPath), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if res := (extendRun{fbOpts: FeatureBuildOptions{ConfigPath: configPath}}).run(t); res.err != nil { + t.Fatal(res.err) + } + if data, err := os.ReadFile(features.LockfilePath(configPath)); err != nil || string(data) != "{}" { + t.Errorf("lockfile = %q, %v; want it untouched", data, err) + } + // Without a configuration there is no lockfile to write. + if res := (extendRun{fbOpts: FeatureBuildOptions{Lockfile: true}}).run(t); res.err != nil { + t.Fatal(res.err) + } + if entries, _ := os.ReadDir("."); len(entries) != 0 { + t.Errorf("wrote %v in the working directory without a configuration", entries) + } + }) + t.Run("frozen drift", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + if err := os.WriteFile(features.LockfilePath(configPath), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + res := extendRun{fbOpts: FeatureBuildOptions{FrozenLockfile: true, ConfigPath: configPath}}.run(t) + if res.err == nil || !strings.HasPrefix(res.err.Error(), "lockfile: ") || res.build != nil { + t.Fatalf("err = %v, build = %v; want a lockfile error before any build", res.err, res.build) + } + }) + t.Run("pins on read", func(t *testing.T) { + uri := serveBytes(t, featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb"}`}), http.StatusOK) + "/devcontainer-feature-tb.tgz" + configPath := filepath.Join(t.TempDir(), "devcontainer.json") + lock := fmt.Sprintf(`{"features":{%q:{"version":"1","resolved":%q,"integrity":"sha256:%s"}}}`, uri, uri, strings.Repeat("0", 64)) + if err := os.WriteFile(features.LockfilePath(configPath), []byte(lock), 0o644); err != nil { + t.Fatal(err) + } + res := extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: configPath}}.run(t) + if _, ok := errors.AsType[*digestMismatchError](res.err); !ok { + t.Fatalf("err = %v, want the lockfile's digest mismatch", res.err) + } + // --no-lockfile neither reads nor verifies it. + unlocked := extendRun{features: map[string]any{uri: true}, fbOpts: FeatureBuildOptions{ConfigPath: configPath, NoLockfile: true}}.run(t) + if unlocked.err != nil { + t.Fatalf("--no-lockfile: err = %v", unlocked.err) + } + }) +} + +// The build of the extended image gets a DOCKER_CONFIG with the CLI's +// credentials for the registry it is tagged for. +func TestExtendImageWithFeaturesBridgesRegistryAuth(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake docker is a shell script") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "docker.log") + script := filepath.Join(dir, "docker") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho \"$* DOCKER_CONFIG=$DOCKER_CONFIG\" >> \"$FAKE_DOCKER_LOG\"\n"), 0o755); err != nil { + t.Fatal(err) + } + ambient := t.TempDir() + t.Setenv("DOCKER_CONFIG", ambient) + t.Setenv("DEVCONTAINERS_OCI_AUTH", "registry.example.com|user|secret") + + base := t.TempDir() + writeLocalFeature(t, base, "hello", nil) + client := &docker.Client{DockerPath: script, Env: []string{"FAKE_DOCKER_LOG=" + logPath}, Log: log.Null} + _, err := extendImageWithFeatures(t.Context(), log.Null, client, &docker.EngineClient{API: &fakeDockerAPI{}}, "base:latest", + map[string]any{"./hello": true}, false, []string{"registry.example.com/app:1"}, &FeatureBuildOptions{FeaturesBasePath: base}) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + var build string + for line := range strings.SplitSeq(string(data), "\n") { + if strings.Contains(line, "Dockerfile.extended") { + build = line + fields := strings.Fields(line) + t.Cleanup(func() { os.RemoveAll(fields[len(fields)-2]) }) + } + } + _, cfg, _ := strings.Cut(build, "DOCKER_CONFIG=") + if cfg == "" || cfg == ambient || !strings.Contains(filepath.Base(cfg), "devcontainer-buildauth-") { + t.Errorf("extended build ran with DOCKER_CONFIG=%q, want a temporary build-auth config (log: %s)", cfg, data) + } +} + +// With buildx, only a daemon older than 23 needs the dockerfile:1.4 syntax +// directive for the build contexts; the classic builder never gets one. +func TestExtendImageWithFeaturesSyntaxDirective(t *testing.T) { + for name, tc := range map[string]struct { + run extendRun + want bool + }{ + "buildx, engine 25": {extendRun{useBuildx: true}, false}, + "buildx, engine 20": {extendRun{useBuildx: true, engine: "20.10.7"}, true}, + "classic, engine 20": {extendRun{engine: "20.10.7"}, false}, + } { + t.Run(name, func(t *testing.T) { + res := tc.run.run(t) + if res.err != nil { + t.Fatal(res.err) + } + data, err := os.ReadFile(filepath.Join(res.features, "Dockerfile.extended")) + if err != nil { + t.Fatal(err) + } + if got := strings.Contains(string(data), "docker/dockerfile:1.4"); got != tc.want { + t.Errorf("syntax directive present = %v, want %v:\n%s", got, tc.want, data) + } + }) + } +} + +// A Feature's metadata entry falls back to the Feature's id when its source +// has no user reference. +func TestFeatureMetadataEntryFallsBackToTheFeatureID(t *testing.T) { + for name, src := range map[string]features.SourceInfo{ + "no source": nil, + "empty user ref": &features.LocalSource{}, + } { + set := &features.Set{SourceInfo: src, Features: []features.Feature{{ID: "hello"}}} + if got := featureMetadataEntry(set, false).ID; got != "hello" { + t.Errorf("%s: id = %q, want hello", name, got) + } + } +} + +// Sets without a staged Feature are left as they are. +func TestRealignFeatureDirsSkipsUnstagedSets(t *testing.T) { + dst := t.TempDir() + unstaged := &features.Set{Features: []features.Feature{{ID: "x"}}} + if err := realignFeatureDirs(dst, []*features.Set{{}, unstaged}); err != nil { + t.Fatal(err) + } + if f := unstaged.Features[0]; f.ConsecutiveId != "" || f.CachePath != "" { + t.Errorf("unstaged Feature = %+v, want it untouched", f) + } +} diff --git a/internal/cli/feature_install_nodocker_held_test.go b/internal/cli/feature_install_nodocker_held_test.go new file mode 100644 index 0000000..a4c2630 --- /dev/null +++ b/internal/cli/feature_install_nodocker_held_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" +) + +// A Feature's containerEnv is resolved against the host (${localEnv:…}) before +// it is written into the extended image, and the build still reports the +// image's names. +func TestExtendImageWithFeaturesResolvesFeatureHostVars(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "envy") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "devcontainer-feature.json"), []byte(`{"id":"envy","version":"1.0.0","containerEnv":{"FROM_HOST":"${localEnv:HOST_VALUE}"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + r := &fakeDockerRunner{} + names, err := extendImageWithFeatures(t.Context(), log.Null, fakeDockerClient(r), &docker.EngineClient{API: &fakeDockerAPI{version: "25.0.0"}}, + "base:latest", map[string]any{"./envy": true}, false, []string{"out:1"}, &FeatureBuildOptions{ + FeaturesBasePath: base, + HostSub: &config.HostSubContext{Platform: "linux", Env: map[string]string{"HOST_VALUE": "resolved-on-host"}}, + }) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(names, []string{"out:1"}) { + t.Errorf("names = %v, want [out:1]", names) + } + var dockerfile string + for _, c := range r.calls { + for i, a := range c { + if a == "-f" && i+1 < len(c) && strings.HasSuffix(c[i+1], "Dockerfile.extended") { + dockerfile = c[i+1] + t.Cleanup(func() { os.RemoveAll(c[len(c)-1]) }) + } + } + } + data, err := os.ReadFile(dockerfile) + if err != nil { + t.Fatalf("extended Dockerfile: %v (calls %v)", err, r.calls) + } + if !strings.Contains(string(data), "resolved-on-host") || strings.Contains(string(data), "localEnv") { + t.Errorf("extended Dockerfile = %s, want FROM_HOST resolved from the host", data) + } +} + +// With buildx, the Features build gets the features folder as the +// dev_containers_feature_content_source build context. +func TestExtendImageWithFeaturesBuildContext(t *testing.T) { + res := extendRun{useBuildx: true}.run(t) + if res.err != nil { + t.Fatal(res.err) + } + i := slices.Index(res.build, "--build-context") + if i < 0 || i+1 >= len(res.build) || res.build[i+1] != "dev_containers_feature_content_source="+res.features { + t.Errorf("build = %v, want --build-context dev_containers_feature_content_source=%s", res.build, res.features) + } +} + +// A tarball download succeeds on every 2xx up to 299 and fails from 300 on, +// and it stops with the caller's context. +func TestDownloadFeatureTarballStatusAndContext(t *testing.T) { + for status, wantErr := range map[int]bool{299: false, 300: true} { + _, _, err := downloadFeatureTarball(t.Context(), nil, serveBytes(t, []byte("tgz"), status), nil) + if (err != nil) != wantErr { + t.Errorf("HTTP %d: err = %v, want an error: %v", status, err, wantErr) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, _, err := downloadFeatureTarball(ctx, nil, serveBytes(t, []byte("tgz"), 200), nil); !errors.Is(err, context.Canceled) { + t.Errorf("cancelled context: err = %v, want %v", err, context.Canceled) + } +} diff --git a/internal/cli/feature_install_test.go b/internal/cli/feature_install_test.go index c5c720f..66df1b1 100644 --- a/internal/cli/feature_install_test.go +++ b/internal/cli/feature_install_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/log" ) @@ -15,7 +16,7 @@ func TestFetchFeatureSets(t *testing.T) { tests := []struct { name string setup func(t *testing.T, baseDir string) - entries map[string]interface{} + entries map[string]any check func(t *testing.T, result *fetchFeatureResult, err error) }{ { @@ -43,7 +44,7 @@ func TestFetchFeatureSets(t *testing.T) { t.Fatal(err) } }, - entries: map[string]interface{}{"./local-feature": map[string]interface{}{}}, + entries: map[string]any{"./local-feature": map[string]any{}}, check: func(t *testing.T, result *fetchFeatureResult, err error) { if err != nil { t.Fatal(err) @@ -73,16 +74,17 @@ func TestFetchFeatureSets(t *testing.T) { { name: "resolves transitive dependsOn (with options and staging)", setup: func(t *testing.T, baseDir string) { - writeLocalFeature(t, baseDir, "feature-a", map[string]interface{}{"./feature-b": map[string]interface{}{"channel": "beta"}}) - writeLocalFeature(t, baseDir, "feature-b", map[string]interface{}{"./feature-c": true}) + writeLocalFeature(t, baseDir, "feature-a", map[string]any{"./feature-b": map[string]any{"channel": "beta"}}) + writeLocalFeature(t, baseDir, "feature-b", map[string]any{"./feature-c": true}) writeLocalFeature(t, baseDir, "feature-c", nil) }, - entries: map[string]interface{}{"./feature-a": map[string]interface{}{}}, + entries: map[string]any{"./feature-a": map[string]any{}}, check: func(t *testing.T, result *fetchFeatureResult, err error) { if err != nil { t.Fatal(err) } - if got, want := featureSetIDs(result.FeatureSets), "./feature-c → ./feature-b → ./feature-a"; got != want { + // TS: a local Feature's id is its devcontainer-feature.json id. + if got, want := featureSetIDs(result.FeatureSets), "feature-c → feature-b → feature-a"; got != want { t.Fatalf("install order = %s, want %s", got, want) } if gotOptions := result.FeatureSets[1].Features[0].UserOptions["channel"]; gotOptions != "beta" { @@ -98,11 +100,11 @@ func TestFetchFeatureSets(t *testing.T) { { name: "deduplicates shared dependency", setup: func(t *testing.T, baseDir string) { - writeLocalFeature(t, baseDir, "feature-a", map[string]interface{}{"./shared": true}) - writeLocalFeature(t, baseDir, "feature-b", map[string]interface{}{"./shared": true}) + writeLocalFeature(t, baseDir, "feature-a", map[string]any{"./shared": true}) + writeLocalFeature(t, baseDir, "feature-b", map[string]any{"./shared": true}) writeLocalFeature(t, baseDir, "shared", nil) }, - entries: map[string]interface{}{"./feature-a": true, "./feature-b": true}, + entries: map[string]any{"./feature-a": true, "./feature-b": true}, check: func(t *testing.T, result *fetchFeatureResult, err error) { if err != nil { t.Fatal(err) @@ -110,7 +112,7 @@ func TestFetchFeatureSets(t *testing.T) { if len(result.FeatureSets) != 3 { t.Fatalf("featureSets = %d, want 3", len(result.FeatureSets)) } - if got := featureSetIDs(result.FeatureSets); got != "./shared → ./feature-a → ./feature-b" { + if got := featureSetIDs(result.FeatureSets); got != "shared → feature-a → feature-b" { t.Fatalf("install order = %s", got) } }, @@ -118,10 +120,10 @@ func TestFetchFeatureSets(t *testing.T) { { name: "reports dependsOn cycle", setup: func(t *testing.T, baseDir string) { - writeLocalFeature(t, baseDir, "feature-a", map[string]interface{}{"./feature-b": true}) - writeLocalFeature(t, baseDir, "feature-b", map[string]interface{}{"./feature-a": true}) + writeLocalFeature(t, baseDir, "feature-a", map[string]any{"./feature-b": true}) + writeLocalFeature(t, baseDir, "feature-b", map[string]any{"./feature-a": true}) }, - entries: map[string]interface{}{"./feature-a": true}, + entries: map[string]any{"./feature-a": true}, check: func(t *testing.T, result *fetchFeatureResult, err error) { if err == nil || !strings.Contains(err.Error(), "circular dependency") { t.Fatalf("error = %v, want circular dependency", err) @@ -134,7 +136,7 @@ func TestFetchFeatureSets(t *testing.T) { t.Run(tt.name, func(t *testing.T) { baseDir := t.TempDir() tt.setup(t, baseDir) - result, err := fetchFeatureSets(t.Context(), log.Null, nil, tt.entries, baseDir, false, nil) + result, err := fetchFeatureSets(t.Context(), log.Null, tt.entries, baseDir, false) if result != nil && result.TmpDir != "" { defer os.RemoveAll(result.TmpDir) } @@ -143,13 +145,13 @@ func TestFetchFeatureSets(t *testing.T) { } } -func writeLocalFeature(t *testing.T, baseDir, name string, dependsOn map[string]interface{}) { +func writeLocalFeature(t *testing.T, baseDir, name string, dependsOn map[string]any) { t.Helper() dir := filepath.Join(baseDir, name) if err := os.MkdirAll(dir, 0755); err != nil { t.Fatal(err) } - metadata := map[string]interface{}{"id": name, "version": "1.0.0"} + metadata := map[string]any{"id": name, "version": "1.0.0"} if dependsOn != nil { metadata["dependsOn"] = dependsOn } @@ -170,8 +172,8 @@ func TestFeatureMetadataEntry_SkipPersistCustomizations(t *testing.T) { SourceInfo: &features.LocalSource{UserID: "./localFeatureA"}, Features: []features.Feature{{ ID: "./localFeatureA", - Init: boolPtr(true), - Customizations: map[string]interface{}{"vscode": map[string]interface{}{"extensions": []interface{}{"extensionA"}}}, + Init: new(true), + Customizations: map[string]any{"vscode": map[string]any{"extensions": []any{"extensionA"}}}, PostCreateCommand: "five", }}, } @@ -204,6 +206,187 @@ func TestFeatureMetadataEntry_UsesOCIUserID(t *testing.T) { } } -func boolPtr(v bool) *bool { - return &v +// TestFetchFeatureSets_StagingLayout: like TS generateFeaturesConfig, the +// Features are staged in /container-features/-, +// each in a folder named by its consecutiveId `_`, +// and no staging leftovers remain. A local Feature's id is its +// devcontainer-feature.json id, else the folder name; its name, when the file +// has none, is the user's reference. +func TestFetchFeatureSets_StagingLayout(t *testing.T) { + baseDir := t.TempDir() + writeLocalFeature(t, baseDir, "dir-a", map[string]any{"./dir-b": true}) + dirB := filepath.Join(baseDir, "dir-b") + if err := os.MkdirAll(dirB, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dirB, "devcontainer-feature.json"), []byte(`{"version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dirB, "install.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + result, err := fetchFeatureSets(t.Context(), log.Null, map[string]any{"./dir-a": true}, baseDir, false) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(result.TmpDir) + + parent := filepath.Join(cacheFolder(), "container-features") + if filepath.Dir(result.TmpDir) != parent { + t.Fatalf("features folder = %q, want a folder of %q", result.TmpDir, parent) + } + prefix := cliVersion() + "-" + base := filepath.Base(result.TmpDir) + if !strings.HasPrefix(base, prefix) || strings.Trim(strings.TrimPrefix(base, prefix), "0123456789") != "" { + t.Fatalf("features folder name = %q, want %s", base, prefix) + } + want := []struct{ id, consecutiveID, name string }{ + {"dir-b", "dir-b_0", "./dir-b"}, + {"dir-a", "dir-a_1", "./dir-a"}, + } + if len(result.FeatureSets) != len(want) { + t.Fatalf("featureSets = %d, want %d", len(result.FeatureSets), len(want)) + } + for i, w := range want { + f := result.FeatureSets[i].Features[0] + if f.ID != w.id || f.ConsecutiveId != w.consecutiveID || f.Name != w.name { + t.Errorf("feature %d = id %q consecutiveId %q name %q, want %q %q %q", i, f.ID, f.ConsecutiveId, f.Name, w.id, w.consecutiveID, w.name) + } + if f.CachePath != filepath.Join(result.TmpDir, w.consecutiveID) { + t.Errorf("feature %d cachePath = %q", i, f.CachePath) + } + if _, err := os.Stat(filepath.Join(f.CachePath, "install.sh")); err != nil { + t.Errorf("feature %d not staged: %v", i, err) + } + } + entries, err := os.ReadDir(result.TmpDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != len(want) { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("features folder holds %v, want only the Features' folders", names) + } +} + +// TestStageFeatureBuildFiles: the files the features Dockerfile refers to are +// written as TS getFeaturesBuildOptions writes them: the builtin env in the +// features folder, and the env file and install wrapper (options in +// devcontainer-feature.json order) in each Feature's folder. +func TestStageFeatureBuildFiles(t *testing.T) { + dst := t.TempDir() + cache := filepath.Join(dst, "hello_0") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cache, "devcontainer-feature.json"), []byte(`{ + // JSONC + "id": "hello", "name": "Hello", "version": "1.0.0", + "options": {"zeta": {"type": "string", "default": "z"}, "alpha": {"type": "boolean", "default": true}}, + }`), 0o644); err != nil { + t.Fatal(err) + } + set := &features.Set{ + SourceInfo: &features.OCISource{UserID: "ghcr.io/o/f/hello:1", UserFeatureIdWithoutVersion: "ghcr.io/o/f/hello"}, + Features: []features.Feature{{ + ID: "hello", Name: "Hello", Version: "1.0.0", ConsecutiveId: "hello_0", CachePath: cache, + Value: map[string]any{"alpha": false}, + Options: map[string]any{"zeta": map[string]any{"default": "z"}, "alpha": map[string]any{"default": true}}, + }}, + } + if err := stageFeatureBuildFiles(dst, []*features.Set{set}, "root", "vscode"); err != nil { + t.Fatal(err) + } + read := func(p string) string { + t.Helper() + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(data) + } + if got := read(filepath.Join(dst, "devcontainer-features.builtin.env")); got != "_CONTAINER_USER=root\n_REMOTE_USER=vscode\n" { + t.Errorf("builtin env = %q", got) + } + if got := read(filepath.Join(cache, "devcontainer-features.env")); got != "ZETA=\"z\"\nALPHA=\"false\"" { + t.Errorf("feature env = %q", got) + } + wrapper := read(filepath.Join(cache, "devcontainer-features-install.sh")) + for _, want := range []string{"echo 'Feature : Hello'\n", "echo 'Id : ghcr.io/o/f/hello'\n", "echo ' ZETA=\"z\"\n ALPHA=\"false\"'\n", ". ../devcontainer-features.builtin.env\n. ./devcontainer-features.env\n"} { + if !strings.Contains(wrapper, want) { + t.Errorf("install wrapper missing %q:\n%s", want, wrapper) + } + } + + // A Feature folder without devcontainer-feature.json is an error, not an + // install with no options. + if err := os.Remove(filepath.Join(cache, "devcontainer-feature.json")); err != nil { + t.Fatal(err) + } + if err := stageFeatureBuildFiles(dst, []*features.Set{set}, "root", "vscode"); err == nil { + t.Error("missing devcontainer-feature.json must fail") + } +} + +// TS getFeatureIdWithoutVersion strips the last :tag or @digest, keeping a +// registry port. +func TestFeatureIDWithoutVersion(t *testing.T) { + for in, want := range map[string]string{ + "ghcr.io/devcontainers/features/node:1": "ghcr.io/devcontainers/features/node", + "ghcr.io/devcontainers/features/node@sha256:ab": "ghcr.io/devcontainers/features/node", + "localhost:5000/o/f/node": "localhost:5000/o/f/node", + "localhost:5000/o/f/node:2": "localhost:5000/o/f/node", + } { + if got := featureIDWithoutVersion(in); got != want { + t.Errorf("featureIDWithoutVersion(%q) = %q, want %q", in, got, want) + } + } +} + +// TS getDevcontainerMetadata records sourceInformation.userFeatureId as a +// Feature entry's id for every source type, not the Feature's own id. +func TestFeatureMetadataEntry_UsesUserFeatureIDForLocalAndTarball(t *testing.T) { + local := featureMetadataEntry(&features.Set{SourceInfo: &features.LocalSource{UserID: "./hello"}, Features: []features.Feature{{ID: "hello"}}}, false) + if local.ID != "./hello" { + t.Errorf("local id = %q", local.ID) + } + tarball := featureMetadataEntry(&features.Set{SourceInfo: &features.TarballSource{TarballURI: "https://x/devcontainer-feature-hello.tgz", UserID: "https://x/devcontainer-feature-hello.tgz"}, Features: []features.Feature{{ID: "hello"}}}, false) + if tarball.ID != "https://x/devcontainer-feature-hello.tgz" { + t.Errorf("tarball id = %q", tarball.ID) + } + if got := tarballFeatureID("https://x/y/devcontainer-feature-hello.tgz?a=b"); got != "hello" { + t.Errorf("tarballFeatureID = %q", got) + } +} + +// A Feature's updateContentCommand reaches the image metadata like its other +// lifecycle hooks (TS pickFeatureLifecycleHookProperties), so up runs it and +// read-configuration merges it. +func TestFeatureMetadataEntryCarriesEveryLifecycleHook(t *testing.T) { + set := &features.Set{ + SourceInfo: &features.LocalSource{LocalPath: "./a", UserID: "./a"}, + Features: []features.Feature{{ + ID: "a", + OnCreateCommand: "create", + UpdateContentCommand: []any{"one", "two"}, + PostCreateCommand: "post-create", + PostStartCommand: "post-start", + PostAttachCommand: "post-attach", + }}, + } + entry := featureMetadataEntry(set, false) + if got, ok := entry.UpdateContentCommand.([]any); !ok || len(got) != 2 || got[0] != "one" { + t.Errorf("updateContentCommand = %#v, want [one two]", entry.UpdateContentCommand) + } + if entry.OnCreateCommand != "create" || entry.PostCreateCommand != "post-create" || + entry.PostStartCommand != "post-start" || entry.PostAttachCommand != "post-attach" { + t.Errorf("hooks = %+v", entry) + } + merged := imagemeta.MergeConfiguration([]imagemeta.Entry{entry}) + if len(merged.UpdateContentCommands) != 1 { + t.Errorf("merged updateContentCommands = %#v, want the Feature's command", merged.UpdateContentCommands) + } } diff --git a/internal/cli/feature_tarball_cap_held_test.go b/internal/cli/feature_tarball_cap_held_test.go new file mode 100644 index 0000000..b51ae8a --- /dev/null +++ b/internal/cli/feature_tarball_cap_held_test.go @@ -0,0 +1,21 @@ +package cli + +import ( + "strings" + "testing" +) + +// A body up to the cap is read whole; one byte more is an error, never a +// truncated tarball. +func TestReadCappedFailsInsteadOfTruncating(t *testing.T) { + data, err := readCapped(strings.NewReader("12345"), 5) + if err != nil || string(data) != "12345" { + t.Fatalf("readCapped at the cap = %q, %v; want the whole body", data, err) + } + if data, err := readCapped(strings.NewReader("123456"), 5); err == nil { + t.Fatalf("readCapped past the cap = %q, want an error", data) + } + if _, err := readCapped(strings.NewReader(strings.Repeat("x", 2<<20+1)), 2<<20); err == nil || !strings.Contains(err.Error(), "2 MiB") { + t.Errorf("the error = %v, want it to name the cap in MiB", err) + } +} diff --git a/internal/cli/feature_tarball_test.go b/internal/cli/feature_tarball_test.go index 02e2b89..870bf5b 100644 --- a/internal/cli/feature_tarball_test.go +++ b/internal/cli/feature_tarball_test.go @@ -8,6 +8,9 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/devcontainers/cli/internal/features" + "github.com/devcontainers/cli/internal/log" ) func TestIsGitHubTarballURI(t *testing.T) { @@ -132,3 +135,35 @@ func TestDownloadFeatureTarball_2xxAccepted(t *testing.T) { t.Errorf("204 should be accepted as success, got %v", err) } } + +// A direct tarball whose digest is not the lockfile's fails while resolving its +// metadata - what resolve-dependencies and the graph TS builds before installing +// read - with TS fetchContentsAtTarballUri's error, unwrapped. A tarball the +// lockfile does not pin, or pins to its digest, resolves. +func TestDirectTarballDigestCheckedAgainstTheLockfile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("not-a-real-tarball")) + })) + defer srv.Close() + uri := srv.URL + "/feature.tgz" + sum := sha256.Sum256([]byte("not-a-real-tarball")) + digest := "sha256:" + hex.EncodeToString(sum[:]) + + resolve := func(lf *features.Lockfile) error { + _, err := newMetadataProcessFeature(nil, log.Null, "", lf)(&features.FNode{UserFeatureID: uri}) + return err + } + wrong := &features.Lockfile{Features: map[string]features.LockfileEntry{uri: {Integrity: "sha256:" + strings.Repeat("0", 64)}}} + if err := resolve(wrong); err == nil || err.Error() != "Digest did not match for "+uri+"." { + t.Errorf("wrong integrity: err = %v, want %q", err, "Digest did not match for "+uri+".") + } + right := &features.Lockfile{Features: map[string]features.LockfileEntry{uri: {Integrity: digest}}} + for name, lf := range map[string]*features.Lockfile{"no lockfile": nil, "matching integrity": right} { + if err := resolve(lf); err != nil && strings.Contains(err.Error(), "Digest did not match") { + t.Errorf("%s: err = %v", name, err) + } + } + if err := verifyTarballDigest(wrong, uri, digest); err == nil || err.Error() != "Digest did not match for "+uri+"." { + t.Errorf("install path: err = %v", err) + } +} diff --git a/internal/cli/features_info.go b/internal/cli/features_info.go index 5637e35..130ae0c 100644 --- a/internal/cli/features_info.go +++ b/internal/cli/features_info.go @@ -41,16 +41,11 @@ func realFeaturesInfoCmd() *cobra.Command { } func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, outputFormat string) error { - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", logLevel, []string{"info", "debug", "trace"}}, - {"output-format", outputFormat, []string{"text", "json"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return err - } + if err := validateEnums( + enumFlag{"log-level", logLevel, logLevels}, + enumFlag{"output-format", outputFormat, logFormats}, + ); err != nil { + return err } logger := log.New(log.Options{ @@ -69,7 +64,7 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, client := newOCIClient(ctx, logger) - jsonOutput := make(map[string]interface{}) + jsonOutput := make(map[string]any) // Manifest if mode == "manifest" || mode == "verbose" { diff --git a/internal/cli/features_info_held_test.go b/internal/cli/features_info_held_test.go new file mode 100644 index 0000000..0cab488 --- /dev/null +++ b/internal/cli/features_info_held_test.go @@ -0,0 +1,162 @@ +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// readRegistry is a read-only OCI distribution registry over plain HTTP on +// 127.0.0.1 (which the OCI client addresses without TLS): it serves the tag +// lists and manifests a test puts in it and 404s anything else. +type readRegistry struct { + host string + tags map[string][]string // repository -> tags + manifests map[string][]byte // repository -> manifest served for any reference +} + +func newReadRegistry(t *testing.T) *readRegistry { + t.Helper() + r := &readRegistry{tags: map[string][]string{}, manifests: map[string][]byte{}} + srv := httptest.NewServer(http.HandlerFunc(r.serve)) + t.Cleanup(srv.Close) + r.host = strings.TrimPrefix(srv.URL, "http://") + return r +} + +func (r *readRegistry) serve(w http.ResponseWriter, req *http.Request) { + path := strings.TrimPrefix(req.URL.Path, "/v2/") + if repo, ok := strings.CutSuffix(path, "/tags/list"); ok { + if tags, ok := r.tags[repo]; ok { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"name": repo, "tags": tags}) + return + } + } + if i := strings.Index(path, "/manifests/"); i >= 0 { + if body, ok := r.manifests[path[:i]]; ok { + sum := sha256.Sum256(body) + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Header().Set("Docker-Content-Digest", "sha256:"+hex.EncodeToString(sum[:])) + w.Header().Set("Content-Length", fmt.Sprint(len(body))) + if req.Method != http.MethodHead { + _, _ = w.Write(body) + } + return + } + } + if path == "" { + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errors":[{"code":"NAME_UNKNOWN","message":"not found"}]}`)) +} + +// featureManifest is a Feature manifest whose keys are deliberately not in +// sorted order, so a re-marshal would show. +func featureManifest(id string) []byte { + return []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` + + `"config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` + + `"layers":[{"mediaType":"application/vnd.devcontainers.layer.v1+tar","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2}],` + + `"annotations":{"dev.containers.metadata":"{\"id\":\"` + id + `\",\"version\":\"1.0.0\",\"name\":\"T\"}","com.github.package.type":"devcontainer_feature"}}`) +} + +func runInfo(t *testing.T, mode, id, format string) (string, error) { + t.Helper() + out := &captureOutput{} + err := runFeaturesInfo(t.Context(), out, mode, id, "info", format) + return out.out.String(), err +} + +func TestFeaturesInfoModes(t *testing.T) { + reg := newReadRegistry(t) + manifest := featureManifest("tool") + reg.manifests["ns/tool"] = manifest + reg.tags["ns/tool"] = []string{"1.0.0"} + id := reg.host + "/ns/tool:1" + + var indented bytes.Buffer + if err := json.Indent(&indented, manifest, "", " "); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(manifest) + canonical := reg.host + "/ns/tool@sha256:" + hex.EncodeToString(sum[:]) + manifestText := encloseStringInBox("Manifest") + "\n" + indented.String() + "\n\n" + + encloseStringInBox("Canonical Identifier") + "\n" + canonical + "\n\n" + tagsText := encloseStringInBox("Published Tags") + "\n1.0.0\n" + depsBox := encloseStringInBox("Dependency Tree (Render with https://mermaid.live/)") + + got, err := runInfo(t, "manifest", id, "text") + if err != nil || got != manifestText { + t.Errorf("manifest text: err = %v\n%s\nwant:\n%s", err, got, manifestText) + } + got, err = runInfo(t, "tags", id, "text") + if err != nil || got != tagsText { + t.Errorf("tags text: err = %v\n%q\nwant %q", err, got, tagsText) + } + got, err = runInfo(t, "dependencies", id, "text") + if err != nil || !strings.HasPrefix(got, depsBox+"\nflowchart") { + t.Errorf("dependencies text: err = %v\n%s", err, got) + } + got, err = runInfo(t, "verbose", id, "text") + if err != nil || !strings.HasPrefix(got, manifestText+tagsText+depsBox+"\n") { + t.Errorf("verbose text: err = %v\n%s", err, got) + } + + var doc map[string]json.RawMessage + got, err = runInfo(t, "manifest", id, "json") + if err != nil || json.Unmarshal([]byte(got), &doc) != nil || len(doc) != 2 || string(doc["canonicalId"]) != `"`+canonical+`"` || doc["manifest"] == nil { + t.Errorf("manifest json: err = %v\n%s", err, got) + } + got, err = runInfo(t, "tags", id, "json") + if err != nil || strings.Join(strings.Fields(got), "") != `{"publishedTags":["1.0.0"]}` { + t.Errorf("tags json: err = %v\n%s", err, got) + } + got, err = runInfo(t, "dependencies", id, "json") + if err != nil || got != "{}\n" { + t.Errorf("dependencies json: err = %v\n%q, want an empty object", err, got) + } + got, err = runInfo(t, "verbose", id, "json") + doc = nil + if err != nil || json.Unmarshal([]byte(got), &doc) != nil || len(doc) != 3 { + t.Errorf("verbose json: err = %v\n%s", err, got) + } +} + +// Every failure exits non-zero; under --output-format json stdout still holds +// an empty object, and under text it holds nothing. +func TestFeaturesInfoFailures(t *testing.T) { + reg := newReadRegistry(t) + reg.manifests["ns/untagged"] = featureManifest("untagged") + reg.tags["ns/untagged"] = []string{} + cases := []struct { + name, mode, id, want string + }{ + {"unparsable id", "manifest", "not a feature", "Failed to parse Feature identifier"}, + {"no manifest", "manifest", reg.host + "/ns/missing:1", "No manifest found"}, + {"no tags", "tags", reg.host + "/ns/untagged:1", "No published versions found"}, + {"unknown repository", "tags", reg.host + "/ns/missing:1", "No published versions found"}, + } + for _, c := range cases { + for format, stdout := range map[string]string{"json": "{}\n", "text": ""} { + got, err := runInfo(t, c.mode, c.id, format) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s (%s): err = %v, want %q", c.name, format, err, c.want) + } + if got != stdout { + t.Errorf("%s (%s): stdout = %q, want %q", c.name, format, got, stdout) + } + } + } + if err := runFeaturesInfo(t.Context(), &captureOutput{}, "tags", reg.host+"/ns/untagged:1", "loud", "text"); err == nil || !strings.Contains(err.Error(), "log-level") { + t.Errorf("invalid log level: err = %v", err) + } +} diff --git a/internal/cli/features_resolve_deps.go b/internal/cli/features_resolve_deps.go index b0adb28..f1f7dbd 100644 --- a/internal/cli/features_resolve_deps.go +++ b/internal/cli/features_resolve_deps.go @@ -24,17 +24,14 @@ func realFeaturesResolveDepsCmd() *cobra.Command { Short: "Read and resolve dependency graph from a configuration", RunE: func(cmd *cobra.Command, args []string) error { out := outputFor(cmd) - // 0.88: workspace-folder defaults to cwd when not provided. - if workspaceFolder == "" { - workspaceFolder, _ = os.Getwd() - } - logger := log.New(log.Options{ Level: log.ParseLevel(logLevel), Format: "text", Writer: os.Stderr, }) + // 0.88: workspace-folder defaults to cwd when not provided, which + // resolvePath("") is. ws := resolvePath(workspaceFolder) loadResult, err := config.LoadDevContainerConfig(ws, "", "") if err != nil { @@ -71,14 +68,14 @@ func realFeaturesResolveDepsCmd() *cobra.Command { return err } - order, err := features.ComputeInstallationOrder(logger, graph, nil) + order, err := features.ComputeInstallationOrder(logger, graph) if err != nil { return err } type installEntry struct { - ID string `json:"id"` - Options interface{} `json:"options"` + ID string `json:"id"` + Options any `json:"options"` } var entries []installEntry @@ -88,7 +85,7 @@ func realFeaturesResolveDepsCmd() *cobra.Command { if ociSrc, ok := src.(*features.OCISource); ok { id = fmt.Sprintf("%s/%s/%s@%s", ociSrc.Registry, ociSrc.Namespace, ociSrc.ID, fs.ComputedDigest) } - var opts interface{} + var opts any if len(fs.Features) > 0 { opts = fs.Features[0].Value } @@ -99,7 +96,7 @@ func realFeaturesResolveDepsCmd() *cobra.Command { // install-order JSON. fmt.Fprintln(out.Stdout(), features.GenerateMermaidDiagram(graph)) - data, _ := json.MarshalIndent(map[string]interface{}{"installOrder": entries}, "", " ") + data, _ := json.MarshalIndent(map[string]any{"installOrder": entries}, "", " ") fmt.Fprintln(out.Stdout(), string(data)) return nil }, diff --git a/internal/cli/features_resolve_deps_held_test.go b/internal/cli/features_resolve_deps_held_test.go new file mode 100644 index 0000000..463b67f --- /dev/null +++ b/internal/cli/features_resolve_deps_held_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + coreerrors "github.com/devcontainers/cli/internal/errors" + "github.com/devcontainers/cli/internal/features" +) + +// resolveDepsWorkspace writes a workspace whose devcontainer.json lists the +// given features, with each local Feature (name -> devcontainer-feature.json) +// in the .devcontainer folder, and returns the workspace and config paths. +func resolveDepsWorkspace(t *testing.T, featuresJSON string, local map[string]string) (string, string) { + t.Helper() + ws := t.TempDir() + dir := filepath.Join(ws, ".devcontainer") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, "devcontainer.json") + if err := os.WriteFile(configPath, []byte(`{"image":"img","features":`+featuresJSON+`}`), 0o644); err != nil { + t.Fatal(err) + } + for name, meta := range local { + writeFeature(t, dir, name, meta) + } + return ws, configPath +} + +func runResolveDeps(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := realFeaturesResolveDepsCmd() + // The root command silences usage and errors; the bare subcommand does not. + cmd.SilenceUsage, cmd.SilenceErrors = true, true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + return out.String(), err +} + +// resolve-dependencies prints the mermaid graph, then the install order with +// each Feature's options, dependencies first. +func TestFeaturesResolveDependencies(t *testing.T) { + ws, _ := resolveDepsWorkspace(t, `{"./a":{"x":"1"}}`, map[string]string{ + "a": `{"id":"a","dependsOn":{"./b":{"y":"2"}}}`, + "b": `{"id":"b"}`, + }) + want := []map[string]any{ + {"id": "./b", "options": map[string]any{"y": "2"}}, + {"id": "./a", "options": map[string]any{"x": "1"}}, + } + check := func(t *testing.T, out string) { + t.Helper() + mermaid, jsonPart, ok := strings.Cut(out, "\n\n") + if !ok || !strings.HasPrefix(mermaid, "flowchart\n") || !strings.Contains(mermaid, "-->") { + t.Fatalf("output = %q, want the mermaid graph first", out) + } + var got struct { + InstallOrder []map[string]any `json:"installOrder"` + } + if err := json.Unmarshal([]byte(jsonPart), &got); err != nil { + t.Fatalf("install order %q: %v", jsonPart, err) + } + if !reflect.DeepEqual(got.InstallOrder, want) { + t.Errorf("installOrder = %v, want %v", got.InstallOrder, want) + } + } + + t.Run("workspace folder flag", func(t *testing.T) { + t.Chdir(t.TempDir()) + out, err := runResolveDeps(t, "--workspace-folder", ws) + if err != nil { + t.Fatal(err) + } + check(t, out) + }) + t.Run("current directory", func(t *testing.T) { + t.Chdir(ws) + out, err := runResolveDeps(t) + if err != nil { + t.Fatal(err) + } + check(t, out) + }) +} + +func TestFeaturesResolveDependenciesFailures(t *testing.T) { + t.Run("no features exits 1 with nothing on stdout", func(t *testing.T) { + ws, _ := resolveDepsWorkspace(t, `{}`, nil) + out, err := runResolveDeps(t, "--workspace-folder", ws) + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 || out != "" { + t.Fatalf("err = %v, stdout = %q; want exit 1 and no output", err, out) + } + }) + t.Run("no configuration", func(t *testing.T) { + if out, err := runResolveDeps(t, "--workspace-folder", t.TempDir()); err == nil || out != "" { + t.Fatalf("err = %v, stdout = %q; want an error", err, out) + } + }) + t.Run("missing Feature", func(t *testing.T) { + ws, _ := resolveDepsWorkspace(t, `{"./gone":{}}`, nil) + if out, err := runResolveDeps(t, "--workspace-folder", ws); err == nil || !strings.Contains(err.Error(), "'./gone' could not be processed") || out != "" { + t.Fatalf("err = %v, stdout = %q", err, out) + } + }) + t.Run("cycle", func(t *testing.T) { + ws, _ := resolveDepsWorkspace(t, `{"./a":{}}`, map[string]string{ + "a": `{"id":"a","dependsOn":{"./b":{}}}`, + "b": `{"id":"b","dependsOn":{"./a":{}}}`, + }) + if out, err := runResolveDeps(t, "--workspace-folder", ws); err == nil || !strings.Contains(err.Error(), "circular dependency") || out != "" { + t.Fatalf("err = %v, stdout = %q", err, out) + } + }) + t.Run("tarball differing from the lockfile", func(t *testing.T) { + uri := serveBytes(t, featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb"}`}), http.StatusOK) + "/f.tgz" + ws, configPath := resolveDepsWorkspace(t, fmt.Sprintf(`{%q:{}}`, uri), nil) + lock := fmt.Sprintf(`{"features":{%q:{"version":"1","resolved":%q,"integrity":"sha256:%s"}}}`, uri, uri, strings.Repeat("0", 64)) + if err := os.WriteFile(features.LockfilePath(configPath), []byte(lock), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runResolveDeps(t, "--workspace-folder", ws); err == nil || err.Error() != "Digest did not match for "+uri+"." { + t.Fatalf("err = %v, want the lockfile's digest mismatch", err) + } + }) +} diff --git a/internal/cli/features_test_runner.go b/internal/cli/features_test_runner.go index ba811f5..9e10521 100644 --- a/internal/cli/features_test_runner.go +++ b/internal/cli/features_test_runner.go @@ -62,6 +62,14 @@ reportResults() { } ` +// The runner starts each test project's container, and runs its test scripts, +// through this binary's own `up` and `exec`; the unit tests replace both to +// drive the runner without Docker or a second process. +var ( + startTestContainer = upTestContainer + runTestScript = execTestInContainer +) + type testResult struct { Name string Status testStatus @@ -193,11 +201,10 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, // Generate devcontainer.json with local feature paths dcDir := filepath.Join(tmpDir, ".devcontainer") - if err := pfs.MkdirAll(dcDir); err != nil { + if err := os.MkdirAll(dcDir, 0o755); err != nil { return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} } - featureEntries := make([]string, 0, len(features)) for _, f := range features { // Copy feature source to temp dir srcPath := filepath.Join(collectionFolder, "src", f) @@ -206,29 +213,18 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, logger.Write(fmt.Sprintf("Failed to stage feature %s: %v", f, err), log.LevelError) return []testResult{{Name: f, Status: testError, Detail: "could not stage feature: " + err.Error()}} } - featureEntries = append(featureEntries, fmt.Sprintf(`"./%s": {}`, f)) - } - - remoteUserLine := "" - if remoteUser != "" { - remoteUserLine = fmt.Sprintf(`"remoteUser": "%s",`, remoteUser) - } - - configJSON := fmt.Sprintf(`{ - %s - "image": "%s", - "features": { - %s } -}`, remoteUserLine, baseImage, strings.Join(featureEntries, ",\n\t\t")) + configJSON := autoTestConfig(features, baseImage, remoteUser) - if err := os.WriteFile(filepath.Join(dcDir, "devcontainer.json"), []byte(configJSON), 0644); err != nil { + // mutate-exempt: only this user's processes read it, and any mode with owner read serves them + err = os.WriteFile(filepath.Join(dcDir, "devcontainer.json"), []byte(configJSON), 0644) + if err != nil { return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} } // Build and start container logger.Write("Building test container...", log.LevelInfo) - containerId, err := upTestContainer(tmpDir, quiet, globalArgs) + containerId, err := startTestContainer(tmpDir, quiet, globalArgs) if err != nil { logger.Write(fmt.Sprintf("Failed to start container: %v", err), log.LevelError) return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} @@ -248,14 +244,16 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, results = append(results, testResult{Name: feature, Status: testError, Detail: err.Error()}) continue } - if err := os.WriteFile(filepath.Join(tmpDir, "dev-container-features-test-lib"), []byte(testLibraryScript), 0755); err != nil { + // mutate-exempt: makeWorkspaceAccessible makes it 0777 before any script runs + err := os.WriteFile(filepath.Join(tmpDir, "dev-container-features-test-lib"), []byte(testLibraryScript), 0755) + if err != nil { results = append(results, testResult{Name: feature, Status: testError, Detail: err.Error()}) continue } logger.Write(fmt.Sprintf("Running test for %s...", feature), log.LevelInfo) makeWorkspaceAccessible(tmpDir) - passed := execTestInContainer(containerId, feature+"/test.sh", tmpDir) + passed := runTestScript(containerId, feature+"/test.sh", tmpDir) status := testFailed if passed { status = testPassed @@ -306,14 +304,14 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu continue } dcDir := filepath.Join(tmpDir, ".devcontainer") - if err := pfs.MkdirAll(dcDir); err != nil { + if err := os.MkdirAll(dcDir, 0o755); err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) os.RemoveAll(tmpDir) continue } // Parse scenario config and update feature paths - var config map[string]interface{} + var config map[string]any if err := json.Unmarshal(scenarioConfig, &config); err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) os.RemoveAll(tmpDir) @@ -321,8 +319,8 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu } // Copy feature sources for any local features - if feats, ok := config["features"].(map[string]interface{}); ok { - updated := make(map[string]interface{}) + if feats, ok := config["features"].(map[string]any); ok { + updated := make(map[string]any) for fid, fval := range feats { if !strings.Contains(fid, "/") { // Local feature reference @@ -348,13 +346,15 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu os.RemoveAll(tmpDir) continue } - if err := os.WriteFile(filepath.Join(dcDir, "devcontainer.json"), configData, 0644); err != nil { + // mutate-exempt: only this user's processes read it, and any mode with owner read serves them + err = os.WriteFile(filepath.Join(dcDir, "devcontainer.json"), configData, 0644) + if err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) os.RemoveAll(tmpDir) continue } - containerId, err := upTestContainer(tmpDir, quiet, globalArgs) + containerId, err := startTestContainer(tmpDir, quiet, globalArgs) if err != nil { logger.Write(fmt.Sprintf("Failed to start scenario container: %v", err), log.LevelError) results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) @@ -368,14 +368,16 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu os.RemoveAll(tmpDir) continue } - if err := os.WriteFile(filepath.Join(tmpDir, "dev-container-features-test-lib"), []byte(testLibraryScript), 0755); err != nil { + // mutate-exempt: makeWorkspaceAccessible makes it 0777 before any script runs + err = os.WriteFile(filepath.Join(tmpDir, "dev-container-features-test-lib"), []byte(testLibraryScript), 0755) + if err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) os.RemoveAll(tmpDir) continue } makeWorkspaceAccessible(tmpDir) - passed := execTestInContainer(containerId, filepath.Base(featureTestDir)+"/"+scenarioName+".sh", tmpDir) + passed := runTestScript(containerId, filepath.Base(featureTestDir)+"/"+scenarioName+".sh", tmpDir) status := testFailed if passed { status = testPassed @@ -396,6 +398,10 @@ func runGlobalTests(logger log.Logger, collectionFolder, filter, baseImage, remo return runScenarioTests(logger, collectionFolder, globalDir, "_global", filter, baseImage, remoteUser, quiet, globalArgs) } +// upTestContainer starts a test project's container with this binary's `up`. +// +// mutate-lane: TestParityMatrix (features.test-single-scenario-success starts +// its scenario container through it). func upTestContainer(workspaceFolder string, quiet bool, globalArgs []string) (string, error) { // Tag the container so cleanupTestContainers (which filters on this label) // can remove it — otherwise every test run leaks a container. A second, @@ -420,8 +426,13 @@ func upTestContainer(workspaceFolder string, quiet bool, globalArgs []string) (s if err != nil { return "", fmt.Errorf("devcontainer up failed: %w", err) } + return containerIDFromUpOutput(out) +} - var result map[string]interface{} +// containerIDFromUpOutput reads the container id from `up`'s JSON result, or +// the reason it failed. +func containerIDFromUpOutput(out []byte) (string, error) { + var result map[string]any if err := json.Unmarshal(out, &result); err != nil { return "", fmt.Errorf("parse up output: %w", err) } @@ -434,6 +445,29 @@ func upTestContainer(workspaceFolder string, quiet bool, globalArgs []string) (s return containerId, nil } +// autoTestConfig is the devcontainer.json of the autogenerated test project: +// the base image, the remote user when one is given, and every Feature under +// test from its staged copy. +func autoTestConfig(features []string, baseImage, remoteUser string) string { + featureEntries := make([]string, 0, len(features)) + for _, f := range features { + featureEntries = append(featureEntries, fmt.Sprintf(`"./%s": {}`, f)) + } + + remoteUserLine := "" + if remoteUser != "" { + remoteUserLine = fmt.Sprintf(`"remoteUser": "%s",`, remoteUser) + } + + return fmt.Sprintf(`{ + %s + "image": "%s", + "features": { + %s + } +}`, remoteUserLine, baseImage, strings.Join(featureEntries, ",\n\t\t")) +} + // makeWorkspaceAccessible chmod -R 0777 the bind-mounted test workspace so the // test/scenario script can enter it as the container's remote user regardless of // the host↔container UID mapping. Matches the TS features-test CLI, which diff --git a/internal/cli/features_test_runner_held_test.go b/internal/cli/features_test_runner_held_test.go new file mode 100644 index 0000000..ba4579e --- /dev/null +++ b/internal/cli/features_test_runner_held_test.go @@ -0,0 +1,184 @@ +package cli + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/pfs" +) + +// fakeTestContainers replaces the runner's `up` and `exec` for one test: start +// answers each project with check(workspace) and the id "cid" (or startErr), +// and a script passes when pass says so. +func fakeTestContainers(t *testing.T, startErr error, check func(ws string), pass func(script, ws string) bool) { + t.Helper() + oldStart, oldRun := startTestContainer, runTestScript + t.Cleanup(func() { startTestContainer, runTestScript = oldStart, oldRun }) + startTestContainer = func(ws string, _ bool, _ []string) (string, error) { + if check != nil { + check(ws) + } + if startErr != nil { + return "", startErr + } + return "cid", nil + } + runTestScript = func(id, script, ws string) bool { + if id != "cid" { + t.Errorf("script %s ran in container %q, want the started one", script, id) + } + if !pfs.IsFile(filepath.Join(ws, "dev-container-features-test-lib")) { + t.Errorf("script %s ran without the test library in %s", script, ws) + } + return pass(script, ws) + } +} + +func readConfig(t *testing.T, ws string) map[string]any { + t.Helper() + data, err := os.ReadFile(filepath.Join(ws, ".devcontainer", "devcontainer.json")) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("generated devcontainer.json does not parse: %v\n%s", err, data) + } + return cfg +} + +func TestAutoTestConfig(t *testing.T) { + want := "{\n\t\"remoteUser\": \"vscode\",\n\t\"image\": \"alpine\",\n\t\"features\": {\n\t\t\"./a\": {},\n\t\t\"./b\": {}\n\t}\n}" + if got := autoTestConfig([]string{"a", "b"}, "alpine", "vscode"); got != want { + t.Errorf("config = %q, want %q", got, want) + } + want = "{\n\t\n\t\"image\": \"alpine\",\n\t\"features\": {\n\t\t\"./a\": {}\n\t}\n}" + if got := autoTestConfig([]string{"a"}, "alpine", ""); got != want { + t.Errorf("config without remote user = %q, want %q", got, want) + } +} + +func TestContainerIDFromUpOutput(t *testing.T) { + if id, err := containerIDFromUpOutput([]byte(`{"outcome":"success","containerId":"abc"}`)); err != nil || id != "abc" { + t.Errorf("success: id = %q, err = %v", id, err) + } + if _, err := containerIDFromUpOutput([]byte(`{"outcome":"error","description":"boom"}`)); err == nil || err.Error() != "up failed: boom" { + t.Errorf("failure: err = %v", err) + } + if _, err := containerIDFromUpOutput([]byte(`not json`)); err == nil { + t.Error("garbage: want a parse error") + } +} + +// The autogenerated project stages every Feature and its config, then each +// Feature's test.sh runs in the one container: passed, failed, or skipped when +// there is no test.sh. +func TestRunAutoTestsRunsEachFeatureTest(t *testing.T) { + base := t.TempDir() + for _, f := range []string{"pass", "fail", "untested"} { + writeFile(t, filepath.Join(base, "src", f, "install.sh"), "#!/bin/sh\n", 0o755) + } + writeFile(t, filepath.Join(base, "test", "pass", "test.sh"), "true\n", 0o755) + writeFile(t, filepath.Join(base, "test", "fail", "test.sh"), "false\n", 0o755) + fakeTestContainers(t, nil, func(ws string) { + cfg := readConfig(t, ws) + if cfg["image"] != "alpine" || len(cfg["features"].(map[string]any)) != 3 { + t.Errorf("config = %v", cfg) + } + if !pfs.IsFile(filepath.Join(ws, ".devcontainer", "pass", "install.sh")) { + t.Error("feature not staged next to the config") + } + }, func(script, ws string) bool { + if !pfs.IsFile(filepath.Join(ws, script)) { + t.Errorf("test script %s not copied into the workspace", script) + } + return script == "pass/test.sh" + }) + + got := runAutoTests(log.Null, base, []string{"pass", "fail", "untested"}, "alpine", "", true, nil) + want := []testResult{ + {Name: "pass", Status: testPassed}, + {Name: "fail", Status: testFailed}, + {Name: "untested", Status: testSkipped, Detail: "test.sh not found"}, + } + if len(got) != len(want) { + t.Fatalf("results = %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("result %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestRunAutoTestsReportsContainerFailure(t *testing.T) { + base := t.TempDir() + writeFile(t, filepath.Join(base, "src", "a", "install.sh"), "#!/bin/sh\n", 0o755) + writeFile(t, filepath.Join(base, "test", "a", "test.sh"), "true\n", 0o755) + fakeTestContainers(t, errors.New("up failed: boom"), nil, func(string, string) bool { + t.Error("a script ran without a container") + return true + }) + got := runAutoTests(log.Null, base, []string{"a"}, "alpine", "", true, nil) + if len(got) != 1 || got[0] != (testResult{Name: "autogenerated setup", Status: testError, Detail: "up failed: boom"}) { + t.Errorf("results = %+v", got) + } +} + +// Scenarios matching --filter run in their own project: local Features are +// staged and referenced as "./", others kept as given; a scenario whose +// config is not an object, or whose container does not start, is an error. +func TestRunScenarioTestsRunsFilteredScenarios(t *testing.T) { + base := t.TempDir() + writeFile(t, filepath.Join(base, "src", "color", "install.sh"), "#!/bin/sh\n", 0o755) + testDir := filepath.Join(base, "test", "color") + writeFile(t, filepath.Join(testDir, "scenarios.json"), `{ + "keep_one": {"image": "alpine", "features": {"color": {"favorite": "red"}, "ghcr.io/x/y/z:1": {}}}, + "keep_bad": ["not", "an", "object"], + "other": {"image": "alpine"} + }`, 0o644) + for _, s := range []string{"keep_one", "keep_bad", "other"} { + writeFile(t, filepath.Join(testDir, s+".sh"), "true\n", 0o755) + } + fakeTestContainers(t, nil, func(ws string) { + feats, _ := readConfig(t, ws)["features"].(map[string]any) + if _, ok := feats["./color"]; !ok || feats["ghcr.io/x/y/z:1"] == nil || len(feats) != 2 { + t.Errorf("features = %v, want ./color and the OCI one", feats) + } + if !pfs.IsFile(filepath.Join(ws, ".devcontainer", "color", "install.sh")) { + t.Error("local feature not staged") + } + }, func(script, ws string) bool { + if script != "color/keep_one.sh" || !pfs.IsFile(filepath.Join(ws, script)) { + t.Errorf("ran %s, want color/keep_one.sh copied into the workspace", script) + } + return true + }) + + got := map[string]testResult{} + for _, r := range runScenarioTests(log.Null, base, testDir, "color", "keep", "alpine", "", true, nil) { + got[r.Name] = r + } + if len(got) != 2 || got["keep_one"].Status != testPassed || got["keep_bad"].Status != testError { + t.Errorf("results = %+v, want keep_one passed and keep_bad an error", got) + } +} + +func TestRunScenarioTestsReportsContainerFailure(t *testing.T) { + base := t.TempDir() + testDir := filepath.Join(base, "test", "x") + writeFile(t, filepath.Join(testDir, "scenarios.json"), `{"s": {"image": "alpine"}}`, 0o644) + writeFile(t, filepath.Join(testDir, "s.sh"), "true\n", 0o755) + fakeTestContainers(t, errors.New("up failed: boom"), nil, func(string, string) bool { + t.Error("a script ran without a container") + return true + }) + got := runScenarioTests(log.Null, base, testDir, "x", "", "alpine", "", true, nil) + if len(got) != 1 || got[0] != (testResult{Name: "s", Status: testError, Detail: "up failed: boom"}) { + t.Errorf("results = %+v", got) + } +} diff --git a/internal/cli/forward_ports_merge_test.go b/internal/cli/forward_ports_merge_test.go new file mode 100644 index 0000000..abfe402 --- /dev/null +++ b/internal/cli/forward_ports_merge_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/imagemeta" +) + +// The config's forwardPorts reach the merged configuration as the raw +// number|string values, and de-dupe against the same ports from image metadata +// (TS mergeForwardPorts). They were passed as config.PortOrString structs, so +// the de-dupe formatted them with %v and emitted "{8080}". +func TestConfigForwardPortsMergeAsRawValues(t *testing.T) { + var cfg config.DevContainer + if err := json.Unmarshal([]byte(`{"image":"x","forwardPorts":[8080,"db:5432"]}`), &cfg); err != nil { + t.Fatal(err) + } + want := []any{float64(8080), "db:5432"} + + configOnly := imagemeta.MergeConfiguration([]imagemeta.Entry{configToMetadataEntry(&cfg)}) + if !reflect.DeepEqual(configOnly.ForwardPorts, want) { + t.Errorf("config only: forwardPorts = %#v, want %#v", configOnly.ForwardPorts, want) + } + + image := imagemeta.Entry{ForwardPorts: []any{float64(8080), "db:5432"}} + withImage := imagemeta.MergeConfiguration([]imagemeta.Entry{image, configToMetadataEntry(&cfg)}) + if !reflect.DeepEqual(withImage.ForwardPorts, want) { + t.Errorf("image + config: forwardPorts = %#v, want %#v (the same ports de-dupe)", withImage.ForwardPorts, want) + } +} diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index ead6451..2fe0a23 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -6,9 +6,8 @@ import ( "strings" "github.com/devcontainers/cli/internal/config" - "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/product" - "github.com/tailscale/hujson" ) // mergeAdditionalFeatures parses a JSON(C) string of additional features and @@ -21,19 +20,13 @@ func mergeAdditionalFeatures(cfg *config.DevContainer, additionalFeaturesJSON st return nil, nil } - // Parse JSONC (tolerant of comments/trailing commas) - standardJSON, err := hujson.Standardize([]byte(additionalFeaturesJSON)) - if err != nil { - return nil, fmt.Errorf("parse additional-features: %w", err) - } - - var additional map[string]interface{} - if err := json.Unmarshal(standardJSON, &additional); err != nil { + var additional map[string]any + if err := jsonc.Unmarshal([]byte(additionalFeaturesJSON), &additional); err != nil { return nil, fmt.Errorf("parse additional-features: %w", err) } if cfg.Features == nil { - cfg.Features = make(map[string]interface{}) + cfg.Features = make(map[string]any) } additionalOnly := make(map[string]bool) @@ -50,19 +43,16 @@ func mergeAdditionalFeatures(cfg *config.DevContainer, additionalFeaturesJSON st // `docker create` publish flags, matching the TS CLI: a number N publishes // 127.0.0.1:N:N, a string is used verbatim (e.g. "8080:80"). func appPortPublishArgs(raw json.RawMessage) []string { - if len(raw) == 0 { - return nil - } - var v interface{} + var v any // an absent appPort (no bytes) fails to decode and publishes nothing if err := json.Unmarshal(raw, &v); err != nil { return nil } - var items []interface{} + var items []any switch t := v.(type) { - case []interface{}: + case []any: items = t default: - items = []interface{}{t} + items = []any{t} } var args []string for _, item := range items { @@ -83,14 +73,6 @@ func cliVersion() string { return product.Get().Version } -// logDimensions returns Dimensions for the logger if terminal size was provided. -func logDimensions(columns, rows int) *log.Dimensions { - if columns > 0 || rows > 0 { - return &log.Dimensions{Columns: columns, Rows: rows} - } - return nil -} - // findPlatformArg extracts the target platform from runArgs, supporting both // "--platform=linux/amd64" and "--platform linux/amd64". Returns "" when absent. func findPlatformArg(runArgs []string) string { diff --git a/internal/cli/helpers_held_test.go b/internal/cli/helpers_held_test.go new file mode 100644 index 0000000..82c03e9 --- /dev/null +++ b/internal/cli/helpers_held_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "maps" + "testing" + + "github.com/devcontainers/cli/internal/config" +) + +// --additional-features adds only the Features the config does not declare +// (the config's value wins), reports which keys came from the flag alone, and +// works on a config without a features object. +func TestMergeAdditionalFeatures(t *testing.T) { + cfg := &config.DevContainer{Features: map[string]any{"ghcr.io/a/b/node:1": map[string]any{"version": "20"}}} + only, err := mergeAdditionalFeatures(cfg, `{ + // JSONC is accepted + "ghcr.io/a/b/node:1": {"version": "18"}, + "ghcr.io/a/b/go:1": {} + }`) + if err != nil { + t.Fatal(err) + } + if want := map[string]bool{"ghcr.io/a/b/go:1": true}; !maps.Equal(only, want) { + t.Errorf("additional-only = %v, want %v", only, want) + } + if v := cfg.Features["ghcr.io/a/b/node:1"].(map[string]any)["version"]; v != "20" { + t.Errorf("config value overridden: version = %v, want 20", v) + } + if _, ok := cfg.Features["ghcr.io/a/b/go:1"]; !ok { + t.Errorf("additional Feature not merged: %v", cfg.Features) + } + + empty := &config.DevContainer{} + if _, err := mergeAdditionalFeatures(empty, `{"ghcr.io/a/b/go:1": {}}`); err != nil { + t.Fatal(err) + } + if len(empty.Features) != 1 { + t.Errorf("features = %v, want the additional Feature on a config without features", empty.Features) + } +} + +func TestMergeAdditionalFeaturesRejectsInvalidJSON(t *testing.T) { + cfg := &config.DevContainer{} + if _, err := mergeAdditionalFeatures(cfg, `{"x":`); err == nil { + t.Fatal("want a parse error for malformed --additional-features") + } + if cfg.Features != nil { + t.Errorf("features = %v, want the config untouched on a parse error", cfg.Features) + } +} diff --git a/internal/cli/image_build_info.go b/internal/cli/image_build_info.go new file mode 100644 index 0000000..f0ff98f --- /dev/null +++ b/internal/cli/image_build_info.go @@ -0,0 +1,422 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" +) + +// imageBuildInfo is TS ImageBuildInfo (imageMetadata.ts): the user the image +// runs as and the devcontainer.metadata entries of the image a configuration +// starts from (the base image for a Dockerfile). +type imageBuildInfo struct { + User string + Metadata []imagemeta.Entry +} + +// errDockerEngineUnavailable is the local-inspect failure when no Docker engine +// client could be created. +var errDockerEngineUnavailable = errors.New("docker engine unavailable") + +// imageInspector is TS inspectDockerImage(params, name, pullImageOnError=true) +// (utils.ts): inspect the local image; failing that, read its config from the +// registry; failing that, pull it (with retries) and inspect it again. +type imageInspector struct { + log log.Logger + local func(ctx context.Context, name string) (*oci.ImageConfig, error) + registry func(ctx context.Context, name string) (*oci.ImageConfig, error) + pull func(ctx context.Context, name string) error + // pullAttempts and pullInterval are TS retry({ maxRetries: 5, + // retryIntervalMilliseconds: 1000 }). + pullAttempts int + pullInterval time.Duration +} + +// targetPlatform is the platform read-configuration inspects images for: TS +// uses the host's (mapNodeOSToGOOS / mapNodeArchitectureToGOARCH), no variant. +func targetPlatform() ocispec.Platform { + return ocispec.Platform{OS: runtime.GOOS, Architecture: runtime.GOARCH} +} + +// newImageInspector wires the inspector to the Docker engine (local inspect and +// pull) and to the invocation's OCI client (registry inspect). +func newImageInspector(ctx context.Context, lgr log.Logger, engine *docker.EngineClient) *imageInspector { + return &imageInspector{ + log: lgr, + local: func(ctx context.Context, name string) (*oci.ImageConfig, error) { + if engine == nil { + return nil, errDockerEngineUnavailable + } + info, err := engine.InspectImage(ctx, name) + if err != nil { + return nil, err + } + cfg := &oci.ImageConfig{} + if info.Config != nil { + cfg.User, cfg.Env, cfg.Labels = info.Config.User, info.Config.Env, info.Config.Labels + } + return cfg, nil + }, + registry: func(ctx context.Context, name string) (*oci.ImageConfig, error) { + details, err := newOCIClient(ctx, lgr).InspectImageInRegistry(ctx, name, targetPlatform()) + if err != nil { + return nil, err + } + return &details.Config, nil + }, + pull: func(ctx context.Context, name string) error { + if engine == nil { + return errDockerEngineUnavailable + } + return engine.PullImage(ctx, name) + }, + pullAttempts: 5, + pullInterval: time.Second, + } +} + +func (i *imageInspector) inspect(ctx context.Context, name string) (*oci.ImageConfig, error) { + cfg, inspectErr := i.local(ctx, name) + if inspectErr == nil { + return cfg, nil + } + cfg, err := i.registry(ctx, name) + if err == nil { + return cfg, nil + } + i.log.Write(fmt.Sprintf("Error fetching image details: %v", err), log.LevelInfo) + var pullErr error + for attempt := range i.pullAttempts { + if pullErr = i.pull(ctx, name); pullErr == nil { + break + } + i.log.Write(fmt.Sprintf("Retrying (Attempt %d) with error '%v'", attempt, pullErr), log.LevelWarning) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(i.pullInterval): + } + } + if pullErr != nil { + i.log.Write(inspectErr.Error(), log.LevelError) + return nil, fmt.Errorf("pull image %s: %w", name, pullErr) + } + cfg, err = i.local(ctx, name) + if err != nil { + return nil, fmt.Errorf("inspect image %s: %w", name, err) + } + return cfg, nil +} + +// composeConfigFunc reads `docker compose config` for the given files. +type composeConfigFunc func(ctx context.Context, composeFiles []string, envFile string) (map[string]any, error) + +// getImageBuildInfo is TS getImageBuildInfo (imageMetadata.ts:333). +func getImageBuildInfo(ctx context.Context, cfg *config.DevContainer, insp *imageInspector, composeConfig composeConfigFunc, lgr log.Logger) (*imageBuildInfo, error) { + configDir := filepath.Dir(cfg.ConfigFilePath) + switch { + case cfg.IsDockerfileConfig(): + dockerfilePath := cfg.Dockerfile() + if !filepath.IsAbs(dockerfilePath) { + dockerfilePath = filepath.Join(configDir, dockerfilePath) + } + if st, err := os.Stat(dockerfilePath); err != nil || st.IsDir() { + return nil, fmt.Errorf("Dockerfile (%s) not found.", dockerfilePath) + } + content, err := os.ReadFile(dockerfilePath) + if err != nil { + return nil, fmt.Errorf("read Dockerfile: %w", err) + } + target := "" + if cfg.Build != nil { + target = cfg.Build.Target + } + return imageBuildInfoFromDockerfile(ctx, insp, string(content), buildArgsFromConfig(cfg), target, lgr) + + case cfg.IsComposeConfig(): + composeFiles, err := config.DockerComposeFilePaths(cfg, osEnvMap(), configDir) + if err != nil { + return nil, fmt.Errorf("resolve compose files: %w", err) + } + composeCfg, err := composeConfig(ctx, composeFiles, "") + if err != nil { + return nil, err + } + if _, ok := composeService(composeCfg, cfg.Service); !ok { + return nil, fmt.Errorf("Service '%s' configured in devcontainer.json not found in Docker Compose configuration.", cfg.Service) + } + svc := parseComposeServiceBuild(composeCfg, cfg.Service) + if svc.HasBuild { + buildContext := svc.Context + if buildContext == "" { + buildContext = filepath.Dir(composeFiles[0]) + } + dockerfilePath := svc.Dockerfile + if dockerfilePath == "" { + dockerfilePath = "Dockerfile" + } + if !filepath.IsAbs(dockerfilePath) { + dockerfilePath = filepath.Join(buildContext, dockerfilePath) + } + content, err := os.ReadFile(dockerfilePath) + if err != nil { + return nil, fmt.Errorf("read Dockerfile for compose service %q: %w", cfg.Service, err) + } + return imageBuildInfoFromDockerfile(ctx, insp, string(content), svc.Args, svc.Target, lgr) + } + return imageBuildInfoFromImage(ctx, insp, svc.Image, lgr) + + default: + if cfg.Image == "" { + return nil, errors.New("No image information specified in devcontainer.json.") + } + return imageBuildInfoFromImage(ctx, insp, cfg.Image, lgr) + } +} + +// imageBuildInfoFromImage is TS getImageBuildInfoFromImage. +func imageBuildInfoFromImage(ctx context.Context, insp *imageInspector, name string, lgr log.Logger) (*imageBuildInfo, error) { + img, err := insp.inspect(ctx, name) + if err != nil { + return nil, err + } + user := img.User + if user == "" { + user = "root" + } + return &imageBuildInfo{User: user, Metadata: imagemeta.ReadMetadataFromLabels(img.Labels, lgr)}, nil +} + +// imageBuildInfoFromDockerfile is TS internalGetImageBuildInfoFromDockerfile: +// the base image (resolved with the build args, the target and the automatic +// BUILD*/TARGET* platform args) is inspected for its metadata and user; a USER +// statement in the Dockerfile takes precedence over the image's user. +func imageBuildInfoFromDockerfile(ctx context.Context, insp *imageInspector, dockerfileText string, buildArgs map[string]string, target string, lgr log.Logger) (*imageBuildInfo, error) { + df := docker.ExtractDockerfile(dockerfileText) + r := newDockerfileResolver(df, buildArgs, globalBuildxPlatformArgs(targetPlatform(), targetPlatform())) + var img *oci.ImageConfig + if baseImage := r.findBaseImage(target); baseImage != "" { + var err error + if img, err = insp.inspect(ctx, baseImage); err != nil { + return nil, err + } + } + var baseEnv map[string]string + if img != nil { + baseEnv = config.EnvMap(img.Env) + } + user := r.findUserStatement(baseEnv, target) + if user == "" && img != nil { + user = img.User + } + if user == "" { + user = "root" + } + info := &imageBuildInfo{User: user} + if img != nil { + info.Metadata = imagemeta.ReadMetadataFromLabels(img.Labels, lgr) + } + return info, nil +} + +// globalBuildxPlatformArgs are BuildKit's automatic platform ARGs in the global +// scope, as TS internalGetImageBuildInfoFromDockerfile defines them. +func globalBuildxPlatformArgs(build, target ocispec.Platform) map[string]string { + join := func(p ocispec.Platform) string { + return strings.Join(slices.DeleteFunc([]string{p.OS, p.Architecture, p.Variant}, func(s string) bool { return s == "" }), "/") + } + return map[string]string{ + "BUILDPLATFORM": join(build), + "BUILDOS": build.OS, + "BUILDARCH": build.Architecture, + "BUILDVARIANT": build.Variant, + "TARGETPLATFORM": join(target), + "TARGETOS": target.OS, + "TARGETARCH": target.Architecture, + "TARGETVARIANT": target.Variant, + } +} + +// argumentExpression is TS dockerfileUtils.ts argumentExpression. +var argumentExpression = regexp.MustCompile(`\$\{?([a-zA-Z0-9_]+)(:(-|\+)([^}]+))?\}?`) + +// dfScope is a TS Stage or the preamble ({ from?, instructions }). +type dfScope struct { + from *docker.From + instructions []docker.Instruction +} + +// dockerfileResolver ports the variable resolution of TS dockerfileUtils.ts +// (replaceVariables / findValue), which findBaseImage and findUserStatement use. +// docker.FindBaseImage expands only the declared global ARGs, so it misses the +// automatic platform ARGs that TS (and BuildKit) provide. +type dockerfileResolver struct { + preamble *dfScope + stages []*dfScope + byLabel map[string]*dfScope + buildArgs map[string]string + platform map[string]string +} + +func newDockerfileResolver(df *docker.Dockerfile, buildArgs, platformArgs map[string]string) *dockerfileResolver { + r := &dockerfileResolver{ + preamble: &dfScope{instructions: df.Preamble.Instructions}, + byLabel: map[string]*dfScope{}, + buildArgs: buildArgs, + platform: platformArgs, + } + for i := range df.Stages { + s := &dfScope{from: &df.Stages[i].From, instructions: df.Stages[i].Instructions} + r.stages = append(r.stages, s) + if s.from.Label != "" { + r.byLabel[s.from.Label] = s + } + } + return r +} + +func (r *dockerfileResolver) startStage(target string) *dfScope { + if target != "" { + return r.byLabel[target] + } + if len(r.stages) == 0 { + return nil + } + return r.stages[len(r.stages)-1] +} + +// fromImage resolves a stage's FROM image in the global scope. +func (r *dockerfileResolver) fromImage(s *dfScope, baseEnv map[string]string) string { + return r.replace(baseEnv, s.from.Image, r.preamble, len(r.preamble.instructions)) +} + +// findBaseImage is TS findBaseImage: follow FROM to the first image +// that is not a stage. The base image's env is not available in FROM. +func (r *dockerfileResolver) findBaseImage(target string) string { + seen := map[*dfScope]bool{} + for s := r.startStage(target); s != nil; { + if seen[s] { + return "" + } + seen[s] = true + image := r.fromImage(s, nil) + next, ok := r.byLabel[image] + if !ok { + return image + } + s = next + } + return "" +} + +// findUserStatement is TS findUserStatement: the last USER of the target stage +// (or of the stage it is built FROM), with its variables resolved. +func (r *dockerfileResolver) findUserStatement(baseEnv map[string]string, target string) string { + seen := map[*dfScope]bool{} + for s := r.startStage(target); s != nil; { + if seen[s] { + return "" + } + seen[s] = true + for i := len(s.instructions) - 1; i >= 0; i-- { + if s.instructions[i].Instruction == "USER" { + return r.replace(baseEnv, s.instructions[i].Name, s, i) + } + } + s = r.byLabel[r.fromImage(s, baseEnv)] + } + return "" +} + +// replace is TS replaceVariables. +func (r *dockerfileResolver) replace(baseEnv map[string]string, str string, s *dfScope, before int) string { + var b strings.Builder + last := 0 + for _, m := range argumentExpression.FindAllStringSubmatchIndex(str, -1) { // mutate-exempt: any n < 0 is every match + b.WriteString(str[last:m[0]]) + last = m[1] + value := r.findValue(baseEnv, str[m[2]:m[3]], s, before) + varExp := m[4] // mutate-exempt: m[4] and m[5] are both -1 exactly when the :-word/:+word group is unmatched + if varExp != -1 { // ${var:-word} / ${var:+word} + option, word := str[m[6]], str[m[8]:m[9]] // the option is one character, - or + + isSet := value != "" + if (option == '-' && !isSet) || (option == '+' && isSet) { + value = word + } + value = trimOneQuote(value) + } + b.WriteString(value) + } + b.WriteString(str[last:]) + return b.String() +} + +// trimOneQuote strips one leading and one trailing quote, as TS getExpressionValue does. +func trimOneQuote(s string) string { + if s != "" && (s[0] == '"' || s[0] == '\'') { + s = s[1:] + } + if s != "" && (s[len(s)-1] == '"' || s[len(s)-1] == '\'') { + s = s[:len(s)-1] + } + return s +} + +// findValue is TS findValue. An ARG without a default (and no build arg) does +// not define the variable, so the lookup continues outward. +func (r *dockerfileResolver) findValue(baseEnv map[string]string, variable string, s *dfScope, before int) string { + considerArg := true + seen := map[*dfScope]bool{} + for { + if seen[s] { + return "" + } + seen[s] = true + for i := min(before, len(s.instructions)) - 1; i >= 0; i-- { + ins := s.instructions[i] + if ins.Name != variable { + continue + } + if ins.Instruction == "ENV" { + return r.replace(baseEnv, ins.Value, s, i) + } + if ins.Instruction == "ARG" && considerArg { + if v, ok := r.buildArgs[ins.Name]; ok { + return r.replace(baseEnv, v, s, i) + } + if ins.Value != "" { + return r.replace(baseEnv, ins.Value, s, i) + } + } + } + if s.from == nil { + if v, ok := baseEnv[variable]; ok { + return v + } + return r.platform[variable] + } + image := r.fromImage(s, baseEnv) + next, ok := r.byLabel[image] + if !ok { + next = r.preamble + } + s = next + before = len(s.instructions) + considerArg = s == r.preamble + } +} diff --git a/internal/cli/image_build_info_held_test.go b/internal/cli/image_build_info_held_test.go new file mode 100644 index 0000000..ec67c00 --- /dev/null +++ b/internal/cli/image_build_info_held_test.go @@ -0,0 +1,208 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "path/filepath" + "testing" + + "github.com/moby/moby/api/types/container" + mobyclient "github.com/moby/moby/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" +) + +// fakeEngineAPI is a Docker engine with local images, images a pull makes +// local, and containers. The docker.API methods it does not override are +// not expected to be called. +type fakeEngineAPI struct { + docker.API + images map[string]string // name -> image inspect JSON + pullable map[string]string + pulls []string + containers map[string]string // id -> container inspect JSON + listed [][]string // label filters of each ContainerList + list func(labels []string) []string +} + +func (f *fakeEngineAPI) ImageInspect(_ context.Context, name string, _ ...mobyclient.ImageInspectOption) (mobyclient.ImageInspectResult, error) { + var res mobyclient.ImageInspectResult + js, ok := f.images[name] + if !ok { + return res, errors.New("No such image: " + name) + } + return res, json.Unmarshal([]byte(js), &res.InspectResponse) +} + +func (f *fakeEngineAPI) ImagePull(_ context.Context, name string, _ mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) { + f.pulls = append(f.pulls, name) + js, ok := f.pullable[name] + if !ok { + return nil, errors.New("pull access denied for " + name) + } + if f.images == nil { + f.images = map[string]string{} + } + f.images[name] = js + return emptyPull{}, nil +} + +func (f *fakeEngineAPI) ContainerInspect(_ context.Context, id string, _ mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) { + var res mobyclient.ContainerInspectResult + js, ok := f.containers[id] + if !ok { + return res, errors.New("No such container: " + id) + } + return res, json.Unmarshal([]byte(js), &res.Container) +} + +func (f *fakeEngineAPI) ContainerList(_ context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) { + var labels []string + for l := range opts.Filters["label"] { + labels = append(labels, l) + } + f.listed = append(f.listed, labels) + var res mobyclient.ContainerListResult + if f.list != nil { + for _, id := range f.list(labels) { + res.Items = append(res.Items, container.Summary{ID: id}) + } + } + return res, nil +} + +func (f *fakeEngineAPI) Close() error { return nil } + +// emptyPull is a pull whose progress stream is empty. +type emptyPull struct{ mobyclient.ImagePullResponse } + +func (emptyPull) Read([]byte) (int, error) { return 0, io.EOF } +func (emptyPull) Close() error { return nil } + +func TestNewImageInspectorWithoutEngine(t *testing.T) { + insp := newImageInspector(context.Background(), log.Null, nil) + insp.pullInterval = 0 + // An invalid name fails the registry inspect without contacting a registry. + got, err := insp.inspect(context.Background(), "Not A Valid Name") + if !errors.Is(err, errDockerEngineUnavailable) || got != nil { + t.Fatalf("inspect = %+v, %v; want the pull to fail with %v", got, err, errDockerEngineUnavailable) + } + if _, err := insp.local(context.Background(), "img"); !errors.Is(err, errDockerEngineUnavailable) { + t.Fatalf("local inspect without an engine = %v, want %v", err, errDockerEngineUnavailable) + } +} + +func TestNewImageInspectorLocalImage(t *testing.T) { + api := &fakeEngineAPI{images: map[string]string{ + "img:1": `{"Config":{"User":"node","Env":["A=b"],"Labels":{"l":"v"}}}`, + "bare": `{}`, + }} + insp := newImageInspector(context.Background(), log.Null, &docker.EngineClient{API: api, Log: log.Null}) + got, err := insp.inspect(context.Background(), "img:1") + if err != nil { + t.Fatalf("inspect: %v", err) + } + if got.User != "node" || len(got.Env) != 1 || got.Env[0] != "A=b" || got.Labels["l"] != "v" { + t.Fatalf("inspect = %+v, want the local image's user, env and labels", got) + } + if got, err = insp.inspect(context.Background(), "bare"); err != nil || got.User != "" || got.Labels != nil { + t.Fatalf("image without config: inspect = %+v, %v; want an empty config", got, err) + } + if len(api.pulls) != 0 { + t.Errorf("pulled %v; a local image is not pulled", api.pulls) + } +} + +func TestNewImageInspectorPullsWhatTheRegistryCannotInspect(t *testing.T) { + const name = "Not A Valid Name" + api := &fakeEngineAPI{pullable: map[string]string{name: `{"Config":{"User":"pulled"}}`}} + insp := newImageInspector(context.Background(), log.Null, &docker.EngineClient{API: api, Log: log.Null}) + got, err := insp.inspect(context.Background(), name) + if err != nil || got.User != "pulled" { + t.Fatalf("inspect = %+v, %v; want the pulled image's config", got, err) + } + if len(api.pulls) != 1 { + t.Errorf("pulls = %v, want one", api.pulls) + } +} + +func TestImageBuildInfoComposeBuildWithoutContext(t *testing.T) { + // A build without a context builds from the first compose file's folder. + dir := writeConfigFixture(t, map[string]string{"compose/compose.yml": "services: {}\n", "compose/Dockerfile": "FROM ctx-base:1\n"}) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), DockerComposeFile: []string{"compose/compose.yml"}, Service: "app"} + f := &fakeImages{registry: map[string]*oci.ImageConfig{"ctx-base:1": metaImage("dev", "from-context")}} + compose := func(context.Context, []string, string) (map[string]any, error) { + return map[string]any{"services": map[string]any{"app": map[string]any{"build": map[string]any{}}}}, nil + } + info, err := getImageBuildInfo(context.Background(), cfg, f.inspector(), compose, log.Null) + if err != nil || info.User != "dev" || len(info.Metadata) != 1 || info.Metadata[0].RemoteUser != "from-context" { + t.Fatalf("info = %+v, %v; want the metadata of the Dockerfile next to the compose file", info, err) + } +} + +func TestImageBuildInfoDockerfileUserFromBaseImageEnv(t *testing.T) { + f := &fakeImages{local: map[string]*oci.ImageConfig{"base": {User: "root", Env: []string{"IMG_USER=vscode"}}}} + info, err := imageBuildInfoFromDockerfile(context.Background(), f.inspector(), "FROM base\nUSER ${IMG_USER}\n", nil, "", log.Null) + if err != nil || info.User != "vscode" { + t.Fatalf("info = %+v, %v; want the USER resolved with the base image's env", info, err) + } +} + +func TestDockerfileResolverVariableExpressions(t *testing.T) { + platform := globalBuildxPlatformArgs(ocispec.Platform{OS: "linux", Architecture: "amd64"}, ocispec.Platform{OS: "linux", Architecture: "amd64"}) + for _, tc := range []struct { + name, dockerfile string + want string + }{ + {"default word, variable set", "ARG V=set\nFROM img:${V:-fallback}\n", "img:set"}, + {"default word, variable unset", "ARG V\nFROM img:${V:-fallback}\n", "img:fallback"}, + {"alternate word, variable set", "ARG V=set\nFROM img:${V:+alt}\n", "img:alt"}, + {"alternate word, variable unset", "ARG V\nFROM img:x${V:+alt}\n", "img:x"}, + {"quoted word", "ARG V\nFROM img:${V:-\"quoted\"}\n", "img:quoted"}, + {"plain variable", "ARG V=1\nFROM img:$V-${V}\n", "img:1-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newDockerfileResolver(docker.ExtractDockerfile(tc.dockerfile), nil, platform) + if got := r.findBaseImage(""); got != tc.want { + t.Errorf("findBaseImage = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDockerfileResolverParentStageArgIsNotInherited(t *testing.T) { + // An ARG of the stage a stage is built FROM is not in scope, its ENV is, and + // so is a global ARG (TS findValue considers ARGs only in the stage itself + // and in the global scope). + platform := globalBuildxPlatformArgs(ocispec.Platform{OS: "linux", Architecture: "amd64"}, ocispec.Platform{OS: "linux", Architecture: "amd64"}) + r := newDockerfileResolver(docker.ExtractDockerfile("ARG G=global\nFROM img AS base\nARG U=parent\nENV E=env\nFROM base\nUSER ${U:-none}-${E}-${G}\n"), nil, platform) + if got := r.findUserStatement(nil, ""); got != "none-env-global" { + t.Fatalf("findUserStatement = %q, want %q", got, "none-env-global") + } +} + +func TestTrimOneQuote(t *testing.T) { + for in, want := range map[string]string{ + ``: ``, + `"`: ``, + `'`: ``, + `a`: `a`, + `"ab"`: `ab`, + `'ab'`: `ab`, + `"ab`: `ab`, + `ab"`: `ab`, + `'ab`: `ab`, + `ab'`: `ab`, + `""ab""`: `"ab"`, + } { + if got := trimOneQuote(in); got != want { + t.Errorf("trimOneQuote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/cli/image_build_info_nodocker_held_test.go b/internal/cli/image_build_info_nodocker_held_test.go new file mode 100644 index 0000000..aca3ddc --- /dev/null +++ b/internal/cli/image_build_info_nodocker_held_test.go @@ -0,0 +1,23 @@ +package cli + +import ( + "testing" + + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" +) + +// An image neither local nor in a registry is pulled five times before the +// inspection fails, as TS retry({ maxRetries: 5 }) tries it. +func TestNewImageInspectorPullsFiveTimes(t *testing.T) { + const name = "Not A Valid Name" + api := &fakeEngineAPI{} + insp := newImageInspector(t.Context(), log.Null, &docker.EngineClient{API: api, Log: log.Null}) + insp.pullInterval = 0 + if got, err := insp.inspect(t.Context(), name); err == nil { + t.Fatalf("inspect = %+v, want the pull failure", got) + } + if len(api.pulls) != 5 { + t.Errorf("pulls = %d, want 5", len(api.pulls)) + } +} diff --git a/internal/cli/image_build_info_test.go b/internal/cli/image_build_info_test.go new file mode 100644 index 0000000..de024e5 --- /dev/null +++ b/internal/cli/image_build_info_test.go @@ -0,0 +1,248 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" + "github.com/devcontainers/cli/internal/oci" +) + +// fakeImages backs an imageInspector: images present locally, images the +// registry knows, and images a pull makes local. +type fakeImages struct { + local, registry, pullable map[string]*oci.ImageConfig + pullFailures int // pulls that fail before one succeeds + localCalls, registryCalls int + pulls int +} + +func (f *fakeImages) inspector() *imageInspector { + return &imageInspector{ + log: log.Null, + local: func(_ context.Context, name string) (*oci.ImageConfig, error) { + f.localCalls++ + if c, ok := f.local[name]; ok { + return c, nil + } + return nil, errors.New("No such image: " + name) + }, + registry: func(_ context.Context, name string) (*oci.ImageConfig, error) { + f.registryCalls++ + if c, ok := f.registry[name]; ok { + return c, nil + } + return nil, errors.New("No manifest found for " + name + ".") + }, + pull: func(_ context.Context, name string) error { + f.pulls++ + if f.pulls <= f.pullFailures { + return errors.New("pull flake") + } + c, ok := f.pullable[name] + if !ok { + return errors.New("pull access denied for " + name) + } + if f.local == nil { + f.local = map[string]*oci.ImageConfig{} + } + f.local[name] = c + return nil + }, + pullAttempts: 5, + } +} + +func metaImage(user, remoteUser string) *oci.ImageConfig { + return &oci.ImageConfig{User: user, Labels: map[string]string{"devcontainer.metadata": `[{"remoteUser":"` + remoteUser + `"}]`}} +} + +func TestImageInspectorFallbackChain(t *testing.T) { + ctx := context.Background() + + t.Run("local", func(t *testing.T) { + f := &fakeImages{local: map[string]*oci.ImageConfig{"img": {User: "local"}}} + got, err := f.inspector().inspect(ctx, "img") + if err != nil || got.User != "local" || f.registryCalls != 0 || f.pulls != 0 { + t.Fatalf("got %+v, %v (registry %d, pulls %d)", got, err, f.registryCalls, f.pulls) + } + }) + t.Run("registry without pulling", func(t *testing.T) { + f := &fakeImages{registry: map[string]*oci.ImageConfig{"img": {User: "remote"}}} + got, err := f.inspector().inspect(ctx, "img") + if err != nil || got.User != "remote" || f.pulls != 0 { + t.Fatalf("got %+v, %v (pulls %d)", got, err, f.pulls) + } + }) + t.Run("pull with retries then inspect", func(t *testing.T) { + f := &fakeImages{pullable: map[string]*oci.ImageConfig{"img": {User: "pulled"}}, pullFailures: 2} + got, err := f.inspector().inspect(ctx, "img") + if err != nil || got.User != "pulled" || f.pulls != 3 || f.localCalls != 2 { + t.Fatalf("got %+v, %v (pulls %d, local %d)", got, err, f.pulls, f.localCalls) + } + }) + t.Run("pull failure is an error", func(t *testing.T) { + f := &fakeImages{} + got, err := f.inspector().inspect(ctx, "img") + if err == nil || got != nil || f.pulls != 5 { + t.Fatalf("got %+v, %v (pulls %d), want an error after 5 pull attempts", got, err, f.pulls) + } + }) +} + +func TestDockerfileResolverBaseImage(t *testing.T) { + platform := globalBuildxPlatformArgs(ocispec.Platform{OS: "linux", Architecture: "amd64"}, ocispec.Platform{OS: "linux", Architecture: "arm64"}) + for _, tc := range []struct { + name, dockerfile, target string + args map[string]string + want string + }{ + {"global arg default", "ARG V=1\nFROM img:${V}\n", "", nil, "img:1"}, + {"build arg override", "ARG V=1\nFROM img:${V}\n", "", map[string]string{"V": "2"}, "img:2"}, + {"automatic platform arg", "FROM img-$TARGETARCH:${BUILDOS}\n", "", nil, "img-arm64:linux"}, + {"declared platform arg without default", "ARG TARGETPLATFORM\nFROM --platform=$BUILDPLATFORM img:${TARGETPLATFORM}\n", "", nil, "img:linux/arm64"}, + {"var expression", "ARG V\nFROM img:${V:-fallback}\n", "", nil, "img:fallback"}, + {"stage chain", "FROM base:1 AS one\nFROM one AS two\nFROM other:2\n", "two", nil, "base:1"}, + {"last stage by default", "FROM a:1 AS one\nFROM one\n", "", nil, "a:1"}, + {"unknown target", "FROM a:1\n", "nope", nil, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newDockerfileResolver(docker.ExtractDockerfile(tc.dockerfile), tc.args, platform) + if got := r.findBaseImage(tc.target); got != tc.want { + t.Errorf("findBaseImage = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDockerfileResolverUserStatement(t *testing.T) { + platform := globalBuildxPlatformArgs(ocispec.Platform{OS: "linux", Architecture: "amd64"}, ocispec.Platform{OS: "linux", Architecture: "amd64"}) + for _, tc := range []struct { + name, dockerfile, target string + args, baseEnv map[string]string + want string + }{ + {"plain", "FROM img\nUSER node\n", "", nil, nil, "node"}, + {"stage arg", "FROM img\nARG U=dev\nUSER $U\n", "", nil, nil, "dev"}, + {"build arg", "FROM img\nARG U=dev\nUSER $U\n", "", map[string]string{"U": "ci"}, nil, "ci"}, + {"base image env", "FROM img\nUSER ${IMG_USER}\n", "", nil, map[string]string{"IMG_USER": "vscode"}, "vscode"}, + {"parent stage", "FROM img AS base\nUSER app\nFROM base\nRUN true\n", "", nil, nil, "app"}, + {"none", "FROM img\nRUN true\n", "", nil, nil, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newDockerfileResolver(docker.ExtractDockerfile(tc.dockerfile), tc.args, platform) + if got := r.findUserStatement(tc.baseEnv, tc.target); got != tc.want { + t.Errorf("findUserStatement = %q, want %q", got, tc.want) + } + }) + } +} + +func writeConfigFixture(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func noCompose(context.Context, []string, string) (map[string]any, error) { + return nil, errors.New("compose not expected") +} + +func TestImageBuildInfoDockerfile(t *testing.T) { + dir := writeConfigFixture(t, map[string]string{"Dockerfile": "ARG V=1\nFROM base:${V}\n"}) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), Build: &config.Build{Dockerfile: "Dockerfile", Args: map[string]string{"V": "2"}}} + f := &fakeImages{registry: map[string]*oci.ImageConfig{"base:2": metaImage("node", "node")}} + info, err := getImageBuildInfo(context.Background(), cfg, f.inspector(), noCompose, log.Null) + if err != nil { + t.Fatalf("getImageBuildInfo: %v", err) + } + if info.User != "node" || len(info.Metadata) != 1 || info.Metadata[0].RemoteUser != "node" { + t.Fatalf("info = %+v", info) + } + if f.pulls != 0 { + t.Errorf("pulled %d times; the registry config is enough", f.pulls) + } +} + +func TestImageBuildInfoDockerfileUserOverridesImage(t *testing.T) { + dir := writeConfigFixture(t, map[string]string{"Dockerfile": "FROM base\nUSER app\n"}) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), DockerFile: "Dockerfile"} + f := &fakeImages{local: map[string]*oci.ImageConfig{"base": metaImage("node", "node")}} + info, err := getImageBuildInfo(context.Background(), cfg, f.inspector(), noCompose, log.Null) + if err != nil || info.User != "app" { + t.Fatalf("info = %+v, %v; want user app", info, err) + } +} + +func TestImageBuildInfoErrors(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "missing.Dockerfile") + for _, tc := range []struct { + name string + cfg *config.DevContainer + compose composeConfigFunc + want string + }{ + {"dockerfile not found", &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), Build: &config.Build{Dockerfile: "missing.Dockerfile"}}, noCompose, "Dockerfile (" + missing + ") not found."}, + {"no image", &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json")}, noCompose, "No image information specified in devcontainer.json."}, + {"service not found", &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), DockerComposeFile: []string{"compose.yml"}, Service: "app"}, + func(context.Context, []string, string) (map[string]any, error) { + return map[string]any{"services": map[string]any{"db": map[string]any{"image": "postgres"}}}, nil + }, "Service 'app' configured in devcontainer.json not found in Docker Compose configuration."}, + } { + t.Run(tc.name, func(t *testing.T) { + info, err := getImageBuildInfo(context.Background(), tc.cfg, (&fakeImages{}).inspector(), tc.compose, log.Null) + if err == nil || err.Error() != tc.want || info != nil { + t.Fatalf("got %+v, %v; want error %q", info, err, tc.want) + } + }) + } +} + +func TestImageBuildInfoCompose(t *testing.T) { + dir := writeConfigFixture(t, map[string]string{"compose.yml": "services: {}\n", "build/Dockerfile.dev": "ARG TAG=1\nFROM svc-base:${TAG}\n"}) + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json"), DockerComposeFile: []string{"compose.yml"}, Service: "app"} + f := &fakeImages{registry: map[string]*oci.ImageConfig{"svc-base:3": metaImage("", "built"), "svc-image:1": metaImage("img", "image")}} + + build := func(context.Context, []string, string) (map[string]any, error) { + return map[string]any{"services": map[string]any{"app": map[string]any{ + "build": map[string]any{"context": filepath.Join(dir, "build"), "dockerfile": "Dockerfile.dev", "args": map[string]any{"TAG": "3"}}, + }}}, nil + } + info, err := getImageBuildInfo(context.Background(), cfg, f.inspector(), build, log.Null) + if err != nil || info.User != "root" || len(info.Metadata) != 1 || info.Metadata[0].RemoteUser != "built" { + t.Fatalf("build service: info = %+v, %v", info, err) + } + + image := func(context.Context, []string, string) (map[string]any, error) { + return map[string]any{"services": map[string]any{"app": map[string]any{"image": "svc-image:1"}}}, nil + } + info, err = getImageBuildInfo(context.Background(), cfg, f.inspector(), image, log.Null) + if err != nil || info.User != "img" || len(info.Metadata) != 1 || info.Metadata[0].RemoteUser != "image" { + t.Fatalf("image service: info = %+v, %v", info, err) + } +} + +func TestImageBuildInfoImagePullFailurePropagates(t *testing.T) { + cfg := &config.DevContainer{ConfigFilePath: "/x/devcontainer.json", Image: "nowhere:1"} + info, err := getImageBuildInfo(context.Background(), cfg, (&fakeImages{}).inspector(), noCompose, log.Null) + if err == nil || info != nil { + t.Fatalf("got %+v, %v; want the pull error", info, err) + } +} diff --git a/internal/cli/metadata_interop_test.go b/internal/cli/metadata_interop_test.go index ce58398..852ff37 100644 --- a/internal/cli/metadata_interop_test.go +++ b/internal/cli/metadata_interop_test.go @@ -39,30 +39,30 @@ func interopEntries() []imagemeta.Entry { return []imagemeta.Entry{ { ID: "ghcr.io/devcontainers/features/node:1", - Mounts: []interface{}{"source=nvm,target=/usr/local/share/nvm,type=volume"}, - ForwardPorts: []interface{}{float64(3000)}, + Mounts: []any{"source=nvm,target=/usr/local/share/nvm,type=volume"}, + ForwardPorts: []any{float64(3000)}, ContainerUser: "node", Init: &yes, OnCreateCommand: "echo feature-oncreate", PostCreateCommand: "npm ci", - Customizations: map[string]interface{}{ - "vscode": map[string]interface{}{ - "extensions": []interface{}{"dbaeumer.vscode-eslint"}, + Customizations: map[string]any{ + "vscode": map[string]any{ + "extensions": []any{"dbaeumer.vscode-eslint"}, }, }, }, { // Config-level entry (no id), highest priority for scalars. - Mounts: []interface{}{"source=cfg,target=/cfg,type=volume"}, - ForwardPorts: []interface{}{float64(8080)}, + Mounts: []any{"source=cfg,target=/cfg,type=volume"}, + ForwardPorts: []any{float64(8080)}, RemoteUser: "node", ContainerUser: "node", WaitFor: "postCreateCommand", PostCreateCommand: "echo config-postcreate", PostAttachCommand: "echo attached", - Customizations: map[string]interface{}{ - "vscode": map[string]interface{}{ - "settings": map[string]interface{}{"editor.formatOnSave": true}, + Customizations: map[string]any{ + "vscode": map[string]any{ + "settings": map[string]any{"editor.formatOnSave": true}, }, }, }, @@ -75,7 +75,7 @@ func interopEntries() []imagemeta.Entry { // parsed structure. func jsonNormEqual(t *testing.T, a, b []byte) bool { t.Helper() - var av, bv interface{} + var av, bv any if err := json.Unmarshal(a, &av); err != nil { t.Fatalf("unmarshal a: %v (%s)", err, a) } @@ -102,7 +102,7 @@ func TestMetadataInteropWhitespaceInvariant(t *testing.T) { // (extra whitespace) and, via a round-trip through interface{}, arbitrary key // order. json.MarshalIndent produces bytes that differ from goLabel yet must // parse identically. - var parsed interface{} + var parsed any if err := json.Unmarshal([]byte(goLabel), &parsed); err != nil { t.Fatalf("parse go label: %v", err) } @@ -189,10 +189,9 @@ func TestMetadataInteropGoRoundTrip(t *testing.T) { // Expected merge computed purely in-memory from the same entries. expected := mergeFromLabel(t, imagemeta.GenerateMetadataLabel(entries)) - // Build the Dockerfile exactly as build.go does for the no-features case - // (base image + metadata label), but pin the base to `scratch` so the build - // needs no network. - info := imagemeta.GenerateExtendImageBuild("scratch", nil, entries, "root", "", false, nil, nil) + // Label a `scratch` image with the metadata, as the no-Features builds do, + // so the build needs no network. + info := imagemeta.GenerateComposeLabelBuild("scratch", entries, "") dockerfile := info.DockerfilePrefixContent + info.DockerfileContent tmp := t.TempDir() @@ -227,26 +226,66 @@ func TestMetadataInteropGoRoundTrip(t *testing.T) { } } -// TestMetadataInteropTSToGo is the TS->Go direction: build an image with the TS -// oracle carrying the same features, then read the devcontainer.metadata label -// with the Go reader and assert the normalized merge matches. It requires the -// compiled reference oracle, so it is guarded to Skip in this hermetic worktree. -// -// TODO: run the full bidirectional exchange (TS build -> Go read AND Go build -> -// TS read) end-to-end once the compiled oracle is present. +// TestMetadataInteropTSToGo is the TS->Go direction: the same configuration - +// a Dockerfile, a local Feature with its own containerEnv, init and hook, and +// config-level properties - is built by the TS oracle and by this CLI, and both +// images' devcontainer.metadata labels, read and merged by the Go reader, are +// the same configuration. It needs the compiled oracle and Docker. func TestMetadataInteropTSToGo(t *testing.T) { repoRoot := findRepoRoot(t) - oracle := filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js") - if _, err := os.Stat(oracle); err != nil { - t.Skip("TS reference oracle not compiled; skipping TS->Go interop") + oracle := filepath.Join(repoRoot, "reference", "devcontainer.js") + if _, err := os.Stat(filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js")); err != nil { + t.Skip("TS reference oracle not compiled (task reference)") } if !isDockerAvailable() { t.Skip("docker required") } - // When the oracle is present, the TS build produces a - // devcontainer.metadata label with different whitespace/key-order than Go's. - // The assertion still holds: read via the Go reader and compare the merge as - // normalized JSON. This guard keeps the worktree hermetic until the - // bidirectional build wiring is added. - t.Skip("TS->Go interop build wiring not implemented yet") + ctx := t.Context() + + ws := t.TempDir() + for path, content := range map[string]string{ + ".devcontainer/devcontainer.json": `{ + "build": {"dockerfile": "Dockerfile"}, + "features": {"./feat": {}}, + "remoteUser": "root", + "containerEnv": {"FROM_CONFIG": "1"}, + "capAdd": ["SYS_PTRACE"], + "mounts": ["type=volume,source=interop-vol,target=/interop"], + "postCreateCommand": "echo config", + "customizations": {"vscode": {"extensions": ["a.b"]}} +}`, + ".devcontainer/Dockerfile": "FROM alpine:3\n", + ".devcontainer/feat/devcontainer-feature.json": `{"id": "feat", "version": "1.0.0", "containerEnv": {"FROM_FEATURE": "2"}, "init": true, "postStartCommand": "echo feature"}`, + ".devcontainer/feat/install.sh": "#!/bin/sh\necho installed\n", + } { + full := filepath.Join(ws, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + label := func(tag string) string { + t.Helper() + out, err := exec.CommandContext(ctx, "docker", "inspect", "--format", + `{{ index .Config.Labels "`+imagemeta.MetadataLabel+`" }}`, tag).Output() + if err != nil { + t.Fatalf("docker inspect %s: %v", tag, err) + } + return string(bytes.TrimRight(out, "\n")) + } + + const tsTag, goTag = "dcc-interop-ts:test", "dcc-interop-go:test" + t.Cleanup(func() { exec.Command("docker", "rmi", "-f", tsTag, goTag).Run() }) + if out, err := exec.CommandContext(ctx, "node", oracle, "build", "--workspace-folder", ws, "--image-name", tsTag).CombinedOutput(); err != nil { + t.Fatalf("TS build: %v\n%s", err, out) + } + assertOutcome(t, runCLI(t, "build", "--workspace-folder", ws, "--image-name", goTag), "success") + + tsMerged, goMerged := mergeFromLabel(t, label(tsTag)), mergeFromLabel(t, label(goTag)) + if !jsonNormEqual(t, tsMerged, goMerged) { + t.Fatalf("the label TS wrote merges differently from Go's:\n--- TS\n%s\n--- Go\n%s", tsMerged, goMerged) + } } diff --git a/internal/cli/mounts.go b/internal/cli/mounts.go index edda750..25e6257 100644 --- a/internal/cli/mounts.go +++ b/internal/cli/mounts.go @@ -4,26 +4,25 @@ import ( "crypto/sha256" "fmt" "path/filepath" - "strings" "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/docker" dockermount "github.com/moby/moby/api/types/mount" ) -func metadataMounts(cfg *config.DevContainer) []interface{} { +func metadataMounts(cfg *config.DevContainer) []any { if len(cfg.Mounts) == 0 { return nil } - mounts := make([]interface{}, 0, len(cfg.Mounts)) + mounts := make([]any, 0, len(cfg.Mounts)) for _, mountValue := range cfg.Mounts { if spec, ok := mountValue.AsString(); ok && spec != "" { mounts = append(mounts, spec) continue } if mountObj, ok := mountValue.AsMount(); ok { - serialized := map[string]interface{}{} + serialized := map[string]any{} if mountObj.Type != "" { serialized["type"] = mountObj.Type } @@ -49,7 +48,7 @@ func metadataMounts(cfg *config.DevContainer) []interface{} { return mounts } -func mountsFromMetadata(entries []interface{}, devcontainerID string) ([]dockermount.Mount, error) { +func mountsFromMetadata(entries []any, devcontainerID string) ([]dockermount.Mount, error) { mounts := make([]dockermount.Mount, 0, len(entries)) for _, entry := range entries { mountSpec, err := mountFromMetadata(entry, devcontainerID) @@ -61,47 +60,7 @@ func mountsFromMetadata(entries []interface{}, devcontainerID string) ([]dockerm return mounts, nil } -// composeServiceImage returns the `image:` of a service from parsed -// `docker compose config` output, or "" if not set (e.g. a build-only service). -func composeServiceImage(composeConfig map[string]interface{}, service string) string { - services, ok := composeConfig["services"].(map[string]interface{}) - if !ok { - return "" - } - svc, ok := services[service].(map[string]interface{}) - if !ok { - return "" - } - image, _ := svc["image"].(string) - return image -} - -func composeVolumeSpecsFromMetadata(entries []interface{}, devcontainerID string) ([]string, []string, error) { - specs := make([]string, 0, len(entries)) - namedVolumes := []string{} - for _, entry := range entries { - mountSpec, err := mountFromMetadata(entry, devcontainerID) - if err != nil { - return nil, nil, err - } - - spec := mountSpec.Target - if mountSpec.Source != "" { - spec = mountSpec.Source + ":" + mountSpec.Target - } - if mountSpec.ReadOnly { - spec += ":ro" - } - specs = append(specs, spec) - - if mountSpec.Type == dockermount.TypeVolume && mountSpec.Source != "" && !strings.Contains(mountSpec.Source, "/") { - namedVolumes = append(namedVolumes, mountSpec.Source) - } - } - return specs, namedVolumes, nil -} - -func mountFromMetadata(entry interface{}, devcontainerID string) (dockermount.Mount, error) { +func mountFromMetadata(entry any, devcontainerID string) (dockermount.Mount, error) { resolve := func(value string) (string, error) { resolved, err := config.NewVariableResolver().Resolve(config.SubstitutionContext{ DevContainerID: devcontainerID, @@ -122,7 +81,7 @@ func mountFromMetadata(entry interface{}, devcontainerID string) (dockermount.Mo return dockermount.Mount{}, err } return docker.ParseMountSpec(spec) - case map[string]interface{}: + case map[string]any: target, _ := raw["target"].(string) // ${devcontainerId} must resolve in target too, not only source — the // string form substitutes the whole spec, so the object form has to match diff --git a/internal/cli/mounts_held_test.go b/internal/cli/mounts_held_test.go new file mode 100644 index 0000000..0d005e2 --- /dev/null +++ b/internal/cli/mounts_held_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/devcontainers/cli/internal/config" +) + +// A string mount from image metadata resolves ${devcontainerId} across the +// whole spec before it is parsed. +func TestMountFromMetadataStringSpec(t *testing.T) { + m, err := mountFromMetadata("type=volume,source=cache-${devcontainerId},target=/cache", "abc123") + if err != nil { + t.Fatal(err) + } + if m.Source != "cache-abc123" || m.Target != "/cache" || string(m.Type) != "volume" { + t.Errorf("mount = %+v, want volume cache-abc123 -> /cache", m) + } +} + +// An object mount keeps the type it names and is a bind mount without one. +func TestMountFromMetadataObjectType(t *testing.T) { + volume, err := mountFromMetadata(map[string]any{"type": "volume", "source": "v", "target": "/v"}, "id") + if err != nil { + t.Fatal(err) + } + if string(volume.Type) != "volume" { + t.Errorf("type = %q, want volume", volume.Type) + } + bind, err := mountFromMetadata(map[string]any{"source": "/h", "target": "/c"}, "id") + if err != nil { + t.Fatal(err) + } + if string(bind.Type) != "bind" { + t.Errorf("type = %q, want bind when the mount names none", bind.Type) + } +} + +// An empty string mount records nothing in the configuration's metadata entry; +// a non-empty one is kept as written. +func TestMetadataMountsSkipsEmptyStrings(t *testing.T) { + var cfg config.DevContainer + if err := json.Unmarshal([]byte(`{"mounts": ["", "source=/h,target=/c,type=bind"]}`), &cfg); err != nil { + t.Fatal(err) + } + got := metadataMounts(&cfg) + if len(got) != 1 || got[0] != "source=/h,target=/c,type=bind" { + t.Errorf("metadataMounts = %#v, want only the non-empty spec", got) + } +} diff --git a/internal/cli/mounts_nodocker_held_test.go b/internal/cli/mounts_nodocker_held_test.go new file mode 100644 index 0000000..7e3dc38 --- /dev/null +++ b/internal/cli/mounts_nodocker_held_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "testing" +) + +// The mounts of image metadata are exactly its entries, in order, each with +// ${devcontainerId} resolved; an invalid entry fails them all. +func TestMountsFromMetadata(t *testing.T) { + got, err := mountsFromMetadata([]any{ + "type=volume,source=a-${devcontainerId},target=/a", + map[string]any{"type": "bind", "source": "/b", "target": "/b"}, + }, "id1") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Source != "a-id1" || got[0].Target != "/a" || got[1].Source != "/b" || got[1].Target != "/b" { + t.Errorf("mounts = %+v, want a-id1 -> /a then /b -> /b", got) + } + if got, err := mountsFromMetadata(nil, "id1"); err != nil || len(got) != 0 { + t.Errorf("no entries: %+v, %v; want none", got, err) + } + if _, err := mountsFromMetadata([]any{"type=volume,target=/a", map[string]any{"source": "/no-target"}}, "id1"); err == nil { + t.Error("an invalid entry: want an error") + } +} diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index caffdae..94ca4b9 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "slices" "sort" "strings" @@ -19,28 +21,12 @@ import ( "github.com/spf13/cobra" ) -// isBareVersion reports whether s is a bare semver-ish target: x / x.y / x.y.z +// bareVersion is a bare semver-ish target: x / x.y / x.y.z // (TS: /^\d+(\.\d+(\.\d+)?)?$/ in upgradeCommand.ts). -func isBareVersion(s string) bool { - if s == "" { - return false - } - parts := strings.Split(s, ".") - if len(parts) > 3 { - return false - } - for _, p := range parts { - if p == "" { - return false - } - for i := 0; i < len(p); i++ { - if p[i] < '0' || p[i] > '9' { - return false - } - } - } - return true -} +var bareVersion = regexp.MustCompile(`^\d+(\.\d+(\.\d+)?)?$`) + +// isBareVersion reports whether s is a bare semver-ish target. +func isBareVersion(s string) bool { return bareVersion.MatchString(s) } // getFeatureIdWithoutVersion strips the trailing ":tag" or "@digest" from a user // feature id, leaving the full id path (TS getFeatureIdWithoutVersion, /[:@][^/]*$/). @@ -122,9 +108,9 @@ func highestSatisfyingTag(versions []*semver.Version, tag string) string { if err != nil { return "" } - for i := len(versions) - 1; i >= 0; i-- { - if constraint.Check(versions[i]) { - return versions[i].Original() + for _, version := range slices.Backward(versions) { + if constraint.Check(version) { + return version.Original() } } return "" @@ -236,7 +222,7 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { } if opts.outputFormat == "json" { - payload := map[string]interface{}{"features": result} + payload := map[string]any{"features": result} data, _ := json.MarshalIndent(payload, "", " ") fmt.Fprintln(out.Stdout(), string(data)) } else { @@ -276,7 +262,7 @@ func dashIfEmpty(s string) string { // devcontainer.json (Go maps are unordered, so the raw file is re-read). Any key // present in features but not recovered from the file is appended sorted, so the // caller never silently drops a feature. -func orderedFeatureKeys(configFilePath string, features map[string]interface{}) []string { +func orderedFeatureKeys(configFilePath string, features map[string]any) []string { ordered, _ := featureKeyOrderFromFile(configFilePath) seen := make(map[string]bool, len(ordered)) diff --git a/internal/cli/outdated_held_test.go b/internal/cli/outdated_held_test.go new file mode 100644 index 0000000..c633b99 --- /dev/null +++ b/internal/cli/outdated_held_test.go @@ -0,0 +1,136 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" +) + +// writeOutdatedWorkspace writes /.devcontainer/devcontainer.json with the +// given features object, and the lockfile when lock is not empty. +func writeOutdatedWorkspace(t *testing.T, features, lock string) string { + t.Helper() + ws := t.TempDir() + writeFile(t, filepath.Join(ws, ".devcontainer", "devcontainer.json"), `{"image":"alpine","features":`+features+`}`, 0o644) + if lock != "" { + writeFile(t, filepath.Join(ws, ".devcontainer", "devcontainer-lock.json"), lock, 0o644) + } + return ws +} + +func runOutdatedFor(t *testing.T, opts outdatedOpts) (string, error) { + t.Helper() + if opts.logLevel == "" { + opts.logLevel = "info" + } + out := &captureOutput{} + err := runOutdated(t.Context(), out, opts) + return out.out.String(), err +} + +// outdated reports, for each versioned Feature, the newest version its tag +// wants, the newest published one, and the current one (the lockfile's when it +// pins one); Features with no published versions, and local ones, are left out. +func TestOutdatedReportsVersions(t *testing.T) { + reg := newReadRegistry(t) + reg.tags["ns/multi"] = []string{"1.0.0", "1.2.0", "2.0.0", "latest"} + reg.tags["ns/single"] = []string{"3.1.0"} + reg.tags["ns/blank"] = []string{"1.0.0"} + reg.tags["ns/none"] = []string{} + h := reg.host + features := `{"` + h + `/ns/multi:1":{}, "` + h + `/ns/single:3":{}, "` + h + `/ns/blank:1":{}, "` + h + `/ns/none:1":{}, "./local":{}}` + lock := `{"features":{"` + h + `/ns/multi:1":{"version":"1.0.0"},"` + h + `/ns/blank:1":{"version":""}}}` + ws := writeOutdatedWorkspace(t, features, lock) + + got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "json"}) + if err != nil { + t.Fatal(err) + } + var doc struct { + Features map[string]outdatedEntry `json:"features"` + } + if err := json.Unmarshal([]byte(got), &doc); err != nil { + t.Fatalf("not JSON: %v\n%s", err, got) + } + want := map[string]outdatedEntry{ + h + "/ns/multi:1": {Current: "1.0.0", Wanted: "1.2.0", WantedMajor: "1", Latest: "2.0.0", LatestMajor: "2"}, + h + "/ns/single:3": {Current: "3.1.0", Wanted: "3.1.0", WantedMajor: "3", Latest: "3.1.0", LatestMajor: "3"}, + h + "/ns/blank:1": {Current: "1.0.0", Wanted: "1.0.0", WantedMajor: "1", Latest: "1.0.0", LatestMajor: "1"}, + } + if len(doc.Features) != len(want) { + t.Errorf("features = %v, want %v", doc.Features, want) + } + for id, w := range want { + if doc.Features[id] != w { + t.Errorf("%s = %+v, want %+v", id, doc.Features[id], w) + } + } + + text, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "text"}) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n") + if len(lines) != 4 || !strings.HasPrefix(lines[0], "Feature") || !strings.HasPrefix(lines[1], h+"/ns/multi ") { + t.Errorf("text table:\n%s", text) + } +} + +// With no Features there is nothing to look up: an empty result in JSON, a +// sentence in text. +func TestOutdatedWithoutFeatures(t *testing.T) { + ws := writeOutdatedWorkspace(t, `{}`, "") + if got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "json"}); err != nil || got != "{\"features\":{}}\n" { + t.Errorf("json: err = %v, stdout = %q", err, got) + } + if got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "text"}); err != nil || got != "No features configured.\n" { + t.Errorf("text: err = %v, stdout = %q", err, got) + } +} + +// --config names the configuration to read, wherever it is; a configuration +// that cannot be loaded, or a log file that cannot be opened, fails the command. +func TestOutdatedConfigAndFailures(t *testing.T) { + reg := newReadRegistry(t) + reg.tags["ns/tool"] = []string{"1.0.0"} + ws := t.TempDir() + cfg := filepath.Join(t.TempDir(), "custom.json") + writeFile(t, cfg, `{"image":"alpine","features":{"`+reg.host+`/ns/tool:1":{}}}`, 0o644) + got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, configPath: cfg, outputFormat: "json"}) + if err != nil || !strings.Contains(got, reg.host+"/ns/tool:1") { + t.Errorf("--config: err = %v, stdout = %s", err, got) + } + + if _, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: t.TempDir(), outputFormat: "json"}); err == nil { + t.Error("missing configuration: want an error") + } + + _, err = runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, configPath: cfg, logFile: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "open log file") { + t.Errorf("log file is a directory: err = %v", err) + } +} + +func TestHighestSatisfyingTagSingleVersion(t *testing.T) { + v := []*semver.Version{semver.MustParse("1.4.0")} + if got := highestSatisfyingTag(v, "latest"); got != "1.4.0" { + t.Errorf("latest of one version = %q, want 1.4.0", got) + } +} + +// Keys of the file that the features map does not hold are not listed. +func TestOrderedFeatureKeysOnlyListsFeatures(t *testing.T) { + cfg := filepath.Join(t.TempDir(), "devcontainer.json") + if err := os.WriteFile(cfg, []byte(`{"features":{"b":{},"gone":{},"a":{}}}`), 0o644); err != nil { + t.Fatal(err) + } + got := orderedFeatureKeys(cfg, map[string]any{"a": nil, "b": nil}) + if want := []string{"b", "a"}; !slices.Equal(got, want) { + t.Errorf("keys = %q, want %q", got, want) + } +} diff --git a/internal/cli/parity_matrix_helpers_test.go b/internal/cli/parity_matrix_helpers_test.go index de3de49..c13ad8f 100644 --- a/internal/cli/parity_matrix_helpers_test.go +++ b/internal/cli/parity_matrix_helpers_test.go @@ -191,8 +191,8 @@ func TestInShardPartitions(t *testing.T) { const cases = 189 for _, total := range []int{1, 2, 3, 4, 7} { claimed := make([]int, cases) - for shard := 0; shard < total; shard++ { - for i := 0; i < cases; i++ { + for shard := range total { + for i := range cases { if inShard(i, shard, total) { claimed[i]++ } @@ -208,7 +208,7 @@ func TestInShardPartitions(t *testing.T) { // TestInShardNoShardingRunsEverything proves total<=1 disables sharding. func TestInShardNoShardingRunsEverything(t *testing.T) { - for i := 0; i < 10; i++ { + for i := range 10 { if !inShard(i, 0, 1) { t.Errorf("inShard(%d,0,1) = false, want every case to run when unsharded", i) } diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index e56410a..61c34d7 100644 --- a/internal/cli/parity_matrix_test.go +++ b/internal/cli/parity_matrix_test.go @@ -23,10 +23,12 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -220,7 +222,7 @@ func TestParityMatrix(t *testing.T) { } }) - cliTS := envOr("CLI_TS", "node "+filepath.Join(repoRoot, "reference", "devcontainer.js")) + cliTS := envOr("CLI_TS", defaultParityTSCLI(repoRoot)) cliGO := envOr("CLI_GO", filepath.Join(repoRoot, "devcontainer")) defaultTimeout := 60 * time.Second // Runtime lane default; overridable via PARITY_RUNTIME_TIMEOUT (e.g. "10m") @@ -267,7 +269,6 @@ func TestParityMatrix(t *testing.T) { selected := 0 for _, tc := range matrix.InitialCases { - tc := tc if !matchesFilter(tc) { continue } @@ -398,8 +399,10 @@ func TestParityMatrix(t *testing.T) { } } if asserts["stderr_normalized"] { - tsReason := extractErrorReason(tsRes.Stdout, tsRes.Stderr) - goReason := extractErrorReason(goRes.Stdout, goRes.Stderr) + // Normalized as stdout is: a reason that names a per-side temporary + // path (an output folder, a workspace) differs only by that path. + tsReason := normalizeStringOpts(extractErrorReason(tsRes.Stdout, tsRes.Stderr), scrubHashes) + goReason := normalizeStringOpts(extractErrorReason(goRes.Stdout, goRes.Stderr), scrubHashes) if tsReason != goReason { t.Errorf("error reason differs:\n TS: %s\n Go: %s", tsReason, goReason) } @@ -575,8 +578,18 @@ func runParityCLIOnce(ctx context.Context, repoRoot, cli, cmdArgs string, env ma return outBuf.String(), errBuf.String(), exitCode } +// runParitySetup runs a case's setup_cmd and returns the KEY=VALUE lines it +// prints. A setup that fails with a transient build error is re-run like the +// measured command (runWithInfraRetry): setups bring a container up, and two +// cases building Features without BuildKit at once race on the fixed +// dev_container_feature_content_temp image both CLIs build. func runParitySetup(ctx context.Context, repoRoot, setupCmd string, env map[string]string) (map[string]string, error) { - stdout, stderr, exitCode, err := runShellCommand(ctx, repoRoot, setupCmd, env) + var err error + stdout, stderr, exitCode := runWithInfraRetry(ctx, parityInfraRetries, func() (string, string, int) { + out, errOut, code, runErr := runShellCommand(ctx, repoRoot, setupCmd, env) + err = runErr + return out, errOut, code + }) if err != nil { return nil, err } @@ -585,7 +598,7 @@ func runParitySetup(ctx context.Context, repoRoot, setupCmd string, env map[stri } setupEnv := map[string]string{} - for _, line := range strings.Split(stdout, "\n") { + for line := range strings.SplitSeq(stdout, "\n") { line = strings.TrimSpace(line) if line == "" { continue @@ -714,10 +727,10 @@ func normalizeOutputOpts(raw string, scrubHashes, keepNulls bool) string { return normalizeStringOpts(raw, scrubHashes) } -func normalizeValueOpts(v interface{}, scrubHashes, keepNulls bool) interface{} { +func normalizeValueOpts(v any, scrubHashes, keepNulls bool) any { switch val := v.(type) { - case map[string]interface{}: - result := make(map[string]interface{}) + case map[string]any: + result := make(map[string]any) for k, v := range val { if v == nil { if keepNulls { @@ -734,8 +747,8 @@ func normalizeValueOpts(v interface{}, scrubHashes, keepNulls bool) interface{} result[k] = normalizeValueOpts(v, scrubHashes, keepNulls) } return sortedMap(result) - case []interface{}: - out := make([]interface{}, len(val)) + case []any: + out := make([]any, len(val)) for i, item := range val { out[i] = normalizeValueOpts(item, scrubHashes, keepNulls) } @@ -750,7 +763,7 @@ func normalizeValueOpts(v interface{}, scrubHashes, keepNulls bool) interface{} } } -func normalizeEmbeddedJSONOpts(raw string, scrubHashes, keepNulls bool) interface{} { +func normalizeEmbeddedJSONOpts(raw string, scrubHashes, keepNulls bool) any { raw = strings.TrimSpace(raw) if raw == "" { return "" @@ -761,7 +774,7 @@ func normalizeEmbeddedJSONOpts(raw string, scrubHashes, keepNulls bool) interfac return normalizeStringOpts(raw, scrubHashes) } -func parseEmbeddedJSON(raw string) (interface{}, bool) { +func parseEmbeddedJSON(raw string) (any, bool) { t := strings.TrimSpace(raw) // Only treat a string as an embedded JSON *document* when it is an object or // array. Parsing bare scalars ("123", "true", "1.0") would coerce a string @@ -775,10 +788,10 @@ func parseEmbeddedJSON(raw string) (interface{}, bool) { // decodeJSONNumber parses JSON with UseNumber so large/precise numbers survive // the round-trip instead of being coerced through float64. -func decodeJSONNumber(raw string) (interface{}, bool) { +func decodeJSONNumber(raw string) (any, bool) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() - var parsed interface{} + var parsed any if dec.Decode(&parsed) == nil { return parsed, true } @@ -786,14 +799,14 @@ func decodeJSONNumber(raw string) (interface{}, bool) { } // sortedMap returns a json.RawMessage with sorted keys. -func sortedMap(m map[string]interface{}) map[string]interface{} { +func sortedMap(m map[string]any) map[string]any { // json.Marshal already sorts keys in Go 1.12+ return m } func normalizeTextOpts(raw string, scrubHashes bool) string { var lines []string - for _, line := range strings.Split(raw, "\n") { + for line := range strings.SplitSeq(raw, "\n") { line = strings.TrimRight(line, "\r") // Strip noise lines @@ -834,7 +847,7 @@ func extractErrorReason(stdout, stderr string) string { text := stderr // Try JSON stdout for error outcome - var parsed map[string]interface{} + var parsed map[string]any jsonStdout := strings.TrimSpace(stdout) if candidate := extractJSONCandidate(jsonStdout); candidate != "" { jsonStdout = candidate @@ -891,8 +904,8 @@ func extractErrorReason(stdout, stderr string) string { // trace). This lets a TS "Error: X" + trace line up with a Go "X" message, // and a banner-only silent-exit line up with Go's empty stderr. lines := strings.Split(strings.TrimSpace(text), "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) + for _, line := range slices.Backward(lines) { + line := strings.TrimSpace(line) if line == "" || isBannerLine(line) || isStackFrame(line) { continue } @@ -1013,7 +1026,7 @@ func extractCLIResultEnv(stdout string) map[string]string { return nil } - var payload map[string]interface{} + var payload map[string]any if err := json.Unmarshal([]byte(candidate), &payload); err != nil { return nil } @@ -1030,7 +1043,7 @@ func extractCLIResultEnv(stdout string) map[string]string { if imageName != "" { env["IMAGE_NAME"] = imageName } - case []interface{}: + case []any: var names []string for i, item := range imageName { name, ok := item.(string) @@ -1063,7 +1076,7 @@ func parityEnv(caseID, side, repoRoot string, isolateCompose bool) map[string]st // no container → "Dev container not found"). Mirror the cliGO/cliTS defaults // (lines above) so setup and the measured command always agree. "PARITY_CLI_GO": envOr("CLI_GO", filepath.Join(repoRoot, "devcontainer")), - "PARITY_CLI_TS": envOr("CLI_TS", "node "+filepath.Join(repoRoot, "reference", "devcontainer.js")), + "PARITY_CLI_TS": envOr("CLI_TS", defaultParityTSCLI(repoRoot)), // Newer BuildKit attaches provenance/SBOM attestations by default, wrapping // the feature-content image in an attestation manifest list. That breaks the // TS CLI's `COPY --from=dev_containers_feature_content_source ...` feature @@ -1110,12 +1123,8 @@ func sanitizeEnvValue(value string) string { func mergeEnv(base, extra map[string]string) map[string]string { out := map[string]string{} - for k, v := range base { - out[k] = v - } - for k, v := range extra { - out[k] = v - } + maps.Copy(out, base) + maps.Copy(out, extra) return out } @@ -1148,6 +1157,20 @@ func findRepoRoot(t *testing.T) string { } } +// defaultParityTSCLI is the oracle's command line: the node on this process's +// PATH, by its absolute path, running reference/devcontainer.js. The cases run +// through `/bin/sh -lc`, and a login shell rebuilds PATH from the system +// profile: on a runner whose setup-node node is not the system one (the docker8 +// fleet's image has node 22 in /usr/local/bin) a bare `node` would then load the +// reference's native node-pty, built for the setup-node version, and fail. +func defaultParityTSCLI(repoRoot string) string { + node, err := exec.LookPath("node") + if err != nil { + node = "node" + } + return node + " " + filepath.Join(repoRoot, "reference", "devcontainer.js") +} + func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v @@ -1222,7 +1245,7 @@ func selected(value, filter string) bool { if filter == "" || filter == "all" { return true } - for _, f := range strings.Split(filter, ",") { + for f := range strings.SplitSeq(filter, ",") { if strings.TrimSpace(f) == value { return true } diff --git a/internal/cli/parity_setup_binary_test.go b/internal/cli/parity_setup_binary_test.go index 9394374..292492a 100644 --- a/internal/cli/parity_setup_binary_test.go +++ b/internal/cli/parity_setup_binary_test.go @@ -34,7 +34,8 @@ func TestParitySetupUsesLaneBinary(t *testing.T) { if got := env["PARITY_CLI_GO"]; got != filepath.Join("/repo", "devcontainer") { t.Errorf("PARITY_CLI_GO default = %q, want the repo-root binary", got) } - if got, want := env["PARITY_CLI_TS"], "node "+filepath.Join("/repo", "reference", "devcontainer.js"); got != want { + // The oracle's node is this process's, by absolute path (defaultParityTSCLI). + if got, want := env["PARITY_CLI_TS"], defaultParityTSCLI("/repo"); got != want { t.Errorf("PARITY_CLI_TS default = %q, want %q", got, want) } diff --git a/internal/cli/parity_setup_retry_test.go b/internal/cli/parity_setup_retry_test.go new file mode 100644 index 0000000..d538680 --- /dev/null +++ b/internal/cli/parity_setup_retry_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A setup_cmd that fails once with a transient build error is re-run and its +// output read; one that keeps failing still fails the case. +func TestParitySetupRetriesATransientBuildFailure(t *testing.T) { + marker := filepath.Join(t.TempDir(), "attempted") + setup := `if [ -e "` + marker + `" ]; then echo READY=yes; else touch "` + marker + `"; echo "ERROR: failed to solve: /tmp/build-features/hello_0: not found" >&2; exit 1; fi` + env, err := runParitySetup(t.Context(), t.TempDir(), setup, nil) + if err != nil { + t.Fatalf("setup after one transient failure: %v", err) + } + if env["READY"] != "yes" { + t.Errorf("setup env = %v, want READY=yes from the second attempt", env) + } + + if _, err := runParitySetup(t.Context(), t.TempDir(), `echo "ERROR: failed to solve: x" >&2; exit 1`, nil); err == nil || !strings.Contains(err.Error(), "exit=1") { + t.Errorf("a setup that keeps failing: err = %v, want the exit status", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/parity_sharding_workflow_test.go b/internal/cli/parity_sharding_workflow_test.go index cb556e6..7fc5214 100644 --- a/internal/cli/parity_sharding_workflow_test.go +++ b/internal/cli/parity_sharding_workflow_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -28,12 +29,7 @@ func (s *stringOrSlice) UnmarshalYAML(n *yaml.Node) error { } func (s stringOrSlice) contains(v string) bool { - for _, x := range s { - if x == v { - return true - } - } - return false + return slices.Contains(s, v) } type wfJob struct { diff --git a/internal/cli/parity_ts_cli_test.go b/internal/cli/parity_ts_cli_test.go new file mode 100644 index 0000000..dda1145 --- /dev/null +++ b/internal/cli/parity_ts_cli_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The oracle's node is the one on this process's PATH, named by its absolute +// path, so a login shell's PATH cannot swap it for another node. +func TestDefaultParityTSCLIUsesThisProcessNode(t *testing.T) { + dir := t.TempDir() + node := filepath.Join(dir, "node") + if err := os.WriteFile(node, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + got := defaultParityTSCLI("/repo") + if want := node + " " + filepath.Join("/repo", "reference", "devcontainer.js"); got != want { + t.Errorf("command = %q, want %q", got, want) + } + + t.Setenv("PATH", t.TempDir()) + if got := defaultParityTSCLI("/repo"); !strings.HasPrefix(got, "node ") { + t.Errorf("without node on PATH the command = %q, want a bare node for the shell to find", got) + } +} diff --git a/internal/cli/publish_parity_test.go b/internal/cli/publish_parity_test.go index cecae46..1ea5bbd 100644 --- a/internal/cli/publish_parity_test.go +++ b/internal/cli/publish_parity_test.go @@ -36,12 +36,10 @@ func TestPublishParity(t *testing.T) { ctx := t.Context() container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "registry:3", - ExposedPorts: []string{"5000/tcp"}, - WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp").WithStartupTimeout(60 * time.Second), - }, - Started: true, + Image: "registry:3", + ExposedPorts: []string{"5000/tcp"}, + WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp").WithStartupTimeout(60 * time.Second), + Started: true, }) if err != nil { t.Skipf("could not start registry container: %v", err) @@ -69,7 +67,6 @@ func TestPublishParity(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { tsNamespace := "tsns/" + tc.name goNamespace := "gons/" + tc.name @@ -94,7 +91,7 @@ func TestPublishParity(t *testing.T) { // Output parity alone is insufficient for a mutating command. Verify the // registry state for every item and the collection metadata artifact. - client := oci.NewClient(log.Null, osEnvMap()) + client := oci.NewClientWithAuthPolicy(log.Null, osEnvMap(), oci.DefaultAuthPolicy()) for _, id := range tc.itemIDs { tagsFor := func(namespace string) []string { ref, err := oci.ParseRef(registry + "/" + namespace + "/" + id) diff --git a/internal/cli/pure_helpers_test.go b/internal/cli/pure_helpers_test.go index 11fb35c..e8c0299 100644 --- a/internal/cli/pure_helpers_test.go +++ b/internal/cli/pure_helpers_test.go @@ -10,7 +10,6 @@ import ( "github.com/Masterminds/semver/v3" "github.com/devcontainers/cli/internal/config" - "github.com/devcontainers/cli/internal/doctor" "github.com/devcontainers/cli/internal/oci" ) @@ -133,7 +132,7 @@ func TestFolderImageName(t *testing.T) { } func TestEnvSliceToMap(t *testing.T) { - got := envSliceToMap([]string{ + got := config.EnvMap([]string{ "FOO=bar", "EMPTY=", "HAS=eq=in=value", @@ -297,10 +296,10 @@ func TestEncloseStringInBox(t *testing.T) { func TestWriteSuccessJSON(t *testing.T) { out := &captureOutput{} - if err := writeSuccessJSON(out, map[string]interface{}{"outcome": "success", "n": 2}); err != nil { + if err := writeSuccessJSON(out, map[string]any{"outcome": "success", "n": 2}); err != nil { t.Fatal(err) } - var got map[string]interface{} + var got map[string]any if err := json.Unmarshal(out.out.Bytes(), &got); err != nil { t.Fatalf("stdout not JSON: %v (%q)", err, out.out.String()) } @@ -311,7 +310,7 @@ func TestWriteSuccessJSON(t *testing.T) { func TestAddFeatureOption(t *testing.T) { // Existing options are preserved and the key is set to true. - got := addFeatureOption(map[string]interface{}{"version": "1"}, "gradle") + got := addFeatureOption(map[string]any{"version": "1"}, "gradle") if got["version"] != "1" || got["gradle"] != true { t.Errorf("addFeatureOption merge wrong: %v", got) } @@ -340,18 +339,6 @@ func TestOCIAnnotationID(t *testing.T) { } } -func TestAppliedAny(t *testing.T) { - if appliedAny(nil) { - t.Error("nil actions -> false") - } - if appliedAny([]doctor.Action{{Applied: false}, {Applied: false}}) { - t.Error("no applied action -> false") - } - if !appliedAny([]doctor.Action{{Applied: false}, {Applied: true}}) { - t.Error("one applied action -> true") - } -} - func TestFeatureKeyOrderFromFile(t *testing.T) { dir := t.TempDir() cfg := filepath.Join(dir, "devcontainer.json") @@ -386,7 +373,7 @@ func TestOrderedFeatureKeys_AppendsMissingSorted(t *testing.T) { // The map has one key NOT present in the file ("z"); it must be appended, // and the never-dropped invariant must hold. - feats := map[string]interface{}{"a": struct{}{}, "b": struct{}{}, "z": struct{}{}} + feats := map[string]any{"a": struct{}{}, "b": struct{}{}, "z": struct{}{}} got := orderedFeatureKeys(cfg, feats) if len(got) != 3 { t.Fatalf("want 3 keys, got %v", got) diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index 999b8a4..5226a6c 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -13,15 +13,23 @@ import ( coreerrors "github.com/devcontainers/cli/internal/errors" "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/log" + "github.com/moby/moby/api/types/container" "github.com/spf13/cobra" ) -// mergedPassthroughKeys are config-only properties spread into mergedConfiguration -// by the TS CLI that the typed MergedConfig does not model. They are copied from -// the raw config into the merged output. -var mergedPassthroughKeys = []string{ - "features", "build", "dockerComposeFile", "service", "workspaceFolder", - "name", "overrideFeatureInstallOrder", +// mergedOwnKeys are the properties TS mergeConfiguration sets itself, from the +// metadata, or removes from the config (replaceProperties) - in imageMetadata.ts. +// Every other property of the config passes into mergedConfiguration as it is +// ({...config}); one of these never does, even when the merge leaves it unset. +var mergedOwnKeys = map[string]bool{ + "customizations": true, "entrypoint": true, "onCreateCommand": true, "updateContentCommand": true, + "postCreateCommand": true, "postStartCommand": true, "postAttachCommand": true, "shutdownAction": true, + "init": true, "privileged": true, "capAdd": true, "securityOpt": true, "entrypoints": true, "mounts": true, + "onCreateCommands": true, "updateContentCommands": true, "postCreateCommands": true, + "postStartCommands": true, "postAttachCommands": true, "waitFor": true, "remoteUser": true, + "containerUser": true, "userEnvProbe": true, "remoteEnv": true, "containerEnv": true, + "overrideCommand": true, "portsAttributes": true, "otherPortsAttributes": true, "forwardPorts": true, + "updateRemoteUserUID": true, "hostRequirements": true, } type readConfigOpts struct { @@ -93,16 +101,11 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) return err } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", opts.logLevel, []string{"info", "debug", "trace"}}, - {"log-format", opts.logFormat, []string{"text", "json"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return err - } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + ); err != nil { + return err } if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { return err @@ -114,100 +117,21 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) } defer closeLog() - workspaceFolder := opts.workspaceFolder - // 0.88: default --workspace-folder to cwd when no --container-id/--id-label/--workspace-folder. - if workspaceFolder == "" && opts.containerID == "" && len(opts.idLabels) == 0 { - workspaceFolder, _ = os.Getwd() - } - - // Resolve to absolute path - if workspaceFolder != "" && !filepath.IsAbs(workspaceFolder) { - cwd, _ := os.Getwd() - workspaceFolder = filepath.Join(cwd, workspaceFolder) - } - - configPath := opts.configPath - if configPath != "" && !filepath.IsAbs(configPath) { - cwd, _ := os.Getwd() - configPath = filepath.Join(cwd, configPath) - } - - overridePath := opts.overrideConfig - if overridePath != "" && !filepath.IsAbs(overridePath) { - cwd, _ := os.Getwd() - overridePath = filepath.Join(cwd, overridePath) - } + cwd, _ := os.Getwd() + workspaceFolder, configPath, overridePath := readConfigurationPaths(opts, cwd) var result *config.LoadResult if workspaceFolder != "" || configPath != "" { var loadErr error - result, loadErr = config.LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overridePath, config.MountOptions{ - MountWorkspaceGitRoot: opts.mountWorkspaceGitRoot, - MountGitWorktreeCommonDir: opts.mountGitWorktreeCommonDir, - }) - if loadErr != nil { - // 0.88: read-configuration exits 1 silently (banner only) when no - // config is found — it does not print an error envelope. Parse/other - // errors still surface normally. - var notFound *config.NotFoundError - if errors.As(loadErr, ¬Found) { - return &coreerrors.ExitCodeError{Code: 1} - } + if result, loadErr = loadReadConfiguration(workspaceFolder, configPath, overridePath, opts); loadErr != nil { return loadErr } - - if _, err := mergeAdditionalFeatures(result.Config, opts.additionalFeatures); err != nil { - return fmt.Errorf("additional-features: %w", err) - } - - result.Config.Extensions = nil - result.Config.Settings = nil - result.Config.DevPort = nil } - // Build config map for output - var cfgMap map[string]interface{} - if result != nil { - cfgJSON, _ := json.Marshal(result.Config) - json.Unmarshal(cfgJSON, &cfgMap) - for k, v := range cfgMap { - if v == nil { - delete(cfgMap, k) - } - } - // Preserve unknown/vendor top-level properties that the typed struct drops: - // the TS config output keeps them. Legacy fields migrated - // into customizations (settings/extensions/devPort) stay removed. - for k, v := range result.Raw { - if _, known := cfgMap[k]; known { - continue - } - if k == "settings" || k == "extensions" || k == "devPort" { - continue - } - cfgMap[k] = v - } - // Emit configFilePath as VS Code URI object (matches TS output) - if cfp, ok := cfgMap["configFilePath"].(string); ok && cfp != "" { - scheme := "vscode-fileHost" - if opts.containerID != "" || opts.configPath != "" || opts.overrideConfig != "" { - scheme = "file" - } - cfgMap["configFilePath"] = map[string]interface{}{ - "$mid": 1, - "fsPath": cfp, - "path": cfp, - "scheme": scheme, - } - } else { - delete(cfgMap, "configFilePath") - } - } else { - cfgMap = map[string]interface{}{} // No config → empty object - } + cfgMap := configurationOutput(result, opts) // Find container for containerEnv substitution - lgr := log.New(log.Options{Version: cliVersion(), Level: log.ParseLevel(opts.logLevel), Format: opts.logFormat, Writer: logDst, Dimensions: logDimensions(opts.terminalColumns, opts.terminalRows)}) + lgr := log.New(log.Options{Version: cliVersion(), Level: log.ParseLevel(opts.logLevel), Format: opts.logFormat, Writer: logDst}) engine, engineErr := docker.NewEngineClient(lgr) if engineErr != nil { // Non-fatal: container lookup features won't work but config can still be read. @@ -217,32 +141,14 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) defer engine.Close() } - containerID := opts.containerID - if containerID == "" && engine != nil { - configFile := "" - if result != nil { - configFile = result.Config.ConfigFilePath - } - // [local_folder, config_file] so multiple configs in one project resolve - // to the right container (fallback to local_folder for legacy ones). - containerID = resolveContainerID(ctx, engine, workspaceFolder, configFile, opts.idLabels) - } - - if containerID != "" && engine != nil { - inspect, inspectErr := engine.InspectContainer(ctx, containerID) - if inspectErr == nil && inspect.Config != nil { - cEnv := envSliceToMap(inspect.Config.Env) - substituted, subErr := resolveContainerVariables(cEnv, cfgMap) - if subErr != nil { - return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr)) - } - if subMap, ok := substituted.(map[string]interface{}); ok { - cfgMap = subMap - } - } + containerID := readConfigurationContainerID(ctx, engine, opts, workspaceFolder, result) + containerCfg := inspectContainerConfig(ctx, engine, containerID) + cfgMap, subErr := substituteContainerEnv(containerCfg, cfgMap) + if subErr != nil { + return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr)) } - output := map[string]interface{}{ + output := map[string]any{ "configuration": cfgMap, } @@ -255,29 +161,25 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) output["cacheKey"] = key } - if result != nil && result.WorkspaceConfig != nil { - if workspaceFolder == "" && opts.containerID != "" { - // container-id without workspace-folder: emit empty workspace (matches TS) - output["workspace"] = map[string]interface{}{} - } else { - ws := map[string]interface{}{ - "workspaceFolder": result.WorkspaceConfig.WorkspaceFolder, - } - if result.WorkspaceConfig.WorkspaceMount != "" { - ws["workspaceMount"] = result.WorkspaceConfig.WorkspaceMount - } - output["workspace"] = ws - } + if ws, ok := workspaceOutput(result, workspaceFolder, opts.containerID); ok { + output["workspace"] = ws } // Include features configuration if requested (or needed for merged config without container) - needsFeaturesConfig := opts.includeFeaturesCfg || (opts.includeMergedCfg && containerID == "") - if needsFeaturesConfig && result != nil && len(result.Config.Features) > 0 { - lgr := log.New(log.Options{Level: log.ParseLevel(opts.logLevel), Format: opts.logFormat, Writer: logDst, Dimensions: logDimensions(opts.terminalColumns, opts.terminalRows)}) - featResult, featErr := fetchFeatureSets(ctx, lgr, nil, result.Config.Features, filepath.Dir(result.Config.ConfigFilePath), opts.skipFeatureAutoMapping, nil) - if featErr == nil && featResult != nil { + var featResult *fetchFeatureResult + if needsFeaturesConfiguration(opts, containerID, result) { + lgr := log.New(log.Options{Level: log.ParseLevel(opts.logLevel), Format: opts.logFormat, Writer: logDst}) + var featErr error + featResult, featErr = fetchFeatureSets(ctx, lgr, result.Config.Features, filepath.Dir(result.Config.ConfigFilePath), opts.skipFeatureAutoMapping) + if featErr != nil { + // TS readConfiguration writes the error to the log and exits 1 with no + // result; a Feature that cannot be resolved is not an empty one. + lgr.Write(featErr.Error(), log.LevelError) + return &coreerrors.ExitCodeError{Code: 1, Err: featErr} + } + if featResult != nil { defer os.RemoveAll(featResult.TmpDir) - output["featuresConfiguration"] = map[string]interface{}{ + output["featuresConfiguration"] = map[string]any{ "featureSets": featResult.FeatureSets, "dstFolder": featResult.TmpDir, } @@ -286,103 +188,263 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) // Include merged configuration if requested if opts.includeMergedCfg { - // Collect metadata entries: image labels + config as last entry - var entries []imagemeta.Entry - - // If we have a container, read image metadata labels - cid := containerID - if cid == "" && opts.containerID != "" { - cid = opts.containerID - } - if cid != "" && engine != nil { - inspect, insErr := engine.InspectContainer(ctx, cid) - if insErr == nil && inspect.Config != nil { - entries = imagemeta.ReadMetadataFromLabels(inspect.Config.Labels, lgr) - } - } else if result != nil && engine != nil { - // No container yet: derive the base metadata from the image and the - // features, like the TS getImageBuildInfo. Otherwise mergedConfiguration - // loses the image's remoteUser/lifecycle/customizations and the features' - // metadata before the container is created — VS Code calls - // this before creating the container. - if result.Config.Image != "" { - if img, imgErr := engine.InspectImage(ctx, result.Config.Image); imgErr == nil && img.Config != nil { - entries = append(entries, imagemeta.ReadMetadataFromLabels(img.Config.Labels, lgr)...) - } + var base []imagemeta.Entry + if containerID == "" { + // No container yet: derive the base metadata from the image (the base + // image of a Dockerfile, the compose service's image or build) and the + // features, like TS readConfiguration does with getImageBuildInfo and + // getDevcontainerMetadata(imageBuildInfo.metadata, config, + // featuresConfig). VS Code calls this before creating the container. + // The image is inspected locally, else in its registry, else pulled; a + // failure is an error, as in TS. The Features are the ones resolved for + // featuresConfiguration above, as in TS. + buildInfo, biErr := readConfigurationImageBuildInfo(ctx, lgr, engine, opts.dockerPath, result) + if biErr != nil { + lgr.Write(biErr.Error(), log.LevelError) + return &coreerrors.ExitCodeError{Code: 1, Err: biErr} } - if len(result.Config.Features) > 0 { - if fr, ferr := fetchFeatureSets(ctx, lgr, nil, result.Config.Features, filepath.Dir(result.Config.ConfigFilePath), opts.skipFeatureAutoMapping, nil); ferr == nil && fr != nil { - defer os.RemoveAll(fr.TmpDir) - for _, fs := range fr.FeatureSets { - entries = append(entries, featureMetadataEntry(fs, false)) - } + base = append(base, buildInfo.Metadata...) + if featResult != nil { + for _, fs := range featResult.FeatureSets { + base = append(base, featureMetadataEntry(fs, false)) } } } - - // Add the config itself as the last entry (highest priority) - if result != nil { - configEntry := configToMetadataEntry(result.Config) - entries = append(entries, configEntry) + merged, mergeErr := mergedConfigurationOutput(base, containerCfg, result, cfgMap, lgr) + if mergeErr != nil { + return mergeErr } + output["mergedConfiguration"] = merged + } + + // OCI auth diagnostics for this invocation (TS readConfiguration emits it + // unconditionally, even when no registry was contacted). + output["ociAuthDiagnostics"] = ociAuthDiagnostics(ctx) + + data, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("marshal output: %w", err) + } - merged := imagemeta.MergeConfiguration(entries) + fmt.Fprintln(out.Stdout(), string(data)) + return nil +} - // mergedConfiguration spreads the config (TS), so image comes from the - // config, not from the metadata entries (which no longer carry it). - if result != nil { - merged.Image = result.Config.Image +// readConfigurationPaths resolves --workspace-folder, --config and +// --override-config against cwd. Without --container-id, --id-label or +// --workspace-folder, the workspace folder is cwd (0.88). +func readConfigurationPaths(opts *readConfigOpts, cwd string) (workspaceFolder, configPath, overridePath string) { + abs := func(p string) string { + if p != "" && !filepath.IsAbs(p) { + return filepath.Join(cwd, p) } + return p + } + workspaceFolder = opts.workspaceFolder + if workspaceFolder == "" && opts.containerID == "" && len(opts.idLabels) == 0 { + workspaceFolder = cwd + } + return abs(workspaceFolder), abs(opts.configPath), abs(opts.overrideConfig) +} - // Copy configFilePath URI to mergedConfiguration (matches TS behavior) - if cfpURI, ok := cfgMap["configFilePath"]; ok { - merged.ConfigFilePath = cfpURI +// loadReadConfiguration loads the configuration read-configuration prints, +// with the --additional-features merged in. +func loadReadConfiguration(workspaceFolder, configPath, overridePath string, opts *readConfigOpts) (*config.LoadResult, error) { + result, err := config.LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overridePath, config.MountOptions{ + MountWorkspaceGitRoot: opts.mountWorkspaceGitRoot, + MountGitWorktreeCommonDir: opts.mountGitWorktreeCommonDir, + }) + if err != nil { + // 0.88: read-configuration exits 1 silently (banner only) when no + // config is found — it does not print an error envelope. Parse/other + // errors still surface normally. + // TS read-configuration exits 1 the same way on a config that is not a + // JSON object. + _, notFound := errors.AsType[*config.NotFoundError](err) + _, notObject := errors.AsType[*config.NotObjectError](err) + if notFound || notObject { + return nil, &coreerrors.ExitCodeError{Code: 1} } + return nil, err + } + if _, err := mergeAdditionalFeatures(result.Config, opts.additionalFeatures); err != nil { + return nil, fmt.Errorf("additional-features: %w", err) + } + return result, nil +} - if cid != "" && engine != nil { - inspect, insErr := engine.InspectContainer(ctx, cid) - if insErr == nil && inspect.Config != nil { - cEnv := envSliceToMap(inspect.Config.Env) - mergedJSON, _ := json.Marshal(merged) - var mergedGeneric interface{} - json.Unmarshal(mergedJSON, &mergedGeneric) - substituted, subErr := resolveContainerVariables(cEnv, mergedGeneric) - if subErr != nil { - return subErr - } - subJSON, _ := json.Marshal(substituted) - json.Unmarshal(subJSON, merged) - } +// configurationOutput is read-configuration's "configuration": the config as +// TS prints it, an empty object without one. Unknown and vendor properties the +// typed config drops are kept. The legacy settings, extensions and devPort are +// left out only where the loader migrated them into customizations: one it +// left in place, as TS updateFromOldProperties does, is printed. configFilePath is a +// VS Code URI, with the file scheme when the config was named on the command +// line or a container was. +func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[string]any { + cfgMap := map[string]any{} + if result == nil { + return cfgMap + } + cfgJSON, _ := json.Marshal(result.Config) + json.Unmarshal(cfgJSON, &cfgMap) + for k, v := range cfgMap { + if v == nil { + delete(cfgMap, k) } + } + for k, v := range result.Raw { + if _, known := cfgMap[k]; known { + continue + } + cfgMap[k] = v + } + scheme := "vscode-fileHost" + if opts.containerID != "" || opts.configPath != "" || opts.overrideConfig != "" { + scheme = "file" + } + cfp := result.Config.ConfigFilePath + cfgMap["configFilePath"] = map[string]any{ + "$mid": 1, + "fsPath": cfp, + "path": cfp, + "scheme": scheme, + } + return cfgMap +} + +// readConfigurationContainerID is the container read-configuration reads: the +// --container-id, else the one the id labels or the workspace folder and +// config file find. Without a Docker engine, only the --container-id. +func readConfigurationContainerID(ctx context.Context, engine *docker.EngineClient, opts *readConfigOpts, workspaceFolder string, result *config.LoadResult) string { + if opts.containerID != "" || engine == nil { + return opts.containerID + } + configFile := "" + if result != nil { + configFile = result.Config.ConfigFilePath + } + // [local_folder, config_file] so multiple configs in one project resolve + // to the right container (fallback to local_folder for legacy ones). + return resolveContainerID(ctx, engine, workspaceFolder, configFile, opts.idLabels) +} + +// inspectContainerConfig is the config of container cid, nil when there is no +// container to inspect or it cannot be inspected. +func inspectContainerConfig(ctx context.Context, engine *docker.EngineClient, cid string) *container.Config { + if cid == "" || engine == nil { + return nil + } + inspect, err := engine.InspectContainer(ctx, cid) + if err != nil { + return nil + } + return inspect.Config +} + +// substituteContainerEnv resolves ${containerEnv:...} in cfgMap with the +// container's environment; without a container cfgMap is returned as it is. +func substituteContainerEnv(containerCfg *container.Config, cfgMap map[string]any) (map[string]any, error) { + if containerCfg == nil { + return cfgMap, nil + } + substituted, err := resolveContainerVariables(config.EnvMap(containerCfg.Env), cfgMap) + if err != nil { + return nil, err + } + if subMap, ok := substituted.(map[string]any); ok { + return subMap, nil + } + return cfgMap, nil +} + +// workspaceOutput is read-configuration's "workspace"; ok is false when the +// configuration gives none. With --container-id and no workspace folder it is +// empty, as in TS. +func workspaceOutput(result *config.LoadResult, workspaceFolder, containerIDFlag string) (ws map[string]any, ok bool) { + if result == nil || result.WorkspaceConfig == nil { + return nil, false + } + if workspaceFolder == "" && containerIDFlag != "" { + return map[string]any{}, true + } + ws = map[string]any{ + "workspaceFolder": result.WorkspaceConfig.WorkspaceFolder, + } + if result.WorkspaceConfig.WorkspaceMount != "" { + ws["workspaceMount"] = result.WorkspaceConfig.WorkspaceMount + } + return ws, true +} + +// needsFeaturesConfiguration reports whether read-configuration resolves the +// config's Features: for featuresConfiguration, or for a merged configuration +// without a container, which takes the Features' metadata (TS readConfiguration). +func needsFeaturesConfiguration(opts *readConfigOpts, containerID string, result *config.LoadResult) bool { + wanted := opts.includeFeaturesCfg || (opts.includeMergedCfg && containerID == "") + return wanted && result != nil && len(result.Config.Features) > 0 +} + +// mergedConfigurationOutput is read-configuration's "mergedConfiguration". The +// metadata entries are the container's devcontainer.metadata label when there is +// a container, else base (the image's and the Features' metadata); the config is +// the last, highest-priority entry. With a container, ${containerEnv:...} is +// resolved with its environment. TS builds mergedConfiguration by spreading the +// config, so the properties mergeConfiguration does not own (image, features, +// build, configFilePath, ...) come from cfgMap. +func mergedConfigurationOutput(base []imagemeta.Entry, containerCfg *container.Config, result *config.LoadResult, cfgMap map[string]any, lgr log.Logger) (map[string]any, error) { + entries := base + if containerCfg != nil { + entries = imagemeta.ReadMetadataFromLabels(containerCfg.Labels, lgr) + } + if result != nil { + entries = append(entries, configToMetadataEntry(result.Config)) + } + merged := imagemeta.MergeConfiguration(entries) - // TS builds mergedConfiguration by spreading the config, so config-only - // properties that the typed MergedConfig does not model (features, build, - // service, workspaceFolder, …) must be carried over. + if containerCfg != nil { mergedJSON, _ := json.Marshal(merged) - var mergedMap map[string]interface{} - if json.Unmarshal(mergedJSON, &mergedMap) == nil { - for _, k := range mergedPassthroughKeys { - if v, ok := cfgMap[k]; ok { - mergedMap[k] = v - } - } - output["mergedConfiguration"] = mergedMap - } else { - output["mergedConfiguration"] = merged + var mergedGeneric any + json.Unmarshal(mergedJSON, &mergedGeneric) + substituted, err := resolveContainerVariables(config.EnvMap(containerCfg.Env), mergedGeneric) + if err != nil { + return nil, err } + subJSON, _ := json.Marshal(substituted) + json.Unmarshal(subJSON, merged) } - // OCI auth diagnostics for this invocation (TS readConfiguration emits it - // unconditionally, even when no registry was contacted). - output["ociAuthDiagnostics"] = ociAuthDiagnostics(ctx) - - data, err := json.Marshal(output) + mergedJSON, err := json.Marshal(merged) if err != nil { - return fmt.Errorf("marshal output: %w", err) + return nil, fmt.Errorf("marshal merged configuration: %w", err) + } + var mergedMap map[string]any + if err := json.Unmarshal(mergedJSON, &mergedMap); err != nil { + return nil, fmt.Errorf("read merged configuration: %w", err) } + for k, v := range cfgMap { + if !mergedOwnKeys[k] { + mergedMap[k] = v + } + } + return mergedMap, nil +} - fmt.Fprintln(out.Stdout(), string(data)) - return nil +// readConfigurationImageBuildInfo runs getImageBuildInfo for read-configuration's +// merged configuration without a container. Without a configuration TS reads an +// empty one, which names no image. +func readConfigurationImageBuildInfo(ctx context.Context, lgr log.Logger, engine *docker.EngineClient, dockerPath string, result *config.LoadResult) (*imageBuildInfo, error) { + cfg := &config.DevContainer{} + if result != nil { + cfg = result.Config + } + composeConfig := func(ctx context.Context, files []string, envFile string) (map[string]any, error) { + client, err := docker.NewComposeClient(dockerPath, "", nil, lgr) + if err != nil { + return nil, err + } + return client.Config(ctx, files, envFile) + } + return getImageBuildInfo(ctx, cfg, newImageInspector(ctx, lgr, engine), composeConfig, lgr) } // configToMetadataEntry converts a DevContainer to a metadata Entry @@ -417,14 +479,14 @@ func configMetadataEntry(cfg *config.DevContainer, omitRemoteEnv bool) imagemeta // (pickConfigProperties in TS). Omitting them made the devcontainer.metadata // label lossy vs the TS CLI. if len(cfg.ForwardPorts) > 0 { - fp := make([]interface{}, len(cfg.ForwardPorts)) + fp := make([]any, len(cfg.ForwardPorts)) for i, p := range cfg.ForwardPorts { - fp[i] = p + fp[i] = p.Value() } entry.ForwardPorts = fp } if len(cfg.PortsAttributes) > 0 { - pa := make(map[string]interface{}, len(cfg.PortsAttributes)) + pa := make(map[string]any, len(cfg.PortsAttributes)) for k, v := range cfg.PortsAttributes { pa[k] = v } @@ -457,5 +519,3 @@ func configMetadataEntry(cfg *config.DevContainer, omitRemoteEnv bool) imagemeta } return entry } - -// envSliceToMap moved to helpers.go diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go new file mode 100644 index 0000000..994934a --- /dev/null +++ b/internal/cli/read_configuration_held_test.go @@ -0,0 +1,542 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/moby/moby/api/types/container" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + coreerrors "github.com/devcontainers/cli/internal/errors" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" +) + +// loadFixtureConfig writes a workspace with .devcontainer/devcontainer.json +// (and the other files given) and loads it as read-configuration does. +func loadFixtureConfig(t *testing.T, devcontainerJSON string, files map[string]string) (string, *config.LoadResult) { + t.Helper() + all := map[string]string{".devcontainer/devcontainer.json": devcontainerJSON} + for k, v := range files { + all[k] = v + } + ws := writeConfigFixture(t, all) + result, err := loadReadConfiguration(ws, "", "", &readConfigOpts{}) + if err != nil { + t.Fatalf("load config: %v", err) + } + return ws, result +} + +func TestLoadReadConfiguration(t *testing.T) { + _, err := loadReadConfiguration(t.TempDir(), "", "", &readConfigOpts{}) + wantExitCode(t, err, 1) + + ws := writeConfigFixture(t, map[string]string{".devcontainer/devcontainer.json": `{ + "image": "img:1", + "extensions": ["ext.one"], + "settings": {"s": 1}, + "devPort": 3000 + }`}) + result, err := loadReadConfiguration(ws, "", "", &readConfigOpts{additionalFeatures: `{"ghcr.io/x/y:1": {}}`}) + if err != nil { + t.Fatal(err) + } + if c := result.Config; c.Extensions != nil || c.Settings != nil || c.DevPort != nil { + t.Errorf("legacy properties = %v, %v, %v; want them dropped", c.Extensions, c.Settings, c.DevPort) + } + if _, ok := result.Config.Features["ghcr.io/x/y:1"]; !ok { + t.Errorf("features = %v, want the additional Feature", result.Config.Features) + } + if _, err := loadReadConfiguration(ws, "", "", &readConfigOpts{additionalFeatures: `not json`}); err == nil || !strings.HasPrefix(err.Error(), "additional-features: ") { + t.Errorf("invalid additional features: err = %v", err) + } + broken := writeConfigFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image": `}) + if _, err := loadReadConfiguration(broken, "", "", &readConfigOpts{}); err == nil { + t.Error("unparseable config: want an error") + } else if _, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok { + t.Errorf("unparseable config: err = %v, want the parse error, not a silent exit", err) + } +} + +func TestReadConfigurationPaths(t *testing.T) { + const cwd = "/work" + for _, tc := range []struct { + name string + opts readConfigOpts + ws, config, overrid string + }{ + {"defaults to cwd", readConfigOpts{}, "/work", "", ""}, + {"relative paths", readConfigOpts{workspaceFolder: "ws", configPath: "c.json", overrideConfig: "o.json"}, "/work/ws", "/work/c.json", "/work/o.json"}, + {"absolute paths", readConfigOpts{workspaceFolder: "/ws", configPath: "/c.json", overrideConfig: "/o.json"}, "/ws", "/c.json", "/o.json"}, + {"container id, no workspace", readConfigOpts{containerID: "abc"}, "", "", ""}, + {"id label, no workspace", readConfigOpts{idLabels: []string{"a=b"}}, "", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + ws, cfg, override := readConfigurationPaths(&tc.opts, cwd) + if ws != tc.ws || cfg != tc.config || override != tc.overrid { + t.Fatalf("paths = %q, %q, %q; want %q, %q, %q", ws, cfg, override, tc.ws, tc.config, tc.overrid) + } + }) + } +} + +func TestConfigurationOutput(t *testing.T) { + if got := configurationOutput(nil, &readConfigOpts{}); len(got) != 0 || got == nil { + t.Fatalf("without a config: %v, want an empty object", got) + } + + ws, result := loadFixtureConfig(t, `{ + "name": "${localWorkspaceFolderBasename}", + "image": "img:1", + "x-vendor": {"a": 1}, + "extensions": ["ext.one"], + "settings": {"s": 1}, + "devPort": 3000 + }`, nil) + got := configurationOutput(result, &readConfigOpts{}) + if got["name"] != filepath.Base(ws) { + t.Errorf("name = %v, want the substituted %q", got["name"], filepath.Base(ws)) + } + if !reflect.DeepEqual(got["x-vendor"], map[string]any{"a": float64(1)}) { + t.Errorf("x-vendor = %v, want the unknown property kept", got["x-vendor"]) + } + for _, k := range []string{"extensions", "settings", "devPort", "onCreateCommand"} { + if v, ok := got[k]; ok { + t.Errorf("%s = %v, want it left out", k, v) + } + } + vscode, _ := got["customizations"].(map[string]any)["vscode"].(map[string]any) + if !reflect.DeepEqual(vscode["extensions"], []any{"ext.one"}) { + t.Errorf("customizations.vscode = %v, want the migrated extensions", vscode) + } + cfp := result.Config.ConfigFilePath + if want := map[string]any{"$mid": 1, "fsPath": cfp, "path": cfp, "scheme": "vscode-fileHost"}; !reflect.DeepEqual(got["configFilePath"], want) { + t.Errorf("configFilePath = %v, want %v", got["configFilePath"], want) + } + + for name, opts := range map[string]readConfigOpts{ + "--config": {configPath: cfp}, + "--override-config": {overrideConfig: cfp}, + "--container-id": {containerID: "abc"}, + } { + got := configurationOutput(result, &opts) + if scheme := got["configFilePath"].(map[string]any)["scheme"]; scheme != "file" { + t.Errorf("with %s: scheme = %v, want file", name, scheme) + } + } +} + +func TestReadConfigurationContainerID(t *testing.T) { + ctx := context.Background() + _, result := loadFixtureConfig(t, `{"image": "img"}`, nil) + ws := "/some/workspace" + configLabel := "devcontainer.config_file=" + result.Config.ConfigFilePath + api := &fakeEngineAPI{list: func(labels []string) []string { + if slices.Contains(labels, configLabel) { + return []string{"by-config"} + } + if slices.Contains(labels, "a=b") { + return []string{"by-label"} + } + return nil + }} + engine := &docker.EngineClient{API: api, Log: log.Null} + + if got := readConfigurationContainerID(ctx, engine, &readConfigOpts{}, ws, result); got != "by-config" { + t.Errorf("by workspace and config file = %q, want by-config", got) + } + if got := readConfigurationContainerID(ctx, engine, &readConfigOpts{idLabels: []string{"a=b"}}, "", nil); got != "by-label" { + t.Errorf("by id label = %q, want by-label", got) + } + api.listed = nil + if got := readConfigurationContainerID(ctx, engine, &readConfigOpts{}, ws, nil); got != "" { + t.Errorf("without a config = %q, want none", got) + } + if len(api.listed) != 1 || slices.ContainsFunc(api.listed[0], func(l string) bool { return strings.HasPrefix(l, "devcontainer.config_file=") }) { + t.Errorf("without a config, listed by %v; want the local folder alone", api.listed) + } + api.listed = nil + if got := readConfigurationContainerID(ctx, engine, &readConfigOpts{containerID: "given"}, ws, result); got != "given" || len(api.listed) != 0 { + t.Errorf("--container-id = %q (listed %v), want it as given without a lookup", got, api.listed) + } + if got := readConfigurationContainerID(ctx, nil, &readConfigOpts{}, ws, result); got != "" { + t.Errorf("without an engine = %q, want none", got) + } + if got := readConfigurationContainerID(ctx, nil, &readConfigOpts{containerID: "given"}, ws, result); got != "given" { + t.Errorf("--container-id without an engine = %q, want given", got) + } +} + +func TestInspectContainerConfig(t *testing.T) { + ctx := context.Background() + api := &fakeEngineAPI{containers: map[string]string{ + "c1": `{"Id":"c1","Config":{"Env":["A=b"]}}`, + "noconfig": `{"Id":"noconfig"}`, + }} + engine := &docker.EngineClient{API: api, Log: log.Null} + if got := inspectContainerConfig(ctx, engine, "c1"); got == nil || !slices.Equal(got.Env, []string{"A=b"}) { + t.Errorf("c1 = %+v, want its config", got) + } + for name, got := range map[string]*container.Config{ + "no container": inspectContainerConfig(ctx, engine, ""), + "no engine": inspectContainerConfig(ctx, nil, "c1"), + "missing container": inspectContainerConfig(ctx, engine, "missing"), + "container w/o config": inspectContainerConfig(ctx, engine, "noconfig"), + } { + if got != nil { + t.Errorf("%s = %+v, want nil", name, got) + } + } +} + +func TestSubstituteContainerEnv(t *testing.T) { + cfgMap := map[string]any{"remoteEnv": map[string]any{"P": "${containerEnv:PATH}"}} + got, err := substituteContainerEnv(nil, cfgMap) + if err != nil || !reflect.DeepEqual(got, cfgMap) { + t.Fatalf("without a container = %v, %v; want the config unchanged", got, err) + } + got, err = substituteContainerEnv(&container.Config{Env: []string{"PATH=/bin"}}, cfgMap) + if err != nil || !reflect.DeepEqual(got, map[string]any{"remoteEnv": map[string]any{"P": "/bin"}}) { + t.Fatalf("with a container = %v, %v; want ${containerEnv:PATH} resolved", got, err) + } +} + +func TestWorkspaceOutput(t *testing.T) { + withMount := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/p", WorkspaceMount: "type=bind,source=/p,target=/workspaces/p"}} + withoutMount := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/p"}} + for _, tc := range []struct { + name string + result *config.LoadResult + ws, container string + want map[string]any + ok bool + }{ + {"no config", nil, "/p", "", nil, false}, + {"no workspace config", &config.LoadResult{}, "/p", "", nil, false}, + {"folder and mount", withMount, "/p", "", map[string]any{"workspaceFolder": "/workspaces/p", "workspaceMount": "type=bind,source=/p,target=/workspaces/p"}, true}, + {"folder only", withoutMount, "/p", "", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, + {"container id without workspace folder", withMount, "", "abc", map[string]any{}, true}, + {"container id and workspace folder", withoutMount, "/p", "abc", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, + {"id label without workspace folder", withoutMount, "", "", map[string]any{"workspaceFolder": "/workspaces/p"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := workspaceOutput(tc.result, tc.ws, tc.container) + if ok != tc.ok || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("workspaceOutput = %v, %v; want %v, %v", got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestNeedsFeaturesConfiguration(t *testing.T) { + withFeatures := &config.LoadResult{Config: &config.DevContainer{Features: map[string]any{"./f": map[string]any{}}}} + noFeatures := &config.LoadResult{Config: &config.DevContainer{}} + for _, tc := range []struct { + name string + opts readConfigOpts + container string + result *config.LoadResult + want bool + }{ + {"features configuration", readConfigOpts{includeFeaturesCfg: true}, "", withFeatures, true}, + {"features configuration with a container", readConfigOpts{includeFeaturesCfg: true}, "c", withFeatures, true}, + {"merged without a container", readConfigOpts{includeMergedCfg: true}, "", withFeatures, true}, + {"merged with a container", readConfigOpts{includeMergedCfg: true}, "c", withFeatures, false}, + {"neither", readConfigOpts{}, "", withFeatures, false}, + {"no features", readConfigOpts{includeFeaturesCfg: true, includeMergedCfg: true}, "", noFeatures, false}, + {"no config", readConfigOpts{includeFeaturesCfg: true, includeMergedCfg: true}, "", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := needsFeaturesConfiguration(&tc.opts, tc.container, tc.result); got != tc.want { + t.Fatalf("needsFeaturesConfiguration = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMergedConfigurationOutput(t *testing.T) { + _, result := loadFixtureConfig(t, `{ + "image": "img:1", + "remoteUser": "config-user", + "remoteEnv": {"P": "${containerEnv:PATH}"}, + "x-vendor": true + }`, nil) + cfgMap := configurationOutput(result, &readConfigOpts{}) + base := []imagemeta.Entry{{RemoteUser: "image-user", ContainerUser: "image-container-user"}} + + t.Run("without a container", func(t *testing.T) { + got, err := mergedConfigurationOutput(base, nil, result, cfgMap, log.Null) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"remoteUser": "config-user", "containerUser": "image-container-user", "image": "img:1", "x-vendor": true, "configFilePath": cfgMap["configFilePath"]} + for k, v := range want { + if !reflect.DeepEqual(got[k], v) { + t.Errorf("%s = %v, want %v", k, got[k], v) + } + } + if env := got["remoteEnv"]; !reflect.DeepEqual(env, map[string]any{"P": "${containerEnv:PATH}"}) { + t.Errorf("remoteEnv = %v, want it unresolved without a container", env) + } + }) + + t.Run("with a container", func(t *testing.T) { + cc := &container.Config{ + Env: []string{"PATH=/container/bin"}, + Labels: map[string]string{"devcontainer.metadata": `[{"containerUser":"label-user","postStartCommand":"from-label"}]`}, + } + got, err := mergedConfigurationOutput(base, cc, result, cfgMap, log.Null) + if err != nil { + t.Fatal(err) + } + if got["containerUser"] != "label-user" || got["remoteUser"] != "config-user" { + t.Errorf("users = %v, %v; want the label's containerUser and the config's remoteUser", got["containerUser"], got["remoteUser"]) + } + if !reflect.DeepEqual(got["postStartCommands"], []any{"from-label"}) { + t.Errorf("postStartCommands = %v, want the label's", got["postStartCommands"]) + } + if env := got["remoteEnv"]; !reflect.DeepEqual(env, map[string]any{"P": "/container/bin"}) { + t.Errorf("remoteEnv = %v, want ${containerEnv:PATH} resolved with the container's env", env) + } + }) + + t.Run("without a config", func(t *testing.T) { + got, err := mergedConfigurationOutput(base, nil, nil, map[string]any{}, log.Null) + if err != nil || got["remoteUser"] != "image-user" { + t.Fatalf("got %v, %v; want the base entries alone", got, err) + } + }) +} + +func TestConfigMetadataEntry(t *testing.T) { + var cfg config.DevContainer + if err := json.Unmarshal([]byte(`{ + "portsAttributes": {"3000": {"label": "app"}}, + "otherPortsAttributes": {"onAutoForward": "ignore"}, + "hostRequirements": {"memory": "4gb"}, + "remoteEnv": {"R": "v"}, + "onCreateCommand": "on-create", + "updateContentCommand": ["update", "content"], + "postCreateCommand": "post-create", + "postStartCommand": "post-start", + "postAttachCommand": {"a": "post-attach"} + }`), &cfg); err != nil { + t.Fatal(err) + } + entryJSON := func(e imagemeta.Entry) map[string]any { + t.Helper() + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m + } + want := map[string]any{ + "portsAttributes": map[string]any{"3000": map[string]any{"label": "app"}}, + "otherPortsAttributes": map[string]any{"onAutoForward": "ignore"}, + "hostRequirements": map[string]any{"memory": "4gb"}, + "remoteEnv": map[string]any{"R": "v"}, + "onCreateCommand": "on-create", + "updateContentCommand": []any{"update", "content"}, + "postCreateCommand": "post-create", + "postStartCommand": "post-start", + "postAttachCommand": map[string]any{"a": "post-attach"}, + } + if got := entryJSON(configMetadataEntry(&cfg, false)); !reflect.DeepEqual(got, want) { + t.Errorf("entry = %v\nwant %v", got, want) + } + delete(want, "remoteEnv") + if got := entryJSON(configMetadataEntry(&cfg, true)); !reflect.DeepEqual(got, want) { + t.Errorf("entry omitting remoteEnv = %v\nwant %v", got, want) + } + if got := entryJSON(configMetadataEntry(&config.DevContainer{}, false)); len(got) != 0 { + t.Errorf("entry of an empty config = %v, want empty", got) + } +} + +// runReadConfigurationCmd runs read-configuration with args and returns its +// stdout parsed, or the command's error. +func runReadConfigurationCmd(t *testing.T, args ...string) (map[string]any, error) { + t.Helper() + var stdout bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"read-configuration"}, args...)) + if err := root.Execute(); err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + return out, nil +} + +func wantExitCode(t *testing.T, err error, code int) { + t.Helper() + exitErr, ok := errors.AsType[*coreerrors.ExitCodeError](err) + if !ok || exitErr.Code != code { + t.Fatalf("err = %v, want exit code %d", err, code) + } +} + +// localFeatureFiles is a local Feature at .devcontainer/feat whose metadata +// adds a capability. +var localFeatureFiles = map[string]string{ + ".devcontainer/feat/devcontainer-feature.json": `{"id": "feat", "version": "1.0.0", "capAdd": ["SYS_PTRACE"]}`, + ".devcontainer/feat/install.sh": "#!/bin/sh\n", +} + +func TestReadConfigurationCommandWithoutConfigExitsOne(t *testing.T) { + _, err := runReadConfigurationCmd(t, "--workspace-folder", t.TempDir()) + wantExitCode(t, err, 1) +} + +func TestReadConfigurationCommandContainerIDWithoutWorkspace(t *testing.T) { + // --container-id alone loads no configuration: there is no workspace folder + // or config file to read one from. + out, err := runReadConfigurationCmd(t, "--container-id", "devcontainer-cli-test-no-such-container") + if err != nil { + t.Fatal(err) + } + if cfg, ok := out["configuration"].(map[string]any); !ok || len(cfg) != 0 { + t.Errorf("configuration = %v, want an empty object", out["configuration"]) + } + if ws, ok := out["workspace"]; ok { + t.Errorf("workspace = %v, want none without a configuration", ws) + } +} + +func TestReadConfigurationCommandFeaturesAndCacheKey(t *testing.T) { + files := map[string]string{".devcontainer/devcontainer.json": `{"image": "img:1", "features": {"./feat": {}}}`} + for k, v := range localFeatureFiles { + files[k] = v + } + ws := writeConfigFixture(t, files) + + out, err := runReadConfigurationCmd(t, "--workspace-folder", ws, "--include-features-configuration", "--cache-key") + if err != nil { + t.Fatal(err) + } + if cfg, _ := out["configuration"].(map[string]any); cfg["image"] != "img:1" { + t.Errorf("configuration = %v", out["configuration"]) + } + if key, _ := out["cacheKey"].(string); key == "" { + t.Errorf("cacheKey = %v, want a key with --cache-key", out["cacheKey"]) + } + fc, _ := out["featuresConfiguration"].(map[string]any) + if sets, _ := fc["featureSets"].([]any); len(sets) != 1 { + t.Errorf("featuresConfiguration = %v, want the local Feature's set", out["featuresConfiguration"]) + } + if wsOut, _ := out["workspace"].(map[string]any); wsOut["workspaceFolder"] == nil { + t.Errorf("workspace = %v, want the workspace folder", out["workspace"]) + } + + out, err = runReadConfigurationCmd(t, "--workspace-folder", ws) + if err != nil { + t.Fatal(err) + } + for _, k := range []string{"cacheKey", "featuresConfiguration", "mergedConfiguration"} { + if v, ok := out[k]; ok { + t.Errorf("%s = %v without its flag, want it left out", k, v) + } + } +} + +func TestReadConfigurationCommandUnresolvableFeatureExitsOne(t *testing.T) { + ws := writeConfigFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image": "img:1", "features": {"./missing": {}}}`}) + _, err := runReadConfigurationCmd(t, "--workspace-folder", ws, "--include-features-configuration") + wantExitCode(t, err, 1) +} + +func TestReadConfigurationCommandMergedWithoutContainer(t *testing.T) { + // A Dockerfile without FROM names no base image, so nothing is inspected: + // the merged configuration is the Features' metadata and the config's. + files := map[string]string{ + ".devcontainer/devcontainer.json": `{"build": {"dockerfile": "Dockerfile"}, "remoteUser": "me", "features": {"./feat": {}}}`, + ".devcontainer/Dockerfile": "ARG X=1\n", + } + for k, v := range localFeatureFiles { + files[k] = v + } + ws := writeConfigFixture(t, files) + out, err := runReadConfigurationCmd(t, "--workspace-folder", ws, "--include-merged-configuration") + if err != nil { + t.Fatal(err) + } + merged, _ := out["mergedConfiguration"].(map[string]any) + if merged["remoteUser"] != "me" { + t.Errorf("mergedConfiguration.remoteUser = %v, want the config's", merged["remoteUser"]) + } + if !reflect.DeepEqual(merged["capAdd"], []any{"SYS_PTRACE"}) { + t.Errorf("mergedConfiguration.capAdd = %v, want the Feature's", merged["capAdd"]) + } + if _, ok := out["featuresConfiguration"]; !ok { + t.Errorf("featuresConfiguration missing; the merged configuration without a container resolves the Features") + } +} + +func TestReadConfigurationCommandMergedWithoutImageExitsOne(t *testing.T) { + ws := writeConfigFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"name": "no image"}`}) + _, err := runReadConfigurationCmd(t, "--workspace-folder", ws, "--include-merged-configuration") + wantExitCode(t, err, 1) + if !strings.Contains(err.Error(), "No image information specified in devcontainer.json.") { + t.Errorf("err = %v, want the missing image named", err) + } +} + +func TestReadConfigurationCommandWithoutDockerEngine(t *testing.T) { + // Without a Docker engine the configuration is still read, with a warning. + t.Setenv("DOCKER_HOST", "not a docker host") + ws := writeConfigFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image": "img:1"}`}) + logFile := filepath.Join(t.TempDir(), "log.txt") + out, err := runReadConfigurationCmd(t, "--workspace-folder", ws, "--log-file", logFile) + if err != nil { + t.Fatal(err) + } + if cfg, _ := out["configuration"].(map[string]any); cfg["image"] != "img:1" { + t.Errorf("configuration = %v", out["configuration"]) + } + logged, err := os.ReadFile(logFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logged), "Warning: Docker engine:") { + t.Errorf("log = %q, want the Docker engine warning", logged) + } +} + +func TestReadConfigurationImageBuildInfo(t *testing.T) { + ctx := context.Background() + if _, err := readConfigurationImageBuildInfo(ctx, log.Null, nil, "", nil); err == nil || err.Error() != "No image information specified in devcontainer.json." { + t.Errorf("without a config: err = %v, want the missing image", err) + } + + _, noFrom := loadFixtureConfig(t, `{"build": {"dockerfile": "Dockerfile"}}`, map[string]string{".devcontainer/Dockerfile": "ARG X=1\n"}) + info, err := readConfigurationImageBuildInfo(ctx, log.Null, nil, "", noFrom) + if err != nil || info.User != "root" || len(info.Metadata) != 0 { + t.Errorf("Dockerfile without a base image: %+v, %v; want user root and no metadata", info, err) + } + + _, compose := loadFixtureConfig(t, `{"dockerComposeFile": "compose.yml", "service": "app"}`, map[string]string{".devcontainer/compose.yml": "services: {app: {image: img}}\n"}) + _, err = readConfigurationImageBuildInfo(ctx, log.Null, nil, filepath.Join(t.TempDir(), "no-docker"), compose) + if err == nil || !strings.Contains(err.Error(), "'docker compose' (v2) not found") { + t.Errorf("compose without docker compose: err = %v, want it named", err) + } +} diff --git a/internal/cli/read_configuration_nodocker_held_test.go b/internal/cli/read_configuration_nodocker_held_test.go new file mode 100644 index 0000000..9df1f71 --- /dev/null +++ b/internal/cli/read_configuration_nodocker_held_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/devcontainers/cli/internal/config" +) + +// A legacy property TS updateFromOldProperties leaves in place stays in the +// configuration read-configuration prints: a devPort whose +// customizations.vscode already has one, and a falsy settings. The TS CLI +// prints both (reference read-configuration of this config). A null devPort +// moves into customizations.vscode when that has none, and otherwise stays at +// the top level and is printed as null, as TS prints it. +func TestConfigurationOutputKeepsUnmigratedLegacyProperties(t *testing.T) { + _, result := loadFixtureConfig(t, `{ + "image": "img:1", + "devPort": 3000, + "settings": false, + "customizations": {"vscode": {"devPort": 1}} + }`, nil) + got := configurationOutput(result, &readConfigOpts{}) + if got["devPort"] != float64(3000) || got["settings"] != false { + t.Errorf("devPort = %v, settings = %v; want 3000 and false kept", got["devPort"], got["settings"]) + } + if vscode := got["customizations"].(map[string]any)["vscode"]; !reflect.DeepEqual(vscode, map[string]any{"devPort": float64(1)}) { + t.Errorf("customizations.vscode = %v, want its own devPort", vscode) + } + + _, result = loadFixtureConfig(t, `{"image": "img:1", "devPort": null, "x-null": null}`, nil) + got = configurationOutput(result, &readConfigOpts{}) + if v, ok := got["devPort"]; ok { + t.Errorf("devPort = %v, want a null devPort left out", v) + } + if v, ok := got["x-null"]; !ok || v != nil { + t.Errorf("x-null = %v (%v), want an unknown null property kept", v, ok) + } + + _, result = loadFixtureConfig(t, `{"image": "img:1", "devPort": null, "customizations": {"vscode": {"devPort": 1}}}`, nil) + got = configurationOutput(result, &readConfigOpts{}) + if v, ok := got["devPort"]; !ok || v != nil { + t.Errorf("devPort = %v (%v), want null kept beside customizations.vscode's own", v, ok) + } +} + +// The config's forwardPorts are part of its metadata entry, a single one too. +func TestConfigMetadataEntryForwardsASinglePort(t *testing.T) { + var cfg config.DevContainer + if err := json.Unmarshal([]byte(`{"forwardPorts": [3000]}`), &cfg); err != nil { + t.Fatal(err) + } + b, err := json.Marshal(configMetadataEntry(&cfg, false)) + if err != nil { + t.Fatal(err) + } + if want := `{"forwardPorts":[3000]}`; string(b) != want { + t.Errorf("entry = %s, want %s", b, want) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f9bf8b1..08c396f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -71,14 +71,18 @@ func NewRootCommand() *cobra.Command { // Execute runs the CLI. The command tree is driven by a context that is // cancelled on SIGINT/SIGTERM so an in-flight `up`/`build` can unwind cleanly // on Ctrl-C instead of being killed mid-operation. +// +// Its exit status is held by the parity lane, which runs the built binary: +// mutate-lane: TestParityMatrix (exec.workspace-image-nonzero-exit passes an +// ExitCodeError's code through as 7, exec.container-not-found prints the error +// and exits 1, and every success case exits 0). func Execute() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() root := NewRootCommand() if err := root.ExecuteContext(ctx); err != nil { - var exitErr *coreerrors.ExitCodeError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*coreerrors.ExitCodeError](err); ok { os.Exit(exitErr.Code) } fmt.Fprintln(os.Stderr, err) diff --git a/internal/cli/run_user_commands.go b/internal/cli/run_user_commands.go index 9915403..2813947 100644 --- a/internal/cli/run_user_commands.go +++ b/internal/cli/run_user_commands.go @@ -2,10 +2,8 @@ package cli import ( "context" - "errors" "fmt" "os" - "path/filepath" "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/docker" @@ -89,29 +87,7 @@ func newRunUserCommandsCmd() *cobra.Command { } func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) error { - if err := validateIDLabels(opts.idLabels); err != nil { - return writeValidationError(out, err.Error()) - } - if err := validateRemoteEnvs(opts.remoteEnvs); err != nil { - return writeValidationError(out, err.Error()) - } - // Default --workspace-folder to cwd when no --container-id/--id-label/--workspace-folder. - if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" { - opts.workspaceFolder, _ = os.Getwd() - } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", opts.logLevel, []string{"info", "debug", "trace"}}, - {"log-format", opts.logFormat, []string{"text", "json"}}, - {"default-user-env-probe", opts.defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return writeValidationError(out, err.Error()) - } - } - if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { + if err := prepareRunUserCommandsOpts(opts); err != nil { return writeValidationError(out, err.Error()) } @@ -122,23 +98,18 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) defer closeLog() logger := log.New(log.Options{ - Version: cliVersion(), - Level: log.ParseLevel(opts.logLevel), - Format: opts.logFormat, - Writer: logDst, - Dimensions: logDimensions(opts.terminalColumns, opts.terminalRows), - Secrets: secretValuesFromFile(opts.secretsFile), + Version: cliVersion(), + Level: log.ParseLevel(opts.logLevel), + Format: opts.logFormat, + Writer: logDst, + Secrets: secretValuesFromFile(opts.secretsFile), }) - var ( - loadResult *config.LoadResult - loadErr error - ) - // Try to load config first — provides better error messages + var loadResult *config.LoadResult if opts.workspaceFolder != "" { - ws := resolvePath(opts.workspaceFolder) - loadResult, loadErr = config.LoadDevContainerConfig(ws, opts.configPath, opts.overrideConfig) + var loadErr error + loadResult, loadErr = config.LoadDevContainerConfig(resolvePath(opts.workspaceFolder), opts.configPath, opts.overrideConfig) if loadErr != nil { return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{ Description: loadErr.Error(), @@ -146,49 +117,62 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) } } + return runUserCommandsInContainer(ctx, out, opts, logger, loadResult) +} + +// prepareRunUserCommandsOpts validates the run-user-commands flags and defaults +// --workspace-folder to the current directory when neither it, --container-id +// nor --id-label says which container to use. +func prepareRunUserCommandsOpts(opts *runUserCommandsOpts) error { + if err := validateIDLabels(opts.idLabels); err != nil { + return err + } + if err := validateRemoteEnvs(opts.remoteEnvs); err != nil { + return err + } + if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" { + opts.workspaceFolder, _ = os.Getwd() + } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + enumFlag{"default-user-env-probe", opts.defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, + ); err != nil { + return err + } + return validateTerminalImplications(opts.terminalColumns, opts.terminalRows) +} + +// runUserCommandsInContainer finds the dev container and runs the lifecycle +// commands in it. loadResult is the config --workspace-folder loaded, or nil. +// +// mutate-lane: TestParityMatrix (run-user-commands.container-config-success, +// run-user-commands.workspace-image-success, +// run-user-commands.skip-post-attach-success, +// run-user-commands.workspace-secrets-success, +// run-user-commands.workspace-subfolder-config-success, +// run-user-commands.container-metadata-success, +// run-user-commands.hook-failure-error). +func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCommandsOpts, logger log.Logger, loadResult *config.LoadResult) error { engine, err := docker.NewEngineClient(logger) if err != nil { return writeErrorResult(out, fmt.Sprintf("Docker engine: %v", err)) } defer engine.Close() - // Find container containerID := opts.containerID if containerID == "" { - labels := opts.idLabels - if len(labels) == 0 && opts.workspaceFolder != "" { - labels = []string{fmt.Sprintf("devcontainer.local_folder=%s", resolvePath(opts.workspaceFolder))} - } - if len(labels) > 0 { - ids, _ := engine.ListContainers(ctx, true, labels) - if len(ids) > 0 { - containerID = ids[0] - } - } + containerID = resolveContainerID(ctx, engine, opts.workspaceFolder, "", opts.idLabels) } - if containerID == "" { return writeErrorResult(out, "Dev container not found.") } - // Load config from --workspace-folder or --config var cfg *config.DevContainer if loadResult != nil { cfg = loadResult.Config - } else if opts.workspaceFolder != "" || opts.configPath != "" { - ws := "." - if opts.workspaceFolder != "" { - ws = resolvePath(opts.workspaceFolder) - } - cp := opts.configPath - if cp != "" && !filepath.IsAbs(cp) { - cwd, _ := os.Getwd() - cp = filepath.Join(cwd, cp) - } - loadResult, loadErr := config.LoadDevContainerConfig(ws, cp, opts.overrideConfig) - if loadErr == nil { - cfg = loadResult.Config - } + } else { + cfg = configFromFlag(opts.configPath, opts.overrideConfig) } // Read metadata from container @@ -196,7 +180,6 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) if err != nil { return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) } - // Build merged config. With a config loaded, read the image's metadata // (features are baked into the image) + the config entry — reading the // container label (which now carries the config metadata) and re-adding the @@ -215,48 +198,11 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) } merged := imagemeta.MergeConfiguration(allEntries) - // Resolve remoteUser (metadata, overridden by config) so the shell server and - // userEnvProbe run as that user, matching the TS CLI. - remoteUser := merged.RemoteUser - if cfg != nil && cfg.RemoteUser != "" { - remoteUser = cfg.RemoteUser - } - - // Build merged remote env: probe → secrets → CLI --remote-env → config remoteEnv - probeStrategy := lifecycle.UserEnvProbeStrategy(opts.defaultUserEnvProbe) - if cfg != nil && cfg.UserEnvProbe != "" { - probeStrategy = lifecycle.UserEnvProbeStrategy(cfg.UserEnvProbe) - } + remoteUser := lifecycleRemoteUser(merged, cfg) + probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, remoteUser, lifecycleUserEnvProbe(opts.defaultUserEnvProbe, cfg), opts.containerSessionDataFolder) + remoteEnv := lifecycleRemoteEnv(probed, buildSecretsFromFile(opts.secretsFile), opts.remoteEnvs, cfg) - var mergedRemoteEnv []string - if probeStrategy != lifecycle.ProbeNone { - probeServer, probeErr := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger) - if probeErr == nil { - probedEnv, _ := lifecycle.ProbeRemoteEnv(logger, probeServer, probeStrategy, remoteUser, opts.containerSessionDataFolder) - probeServer.Close() - for k, v := range probedEnv { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, v)) - } - } - } - if opts.secretsFile != "" { - if secretEnvs, secErr := readSecretsFile(opts.secretsFile); secErr == nil { - mergedRemoteEnv = append(mergedRemoteEnv, secretEnvs...) - } - } - mergedRemoteEnv = append(mergedRemoteEnv, opts.remoteEnvs...) - if cfg != nil { - for k, v := range cfg.RemoteEnv { - if v != nil { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, *v)) - } - } - } - - shellServer, err := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger, mergedRemoteEnv...) - if err != nil { - return writeErrorResult(out, fmt.Sprintf("Failed to start shell server: %v", err)) - } + shellServer := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, remoteEnv...) defer shellServer.Close() workDir := "" @@ -265,40 +211,79 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) } executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger, WorkDir: workDir} - // Install dotfiles - if opts.dotfilesRepo != "" { - _ = lifecycle.InstallDotfiles(logger, executor, lifecycle.DotfilesConfig{ - Repository: opts.dotfilesRepo, - InstallCommand: opts.dotfilesCommand, - TargetPath: opts.dotfilesTarget, - }) - } - - // Run lifecycle hooks + applyLifecycleMarkers(shellServer, merged, inspect, opts.prebuild) hookErr := lifecycle.RunHooks(logger, executor, merged, lifecycle.RunOptions{ SkipNonBlocking: opts.skipNonBlocking, SkipPostAttach: opts.skipPostAttach, Prebuild: opts.prebuild, + AfterPostCreate: dotfilesAfterPostCreate(logger, executor, lifecycle.DotfilesConfig{ + Repository: opts.dotfilesRepo, + InstallCommand: opts.dotfilesCommand, + TargetPath: opts.dotfilesTarget, + }), }) if hookErr != nil { - var hook *lifecycle.HookError - var cmdErr *lifecycle.CommandError - if errors.As(hookErr, &hook) && errors.As(hookErr, &cmdErr) { - description := fmt.Sprintf("%s from devcontainer.json failed.", hook.Phase) - return writeErrorJSON(out, coreerrors.ErrorOutput{ - Outcome: "error", - Message: cmdErr.Error(), - Description: description, - }) - } - return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{ - Description: fmt.Sprintf("An error occurred running user commands: %v", hookErr), - })) + // TS run-user-commands reports only the message and description. + return writeErrorJSON(out, coreerrors.ToErrorOutput(hookContainerError(hookErr, "", "An error occurred running user commands in the container."))) } - return writeSuccessJSON(out, map[string]interface{}{ + return writeSuccessJSON(out, map[string]any{ "outcome": "success", "result": "done", }) } + +// configFromFlag is the config --config names when no --workspace-folder loaded +// one (the --container-id path); a relative path is taken from the current +// directory. A config that does not load leaves the container's metadata as the +// only source, so it yields nil. +func configFromFlag(configPath, overrideConfig string) *config.DevContainer { + if configPath == "" { + return nil + } + loadResult, err := config.LoadDevContainerConfig(".", resolvePath(configPath), overrideConfig) + if err != nil { + return nil + } + return loadResult.Config +} + +// lifecycleRemoteUser is the user the shell server and userEnvProbe run as: the +// metadata's remoteUser, overridden by the config's, matching the TS CLI. +func lifecycleRemoteUser(merged *imagemeta.MergedConfig, cfg *config.DevContainer) string { + if cfg != nil && cfg.RemoteUser != "" { + return cfg.RemoteUser + } + return merged.RemoteUser +} + +// lifecycleUserEnvProbe is the config's userEnvProbe, or the +// --default-user-env-probe value when the config sets none. +func lifecycleUserEnvProbe(defaultProbe string, cfg *config.DevContainer) lifecycle.UserEnvProbeStrategy { + if cfg != nil && cfg.UserEnvProbe != "" { + return lifecycle.UserEnvProbeStrategy(cfg.UserEnvProbe) + } + return lifecycle.UserEnvProbeStrategy(defaultProbe) +} + +// lifecycleRemoteEnv is the KEY=VALUE environment the lifecycle commands get, in +// the order later entries win: the probed environment, the --secrets-file +// secrets, --remote-env, then the config's remoteEnv, whose null values are left +// out. +func lifecycleRemoteEnv(probed map[string]string, secrets, remoteEnvs []string, cfg *config.DevContainer) []string { + var env []string + for k, v := range probed { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + env = append(env, secrets...) + env = append(env, remoteEnvs...) + if cfg != nil { + for k, v := range cfg.RemoteEnv { + if v != nil { + env = append(env, fmt.Sprintf("%s=%s", k, *v)) + } + } + } + return env +} diff --git a/internal/cli/run_user_commands_held_test.go b/internal/cli/run_user_commands_held_test.go new file mode 100644 index 0000000..8d6620b --- /dev/null +++ b/internal/cli/run_user_commands_held_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/lifecycle" +) + +func validRunUserCommandsOpts() *runUserCommandsOpts { + return &runUserCommandsOpts{ + workspaceFolder: "/ws", + logLevel: "info", + logFormat: "text", + defaultUserEnvProbe: "loginInteractiveShell", + } +} + +func TestPrepareRunUserCommandsOptsRejectsInvalidFlags(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*runUserCommandsOpts) + error string + }{ + {"id-label", func(o *runUserCommandsOpts) { o.idLabels = []string{"broken"} }, "id-label must match"}, + {"remote-env", func(o *runUserCommandsOpts) { o.remoteEnvs = []string{"broken"} }, "remote-env must match"}, + {"default-user-env-probe", func(o *runUserCommandsOpts) { o.defaultUserEnvProbe = "broken" }, "default-user-env-probe"}, + {"terminal-columns", func(o *runUserCommandsOpts) { o.terminalColumns = 80 }, "terminal-columns -> terminal-rows"}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := validRunUserCommandsOpts() + tc.edit(opts) + if err := prepareRunUserCommandsOpts(opts); err == nil || !strings.Contains(err.Error(), tc.error) { + t.Errorf("error = %v, want one containing %q", err, tc.error) + } + }) + } + if err := prepareRunUserCommandsOpts(validRunUserCommandsOpts()); err != nil { + t.Errorf("valid flags: %v", err) + } +} + +// Without --workspace-folder, --container-id or --id-label the workspace is the +// current directory; any of them says which container to use instead. +func TestPrepareRunUserCommandsOptsDefaultsTheWorkspace(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + edit func(*runUserCommandsOpts) + want string + }{ + {"nothing given", func(*runUserCommandsOpts) {}, cwd}, + {"workspace folder", func(o *runUserCommandsOpts) { o.workspaceFolder = "/given" }, "/given"}, + {"container id", func(o *runUserCommandsOpts) { o.containerID = "abc" }, ""}, + {"id label", func(o *runUserCommandsOpts) { o.idLabels = []string{"a=b"} }, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := validRunUserCommandsOpts() + opts.workspaceFolder = "" + tc.edit(opts) + if err := prepareRunUserCommandsOpts(opts); err != nil { + t.Fatal(err) + } + if opts.workspaceFolder != tc.want { + t.Errorf("workspaceFolder = %q, want %q", opts.workspaceFolder, tc.want) + } + }) + } +} + +func TestRunUserCommands_InvalidFlag_PreDocker(t *testing.T) { + opts := validRunUserCommandsOpts() + opts.remoteEnvs = []string{"broken"} + out := &captureOutput{} + ece := asExitCode(t, runUserCommands(t.Context(), out, opts)) + if ece.Code != 1 { + t.Errorf("exit code = %d, want 1", ece.Code) + } + if !strings.Contains(out.err.String(), "remote-env must match") { + t.Errorf("stderr = %q, want the --remote-env message", out.err.String()) + } + if out.out.Len() != 0 { + t.Errorf("stdout = %q, want empty on a validation error", out.out.String()) + } +} + +func TestRunUserCommands_ConfigLoadError_PreDocker(t *testing.T) { + opts := validRunUserCommandsOpts() + opts.workspaceFolder = t.TempDir() // no devcontainer.json + out := &captureOutput{} + err := runUserCommands(t.Context(), out, opts) + ece := asExitCode(t, err) + if ece.Code != 1 { + t.Errorf("exit code = %d, want 1", ece.Code) + } + if !strings.Contains(out.out.String(), `"outcome":"error"`) || !strings.Contains(out.out.String(), "Dev container config") { + t.Errorf("stdout = %q, want the config-not-found error envelope", out.out.String()) + } + if strings.Contains(out.out.String(), "Dev container not found") || strings.Contains(err.Error(), "Docker engine") { + t.Errorf("went on to look for the container: %q", out.out.String()) + } +} + +func TestConfigFromFlag(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.WriteFile(filepath.Join(dir, "devcontainer.json"), []byte(`{"image":"alpine","remoteUser":"dev"}`), 0o644); err != nil { + t.Fatal(err) + } + + if cfg := configFromFlag("", ""); cfg != nil { + t.Errorf("no --config: %+v, want nil", cfg) + } + for _, path := range []string{filepath.Join(dir, "devcontainer.json"), "devcontainer.json"} { + if cfg := configFromFlag(path, ""); cfg == nil || cfg.RemoteUser != "dev" { + t.Errorf("--config %s: %+v, want the config loaded", path, cfg) + } + } + if cfg := configFromFlag("missing.json", ""); cfg != nil { + t.Errorf("unloadable --config: %+v, want nil (the container metadata is used)", cfg) + } +} + +func TestLifecycleRemoteUser(t *testing.T) { + merged := &imagemeta.MergedConfig{RemoteUser: "image-user"} + for _, tc := range []struct { + name string + cfg *config.DevContainer + want string + }{ + {"no config", nil, "image-user"}, + {"config without remoteUser", &config.DevContainer{}, "image-user"}, + {"config remoteUser wins", &config.DevContainer{RemoteUser: "dev"}, "dev"}, + } { + if got := lifecycleRemoteUser(merged, tc.cfg); got != tc.want { + t.Errorf("%s: %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestLifecycleUserEnvProbe(t *testing.T) { + for _, tc := range []struct { + name string + cfg *config.DevContainer + want lifecycle.UserEnvProbeStrategy + }{ + {"no config", nil, "loginShell"}, + {"config without userEnvProbe", &config.DevContainer{}, "loginShell"}, + {"config userEnvProbe wins", &config.DevContainer{UserEnvProbe: "none"}, "none"}, + } { + if got := lifecycleUserEnvProbe("loginShell", tc.cfg); got != tc.want { + t.Errorf("%s: %q, want %q", tc.name, got, tc.want) + } + } +} + +// Later entries win, so the order is the precedence: probe, secrets, +// --remote-env, then the config's remoteEnv. +func TestLifecycleRemoteEnvOrder(t *testing.T) { + probed := map[string]string{"PATH": "/probed"} + secrets := []string{"TOKEN=s"} + cli := []string{"PATH=/cli"} + value := "/config" + cfg := &config.DevContainer{RemoteEnv: map[string]*string{"PATH": &value, "UNSET": nil}} + + if got, want := lifecycleRemoteEnv(probed, secrets, cli, cfg), []string{"PATH=/probed", "TOKEN=s", "PATH=/cli", "PATH=/config"}; !slices.Equal(got, want) { + t.Errorf("with config: %v, want %v", got, want) + } + if got, want := lifecycleRemoteEnv(probed, secrets, cli, nil), []string{"PATH=/probed", "TOKEN=s", "PATH=/cli"}; !slices.Equal(got, want) { + t.Errorf("without config: %v, want %v", got, want) + } +} diff --git a/internal/cli/seams_test.go b/internal/cli/seams_test.go index 5e0d0b6..e0e6ec3 100644 --- a/internal/cli/seams_test.go +++ b/internal/cli/seams_test.go @@ -3,14 +3,14 @@ package cli import ( "bytes" "context" - "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" - "strings" "testing" + coreerrors "github.com/devcontainers/cli/internal/errors" "github.com/devcontainers/cli/internal/oci" ) @@ -34,6 +34,7 @@ type fakeRegistry struct { pushed []string // resources successfully pushed (for assertions) pushedTags map[string][]string // resource -> tags requested on push collectionPush int // times PushCollectionMetadata was called + collectionRef string // the resource of the last collection push failCollection bool // when true, PushCollectionMetadata errors } @@ -71,6 +72,7 @@ func (f *fakeRegistry) PushArtifact(ctx context.Context, ref *oci.Ref, tgzPath s func (f *fakeRegistry) PushCollectionMetadata(ctx context.Context, ref *oci.Ref, collectionJSONPath string) (*oci.PushResult, error) { f.collectionPush++ + f.collectionRef = ref.Resource if f.failCollection { return nil, fmt.Errorf("simulated collection metadata push failure") } @@ -87,11 +89,12 @@ func writeSeamFeature(t *testing.T, srcDir, id, version string) { } } -// TestPublishCollectionPartialFailure proves the oci.Registry and cli.Output -// seams work together: a fake registry drives a partial-publish (one feature -// pushes, one fails), and the captured stdout carries the result JSON — no real -// registry, no global os.Stdout swap. -func TestPublishCollectionPartialFailure(t *testing.T) { +// TestPublishCollectionStopsAtTheFirstFailure proves the oci.Registry and +// cli.Output seams work together: a fake registry fails one feature's push, and +// publish ends there with exit 1 and no result JSON, as TS featuresCLI/publish.ts +// does (`process.exit(1)` after "(!) ERR: Failed to publish"). The features are +// published in collection order, so "bad" stops it before "good". +func TestPublishCollectionStopsAtTheFirstFailure(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") writeSeamFeature(t, src, "good", "1.0.0") @@ -103,28 +106,14 @@ func TestPublishCollectionPartialFailure(t *testing.T) { } out := &captureOutput{} - err := publishCollectionWith(t.Context(), out, reg, target, "ghcr.io", "me/features", "feature", "info") - if err == nil { - t.Fatal("expected a partial-publish error, got nil") + err := publishCollectionWith(t.Context(), out, reg, filepath.Join(target, "src"), "ghcr.io", "me/features", "feature", "info") + if exit, ok := errors.AsType[*coreerrors.ExitCodeError](err); !ok || exit.Code != 1 { + t.Fatalf("err = %v, want exit code 1", err) } - if !strings.Contains(err.Error(), "publish operation(s) failed") { - t.Fatalf("error = %v, want partial-publish failure", err) + if len(reg.pushed) != 0 || reg.collectionPush != 0 { + t.Fatalf("pushed = %v, collection pushes = %d: publish must stop at the first failure", reg.pushed, reg.collectionPush) } - - // The good feature was pushed; the bad one was not. - if len(reg.pushed) != 1 || !strings.HasSuffix(reg.pushed[0], "/good") { - t.Fatalf("pushed = %v, want exactly [.../good]", reg.pushed) - } - - // stdout carries the result JSON keyed by the published feature id. - var result map[string]interface{} - if jerr := json.Unmarshal(out.out.Bytes(), &result); jerr != nil { - t.Fatalf("stdout is not valid JSON: %v (%q)", jerr, out.out.String()) - } - if _, ok := result["good"]; !ok { - t.Fatalf("result missing 'good': %v", result) - } - if _, ok := result["bad"]; ok { - t.Fatalf("result should not contain failed 'bad': %v", result) + if out.out.Len() != 0 { + t.Fatalf("stdout = %q, want no result after a failure", out.out.String()) } } diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index 866a771..3adfaa5 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -37,8 +37,8 @@ func secretValuesFromFile(path string) []string { } values := make([]string, 0, len(envs)) for _, e := range envs { - if i := strings.IndexByte(e, '='); i >= 0 { - values = append(values, e[i+1:]) + if _, after, ok := strings.Cut(e, "="); ok { + values = append(values, after) } } return values diff --git a/internal/cli/secrets_held_test.go b/internal/cli/secrets_held_test.go new file mode 100644 index 0000000..bcd5ecf --- /dev/null +++ b/internal/cli/secrets_held_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// The values masked in logs are exactly the secrets file's values; no file, an +// unreadable one or a malformed one masks nothing. +func TestSecretValuesFromFile(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "secrets.json") + if err := os.WriteFile(good, []byte(`{"TOKEN":"s3cr=t","USER":"bob"}`), 0o600); err != nil { + t.Fatal(err) + } + got := secretValuesFromFile(good) + slices.Sort(got) + if want := []string{"bob", "s3cr=t"}; !slices.Equal(got, want) { + t.Errorf("values = %q, want %q", got, want) + } + + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte(`[1,2]`), 0o600); err != nil { + t.Fatal(err) + } + for _, p := range []string{"", filepath.Join(dir, "missing.json"), bad} { + if got := secretValuesFromFile(p); got != nil { + t.Errorf("secretValuesFromFile(%q) = %q, want nil", p, got) + } + } +} diff --git a/internal/cli/setup.go b/internal/cli/setup.go index ffc611b..bb7a1ea 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -1,9 +1,13 @@ package cli import ( + "cmp" + "context" "encoding/json" "fmt" + "github.com/moby/moby/api/types/container" + "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/docker" coreerrors "github.com/devcontainers/cli/internal/errors" @@ -13,254 +17,248 @@ import ( "github.com/spf13/cobra" ) +type setUpOpts struct { + dockerPath string + containerID string + configPath string + logLevel string + logFormat string + logFile string + terminalLogFile string + skipPostCreate bool + skipNonBlocking bool + dotfilesRepo string + dotfilesCommand string + dotfilesTarget string + includeConfig bool + includeMerged bool + remoteEnvs []string + defaultUserEnvProbe string + containerSessionDataFolder string + terminalColumns int + terminalRows int +} + func newSetUpCmd() *cobra.Command { - var ( - dockerPath string - containerID string - configPath string - logLevel string - logFormat string - logFile string - terminalLogFile string - skipPostCreate bool - skipNonBlocking bool - dotfilesRepo string - dotfilesCommand string - dotfilesTarget string - includeConfig bool - includeMerged bool - remoteEnvs []string - defaultUserEnvProbe string - containerSessionDataFolder string - terminalColumns int - terminalRows int - ) + var opts setUpOpts cmd := &cobra.Command{ Use: "set-up", Short: "Set up an existing container as a dev container", RunE: func(cmd *cobra.Command, args []string) error { - out := outputFor(cmd) - if containerID == "" { - return writeValidationError(out, "Missing required argument: --container-id") - } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", logLevel, []string{"info", "debug", "trace"}}, - {"log-format", logFormat, []string{"text", "json"}}, - {"default-user-env-probe", defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return writeValidationError(out, err.Error()) - } - } - if err := validateTerminalImplications(terminalColumns, terminalRows); err != nil { - return writeValidationError(out, err.Error()) - } - // TS parity: set-up rejects malformed --remote-env at parse time like - // its siblings (exec/up/run-user-commands); Go was missing this. - if err := validateRemoteEnvs(remoteEnvs); err != nil { - return writeValidationError(out, err.Error()) - } + return runSetUp(cmd.Context(), outputFor(cmd), &opts) + }, + } - logDst, closeLog, logErr := logWriter(logFile, terminalLogFile) - if logErr != nil { - return writeErrorResult(out, fmt.Sprintf("open log file: %v", logErr)) - } - defer closeLog() + f := cmd.Flags() + f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.") + f.StringVar(&opts.containerID, "container-id", "", "Id of the container.") + f.StringVar(&opts.configPath, "config", "", "devcontainer.json path.") + f.StringVar(&opts.logLevel, "log-level", "info", "Log level.") + f.StringVar(&opts.logFormat, "log-format", "text", "Log format.") + f.BoolVar(&opts.skipPostCreate, "skip-post-create", false, "Skip post-create commands.") + f.BoolVar(&opts.skipNonBlocking, "skip-non-blocking-commands", false, "Stop after waitFor.") + f.StringVar(&opts.dotfilesRepo, "dotfiles-repository", "", "Dotfiles repo.") + f.StringVar(&opts.dotfilesCommand, "dotfiles-install-command", "", "Dotfiles install command.") + f.StringVar(&opts.dotfilesTarget, "dotfiles-target-path", "~/dotfiles", "Dotfiles target.") + f.BoolVar(&opts.includeConfig, "include-configuration", false, "Include config.") + f.BoolVar(&opts.includeMerged, "include-merged-configuration", false, "Include merged config.") + f.String("container-data-folder", "", "") + f.String("container-system-data-folder", "", "") + f.StringVar(&opts.defaultUserEnvProbe, "default-user-env-probe", "loginInteractiveShell", "") + f.String("user-data-folder", "", "") + f.StringArrayVar(&opts.remoteEnvs, "remote-env", nil, "Remote env vars.") + f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "") + f.IntVar(&opts.terminalColumns, "terminal-columns", 0, "") + f.IntVar(&opts.terminalRows, "terminal-rows", 0, "") - logger := log.New(log.Options{ - Version: cliVersion(), - Level: log.ParseLevel(logLevel), - Format: logFormat, - Writer: logDst, - Dimensions: logDimensions(terminalColumns, terminalRows), - }) + addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) + return cmd +} - ctx := cmd.Context() - engine, err := docker.NewEngineClient(logger) - if err != nil { - return writeErrorResult(out, fmt.Sprintf("Docker engine: %v", err)) - } - defer engine.Close() +// runSetUp validates the flags, opens the log and finds the container, then +// hands it to setUpContainer. +func runSetUp(ctx context.Context, out Output, opts *setUpOpts) error { + if opts.containerID == "" { + return writeValidationError(out, "Missing required argument: --container-id") + } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + enumFlag{"default-user-env-probe", opts.defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, + ); err != nil { + return writeValidationError(out, err.Error()) + } + if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { + return writeValidationError(out, err.Error()) + } + // TS parity: set-up rejects malformed --remote-env at parse time like + // its siblings (exec/up/run-user-commands); Go was missing this. + if err := validateRemoteEnvs(opts.remoteEnvs); err != nil { + return writeValidationError(out, err.Error()) + } - inspect, err := engine.InspectContainer(ctx, containerID) - if err != nil { - return writeErrorResult(out, "Dev container not found.") - } + logDst, closeLog, logErr := logWriter(opts.logFile, opts.terminalLogFile) + if logErr != nil { + return writeErrorResult(out, fmt.Sprintf("open log file: %v", logErr)) + } + defer closeLog() - // Get containerEnv from running container - containerEnv := envSliceToMap(inspect.Config.Env) + logger := log.New(log.Options{ + Version: cliVersion(), + Level: log.ParseLevel(opts.logLevel), + Format: opts.logFormat, + Writer: logDst, + }) - // Load config if provided - var cfg *config.DevContainer - if configPath != "" { - loadResult, loadErr := config.LoadDevContainerConfig(".", resolvePath(configPath), "") - if loadErr == nil { - cfg = loadResult.Config - } - } + engine, err := docker.NewEngineClient(logger) + if err != nil { + return writeErrorResult(out, fmt.Sprintf("Docker engine: %v", err)) + } + defer engine.Close() - // Read metadata from container labels - entries := imagemeta.ReadMetadataFromLabels(inspect.Config.Labels, logger) + inspect, err := engine.InspectContainer(ctx, opts.containerID) + if err != nil { + return writeErrorResult(out, "Dev container not found.") + } + return setUpContainer(ctx, out, engine, logger, opts, inspect) +} - // Build merged config: metadata entries + config as last entry - allEntries := make([]imagemeta.Entry, len(entries)) - copy(allEntries, entries) - if cfg != nil { - allEntries = append(allEntries, configToMetadataEntry(cfg)) - } - merged := imagemeta.MergeConfiguration(allEntries) +// setUpContainer runs the lifecycle hooks in the container and writes the +// result. +// +// mutate-lane: TestParityMatrix - set-up.container-config-success, +// set-up.container-metadata-success and set-up.after-failed-post-create-success +// set up a real container through it. +func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient, logger log.Logger, opts *setUpOpts, inspect container.InspectResponse) error { + containerID := opts.containerID + cfg := setUpConfig(opts.configPath) + merged := setUpMerged(inspect.Config.Labels, cfg, logger) - // Resolve remoteUser (metadata, overridden by config) so the shell server - // and userEnvProbe run as that user, matching the TS CLI. - remoteUser := merged.RemoteUser - if cfg != nil && cfg.RemoteUser != "" { - remoteUser = cfg.RemoteUser - } + var cfgProbe string + var cfgRemoteEnv map[string]*string + if cfg != nil { + cfgProbe, cfgRemoteEnv = cfg.UserEnvProbe, cfg.RemoteEnv + } + // The shell server and userEnvProbe run as the merged remoteUser (the + // config's wins), matching the TS CLI. Remote env: probe → CLI --remote-env + // → config remoteEnv. + probeStrategy := lifecycle.UserEnvProbeStrategy(cmp.Or(cfgProbe, opts.defaultUserEnvProbe)) + probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, merged.RemoteUser, probeStrategy, opts.containerSessionDataFolder) + shellServer := lifecycle.NewShellServer(ctx, engine, containerID, merged.RemoteUser, hookRemoteEnv(probed, opts.remoteEnvs, cfgRemoteEnv)...) + defer shellServer.Close() - // Build merged remote env: probe → CLI --remote-env → config remoteEnv - probeStrategy := lifecycle.UserEnvProbeStrategy(defaultUserEnvProbe) - if cfg != nil && cfg.UserEnvProbe != "" { - probeStrategy = lifecycle.UserEnvProbeStrategy(cfg.UserEnvProbe) - } + executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger} - var mergedRemoteEnv []string - if probeStrategy != lifecycle.ProbeNone { - probeServer, probeErr := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger) - if probeErr == nil { - probedEnv, _ := lifecycle.ProbeRemoteEnv(logger, probeServer, probeStrategy, remoteUser, containerSessionDataFolder) - probeServer.Close() - for k, v := range probedEnv { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, v)) - } - } - } - mergedRemoteEnv = append(mergedRemoteEnv, remoteEnvs...) - if cfg != nil { - for k, v := range cfg.RemoteEnv { - if v != nil { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, *v)) - } - } - } - - shellServer, err := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger, mergedRemoteEnv...) - if err != nil { - return writeErrorResult(out, fmt.Sprintf("Failed to start shell: %v", err)) - } - defer shellServer.Close() + if !opts.skipPostCreate { + applyLifecycleMarkers(shellServer, merged, inspect, false) + hookErr := lifecycle.RunHooks(logger, executor, merged, lifecycle.RunOptions{ + SkipNonBlocking: opts.skipNonBlocking, + AfterPostCreate: dotfilesAfterPostCreate(logger, executor, lifecycle.DotfilesConfig{ + Repository: opts.dotfilesRepo, + InstallCommand: opts.dotfilesCommand, + TargetPath: opts.dotfilesTarget, + }), + }) + if hookErr != nil { + // TS set-up reports only the message and description. + return writeErrorJSON(out, coreerrors.ToErrorOutput(hookContainerError(hookErr, "", "An error occurred running user commands in the container."))) + } + } - executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger} + result, err := setUpResult(cfg, merged, config.EnvMap(inspect.Config.Env), opts.includeConfig, opts.includeMerged) + if err != nil { + return writeErrorResult(out, err.Error()) + } + return writeSuccessJSON(out, result) +} - if dotfilesRepo != "" { - _ = lifecycle.InstallDotfiles(logger, executor, lifecycle.DotfilesConfig{ - Repository: dotfilesRepo, - InstallCommand: dotfilesCommand, - TargetPath: dotfilesTarget, - }) - } +// setUpConfig is the --config file set-up merges over the container's +// metadata: nil when no --config is given or it cannot be loaded. +func setUpConfig(configPath string) *config.DevContainer { + if configPath == "" { + return nil + } + loadResult, err := config.LoadDevContainerConfig(".", resolvePath(configPath), "") + if err != nil { + return nil + } + return loadResult.Config +} - if !skipPostCreate { - hookErr := lifecycle.RunHooks(logger, executor, merged, lifecycle.RunOptions{ - SkipNonBlocking: skipNonBlocking, - }) - if hookErr != nil { - return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{ - Description: fmt.Sprintf("An error occurred running user commands: %v", hookErr), - })) - } - } +// setUpMerged is set-up's merged configuration: the container's metadata label +// entries, then the config (when there is one) as the last entry. +func setUpMerged(labels map[string]string, cfg *config.DevContainer, logger log.Logger) *imagemeta.MergedConfig { + entries := imagemeta.ReadMetadataFromLabels(labels, logger) + if cfg != nil { + entries = append(entries, configToMetadataEntry(cfg)) + } + return imagemeta.MergeConfiguration(entries) +} - // Build result with containerEnv substitution - result := map[string]interface{}{"outcome": "success"} +// setUpResult is set-up's success result: configuration +// (--include-configuration; {} without a config) and mergedConfiguration +// (--include-merged-configuration), with ${containerEnv:…} resolved from the +// container's environment and configFilePath as a file URI, as TS reports them. +func setUpResult(cfg *config.DevContainer, merged *imagemeta.MergedConfig, containerEnv map[string]string, includeConfig, includeMerged bool) (map[string]any, error) { + result := map[string]any{"outcome": "success"} - if includeConfig { - if cfg != nil { - // Substitute containerEnv in config - cfgJSON, _ := json.Marshal(cfg) - var cfgGeneric interface{} - json.Unmarshal(cfgJSON, &cfgGeneric) - substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric) - if subErr != nil { - return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr)) + if includeConfig { + if cfg != nil { + // Substitute containerEnv in config + cfgJSON, _ := json.Marshal(cfg) + var cfgGeneric any + json.Unmarshal(cfgJSON, &cfgGeneric) + substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric) + if subErr != nil { + return nil, fmt.Errorf("resolve configuration container variables: %w", subErr) + } + // Clean null values and internal fields + if subMap, ok := substituted.(map[string]any); ok { + for k, v := range subMap { + if v == nil { + delete(subMap, k) } - // Clean null values and internal fields - if subMap, ok := substituted.(map[string]interface{}); ok { - for k, v := range subMap { - if v == nil { - delete(subMap, k) - } - } - // Convert configFilePath to URI format (matches TS) - if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { - subMap["configFilePath"] = map[string]interface{}{ - "$mid": 1, - "fsPath": cfp, - "path": cfp, - "scheme": "file", - } - } else { - delete(subMap, "configFilePath") - } - delete(subMap, "extensions") - delete(subMap, "settings") - delete(subMap, "devPort") - result["configuration"] = subMap - } else { - result["configuration"] = substituted + } + // Convert configFilePath to URI format (matches TS) + if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { + subMap["configFilePath"] = map[string]any{ + "$mid": 1, + "fsPath": cfp, + "path": cfp, + "scheme": "file", } } else { - // No config file → empty object (TS behavior) - result["configuration"] = map[string]interface{}{} + delete(subMap, "configFilePath") } + delete(subMap, "extensions") + delete(subMap, "settings") + delete(subMap, "devPort") + result["configuration"] = subMap + } else { + result["configuration"] = substituted } + } else { + // No config file → empty object (TS behavior) + result["configuration"] = map[string]any{} + } + } - if includeMerged { - // Copy configFilePath URI to mergedConfiguration (matches TS) - if cfgResult, ok := result["configuration"].(map[string]interface{}); ok { - if cfpURI, ok := cfgResult["configFilePath"]; ok { - merged.ConfigFilePath = cfpURI - } - } - mergedJSON, _ := json.Marshal(merged) - var mergedGeneric interface{} - json.Unmarshal(mergedJSON, &mergedGeneric) - substituted, subErr := resolveContainerVariables(containerEnv, mergedGeneric) - if subErr != nil { - return writeErrorResult(out, fmt.Sprintf("resolve merged container variables: %v", subErr)) - } - result["mergedConfiguration"] = substituted + if includeMerged { + // Copy configFilePath URI to mergedConfiguration (matches TS) + if cfgResult, ok := result["configuration"].(map[string]any); ok { + if cfpURI, ok := cfgResult["configFilePath"]; ok { + merged.ConfigFilePath = cfpURI } - - return writeSuccessJSON(out, result) - }, + } + mergedJSON, _ := json.Marshal(merged) + var mergedGeneric any + json.Unmarshal(mergedJSON, &mergedGeneric) + substituted, subErr := resolveContainerVariables(containerEnv, mergedGeneric) + if subErr != nil { + return nil, fmt.Errorf("resolve merged container variables: %w", subErr) + } + result["mergedConfiguration"] = substituted } - - f := cmd.Flags() - f.StringVar(&dockerPath, "docker-path", "", "Docker CLI path.") - f.StringVar(&containerID, "container-id", "", "Id of the container.") - f.StringVar(&configPath, "config", "", "devcontainer.json path.") - f.StringVar(&logLevel, "log-level", "info", "Log level.") - f.StringVar(&logFormat, "log-format", "text", "Log format.") - f.BoolVar(&skipPostCreate, "skip-post-create", false, "Skip post-create commands.") - f.BoolVar(&skipNonBlocking, "skip-non-blocking-commands", false, "Stop after waitFor.") - f.StringVar(&dotfilesRepo, "dotfiles-repository", "", "Dotfiles repo.") - f.StringVar(&dotfilesCommand, "dotfiles-install-command", "", "Dotfiles install command.") - f.StringVar(&dotfilesTarget, "dotfiles-target-path", "~/dotfiles", "Dotfiles target.") - f.BoolVar(&includeConfig, "include-configuration", false, "Include config.") - f.BoolVar(&includeMerged, "include-merged-configuration", false, "Include merged config.") - f.String("container-data-folder", "", "") - f.String("container-system-data-folder", "", "") - f.StringVar(&defaultUserEnvProbe, "default-user-env-probe", "loginInteractiveShell", "") - f.String("user-data-folder", "", "") - f.StringArrayVar(&remoteEnvs, "remote-env", nil, "Remote env vars.") - f.StringVar(&containerSessionDataFolder, "container-session-data-folder", "", "") - f.IntVar(&terminalColumns, "terminal-columns", 0, "") - f.IntVar(&terminalRows, "terminal-rows", 0, "") - - addLogFileFlags(cmd, &logFile, &terminalLogFile) - return cmd + return result, nil } diff --git a/internal/cli/setup_held_test.go b/internal/cli/setup_held_test.go new file mode 100644 index 0000000..d102c9a --- /dev/null +++ b/internal/cli/setup_held_test.go @@ -0,0 +1,233 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" +) + +// runSetUpCmd runs `set-up` with args and returns its stdout, stderr and error. +func runSetUpCmd(t *testing.T, args ...string) (string, string, error) { + t.Helper() + cmd := newSetUpCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestSetUpRejectsInvalidFlagsBeforeLookingForTheContainer(t *testing.T) { + // No engine is reachable: a flag that passed validation would end in + // "Dev container not found." on stdout instead. + t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:1") + for _, tc := range []struct { + name string + args []string + want string + }{ + {"no container", nil, "Missing required argument: --container-id"}, + {"log level", []string{"--container-id", "c1", "--log-level", "loud"}, "log-level"}, + {"log format", []string{"--container-id", "c1", "--log-format", "xml"}, "log-format"}, + {"env probe", []string{"--container-id", "c1", "--default-user-env-probe", "always"}, "default-user-env-probe"}, + {"terminal", []string{"--container-id", "c1", "--terminal-columns", "80"}, "terminal-rows"}, + {"remote env", []string{"--container-id", "c1", "--remote-env", "NOEQUALS"}, "remote-env"}, + } { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := runSetUpCmd(t, tc.args...) + if err == nil { + t.Fatal("set-up accepted invalid flags") + } + if stdout != "" { + t.Errorf("stdout = %q, want nothing for a usage error", stdout) + } + if !strings.Contains(stderr, tc.want) { + t.Errorf("stderr = %q, want it to name %q", stderr, tc.want) + } + }) + } +} + +func TestSetUpReportsAContainerTheEngineCannotFind(t *testing.T) { + startFakeEngine(t, map[string]map[string]string{}) + + stdout, _, err := runSetUpCmd(t, "--container-id", "missing", "--terminal-columns", "80", "--terminal-rows", "24", "--remote-env", "A=b") + + if err == nil { + t.Fatal("set-up succeeded without a container") + } + var got map[string]any + if jerr := json.Unmarshal([]byte(stdout), &got); jerr != nil { + t.Fatalf("stdout is not a JSON result: %v\n%s", jerr, stdout) + } + if got["outcome"] != "error" || got["message"] != "Dev container not found." { + t.Errorf("result = %v, want Dev container not found.", got) + } +} + +func TestSetUpReportsALogFileItCannotOpen(t *testing.T) { + t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:1") + + stdout, _, err := runSetUpCmd(t, "--container-id", "c1", "--log-file", t.TempDir()) + + if err == nil || !strings.Contains(stdout, `"open log file: `) { + t.Errorf("stdout = %q, err = %v; want the log file error", stdout, err) + } +} + +func TestSetUpReportsAnUnusableDockerHost(t *testing.T) { + t.Setenv("DOCKER_HOST", "not a docker host") + + stdout, _, err := runSetUpCmd(t, "--container-id", "c1") + + if err == nil || !strings.Contains(stdout, `"Docker engine: `) { + t.Errorf("stdout = %q, err = %v; want the engine client's error", stdout, err) + } +} + +func TestSetUpConfigLoadsTheGivenConfigOnly(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "devcontainer.json") + if err := os.WriteFile(good, []byte(`{"image":"alpine:3","remoteUser":"vscode"}`), 0o644); err != nil { + t.Fatal(err) + } + bad := filepath.Join(dir, "broken.json") + if err := os.WriteFile(bad, []byte(`{"image":`), 0o644); err != nil { + t.Fatal(err) + } + + if cfg := setUpConfig(""); cfg != nil { + t.Errorf("setUpConfig(\"\") = %+v, want nil without --config", cfg) + } + if cfg := setUpConfig(good); cfg == nil || cfg.RemoteUser != "vscode" { + t.Errorf("setUpConfig(%q) = %+v, want the config's remoteUser", good, cfg) + } + if cfg := setUpConfig(bad); cfg != nil { + t.Errorf("setUpConfig(%q) = %+v, want nil for a config that does not load", bad, cfg) + } +} + +func TestSetUpMergedPutsTheConfigOverTheContainerMetadata(t *testing.T) { + labels := map[string]string{imagemeta.MetadataLabel: `[{"remoteUser":"node","postCreateCommand":"from-image"}]`} + logger := log.New(log.Options{Writer: &bytes.Buffer{}}) + cfg := &config.DevContainer{RemoteUser: "vscode"} + if err := json.Unmarshal([]byte(`"from-config"`), &cfg.PostCreateCommand); err != nil { + t.Fatal(err) + } + + if got := setUpMerged(labels, nil, logger); got.RemoteUser != "node" || !reflect.DeepEqual(got.PostCreateCommands, []any{"from-image"}) { + t.Errorf("without a config: remoteUser %q, postCreate %v; want the label's", got.RemoteUser, got.PostCreateCommands) + } + got := setUpMerged(labels, cfg, logger) + if got.RemoteUser != "vscode" { + t.Errorf("remoteUser = %q, want the config's", got.RemoteUser) + } + if want := []any{"from-image", "from-config"}; !reflect.DeepEqual(got.PostCreateCommands, want) { + t.Errorf("postCreateCommands = %v, want %v", got.PostCreateCommands, want) + } +} + +// setUpResultJSON is setUpResult's result as set-up prints it. +func setUpResultJSON(t *testing.T, cfg *config.DevContainer, merged *imagemeta.MergedConfig, env map[string]string, includeConfig, includeMerged bool) map[string]any { + t.Helper() + result, err := setUpResult(cfg, merged, env, includeConfig, includeMerged) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + return got +} + +func strPtr(s string) *string { return &s } + +func TestSetUpResultReportsTheConfigurationAsTSDoes(t *testing.T) { + cfg := &config.DevContainer{ + ConfigFilePath: "/ws/.devcontainer/devcontainer.json", + Image: "alpine:3", + RemoteEnv: map[string]*string{"FROM_CONTAINER": strPtr("${containerEnv:FOO}")}, + } + merged := imagemeta.MergeConfiguration([]imagemeta.Entry{configToMetadataEntry(cfg)}) + env := map[string]string{"FOO": "bar"} + + got := setUpResultJSON(t, cfg, merged, env, true, true) + + wantURI := map[string]any{"$mid": float64(1), "fsPath": cfg.ConfigFilePath, "path": cfg.ConfigFilePath, "scheme": "file"} + conf, _ := got["configuration"].(map[string]any) + if !reflect.DeepEqual(conf["configFilePath"], wantURI) { + t.Errorf("configuration.configFilePath = %v, want %v", conf["configFilePath"], wantURI) + } + if re, _ := conf["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { + t.Errorf("configuration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", conf["remoteEnv"]) + } + for k, v := range conf { + if v == nil { + t.Errorf("configuration.%s is null; TS leaves unset properties out", k) + } + } + mc, _ := got["mergedConfiguration"].(map[string]any) + if !reflect.DeepEqual(mc["configFilePath"], wantURI) { + t.Errorf("mergedConfiguration.configFilePath = %v, want %v", mc["configFilePath"], wantURI) + } + if re, _ := mc["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { + t.Errorf("mergedConfiguration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", mc["remoteEnv"]) + } +} + +func TestSetUpResultLeavesOutWhatWasNotAskedFor(t *testing.T) { + merged := imagemeta.MergeConfiguration(nil) + + if got := setUpResultJSON(t, nil, merged, nil, false, false); !reflect.DeepEqual(got, map[string]any{"outcome": "success"}) { + t.Errorf("result = %v, want the outcome alone", got) + } + got := setUpResultJSON(t, nil, merged, nil, true, false) + if !reflect.DeepEqual(got["configuration"], map[string]any{}) { + t.Errorf("configuration without a config = %v, want {}", got["configuration"]) + } + if _, ok := got["mergedConfiguration"]; ok { + t.Errorf("mergedConfiguration reported without --include-merged-configuration: %v", got) + } +} + +func TestSetUpResultDropsAConfigFilePathThatResolvesToNothing(t *testing.T) { + cfg := &config.DevContainer{ConfigFilePath: "${containerEnv:UNSET}"} + + got := setUpResultJSON(t, cfg, imagemeta.MergeConfiguration(nil), nil, true, true) + + conf, _ := got["configuration"].(map[string]any) + if v, ok := conf["configFilePath"]; ok { + t.Errorf("configuration.configFilePath = %v, want it left out", v) + } + mc, _ := got["mergedConfiguration"].(map[string]any) + if v, ok := mc["configFilePath"]; ok { + t.Errorf("mergedConfiguration.configFilePath = %v, want it left out", v) + } +} + +func TestSetUpResultReportsAContainerEnvReferenceWithoutAName(t *testing.T) { + cfg := &config.DevContainer{RemoteEnv: map[string]*string{"X": strPtr("${containerEnv}")}} + if _, err := setUpResult(cfg, imagemeta.MergeConfiguration(nil), nil, true, false); err == nil || !strings.HasPrefix(err.Error(), "resolve configuration container variables: ") { + t.Errorf("configuration error = %v, want the substitution error", err) + } + merged := imagemeta.MergeConfiguration([]imagemeta.Entry{{RemoteEnv: cfg.RemoteEnv}}) + if _, err := setUpResult(nil, merged, nil, false, true); err == nil || !strings.HasPrefix(err.Error(), "resolve merged container variables: ") { + t.Errorf("merged error = %v, want the substitution error", err) + } +} diff --git a/internal/cli/stop.go b/internal/cli/stop.go index 91d9448..4a61f58 100644 --- a/internal/cli/stop.go +++ b/internal/cli/stop.go @@ -76,7 +76,9 @@ func addStopFlags(cmd *cobra.Command, opts *stopOpts) { } func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) error { - if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.containerID == "" { + // The workspace defaults to the current directory. It is only looked at + // when neither --container-id nor --id-label names the container. + if opts.workspaceFolder == "" { opts.workspaceFolder, _ = os.Getwd() } @@ -90,7 +92,7 @@ func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) id := resolveWorkspaceContainer(ctx, engine, opts) if id == "" { // Nothing to do — already gone. Not an error (idempotent). - return writeSuccessJSON(out, map[string]interface{}{"outcome": "success", "result": "no-container-found"}) + return writeSuccessJSON(out, map[string]any{"outcome": "success", "result": "no-container-found"}) } // Compose services carry the project label; operate on the whole project so @@ -125,7 +127,7 @@ func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) return writeErrorResult(out, fmt.Sprintf("%s: %v", action, err)) } - return writeSuccessJSON(out, map[string]interface{}{ + return writeSuccessJSON(out, map[string]any{ "outcome": "success", "containerId": id, "result": action, diff --git a/internal/cli/stop_held_test.go b/internal/cli/stop_held_test.go new file mode 100644 index 0000000..7b9c979 --- /dev/null +++ b/internal/cli/stop_held_test.go @@ -0,0 +1,342 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" +) + +// fakeEngine is a Docker Engine API server holding a few containers, so stop +// and down can run against an engine without Docker. It records the calls the +// command makes. +type fakeEngine struct { + t *testing.T + mu sync.Mutex + containers map[string]map[string]string // id -> labels; nil labels: inspect has no Config + stopFails bool + calls []string +} + +var fakeEnginePath = regexp.MustCompile(`^(?:/v[0-9.]+)?(/.*)$`) + +// startFakeEngine serves the fake engine and points DOCKER_HOST at it. +func startFakeEngine(t *testing.T, containers map[string]map[string]string) *fakeEngine { + t.Helper() + e := &fakeEngine{t: t, containers: containers} + srv := httptest.NewServer(http.HandlerFunc(e.serve)) + t.Cleanup(srv.Close) + t.Setenv("DOCKER_HOST", "tcp://"+srv.Listener.Addr().String()) + t.Setenv("DOCKER_API_VERSION", "1.47") + t.Setenv("DOCKER_TLS_VERIFY", "") + t.Setenv("DOCKER_CERT_PATH", "") + return e +} + +func (e *fakeEngine) record(call string) { + e.mu.Lock() + defer e.mu.Unlock() + e.calls = append(e.calls, call) +} + +func (e *fakeEngine) recorded() []string { + e.mu.Lock() + defer e.mu.Unlock() + return slices.Clone(e.calls) +} + +func (e *fakeEngine) serve(w http.ResponseWriter, r *http.Request) { + path := fakeEnginePath.FindStringSubmatch(r.URL.Path)[1] + w.Header().Set("Content-Type", "application/json") + switch { + case path == "/_ping": + w.Header().Set("Api-Version", "1.47") + fmt.Fprint(w, "OK") + case r.Method == http.MethodGet && path == "/containers/json": + var filters map[string]map[string]bool + if f := r.URL.Query().Get("filters"); f != "" { + if err := json.Unmarshal([]byte(f), &filters); err != nil { + e.t.Errorf("filters %q: %v", f, err) + } + } + var want []string + for l := range filters["label"] { + want = append(want, l) + } + slices.Sort(want) + e.record("list " + strings.Join(want, ",")) + items := []map[string]string{} + for id, labels := range e.containers { + if hasLabels(labels, want) { + items = append(items, map[string]string{"Id": id}) + } + } + json.NewEncoder(w).Encode(items) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/json"): + id := strings.TrimSuffix(strings.TrimPrefix(path, "/containers/"), "/json") + labels, ok := e.containers[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"message":"No such container: %s"}`, id) + return + } + body := map[string]any{"Id": id} + if labels != nil { + body["Config"] = map[string]any{"Labels": labels} + } + json.NewEncoder(w).Encode(body) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/stop"): + e.record("stop " + strings.TrimSuffix(strings.TrimPrefix(path, "/containers/"), "/stop")) + if e.stopFails { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"cannot stop"}`) + return + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete: + e.record("remove " + strings.TrimPrefix(path, "/containers/")) + w.WriteHeader(http.StatusNoContent) + default: + e.t.Errorf("unexpected engine call %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotImplemented) + } +} + +func hasLabels(labels map[string]string, want []string) bool { + for _, l := range want { + k, v, _ := strings.Cut(l, "=") + if got, ok := labels[k]; !ok || got != v { + return false + } + } + return true +} + +// fakeDockerCLI writes a docker CLI that records its arguments and answers +// `compose version` (unless composeMissing) and fails `compose ... stop|down` +// with composeExit. It returns the CLI's path and a reader of what it ran. +func fakeDockerCLI(t *testing.T, composeMissing bool, composeExit int) (string, func() []string) { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "calls") + versionExit := 0 + if composeMissing { + versionExit = 1 + } + script := fmt.Sprintf(`#!/bin/sh +echo "$*" >> %q +case "$*" in + "compose version --short") [ %d -eq 0 ] && echo 2.24.0; exit %d ;; + compose*) echo "compose said no" >&2; exit %d ;; +esac +`, logPath, versionExit, versionExit, composeExit) + cli := filepath.Join(dir, "docker") + if err := os.WriteFile(cli, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return cli, func() []string { + b, err := os.ReadFile(logPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSpace(string(b)), "\n") + } +} + +func runStopOrDownForTest(t *testing.T, opts stopOpts, remove bool) map[string]any { + t.Helper() + var out captureOutput + _ = runStopOrDown(context.Background(), &out, opts, remove) + var got map[string]any + if err := json.Unmarshal(out.out.Bytes(), &got); err != nil { + t.Fatalf("stdout is not one JSON result: %v\n%s", err, out.out.String()) + } + return got +} + +func assertResult(t *testing.T, got map[string]any, want map[string]any) { + t.Helper() + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %v, want %v (result %v)", k, got[k], v, got) + } + } +} + +func TestStopWithoutATargetStopsTheCurrentDirectorysContainer(t *testing.T) { + ws := t.TempDir() + t.Chdir(ws) + engine := startFakeEngine(t, map[string]map[string]string{ + "c1": {"devcontainer.local_folder": ws}, + }) + cli, composeCalls := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{dockerPath: cli}, false) + + assertResult(t, got, map[string]any{"outcome": "success", "containerId": "c1", "result": "stopped"}) + if calls := engine.recorded(); !slices.Contains(calls, "stop c1") || slices.Contains(calls, "remove c1") { + t.Errorf("engine calls = %v, want c1 stopped and not removed", calls) + } + if calls := composeCalls(); calls != nil { + t.Errorf("docker CLI ran %v for a container outside Compose", calls) + } +} + +func TestStopGivenAWorkspaceLooksForItsContainerNotTheCurrentDirectorys(t *testing.T) { + ws := t.TempDir() + t.Chdir(t.TempDir()) + startFakeEngine(t, map[string]map[string]string{ + "c1": {"devcontainer.local_folder": ws}, + }) + cli, _ := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{workspaceFolder: ws, dockerPath: cli}, false) + + assertResult(t, got, map[string]any{"outcome": "success", "containerId": "c1", "result": "stopped"}) +} + +func TestStopWithNoContainerSucceedsWithoutDoingAnything(t *testing.T) { + ws := t.TempDir() + engine := startFakeEngine(t, map[string]map[string]string{ + "other": {"devcontainer.local_folder": "/elsewhere"}, + }) + cli, _ := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{workspaceFolder: ws, dockerPath: cli}, true) + + assertResult(t, got, map[string]any{"outcome": "success", "result": "no-container-found"}) + if _, ok := got["containerId"]; ok { + t.Errorf("result names a container: %v", got) + } + for _, c := range engine.recorded() { + if !strings.HasPrefix(c, "list ") { + t.Errorf("engine call %q with no container to act on", c) + } + } +} + +func TestDownStopsAndRemovesTheContainer(t *testing.T) { + engine := startFakeEngine(t, map[string]map[string]string{"c1": {}}) + cli, _ := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli}, true) + + assertResult(t, got, map[string]any{"outcome": "success", "containerId": "c1", "result": "removed"}) + if calls := engine.recorded(); !slices.Equal(calls, []string{"stop c1", "remove c1"}) { + t.Errorf("engine calls = %v, want stop then remove", calls) + } +} + +func TestStopOrDownReportsAStopTheEngineRefused(t *testing.T) { + for _, tc := range []struct { + remove bool + action string + }{{false, "stopped"}, {true, "removed"}} { + t.Run(tc.action, func(t *testing.T) { + engine := startFakeEngine(t, map[string]map[string]string{"c1": {}}) + engine.stopFails = true + cli, _ := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli}, tc.remove) + + assertResult(t, got, map[string]any{"outcome": "error"}) + if msg, _ := got["message"].(string); !strings.HasPrefix(msg, tc.action+": ") || !strings.Contains(msg, "cannot stop") { + t.Errorf("message = %q, want the engine's refusal after %q", msg, tc.action) + } + if calls := engine.recorded(); slices.Contains(calls, "remove c1") { + t.Errorf("engine calls = %v: a container that did not stop was removed", calls) + } + }) + } +} + +func TestStopOfAContainerWithoutConfigStopsItDirectly(t *testing.T) { + engine := startFakeEngine(t, map[string]map[string]string{"c1": nil}) + cli, composeCalls := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli}, false) + + assertResult(t, got, map[string]any{"outcome": "success", "containerId": "c1", "result": "stopped"}) + if calls := engine.recorded(); !slices.Equal(calls, []string{"stop c1"}) { + t.Errorf("engine calls = %v, want c1 stopped", calls) + } + if calls := composeCalls(); calls != nil { + t.Errorf("docker CLI ran %v", calls) + } +} + +func TestStopOrDownOfAComposeServiceActsOnTheWholeProject(t *testing.T) { + for _, tc := range []struct { + name string + remove bool + removeVolumes bool + want string + action string + }{ + {"stop", false, false, "compose --project-name proj stop", "stopped"}, + {"down", true, false, "compose --project-name proj down", "removed"}, + {"down --remove-volumes", true, true, "compose --project-name proj down --volumes", "removed"}, + } { + t.Run(tc.name, func(t *testing.T) { + engine := startFakeEngine(t, map[string]map[string]string{ + "c1": {"com.docker.compose.project": "proj"}, + }) + cli, composeCalls := fakeDockerCLI(t, false, 0) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli, removeVolumes: tc.removeVolumes}, tc.remove) + + assertResult(t, got, map[string]any{"outcome": "success", "containerId": "c1", "result": tc.action}) + if calls := composeCalls(); !slices.Contains(calls, tc.want) { + t.Errorf("docker CLI ran %v, want %q", calls, tc.want) + } + for _, c := range engine.recorded() { + if strings.HasPrefix(c, "stop ") || strings.HasPrefix(c, "remove ") { + t.Errorf("engine call %q: Compose owns the project's containers", c) + } + } + }) + } +} + +func TestStopOfAComposeServiceReportsWhatComposeRefused(t *testing.T) { + startFakeEngine(t, map[string]map[string]string{"c1": {"com.docker.compose.project": "proj"}}) + cli, _ := fakeDockerCLI(t, false, 3) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli}, false) + + assertResult(t, got, map[string]any{"outcome": "error"}) + if msg, _ := got["message"].(string); !strings.HasPrefix(msg, "stopped: ") || !strings.Contains(msg, "compose said no") { + t.Errorf("message = %q, want Compose's refusal", msg) + } +} + +func TestStopOfAComposeServiceWithoutComposeV2IsAnError(t *testing.T) { + startFakeEngine(t, map[string]map[string]string{"c1": {"com.docker.compose.project": "proj"}}) + cli, _ := fakeDockerCLI(t, true, 0) + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1", dockerPath: cli}, false) + + assertResult(t, got, map[string]any{"outcome": "error", "message": "'docker compose' (v2) not found"}) +} + +func TestStopWithAnUnusableDockerHostIsAnError(t *testing.T) { + t.Setenv("DOCKER_HOST", "not a docker host") + + got := runStopOrDownForTest(t, stopOpts{containerID: "c1"}, false) + + assertResult(t, got, map[string]any{"outcome": "error"}) + if msg, _ := got["message"].(string); !strings.HasPrefix(msg, "Docker engine: ") { + t.Errorf("message = %q, want the engine client's error", msg) + } +} diff --git a/internal/cli/templates_apply.go b/internal/cli/templates_apply.go index 9f7c2c3..3a3fbb3 100644 --- a/internal/cli/templates_apply.go +++ b/internal/cli/templates_apply.go @@ -26,8 +26,9 @@ func realTemplatesApplyCmd() *cobra.Command { Use: "apply", Short: "Apply a template to the project", RunE: func(cmd *cobra.Command, args []string) error { - if templateID == "" { - return fmt.Errorf("Missing required argument: --template-id") + selected, err := templateSelection(templateID, templateArgs, featuresJSON, omitPaths) + if err != nil { + return err } logger := log.New(log.Options{ @@ -37,43 +38,8 @@ func realTemplatesApplyCmd() *cobra.Command { }) ws := resolvePath(workspaceFolder) - - // Parse template args - var options map[string]string - if templateArgs != "" { - if err := jsonc.Unmarshal([]byte(templateArgs), &options); err != nil { - // TS emits a fixed message (no parser detail) so both the - // non-object and malformed-JSON paths canonicalize identically. - return fmt.Errorf("Invalid template arguments provided.") - } - } - if options == nil { - options = map[string]string{} - } - - // Parse omit paths - var omit []string - if omitPaths != "" { - jsonc.Unmarshal([]byte(omitPaths), &omit) - } - - // Parse features - var featureOpts []templates.TemplateFeatureOption - if featuresJSON != "" && featuresJSON != "[]" { - if err := json.Unmarshal([]byte(featuresJSON), &featureOpts); err != nil { - return fmt.Errorf("Invalid features JSON: %w", err) - } - } - ociClient := newOCIClient(cmd.Context(), logger) - selected := templates.SelectedTemplate{ - ID: templateID, - Options: options, - Features: featureOpts, - OmitPaths: omit, - } - files, err := templates.FetchAndApply(templates.ApplyParams{ OCIClient: ociClient, Logger: logger, @@ -85,7 +51,7 @@ func realTemplatesApplyCmd() *cobra.Command { return err } - data, _ := json.Marshal(map[string]interface{}{"files": files}) + data, _ := json.Marshal(map[string]any{"files": files}) fmt.Fprintln(outputFor(cmd).Stdout(), string(data)) return nil }, @@ -101,3 +67,35 @@ func realTemplatesApplyCmd() *cobra.Command { f.StringVar(&omitPaths, "omit-paths", "[]", "Paths to omit JSON.") return cmd } + +// templateSelection validates the apply flags and turns them into the template +// to apply: --template-id is required, --template-args must be a JSON object of +// strings, and --features a JSON array; --omit-paths is best-effort, as in TS. +func templateSelection(templateID, templateArgs, featuresJSON, omitPaths string) (templates.SelectedTemplate, error) { + if templateID == "" { + return templates.SelectedTemplate{}, fmt.Errorf("Missing required argument: --template-id") + } + // A nil options map is applied as an empty one. + var options map[string]string + if templateArgs != "" { + if err := jsonc.Unmarshal([]byte(templateArgs), &options); err != nil { + // TS emits a fixed message (no parser detail) so both the + // non-object and malformed-JSON paths canonicalize identically. + return templates.SelectedTemplate{}, fmt.Errorf("Invalid template arguments provided.") + } + } + var omit []string + _ = jsonc.Unmarshal([]byte(omitPaths), &omit) // empty or malformed omits nothing + var featureOpts []templates.TemplateFeatureOption + if featuresJSON != "" { + if err := json.Unmarshal([]byte(featuresJSON), &featureOpts); err != nil { + return templates.SelectedTemplate{}, fmt.Errorf("Invalid features JSON: %w", err) + } + } + return templates.SelectedTemplate{ + ID: templateID, + Options: options, + Features: featureOpts, + OmitPaths: omit, + }, nil +} diff --git a/internal/cli/templates_apply_held_test.go b/internal/cli/templates_apply_held_test.go new file mode 100644 index 0000000..ac5e395 --- /dev/null +++ b/internal/cli/templates_apply_held_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "slices" + "strings" + "testing" +) + +func TestTemplateSelection(t *testing.T) { + sel, err := templateSelection("ghcr.io/o/t/node:1", `{"version": "20"}`, `[{"id":"ghcr.io/o/f/go:1"}]`, `[".github/*"]`) + if err != nil { + t.Fatal(err) + } + if sel.ID != "ghcr.io/o/t/node:1" || sel.Options["version"] != "20" { + t.Errorf("selection = %+v", sel) + } + if len(sel.Features) != 1 || sel.Features[0].ID != "ghcr.io/o/f/go:1" { + t.Errorf("features = %+v", sel.Features) + } + if !slices.Equal(sel.OmitPaths, []string{".github/*"}) { + t.Errorf("omit paths = %q", sel.OmitPaths) + } + + // Empty flags select nothing extra; a malformed --omit-paths omits nothing. + bare, err := templateSelection("ghcr.io/o/t/node:1", "", "", "[") + if err != nil { + t.Fatal(err) + } + if len(bare.Options) != 0 || len(bare.Features) != 0 || len(bare.OmitPaths) != 0 { + t.Errorf("bare selection = %+v, want nothing besides the id", bare) + } +} + +func TestTemplateSelectionRejects(t *testing.T) { + cases := []struct { + name, id, args, features, want string + }{ + {"missing id", "", "{}", "[]", "Missing required argument: --template-id"}, + {"args not an object", "x", `["a"]`, "[]", "Invalid template arguments provided."}, + {"malformed args", "x", `{`, "[]", "Invalid template arguments provided."}, + {"malformed features", "x", "{}", `[{`, "Invalid features JSON"}, + } + for _, c := range cases { + _, err := templateSelection(c.id, c.args, c.features, "[]") + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", c.name, err, c.want) + } + } +} + +// A template that cannot be fetched fails the command without printing files. +func TestTemplatesApplyReportsFetchError(t *testing.T) { + cmd := realTemplatesApplyCmd() + out := &captureOutput{} + cmd.SetOut(&out.out) + cmd.SetErr(&out.err) + cmd.SetArgs([]string{"--template-id", "UPPER/Not A Ref", "--workspace-folder", t.TempDir()}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "parse template ID") { + t.Fatalf("err = %v, want the template id parse error", err) + } + if strings.Contains(out.out.String(), `"files"`) { + t.Errorf("stdout = %q, want no files result", out.out.String()) + } +} diff --git a/internal/cli/up.go b/internal/cli/up.go index 3f90006..b561659 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -1,20 +1,23 @@ package cli import ( + "cmp" "context" "encoding/json" "errors" "fmt" "os" "path/filepath" + "slices" "strings" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" dockermount "github.com/moby/moby/api/types/mount" "github.com/devcontainers/cli/internal/config" "github.com/devcontainers/cli/internal/docker" coreerrors "github.com/devcontainers/cli/internal/errors" - "github.com/devcontainers/cli/internal/features" "github.com/devcontainers/cli/internal/imagemeta" "github.com/devcontainers/cli/internal/lifecycle" "github.com/devcontainers/cli/internal/log" @@ -180,28 +183,28 @@ func validateUpOpts(opts *upOpts) error { if err := validateMounts(opts.mounts); err != nil { return err } - for _, v := range []struct { - flag, val string - choices []string - }{ - {"log-level", opts.logLevel, []string{"info", "debug", "trace"}}, - {"log-format", opts.logFormat, []string{"text", "json"}}, - {"buildkit", opts.buildkit, []string{"auto", "never"}}, - {"workspace-mount-consistency", opts.workspaceMountConsistency, []string{"consistent", "cached", "delegated"}}, - {"gpu-availability", opts.gpuAvailability, []string{"all", "detect", "none"}}, - {"default-user-env-probe", opts.defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, - {"update-remote-user-uid-default", opts.updateRemoteUserUIDDefault, []string{"on", "off", "never"}}, - } { - if err := validateEnum(v.flag, v.val, v.choices); err != nil { - return err - } + if err := validateEnums( + enumFlag{"log-level", opts.logLevel, logLevels}, + enumFlag{"log-format", opts.logFormat, logFormats}, + enumFlag{"buildkit", opts.buildkit, []string{"auto", "never"}}, + enumFlag{"workspace-mount-consistency", opts.workspaceMountConsistency, []string{"consistent", "cached", "delegated"}}, + enumFlag{"gpu-availability", opts.gpuAvailability, []string{"all", "detect", "none"}}, + enumFlag{"default-user-env-probe", opts.defaultUserEnvProbe, []string{"none", "loginShell", "interactiveShell", "loginInteractiveShell"}}, + enumFlag{"update-remote-user-uid-default", opts.updateRemoteUserUIDDefault, []string{"on", "off", "never"}}, + ); err != nil { + return err } return validateTerminalImplications(opts.terminalColumns, opts.terminalRows) } // resolveUpPaths resolves the workspace/config/override paths to absolute form. -// Each result is empty when its corresponding flag was not set. +// Each result is empty when its corresponding flag was not set, except the +// workspace: 0.88 defaults --workspace-folder to the current directory when +// neither --workspace-folder, --id-label nor --override-config is given. func resolveUpPaths(opts *upOpts) (workspaceFolder, configPath, overridePath string) { + if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.overrideConfig == "" { + opts.workspaceFolder, _ = os.Getwd() + } if opts.workspaceFolder != "" { workspaceFolder = resolvePath(opts.workspaceFolder) } @@ -214,6 +217,24 @@ func resolveUpPaths(opts *upOpts) (workspaceFolder, configPath, overridePath str return } +// upHasNothingToRead reports whether up was given neither a workspace nor a +// config. TS reads the config before it looks for a container (configContainer.ts +// resolveWithLocalFolder), so there is nothing to read even when --id-label +// finds a container. +func upHasNothingToRead(workspaceFolder, configPath, overridePath string) bool { + return workspaceFolder == "" && configPath == "" && overridePath == "" +} + +// runUp drives the whole `up` flow against the engine; its pure decisions live +// in resolveUpPaths, upHasNothingToRead, upRemoteUser and addUpConfiguration. +// +// mutate-lane: TestParityMatrix - every `up` case of docs/parity/parity-matrix.yaml +// runs it, e.g. up.image-success, up.compose-with-name-success, +// up.id-label-only-existing-container-error, up.config-without-workspace-success, +// up.compose-dockerfile-with-features-reuse-stopped-container-with-persisted-overrides-success +// and up.image-post-create-remote-env-success; also TestE2E_UpReusesStoppedContainer +// (restarts a stopped single container), TestE2E_BuildUpExec_Image and +// TestE2E_RemoveExistingBeforeInitialize. func runUp(ctx context.Context, out Output, opts *upOpts) error { if err := validateUpOpts(opts); err != nil { return writeValidationError(out, err.Error()) @@ -235,11 +256,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { // Non-blocking hint (interactive TTY only) if the host was never checked or a // previous `devcontainer check` found a failing configuration. warnUncheckedHost(out) - // 0.88: default --workspace-folder to the current directory when neither - // --workspace-folder, --id-label nor --override-config is given. - if opts.workspaceFolder == "" && len(opts.idLabels) == 0 && opts.overrideConfig == "" { - opts.workspaceFolder, _ = os.Getwd() - } workspaceFolder, configPath, overridePath := resolveUpPaths(opts) @@ -250,12 +266,11 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { defer closeLog() logger := log.New(log.Options{ - Version: cliVersion(), - Level: log.ParseLevel(opts.logLevel), - Format: opts.logFormat, - Writer: logDst, - Dimensions: logDimensions(opts.terminalColumns, opts.terminalRows), - Secrets: secretValuesFromFile(opts.secretsFile), + Version: cliVersion(), + Level: log.ParseLevel(opts.logLevel), + Format: opts.logFormat, + Writer: logDst, + Secrets: secretValuesFromFile(opts.secretsFile), }) // Route the OCI auth diagnostic lines at this command's logger. ociAuthPolicy(ctx).SetLogger(logger) @@ -273,52 +288,11 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { // Otherwise, derive labels from workspace folder + config file path. idLabels := opts.idLabels - // Look for existing container early — needed for --expect-existing-container - // and for the --id-label-only path (no workspace-folder). - if len(idLabels) > 0 { - existingIDs, _ := engine.ListContainers(ctx, true, idLabels) - - if len(existingIDs) > 0 && opts.removeExisting { - logger.Write(fmt.Sprintf("Removing existing container %s...", existingIDs[0]), log.LevelInfo) - _ = engine.RemoveContainer(ctx, existingIDs[0]) - existingIDs = nil - } - - if len(existingIDs) > 0 { - containerID := existingIDs[0] - logger.Write(fmt.Sprintf("Using existing container %s", containerID), log.LevelInfo) - - inspectResp, inspErr := engine.InspectContainer(ctx, containerID) - if inspErr == nil && inspectResp.State != nil && !inspectResp.State.Running { - logger.Write("Starting existing container...", log.LevelInfo) - if startErr := engine.StartContainer(ctx, containerID); startErr != nil { - return writeErrorResult(out, fmt.Sprintf("Failed to start container: %v", startErr)) - } - } - - // Run lifecycle hooks on reattach: postAttach every time, - // postStart when restarted; onCreate/updateContent/postCreate are gated - // by markers. Hooks come from the container's baked metadata. - if !opts.skipPostCreate { - run.runReattachLifecycle(ctx, containerID) - } - - return run.finishUp(ctx, containerID, nil, nil) - } - - if opts.expectExistingContainer { - return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{ - Description: "The expected container does not exist.", - })) - } - - // No existing container found with --id-label, fall through to config-based creation. - if workspaceFolder == "" { - return writeErrorResult(out, "No dev container config and no workspace found.") - } + if upHasNothingToRead(workspaceFolder, configPath, overridePath) { + return writeErrorResult(out, "No dev container config and no workspace found.") } - // Load config — requires workspaceFolder (guaranteed non-empty at this point) + // Load config loadResult, err := config.LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overridePath, config.MountOptions{ MountWorkspaceGitRoot: opts.mountWorkspaceGitRoot, MountGitWorktreeCommonDir: opts.mountGitWorktreeCommonDir, @@ -348,7 +322,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { idCtx := config.SubstitutionContext{ HostSubContext: loadResult.HostSub, - DevContainerID: config.ComputeDevContainerID(idLabelsMap(idLabels)), + DevContainerID: config.ComputeDevContainerID(config.EnvMap(idLabels)), } resolver := config.NewVariableResolver() if err := resolver.BeforeContainerInto(idCtx, cfg); err != nil { @@ -358,13 +332,10 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeErrorResult(out, fmt.Sprintf("apply workspace pre-container substitutions: %v", err)) } - if derr := enforceDisallowedFeatures(ctx, cfg, logger); derr != nil { - return writeErrorJSON(out, coreerrors.ToErrorOutput(derr)) - } - // Apply --remove-existing-container up-front, BEFORE initializeCommand, so a // failed initializeCommand (or rebuild) does not leave the old container - // running — matching the VS Code extension (upstream #844). + // running — matching the VS Code extension (upstream #844). TS removes it while + // finding the id labels, before the disallowed-Feature check. existingIDs, _ := engine.ListContainers(ctx, true, idLabels) if len(existingIDs) > 0 && opts.removeExisting { logger.Write(fmt.Sprintf("Removing existing container %s...", existingIDs[0]), log.LevelInfo) @@ -372,6 +343,10 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { existingIDs = nil } + if derr := enforceDisallowedFeatures(ctx, cfg, opts.additionalFeatures, logger, stopRunningContainer(engine, idLabels)); derr != nil { + return writeErrorJSON(out, coreerrors.ToErrorOutput(derr)) + } + // Run initializeCommand on the host (before container creation). // A failure aborts `up` with an error outcome, matching the TS CLI (which // throws a ContainerError). Swallowing it produced silently-broken setups. @@ -446,39 +421,8 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) } - // Determine remote user: config → container metadata → image metadata → - // container USER → root - remoteUser := cfg.RemoteUser - if remoteUser == "" && inspectResp.Config != nil { - // Check the container's metadata label for remoteUser. - metaEntries := imagemeta.ReadMetadataFromLabels(inspectResp.Config.Labels, logger) - if len(metaEntries) > 0 { - metaMerged := imagemeta.MergeConfiguration(metaEntries) - if metaMerged.RemoteUser != "" { - remoteUser = metaMerged.RemoteUser - } - } - } - if remoteUser == "" && inspectResp.Image != "" { - // Fall back to the base image's baked metadata. For compose services the - // container's own devcontainer.metadata label is a config-derived override - // that may lack remoteUser, while the base image (e.g. a devcontainers/* - // image) still carries it in its metadata label. - if img, imgErr := engine.InspectImage(ctx, inspectResp.Image); imgErr == nil && img.Config != nil { - metaMerged := imagemeta.MergeConfiguration(imagemeta.ReadMetadataFromLabels(img.Config.Labels, logger)) - if metaMerged.RemoteUser != "" { - remoteUser = metaMerged.RemoteUser - } - } - } - if remoteUser == "" { - if inspectResp.Config != nil { - remoteUser = inspectResp.Config.User - } - if remoteUser == "" { - remoteUser = "root" - } - } + imageLabels := func(ref string) map[string]string { return run.imageLabels(ctx, ref) } + remoteUser := upRemoteUser(cfg.RemoteUser, inspectResp, imageLabels, logger) remoteWorkspaceFolder := loadResult.WorkspaceConfig.WorkspaceFolder @@ -490,260 +434,166 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } } - result := map[string]interface{}{ - "outcome": "success", - "containerId": containerID, - "remoteUser": remoteUser, - "remoteWorkspaceFolder": remoteWorkspaceFolder, - "ociAuthDiagnostics": ociAuthDiagnostics(ctx), + result := map[string]any{ + "outcome": "success", + "containerId": containerID, + "remoteUser": remoteUser, + "ociAuthDiagnostics": ociAuthDiagnostics(ctx), + } + // TS leaves remoteWorkspaceFolder out when there is none (up --config with no + // workspace and no workspaceFolder in the config). + if remoteWorkspaceFolder != "" { + result["remoteWorkspaceFolder"] = remoteWorkspaceFolder } if opts.composeProjectName != "" { result["composeProjectName"] = opts.composeProjectName } - if opts.includeConfig || opts.includeMergedConfig { - // Reuse inspectResp from above — no need to re-inspect the same container. - containerEnv := map[string]string{} - if inspectResp.Config != nil { - for _, e := range inspectResp.Config.Env { - if i := strings.IndexByte(e, '='); i >= 0 { - containerEnv[e[:i]] = e[i+1:] - } - } - } - - if opts.includeConfig { - cfgJSON, _ := json.Marshal(cfg) - var cfgGeneric interface{} - json.Unmarshal(cfgJSON, &cfgGeneric) - substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric) - if subErr != nil { - return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr)) - } - if subMap, ok := substituted.(map[string]interface{}); ok { - for k, v := range subMap { - if v == nil { - delete(subMap, k) - } - } - if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { - scheme := "vscode-fileHost" - if opts.configPath != "" || opts.overrideConfig != "" { - scheme = "file" - } - subMap["configFilePath"] = map[string]interface{}{ - "$mid": 1, - "path": cfp, - "scheme": scheme, - } - } else { - delete(subMap, "configFilePath") - } - delete(subMap, "extensions") - delete(subMap, "settings") - delete(subMap, "devPort") - result["configuration"] = subMap - } else { - result["configuration"] = substituted - } - } - - if opts.includeMergedConfig { - // Read the image's metadata (features are baked into the image), not the - // container's — the container now carries the full config metadata as a - // label, so reading it here and re-adding the config entry would double - // the lifecycle commands. The output entry uses the config's - // remoteEnv regardless of --omit-config-remote-env-from-metadata (that - // flag only affects the persisted label). - var entries []imagemeta.Entry - if inspectResp.Image != "" { - if img, imgErr := engine.InspectImage(ctx, inspectResp.Image); imgErr == nil && img.Config != nil { - entries = imagemeta.ReadMetadataFromLabels(img.Config.Labels, logger) - } - } - configEntry := configToMetadataEntry(cfg) - entries = append(entries, configEntry) - merged := imagemeta.MergeConfiguration(entries) - // image is spread from the config into mergedConfiguration (TS); it is - // not part of the metadata entries. - merged.Image = cfg.Image - if cfgResult, ok := result["configuration"].(map[string]interface{}); ok { - if cfpURI, ok := cfgResult["configFilePath"]; ok { - merged.ConfigFilePath = cfpURI - } - } - - // Substitute containerEnv into merged config - mergedJSON, _ := json.Marshal(merged) - var mergedGeneric interface{} - json.Unmarshal(mergedJSON, &mergedGeneric) - mergedSub, subErr := resolveContainerVariables(containerEnv, mergedGeneric) - if subErr != nil { - return writeErrorResult(out, fmt.Sprintf("resolve merged container variables: %v", subErr)) - } - // Carry over config-only properties spread into mergedConfiguration by - // the TS CLI that the typed MergedConfig does not model. - if mm, ok := mergedSub.(map[string]interface{}); ok { - if cfgResult, ok := result["configuration"].(map[string]interface{}); ok { - for _, k := range mergedPassthroughKeys { - if v, ok := cfgResult[k]; ok { - mm[k] = v - } - } - } - } - result["mergedConfiguration"] = mergedSub - } + if err := addUpConfiguration(result, opts, cfg, inspectResp, imageLabels, logger); err != nil { + return writeErrorResult(out, err.Error()) } return writeSuccessJSON(out, result) } -func idLabelsMap(labels []string) map[string]string { - result := make(map[string]string, len(labels)) - for _, label := range labels { - if i := strings.IndexByte(label, '='); i >= 0 { - result[label[:i]] = label[i+1:] - } +// imageLabels are the labels of the image ref names, nil when there is no ref +// or the engine cannot inspect it. +// +// mutate-lane: TestParityMatrix - up.update-uid-remote-user-from-image-metadata-success, +// up.compose-features-staging-layout-success and up.image-post-create-remote-env-success +// read the remote user and the merged configuration through it. +func (r *upRunner) imageLabels(ctx context.Context, ref string) map[string]string { + if ref == "" { + return nil + } + img, err := r.engine.InspectImage(ctx, ref) + if err != nil || img.Config == nil { + return nil } - return result + return img.Config.Labels } -// finishUp produces the JSON result for an existing container found via --id-label -// (no config/loadResult available). cfg and loadResult may be nil. -func (r *upRunner) finishUp(ctx context.Context, containerID string, cfg *config.DevContainer, loadResult *config.LoadResult) error { - out, logger, engine := r.out, r.log, r.engine - inspectResp, err := engine.InspectContainer(ctx, containerID) - if err != nil { - return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) +// upRemoteUser is the user up reports and runs the lifecycle as: the config's +// remoteUser, else the container's metadata label, else the base image's +// metadata, else the container's USER, else root. The base image matters for +// compose services: their container's devcontainer.metadata label is a +// config-derived override that may lack remoteUser, while the base image (e.g. +// a devcontainers/* image) still carries it. imageLabels is only called when the +// earlier sources name no user. +func upRemoteUser(cfgUser string, ctr container.InspectResponse, imageLabels func(ref string) map[string]string, logger log.Logger) string { + metadataUser := func(labels map[string]string) string { + return imagemeta.MergeConfiguration(imagemeta.ReadMetadataFromLabels(labels, logger)).RemoteUser } - - remoteUser := "" - if cfg != nil { - remoteUser = cfg.RemoteUser + if cfgUser != "" { + return cfgUser } - if remoteUser == "" && inspectResp.Config != nil { - metaEntries := imagemeta.ReadMetadataFromLabels(inspectResp.Config.Labels, logger) - if len(metaEntries) > 0 { - metaMerged := imagemeta.MergeConfiguration(metaEntries) - if metaMerged.RemoteUser != "" { - remoteUser = metaMerged.RemoteUser - } - } + var ctrLabels map[string]string + ctrUser := "" + if ctr.Config != nil { + ctrLabels, ctrUser = ctr.Config.Labels, ctr.Config.User } - if remoteUser == "" { - if inspectResp.Config != nil { - remoteUser = inspectResp.Config.User - } - if remoteUser == "" { - remoteUser = "root" - } - } - - remoteWorkspaceFolder := "" - if loadResult != nil { - remoteWorkspaceFolder = loadResult.WorkspaceConfig.WorkspaceFolder + if user := metadataUser(ctrLabels); user != "" { + return user } - - result := map[string]interface{}{ - "outcome": "success", - "containerId": containerID, - "remoteUser": remoteUser, - "remoteWorkspaceFolder": remoteWorkspaceFolder, - "ociAuthDiagnostics": ociAuthDiagnostics(ctx), + if user := metadataUser(imageLabels(ctr.Image)); user != "" { + return user } - - return writeSuccessJSON(out, result) + return cmp.Or(ctrUser, "root") } -// runReattachLifecycle runs lifecycle hooks when reattaching to an existing -// container (found via --id-label, no config loaded). Hooks come from the -// container's baked devcontainer.metadata; markers gate onCreate/updateContent/ -// postCreate (already ran) and postStart (per startedAt), while postAttach runs -// every attach — matching the TS reattach path. Best-effort: probe -// or shell failures degrade to a warning. -func (r *upRunner) runReattachLifecycle(ctx context.Context, containerID string) { - logger, engine, opts := r.log, r.engine, r.opts - inspectResp, err := engine.InspectContainer(ctx, containerID) - if err != nil || inspectResp.Config == nil { - return - } - entries := imagemeta.ReadMetadataFromLabels(inspectResp.Config.Labels, logger) - merged := imagemeta.MergeConfiguration(entries) - if len(merged.OnCreateCommands)+len(merged.UpdateContentCommands)+len(merged.PostCreateCommands)+len(merged.PostStartCommands)+len(merged.PostAttachCommands) == 0 { - return - } - - remoteUser := merged.RemoteUser - if remoteUser == "" { - remoteUser = inspectResp.Config.User - } - - // Probe remote env + secrets. The config's remoteEnv is already baked into the - // container metadata (merged.RemoteEnv), so it is not re-added here. - var mergedRemoteEnv []string - probeStrategy := lifecycle.UserEnvProbeStrategy(opts.defaultUserEnvProbe) - if merged.UserEnvProbe != "" { - probeStrategy = lifecycle.UserEnvProbeStrategy(merged.UserEnvProbe) - } - if probeStrategy != lifecycle.ProbeNone { - if probeServer, probeErr := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger); probeErr == nil { - probedEnv, _ := lifecycle.ProbeRemoteEnv(logger, probeServer, probeStrategy, remoteUser, opts.containerSessionDataFolder) - probeServer.Close() - for k, v := range probedEnv { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, v)) - } - } - } - if opts.secretsFile != "" { - if secretEnvs, sErr := readSecretsFile(opts.secretsFile); sErr == nil { - mergedRemoteEnv = append(mergedRemoteEnv, secretEnvs...) - } - } - mergedRemoteEnv = append(mergedRemoteEnv, opts.remoteEnvs...) - for k, v := range merged.RemoteEnv { - if v != nil { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, *v)) - } +// addUpConfiguration adds the configuration (--include-configuration) and the +// mergedConfiguration (--include-merged-configuration) to up's result, with +// ${containerEnv:…} resolved from the container's environment as TS does. +// +// mergedConfiguration reads the image's metadata (features are baked into the +// image), not the container's: the container carries the full config metadata +// as a label, so reading it and re-adding the config entry would double the +// lifecycle commands. Its config entry uses the config's remoteEnv regardless of +// --omit-config-remote-env-from-metadata (that flag only affects the persisted +// label). +func addUpConfiguration(result map[string]any, opts *upOpts, cfg *config.DevContainer, ctr container.InspectResponse, imageLabels func(ref string) map[string]string, logger log.Logger) error { + if !opts.includeConfig && !opts.includeMergedConfig { + return nil } - - shellServer, err := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger, mergedRemoteEnv...) - if err != nil { - logger.Write(fmt.Sprintf("Could not start shell server for reattach hooks: %v", err), log.LevelWarning) - return + var containerEnv map[string]string + if ctr.Config != nil { + containerEnv = config.EnvMap(ctr.Config.Env) } - defer shellServer.Close() - executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger} - if markerFolder := resolveContainerUserDataFolder(shellServer); markerFolder != "" { - createdAt := inspectResp.Created - startedAt := "" - if inspectResp.State != nil { - startedAt = inspectResp.State.StartedAt - } - if !markerShouldRun(shellServer, markerFolder, "onCreateCommand", createdAt) { - merged.OnCreateCommands = nil - } - if !markerShouldRun(shellServer, markerFolder, "updateContentCommand", createdAt) { - merged.UpdateContentCommands = nil + if opts.includeConfig { + cfgJSON, _ := json.Marshal(cfg) + var cfgGeneric any + json.Unmarshal(cfgJSON, &cfgGeneric) + substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric) + if subErr != nil { + return fmt.Errorf("resolve configuration container variables: %w", subErr) } - if !markerShouldRun(shellServer, markerFolder, "postCreateCommand", createdAt) { - merged.PostCreateCommands = nil - } - if !markerShouldRun(shellServer, markerFolder, "postStartCommand", startedAt) { - merged.PostStartCommands = nil + if subMap, ok := substituted.(map[string]any); ok { + for k, v := range subMap { + if v == nil { + delete(subMap, k) + } + } + if cfp, ok := subMap["configFilePath"].(string); ok && cfp != "" { + scheme := "vscode-fileHost" + if opts.configPath != "" || opts.overrideConfig != "" { + scheme = "file" + } + subMap["configFilePath"] = map[string]any{ + "$mid": 1, + "path": cfp, + "scheme": scheme, + } + } else { + delete(subMap, "configFilePath") + } + delete(subMap, "extensions") + delete(subMap, "settings") + delete(subMap, "devPort") + result["configuration"] = subMap + } else { + result["configuration"] = substituted + } + } + + if opts.includeMergedConfig { + entries := imagemeta.ReadMetadataFromLabels(imageLabels(ctr.Image), logger) + merged := imagemeta.MergeConfiguration(append(entries, configToMetadataEntry(cfg))) + // image is spread from the config into mergedConfiguration (TS); it is + // not part of the metadata entries. + merged.Image = cfg.Image + + // Substitute containerEnv into merged config + mergedJSON, _ := json.Marshal(merged) + var mergedGeneric any + json.Unmarshal(mergedJSON, &mergedGeneric) + mergedSub, subErr := resolveContainerVariables(containerEnv, mergedGeneric) + if subErr != nil { + return fmt.Errorf("resolve merged container variables: %w", subErr) + } + // Every config property TS mergeConfiguration does not set itself is + // spread into mergedConfiguration ({...config}), the configFilePath URI + // among them. + cfgResult, _ := result["configuration"].(map[string]any) + if mm, ok := mergedSub.(map[string]any); ok { + for k, v := range cfgResult { + if !mergedOwnKeys[k] { + mm[k] = v + } + } } + result["mergedConfiguration"] = mergedSub } - - if hookErr := lifecycle.RunHooks(logger, executor, merged, lifecycle.RunOptions{ - SkipNonBlocking: opts.skipNonBlocking, - SkipPostAttach: opts.skipPostAttach, - Prebuild: opts.prebuild, - }); hookErr != nil { - logger.Write(fmt.Sprintf("Lifecycle hooks failed: %v", hookErr), log.LevelWarning) - } + return nil } +// fromDockerfile builds the config's Dockerfile, installs its Features and +// starts the container. +// +// mutate-lane: TestParityMatrix - up.update-uid-remote-user-from-image-metadata-success, +// up.dockerfile-without-features-omit-config-remote-env-from-metadata-success, +// up.dockerfile-with-parallel-commands-success and up.update-uid-amd64-dockerfile-platform-success +// build through it; also TestE2E_BuildUpExec_Dockerfile. func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, useBuildx bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts @@ -761,7 +611,7 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, // Base-image resolution uses only an explicit config target (not the // auto-added final-stage name), then reads its metadata (remoteUser, etc.). findTarget := "" - if cfg.Build != nil && cfg.Build.Target != "" { + if cfg.Build != nil { findTarget = cfg.Build.Target } baseImage := docker.FindBaseImage(prep.Parsed, buildArgsFromConfig(cfg), findTarget) @@ -778,10 +628,7 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, metadata = append(metadata, imagemeta.Entry{RemoteUser: cfg.RemoteUser, ContainerUser: cfg.ContainerUser}) metadataLabel := imagemeta.GenerateMetadataLabel(metadata) - contextPath := configDir - if cfg.BuildContext() != "" { - contextPath = filepath.Join(configDir, cfg.BuildContext()) - } + contextPath := filepath.Join(configDir, cfg.BuildContext()) upCacheFrom := cacheFromForDockerfileBuild(opts.cacheFrom, cfg) // Bridge the CLI credential chain to the build subprocess via a temporary @@ -818,18 +665,7 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, // Extend with features if any if len(cfg.Features) > 0 { - names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, imageName, cfg.Features, useBuildx, nil, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - }) + names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, imageName, cfg.Features, useBuildx, nil, r.featureBuildOptions(cfg, loadResult, "")) if err != nil { return "", fmt.Errorf("install features: %w", err) } @@ -838,11 +674,20 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, } } - imageName = r.maybeUpdateRemoteUserUID(ctx, imageName, cfg.RemoteUser, cfg) + imageName, err = r.singleContainerRemoteUserUID(ctx, imageName, cfg, workspaceFolder) + if err != nil { + return "", err + } return r.runContainer(ctx, imageName, cfg, loadResult, workspaceFolder, idLabels) } +// fromImage pulls the config's image when it is missing, installs its +// Features and starts the container. +// +// mutate-lane: TestParityMatrix - up.image-success, up.image-with-features-success, +// up.image-with-git-feature-success and up.update-uid-amd64-platform-option-success +// start through it; also TestE2E_BuildUpExec_Image and TestE2E_FeatureUserHomeAndLocalEnv. func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, useBuildx bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts if cfg.Image == "" { @@ -869,19 +714,7 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load // Extend with features if any imageName := cfg.Image if len(cfg.Features) > 0 { - names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, nil, &FeatureBuildOptions{ - Platform: platform, - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - }) + names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, nil, r.featureBuildOptions(cfg, loadResult, platform)) if err != nil { return "", fmt.Errorf("install features: %w", err) } @@ -890,7 +723,10 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load } } - imageName = r.maybeUpdateRemoteUserUID(ctx, imageName, cfg.RemoteUser, cfg) + imageName, err = r.singleContainerRemoteUserUID(ctx, imageName, cfg, workspaceFolder) + if err != nil { + return "", err + } return r.runContainer(ctx, imageName, cfg, loadResult, workspaceFolder, idLabels) } @@ -901,6 +737,10 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load // (produced by an earlier build of the same configuration), so the merged // configuration is recovered from that label the same way as a plain image-based // config. remoteUser/mounts/lifecycle still come from devcontainer.json. +// +// mutate-lane: TestE2E_UpCacheImage - starts a container from a local prebuilt +// image and fails to pull a missing one (--cache-image is Go-only, so no parity +// case covers it). func (r *upRunner) fromCacheImage(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string) (string, error) { logger, engine := r.log, r.engine image := r.opts.cacheImage @@ -913,40 +753,50 @@ func (r *upRunner) fromCacheImage(ctx context.Context, cfg *config.DevContainer, } logger.Write(fmt.Sprintf("Using prebuilt cache image %s; skipping build and feature install", image), log.LevelInfo) - image = r.maybeUpdateRemoteUserUID(ctx, image, cfg.RemoteUser, cfg) + image, err := r.singleContainerRemoteUserUID(ctx, image, cfg, workspaceFolder) + if err != nil { + return "", err + } return r.runContainer(ctx, image, cfg, loadResult, workspaceFolder, idLabels) } -// maybeUpdateRemoteUserUID rebuilds imageName with the remote user's UID/GID -// remapped to the host user's, matching the TS updateRemoteUserUID. Returns the -// (possibly new) image name; a no-op when disabled, when remoteUser is empty or -// root, or when the host UID is not a regular user. Applies to both the image -// and Dockerfile paths of up (previously only the image path called it, so -// Dockerfile-based configs with a remoteUser had broken bind-mount permissions -// on Linux). -func (r *upRunner) maybeUpdateRemoteUserUID(ctx context.Context, imageName, remoteUser string, cfg *config.DevContainer) string { - logger, dockerClient, opts := r.log, r.docker, r.opts - shouldUpdateUID := opts.updateRemoteUserUIDDefault == "on" - if cfg.UpdateRemoteUserUID != nil { - shouldUpdateUID = *cfg.UpdateRemoteUserUID - } - if !shouldUpdateUID || remoteUser == "" || remoteUser == "root" { - return imageName - } - hostUID := os.Getuid() - hostGID := os.Getgid() - if hostUID <= 0 { - return imageName - } - bkInfo := dockerClient.DetectBuildKit(ctx) - updatedImage, err := docker.UpdateRemoteUserUID(ctx, dockerClient, logger, imageName, remoteUser, hostUID, hostGID, bkInfo.Available) - if err != nil { - logger.Write(fmt.Sprintf("UID update failed: %v", err), log.LevelWarning) - return imageName +// singleContainerRemoteUserUID is TS singleContainer's updateRemoteUserUID call: +// the merged configuration is the image's metadata and the config's, and the +// user is the runArgs' --user, else the merged containerUser. +func (r *upRunner) singleContainerRemoteUserUID(ctx context.Context, imageName string, cfg *config.DevContainer, workspaceFolder string) (string, error) { + imageDetails := func() (image.InspectResponse, error) { return r.engine.InspectImage(ctx, imageName) } + var entries []imagemeta.Entry + if details, err := imageDetails(); err == nil && details.Config != nil { + entries = imagemeta.ReadMetadataFromLabels(details.Config.Labels, r.log) + } + merged := imagemeta.MergeConfiguration(append(entries, configMetadataEntry(cfg, r.opts.omitConfigRemoteEnvFromMeta))) + user := cmp.Or(findUserArg(cfg.RunArgs), merged.ContainerUser) + return r.updateRemoteUserUID(ctx, merged, imageName, imageDetails, user, workspaceFolder, r.persistedFolder()) +} + +// findUserArg is TS findUserArg: the last -u/--user of runArgs. +func findUserArg(runArgs []string) string { + for i := len(runArgs) - 1; i >= 0; i-- { + arg := runArgs[i] + if (arg == "-u" || arg == "--user") && i+1 < len(runArgs) { + return runArgs[i+1] + } + for _, p := range []string{"-u=", "--user="} { + if v, ok := strings.CutPrefix(arg, p); ok { + return v + } + } } - return updatedImage + return "" } +// runContainer creates and starts the single container of an image or +// Dockerfile config. +// +// mutate-lane: TestParityMatrix - up.image-success, up.app-port-success, +// up.override-command-false-success, up.workspace-mount-custom-success, +// up.image-with-mounts-success and up.config-without-workspace-success create +// their container through it; also TestE2E_ReadonlyMountObject. func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts // Inspect image once — used for metadata and containerUser resolution. @@ -965,21 +815,10 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf // what the TS CLI adds with `-l`. containerMetadataLabel := imagemeta.GenerateMetadataLabel(metaEntries) - containerWorkspace := loadResult.WorkspaceConfig.WorkspaceFolder - // Build CMD script with feature entrypoints - var cmdScript strings.Builder - cmdScript.WriteString("echo Container started\ntrap \"exit 0\" 15\n") - for _, ep := range merged.Entrypoints { - cmdScript.WriteString(ep + "\n") - } - cmdScript.WriteString("exec \"$@\"\nwhile sleep 1 & wait $!; do :; done") + cmdScript := docker.BuildEntrypointScript(merged.Entrypoints) - // User: merged containerUser → image USER → omit (docker default) - containerUser := merged.ContainerUser - if containerUser == "" && imageInspect.Config != nil && imageInspect.Config.User != "" { - containerUser = imageInspect.Config.User - } + containerUser := containerRunUser(merged.ContainerUser, imageInspect) // Container env from the merged metadata (image + config), NOT remote-env // (applied at exec/lifecycle time). The merged containerEnv now applies the @@ -992,42 +831,11 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf envVars = append(envVars, fmt.Sprintf("%s=%s", k, v)) } - // Mounts. Use the computed workspaceMount (which honors a custom - // `workspaceMount` in the config and the git-root source folder) rather than - // always binding the workspace folder. Fall back to a bind if unset. - var wsMount dockermount.Mount - if spec := loadResult.WorkspaceConfig.WorkspaceMount; spec != "" { - parsed, parseErr := docker.ParseMountSpec(spec) - if parseErr != nil { - return "", fmt.Errorf("invalid workspaceMount %q: %w", spec, parseErr) - } - wsMount = parsed - } else { - wsMount = dockermount.Mount{Type: dockermount.TypeBind, Source: workspaceFolder, Target: containerWorkspace} - } - if opts.workspaceMountConsistency != "" { - wsMount.Consistency = dockermount.Consistency(opts.workspaceMountConsistency) - } - mounts := []dockermount.Mount{wsMount} - // Extra workspace-derived mounts (currently the Git worktree common dir from - // --mount-git-worktree-common-dir) so Git works inside the container. - for _, spec := range loadResult.WorkspaceConfig.AdditionalMounts { - parsed, parseErr := docker.ParseMountSpec(spec) - if parseErr != nil { - return "", fmt.Errorf("invalid additional mount %q: %w", spec, parseErr) - } - if opts.workspaceMountConsistency != "" { - parsed.Consistency = dockermount.Consistency(opts.workspaceMountConsistency) - } - mounts = append(mounts, parsed) - } - idLabelMap := map[string]string{} - for _, label := range idLabels { - if i := strings.IndexByte(label, '='); i >= 0 { - idLabelMap[label[:i]] = label[i+1:] - } + mounts, err := upWorkspaceMounts(loadResult.WorkspaceConfig, workspaceFolder, opts.workspaceMountConsistency) + if err != nil { + return "", err } - devcontainerID := config.ComputeDevContainerID(idLabelMap) + devcontainerID := config.ComputeDevContainerID(config.EnvMap(idLabels)) if len(merged.Mounts) > 0 { metadataMounts, err := mountsFromMetadata(merged.Mounts, devcontainerID) if err != nil { @@ -1035,13 +843,11 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf } mounts = append(mounts, metadataMounts...) } - for _, m := range opts.mounts { - parsed, parseErr := docker.ParseMountSpec(m) - if parseErr != nil { - return "", fmt.Errorf("invalid --mount %q: %w", m, parseErr) - } - mounts = append(mounts, parsed) + flagMounts, err := parseFlagMounts(opts.mounts) + if err != nil { + return "", err } + mounts = append(mounts, flagMounts...) // GPU support: add --gpus all if config requests GPU and availability allows it. // gpu:false must NOT enable GPUs (a raw-length check treated "false" as truthy). @@ -1064,7 +870,7 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf // overrideCommand is false, append the image's own entrypoint+cmd so the // script's `exec "$@"` runs them (e.g. dind, databases) instead of just // sleeping — matching the TS CLI. Default (nil/true) keeps the sleep loop. - cmdArgs := []string{"-c", cmdScript.String(), "-"} + cmdArgs := []string{"-c", cmdScript, "-"} if merged.OverrideCommand != nil && !*merged.OverrideCommand && imageInspect.Config != nil { cmdArgs = append(cmdArgs, imageInspect.Config.Entrypoint...) cmdArgs = append(cmdArgs, imageInspect.Config.Cmd...) @@ -1105,6 +911,64 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf return containerID, nil } +// containerRunUser is the user the container runs as: the merged +// containerUser, else the image's USER, else "" (docker's default). +func containerRunUser(metadataUser string, img image.InspectResponse) string { + imageUser := "" + if img.Config != nil { + imageUser = img.Config.User + } + return cmp.Or(metadataUser, imageUser) +} + +// upWorkspaceMounts are the container's workspace mounts. The workspace mount is +// the computed workspaceMount (which honors a custom `workspaceMount` in the +// config and the git-root source folder), else a bind of the workspace folder; +// with no workspace and no workspaceMount (up --config alone) TS mounts nothing. +// The extra workspace-derived mounts (currently the Git worktree common dir from +// --mount-git-worktree-common-dir) follow so Git works inside the container. +// --workspace-mount-consistency, when set, applies to all of them. +func upWorkspaceMounts(wc *config.WorkspaceConfig, workspaceFolder, consistency string) ([]dockermount.Mount, error) { + var mounts []dockermount.Mount + if spec := wc.WorkspaceMount; spec != "" || workspaceFolder != "" { + wsMount := dockermount.Mount{Type: dockermount.TypeBind, Source: workspaceFolder, Target: wc.WorkspaceFolder} + if spec != "" { + parsed, parseErr := docker.ParseMountSpec(spec) + if parseErr != nil { + return nil, fmt.Errorf("invalid workspaceMount %q: %w", spec, parseErr) + } + wsMount = parsed + } + mounts = append(mounts, wsMount) + } + for _, spec := range wc.AdditionalMounts { + parsed, parseErr := docker.ParseMountSpec(spec) + if parseErr != nil { + return nil, fmt.Errorf("invalid additional mount %q: %w", spec, parseErr) + } + mounts = append(mounts, parsed) + } + if consistency != "" { + for i := range mounts { + mounts[i].Consistency = dockermount.Consistency(consistency) + } + } + return mounts, nil +} + +// parseFlagMounts parses the --mount flags. +func parseFlagMounts(specs []string) ([]dockermount.Mount, error) { + var mounts []dockermount.Mount + for _, m := range specs { + parsed, parseErr := docker.ParseMountSpec(m) + if parseErr != nil { + return nil, fmt.Errorf("invalid --mount %q: %w", m, parseErr) + } + mounts = append(mounts, parsed) + } + return mounts, nil +} + // resolveComposeProjectName computes the compose project name following the TS // CLI resolution chain: 1. COMPOSE_PROJECT_NAME env, 2. .env // COMPOSE_PROJECT_NAME, 3. name: from the compose config, 4. directory-based @@ -1120,10 +984,10 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en // 2. .env file (already handled by config.DockerComposeFilePaths, but check here too) configDir := filepath.Dir(cfg.ConfigFilePath) if data, err := os.ReadFile(filepath.Join(configDir, ".env")); err == nil { - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { line = strings.TrimSpace(line) - if strings.HasPrefix(line, "COMPOSE_PROJECT_NAME=") { - val := strings.TrimSpace(strings.TrimPrefix(line, "COMPOSE_PROJECT_NAME=")) + if after, ok := strings.CutPrefix(line, "COMPOSE_PROJECT_NAME="); ok { + val := strings.TrimSpace(after) if val != "" { return docker.ToProjectName(val, newNames) } @@ -1139,8 +1003,8 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en } // "devcontainer" might be the default from docker compose config. // Check if it's explicitly in any compose file. - for i := len(composeFiles) - 1; i >= 0; i-- { - raw, readErr := os.ReadFile(composeFiles[i]) + for _, composeFile := range slices.Backward(composeFiles) { + raw, readErr := os.ReadFile(composeFile) if readErr == nil && strings.Contains(string(raw), "name:") { return docker.ToProjectName(name, newNames) } @@ -1156,25 +1020,6 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en return docker.ToProjectName(filepath.Base(workingDir), newNames) } -// overrideDirFor returns the directory that compose override files are written -// to. When userDataFolder is set the overrides live in a stable subdir so a -// container can be restarted without re-injecting them; otherwise a temp dir is -// used and the returned cleanup removes it. cleanup is always non-nil. -func overrideDirFor(userDataFolder, tmpPattern string) (dir string, cleanup func(), err error) { - if userDataFolder != "" { - dir = filepath.Join(userDataFolder, "docker-compose") - if err := os.MkdirAll(dir, 0755); err != nil { - return "", func() {}, fmt.Errorf("create override dir: %w", err) - } - return dir, func() {}, nil - } - dir, err = os.MkdirTemp("", tmpPattern) - if err != nil { - return "", func() {}, fmt.Errorf("create temp override dir: %w", err) - } - return dir, func() { os.RemoveAll(dir) }, nil -} - // composeServiceBuild captures the image and `build:` fields of a compose // service that fromCompose needs, parsed once from `docker compose config` // output instead of re-walking the untyped map at each use. @@ -1187,20 +1032,20 @@ type composeServiceBuild struct { Args map[string]string } -func parseComposeServiceBuild(composeConfig map[string]interface{}, service string) composeServiceBuild { +func parseComposeServiceBuild(composeConfig map[string]any, service string) composeServiceBuild { var out composeServiceBuild - services, ok := composeConfig["services"].(map[string]interface{}) + services, ok := composeConfig["services"].(map[string]any) if !ok { return out } - svc, ok := services[service].(map[string]interface{}) + svc, ok := services[service].(map[string]any) if !ok { return out } if img, ok := svc["image"].(string); ok { out.Image = img } - build, ok := svc["build"].(map[string]interface{}) + build, ok := svc["build"].(map[string]any) if !ok { return out } @@ -1214,7 +1059,7 @@ func parseComposeServiceBuild(composeConfig map[string]interface{}, service stri if tgt, ok := build["target"].(string); ok { out.Target = tgt } - if args, ok := build["args"].(map[string]interface{}); ok { + if args, ok := build["args"].(map[string]any); ok { out.Args = make(map[string]string, len(args)) for k, v := range args { out.Args[k] = fmt.Sprintf("%v", v) @@ -1223,6 +1068,13 @@ func parseComposeServiceBuild(composeConfig map[string]interface{}, service stri return out } +// fromCompose starts (or reuses) the service container of a Docker Compose +// config. +// +// mutate-lane: TestParityMatrix - up.compose-with-name-success, +// up.compose-image-without-features-success, up.compose-dockerfile-with-features-success, +// up.compose-dockerfile-with-features-reuse-stopped-container-with-persisted-overrides-success +// and up.compose-image-with-mounts-success start their project through it. func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, useBuildx bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts if cfg.Service == "" { @@ -1244,497 +1096,77 @@ func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, lo // Stream compose build/up progress to the same destination as docker builds. composeClient.ProgressWriter = dockerClient.ProgressWriter + composeConfig, err := composeClient.Config(ctx, composeFiles, "") + if err != nil { + return "", err + } projectName := resolveComposeProjectName(ctx, cfg, env, composeFiles, composeClient) logger.Write(fmt.Sprintf("Compose project: %s, service: %s", projectName, cfg.Service), log.LevelInfo) + opts.composeProjectName = projectName - // Check for existing compose container (skip build if it exists) - existingComposeIDs, _ := engine.ListContainers(ctx, true, []string{ + existingComposeIDs, err := engine.ListContainers(ctx, true, []string{ fmt.Sprintf("com.docker.compose.project=%s", projectName), fmt.Sprintf("com.docker.compose.service=%s", cfg.Service), }) - + if err != nil { + return "", fmt.Errorf("list compose containers: %w", err) + } if opts.expectExistingContainer && len(existingComposeIDs) == 0 { return "", fmt.Errorf("the expected container does not exist") } - - noComposeBuild := len(existingComposeIDs) > 0 - featureImageName := "" - if len(cfg.Features) > 0 { - // Read compose config to determine service setup - composeConfig, configErr := composeClient.Config(ctx, composeFiles, "") - var svcBuild composeServiceBuild - if configErr == nil { - svcBuild = parseComposeServiceBuild(composeConfig, cfg.Service) - } - serviceHasBuild := svcBuild.HasBuild - serviceImage := svcBuild.Image - serviceDockerfile := svcBuild.Dockerfile - serviceBuildContext := svcBuild.Context - serviceBuildTarget := svcBuild.Target - - // Fetch and resolve features - fetchResult, fetchErr := fetchFeatureSets(ctx, logger, nil, cfg.Features, filepath.Dir(cfg.ConfigFilePath), opts.skipFeatureAutoMapping, nil) - if fetchErr != nil { - return "", fetchErr - } - - featureSets := []*features.Set{} - tmpDir := "" - if fetchResult != nil { - featureSets = fetchResult.FeatureSets - tmpDir = fetchResult.TmpDir - defer os.RemoveAll(tmpDir) - } - - if serviceHasBuild && serviceDockerfile != "" { - // TS CLI approach: inject features into the Dockerfile - // Read the original Dockerfile - configDir := filepath.Dir(cfg.ConfigFilePath) - originalDockerfilePath := serviceDockerfile - if !filepath.IsAbs(originalDockerfilePath) { - if serviceBuildContext != "" { - originalDockerfilePath = filepath.Join(serviceBuildContext, originalDockerfilePath) - } - if !filepath.IsAbs(originalDockerfilePath) { - originalDockerfilePath = filepath.Join(configDir, originalDockerfilePath) - } - } - - dockerfileContent, readErr := os.ReadFile(originalDockerfilePath) - if readErr != nil { - return "", fmt.Errorf("read Dockerfile for compose service: %w", readErr) - } - - // Find base image for metadata — pass compose build args for variable resolution - df := docker.ExtractDockerfile(string(dockerfileContent)) - baseImage := docker.FindBaseImage(df, svcBuild.Args, serviceBuildTarget) - - // Inspect base image once for metadata and user resolution. - baseImageInspect, _ := engine.InspectImage(ctx, baseImage) - - metadata := []imagemeta.Entry{} - var baseEntries []imagemeta.Entry - if baseImageInspect.Config != nil { - baseEntries = imagemeta.ReadMetadataFromLabels(baseImageInspect.Config.Labels, logger) - metadata = append(metadata, baseEntries...) - } - for _, fs := range featureSets { - if len(fs.Features) > 0 { - feat := fs.Features[0] - metadata = append(metadata, imagemeta.Entry{ - ID: feat.ID, Init: feat.Init, Privileged: feat.Privileged, - CapAdd: feat.CapAdd, SecurityOpt: feat.SecurityOpt, - ContainerEnv: feat.ContainerEnv, Mounts: feat.Mounts, - Customizations: feat.Customizations, Entrypoint: feat.Entrypoint, - }) - } - } - metadata = append(metadata, configMetadataEntry(cfg, opts.omitConfigRemoteEnvFromMeta)) - - containerUser := "root" - remoteUser := "root" - if baseImageInspect.Config != nil { - if baseImageInspect.Config.User != "" { - containerUser = baseImageInspect.Config.User - } - if len(baseEntries) > 0 { - baseMerged := imagemeta.MergeConfiguration(baseEntries) - if baseMerged.ContainerUser != "" { - containerUser = baseMerged.ContainerUser - } - if baseMerged.RemoteUser != "" { - remoteUser = baseMerged.RemoteUser - } - } - } - if remoteUser == "root" { - remoteUser = containerUser - } - - // When a build target is set, features must extend that stage. - // Otherwise, add a synthetic name to the final stage so the injected - // Dockerfile can reference it safely. - baseStageName := serviceBuildTarget - if baseStageName == "" { - baseStageName = "dev_containers_auto_added_stage_label" - _, modifiedDF := docker.EnsureFinalStageName(string(dockerfileContent), baseStageName) - if modifiedDF != "" { - dockerfileContent = []byte(modifiedDF) - } - } - - // Generate feature Dockerfile content for compose injection - // No build secrets here: `docker compose build` does not forward - // --secret to the injected feature stage, so mounting them would break - // the build. Compose feature build secrets remain unsupported for now. - featureContent := imagemeta.GenerateExtendImageBuildForCompose( - baseStageName, featureSets, metadata, containerUser, remoteUser, nil, nil, - ) - combinedDockerfile := string(dockerfileContent) + "\n" + featureContent - - // Copy feature content to compose build context so COPY works - buildCtx := serviceBuildContext - if !filepath.IsAbs(buildCtx) { - buildCtx = filepath.Join(filepath.Dir(cfg.ConfigFilePath), buildCtx) - } - for i := range featureSets { - srcDir := filepath.Join(tmpDir, fmt.Sprintf("_dev_container_feature_%d", i)) - dstDir := filepath.Join(buildCtx, fmt.Sprintf("_dev_container_feature_%d", i)) - os.RemoveAll(dstDir) - if cpErr := copyDir(srcDir, dstDir); cpErr != nil { - return "", fmt.Errorf("copy feature %d to build context: %w", i, cpErr) - } - defer os.RemoveAll(dstDir) - } - - // Write combined Dockerfile to the build context - combinedPath := filepath.Join(buildCtx, "Dockerfile-with-features") - if err := os.WriteFile(combinedPath, []byte(combinedDockerfile), 0644); err != nil { - return "", fmt.Errorf("write combined Dockerfile: %w", err) - } - defer os.Remove(combinedPath) - // Preserve the service Dockerfile's sibling .dockerignore (upstream #969). - copyDockerignore(originalDockerfilePath, combinedPath) - defer os.Remove(combinedPath + ".dockerignore") - - // Persist the build override when possible so a stopped compose container - // can be restarted without re-injecting features or losing the override. - overrideDir, cleanup, err := overrideDirFor(opts.userDataFolder, "devcontainer-compose-build-") - if err != nil { - return "", err - } - defer cleanup() - - buildOverride := docker.NewComposeOverride() - svc := buildOverride.Service(cfg.Service) - svc.Build = &docker.BuildOverride{ - Dockerfile: combinedPath, - Target: "dev_containers_target_stage", - } - if !serviceHasBuild || serviceBuildContext == "" { - svc.Build.Context = configDir - } - - buildOverridePath := filepath.Join(overrideDir, fmt.Sprintf("docker-compose.devcontainer.build-%s.json", projectName)) - if wErr := buildOverride.WriteFile(buildOverridePath); wErr != nil { - return "", fmt.Errorf("write compose build override: %w", wErr) - } - composeFiles = append(composeFiles, buildOverridePath) - - logger.Write("Injected features into compose Dockerfile", log.LevelInfo) - } else { - // Image-only service: build features as separate image - if !noComposeBuild { - buildErr := composeClient.Build(ctx, composeFiles, "", nil, []string{cfg.Service}, opts.buildNoCache) - if buildErr != nil { - return "", fmt.Errorf("compose build: %w", buildErr) - } - - baseImageName := serviceImage - if baseImageName == "" { - baseImageName = fmt.Sprintf("%s-%s", projectName, cfg.Service) - } - - logger.Write(fmt.Sprintf("Installing features on compose service %s...", cfg.Service), log.LevelInfo) - names, extErr := extendImageWithFeatures(ctx, logger, dockerClient, engine, baseImageName, cfg.Features, useBuildx, nil, &FeatureBuildOptions{ - Secrets: buildSecretsFromFile(opts.secretsFile), - OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, - FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), - SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, - Lockfile: opts.experimentalLockfile, - FrozenLockfile: opts.experimentalFrozenLockfile, - NoLockfile: opts.noLockfile, - ConfigPath: cfg.ConfigFilePath, - LockfileExcludeIDs: opts.lockfileExcludeIDs, - HostSub: &loadResult.HostSub, - }) - if extErr != nil { - return "", fmt.Errorf("install features on compose service: %w", extErr) - } - if len(names) > 0 { - featureImageName = names[0] - } - } - } - } - - // Generate containerEnv override if containerEnv is set - if len(cfg.ContainerEnv) > 0 { - envOverrideDir, cleanup, err := overrideDirFor(opts.userDataFolder, "devcontainer-compose-env-") + var existing *container.InspectResponse + if len(existingComposeIDs) > 0 { + inspect, err := engine.InspectContainer(ctx, existingComposeIDs[0]) if err != nil { - return "", err - } - defer cleanup() - - envOverride := docker.NewComposeOverride() - envSvc := envOverride.Service(cfg.Service) - for k, v := range cfg.ContainerEnv { - envSvc.AddEnv(k, v) // escaping handled by ComposeOverride - } - envOverridePath := filepath.Join(envOverrideDir, fmt.Sprintf("docker-compose.devcontainer.env-%s.json", projectName)) - if wErr := envOverride.WriteFile(envOverridePath); wErr != nil { - return "", fmt.Errorf("write compose env override: %w", wErr) - } - composeFiles = append(composeFiles, envOverridePath) - } - - if len(cfg.Mounts) > 0 { - mountOverrideDir, cleanup, err := overrideDirFor(opts.userDataFolder, "devcontainer-compose-mounts-") - if err != nil { - return "", err - } - defer cleanup() - - idLabelMap := map[string]string{} - for _, label := range idLabels { - if i := strings.IndexByte(label, '='); i >= 0 { - idLabelMap[label[:i]] = label[i+1:] - } - } - devcontainerID := config.ComputeDevContainerID(idLabelMap) - volumeSpecs, namedVolumes, err := composeVolumeSpecsFromMetadata(metadataMounts(cfg), devcontainerID) - if err != nil { - return "", fmt.Errorf("resolve compose mounts: %w", err) - } - - mountOverride := docker.NewComposeOverride() - mountSvc := mountOverride.Service(cfg.Service) - for _, spec := range volumeSpecs { - mountSvc.AddVolume(spec) - } - for _, volumeName := range namedVolumes { - mountOverride.AddVolume(volumeName) - } - - mountOverridePath := filepath.Join(mountOverrideDir, fmt.Sprintf("docker-compose.devcontainer.mounts-%s.json", projectName)) - if wErr := mountOverride.WriteFile(mountOverridePath); wErr != nil { - return "", fmt.Errorf("write compose mounts override: %w", wErr) - } - composeFiles = append(composeFiles, mountOverridePath) - } - - // Always create a compose override for the target service to attach the - // devcontainer.* labels (id-labels + metadata) so the container is - // discoverable by exec and VS Code — matching the TS CLI, which sets - // additionalLabels on the compose service. When features were installed - // we also add the runtime settings (image, entrypoint, env, mounts). - { - overrideDir, cleanup, err := overrideDirFor(opts.userDataFolder, "devcontainer-compose-features-") - if err != nil { - return "", err - } - defer cleanup() - - override := docker.NewComposeOverride() - svc := override.Service(cfg.Service) - - withFeatures := featureImageName != "" || len(cfg.Features) > 0 - if featureImageName != "" { - svc.Image = featureImageName - } - - // Read metadata from the feature image (or the service's base image) for - // both the runtime settings and the devcontainer.metadata label. Resolve - // the service's declared image via `docker compose config` rather than - // guessing "-": with a generic project name (e.g. - // "devcontainer") that guess can match a stale image left by another - // fixture and poison the metadata (e.g. a wrong remoteUser). - inspectImage := featureImageName - if inspectImage == "" { - if composeConfig, cfgErr := composeClient.Config(ctx, composeFiles, ""); cfgErr == nil { - inspectImage = composeServiceImage(composeConfig, cfg.Service) - } - if inspectImage == "" { - inspectImage = fmt.Sprintf("%s-%s", projectName, cfg.Service) - } - } - var metaEntries []imagemeta.Entry - if imageInspect, inspErr := engine.InspectImage(ctx, inspectImage); inspErr == nil && imageInspect.Config != nil { - metaEntries = imagemeta.ReadMetadataFromLabels(imageInspect.Config.Labels, logger) - } - metaEntries = append(metaEntries, configMetadataEntry(cfg, opts.omitConfigRemoteEnvFromMeta)) - - // Feature runtime settings — only when features were installed (unchanged). - if withFeatures { - merged := imagemeta.MergeConfiguration(metaEntries) - - if merged.Privileged != nil && *merged.Privileged { - svc.SetPrivileged(true) - } - svc.CapAdd = merged.CapAdd - svc.SecurityOpt = merged.SecurityOpt - - // Entrypoint — chain feature entrypoints via BuildEntrypointScriptCompose - script := docker.BuildEntrypointScriptCompose(merged.Entrypoints) - svc.Entrypoint = []string{"/bin/sh", "-c", script, "-"} - svc.Command = []string{} - - if merged.Init != nil && *merged.Init { - svc.SetInit(true) - } - if merged.ContainerUser != "" { - svc.User = merged.ContainerUser - } - for k, v := range merged.ContainerEnv { - svc.AddEnv(k, v) // escaping handled by ComposeOverride - } - - // Volumes with ${devcontainerId} substitution - if len(merged.Mounts) > 0 { - idLabelMap := map[string]string{} - for _, l := range idLabels { - if i := strings.IndexByte(l, '='); i >= 0 { - idLabelMap[l[:i]] = l[i+1:] - } - } - devcontainerID := config.ComputeDevContainerID(idLabelMap) - - volumeSpecs, namedVolumes, err := composeVolumeSpecsFromMetadata(merged.Mounts, devcontainerID) - if err != nil { - return "", fmt.Errorf("resolve compose feature mounts: %w", err) - } - for _, spec := range volumeSpecs { - svc.AddVolume(spec) - } - for _, volumeName := range namedVolumes { - override.AddVolume(volumeName) - } - } - } - - // Labels (always): id-labels (local_folder, config_file, --id-label) plus - // the merged devcontainer.metadata label. - for _, l := range idLabels { - svc.AddLabel(l) - } - if metaLabel := imagemeta.GenerateMetadataLabel(metaEntries); metaLabel != "" { - svc.AddLabel(imagemeta.MetadataLabel + "=" + metaLabel) - } - - // Build override (persisted for container reuse) — only when there's a feature image - if featureImageName != "" { - buildOverride := docker.NewComposeOverride() - buildSvc := buildOverride.Service(cfg.Service) - buildSvc.Image = featureImageName - buildOverridePath := filepath.Join(overrideDir, fmt.Sprintf("docker-compose.devcontainer.build-%s.json", projectName)) - if wErr := buildOverride.WriteFile(buildOverridePath); wErr != nil { - return "", fmt.Errorf("write compose feature build override: %w", wErr) - } - composeFiles = append(composeFiles, buildOverridePath) - } - - featOverridePath := filepath.Join(overrideDir, fmt.Sprintf("docker-compose.devcontainer.containerFeatures-%s.json", projectName)) - if wErr := override.WriteFile(featOverridePath); wErr != nil { - return "", fmt.Errorf("write compose features override: %w", wErr) - } - composeFiles = append(composeFiles, featOverridePath) - } - - // Build (skip if features already built the base) - if featureImageName == "" && !noComposeBuild { - if opts.buildNoCache { - logger.Write("Building with --no-cache...", log.LevelInfo) - } - buildErr := composeClient.Build(ctx, composeFiles, "", nil, []string{cfg.Service}, opts.buildNoCache) - if buildErr != nil { - return "", fmt.Errorf("compose build: %w", buildErr) - } - } - - // Up. Match the TS CLI: when runServices is empty, pass NO service filter so - // `compose up` brings up the whole project (all services and their - // dependencies, e.g. a db that the app shares a network namespace with via - // `network_mode: service:db`). Only when runServices is set do we limit to - // those services (plus the main service). Filtering to just the app service - // breaks reuse of a stopped project: `up --no-recreate app` does not reliably - // restart the stopped db, so the app fails to join its network namespace. - var services []string - if len(cfg.RunServices) > 0 { - services = append(services, cfg.RunServices...) - hasService := false - for _, s := range services { - if s == cfg.Service { - hasService = true - break - } - } - if !hasService { - services = append(services, cfg.Service) - } - } - - noRecreate := len(existingComposeIDs) > 0 || opts.expectExistingContainer - upErr := composeClient.Up(ctx, composeFiles, "", nil, projectName, services, noRecreate) - if upErr != nil { - return "", fmt.Errorf("compose up: %w", upErr) - } - - // Find the container for the target service (all=true to include just-started containers) - containerIDs, err := engine.ListContainers(ctx, true, []string{ - fmt.Sprintf("com.docker.compose.project=%s", projectName), - fmt.Sprintf("com.docker.compose.service=%s", cfg.Service), + return "", fmt.Errorf("inspect compose container: %w", err) + } + existing = &inspect + } + // TS openDockerComposeDevContainer only starts the project when the service + // container is missing or not running. + if existing != nil && existing.State != nil && existing.State.Running { + return existing.ID, nil + } + if _, ok := composeService(composeConfig, cfg.Service); !ok { + return "", fmt.Errorf("Service '%s' configured in devcontainer.json not found in Docker Compose configuration.", cfg.Service) + } + + containerID, err := r.startComposeContainer(ctx, composeStart{ + cfg: cfg, + loadResult: loadResult, + workspaceFolder: workspaceFolder, + idLabels: idLabels, + useBuildx: useBuildx, + projectName: projectName, + composeFiles: composeFiles, + composeConfig: composeConfig, + client: composeClient, + container: existing, }) - if err != nil || len(containerIDs) == 0 { - return "", fmt.Errorf("could not find container for service %q in project %q", cfg.Service, projectName) + if err != nil { + return "", err } - - containerID := containerIDs[0] logger.Write(fmt.Sprintf("Compose container: %s", containerID[:12]), log.LevelInfo) - - // Store for JSON result - opts.composeProjectName = projectName - return containerID, nil } // runLifecycleForUp runs lifecycle hooks on a newly created container. It returns // a non-nil error (a *coreerrors.ContainerError with a TS-compatible description) // only when a lifecycle hook fails; probe/dotfiles issues stay non-fatal warnings. +// +// mutate-lane: TestParityMatrix - up.image-post-create-remote-env-success, +// up.dockerfile-with-parallel-commands-success, up.feature-hook-failure-error, +// up.parallel-hook-failure-error, up.workspace-secrets-success and the +// up.dotfiles-local-* cases run their hooks through it. func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cfg *config.DevContainer, workDir string, remoteUser string) error { logger, engine, opts := r.log, r.engine, r.opts - // Build merged remote env: probed shell env → CLI --remote-env → config remoteEnv - probeStrategy := lifecycle.UserEnvProbeStrategy(opts.defaultUserEnvProbe) - if cfg.UserEnvProbe != "" { - probeStrategy = lifecycle.UserEnvProbeStrategy(cfg.UserEnvProbe) - } - - var mergedRemoteEnv []string - - // Probe user environment inside the container (with caching) - if probeStrategy != lifecycle.ProbeNone { - probeServer, probeErr := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger) - if probeErr == nil { - probedEnv, _ := lifecycle.ProbeRemoteEnv(logger, probeServer, probeStrategy, remoteUser, opts.containerSessionDataFolder) - probeServer.Close() - for k, v := range probedEnv { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, v)) - } - } - } - - // Secrets override probed env and are visible to lifecycle hooks. - if opts.secretsFile != "" { - if secretEnvs, err := readSecretsFile(opts.secretsFile); err == nil { - mergedRemoteEnv = append(mergedRemoteEnv, secretEnvs...) - } else { - logger.Write(fmt.Sprintf("Warning: secrets file: %v", err), log.LevelWarning) - } - } + probeStrategy := lifecycle.UserEnvProbeStrategy(cmp.Or(cfg.UserEnvProbe, opts.defaultUserEnvProbe)) + probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, remoteUser, probeStrategy, opts.containerSessionDataFolder) + // Secrets override the probed env and are visible to lifecycle hooks. + extra := append(secretsRemoteEnv(opts.secretsFile, logger), opts.remoteEnvs...) + mergedRemoteEnv := hookRemoteEnv(probed, extra, cfg.RemoteEnv) - // CLI --remote-env overrides probed/secrets - mergedRemoteEnv = append(mergedRemoteEnv, opts.remoteEnvs...) - // Config remoteEnv overrides everything - for k, v := range cfg.RemoteEnv { - if v != nil { - mergedRemoteEnv = append(mergedRemoteEnv, fmt.Sprintf("%s=%s", k, *v)) - } - } - - shellServer, err := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, logger, mergedRemoteEnv...) - if err != nil { - logger.Write(fmt.Sprintf("Could not start shell server for lifecycle hooks: %v", err), log.LevelWarning) - return nil - } + shellServer := lifecycle.NewShellServer(ctx, engine, containerID, remoteUser, mergedRemoteEnv...) defer shellServer.Close() executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger, WorkDir: workDir} @@ -1743,59 +1175,13 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf // the image), NOT the container: the container now carries the config metadata // as a label, so reading it and then re-adding the config commands below would // run every hook twice. - inspectResp, inspErr := engine.InspectContainer(ctx, containerID) - var merged *imagemeta.MergedConfig - if inspErr == nil && inspectResp.Image != "" { - if img, imgErr := engine.InspectImage(ctx, inspectResp.Image); imgErr == nil && img.Config != nil { - merged = imagemeta.MergeConfiguration(imagemeta.ReadMetadataFromLabels(img.Config.Labels, logger)) - } - } - if merged == nil { - merged = imagemeta.MergeConfiguration(nil) - } + // A container the engine cannot inspect has no image: its hooks are the + // config's alone. + inspectResp, _ := engine.InspectContainer(ctx, containerID) + merged := imagemeta.MergeConfiguration(imagemeta.ReadMetadataFromLabels(r.imageLabels(ctx, inspectResp.Image), logger)) + appendConfigHooks(merged, cfg) - // Add commands from config. Preserve the raw value so object-form commands - // continue to run in parallel like the TS CLI. - if !cfg.OnCreateCommand.IsEmpty() { - merged.OnCreateCommands = append(merged.OnCreateCommands, cfg.OnCreateCommand.Raw()) - } - if !cfg.UpdateContentCommand.IsEmpty() { - merged.UpdateContentCommands = append(merged.UpdateContentCommands, cfg.UpdateContentCommand.Raw()) - } - if !cfg.PostCreateCommand.IsEmpty() { - merged.PostCreateCommands = append(merged.PostCreateCommands, cfg.PostCreateCommand.Raw()) - } - if !cfg.PostStartCommand.IsEmpty() { - merged.PostStartCommands = append(merged.PostStartCommands, cfg.PostStartCommand.Raw()) - } - if !cfg.PostAttachCommand.IsEmpty() { - merged.PostAttachCommands = append(merged.PostAttachCommands, cfg.PostAttachCommand.Raw()) - } - - // Marker files: make hooks idempotent across repeated `up`s and - // editor reconnections. onCreate/updateContent/postCreate run only when their - // marker does not already record this container's createdAt; postStart runs - // only when the marker does not record its startedAt. postAttach always runs. - // Matches the TS runPostCreateCommand/runPostStartCommand + updateMarkerFile. - if markerFolder := resolveContainerUserDataFolder(shellServer); markerFolder != "" { - createdAt := inspectResp.Created - startedAt := "" - if inspectResp.State != nil { - startedAt = inspectResp.State.StartedAt - } - if !markerShouldRun(shellServer, markerFolder, "onCreateCommand", createdAt) { - merged.OnCreateCommands = nil - } - if !markerShouldRun(shellServer, markerFolder, "updateContentCommand", createdAt) { - merged.UpdateContentCommands = nil - } - if !markerShouldRun(shellServer, markerFolder, "postCreateCommand", createdAt) { - merged.PostCreateCommands = nil - } - if !markerShouldRun(shellServer, markerFolder, "postStartCommand", startedAt) { - merged.PostStartCommands = nil - } - } + applyLifecycleMarkers(shellServer, merged, inspectResp, opts.prebuild) hookErr := lifecycle.RunHooks(logger, executor, merged, lifecycle.RunOptions{ SkipNonBlocking: opts.skipNonBlocking, @@ -1803,29 +1189,137 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf Prebuild: opts.prebuild, // Dotfiles install after postCreateCommand (before postStart), as remoteUser // (executor runs docker exec -u) and with secrets in the env, matching TS. - AfterPostCreate: func() { - if opts.dotfilesRepo != "" { - if dfErr := lifecycle.InstallDotfiles(logger, executor, lifecycle.DotfilesConfig{ - Repository: opts.dotfilesRepo, - InstallCommand: opts.dotfilesCommand, - TargetPath: opts.dotfilesTarget, - }); dfErr != nil { - logger.Write(fmt.Sprintf("Dotfiles installation failed: %v", dfErr), log.LevelWarning) - } - } - }, + AfterPostCreate: dotfilesAfterPostCreate(logger, executor, lifecycle.DotfilesConfig{ + Repository: opts.dotfilesRepo, + InstallCommand: opts.dotfilesCommand, + TargetPath: opts.dotfilesTarget, + }), }) if hookErr != nil { - phase := "postCreateCommand" - var he *lifecycle.HookError - if errors.As(hookErr, &he) { - phase = string(he.Phase) + return hookContainerError(hookErr, containerID, "An error occurred setting up the container.") + } + return nil +} + +// secretsRemoteEnv are the --secrets-file variables (NAME=value) for the +// lifecycle hooks. A file that cannot be read or parsed is a warning, not an +// error, and contributes nothing. +func secretsRemoteEnv(path string, logger log.Logger) []string { + if path == "" { + return nil + } + envs, err := readSecretsFile(path) + if err != nil { + logger.Write(fmt.Sprintf("Warning: secrets file: %v", err), log.LevelWarning) + return nil + } + return envs +} + +// hookRemoteEnv is the remote env lifecycle hooks run with, a later entry +// overriding an earlier one: the probed shell env, then extra (secrets, +// --remote-env), then the config's remoteEnv, whose null values add nothing. +func hookRemoteEnv(probed map[string]string, extra []string, cfgRemoteEnv map[string]*string) []string { + var env []string + for k, v := range probed { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + env = append(env, extra...) + for k, v := range cfgRemoteEnv { + if v != nil { + env = append(env, fmt.Sprintf("%s=%s", k, *v)) } - return &coreerrors.ContainerError{ - Description: fmt.Sprintf("%s from devcontainer.json failed.", phase), + } + return env +} + +// appendConfigHooks adds the config's lifecycle commands after the image +// metadata's. It keeps each raw value so object-form commands still run in +// parallel like the TS CLI. +func appendConfigHooks(merged *imagemeta.MergedConfig, cfg *config.DevContainer) { + for _, h := range []struct { + list *[]any + cmd *config.LifecycleCommand + }{ + {&merged.OnCreateCommands, &cfg.OnCreateCommand}, + {&merged.UpdateContentCommands, &cfg.UpdateContentCommand}, + {&merged.PostCreateCommands, &cfg.PostCreateCommand}, + {&merged.PostStartCommands, &cfg.PostStartCommand}, + {&merged.PostAttachCommands, &cfg.PostAttachCommand}, + } { + if !h.cmd.IsEmpty() { + *h.list = append(*h.list, h.cmd.Raw()) + } + } +} + +// hookContainerError is TS runLifecycleCommand's ContainerError for a failed +// lifecycle command: the description names the command and where it comes from, +// the message is the command's own error, and up reports the container. Any +// other error gets the command's generic description (fallback). +func hookContainerError(hookErr error, containerID, fallback string) *coreerrors.ContainerError { + he, ok := errors.AsType[*lifecycle.HookError](hookErr) + if !ok { + return &coreerrors.ContainerError{Description: fallback, OriginalError: hookErr, ContainerID: containerID} + } + return &coreerrors.ContainerError{Description: he.Description(), OriginalError: he.Err, ContainerID: containerID} +} + +// applyLifecycleMarkers drops the hooks a previous run already ran, as TS +// runPostCreateCommand/runPostStartCommand do with updateMarkerFile for up, +// set-up and run-user-commands alike: onCreate, updateContent and postCreate run +// once per container (createdAt), postStart once per start (startedAt), and +// postAttach always. The marker is written before the hook runs, so a hook that +// failed is not run again. --prebuild reruns updateContentCommand. +// +// mutate-lane: TestParityMatrix - set-up.after-failed-post-create-success (set-up +// after a failed postCreateCommand does not rerun the hooks whose marker was +// written) and up.dockerfile-with-parallel-commands-success run it against a +// real container; dropRanHooks holds its decisions. +func applyLifecycleMarkers(server *lifecycle.ShellServer, merged *imagemeta.MergedConfig, inspect container.InspectResponse, prebuild bool) { + folder := resolveContainerUserDataFolder(server) + if folder == "" { + return + } + dropRanHooks(merged, inspect, prebuild, func(hook, timestamp string) bool { + return markerShouldRun(server, folder, hook, timestamp) + }) +} + +// dropRanHooks drops from merged the hooks whose marker says they already ran. +// shouldRun is asked about every marked hook, in TS order, with the container's +// createdAt for onCreate, updateContent and postCreate and its startedAt for +// postStart; asking also updates the marker, so it is asked even when +// --prebuild keeps updateContentCommand regardless. +func dropRanHooks(merged *imagemeta.MergedConfig, inspect container.InspectResponse, prebuild bool, shouldRun func(hook, timestamp string) bool) { + startedAt := "" + if inspect.State != nil { + startedAt = inspect.State.StartedAt + } + if !shouldRun("onCreateCommand", inspect.Created) { + merged.OnCreateCommands = nil + } + if !shouldRun("updateContentCommand", inspect.Created) && !prebuild { + merged.UpdateContentCommands = nil + } + if !shouldRun("postCreateCommand", inspect.Created) { + merged.PostCreateCommands = nil + } + if !shouldRun("postStartCommand", startedAt) { + merged.PostStartCommands = nil + } +} + +// dotfilesAfterPostCreate installs the dotfiles repository where TS +// runLifecycleHooks does: after postCreateCommand, before postStartCommand. A +// failure is logged and not fatal; with no repository InstallDotfiles does +// nothing. +func dotfilesAfterPostCreate(logger log.Logger, executor lifecycle.CommandExecutor, cfg lifecycle.DotfilesConfig) func() { + return func() { + if err := lifecycle.InstallDotfiles(logger, executor, cfg); err != nil { + logger.Write(fmt.Sprintf("Dotfiles installation failed: %v", err), log.LevelWarning) } } - return nil } // resolveContainerUserDataFolder returns the in-container folder where lifecycle @@ -1856,3 +1350,22 @@ func markerShouldRun(server *lifecycle.ShellServer, folder, hookName, content st _, code, err := server.Exec(cmd) return err == nil && code == 0 } + +// featureBuildOptions are the Feature install options of up's image builds; +// platform is the target platform ("" for the daemon's). +func (r *upRunner) featureBuildOptions(cfg *config.DevContainer, loadResult *config.LoadResult, platform string) *FeatureBuildOptions { + opts := r.opts + return &FeatureBuildOptions{ + Platform: platform, + Secrets: buildSecretsFromFile(opts.secretsFile), + OverrideFeatureInstallOrder: cfg.OverrideFeatureInstallOrder, + FeaturesBasePath: filepath.Dir(cfg.ConfigFilePath), + SkipFeatureAutoMapping: opts.skipFeatureAutoMapping, + Lockfile: opts.experimentalLockfile, + FrozenLockfile: opts.experimentalFrozenLockfile, + NoLockfile: opts.noLockfile, + ConfigPath: cfg.ConfigFilePath, + LockfileExcludeIDs: opts.lockfileExcludeIDs, + HostSub: &loadResult.HostSub, + } +} diff --git a/internal/cli/up_held_test.go b/internal/cli/up_held_test.go new file mode 100644 index 0000000..0989683 --- /dev/null +++ b/internal/cli/up_held_test.go @@ -0,0 +1,661 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + osexec "os/exec" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + dockerspec "github.com/moby/docker-image-spec/specs-go/v1" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/image" + dockermount "github.com/moby/moby/api/types/mount" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/lifecycle" + "github.com/devcontainers/cli/internal/log" +) + +func quietLogger() log.Logger { return log.New(log.Options{Writer: io.Discard}) } + +func TestResolveUpPathsDefaultsTheWorkspaceToTheCurrentDirectory(t *testing.T) { + cwd := t.TempDir() + t.Chdir(cwd) + for _, tc := range []struct { + name string + opts upOpts + want [3]string + }{ + {"nothing given", upOpts{}, [3]string{cwd, "", ""}}, + {"id label", upOpts{idLabels: []string{"a=b"}}, [3]string{"", "", ""}}, + {"override config", upOpts{overrideConfig: "o.json"}, [3]string{"", "", filepath.Join(cwd, "o.json")}}, + {"workspace", upOpts{workspaceFolder: "ws", configPath: "/c.json"}, [3]string{filepath.Join(cwd, "ws"), "/c.json", ""}}, + } { + t.Run(tc.name, func(t *testing.T) { + ws, cfg, ov := resolveUpPaths(&tc.opts) + if got := [3]string{ws, cfg, ov}; got != tc.want { + t.Errorf("resolveUpPaths = %q, want %q", got, tc.want) + } + }) + } +} + +func TestUpHasNothingToReadOnlyWithoutWorkspaceAndConfig(t *testing.T) { + for _, tc := range []struct { + ws, cfg, ov string + want bool + }{ + {"", "", "", true}, + {"/ws", "", "", false}, + {"", "/c.json", "", false}, + {"", "", "/o.json", false}, + } { + if got := upHasNothingToRead(tc.ws, tc.cfg, tc.ov); got != tc.want { + t.Errorf("upHasNothingToRead(%q, %q, %q) = %v, want %v", tc.ws, tc.cfg, tc.ov, got, tc.want) + } + } +} + +func metadataLabels(json string) map[string]string { + return map[string]string{imagemeta.MetadataLabel: json} +} + +func TestUpRemoteUserFallsBackInTSOrder(t *testing.T) { + withUser := func(user string, labels map[string]string) container.InspectResponse { + return container.InspectResponse{Image: "sha256:img", Config: &container.Config{User: user, Labels: labels}} + } + for _, tc := range []struct { + name string + cfgUser string + ctr container.InspectResponse + imageLabels map[string]string + want string + wantImage bool // whether the image had to be asked + }{ + {"config", "cfg", withUser("ctr", metadataLabels(`[{"remoteUser":"meta"}]`)), metadataLabels(`[{"remoteUser":"img"}]`), "cfg", false}, + {"container metadata", "", withUser("ctr", metadataLabels(`[{"remoteUser":"meta"}]`)), metadataLabels(`[{"remoteUser":"img"}]`), "meta", false}, + {"image metadata", "", withUser("ctr", metadataLabels(`[{"containerEnv":{"A":"b"}}]`)), metadataLabels(`[{"remoteUser":"img"}]`), "img", true}, + {"container USER", "", withUser("ctr", nil), nil, "ctr", true}, + {"root", "", withUser("", nil), nil, "root", true}, + {"no container config", "", container.InspectResponse{Image: "sha256:img"}, metadataLabels(`[{"remoteUser":"img"}]`), "img", true}, + {"nothing at all", "", container.InspectResponse{}, nil, "root", true}, + } { + t.Run(tc.name, func(t *testing.T) { + var asked []string + imageLabels := func(ref string) map[string]string { + asked = append(asked, ref) + return tc.imageLabels + } + if got := upRemoteUser(tc.cfgUser, tc.ctr, imageLabels, quietLogger()); got != tc.want { + t.Errorf("upRemoteUser = %q, want %q", got, tc.want) + } + if tc.wantImage != (len(asked) > 0) { + t.Errorf("image asked %v, want asked = %v", asked, tc.wantImage) + } + if len(asked) > 0 && asked[0] != tc.ctr.Image { + t.Errorf("image asked for %q, want the container's image %q", asked[0], tc.ctr.Image) + } + }) + } +} + +// upConfigurationJSON is what addUpConfiguration adds to up's result, as up +// prints it. +func upConfigurationJSON(t *testing.T, opts *upOpts, cfg *config.DevContainer, ctr container.InspectResponse, imageLabels map[string]string) map[string]any { + t.Helper() + result := map[string]any{} + if err := addUpConfiguration(result, opts, cfg, ctr, func(string) map[string]string { return imageLabels }, quietLogger()); err != nil { + t.Fatal(err) + } + b, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + return got +} + +func TestAddUpConfigurationNamesTheConfigFileAsTSDoes(t *testing.T) { + const cfp = "/ws/.devcontainer/devcontainer.json" + for _, tc := range []struct { + name string + opts upOpts + scheme string + }{ + {"found in the workspace", upOpts{}, "vscode-fileHost"}, + {"--config", upOpts{configPath: "c.json"}, "file"}, + {"--override-config", upOpts{overrideConfig: "o.json"}, "file"}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.opts.includeConfig, tc.opts.includeMergedConfig = true, true + got := upConfigurationJSON(t, &tc.opts, &config.DevContainer{ConfigFilePath: cfp, Image: "alpine:3"}, container.InspectResponse{}, nil) + + want := map[string]any{"$mid": float64(1), "path": cfp, "scheme": tc.scheme} + conf, _ := got["configuration"].(map[string]any) + if !reflect.DeepEqual(conf["configFilePath"], want) { + t.Errorf("configuration.configFilePath = %v, want %v", conf["configFilePath"], want) + } + mc, _ := got["mergedConfiguration"].(map[string]any) + if !reflect.DeepEqual(mc["configFilePath"], want) { + t.Errorf("mergedConfiguration.configFilePath = %v, want %v", mc["configFilePath"], want) + } + }) + } +} + +func TestAddUpConfigurationResolvesTheContainerEnv(t *testing.T) { + cfg := &config.DevContainer{ + Image: "alpine:3", + RemoteEnv: map[string]*string{"FROM_CONTAINER": strPtr("${containerEnv:FOO}")}, + } + ctr := container.InspectResponse{Image: "sha256:img", Config: &container.Config{Env: []string{"FOO=bar"}}} + imageLabels := metadataLabels(`[{"remoteUser":"node","postCreateCommand":"from-image"}]`) + + got := upConfigurationJSON(t, &upOpts{includeConfig: true, includeMergedConfig: true}, cfg, ctr, imageLabels) + + conf, _ := got["configuration"].(map[string]any) + if re, _ := conf["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { + t.Errorf("configuration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", conf["remoteEnv"]) + } + for k, v := range conf { + if v == nil { + t.Errorf("configuration.%s is null; TS leaves unset properties out", k) + } + } + if _, ok := conf["configFilePath"]; ok { + t.Errorf("configuration.configFilePath = %v for a config with no file", conf["configFilePath"]) + } + mc, _ := got["mergedConfiguration"].(map[string]any) + if re, _ := mc["remoteEnv"].(map[string]any); re["FROM_CONTAINER"] != "bar" { + t.Errorf("mergedConfiguration.remoteEnv = %v, want ${containerEnv:FOO} resolved to bar", mc["remoteEnv"]) + } + if mc["remoteUser"] != "node" || !reflect.DeepEqual(mc["postCreateCommands"], []any{"from-image"}) { + t.Errorf("mergedConfiguration = %v, want the image's metadata merged in", mc) + } + if mc["image"] != "alpine:3" { + t.Errorf("mergedConfiguration.image = %v, want the config's image", mc["image"]) + } +} + +func TestAddUpConfigurationAddsOnlyWhatWasAskedFor(t *testing.T) { + cfg := &config.DevContainer{Image: "alpine:3"} + if got := upConfigurationJSON(t, &upOpts{}, cfg, container.InspectResponse{}, nil); len(got) != 0 { + t.Errorf("result = %v, want nothing added", got) + } + got := upConfigurationJSON(t, &upOpts{includeMergedConfig: true}, cfg, container.InspectResponse{}, nil) + if _, ok := got["configuration"]; ok { + t.Errorf("configuration added without --include-configuration: %v", got) + } + if mc, _ := got["mergedConfiguration"].(map[string]any); mc["image"] != "alpine:3" { + t.Errorf("mergedConfiguration = %v, want it added", got["mergedConfiguration"]) + } +} + +func TestAddUpConfigurationReportsAContainerEnvReferenceWithoutAName(t *testing.T) { + cfg := &config.DevContainer{RemoteEnv: map[string]*string{"X": strPtr("${containerEnv}")}} + labels := func(string) map[string]string { return nil } + err := addUpConfiguration(map[string]any{}, &upOpts{includeConfig: true}, cfg, container.InspectResponse{}, labels, quietLogger()) + if err == nil || !strings.HasPrefix(err.Error(), "resolve configuration container variables: ") { + t.Errorf("configuration error = %v, want the substitution error", err) + } + err = addUpConfiguration(map[string]any{}, &upOpts{includeMergedConfig: true}, cfg, container.InspectResponse{}, labels, quietLogger()) + if err == nil || !strings.HasPrefix(err.Error(), "resolve merged container variables: ") { + t.Errorf("merged error = %v, want the substitution error", err) + } +} + +func TestFindUserArgIsTheLastUserFlag(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {nil, ""}, + {[]string{"-u", "a"}, "a"}, + {[]string{"--user", "b"}, "b"}, + {[]string{"-u=c"}, "c"}, + {[]string{"--user=d"}, "d"}, + {[]string{"-u", "a", "--privileged", "--user", "b"}, "b"}, + {[]string{"--user=d", "-u", "a"}, "a"}, + {[]string{"--cap-add", "SYS_PTRACE", "-u"}, ""}, + {[]string{"-u", "a", "-u"}, "a"}, + {[]string{"--name", "x"}, ""}, + } { + if got := findUserArg(tc.args); got != tc.want { + t.Errorf("findUserArg(%q) = %q, want %q", tc.args, got, tc.want) + } + } +} + +func TestContainerRunUserPrefersTheMetadataUser(t *testing.T) { + img := image.InspectResponse{Config: &dockerspec.DockerOCIImageConfig{ImageConfig: ocispec.ImageConfig{User: "imageuser"}}} + for _, tc := range []struct { + meta string + img image.InspectResponse + want string + }{ + {"meta", img, "meta"}, + {"", img, "imageuser"}, + {"", image.InspectResponse{}, ""}, + } { + if got := containerRunUser(tc.meta, tc.img); got != tc.want { + t.Errorf("containerRunUser(%q, %+v) = %q, want %q", tc.meta, tc.img.Config, got, tc.want) + } + } +} + +func TestUpWorkspaceMounts(t *testing.T) { + bind := func(src, dst string, c dockermount.Consistency) dockermount.Mount { + return dockermount.Mount{Type: dockermount.TypeBind, Source: src, Target: dst, Consistency: c} + } + for _, tc := range []struct { + name string + wc config.WorkspaceConfig + ws string + consistency string + want []dockermount.Mount + }{ + {"workspace bind", config.WorkspaceConfig{WorkspaceFolder: "/workspaces/p"}, "/src/p", "cached", + []dockermount.Mount{bind("/src/p", "/workspaces/p", "cached")}}, + {"custom workspaceMount", config.WorkspaceConfig{WorkspaceFolder: "/w", WorkspaceMount: "type=bind,source=/elsewhere,target=/w"}, "/src/p", "delegated", + []dockermount.Mount{bind("/elsewhere", "/w", "delegated")}}, + {"workspaceMount without a workspace", config.WorkspaceConfig{WorkspaceFolder: "/w", WorkspaceMount: "type=bind,source=/elsewhere,target=/w"}, "", "cached", + []dockermount.Mount{bind("/elsewhere", "/w", "cached")}}, + {"no workspace, no workspaceMount", config.WorkspaceConfig{WorkspaceFolder: "/w"}, "", "cached", nil}, + {"no consistency keeps the spec's", config.WorkspaceConfig{WorkspaceFolder: "/w", WorkspaceMount: "type=bind,source=/s,target=/w,consistency=consistent", AdditionalMounts: []string{"type=bind,source=/git,target=/git,consistency=delegated"}}, "/src", "", + []dockermount.Mount{bind("/s", "/w", "consistent"), bind("/git", "/git", "delegated")}}, + {"worktree common dir", config.WorkspaceConfig{WorkspaceFolder: "/w", AdditionalMounts: []string{"type=bind,source=/git,target=/git"}}, "/src", "cached", + []dockermount.Mount{bind("/src", "/w", "cached"), bind("/git", "/git", "cached")}}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := upWorkspaceMounts(&tc.wc, tc.ws, tc.consistency) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("mounts = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestUpWorkspaceMountsRejectsAnUnparsableSpec(t *testing.T) { + if _, err := upWorkspaceMounts(&config.WorkspaceConfig{WorkspaceMount: "source=/s"}, "/src", "cached"); err == nil || !strings.Contains(err.Error(), "invalid workspaceMount") { + t.Errorf("workspaceMount error = %v", err) + } + if _, err := upWorkspaceMounts(&config.WorkspaceConfig{AdditionalMounts: []string{"source=/s"}}, "/src", "cached"); err == nil || !strings.Contains(err.Error(), "invalid additional mount") { + t.Errorf("additional mount error = %v", err) + } +} + +func TestParseFlagMounts(t *testing.T) { + got, err := parseFlagMounts([]string{"type=volume,source=v,target=/v", "type=bind,source=/s,target=/t"}) + if err != nil { + t.Fatal(err) + } + want := []dockermount.Mount{ + {Type: dockermount.TypeVolume, Source: "v", Target: "/v"}, + {Type: dockermount.TypeBind, Source: "/s", Target: "/t"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("mounts = %+v, want %+v", got, want) + } + if _, err := parseFlagMounts([]string{"source=/s"}); err == nil || !strings.Contains(err.Error(), "invalid --mount") { + t.Errorf("error = %v, want an invalid --mount error", err) + } +} + +// composeConfigRunner answers `docker compose config` with config (or fails +// when config is nil). +type composeConfigRunner struct{ config map[string]any } + +func (r composeConfigRunner) Run(_ context.Context, _ io.Writer, _ string, args ...string) ([]byte, []byte, int, error) { + if !slices.Contains(args, "config") { + return nil, []byte("unexpected compose call"), 1, nil + } + if r.config == nil { + return nil, []byte("no configuration file provided"), 1, nil + } + b, err := json.Marshal(r.config) + return b, nil, 0, err +} + +func TestResolveComposeProjectNameFollowsTheTSChain(t *testing.T) { + write := func(t *testing.T, path, content string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + type setup struct { + configDir string + composeFiles []string + } + // inDevcontainer lays a config and its compose file out under + // /My_Proj/.devcontainer. + inDevcontainer := func(t *testing.T, compose string) setup { + dir := filepath.Join(t.TempDir(), "My_Proj", ".devcontainer") + return setup{dir, []string{write(t, filepath.Join(dir, "docker-compose.yml"), compose)}} + } + for _, tc := range []struct { + name string + env map[string]string + dotEnv string + compose string + config map[string]any + layout func(t *testing.T, compose string) setup + want string + }{ + {"environment", map[string]string{"COMPOSE_PROJECT_NAME": "From.Env"}, "COMPOSE_PROJECT_NAME=dotenv", "", map[string]any{"name": "named"}, inDevcontainer, "fromenv"}, + {"empty environment", map[string]string{"COMPOSE_PROJECT_NAME": ""}, "", "", nil, inDevcontainer, "my_proj_devcontainer"}, + {".env", nil, "OTHER=1\n COMPOSE_PROJECT_NAME= Dot_Env \n", "", map[string]any{"name": "named"}, inDevcontainer, "dot_env"}, + {"empty .env", nil, "COMPOSE_PROJECT_NAME=\n", "", nil, inDevcontainer, "my_proj_devcontainer"}, + {"compose name", nil, "", "", map[string]any{"name": "Named"}, inDevcontainer, "named"}, + {"empty compose name", nil, "", "", map[string]any{"name": ""}, inDevcontainer, "my_proj_devcontainer"}, + {"compose name devcontainer, written", nil, "", "name: devcontainer\n", map[string]any{"name": "devcontainer"}, inDevcontainer, "devcontainer"}, + {"compose name devcontainer, defaulted", nil, "", "services: {}\n", map[string]any{"name": "devcontainer"}, inDevcontainer, "my_proj_devcontainer"}, + {"compose config fails", nil, "", "", nil, inDevcontainer, "my_proj_devcontainer"}, + {"outside .devcontainer", nil, "", "", nil, func(t *testing.T, compose string) setup { + root := t.TempDir() + return setup{filepath.Join(root, "cfg"), []string{ + write(t, filepath.Join(root, "First_Dir", "a.yml"), compose), + write(t, filepath.Join(root, "second", "b.yml"), compose), + }} + }, "first_dir"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := tc.layout(t, tc.compose) + if tc.dotEnv != "" { + write(t, filepath.Join(s.configDir, ".env"), tc.dotEnv) + } + cfg := &config.DevContainer{ConfigFilePath: filepath.Join(s.configDir, "devcontainer.json")} + client := &docker.ComposeClient{Command: []string{"docker", "compose"}, Version: []int{2, 24, 0}, Log: quietLogger(), Runner: composeConfigRunner{tc.config}} + + if got := resolveComposeProjectName(context.Background(), cfg, tc.env, s.composeFiles, client); got != tc.want { + t.Errorf("project name = %q, want %q", got, tc.want) + } + }) + } +} + +func TestParseComposeServiceBuildReadsTheServicesBuild(t *testing.T) { + cfg := map[string]any{"services": map[string]any{ + "app": map[string]any{ + "image": "app:dev", + "build": map[string]any{"dockerfile": "Dockerfile.dev", "context": "/ctx", "target": "dev", "args": map[string]any{"N": 1.0, "S": "s"}}, + }, + "db": map[string]any{"image": "postgres"}, + }} + want := composeServiceBuild{Image: "app:dev", HasBuild: true, Dockerfile: "Dockerfile.dev", Context: "/ctx", Target: "dev", Args: map[string]string{"N": "1", "S": "s"}} + if got := parseComposeServiceBuild(cfg, "app"); !reflect.DeepEqual(got, want) { + t.Errorf("app = %+v, want %+v", got, want) + } + if got := parseComposeServiceBuild(cfg, "db"); !reflect.DeepEqual(got, composeServiceBuild{Image: "postgres"}) { + t.Errorf("db = %+v, want its image and no build", got) + } +} + +func TestSecretsRemoteEnv(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "secrets.json") + if err := os.WriteFile(good, []byte(`{"TOKEN":"s3cr3t"}`), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, path string + want []string + warns bool + }{ + {"no file", "", nil, false}, + {"file", good, []string{"TOKEN=s3cr3t"}, false}, + {"missing file", filepath.Join(dir, "missing.json"), nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var logged bytes.Buffer + got := secretsRemoteEnv(tc.path, log.New(log.Options{Writer: &logged})) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("env = %q, want %q", got, tc.want) + } + if warned := strings.Contains(logged.String(), "Warning: secrets file:"); warned != tc.warns { + t.Errorf("log = %q, want a warning: %v", logged.String(), tc.warns) + } + }) + } +} + +func TestHookRemoteEnvOrdersLaterSourcesLast(t *testing.T) { + got := hookRemoteEnv( + map[string]string{"P": "probed"}, + []string{"S=secret", "R=cli"}, + map[string]*string{"C": strPtr("config"), "UNSET": nil}, + ) + if want := []string{"P=probed", "S=secret", "R=cli", "C=config"}; !reflect.DeepEqual(got, want) { + t.Errorf("env = %q, want %q", got, want) + } +} + +func TestAppendConfigHooksAddsTheConfigsCommandsAfterTheImages(t *testing.T) { + merged := imagemeta.MergeConfiguration([]imagemeta.Entry{{OnCreateCommand: "img-oc", PostStartCommand: "img-ps"}}) + var cfg config.DevContainer + if err := json.Unmarshal([]byte(`{ + "onCreateCommand": "oc", + "updateContentCommand": ["uc", "arg"], + "postCreateCommand": {"a": "pc"}, + "postAttachCommand": "pa" + }`), &cfg); err != nil { + t.Fatal(err) + } + + appendConfigHooks(merged, &cfg) + + for _, tc := range []struct { + name string + got []any + want []any + }{ + {"onCreate", merged.OnCreateCommands, []any{"img-oc", "oc"}}, + {"updateContent", merged.UpdateContentCommands, []any{[]any{"uc", "arg"}}}, + {"postCreate", merged.PostCreateCommands, []any{map[string]any{"a": "pc"}}}, + {"postStart", merged.PostStartCommands, []any{"img-ps"}}, + {"postAttach", merged.PostAttachCommands, []any{"pa"}}, + } { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want) + } + } +} + +func TestDropRanHooksAsksEveryMarkerAndDropsWhatRan(t *testing.T) { + inspect := container.InspectResponse{Created: "created", State: &container.State{StartedAt: "started"}} + allHooks := func() *imagemeta.MergedConfig { + return &imagemeta.MergedConfig{ + OnCreateCommands: []any{"oc"}, UpdateContentCommands: []any{"uc"}, PostCreateCommands: []any{"pc"}, + PostStartCommands: []any{"ps"}, PostAttachCommands: []any{"pa"}, + } + } + wantAsked := []string{"onCreateCommand@created", "updateContentCommand@created", "postCreateCommand@created", "postStartCommand@started"} + + for _, tc := range []struct { + name string + ran map[string]bool + prebuild bool + want *imagemeta.MergedConfig + }{ + {"first run", nil, false, allHooks()}, + {"all ran", map[string]bool{"onCreateCommand": true, "updateContentCommand": true, "postCreateCommand": true, "postStartCommand": true}, false, + &imagemeta.MergedConfig{PostAttachCommands: []any{"pa"}}}, + {"restarted", map[string]bool{"onCreateCommand": true, "updateContentCommand": true, "postCreateCommand": true}, false, + &imagemeta.MergedConfig{PostStartCommands: []any{"ps"}, PostAttachCommands: []any{"pa"}}}, + {"prebuild reruns updateContent", map[string]bool{"onCreateCommand": true, "updateContentCommand": true, "postCreateCommand": true, "postStartCommand": true}, true, + &imagemeta.MergedConfig{UpdateContentCommands: []any{"uc"}, PostAttachCommands: []any{"pa"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + var asked []string + merged := allHooks() + dropRanHooks(merged, inspect, tc.prebuild, func(hook, timestamp string) bool { + asked = append(asked, hook+"@"+timestamp) + return !tc.ran[hook] + }) + if !reflect.DeepEqual(merged, tc.want) { + t.Errorf("hooks = %+v, want %+v", merged, tc.want) + } + if !slices.Equal(asked, wantAsked) { + t.Errorf("markers asked %v, want %v", asked, wantAsked) + } + }) + } +} + +func TestDropRanHooksWithoutAStartTimeAsksWithNone(t *testing.T) { + var asked []string + dropRanHooks(&imagemeta.MergedConfig{}, container.InspectResponse{Created: "created"}, false, func(hook, timestamp string) bool { + asked = append(asked, hook+"@"+timestamp) + return true + }) + if asked[len(asked)-1] != "postStartCommand@" { + t.Errorf("markers asked %v, want postStart asked with no timestamp", asked) + } +} + +// recordingExecutor records the commands it is asked to run and fails them +// with err. +type recordingExecutor struct { + commands []string + err error +} + +func (e *recordingExecutor) Exec(command string) error { + e.commands = append(e.commands, command) + return e.err +} + +var _ lifecycle.CommandExecutor = (*recordingExecutor)(nil) + +func TestDotfilesAfterPostCreateInstallsAndOnlyWarnsOnFailure(t *testing.T) { + for _, tc := range []struct { + name string + repo string + err error + installs bool + warns bool + }{ + {"no repository", "", nil, false, false}, + {"installed", "me/dotfiles", nil, true, false}, + {"failed", "me/dotfiles", errors.New("exit status 128"), true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var logged bytes.Buffer + exec := &recordingExecutor{err: tc.err} + dotfilesAfterPostCreate(log.New(log.Options{Writer: &logged}), exec, lifecycle.DotfilesConfig{Repository: tc.repo})() + if installs := len(exec.commands) > 0; installs != tc.installs { + t.Errorf("ran %d commands, want an install: %v", len(exec.commands), tc.installs) + } + warned := strings.Contains(logged.String(), "Dotfiles installation failed: exit status 128") + if warned != tc.warns { + t.Errorf("log = %q, want the failure warned: %v", logged.String(), tc.warns) + } + }) + } +} + +// TestE2E_UpCacheImage starts a container from a prebuilt --cache-image instead +// of the config's image, and reports a cache image it cannot pull. +func TestE2E_UpCacheImage(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker not available") + } + tag := fmt.Sprintf("devcontainer-cli-e2e-cache-image:%d", time.Now().UnixNano()) + build := osexec.Command("docker", "build", "-q", "-t", tag, "-") + build.Stdin = strings.NewReader("FROM alpine:3\nRUN touch /prebuilt-marker\n") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("docker build: %v\n%s", err, out) + } + defer osexec.Command("docker", "rmi", "-f", tag).Run() + + ws := writeFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image":"alpine:3"}`}) + upOut := runCLI(t, "up", "--workspace-folder", ws, "--cache-image", tag, "--skip-post-create") + assertOutcome(t, upOut, "success") + id, _ := parseJSON(t, upOut)["containerId"].(string) + if id == "" { + t.Fatal("no containerId") + } + defer removeByID(id) + if got := dockerExec(t, id, "test -f /prebuilt-marker && echo prebuilt"); got != "prebuilt" { + t.Errorf("container was not started from the cache image: %q", got) + } + + missing := writeFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image":"alpine:3"}`}) + failOut := runCLI(t, "up", "--workspace-folder", missing, "--cache-image", "127.0.0.1:1/missing/image:none", "--skip-post-create") + result := parseJSON(t, failOut) + if id, _ := result["containerId"].(string); id != "" { + removeByID(id) + } + if desc, _ := result["description"].(string); result["outcome"] != "error" || !strings.Contains(desc, "pull cache image") { + t.Errorf("up with a cache image it cannot pull = %v, want the pull error", result) + } +} + +// TestE2E_UpReusesStoppedContainer starts the workspace's stopped container +// again instead of creating another one. +func TestE2E_UpReusesStoppedContainer(t *testing.T) { + if !dockerAvailable() { + t.Skip("Docker not available") + } + ws := writeFixture(t, map[string]string{".devcontainer/devcontainer.json": `{"image":"alpine:3"}`}) + + first := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create") + assertOutcome(t, first, "success") + id, _ := parseJSON(t, first)["containerId"].(string) + if id == "" { + t.Fatal("no containerId") + } + defer removeByID(id) + if out, err := osexec.Command("docker", "stop", "-t", "0", id).CombinedOutput(); err != nil { + t.Fatalf("docker stop: %v\n%s", err, out) + } + + second := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create") + assertOutcome(t, second, "success") + if again, _ := parseJSON(t, second)["containerId"].(string); again != id { + removeByID(again) + t.Fatalf("second up used container %q, want the stopped %q", again, id) + } + running, err := osexec.Command("docker", "inspect", "--format", "{{.State.Running}}", id).Output() + if err != nil || strings.TrimSpace(string(running)) != "true" { + t.Errorf("reused container running = %q (%v), want true", running, err) + } +} + +func TestAddUpConfigurationDropsAConfigFilePathThatResolvesToNothing(t *testing.T) { + cfg := &config.DevContainer{ConfigFilePath: "${containerEnv:UNSET}"} + + got := upConfigurationJSON(t, &upOpts{includeConfig: true, includeMergedConfig: true, configPath: "c.json"}, cfg, container.InspectResponse{}, nil) + + conf, _ := got["configuration"].(map[string]any) + if v, ok := conf["configFilePath"]; ok { + t.Errorf("configuration.configFilePath = %v, want it left out", v) + } + mc, _ := got["mergedConfiguration"].(map[string]any) + if v, ok := mc["configFilePath"]; ok { + t.Errorf("mergedConfiguration.configFilePath = %v, want it left out", v) + } +} diff --git a/internal/cli/up_nodocker_held_test.go b/internal/cli/up_nodocker_held_test.go new file mode 100644 index 0000000..c14749a --- /dev/null +++ b/internal/cli/up_nodocker_held_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "runtime" + "slices" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" +) + +// The remote user of the UID update comes from the image's metadata label too: +// a root image whose metadata names a non-root remoteUser gets the -uid image, +// built for that user. +func TestSingleContainerRemoteUserUIDReadsTheImageMetadata(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the UID update runs on Linux only") + } + engine := &fakeEngineAPI{images: map[string]string{ + "img:1": `{"Config":{"User":"root","Labels":{"devcontainer.metadata":"[{\"remoteUser\":\"node\"}]"}}}`, + }} + runner := &fakeDockerRunner{} + r := &upRunner{ + log: log.Null, + docker: fakeDockerClient(runner), + engine: &docker.EngineClient{API: engine, Log: log.Null}, + opts: &upOpts{updateRemoteUserUIDDefault: "on", userDataFolder: t.TempDir()}, + } + got, err := r.singleContainerRemoteUserUID(t.Context(), "img:1", &config.DevContainer{}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(got, "-uid") { + t.Errorf("image = %q, want the -uid image", got) + } + if len(runner.calls) != 1 || !slices.Contains(runner.calls[0], "REMOTE_USER=node") { + t.Errorf("docker calls = %v, want one UID build for node", runner.calls) + } +} diff --git a/internal/cli/up_remote_uid_held_test.go b/internal/cli/up_remote_uid_held_test.go new file mode 100644 index 0000000..87d6a29 --- /dev/null +++ b/internal/cli/up_remote_uid_held_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "io" + "testing" + + "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/docker" + "github.com/devcontainers/cli/internal/log" +) + +// An image whose inspection has no Config carries no metadata: the UID update +// reads none and, with updates off, keeps the image. +func TestSingleContainerRemoteUserUIDImageWithoutConfig(t *testing.T) { + logger := log.New(log.Options{Writer: io.Discard}) + engine := &fakeEngineAPI{images: map[string]string{"img:1": `{}`}} + r := &upRunner{ + log: logger, + engine: &docker.EngineClient{API: engine, Log: logger}, + opts: &upOpts{updateRemoteUserUIDDefault: "off"}, + } + got, err := r.singleContainerRemoteUserUID(t.Context(), "img:1", &config.DevContainer{}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if got != "img:1" { + t.Errorf("image = %q, want img:1 kept", got) + } +} diff --git a/internal/cli/upstream_fixes_test.go b/internal/cli/upstream_fixes_test.go index 26e39a6..6600489 100644 --- a/internal/cli/upstream_fixes_test.go +++ b/internal/cli/upstream_fixes_test.go @@ -34,7 +34,7 @@ func TestMetadataMounts_ReadonlyObject(t *testing.T) { if len(entries) != 1 { t.Fatalf("want 1 mount entry, got %d", len(entries)) } - m, ok := entries[0].(map[string]interface{}) + m, ok := entries[0].(map[string]any) if !ok { t.Fatalf("mount entry is %T, want map", entries[0]) } @@ -53,12 +53,12 @@ func TestMetadataMounts_ReadonlyObject(t *testing.T) { } // compose path: the volume spec must end in :ro. - specs, _, err := composeVolumeSpecsFromMetadata(entries, "devid") + composeMounts, err := composeMountsFromMetadata(entries, func(s string) (string, error) { return s, nil }) if err != nil { t.Fatal(err) } - if len(specs) != 1 || !strings.HasSuffix(specs[0], ":ro") { - t.Errorf("#881 compose spec missing :ro: %v", specs) + if len(composeMounts) != 1 || !strings.HasSuffix(composeVolumeSpec(composeMounts[0]), ":ro") { + t.Errorf("#881 compose spec missing :ro: %v", composeMounts) } } @@ -70,7 +70,7 @@ func TestMetadataMounts_ReadonlyOmittedByDefault(t *testing.T) { mountOrString(t, `{"type":"bind","source":"/local","target":"/remote"}`), }, } - m := metadataMounts(cfg)[0].(map[string]interface{}) + m := metadataMounts(cfg)[0].(map[string]any) if _, present := m["readonly"]; present { t.Errorf("#881 readonly must be omitted when unset: %v", m) } @@ -124,7 +124,7 @@ func TestBuildPositionalPath(t *testing.T) { // must resolve in an object mount's target, not only its source (the string form // already substitutes the whole spec). func TestMountFromMetadata_DevcontainerIdInTarget(t *testing.T) { - m, err := mountFromMetadata(map[string]interface{}{ + m, err := mountFromMetadata(map[string]any{ "type": "volume", "source": "cache-${devcontainerId}", "target": "/cache/${devcontainerId}", diff --git a/internal/cli/validate.go b/internal/cli/validate.go index c545bc1..4ef6ca0 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "regexp" + "slices" "strings" ) @@ -51,11 +52,33 @@ func validateTerminalImplications(columns, rows int) error { } // validateEnum checks that a flag value is one of the allowed choices. -func validateEnum(flagName, value string, choices []string) error { - for _, c := range choices { - if value == c { - return nil +// logLevels and logFormats are the choices TS yargs declares for --log-level +// and --log-format. +var ( + logLevels = []string{"info", "debug", "trace"} + logFormats = []string{"text", "json"} +) + +// enumFlag is a flag value that must be one of choices. +type enumFlag struct { + flag, value string + choices []string +} + +// validateEnums returns the error for the first flag whose value is not one of +// its choices, in the order given (yargs reports the first invalid choice). +func validateEnums(flags ...enumFlag) error { + for _, f := range flags { + if err := validateEnum(f.flag, f.value, f.choices); err != nil { + return err } } + return nil +} + +func validateEnum(flagName, value string, choices []string) error { + if slices.Contains(choices, value) { + return nil + } return fmt.Errorf("Invalid value %q for --%s. Choose from: %s", value, flagName, strings.Join(choices, ", ")) } diff --git a/internal/config/loader.go b/internal/config/loader.go index d75d7a9..6612966 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -2,8 +2,11 @@ package config import ( + "cmp" "encoding/json" + "errors" "fmt" + "maps" "os" "path" "path/filepath" @@ -14,6 +17,40 @@ import ( "github.com/devcontainers/cli/internal/pfs" ) +// NotObjectError is a configuration file that parses to something other than a +// JSON object (null, an array, a scalar), which TS readDevContainerConfigFile +// rejects with this message. +type NotObjectError struct { + Path string +} + +func (e *NotObjectError) Error() string { + return fmt.Sprintf("Dev container config (%s) must contain a JSON object literal.", e.Path) +} + +// wrapParseError names the file a parse error came from; a NotObjectError +// already names it, in TS's words, and is returned as it is. +func wrapParseError(what, path string, err error) error { + if _, ok := errors.AsType[*NotObjectError](err); ok { + return err + } + return fmt.Errorf("%s %s: %w", what, path, err) +} + +// parseConfigObject parses a devcontainer.json document, which must be a JSON +// object; path is what a NotObjectError names. +func parseConfigObject(data []byte, path string) (map[string]any, error) { + var v any + if err := jsonc.Unmarshal(data, &v); err != nil { + return nil, err + } + m, ok := v.(map[string]any) + if !ok { + return nil, &NotObjectError{Path: path} + } + return m, nil +} + // NotFoundError is returned when no devcontainer.json can be discovered. // It is distinguished from parse/other errors so callers (e.g. read-configuration) // can mirror the TS CLI, which exits 1 silently when config is absent. @@ -27,8 +64,14 @@ func (e *NotFoundError) Error() string { // LoadResult contains a loaded and substituted devcontainer configuration. type LoadResult struct { - Config *DevContainer - Raw map[string]interface{} // pre-substitution + Config *DevContainer + // Raw is the configuration after host-side substitution, as a map: it keeps + // the properties the typed Config has no field for. + Raw map[string]any + // RawConfig is the configuration as written - deprecated properties moved + // into customizations, nothing substituted - which TS keeps as a config's raw + // and writes into the devcontainer.metadata label. + RawConfig map[string]any WorkspaceConfig *WorkspaceConfig // HostSub is the host-side substitution context used for the config; reuse it // so Feature metadata (mounts/containerEnv) resolves ${localEnv:…} the same way. @@ -131,12 +174,19 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi // so an orchestrator can supply a partial override without restating the whole // devcontainer.json. When there is no readable base, the override stands alone // (identical to the TS/replace behavior). - var raw map[string]interface{} + // The message names the --config file even when an override is read, as TS's + // does (uriToFsPath(configFile)). + objectPath := cmp.Or(configPath, overrideConfigPath) + var raw map[string]any if configPath != "" { - if baseData, berr := os.ReadFile(configPath); berr == nil { - if perr := jsonc.Unmarshal(baseData, &raw); perr != nil { - return nil, fmt.Errorf("parse config %s: %w", configPath, perr) + baseData, err := os.ReadFile(configPath) + switch { + case err == nil: + if raw, err = parseConfigObject(baseData, objectPath); err != nil { + return nil, wrapParseError("parse config", configPath, err) } + case overrideConfigPath == "": + return nil, fmt.Errorf("read config %s: %w", configPath, err) } } if overrideConfigPath != "" { @@ -144,26 +194,17 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi if oerr != nil { return nil, fmt.Errorf("read override config %s: %w", overrideConfigPath, oerr) } - var overrideRaw map[string]interface{} - if perr := jsonc.Unmarshal(overrideData, &overrideRaw); perr != nil { - return nil, fmt.Errorf("parse override config %s: %w", overrideConfigPath, perr) + overrideRaw, perr := parseConfigObject(overrideData, objectPath) + if perr != nil { + return nil, wrapParseError("parse override config", overrideConfigPath, perr) } raw = deepMergeConfig(raw, overrideRaw) } - if raw == nil { - // No override and the base could not be read: surface the base read error. - readPath := configPath - if overrideConfigPath != "" { - readPath = overrideConfigPath - } - data, err := os.ReadFile(readPath) - if err != nil { - return nil, fmt.Errorf("read config %s: %w", readPath, err) - } - if err := jsonc.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse config %s: %w", readPath, err) - } - } + + // TS moves the deprecated properties before anything reads the config, so the + // raw config, the substituted one and the typed struct all carry the move. + raw = updateFromOldProperties(raw) + rawConfig := raw // Derive the typed struct from the (possibly merged) raw config. var config DevContainer @@ -171,14 +212,9 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi return nil, fmt.Errorf("unmarshal config: %w", err) } - // Migrate deprecated properties - UpdateFromOldProperties(&config) - - // Set config file path - if configPath != "" { - config.ConfigFilePath = configPath - } else { - config.ConfigFilePath = overrideConfigPath + configFilePath := configPath + if configFilePath == "" { + configFilePath = overrideConfigPath } // Compute workspace config using the caller's mount options. @@ -191,15 +227,15 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi Platform: currentPlatform(), LocalWorkspaceFolder: localWS, ContainerWorkspaceFolder: wsConfig.WorkspaceFolder, - Env: envFromOS(), - ConfigFilePath: config.ConfigFilePath, + Env: EnvMap(os.Environ()), + ConfigFilePath: configFilePath, } substituted, err := NewVariableResolver().Resolve(SubstitutionContext{HostSubContext: ctx}, PhaseHost, raw) if err != nil { return nil, fmt.Errorf("apply host substitutions: %w", err) } - if m, ok := substituted.(map[string]interface{}); ok { + if m, ok := substituted.(map[string]any); ok { raw = m } @@ -218,14 +254,12 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi wsConfig.WorkspaceMount = config.WorkspaceMount } - config.ConfigFilePath = configPath - if configPath == "" { - config.ConfigFilePath = overrideConfigPath - } + config.ConfigFilePath = configFilePath return &LoadResult{ Config: &config, Raw: raw, + RawConfig: rawConfig, WorkspaceConfig: wsConfig, HostSub: ctx, }, nil @@ -235,21 +269,19 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi // are merged recursively; scalars and arrays in the override replace the base. // A nil base returns the override (and vice-versa), so it composes cleanly when // only one side is present. -func deepMergeConfig(base, override map[string]interface{}) map[string]interface{} { +func deepMergeConfig(base, override map[string]any) map[string]any { if base == nil { return override } if override == nil { return base } - out := make(map[string]interface{}, len(base)) - for k, v := range base { - out[k] = v - } + out := make(map[string]any, len(base)) + maps.Copy(out, base) for k, ov := range override { if bv, ok := out[k]; ok { - if bm, bok := bv.(map[string]interface{}); bok { - if om, ook := ov.(map[string]interface{}); ook { + if bm, bok := bv.(map[string]any); bok { + if om, ook := ov.(map[string]any); ook { out[k] = deepMergeConfig(bm, om) continue } @@ -290,10 +322,10 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str if pfs.IsFile(envFilePath) { data, err := os.ReadFile(envFilePath) if err == nil { - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { line = strings.TrimSpace(line) - if strings.HasPrefix(line, "COMPOSE_FILE=") { - value := strings.TrimPrefix(line, "COMPOSE_FILE=") + if after, ok := strings.CutPrefix(line, "COMPOSE_FILE="); ok { + value := after value = strings.TrimSpace(value) if value != "" { sep := string(os.PathListSeparator) @@ -321,6 +353,12 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str // --- Helpers --- func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWorkspaceGitRoot, mountGitWorktreeCommonDir bool) *WorkspaceConfig { + // With no workspace (up --config without --workspace-folder) nothing is + // mounted: the folder and mount are the config's own, possibly empty, as TS + // getWorkspaceConfiguration leaves them for an undefined workspace. + if workspace.RootFolderPath == "" && !config.IsComposeConfig() { + return &WorkspaceConfig{WorkspaceFolder: config.WorkspaceFolder, WorkspaceMount: config.WorkspaceMount} + } sourceFolder := workspace.RootFolderPath // Detect git root for workspace mounting (matches TS getHostMountFolder) @@ -360,9 +398,8 @@ func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWor // workspaceFolder is the container mount folder, plus the workspace's path // relative to the git root when it sits in a subfolder. containerFolder := containerMountFolder - if mountWorkspaceGitRoot && sourceFolder != workspace.RootFolderPath { - rel, err := filepath.Rel(sourceFolder, workspace.RootFolderPath) - if err == nil && rel != "." { + if sourceFolder != workspace.RootFolderPath { + if rel, err := filepath.Rel(sourceFolder, workspace.RootFolderPath); err == nil { containerFolder = path.Join(containerMountFolder, filepath.ToSlash(rel)) } } @@ -490,7 +527,7 @@ func substituteHostString(workspace *Workspace, s string) string { ctx := HostSubContext{ Platform: currentPlatform(), LocalWorkspaceFolder: strings.TrimRight(workspace.RootFolderPath, "/\\"), - Env: envFromOS(), + Env: EnvMap(os.Environ()), } out, err := NewVariableResolver().resolveString(SubstitutionContext{HostSubContext: ctx}, PhaseHost, s) if err != nil { @@ -502,7 +539,7 @@ func substituteHostString(workspace *Workspace, s string) string { // parseGitdir extracts the target of a `gitdir: ` line from a `.git` // gitlink file. No regexp, matching the rest of the package. func parseGitdir(content string) (string, bool) { - for _, line := range strings.Split(content, "\n") { + for line := range strings.SplitSeq(content, "\n") { line = strings.TrimRight(line, "\r") if rest, found := strings.CutPrefix(line, "gitdir:"); found { return strings.TrimSpace(rest), true @@ -558,17 +595,20 @@ func currentPlatform() string { return os.Getenv("GOOS_OVERRIDE") // for testing; falls back to runtime } -func envFromOS() map[string]string { - env := make(map[string]string) - for _, e := range os.Environ() { - if i := strings.IndexByte(e, '='); i >= 0 { - env[e[:i]] = e[i+1:] +// EnvMap turns KEY=VALUE entries (os.Environ, a container's Config.Env, id +// labels) into a map; an entry without '=' is skipped and a repeated key keeps +// its last value. +func EnvMap(entries []string) map[string]string { + m := make(map[string]string, len(entries)) + for _, e := range entries { + if k, v, ok := strings.Cut(e, "="); ok { + m[k] = v } } - return env + return m } -func remarshal(raw map[string]interface{}, target *DevContainer) error { +func remarshal(raw map[string]any, target *DevContainer) error { data, err := json.Marshal(raw) if err != nil { return err diff --git a/internal/config/loader_nodocker_held_test.go b/internal/config/loader_nodocker_held_test.go new file mode 100644 index 0000000..bac68f8 --- /dev/null +++ b/internal/config/loader_nodocker_held_test.go @@ -0,0 +1,145 @@ +package config + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// A --config that does not exist fails with the read error itself, which the +// CLI reports as the missing config (TS: "Dev container config (...) not found."). +func TestLoadReportsAMissingConfigFile(t *testing.T) { + ws := t.TempDir() + missing := filepath.Join(ws, "nope.json") + _, err := LoadDevContainerConfigWithMounts(ws, missing, "", MountOptions{}) + if !errors.Is(err, fs.ErrNotExist) || !strings.HasPrefix(err.Error(), "read config "+missing) { + t.Fatalf("err = %v, want the read error for %s", err, missing) + } +} + +// The config file path is the --config file, else the override file (TS +// readDevContainerConfigFile: overrideConfigFile ?? configFile) - never a +// "configFilePath" property of the file - and it is what host substitution +// errors name. +func TestLoadSetsTheConfigFilePath(t *testing.T) { + ws := t.TempDir() + cfg := filepath.Join(ws, "base.json") + writeFile(t, cfg, `{"image":"x","configFilePath":"/bogus.json"}`) + res, err := LoadDevContainerConfigWithMounts(ws, cfg, "", MountOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Config.ConfigFilePath != cfg || res.HostSub.ConfigFilePath != cfg { + t.Errorf("ConfigFilePath = %q, HostSub.ConfigFilePath = %q, want %q", res.Config.ConfigFilePath, res.HostSub.ConfigFilePath, cfg) + } + + override := filepath.Join(ws, "override.json") + writeFile(t, override, `{"image":"y"}`) + res, err = LoadDevContainerConfigWithMounts(ws, "", override, MountOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Config.ConfigFilePath != override || res.HostSub.ConfigFilePath != override { + t.Errorf("override only: ConfigFilePath = %q, HostSub.ConfigFilePath = %q, want %q", res.Config.ConfigFilePath, res.HostSub.ConfigFilePath, override) + } + + writeFile(t, override, `{"image":"y","containerEnv":{"A":"${localEnv}"}}`) + _, err = LoadDevContainerConfigWithMounts(ws, "", override, MountOptions{}) + if err == nil || !strings.Contains(err.Error(), "in override.json") { + t.Errorf("substitution error = %v, want it to name override.json", err) + } +} + +// workspaceFolder and workspaceMount are reported with their host variables +// resolved (TS getWorkspaceConfiguration reads the substituted config). +func TestLoadResolvesWorkspaceFolderAndMount(t *testing.T) { + ws := filepath.Join(t.TempDir(), "proj") + cfg := filepath.Join(ws, ".devcontainer", "devcontainer.json") + writeFile(t, cfg, `{ + "image": "x", + "workspaceFolder": "/w/${localWorkspaceFolderBasename}", + "workspaceMount": "source=${localWorkspaceFolder},target=/w/${localWorkspaceFolderBasename},type=bind" + }`) + res, err := LoadDevContainerConfigWithMounts(ws, cfg, "", MountOptions{}) + if err != nil { + t.Fatal(err) + } + if got := res.WorkspaceConfig.WorkspaceFolder; got != "/w/proj" { + t.Errorf("WorkspaceFolder = %q, want /w/proj", got) + } + if got, want := res.WorkspaceConfig.WorkspaceMount, "source="+ws+",target=/w/proj,type=bind"; got != want { + t.Errorf("WorkspaceMount = %q, want %q", got, want) + } +} + +// With --mount-workspace-git-root a workspace below its Git root mounts the +// root and works in the subfolder (TS getWorkspaceConfiguration); without it +// the workspace mounts by itself. +func TestComputeWorkspaceConfigWorksInTheGitRootsSubfolder(t *testing.T) { + root := filepath.Join(t.TempDir(), "repo") + sub := filepath.Join(root, "a", "b") + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if got := computeWorkspaceConfig(&Workspace{RootFolderPath: sub}, &DevContainer{}, true, false).WorkspaceFolder; got != "/workspaces/repo/a/b" { + t.Errorf("git root mounted: WorkspaceFolder = %q, want /workspaces/repo/a/b", got) + } + if got := computeWorkspaceConfig(&Workspace{RootFolderPath: root}, &DevContainer{}, true, false).WorkspaceFolder; got != "/workspaces/repo" { + t.Errorf("at the git root: WorkspaceFolder = %q, want /workspaces/repo", got) + } + if got := computeWorkspaceConfig(&Workspace{RootFolderPath: sub}, &DevContainer{}, false, false).WorkspaceFolder; got != "/workspaces/b" { + t.Errorf("git root not mounted: WorkspaceFolder = %q, want /workspaces/b", got) + } +} + +// substituteHostString resolves the host variables of a string, and leaves a +// string whose substitution fails as written. +func TestSubstituteHostString(t *testing.T) { + w := &Workspace{RootFolderPath: "/home/me/proj/"} + if got := substituteHostString(w, "/w/${localWorkspaceFolderBasename}"); got != "/w/proj" { + t.Errorf("substituteHostString = %q, want /w/proj", got) + } + if got := substituteHostString(w, "/w/${localEnv}"); got != "/w/${localEnv}" { + t.Errorf("failed substitution = %q, want it unchanged", got) + } +} + +// An empty COMPOSE_FILE is no COMPOSE_FILE, and a .env's COMPOSE_FILE comes +// next (TS getDockerComposeFilePaths: `if (envComposeFile)`, then the .env file). +func TestDockerComposeFilePathsFromTheDotEnv(t *testing.T) { + dir := t.TempDir() + c := &DevContainer{ConfigFilePath: filepath.Join(dir, "devcontainer.json")} + got, err := DockerComposeFilePaths(c, map[string]string{"COMPOSE_FILE": ""}, dir) + if err != nil { + t.Fatal(err) + } + if want := []string{filepath.Join(dir, "docker-compose.yml")}; !slices.Equal(got, want) { + t.Errorf("empty COMPOSE_FILE: paths = %v, want %v", got, want) + } + + writeFile(t, filepath.Join(dir, ".env"), "OTHER=1\nCOMPOSE_FILE=a.yml"+string(os.PathListSeparator)+"b.yml\n") + got, err = DockerComposeFilePaths(c, map[string]string{}, dir) + if err != nil { + t.Fatal(err) + } + if want := []string{filepath.Join(dir, "a.yml"), filepath.Join(dir, "b.yml")}; !slices.Equal(got, want) { + t.Errorf(".env COMPOSE_FILE: paths = %v, want %v", got, want) + } +} diff --git a/internal/config/loader_object_held_test.go b/internal/config/loader_object_held_test.go new file mode 100644 index 0000000..ddeb22a --- /dev/null +++ b/internal/config/loader_object_held_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// A devcontainer.json that is not a JSON object fails as TS +// readDevContainerConfigFile fails it, naming the file; an object loads. +func TestLoadRejectsAConfigThatIsNotAnObject(t *testing.T) { + for _, content := range []string{"null", "[1]", `"image"`, "3"} { + ws := t.TempDir() + cfg := filepath.Join(ws, ".devcontainer.json") + if err := os.WriteFile(cfg, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadDevContainerConfig(ws, cfg, "") + var notObject *NotObjectError + if !errors.As(err, ¬Object) || notObject.Path != cfg { + t.Fatalf("%s: err = %v, want a NotObjectError for %s", content, err, cfg) + } + if want := "Dev container config (" + cfg + ") must contain a JSON object literal."; err.Error() != want { + t.Errorf("%s: message = %q, want %q", content, err.Error(), want) + } + } + + ws := t.TempDir() + base := filepath.Join(ws, "base.json") + override := filepath.Join(ws, "override.json") + if err := os.WriteFile(base, []byte(`{"image": "img:1"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(override, []byte("null"), 0o644); err != nil { + t.Fatal(err) + } + var notObject *NotObjectError + if _, err := LoadDevContainerConfig(ws, base, override); !errors.As(err, ¬Object) || notObject.Path != base { + t.Errorf("a null override: err = %v, want a NotObjectError naming the --config file", err) + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 718d3c6..5a1d2ad 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -394,3 +394,42 @@ func TestLoadAllFixtures(t *testing.T) { } t.Logf("loaded %d fixtures, skipped %d (no devcontainer.json)", loaded, skipped) } + +// The deprecated properties reach the typed config even when customizations is +// already set (the substituted re-read used to overwrite the move), and +// RawConfig keeps the configuration as written, variables unresolved. +func TestLoadMovesDeprecatedPropertiesAndKeepsRawConfig(t *testing.T) { + ws := t.TempDir() + cfg := filepath.Join(ws, ".devcontainer.json") + if err := os.WriteFile(cfg, []byte(`{ + "image": "debian", + "extensions": ["a.ext"], + "settings": {"x": 1}, + "customizations": {"vscode": {"settings": {"y": 2}}}, + "containerEnv": {"P": "${localEnv:HOME}"} + }`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", "/home/someone") + res, err := LoadDevContainerConfig(ws, cfg, "") + if err != nil { + t.Fatal(err) + } + vscode, _ := res.Config.Customizations["vscode"].(map[string]any) + if exts, _ := vscode["extensions"].([]any); len(exts) != 1 || exts[0] != "a.ext" { + t.Errorf("customizations.vscode.extensions = %v", vscode["extensions"]) + } + if settings, _ := vscode["settings"].(map[string]any); settings["x"] != float64(1) || settings["y"] != float64(2) { + t.Errorf("customizations.vscode.settings = %v", vscode["settings"]) + } + if res.Config.ContainerEnv["P"] != "/home/someone" { + t.Errorf("containerEnv.P = %q, want the substituted HOME", res.Config.ContainerEnv["P"]) + } + rawEnv, _ := res.RawConfig["containerEnv"].(map[string]any) + if rawEnv["P"] != "${localEnv:HOME}" { + t.Errorf("RawConfig containerEnv.P = %v, want it unresolved", rawEnv["P"]) + } + if _, ok := res.RawConfig["extensions"]; ok { + t.Error("RawConfig keeps the deprecated extensions") + } +} diff --git a/internal/config/override_merge_test.go b/internal/config/override_merge_test.go index 356b941..dfc8831 100644 --- a/internal/config/override_merge_test.go +++ b/internal/config/override_merge_test.go @@ -8,19 +8,19 @@ import ( ) func TestDeepMergeConfig(t *testing.T) { - base := map[string]interface{}{ + base := map[string]any{ "image": "ubuntu", "remoteUser": "vscode", - "runArgs": []interface{}{"--a"}, - "customizations": map[string]interface{}{ - "vscode": map[string]interface{}{"extensions": []interface{}{"x"}}, + "runArgs": []any{"--a"}, + "customizations": map[string]any{ + "vscode": map[string]any{"extensions": []any{"x"}}, }, } - override := map[string]interface{}{ + override := map[string]any{ "remoteUser": "root", - "runArgs": []interface{}{"--b"}, // arrays replace, not append - "customizations": map[string]interface{}{ - "vscode": map[string]interface{}{"settings": map[string]interface{}{"k": "v"}}, + "runArgs": []any{"--b"}, // arrays replace, not append + "customizations": map[string]any{ + "vscode": map[string]any{"settings": map[string]any{"k": "v"}}, }, } got := deepMergeConfig(base, override) @@ -31,21 +31,21 @@ func TestDeepMergeConfig(t *testing.T) { if got["remoteUser"] != "root" { t.Errorf("scalar override failed: remoteUser=%v", got["remoteUser"]) } - if !reflect.DeepEqual(got["runArgs"], []interface{}{"--b"}) { + if !reflect.DeepEqual(got["runArgs"], []any{"--b"}) { t.Errorf("array should be replaced, got %v", got["runArgs"]) } // Nested object merged: extensions (base) preserved AND settings (override) added. - vscode := got["customizations"].(map[string]interface{})["vscode"].(map[string]interface{}) - if !reflect.DeepEqual(vscode["extensions"], []interface{}{"x"}) { + vscode := got["customizations"].(map[string]any)["vscode"].(map[string]any) + if !reflect.DeepEqual(vscode["extensions"], []any{"x"}) { t.Errorf("nested base key lost: extensions=%v", vscode["extensions"]) } - if vscode["settings"].(map[string]interface{})["k"] != "v" { + if vscode["settings"].(map[string]any)["k"] != "v" { t.Errorf("nested override key missing: settings=%v", vscode["settings"]) } } func TestDeepMergeConfigNilHandling(t *testing.T) { - m := map[string]interface{}{"a": 1} + m := map[string]any{"a": 1} if got := deepMergeConfig(nil, m); !reflect.DeepEqual(got, m) { t.Errorf("nil base should return override, got %v", got) } diff --git a/internal/config/spec_compliance_test.go b/internal/config/spec_compliance_test.go index 5fca4e4..1fe23af 100644 --- a/internal/config/spec_compliance_test.go +++ b/internal/config/spec_compliance_test.go @@ -45,10 +45,10 @@ func TestSpecCompliance(t *testing.T) { } schemaProps := schemaPropertyNames(t) - structTags := structJSONTags(reflect.TypeOf(DevContainer{})) + structTags := structJSONTags(reflect.TypeFor[DevContainer]()) // MountOrString wraps its value in an interface, so reflection can't reach // the Mount struct through it — seed it explicitly. - for name := range structJSONTags(reflect.TypeOf(Mount{})) { + for name := range structJSONTags(reflect.TypeFor[Mount]()) { structTags[name] = true } @@ -75,16 +75,16 @@ func schemaPropertyNames(t *testing.T) map[string]bool { if err != nil { t.Fatalf("read vendored schema: %v (run `task spec:schema-update`)", err) } - var root interface{} + var root any if err := json.Unmarshal(data, &root); err != nil { t.Fatalf("parse schema: %v", err) } out := map[string]bool{} - var walk func(node interface{}) - walk = func(node interface{}) { + var walk func(node any) + walk = func(node any) { switch n := node.(type) { - case map[string]interface{}: - if props, ok := n["properties"].(map[string]interface{}); ok { + case map[string]any: + if props, ok := n["properties"].(map[string]any); ok { for k := range props { out[k] = true } @@ -92,7 +92,7 @@ func schemaPropertyNames(t *testing.T) map[string]bool { for _, v := range n { walk(v) } - case []interface{}: + case []any: for _, v := range n { walk(v) } @@ -109,7 +109,7 @@ func structJSONTags(t reflect.Type) map[string]bool { seen := map[reflect.Type]bool{} var visit func(t reflect.Type) visit = func(t reflect.Type) { - for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array { t = t.Elem() } if t.Kind() == reflect.Map { @@ -120,9 +120,8 @@ func structJSONTags(t reflect.Type) map[string]bool { return } seen[t] = true - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if name := strings.Split(f.Tag.Get("json"), ",")[0]; name != "" && name != "-" { + for f := range t.Fields() { + if name, _, _ := strings.Cut(f.Tag.Get("json"), ","); name != "" && name != "-" { out[name] = true } visit(f.Type) diff --git a/internal/config/types.go b/internal/config/types.go index f9e865b..adcfdb3 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "maps" ) // DevContainer represents a devcontainer.json. @@ -12,42 +13,42 @@ type DevContainer struct { ConfigFilePath string `json:"configFilePath,omitempty"` // --- Common properties (all variants) --- - Name string `json:"name,omitempty"` - ForwardPorts []PortOrString `json:"forwardPorts,omitempty"` - AppPort json.RawMessage `json:"appPort,omitempty"` // number | string | (number|string)[] - PortsAttributes map[string]PortAttrs `json:"portsAttributes,omitempty"` - OtherPortsAttributes *PortAttrs `json:"otherPortsAttributes,omitempty"` - RunArgs []string `json:"runArgs,omitempty"` - ShutdownAction string `json:"shutdownAction,omitempty"` - OverrideCommand *bool `json:"overrideCommand,omitempty"` - InitializeCommand LifecycleCommand `json:"initializeCommand,omitempty"` - OnCreateCommand LifecycleCommand `json:"onCreateCommand,omitempty"` - UpdateContentCommand LifecycleCommand `json:"updateContentCommand,omitempty"` - PostCreateCommand LifecycleCommand `json:"postCreateCommand,omitempty"` - PostStartCommand LifecycleCommand `json:"postStartCommand,omitempty"` - PostAttachCommand LifecycleCommand `json:"postAttachCommand,omitempty"` - WaitFor string `json:"waitFor,omitempty"` - WorkspaceFolder string `json:"workspaceFolder,omitempty"` - WorkspaceMount string `json:"workspaceMount,omitempty"` - Mounts []MountOrString `json:"mounts,omitempty"` - ContainerEnv map[string]string `json:"containerEnv,omitempty"` - ContainerUser string `json:"containerUser,omitempty"` - Init *bool `json:"init,omitempty"` - Privileged *bool `json:"privileged,omitempty"` - CapAdd []string `json:"capAdd,omitempty"` - SecurityOpt []string `json:"securityOpt,omitempty"` - RemoteEnv map[string]*string `json:"remoteEnv,omitempty"` - RemoteUser string `json:"remoteUser,omitempty"` - UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` - UserEnvProbe string `json:"userEnvProbe,omitempty"` - Features map[string]interface{} `json:"features,omitempty"` - OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` - HostRequirements *HostRequirements `json:"hostRequirements,omitempty"` - Customizations map[string]interface{} `json:"customizations,omitempty"` + Name string `json:"name,omitempty"` + ForwardPorts []PortOrString `json:"forwardPorts,omitempty"` + AppPort json.RawMessage `json:"appPort,omitempty"` // number | string | (number|string)[] + PortsAttributes map[string]PortAttrs `json:"portsAttributes,omitempty"` + OtherPortsAttributes *PortAttrs `json:"otherPortsAttributes,omitempty"` + RunArgs []string `json:"runArgs,omitempty"` + ShutdownAction string `json:"shutdownAction,omitempty"` + OverrideCommand *bool `json:"overrideCommand,omitempty"` + InitializeCommand LifecycleCommand `json:"initializeCommand"` + OnCreateCommand LifecycleCommand `json:"onCreateCommand"` + UpdateContentCommand LifecycleCommand `json:"updateContentCommand"` + PostCreateCommand LifecycleCommand `json:"postCreateCommand"` + PostStartCommand LifecycleCommand `json:"postStartCommand"` + PostAttachCommand LifecycleCommand `json:"postAttachCommand"` + WaitFor string `json:"waitFor,omitempty"` + WorkspaceFolder string `json:"workspaceFolder,omitempty"` + WorkspaceMount string `json:"workspaceMount,omitempty"` + Mounts []MountOrString `json:"mounts,omitempty"` + ContainerEnv map[string]string `json:"containerEnv,omitempty"` + ContainerUser string `json:"containerUser,omitempty"` + Init *bool `json:"init,omitempty"` + Privileged *bool `json:"privileged,omitempty"` + CapAdd []string `json:"capAdd,omitempty"` + SecurityOpt []string `json:"securityOpt,omitempty"` + RemoteEnv map[string]*string `json:"remoteEnv,omitempty"` + RemoteUser string `json:"remoteUser,omitempty"` + UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` + UserEnvProbe string `json:"userEnvProbe,omitempty"` + Features map[string]any `json:"features,omitempty"` + OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` + HostRequirements *HostRequirements `json:"hostRequirements,omitempty"` + Customizations map[string]any `json:"customizations,omitempty"` // Secrets is the spec's "recommended secrets" metadata (env-var key -> // {description, documentationUrl}). The CLI does not act on it; kept as an // open map so it round-trips losslessly through read-configuration/metadata. - Secrets map[string]interface{} `json:"secrets,omitempty"` + Secrets map[string]any `json:"secrets,omitempty"` // --- Image variant --- Image string `json:"image,omitempty"` @@ -63,9 +64,9 @@ type DevContainer struct { RunServices []string `json:"runServices,omitempty"` // --- Deprecated / VS Code specific (migrated by UpdateFromOldProperties) --- - Extensions []string `json:"extensions,omitempty"` - Settings interface{} `json:"settings,omitempty"` - DevPort *int `json:"devPort,omitempty"` + Extensions []string `json:"extensions,omitempty"` + Settings any `json:"settings,omitempty"` + DevPort *int `json:"devPort,omitempty"` } // Build represents the "build" property in devcontainer.json. @@ -155,7 +156,7 @@ func (c *DevContainer) BuildContext() string { // LifecycleCommand can be string, []string, or map[string]interface{}. // This matches the TS type: string | string[] | Record. type LifecycleCommand struct { - raw interface{} // string, []interface{}, or map[string]interface{} + raw any // string, []interface{}, or map[string]interface{} } func (c *LifecycleCommand) UnmarshalJSON(data []byte) error { @@ -182,7 +183,7 @@ func (c *LifecycleCommand) AsString() (string, bool) { // AsStringSlice returns the command as []string, or nil. func (c *LifecycleCommand) AsStringSlice() ([]string, bool) { - arr, ok := c.raw.([]interface{}) + arr, ok := c.raw.([]any) if !ok { return nil, false } @@ -198,19 +199,19 @@ func (c *LifecycleCommand) AsStringSlice() ([]string, bool) { } // AsMap returns the command as a map (for parallel commands), or nil. -func (c *LifecycleCommand) AsMap() (map[string]interface{}, bool) { - m, ok := c.raw.(map[string]interface{}) +func (c *LifecycleCommand) AsMap() (map[string]any, bool) { + m, ok := c.raw.(map[string]any) return m, ok } // Raw returns the underlying value. -func (c *LifecycleCommand) Raw() interface{} { +func (c *LifecycleCommand) Raw() any { return c.raw } // PortOrString represents a port that can be number or string (e.g., "8080:80"). type PortOrString struct { - raw interface{} // float64 (JSON number) or string + raw any // float64 (JSON number) or string } func (p *PortOrString) UnmarshalJSON(data []byte) error { @@ -221,9 +222,14 @@ func (p PortOrString) MarshalJSON() ([]byte, error) { return json.Marshal(p.raw) } +// Value returns the port as decoded: a float64 for a JSON number, else a string. +func (p PortOrString) Value() any { + return p.raw +} + // MountOrString represents either a Mount object or a string mount spec. type MountOrString struct { - raw interface{} // string or map → Mount + raw any // string or map → Mount } func (m *MountOrString) UnmarshalJSON(data []byte) error { @@ -290,51 +296,72 @@ func (s StringOrStrings) MarshalJSON() ([]byte, error) { // --- Legacy property migration --- -// UpdateFromOldProperties migrates deprecated extensions/settings/devPort -// to customizations.vscode.*, matching the TS function in configuration.ts. -func UpdateFromOldProperties(c *DevContainer) { - if len(c.Extensions) == 0 && c.Settings == nil && c.DevPort == nil { - return +// updateFromOldProperties is TS updateFromOldProperties: it moves the +// deprecated top-level extensions, settings and devPort of a parsed +// devcontainer.json into customizations.vscode, on a copy. extensions are +// appended to customizations.vscode.extensions; settings are merged under +// customizations.vscode.settings, whose keys win; devPort moves only when +// customizations.vscode has none, and otherwise stays where it is; a null +// devPort is present, as for the TS `devPort !== undefined`. +func updateFromOldProperties(raw map[string]any) map[string]any { + devPort, hasDevPort := raw["devPort"] + if !truthy(raw["extensions"]) && !truthy(raw["settings"]) && !hasDevPort { + return raw } - - if c.Customizations == nil { - c.Customizations = make(map[string]interface{}) + out := maps.Clone(raw) + customizations, _ := out["customizations"].(map[string]any) + customizations = maps.Clone(customizations) + if customizations == nil { + customizations = map[string]any{} } - vscodeRaw, _ := c.Customizations["vscode"].(map[string]interface{}) - if vscodeRaw == nil { - vscodeRaw = make(map[string]interface{}) + vscode, _ := customizations["vscode"].(map[string]any) + vscode = maps.Clone(vscode) + if vscode == nil { + vscode = map[string]any{} } - - if len(c.Extensions) > 0 { - existing, _ := vscodeRaw["extensions"].([]interface{}) - for _, ext := range c.Extensions { - existing = append(existing, ext) + if truthy(out["extensions"]) { + existing, _ := vscode["extensions"].([]any) + added, ok := out["extensions"].([]any) + if !ok { + added = []any{out["extensions"]} } - vscodeRaw["extensions"] = existing - c.Extensions = nil + vscode["extensions"] = append(append([]any{}, existing...), added...) + delete(out, "extensions") } - - if c.Settings != nil { - if existingSettings, ok := vscodeRaw["settings"].(map[string]interface{}); ok { - if newSettings, ok := c.Settings.(map[string]interface{}); ok { - for k, v := range newSettings { - if _, exists := existingSettings[k]; !exists { - existingSettings[k] = v - } - } - } - } else { - vscodeRaw["settings"] = c.Settings + if truthy(out["settings"]) { + merged := map[string]any{} + if m, ok := out["settings"].(map[string]any); ok { + maps.Copy(merged, m) + } + if m, ok := vscode["settings"].(map[string]any); ok { + maps.Copy(merged, m) } - c.Settings = nil + vscode["settings"] = merged + delete(out, "settings") } - - if c.DevPort != nil { - if _, exists := vscodeRaw["devPort"]; !exists { - vscodeRaw["devPort"] = *c.DevPort + if hasDevPort { + if _, set := vscode["devPort"]; !set { + vscode["devPort"] = devPort + delete(out, "devPort") } - c.DevPort = nil } + customizations["vscode"] = vscode + out["customizations"] = customizations + return out +} - c.Customizations["vscode"] = vscodeRaw +// truthy is JavaScript truthiness for a JSON value. +func truthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case string: + return x != "" + case float64: + return x != 0 + default: + return true + } } diff --git a/internal/config/types_nodocker_held_test.go b/internal/config/types_nodocker_held_test.go new file mode 100644 index 0000000..c9e524a --- /dev/null +++ b/internal/config/types_nodocker_held_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TS updateFromOldProperties moves a property by JavaScript truthiness +// (`if (copy.extensions)`), and devPort whenever it is not undefined, so a +// null devPort moves too; an empty string or 0 stays where it is. +func TestUpdateFromOldPropertiesFollowsJavaScriptTruthiness(t *testing.T) { + for _, tt := range []struct{ in, want string }{ + {`{"devPort":null}`, `{"customizations":{"vscode":{"devPort":null}}}`}, + {`{"extensions":""}`, `{"extensions":""}`}, + {`{"extensions":"a"}`, `{"customizations":{"vscode":{"extensions":["a"]}}}`}, + {`{"extensions":0}`, `{"extensions":0}`}, + {`{"extensions":1}`, `{"customizations":{"vscode":{"extensions":[1]}}}`}, + {`{"extensions":false,"settings":0}`, `{"extensions":false,"settings":0}`}, + } { + var in, want map[string]any + if err := json.Unmarshal([]byte(tt.in), &in); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(tt.want), &want); err != nil { + t.Fatal(err) + } + if got := updateFromOldProperties(in); !reflect.DeepEqual(got, want) { + g, _ := json.Marshal(got) + t.Errorf("updateFromOldProperties(%s) = %s, want %s", tt.in, g, tt.want) + } + } +} diff --git a/internal/config/types_test.go b/internal/config/types_test.go index dd763ae..c25bef3 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "reflect" "strings" "testing" @@ -204,30 +205,44 @@ func TestStringOrStrings_Array(t *testing.T) { } } +// updateFromOldProperties follows TS updateFromOldProperties: extensions are +// appended, settings merged under the existing ones (which win), devPort moved +// only when customizations.vscode has none, and the input is left untouched. func TestUpdateFromOldProperties(t *testing.T) { - c := &DevContainer{ - Extensions: []string{"ms-python.python"}, - DevPort: intPtr(3000), - } - UpdateFromOldProperties(c) - - if len(c.Extensions) != 0 { - t.Error("extensions should be cleared") - } - if c.DevPort != nil { - t.Error("devPort should be cleared") - } - - vscode, ok := c.Customizations["vscode"].(map[string]interface{}) - if !ok { - t.Fatal("expected vscode customizations") - } - exts, ok := vscode["extensions"].([]interface{}) - if !ok || len(exts) != 1 || exts[0] != "ms-python.python" { - t.Errorf("extensions = %v", vscode["extensions"]) + tests := []struct { + name string + in string + want string + }{ + {"nothing deprecated", `{"image":"x"}`, `{"image":"x"}`}, + {"empty deprecated values", `{"extensions":[],"settings":null}`, `{"settings":null,"customizations":{"vscode":{"extensions":[]}}}`}, + { + "into existing customizations", + `{"extensions":["a"],"settings":{"x":1,"y":1},"customizations":{"vscode":{"extensions":["b"],"settings":{"y":2}}}}`, + `{"customizations":{"vscode":{"extensions":["b","a"],"settings":{"x":1,"y":2}}}}`, + }, + {"devPort moves", `{"devPort":3000}`, `{"customizations":{"vscode":{"devPort":3000}}}`}, + {"devPort stays when vscode has one", `{"devPort":3000,"customizations":{"vscode":{"devPort":1}}}`, `{"devPort":3000,"customizations":{"vscode":{"devPort":1}}}`}, } - if vscode["devPort"] != 3000 { - t.Errorf("devPort = %v", vscode["devPort"]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in, want map[string]any + if err := json.Unmarshal([]byte(tt.in), &in); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(tt.want), &want); err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(in) + got := updateFromOldProperties(in) + if !reflect.DeepEqual(got, want) { + g, _ := json.Marshal(got) + t.Errorf("got %s, want %s", g, tt.want) + } + if after, _ := json.Marshal(in); string(after) != string(before) { + t.Errorf("input changed: %s -> %s", before, after) + } + }) } } @@ -269,8 +284,6 @@ func TestParseFixture_WithJSONC(t *testing.T) { } } -func intPtr(i int) *int { return &i } - // TestPortAttrsSpecFields locks all five spec-defined portsAttributes properties // (label, protocol, onAutoForward, requireLocalPort, elevateIfNeeded) through a // parse → re-marshal round-trip. protocol and requireLocalPort were previously diff --git a/internal/config/varsub.go b/internal/config/varsub.go index 895d3b7..b992731 100644 --- a/internal/config/varsub.go +++ b/internal/config/varsub.go @@ -7,7 +7,6 @@ import ( "math/big" "path" "runtime" - "sort" "strings" ) @@ -34,49 +33,40 @@ type VariableResolver struct{} func NewVariableResolver() *VariableResolver { return &VariableResolver{} } -func (r *VariableResolver) Resolve(ctx SubstitutionContext, phase SubstitutionPhase, value interface{}) (interface{}, error) { - if phase == PhaseHost && ctx.ContainerWorkspaceFolder != "" { - resolved, err := r.resolveString(ctx, phase, ctx.ContainerWorkspaceFolder) - if err != nil { - return nil, err +func (r *VariableResolver) Resolve(ctx SubstitutionContext, phase SubstitutionPhase, value any) (any, error) { + // TS substitute resolves the container workspace folder before the value + // that may name it. Only the host phase reads it (resolveHostTag). + if phase == PhaseHost { + if ctx.ContainerWorkspaceFolder != "" { + resolved, err := r.resolveString(ctx, phase, ctx.ContainerWorkspaceFolder) + if err != nil { + return nil, err + } + ctx.ContainerWorkspaceFolder = resolved } - ctx.ContainerWorkspaceFolder = resolved } return r.resolveRecursive(ctx, phase, value) } -// BeforeContainer always applies both pre-container phases in canonical order. -func (r *VariableResolver) BeforeContainer(ctx SubstitutionContext, value interface{}) (interface{}, error) { - value, err := r.Resolve(ctx, PhaseHost, value) - if err != nil { - return nil, err - } - return r.Resolve(ctx, PhaseIdentity, value) -} - -func (r *VariableResolver) AfterContainer(ctx SubstitutionContext, value interface{}) (interface{}, error) { +func (r *VariableResolver) AfterContainer(ctx SubstitutionContext, value any) (any, error) { return r.Resolve(ctx, PhaseContainer, value) } -func (r *VariableResolver) BeforeContainerInto(ctx SubstitutionContext, target interface{}) error { +func (r *VariableResolver) BeforeContainerInto(ctx SubstitutionContext, target any) error { if err := r.ResolveInto(ctx, PhaseHost, target); err != nil { return err } return r.ResolveInto(ctx, PhaseIdentity, target) } -func (r *VariableResolver) AfterContainerInto(ctx SubstitutionContext, target interface{}) error { - return r.ResolveInto(ctx, PhaseContainer, target) -} - // ResolveInto applies a phase to any JSON-serializable configuration value and // unmarshals the result back into target. target must be a non-nil pointer. -func (r *VariableResolver) ResolveInto(ctx SubstitutionContext, phase SubstitutionPhase, target interface{}) error { +func (r *VariableResolver) ResolveInto(ctx SubstitutionContext, phase SubstitutionPhase, target any) error { data, err := json.Marshal(target) if err != nil { return fmt.Errorf("marshal substitution target: %w", err) } - var value interface{} + var value any if err := json.Unmarshal(data, &value); err != nil { return fmt.Errorf("decode substitution target: %w", err) } @@ -94,12 +84,12 @@ func (r *VariableResolver) ResolveInto(ctx SubstitutionContext, phase Substituti return nil } -func (r *VariableResolver) resolveRecursive(ctx SubstitutionContext, phase SubstitutionPhase, value interface{}) (interface{}, error) { +func (r *VariableResolver) resolveRecursive(ctx SubstitutionContext, phase SubstitutionPhase, value any) (any, error) { switch v := value.(type) { case string: return r.resolveString(ctx, phase, v) - case []interface{}: - result := make([]interface{}, len(v)) + case []any: + result := make([]any, len(v)) for i, item := range v { resolved, err := r.resolveRecursive(ctx, phase, item) if err != nil { @@ -108,8 +98,8 @@ func (r *VariableResolver) resolveRecursive(ctx SubstitutionContext, phase Subst result[i] = resolved } return result, nil - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) + case map[string]any: + result := make(map[string]any, len(v)) for key, item := range v { resolved, err := r.resolveRecursive(ctx, phase, item) if err != nil { @@ -263,23 +253,16 @@ type HostSubContext struct { // ComputeDevContainerID produces a deterministic ID from id-labels, // matching the TS implementation exactly: SHA256 → BigInt → base32 padded to 52. func ComputeDevContainerID(idLabels map[string]string) string { - // Sort keys for determinism (matches JSON.stringify with sorted keys) - keys := make([]string, 0, len(idLabels)) - for k := range idLabels { - keys = append(keys, k) - } - sort.Strings(keys) - - data, _ := json.Marshal(sortedMap(keys, idLabels)) + // encoding/json writes a map's keys sorted, as the TS JSON.stringify of the + // sorted labels does. + data, _ := json.Marshal(idLabels) hash := sha256.Sum256(data) // Convert hash to BigInt, then to base32 string, padded to 52 chars n := new(big.Int).SetBytes(hash[:]) str := n.Text(32) - if len(str) < 52 { - str = strings.Repeat("0", 52-len(str)) + str - } - return str + // A 256-bit hash has at most 52 base-32 digits. + return strings.Repeat("0", 52-len(str)) + str } func normalizeEnvKeys(env map[string]string) map[string]string { @@ -291,22 +274,8 @@ func normalizeEnvKeys(env map[string]string) map[string]string { } func winBasename(p string) string { - // Handle both / and \ separators on Windows - for i := len(p) - 1; i >= 0; i-- { - if p[i] == '/' || p[i] == '\\' { - return p[i+1:] - } - } - return p -} - -func sortedMap(keys []string, m map[string]string) map[string]string { - // json.Marshal on map[string]string sorts keys by default in Go - result := make(map[string]string, len(keys)) - for _, k := range keys { - result[k] = m[k] - } - return result + // Both / and \ separate on Windows; no separator leaves p whole. + return p[strings.LastIndexAny(p, "/\\")+1:] } // IsWindows returns true if the current platform is Windows. diff --git a/internal/config/varsub_nodocker_held_test.go b/internal/config/varsub_nodocker_held_test.go new file mode 100644 index 0000000..cee1dbc --- /dev/null +++ b/internal/config/varsub_nodocker_held_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +// In the host phase ${containerWorkspaceFolder} is the container workspace +// folder with its own host variables resolved first (TS substitute resolves +// context.containerWorkspaceFolder before substituting the value); the other +// phases leave it alone. +func TestResolveHostPhaseResolvesTheContainerWorkspaceFolderFirst(t *testing.T) { + ctx := SubstitutionContext{HostSubContext: HostSubContext{ + LocalWorkspaceFolder: "/home/me/proj", + ContainerWorkspaceFolder: "/workspaces/${localWorkspaceFolderBasename}", + }} + got, err := NewVariableResolver().Resolve(ctx, PhaseHost, "${containerWorkspaceFolder}/src") + if err != nil { + t.Fatal(err) + } + if got != "/workspaces/proj/src" { + t.Errorf("host phase = %q, want /workspaces/proj/src", got) + } + + // With no container workspace folder the tag stays as written. + ctx.ContainerWorkspaceFolder = "" + if got, err := NewVariableResolver().Resolve(ctx, PhaseHost, "${containerWorkspaceFolder}"); err != nil || got != "${containerWorkspaceFolder}" { + t.Errorf("no container folder = %q, %v; want the tag unchanged", got, err) + } +} diff --git a/internal/config/varsub_test.go b/internal/config/varsub_test.go index 4d28280..1626e87 100644 --- a/internal/config/varsub_test.go +++ b/internal/config/varsub_test.go @@ -10,23 +10,20 @@ import ( func TestVariableResolverBeforeContainerPhases(t *testing.T) { resolver := NewVariableResolver() ctx := SubstitutionContext{ - HostSubContext: HostSubContext{ - Platform: "linux", - LocalWorkspaceFolder: "/work/project", - Env: map[string]string{"HOME": "/home/test"}, - }, - IDLabels: map[string]string{"devcontainer.local_folder": "/work/project"}, + Platform: "linux", + LocalWorkspaceFolder: "/work/project", + Env: map[string]string{"HOME": "/home/test"}, + IDLabels: map[string]string{"devcontainer.local_folder": "/work/project"}, } - input := map[string]interface{}{ + input := map[string]any{ "workspace": "${localWorkspaceFolder}", "id": "${devcontainerId}", "later": "${containerEnv:PATH}", } - resolved, err := resolver.BeforeContainer(ctx, input) - if err != nil { + if err := resolver.BeforeContainerInto(ctx, &input); err != nil { t.Fatal(err) } - got := resolved.(map[string]interface{}) + got := input if got["workspace"] != "/work/project" { t.Errorf("workspace = %q", got["workspace"]) } @@ -40,9 +37,8 @@ func TestVariableResolverBeforeContainerPhases(t *testing.T) { func TestVariableResolverEnvironmentErrorIncludesConfig(t *testing.T) { resolver := NewVariableResolver() - _, err := resolver.Resolve(SubstitutionContext{HostSubContext: HostSubContext{ - ConfigFilePath: "/work/.devcontainer/devcontainer.json", - }}, PhaseHost, "${localEnv}") + _, err := resolver.Resolve(SubstitutionContext{ + ConfigFilePath: "/work/.devcontainer/devcontainer.json"}, PhaseHost, "${localEnv}") if err == nil { t.Fatal("expected missing environment variable name to fail") } @@ -86,8 +82,8 @@ func TestVariableResolverHostPhase(t *testing.T) { tests := []struct { name string ctx HostSubContext - input interface{} - want interface{} + input any + want any }{ // localEnv substitution {"localEnv HOME", localEnvCtx, "${localEnv:HOME}", "/home/user"}, @@ -120,19 +116,19 @@ func TestVariableResolverHostPhase(t *testing.T) { { "recursive map and slice", recursiveCtx, - map[string]interface{}{ + map[string]any{ "workspaceFolder": "${localWorkspaceFolder}", - "env": map[string]interface{}{ + "env": map[string]any{ "HOME": "${localEnv:HOME}", }, - "ports": []interface{}{"${localEnv:HOME}:8080"}, + "ports": []any{"${localEnv:HOME}:8080"}, }, - map[string]interface{}{ + map[string]any{ "workspaceFolder": "/home/user/project", - "env": map[string]interface{}{ + "env": map[string]any{ "HOME": "/home/user", }, - "ports": []interface{}{"/home/user:8080"}, + "ports": []any{"/home/user:8080"}, }, }, } @@ -249,8 +245,8 @@ func TestVariableResolverContainerPhase(t *testing.T) { for _, tt := range tests { got, err := NewVariableResolver().AfterContainer(SubstitutionContext{ - HostSubContext: HostSubContext{Platform: "linux"}, - ContainerEnv: containerEnv, + Platform: "linux", + ContainerEnv: containerEnv, }, tt.input) if err != nil { t.Fatal(err) diff --git a/internal/config/varsub_win_held_test.go b/internal/config/varsub_win_held_test.go new file mode 100644 index 0000000..27fefb2 --- /dev/null +++ b/internal/config/varsub_win_held_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +// On win32 both '\' and '/' separate the workspace folder's basename, and a +// folder without one is its own basename. +func TestLocalWorkspaceFolderBasenameOnWindows(t *testing.T) { + for folder, want := range map[string]string{ + `C:\Users\me\project`: "project", + `C:/Users/me/project`: "project", + `C:\Users/me\mixed`: "mixed", + `project`: "project", + } { + ctx := HostSubContext{Platform: "win32", LocalWorkspaceFolder: folder, Env: map[string]string{}} + got, err := NewVariableResolver().Resolve(SubstitutionContext{HostSubContext: ctx}, PhaseHost, "${localWorkspaceFolderBasename}") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("basename of %q = %v, want %q", folder, got, want) + } + } +} diff --git a/internal/devtools/coverage.go b/internal/devtools/coverage.go index 2841c5e..ead2bb0 100644 --- a/internal/devtools/coverage.go +++ b/internal/devtools/coverage.go @@ -4,7 +4,8 @@ import ( "bufio" "fmt" "io" - "sort" + "path" + "slices" "strconv" "strings" ) @@ -42,7 +43,6 @@ func ParseProfile(r io.Reader) (pkgs []PkgCov, total PkgCov, err error) { var gCov, gTot int sc := bufio.NewScanner(r) - sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) first := true for sc.Scan() { line := sc.Text() @@ -56,14 +56,9 @@ func ParseProfile(r io.Reader) (pkgs []PkgCov, total PkgCov, err error) { if len(fields) < 3 { continue } - // "import/path/file.go:1.1,3.2" -> "import/path" (package import path). - p := fields[0] - if i := strings.IndexByte(p, ':'); i >= 0 { - p = p[:i] - } - if i := strings.LastIndex(p, "/"); i >= 0 { - p = p[:i] - } + // "import/path/file.go:1.1,3.2" -> "import/path" (package import path): the + // file and its positions hold no '/'. + p := path.Dir(fields[0]) stmts, _ := strconv.Atoi(fields[1]) count, _ := strconv.Atoi(fields[2]) tot[p] += stmts @@ -80,7 +75,7 @@ func ParseProfile(r io.Reader) (pkgs []PkgCov, total PkgCov, err error) { for p := range tot { pkgs = append(pkgs, PkgCov{Pkg: p, Covered: cov[p], Total: tot[p]}) } - sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Pkg < pkgs[j].Pkg }) + slices.SortFunc(pkgs, func(a, b PkgCov) int { return strings.Compare(a.Pkg, b.Pkg) }) return pkgs, PkgCov{Pkg: "TOTAL", Covered: gCov, Total: gTot}, nil } diff --git a/internal/devtools/metrics.go b/internal/devtools/metrics.go new file mode 100644 index 0000000..ca0da4e --- /dev/null +++ b/internal/devtools/metrics.go @@ -0,0 +1,86 @@ +package devtools + +import ( + "compress/gzip" + "io" + "os" + "time" +) + +// Metrics is artifacts/metrics.json: startup time of the Go binary against the +// Node oracle, and the size of the binaries a release ships. A measurement that +// could not be taken is null. +type Metrics struct { + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + TimingTool string `json:"timing_tool"` + Runs int `json:"runs"` + StartupMs StartupMs `json:"startup_ms"` + SizesBytes SizesBytes `json:"sizes_bytes"` +} + +// StartupMs is the mean wall-clock time of each command, in milliseconds. +type StartupMs struct { + GoVersion *float64 `json:"go_version"` + GoReadConfiguration *float64 `json:"go_read_configuration"` + NodeVersion *float64 `json:"node_version"` +} + +// SizesBytes is the size of each binary, and of the release binaries gzipped. +type SizesBytes struct { + LocalBinary *int64 `json:"local_binary"` + LinuxAmd64Binary *int64 `json:"linux_amd64_binary"` + LinuxAmd64Gzip *int64 `json:"linux_amd64_gzip"` + LinuxArm64Binary *int64 `json:"linux_arm64_binary"` + LinuxArm64Gzip *int64 `json:"linux_arm64_gzip"` +} + +// MeanMillis runs once to warm up, then runs times, and returns the mean +// wall-clock milliseconds of a run. A run's failure is measured like a success: +// the former shell loop timed `cmd || true`. +func MeanMillis(runs int, run func()) float64 { + if runs <= 0 { + return 0 + } + run() + start := time.Now() + for range runs { + run() + } + return float64(time.Since(start)) / float64(time.Millisecond) / float64(runs) +} + +// FileSize is the size of path, or nil when it cannot be read. +func FileSize(path string) *int64 { + fi, err := os.Stat(path) + if err != nil { + return nil + } + return new(fi.Size()) +} + +// GzipSize is the size of path compressed with gzip's default level, or nil when +// it cannot be read. +func GzipSize(path string) *int64 { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + cw := &countWriter{} + zw := gzip.NewWriter(cw) + if _, err := io.Copy(zw, f); err != nil { + return nil + } + if err := zw.Close(); err != nil { + return nil + } + return new(cw.n) +} + +type countWriter struct{ n int64 } + +func (c *countWriter) Write(p []byte) (int, error) { + c.n += int64(len(p)) + return len(p), nil +} diff --git a/internal/devtools/metrics_test.go b/internal/devtools/metrics_test.go new file mode 100644 index 0000000..7e7c352 --- /dev/null +++ b/internal/devtools/metrics_test.go @@ -0,0 +1,82 @@ +package devtools + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// metrics.json keeps the shape the former shell wrote: every key present, and a +// measurement that was not taken written as null, not omitted. +func TestMetricsJSONKeepsEveryKeyAndNulls(t *testing.T) { + ms := 1.5 + size := int64(10) + m := Metrics{Version: "v1", GeneratedAt: "2026-10-25T00:00:00Z", TimingTool: "go", Runs: 3, + StartupMs: StartupMs{GoVersion: &ms}, + SizesBytes: SizesBytes{LocalBinary: &size}} + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + want := `{"version":"v1","generated_at":"2026-10-25T00:00:00Z","timing_tool":"go","runs":3,` + + `"startup_ms":{"go_version":1.5,"go_read_configuration":null,"node_version":null},` + + `"sizes_bytes":{"local_binary":10,"linux_amd64_binary":null,"linux_amd64_gzip":null,"linux_arm64_binary":null,"linux_arm64_gzip":null}}` + if string(b) != want { + t.Errorf("got %s\nwant %s", b, want) + } +} + +// MeanMillis warms up once and then times exactly runs calls. +func TestMeanMillisWarmsUpThenRuns(t *testing.T) { + calls := 0 + if got := MeanMillis(4, func() { calls++ }); got < 0 { + t.Errorf("mean = %v, want >= 0", got) + } + if calls != 5 { + t.Errorf("calls = %d, want 5 (1 warmup + 4 runs)", calls) + } + if got := MeanMillis(0, func() { t.Fatal("ran with runs=0") }); got != 0 { + t.Errorf("runs=0 mean = %v, want 0", got) + } + calls = 0 + MeanMillis(1, func() { calls++ }) + if calls != 2 { + t.Errorf("runs=1: calls = %d, want 2 (1 warmup + 1 run)", calls) + } +} + +// The mean is in milliseconds: a run of 20 ms measures as 20 ms, not as its +// microseconds or seconds. +func TestMeanMillisIsMilliseconds(t *testing.T) { + got := MeanMillis(2, func() { time.Sleep(20 * time.Millisecond) }) + if got < 20 || got > 2000 { + t.Errorf("mean = %v ms for 20 ms runs", got) + } +} + +// Sizes are nil for a missing file, and the gzip size is what gzip writes. +func TestFileAndGzipSize(t *testing.T) { + dir := t.TempDir() + if FileSize(filepath.Join(dir, "absent")) != nil || GzipSize(filepath.Join(dir, "absent")) != nil { + t.Fatal("a missing file has no size") + } + data := bytes.Repeat([]byte("devcontainer "), 1000) + p := filepath.Join(dir, "bin") + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } + if got := FileSize(p); got == nil || *got != int64(len(data)) { + t.Errorf("FileSize = %v, want %d", got, len(data)) + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Write(data) + zw.Close() + if got := GzipSize(p); got == nil || *got != int64(buf.Len()) { + t.Errorf("GzipSize = %v, want %d", got, buf.Len()) + } +} diff --git a/internal/devtools/parityaffected.go b/internal/devtools/parityaffected.go index 53b67f4..c2f543a 100644 --- a/internal/devtools/parityaffected.go +++ b/internal/devtools/parityaffected.go @@ -5,7 +5,8 @@ package devtools import ( - "sort" + "maps" + "slices" "strings" ) @@ -48,12 +49,7 @@ func ParityAffected(files []string) string { if len(cmds) == 0 { return "none" } - keys := make([]string, 0, len(cmds)) - for k := range cmds { - keys = append(keys, k) - } - sort.Strings(keys) - return strings.Join(keys, ",") + return strings.Join(slices.Sorted(maps.Keys(cmds)), ",") } type fileKind int diff --git a/internal/devtools/release.go b/internal/devtools/release.go new file mode 100644 index 0000000..08b1001 --- /dev/null +++ b/internal/devtools/release.go @@ -0,0 +1,58 @@ +package devtools + +import ( + "errors" + "fmt" + "strings" +) + +// ReleaseInput is what the release workflow knows when it resolves a release: +// the event, the dispatch's version input, the pushed tag, and the repository's +// state (the commit to release, today's CalVer date, and the tags already made). +type ReleaseInput struct { + Event string // GITHUB_EVENT_NAME; empty outside CI, which answers as a dispatch + VersionInput string // a dispatch's version, with or without a leading v + RefName string // GITHUB_REF_NAME: the pushed tag + PushedSHA string // GITHUB_SHA of a tag push + HeadSHA string // HEAD, the commit a dispatch releases + Today string // YYYYMMDD in UTC + TagsToday int // tags named v.* + TagExists func(tag string) bool +} + +// Release is the version, tag and commit to release. +type Release struct { + Version, Tag, SHA string +} + +// ErrInvalidVersion is a version holding anything but [0-9A-Za-z.+-]: the value +// later reaches git and docker command lines. +var ErrInvalidVersion = errors.New("invalid version") + +// ErrTagExists is a version whose tag was already released; releases are immutable. +var ErrTagExists = errors.New("tag already exists") + +// ResolveRelease decides the release: a tag push releases that tag; a dispatch +// (or a run outside CI) releases its version input, or the next CalVer of the +// day (YYYYMMDD.NN), at HEAD. +func ResolveRelease(in ReleaseInput) (Release, error) { + if in.Event != "" && in.Event != "workflow_dispatch" { + return Release{Version: strings.TrimPrefix(in.RefName, "v"), Tag: in.RefName, SHA: in.PushedSHA}, nil + } + version := strings.TrimPrefix(in.VersionInput, "v") + if version == "" { + version = fmt.Sprintf("%s.%02d", in.Today, in.TagsToday+1) + } + if !validVersion(version) { + return Release{}, fmt.Errorf("%w '%s': use only [0-9A-Za-z.+-]", ErrInvalidVersion, version) + } + tag := "v" + version + if in.TagExists != nil && in.TagExists(tag) { + return Release{}, fmt.Errorf("%w: %s — releases are immutable, bump the build number", ErrTagExists, tag) + } + return Release{Version: version, Tag: tag, SHA: in.HeadSHA}, nil +} + +func validVersion(v string) bool { + return v != "" && strings.Trim(v, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.+-") == "" +} diff --git a/internal/devtools/release_test.go b/internal/devtools/release_test.go new file mode 100644 index 0000000..faf3235 --- /dev/null +++ b/internal/devtools/release_test.go @@ -0,0 +1,72 @@ +package devtools + +import ( + "errors" + "slices" + "testing" +) + +// TestResolveRelease covers what the former release:resolve shell decided: a tag +// push releases its tag; a dispatch releases its input (one leading v dropped) or +// the day's next CalVer, rejects a version outside [0-9A-Za-z.+-], and refuses a +// tag that already exists. +func TestResolveRelease(t *testing.T) { + exists := func(tags ...string) func(string) bool { + return func(tag string) bool { + return slices.Contains(tags, tag) + } + } + base := ReleaseInput{HeadSHA: "head", Today: "20261025", TagExists: exists()} + cases := []struct { + name string + in func(ReleaseInput) ReleaseInput + want Release + wantErr error + }{ + {"tag push", func(i ReleaseInput) ReleaseInput { + i.Event, i.RefName, i.PushedSHA = "push", "v20261024.03", "pushed" + return i + }, Release{"20261024.03", "v20261024.03", "pushed"}, nil}, + {"dispatch input with v", func(i ReleaseInput) ReleaseInput { + i.Event, i.VersionInput = "workflow_dispatch", "v1.2.3" + return i + }, Release{"1.2.3", "v1.2.3", "head"}, nil}, + {"only one leading v is dropped", func(i ReleaseInput) ReleaseInput { + i.VersionInput = "vv1" + return i + }, Release{"v1", "vv1", "head"}, nil}, + {"outside CI, first CalVer of the day", func(i ReleaseInput) ReleaseInput { return i }, + Release{"20261025.01", "v20261025.01", "head"}, nil}, + {"next CalVer after two tags today", func(i ReleaseInput) ReleaseInput { + i.TagsToday = 2 + return i + }, Release{"20261025.03", "v20261025.03", "head"}, nil}, + {"build metadata and prerelease are plain tokens", func(i ReleaseInput) ReleaseInput { + i.VersionInput = "1.0.0-rc.1+b2" + return i + }, Release{"1.0.0-rc.1+b2", "v1.0.0-rc.1+b2", "head"}, nil}, + {"shell metacharacters rejected", func(i ReleaseInput) ReleaseInput { + i.VersionInput = "1.0;rm -rf" + return i + }, Release{}, ErrInvalidVersion}, + {"a lone v is empty after the trim and gets the CalVer", func(i ReleaseInput) ReleaseInput { + i.VersionInput = "v" + return i + }, Release{"20261025.01", "v20261025.01", "head"}, nil}, + {"existing tag refused", func(i ReleaseInput) ReleaseInput { + i.VersionInput, i.TagExists = "1.2.3", exists("v1.2.3") + return i + }, Release{}, ErrTagExists}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveRelease(tc.in(base)) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if got != tc.want { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} diff --git a/internal/devtools/runnerdisk.go b/internal/devtools/runnerdisk.go new file mode 100644 index 0000000..0729405 --- /dev/null +++ b/internal/devtools/runnerdisk.go @@ -0,0 +1,24 @@ +package devtools + +import "syscall" + +// RunnerDiskRoom is the free space on / the runtime parity lane needs: its cases' +// images, the reference oracle's and the build cache exports. GitHub's runners +// start with about 20 GB free and get there by deleting preinstalled SDKs; the +// docker8 fleet's 150 GB volume starts past it. +const RunnerDiskRoom = 60 << 30 + +// NeedsDiskFreeing reports whether a runner with free bytes on / has to delete +// its preinstalled SDKs to make RunnerDiskRoom. +func NeedsDiskFreeing(free uint64) bool { + return free < RunnerDiskRoom +} + +// FreeDisk returns the bytes free on the filesystem holding path. +func FreeDisk(path string) (uint64, error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, err + } + return st.Bavail * uint64(st.Bsize), nil +} diff --git a/internal/devtools/runnerdisk_test.go b/internal/devtools/runnerdisk_test.go new file mode 100644 index 0000000..440c637 --- /dev/null +++ b/internal/devtools/runnerdisk_test.go @@ -0,0 +1,36 @@ +package devtools + +import "testing" + +// A runner with less than RunnerDiskRoom free deletes its SDKs (GitHub's, about +// 20 GB free); one with room keeps them (the docker8 fleet's 150 GB volume). +func TestNeedsDiskFreeing(t *testing.T) { + tests := []struct { + name string + free uint64 + want bool + }{ + {"github runner", 20 << 30, true}, + {"one byte short", RunnerDiskRoom - 1, true}, + {"exactly the room", RunnerDiskRoom, false}, + {"fleet volume", 140 << 30, false}, + } + for _, tt := range tests { + if got := NeedsDiskFreeing(tt.free); got != tt.want { + t.Errorf("%s: NeedsDiskFreeing(%d) = %v, want %v", tt.name, tt.free, got, tt.want) + } + } +} + +func TestFreeDiskReadsTheFilesystem(t *testing.T) { + free, err := FreeDisk(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if free == 0 { + t.Fatal("FreeDisk = 0 on a writable temp dir") + } + if _, err := FreeDisk("/nonexistent/path"); err == nil { + t.Fatal("FreeDisk on a missing path: want an error") + } +} diff --git a/internal/devtools/versions_gate_test.go b/internal/devtools/versions_gate_test.go new file mode 100644 index 0000000..4dfe1fd --- /dev/null +++ b/internal/devtools/versions_gate_test.go @@ -0,0 +1,24 @@ +package devtools + +import ( + "testing" + + "github.com/spin-stack/go-tools/versions/gate" +) + +// versions.yaml is the one place this repository pins an image, a download or a +// tool version: a pin written anywhere else, an entry nothing reads, or one a +// bump could not rewrite fails. Excused: reference/ is upstream's submodule, +// pinned by its own lockfile; src/test/ is upstream's fixtures, whose lockfiles' +// digests are what the parity tests compare; updateUID.Dockerfile is upstream's, +// shipped in the binary, and its BASE_IMAGE is the user's image, handed in at run +// time (docker.BuildUIDImage), not a pin; internal/imagemeta/testdata/ holds +// Dockerfiles the TS CLI generated, whose ARG defaults are its output, not pins. +func TestVersionsYAMLIsTheOnlyPin(t *testing.T) { + g := gate.Gate{Root: "../..", Elsewhere: []string{ + "go.sum", "reference/", "src/test/", "internal/docker/updateUID.Dockerfile", "internal/imagemeta/testdata/", + }} + if err := g.Check(); err != nil { + t.Error(err) + } +} diff --git a/internal/docker/builder_detect_held_test.go b/internal/docker/builder_detect_held_test.go new file mode 100644 index 0000000..804b98a --- /dev/null +++ b/internal/docker/builder_detect_held_test.go @@ -0,0 +1,26 @@ +package docker + +import ( + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// A buildx command that exits non-zero reports nothing, even when it printed +// what would parse as a builder. +func TestBuilderDetectionIgnoresAFailedBuildx(t *testing.T) { + inspect := &recordingRunner{responses: []cannedResponse{{stdout: []byte("Name: default\nDriver: docker\n"), code: 1}}} + c := &Client{DockerPath: "docker", Log: log.Null, Runner: inspect} + if info := c.DetectActiveBuilder(t.Context()); info.Name != "" || info.Driver != "" { + t.Errorf("DetectActiveBuilder after a failed inspect = %+v, want empty", info) + } + + ls := &recordingRunner{responses: []cannedResponse{ + {stdout: []byte("default\n"), code: 0}, + {stdout: []byte("NAME DRIVER\ndefault docker running\n"), code: 1}, + }} + c2 := &Client{DockerPath: "docker", Log: log.Null, Runner: ls} + if got := c2.FindDockerDriverBuilder(t.Context()); got != "" { + t.Errorf("FindDockerDriverBuilder after a failed ls = %q, want empty", got) + } +} diff --git a/internal/docker/client.go b/internal/docker/client.go index 083a832..4d60dbf 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -239,7 +239,7 @@ func (c *Client) DetectActiveBuilder(ctx context.Context) *BuilderInfo { return &BuilderInfo{} } info := &BuilderInfo{} - for _, line := range strings.Split(string(res.Stdout), "\n") { + for line := range strings.SplitSeq(string(res.Stdout), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Name:") && info.Name == "" { info.Name = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) @@ -265,12 +265,12 @@ func (c *Client) FindDockerDriverBuilder(ctx context.Context) string { // Parse output: each builder starts at column 0 with NAME, followed by DRIVER // Prefer builder matching the current context, fall back to any docker driver builder fallback := "" - for _, line := range strings.Split(string(res.Stdout), "\n") { - if line == "" || strings.HasPrefix(line, "NAME") { + for line := range strings.SplitSeq(string(res.Stdout), "\n") { + if strings.HasPrefix(line, "NAME") { continue } - fields := strings.Fields(line) - if len(fields) >= 2 && !strings.HasPrefix(fields[0], " ") { + fields := strings.Fields(line) // a blank line has none + if len(fields) >= 2 { name := strings.TrimSuffix(fields[0], "*") driver := fields[1] if driver == "docker" { diff --git a/internal/docker/client_nodocker_held_test.go b/internal/docker/client_nodocker_held_test.go new file mode 100644 index 0000000..2d40b68 --- /dev/null +++ b/internal/docker/client_nodocker_held_test.go @@ -0,0 +1,19 @@ +package docker + +import ( + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// `docker buildx inspect` names the builder and its driver first, then repeats +// Name: for each of its nodes; the builder is the first Name: and Driver:. +func TestDetectActiveBuilderKeepsTheBuildersOwnNameAndDriver(t *testing.T) { + out := "Name: mybuilder\nDriver: docker-container\n\nNodes:\nName: mybuilder0\nDriver: other\nEndpoint: unix:///var/run/docker.sock\n" + rr := &recordingRunner{responses: []cannedResponse{{stdout: []byte(out)}}} + c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} + info := c.DetectActiveBuilder(t.Context()) + if info.Name != "mybuilder" || info.Driver != "docker-container" { + t.Errorf("DetectActiveBuilder = %+v, want {mybuilder docker-container}", info) + } +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 058895c..6fd7008 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -91,7 +91,7 @@ func (c *ComposeClient) run(ctx context.Context, stream bool, args ...string) (* } // Config runs `docker compose config` and returns parsed YAML. -func (c *ComposeClient) Config(ctx context.Context, composeFiles []string, envFile string) (map[string]interface{}, error) { +func (c *ComposeClient) Config(ctx context.Context, composeFiles []string, envFile string) (map[string]any, error) { args := c.buildGlobalArgs(composeFiles, envFile) args = append(args, "config", "--format", "json") @@ -103,7 +103,7 @@ func (c *ComposeClient) Config(ctx context.Context, composeFiles []string, envFi return nil, fmt.Errorf("compose config failed (exit %d): %s", res.ExitCode, string(res.Stderr)) } - var config map[string]interface{} + var config map[string]any if err := json.Unmarshal(res.Stdout, &config); err != nil { return nil, fmt.Errorf("parse compose config: %w", err) } @@ -272,3 +272,32 @@ func ToProjectName(basename string, newProjectName bool) string { return -1 }, strings.ToLower(basename)) } + +// DefaultImageName is the image name Compose gives a service built without an +// `image:` (TS getDefaultImageName): "-", with "_" before +// Compose 2.8.0. +func (c *ComposeClient) DefaultImageName(projectName, service string) string { + sep := "-" + if len(c.Version) > 0 && isEarlierVersion(c.Version, []int{2, 8, 0}) { + sep = "_" + } + return projectName + sep + service +} + +// isEarlierVersion is TS commonUtils.isEarlierVersion: left precedes right, +// a missing component counting as 0. +func isEarlierVersion(left, right []int) bool { + for i := range max(len(left), len(right)) { + var l, r int + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + if l != r { + return l < r + } + } + return false +} diff --git a/internal/docker/compose_nodocker_held_test.go b/internal/docker/compose_nodocker_held_test.go new file mode 100644 index 0000000..b8004e4 --- /dev/null +++ b/internal/docker/compose_nodocker_held_test.go @@ -0,0 +1,30 @@ +package docker + +import "testing" + +// A service built without an image is tagged "-", and +// "_" by Compose before 2.8.0, as TS getDefaultImageName +// decides with isEarlierVersion(version, [2, 8, 0]): a missing version +// component counts as 0, and an unknown version is a current Compose. +func TestDefaultImageNameFollowsTheComposeVersion(t *testing.T) { + for _, tc := range []struct { + version []int + want string + }{ + {nil, "proj-app"}, + {[]int{1}, "proj_app"}, + {[]int{1, 29, 2}, "proj_app"}, + {[]int{2}, "proj_app"}, + {[]int{2, 7, 9}, "proj_app"}, + {[]int{2, 8}, "proj-app"}, + {[]int{2, 8, 0}, "proj-app"}, + {[]int{2, 8, 1}, "proj-app"}, + {[]int{2, 10}, "proj-app"}, + {[]int{3}, "proj-app"}, + } { + c := &ComposeClient{Version: tc.version} + if got := c.DefaultImageName("proj", "app"); got != tc.want { + t.Errorf("DefaultImageName with Compose %v = %q, want %q", tc.version, got, tc.want) + } + } +} diff --git a/internal/docker/composeoverride.go b/internal/docker/composeoverride.go index 718a208..de16a36 100644 --- a/internal/docker/composeoverride.go +++ b/internal/docker/composeoverride.go @@ -1,134 +1,11 @@ package docker -import ( - "encoding/json" - "os" - "strings" -) +import "strings" -// ComposeOverride represents a Docker Compose override file. -// Serialized as JSON, which Docker Compose v2 reads natively. -type ComposeOverride struct { - Services map[string]*ServiceOverride `json:"services"` - Volumes map[string]interface{} `json:"volumes,omitempty"` -} - -// ServiceOverride represents overrides for a single compose service. -type ServiceOverride struct { - Image string `json:"image,omitempty"` - Build *BuildOverride `json:"build,omitempty"` - Entrypoint []string `json:"entrypoint,omitempty"` - Command []string `json:"command,omitempty"` - Environment []string `json:"environment,omitempty"` - User string `json:"user,omitempty"` - Privileged *bool `json:"privileged,omitempty"` - Init *bool `json:"init,omitempty"` - CapAdd []string `json:"cap_add,omitempty"` - SecurityOpt []string `json:"security_opt,omitempty"` - VolumesSpec []string `json:"volumes,omitempty"` - Labels []string `json:"labels,omitempty"` -} - -// BuildOverride represents build configuration overrides. -type BuildOverride struct { - Dockerfile string `json:"dockerfile,omitempty"` - Context string `json:"context,omitempty"` - Target string `json:"target,omitempty"` - Args map[string]string `json:"args,omitempty"` - AdditionalContexts map[string]string `json:"additional_contexts,omitempty"` -} - -// NewComposeOverride creates a new empty override. -func NewComposeOverride() *ComposeOverride { - return &ComposeOverride{ - Services: make(map[string]*ServiceOverride), - } -} - -// Service returns (or creates) the ServiceOverride for the given name. -func (o *ComposeOverride) Service(name string) *ServiceOverride { - if svc, ok := o.Services[name]; ok { - return svc - } - svc := &ServiceOverride{} - o.Services[name] = svc - return svc -} - -// AddVolume declares a named volume at the top level. -func (o *ComposeOverride) AddVolume(name string) { - if o.Volumes == nil { - o.Volumes = make(map[string]interface{}) - } - o.Volumes[name] = struct{}{} -} - -// AddEnv adds an environment variable to the service, with proper escaping -// for Docker Compose: \n → \n literal, $ → $$ -func (s *ServiceOverride) AddEnv(key, value string) { - escaped := value - escaped = strings.ReplaceAll(escaped, "\n", `\n`) - escaped = strings.ReplaceAll(escaped, "$", "$$") - s.Environment = append(s.Environment, key+"="+escaped) -} - -// AddVolume adds a volume mount spec to the service. -func (s *ServiceOverride) AddVolume(spec string) { - s.VolumesSpec = append(s.VolumesSpec, spec) -} - -// AddLabel adds a "key=value" label to the service, escaping $ as $$ so Docker -// Compose does not interpolate it (matching AddEnv). -func (s *ServiceOverride) AddLabel(label string) { - s.Labels = append(s.Labels, strings.ReplaceAll(label, "$", "$$")) -} - -// SetPrivileged sets the privileged flag. -func (s *ServiceOverride) SetPrivileged(v bool) { - s.Privileged = &v -} - -// SetInit sets the init flag. -func (s *ServiceOverride) SetInit(v bool) { - s.Init = &v -} - -// MarshalJSON produces valid Docker Compose JSON. -func (o *ComposeOverride) MarshalJSON() ([]byte, error) { - type alias ComposeOverride - return json.MarshalIndent((*alias)(o), "", " ") -} - -// WriteFile writes the override to a JSON file that Docker Compose v2 can read. -func (o *ComposeOverride) WriteFile(path string) error { - data, err := o.MarshalJSON() - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - -// BuildEntrypointScript generates the /bin/sh -c script that chains -// feature entrypoints (e.g., docker-init.sh) with a sleep loop. -// Matches the TS CLI pattern in src/spec-node/dockerCompose.ts:549-553. +// BuildEntrypointScript generates the /bin/sh -c script that chains feature +// entrypoints (e.g., docker-init.sh) with a sleep loop, as TS singleContainer.ts +// spawnDevContainer writes it: the entrypoints' line is there even when there +// are none, as an empty line. func BuildEntrypointScript(customEntrypoints []string) string { - var parts []string - parts = append(parts, "echo Container started") - parts = append(parts, `trap "exit 0" 15`) - parts = append(parts, customEntrypoints...) - parts = append(parts, `exec "$@"`) - parts = append(parts, `while sleep 1 & wait $!; do :; done`) - return strings.Join(parts, "\n") -} - -// BuildEntrypointScriptCompose generates the entrypoint script for compose, -// where $ must be escaped as $$ for compose interpolation. -func BuildEntrypointScriptCompose(customEntrypoints []string) string { - var parts []string - parts = append(parts, "echo Container started") - parts = append(parts, `trap "exit 0" 15`) - parts = append(parts, customEntrypoints...) - parts = append(parts, `exec "$$@"`) - parts = append(parts, `while sleep 1 & wait $$!; do :; done`) - return strings.Join(parts, "\n") + return "echo Container started\ntrap \"exit 0\" 15\n" + strings.Join(customEntrypoints, "\n") + "\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done" } diff --git a/internal/docker/composeoverride_test.go b/internal/docker/composeoverride_test.go index 4911c8b..6859444 100644 --- a/internal/docker/composeoverride_test.go +++ b/internal/docker/composeoverride_test.go @@ -1,175 +1,20 @@ package docker -import ( - "encoding/json" - "strings" - "testing" -) - -// composeService extracts services[name] from a parsed compose override. -func composeService(t *testing.T, parsed map[string]interface{}, name string) map[string]interface{} { - t.Helper() - services, ok := parsed["services"].(map[string]interface{}) - if !ok { - t.Fatal("missing services") - } - svc, ok := services[name].(map[string]interface{}) - if !ok { - t.Fatalf("missing service %q", name) - } - return svc -} - -func TestComposeOverride(t *testing.T) { - tests := []struct { - name string - build func(o *ComposeOverride) - check func(t *testing.T, parsed map[string]interface{}) - }{ - { - name: "image, privileged and capAdd", - build: func(o *ComposeOverride) { - svc := o.Service("app") - svc.Image = "myimage:latest" - svc.SetPrivileged(true) - svc.CapAdd = []string{"SYS_PTRACE"} - }, - check: func(t *testing.T, parsed map[string]interface{}) { - app := composeService(t, parsed, "app") - if app["image"] != "myimage:latest" { - t.Errorf("image = %v", app["image"]) - } - if app["privileged"] != true { - t.Errorf("privileged = %v", app["privileged"]) - } - }, - }, - { - name: "environment escaping ($ and newline)", - build: func(o *ComposeOverride) { - svc := o.Service("app") - svc.AddEnv("SIMPLE", "hello") - svc.AddEnv("DOLLAR", "value with $dollar") - svc.AddEnv("NEWLINE", "line1\nline2") - }, - check: func(t *testing.T, parsed map[string]interface{}) { - env := composeService(t, parsed, "app")["environment"].([]interface{}) - want := []string{"SIMPLE=hello", "DOLLAR=value with $$dollar", `NEWLINE=line1\nline2`} - for i, w := range want { - if env[i] != w { - t.Errorf("env[%d] = %v, want %q", i, env[i], w) - } - } - }, - }, - { - name: "named volume", - build: func(o *ComposeOverride) { - o.Service("app").AddVolume("mydata:/data") - o.AddVolume("mydata") - }, - check: func(t *testing.T, parsed map[string]interface{}) { - volumes, ok := parsed["volumes"].(map[string]interface{}) - if !ok { - t.Fatal("missing top-level volumes") - } - if _, ok := volumes["mydata"]; !ok { - t.Error("missing named volume 'mydata'") - } - }, - }, - { - name: "build override", - build: func(o *ComposeOverride) { - o.Service("app").Build = &BuildOverride{ - Dockerfile: "/path/to/Dockerfile", - Target: "dev_stage", - Args: map[string]string{"VARIANT": "18"}, - } - }, - check: func(t *testing.T, parsed map[string]interface{}) { - build := composeService(t, parsed, "app")["build"].(map[string]interface{}) - if build["dockerfile"] != "/path/to/Dockerfile" { - t.Errorf("dockerfile = %v", build["dockerfile"]) - } - if build["target"] != "dev_stage" { - t.Errorf("target = %v", build["target"]) - } - }, - }, - { - name: "entrypoint script", - build: func(o *ComposeOverride) { - svc := o.Service("app") - script := BuildEntrypointScriptCompose([]string{"/usr/local/share/docker-init.sh"}) - svc.Entrypoint = []string{"/bin/sh", "-c", script, "-"} - svc.Command = []string{} - }, - check: func(t *testing.T, parsed map[string]interface{}) { - ep := composeService(t, parsed, "app")["entrypoint"].([]interface{}) - if ep[0] != "/bin/sh" { - t.Errorf("entrypoint[0] = %v", ep[0]) - } - if ep[1] != "-c" { - t.Errorf("entrypoint[1] = %v", ep[1]) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - o := NewComposeOverride() - tt.build(o) - data, err := o.MarshalJSON() - if err != nil { - t.Fatal(err) - } - var parsed map[string]interface{} - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatal("invalid JSON:", err) - } - tt.check(t, parsed) - }) - } -} +import "testing" +// The script is TS spawnDevContainer's byte for byte: the entrypoints' line is +// there even with no entrypoint, as an empty line between trap and exec. func TestBuildEntrypointScript(t *testing.T) { - script := BuildEntrypointScript([]string{"/usr/local/share/docker-init.sh"}) - if script == "" { - t.Error("empty script") - } - // Check structure - lines := []string{ - "echo Container started", - `trap "exit 0" 15`, - "/usr/local/share/docker-init.sh", - `exec "$@"`, - `while sleep 1 & wait $!; do :; done`, - } - for _, line := range lines { - if !contains(script, line) { - t.Errorf("missing line: %s", line) - } - } -} - -func TestBuildEntrypointScriptCompose(t *testing.T) { - script := BuildEntrypointScriptCompose([]string{"/usr/local/share/docker-init.sh"}) - if strings.Contains(script, `\n`) { - t.Fatalf("compose script must contain real newlines, got %q", script) - } - for _, line := range []string{ - "echo Container started", - `trap "exit 0" 15`, - "/usr/local/share/docker-init.sh", - `exec "$$@"`, - `while sleep 1 & wait $$!; do :; done`, + for _, tt := range []struct { + entrypoints []string + want string + }{ + {nil, "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done"}, + {[]string{"/usr/local/share/docker-init.sh"}, "echo Container started\ntrap \"exit 0\" 15\n/usr/local/share/docker-init.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done"}, + {[]string{"/a.sh", "/b.sh"}, "echo Container started\ntrap \"exit 0\" 15\n/a.sh\n/b.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done"}, } { - if !contains(script, line) { - t.Errorf("missing line: %s", line) + if got := BuildEntrypointScript(tt.entrypoints); got != tt.want { + t.Errorf("BuildEntrypointScript(%q) =\n%q\nwant\n%q", tt.entrypoints, got, tt.want) } } } - -// contains helper already defined in dockerfile_test.go diff --git a/internal/docker/dockerfilegen.go b/internal/docker/dockerfilegen.go deleted file mode 100644 index 740c1fc..0000000 --- a/internal/docker/dockerfilegen.go +++ /dev/null @@ -1,132 +0,0 @@ -package docker - -import ( - "fmt" - "strings" -) - -// DockerfileBuilder constructs valid Dockerfiles from typed instructions. -// All value escaping is handled internally. -type DockerfileBuilder struct { - lines []string -} - -// NewDockerfileBuilder creates a new builder. -func NewDockerfileBuilder() *DockerfileBuilder { - return &DockerfileBuilder{} -} - -// Arg adds an ARG instruction. -func (b *DockerfileBuilder) Arg(name, defaultValue string) { - b.lines = append(b.lines, fmt.Sprintf("ARG %s=%s", name, defaultValue)) -} - -// FromClause is returned by From() to allow chaining .As() and .Platform(). -type FromClause struct { - b *DockerfileBuilder - i int // index into b.lines -} - -// From adds a FROM instruction. Chain with .As() or .Platform(). -func (b *DockerfileBuilder) From(image string) *FromClause { - idx := len(b.lines) - b.lines = append(b.lines, fmt.Sprintf("FROM %s", image)) - return &FromClause{b: b, i: idx} -} - -// As adds an AS clause to a FROM instruction. -func (f *FromClause) As(name string) *FromClause { - f.b.lines[f.i] += " AS " + name - return f -} - -// Platform adds a --platform flag to a FROM instruction. -func (f *FromClause) Platform(p string) *FromClause { - // Insert --platform before the image name - line := f.b.lines[f.i] - f.b.lines[f.i] = strings.Replace(line, "FROM ", fmt.Sprintf("FROM --platform=%s ", p), 1) - return f -} - -// User adds a USER instruction. -func (b *DockerfileBuilder) User(user string) { - b.lines = append(b.lines, fmt.Sprintf("USER %s", user)) -} - -// Env adds an ENV instruction with proper escaping of the value. -// Escapes: \ → \\, " → \", $ → \$ -func (b *DockerfileBuilder) Env(key, value string) { - b.lines = append(b.lines, fmt.Sprintf("ENV %s=%s", key, escapeDockerfileValue(value))) -} - -// EnvRaw adds an ENV instruction with a raw KEY=VALUE assignment (no escaping). -// Used for feature-generated env vars that are already formatted. -func (b *DockerfileBuilder) EnvRaw(assignment string) { - b.lines = append(b.lines, "ENV "+assignment) -} - -// CopyClause is returned by Copy() to allow chaining .From(). -type CopyClause struct { - b *DockerfileBuilder - i int -} - -// Copy adds a COPY instruction. Chain with .From() for multi-stage copies. -func (b *DockerfileBuilder) Copy(src, dst string) *CopyClause { - idx := len(b.lines) - b.lines = append(b.lines, fmt.Sprintf("COPY %s %s", src, dst)) - return &CopyClause{b: b, i: idx} -} - -// From adds a --from= clause to a COPY instruction. -func (c *CopyClause) From(stage string) *CopyClause { - line := c.b.lines[c.i] - c.b.lines[c.i] = strings.Replace(line, "COPY ", fmt.Sprintf("COPY --from=%s ", stage), 1) - return c -} - -// Run adds a RUN instruction. -func (b *DockerfileBuilder) Run(cmd string) { - b.lines = append(b.lines, "RUN "+cmd) -} - -// RunWithMounts adds a RUN instruction carrying the given `--mount=...` flags -// (e.g. build secrets: `--mount=type=secret,id=X`). Falls back to a plain RUN -// when there are none. -func (b *DockerfileBuilder) RunWithMounts(mounts []string, cmd string) { - if len(mounts) == 0 { - b.Run(cmd) - return - } - b.lines = append(b.lines, "RUN "+strings.Join(mounts, " ")+" "+cmd) -} - -// Label adds a LABEL instruction with proper escaping. -// The value is double-quoted with \, ", and $ escaped. -func (b *DockerfileBuilder) Label(key, value string) { - b.lines = append(b.lines, fmt.Sprintf("LABEL %s=%s", key, escapeDockerfileValue(value))) -} - -// BlankLine adds an empty line for readability. -func (b *DockerfileBuilder) BlankLine() { - b.lines = append(b.lines, "") -} - -// Comment adds a comment line. -func (b *DockerfileBuilder) Comment(text string) { - b.lines = append(b.lines, "# "+text) -} - -// String returns the complete Dockerfile content. -func (b *DockerfileBuilder) String() string { - return strings.Join(b.lines, "\n") + "\n" -} - -// escapeDockerfileValue escapes a string for use in a Dockerfile double-quoted value. -// Handles: \ → \\, " → \", $ → \$ -func escapeDockerfileValue(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - s = strings.ReplaceAll(s, `$`, `\$`) - return `"` + s + `"` -} diff --git a/internal/docker/dockerfilegen_test.go b/internal/docker/dockerfilegen_test.go deleted file mode 100644 index 6362ab9..0000000 --- a/internal/docker/dockerfilegen_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package docker - -import ( - "strings" - "testing" -) - -func envBuild(key, value string) func(*DockerfileBuilder) { - return func(b *DockerfileBuilder) { b.Env(key, value) } -} - -func TestDockerfileBuilder(t *testing.T) { - tests := []struct { - name string - build func(b *DockerfileBuilder) - wantContains []string - }{ - { - name: "basic directives", - build: func(b *DockerfileBuilder) { - b.Arg("BASE", "ubuntu:22.04") - b.From("$BASE").As("builder") - b.User("root") - b.Env("FOO", "bar") - b.Run("apt-get update") - }, - wantContains: []string{ - "ARG BASE=ubuntu:22.04", "FROM $BASE AS builder", "USER root", - `ENV FOO="bar"`, "RUN apt-get update", - }, - }, - {name: "env escaping: simple", build: envBuild("SIMPLE", "hello"), wantContains: []string{`ENV SIMPLE="hello"`}}, - {name: "env escaping: dollar", build: envBuild("DOLLAR", "value with $dollar"), wantContains: []string{`ENV DOLLAR="value with \$dollar"`}}, - {name: "env escaping: backslash", build: envBuild("BACKSLASH", `value with \back`), wantContains: []string{`ENV BACKSLASH="value with \\back"`}}, - {name: "env escaping: quotes", build: envBuild("QUOTES", `value with "quotes"`), wantContains: []string{`ENV QUOTES="value with \"quotes\""`}}, - {name: "env escaping: all", build: envBuild("ALL", `$1 "2" \3`), wantContains: []string{`ENV ALL="\$1 \"2\" \\3"`}}, - { - name: "label escaping", - build: func(b *DockerfileBuilder) { - b.Label("devcontainer.metadata", `[{"remoteUser":"node","postCreateCommand":"echo \"Val: $TEST\""}]`) - }, - wantContains: []string{`LABEL devcontainer.metadata="[{\"remoteUser\":\"node\",\"postCreateCommand\":\"echo \\\"Val: \$TEST\\\"\"}]"`}, - }, - { - name: "copy from stage", - build: func(b *DockerfileBuilder) { b.Copy("src/", "/dst/").From("builder") }, - wantContains: []string{"COPY --from=builder src/ /dst/"}, - }, - { - name: "from with platform", - build: func(b *DockerfileBuilder) { b.From("ubuntu:22.04").Platform("linux/amd64").As("base") }, - wantContains: []string{"FROM --platform=linux/amd64 ubuntu:22.04 AS base"}, - }, - { - name: "raw env", - build: func(b *DockerfileBuilder) { b.EnvRaw("_BUILD_ARG_GO=true"); b.EnvRaw(`GOPATH="/go"`) }, - wantContains: []string{"ENV _BUILD_ARG_GO=true", `ENV GOPATH="/go"`}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b := NewDockerfileBuilder() - tt.build(b) - out := b.String() - for _, want := range tt.wantContains { - if !strings.Contains(out, want) { - t.Errorf("output missing %q:\n%s", want, out) - } - } - }) - } -} diff --git a/internal/docker/engine.go b/internal/docker/engine.go index 853b960..9491a55 100644 --- a/internal/docker/engine.go +++ b/internal/docker/engine.go @@ -36,7 +36,6 @@ type API interface { ExecInspect(ctx context.Context, execID string, options mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) ImageInspect(ctx context.Context, imageID string, options ...mobyclient.ImageInspectOption) (mobyclient.ImageInspectResult, error) ImagePull(ctx context.Context, refStr string, options mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) - ImageTag(ctx context.Context, options mobyclient.ImageTagOptions) (mobyclient.ImageTagResult, error) ServerVersion(ctx context.Context, options mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) } @@ -87,19 +86,6 @@ func (e *EngineClient) InspectContainer(ctx context.Context, id string) (contain return res.Container, nil } -// InspectContainers returns inspect responses for multiple containers. -func (e *EngineClient) InspectContainers(ctx context.Context, ids ...string) ([]container.InspectResponse, error) { - results := make([]container.InspectResponse, 0, len(ids)) - for _, id := range ids { - res, err := e.API.ContainerInspect(ctx, id, mobyclient.ContainerInspectOptions{}) - if err != nil { - return nil, err - } - results = append(results, res.Container) - } - return results, nil -} - // InspectImage returns the full inspect response for an image. func (e *EngineClient) InspectImage(ctx context.Context, ref string) (image.InspectResponse, error) { res, err := e.API.ImageInspect(ctx, ref) @@ -192,31 +178,6 @@ func (e *EngineClient) RemoveContainer(ctx context.Context, id string) error { return nil } -// Events streams Docker events filtered by the given map (e.g. {"container": ["id"], "event": ["destroy"]}). -// Cancel the context to stop streaming. -func (e *EngineClient) Events(ctx context.Context, eventFilters map[string][]string) (<-chan events.Message, <-chan error) { - f := mobyclient.Filters{} - for key, values := range eventFilters { - f.Add(key, values...) - } - res := e.API.Events(ctx, mobyclient.EventsListOptions{Filters: f}) - return res.Messages, res.Err -} - -// IsPodman returns true if the daemon identifies itself as Podman. -func (e *EngineClient) IsPodman(ctx context.Context) bool { - ver, err := e.API.ServerVersion(ctx, mobyclient.ServerVersionOptions{}) - if err != nil { - return false - } - for _, comp := range ver.Components { - if strings.EqualFold(comp.Name, "podman") { - return true - } - } - return false -} - // PullImage pulls an image from a registry, consuming the output stream to // completion. It uses no authentication by default (public images). func (e *EngineClient) PullImage(ctx context.Context, ref string) error { @@ -286,6 +247,12 @@ func (e *EngineClient) StopContainer(ctx context.Context, id string) error { return err } +// StopContainerNow stops a container with no grace period (`docker stop -t 0`). +func (e *EngineClient) StopContainerNow(ctx context.Context, id string) error { + _, err := e.API.ContainerStop(ctx, id, mobyclient.ContainerStopOptions{Timeout: new(0)}) + return err +} + // CreateContainer creates a new container and returns its ID. func (e *EngineClient) CreateContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (string, error) { res, err := e.API.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: config, HostConfig: hostConfig}) @@ -295,12 +262,6 @@ func (e *EngineClient) CreateContainer(ctx context.Context, config *container.Co return res.ID, nil } -// ImageTag tags an image. -func (e *EngineClient) ImageTag(ctx context.Context, source, target string) error { - _, err := e.API.ImageTag(ctx, mobyclient.ImageTagOptions{Source: source, Target: target}) - return err -} - // --- Utility functions (no daemon required) --- var ( diff --git a/internal/docker/engine_test.go b/internal/docker/engine_test.go index 88dfbc2..7d15290 100644 --- a/internal/docker/engine_test.go +++ b/internal/docker/engine_test.go @@ -32,7 +32,6 @@ type mockAPI struct { eventsFn func(ctx context.Context, opts mobyclient.EventsListOptions) (<-chan events.Message, <-chan error) imageInspectFn func(ctx context.Context, id string, opts ...mobyclient.ImageInspectOption) (image.InspectResponse, error) imagePullFn func(ctx context.Context, ref string, opts mobyclient.ImagePullOptions) (mobyclient.ImagePullResponse, error) - imageTagFn func(ctx context.Context, opts mobyclient.ImageTagOptions) error serverVersionFn func(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) execCreateFn func(ctx context.Context, containerID string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) execAttachFn func(ctx context.Context, execID string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) @@ -132,13 +131,6 @@ func (m *mockAPI) ImageInspect(ctx context.Context, id string, opts ...mobyclien return mobyclient.ImageInspectResult{}, errors.New("not implemented") } -func (m *mockAPI) ImageTag(ctx context.Context, opts mobyclient.ImageTagOptions) (mobyclient.ImageTagResult, error) { - if m.imageTagFn != nil { - return mobyclient.ImageTagResult{}, m.imageTagFn(ctx, opts) - } - return mobyclient.ImageTagResult{}, errors.New("not implemented") -} - func (m *mockAPI) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) { if m.serverVersionFn != nil { return m.serverVersionFn(ctx, opts) @@ -171,27 +163,6 @@ func TestInspectContainer(t *testing.T) { } } -func TestInspectContainers(t *testing.T) { - calls := 0 - api := &mockAPI{ - containerInspectFn: func(_ context.Context, id string) (container.InspectResponse, error) { - calls++ - return container.InspectResponse{ID: id}, nil - }, - } - e := newTestEngine(api) - results, err := e.InspectContainers(t.Context(), "a", "b", "c") - if err != nil { - t.Fatal(err) - } - if len(results) != 3 { - t.Fatalf("got %d results, want 3", len(results)) - } - if calls != 3 { - t.Errorf("API called %d times, want 3", calls) - } -} - func TestInspectImage(t *testing.T) { api := &mockAPI{ imageInspectFn: func(_ context.Context, id string, _ ...mobyclient.ImageInspectOption) (image.InspectResponse, error) { @@ -327,26 +298,6 @@ func TestRemoveContainer(t *testing.T) { } } -func TestImageTag(t *testing.T) { - tagged := false - api := &mockAPI{ - imageTagFn: func(_ context.Context, opts mobyclient.ImageTagOptions) error { - if opts.Source != "src:v1" || opts.Target != "dst:v2" { - t.Errorf("tag(%q, %q)", opts.Source, opts.Target) - } - tagged = true - return nil - }, - } - e := newTestEngine(api) - if err := e.ImageTag(t.Context(), "src:v1", "dst:v2"); err != nil { - t.Fatal(err) - } - if !tagged { - t.Error("ImageTag not called") - } -} - func TestToDockerImageName(t *testing.T) { tests := []struct { input string @@ -371,34 +322,6 @@ func TestToDockerImageName(t *testing.T) { } } -func TestEvents(t *testing.T) { - api := &mockAPI{ - eventsFn: func(_ context.Context, _ mobyclient.EventsListOptions) (<-chan events.Message, <-chan error) { - ch := make(chan events.Message, 2) - errCh := make(chan error, 1) - ch <- events.Message{Action: events.ActionStart} - ch <- events.Message{Action: events.ActionDie} - close(ch) - return ch, errCh - }, - } - e := newTestEngine(api) - ctx := t.Context() - msgCh, errCh := e.Events(ctx, map[string][]string{"container": {"abc"}}) - var msgs []events.Message - for msg := range msgCh { - msgs = append(msgs, msg) - } - select { - case err := <-errCh: - t.Fatalf("unexpected error: %v", err) - default: - } - if len(msgs) != 2 { - t.Errorf("got %d messages, want 2", len(msgs)) - } -} - // TestImageLabelsEnsuringPresent guards the inspect→pull→inspect fallback: the // base image's baked metadata (e.g. devcontainer.metadata remoteUser) must be // read even on a fresh run where the image is not cached yet. Without the pull @@ -486,10 +409,7 @@ func TestParsePlatform(t *testing.T) { // client methods. WithAPIVersion pins the version so no /_ping negotiation runs. func newMockEngine(t *testing.T, doer func(*http.Request) (*http.Response, error)) *EngineClient { t.Helper() - cli, err := mobyclient.NewClientWithOpts( - mobyclient.WithHTTPClient(&http.Client{Transport: roundTripFunc(doer)}), - mobyclient.WithAPIVersion("1.51"), - ) + cli, err := mobyclient.New(mobyclient.WithHTTPClient(&http.Client{Transport: roundTripFunc(doer)}), mobyclient.WithAPIVersion("1.51")) if err != nil { t.Fatalf("mock client: %v", err) } diff --git a/internal/docker/exec_test.go b/internal/docker/exec_test.go index a65f6f3..e2996ac 100644 --- a/internal/docker/exec_test.go +++ b/internal/docker/exec_test.go @@ -52,10 +52,9 @@ func TestExecInContainer(t *testing.T) { if execID != "exec1" { t.Errorf("execID = %q", execID) } - return mobyclient.ExecAttachResult{HijackedResponse: mobyclient.HijackedResponse{ + return mobyclient.ExecAttachResult{ Conn: nopConn{}, - Reader: framedExecStream(t, "hello\n", "oops\n"), - }}, nil + Reader: framedExecStream(t, "hello\n", "oops\n")}, nil }, execInspectFn: func(_ context.Context, _ string) (mobyclient.ExecInspectResult, error) { return mobyclient.ExecInspectResult{ExitCode: 3}, nil diff --git a/internal/docker/runargs.go b/internal/docker/runargs.go index 964800d..3558a98 100644 --- a/internal/docker/runargs.go +++ b/internal/docker/runargs.go @@ -10,7 +10,7 @@ import ( // ParseMountSpec parses a --mount flag value (e.g., "type=bind,source=/a,target=/b"). func ParseMountSpec(spec string) (mount.Mount, error) { m := mount.Mount{} - for _, part := range strings.Split(spec, ",") { + for part := range strings.SplitSeq(spec, ",") { k, v, ok := strings.Cut(part, "=") if !ok { // Bare boolean flags (e.g. "readonly"/"ro" with no =value). diff --git a/internal/docker/runargs_nodocker_held_test.go b/internal/docker/runargs_nodocker_held_test.go new file mode 100644 index 0000000..41b7cdf --- /dev/null +++ b/internal/docker/runargs_nodocker_held_test.go @@ -0,0 +1,23 @@ +package docker + +import "testing" + +// A readonly/ro key with a value is read-only for "true" and "1" and writable +// for anything else, the subset of strconv.ParseBool the Docker CLI's own +// --mount parser (opts.MountOpt) accepts for that key. +func TestParseMountSpecReadsTheReadonlyValue(t *testing.T) { + for spec, want := range map[string]bool{ + "target=/t,readonly=true": true, + "target=/t,ro=1": true, + "target=/t,readonly=false": false, + "target=/t,ro=0": false, + } { + m, err := ParseMountSpec(spec) + if err != nil { + t.Fatalf("ParseMountSpec(%q): %v", spec, err) + } + if m.ReadOnly != want { + t.Errorf("ParseMountSpec(%q).ReadOnly = %v, want %v", spec, m.ReadOnly, want) + } + } +} diff --git a/internal/docker/runargs_test.go b/internal/docker/runargs_test.go index ccedbe7..708c1f4 100644 --- a/internal/docker/runargs_test.go +++ b/internal/docker/runargs_test.go @@ -37,8 +37,6 @@ func TestParseMountSpec_BareRO(t *testing.T) { } } -func boolPtr(b bool) *bool { return &b } - // TestCreateContainerArgs_Table asserts the exact `docker create` argument // slice for discriminating inputs. Ordering is load-bearing: id-labels first, // then mounts/env/caps/security, flags, runArgs (arbitrary devcontainer.json @@ -100,7 +98,7 @@ func TestCreateContainerArgs_Table(t *testing.T) { nil, nil, []string{"SYS_PTRACE", "NET_ADMIN"}, []string{"seccomp=unconfined"}, - true, boolPtr(true), nil) + true, new(true), nil) }, want: []string{"create", "--cap-add", "SYS_PTRACE", "--cap-add", "NET_ADMIN", @@ -113,7 +111,7 @@ func TestCreateContainerArgs_Table(t *testing.T) { { name: "InitFalsePointerOmitsInit", call: func() []string { - return CreateContainerArgs("img", nil, nil, nil, "", nil, nil, nil, nil, false, boolPtr(false), nil) + return CreateContainerArgs("img", nil, nil, nil, "", nil, nil, nil, nil, false, new(false), nil) }, want: []string{"create", "img"}, }, diff --git a/internal/docker/updateuid.go b/internal/docker/updateuid.go index ca8d77a..832fab1 100644 --- a/internal/docker/updateuid.go +++ b/internal/docker/updateuid.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strconv" - - "github.com/devcontainers/cli/internal/log" ) // updateUIDDockerfile is the Dockerfile that remaps the container user's UID/GID, @@ -18,53 +16,59 @@ import ( //go:embed updateUID.Dockerfile var updateUIDDockerfile string -// UpdateRemoteUserUID builds and runs a temporary image that remaps the container -// user's UID/GID to match the host user's, preventing bind-mount permission -// issues. Matches the TS updateRemoteUserUID behavior. -func UpdateRemoteUserUID(ctx context.Context, client *Client, logger log.Logger, imageName, remoteUser string, hostUID, hostGID int, useBuildx bool) (string, error) { - if remoteUser == "" || remoteUser == "root" { - return imageName, nil - } - if hostUID == 0 { - return imageName, nil // host is root, no remap needed - } - - logger.Write(fmt.Sprintf("Updating remote user UID/GID to %d:%d for user %q", hostUID, hostGID, remoteUser), log.LevelInfo) +// UIDUpdate describes the image TS updateRemoteUserUID builds, for a single +// container or a Compose service (containerFeatures.ts getRemoteUserUIDUpdateDetails). +type UIDUpdate struct { + BaseImage string // image to remap + FixedImage string // tag of the remapped image + RemoteUser string + ImageUser string + Platform string // "os/arch[/variant]" of the base image, "" for none + UID, GID int + // ContextDir is the (empty) build context directory. + ContextDir string +} - // Materialize the embedded Dockerfile into a temp build context. - tmpDir, err := os.MkdirTemp("", "devcontainer-updateuid-") +// BuildUIDImage builds u.FixedImage from u.BaseImage with the remote +// user's UID/GID remapped, with the same `docker build` arguments as the TS +// updateRemoteUserUID: IMAGE_USER is the base image's user (the final USER of +// the remapped image), not the remote user. +func BuildUIDImage(ctx context.Context, client *Client, u UIDUpdate) error { + dir, err := os.MkdirTemp("", "devcontainer-updateuid-") if err != nil { - return imageName, fmt.Errorf("updateUID temp dir: %w", err) + return fmt.Errorf("updateUID temp dir: %w", err) } - defer os.RemoveAll(tmpDir) - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - if err := os.WriteFile(dockerfilePath, []byte(updateUIDDockerfile), 0o644); err != nil { - return imageName, fmt.Errorf("write updateUID Dockerfile: %w", err) + defer os.RemoveAll(dir) + dockerfile := filepath.Join(dir, "updateUID.Dockerfile") + if err := os.WriteFile(dockerfile, []byte(updateUIDDockerfile), 0o644); err != nil { + return fmt.Errorf("write updateUID Dockerfile: %w", err) } - - updatedImageName := imageName + "-uid" - - result, err := client.Build(ctx, BuildOptions{ - Dockerfile: dockerfilePath, - ContextPath: tmpDir, - Tags: []string{updatedImageName}, + contextDir := u.ContextDir + if contextDir == "" { + contextDir = dir + } + var extra []string + if u.Platform != "" { + extra = []string{"--platform", u.Platform} + } + res, err := client.Build(ctx, BuildOptions{ + Dockerfile: dockerfile, + ContextPath: contextDir, + Tags: []string{u.FixedImage}, + ExtraArgs: extra, BuildArgs: map[string]string{ - "BASE_IMAGE": imageName, - "REMOTE_USER": remoteUser, - "NEW_UID": strconv.Itoa(hostUID), - "NEW_GID": strconv.Itoa(hostGID), - "IMAGE_USER": remoteUser, + "BASE_IMAGE": u.BaseImage, + "REMOTE_USER": u.RemoteUser, + "NEW_UID": strconv.Itoa(u.UID), + "NEW_GID": strconv.Itoa(u.GID), + "IMAGE_USER": u.ImageUser, }, - UseBuildx: useBuildx, }) if err != nil { - return imageName, fmt.Errorf("updateUID build: %w", err) + return fmt.Errorf("updateUID build: %w", err) } - if result.ExitCode != 0 { - logger.Write(fmt.Sprintf("updateUID build failed (exit %d), continuing with original image", result.ExitCode), log.LevelWarning) - return imageName, nil + if res.ExitCode != 0 { + return fmt.Errorf("updateUID build failed (exit %d): %s", res.ExitCode, string(res.Stderr)) } - - logger.Write(fmt.Sprintf("UID/GID updated: %s → %s", imageName, updatedImageName), log.LevelInfo) - return updatedImageName, nil + return nil } diff --git a/internal/docker/updateuid_nodocker_held_test.go b/internal/docker/updateuid_nodocker_held_test.go new file mode 100644 index 0000000..7cc4675 --- /dev/null +++ b/internal/docker/updateuid_nodocker_held_test.go @@ -0,0 +1,56 @@ +package docker + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// buildContext returns the context path of a recorded `docker build`: its last +// argument. +func buildContext(t *testing.T, call recordedCall) string { + t.Helper() + if len(call.args) == 0 || call.args[0] != "build" { + t.Fatalf("not a docker build: %v", call.args) + } + return call.args[len(call.args)-1] +} + +// The UID image builds from the directory it writes updateUID.Dockerfile to +// unless a context directory is given, as TS updateRemoteUserUID builds in the +// folder holding its Dockerfile; a platform reaches the command line as +// --platform. +func TestBuildUIDImageContextAndPlatform(t *testing.T) { + rr := &recordingRunner{} + c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} + u := UIDUpdate{BaseImage: "base", FixedImage: "base-uid", RemoteUser: "vscode", ImageUser: "root", UID: 1000, GID: 1000} + if err := BuildUIDImage(t.Context(), c, u); err != nil { + t.Fatal(err) + } + call := rr.last() + i := slices.Index(call.args, "-f") + if i < 0 { + t.Fatalf("no -f in %v", call.args) + } + if got, want := buildContext(t, call), filepath.Dir(call.args[i+1]); got != want || got == "" { + t.Errorf("context = %q, want the Dockerfile's directory %q", got, want) + } + if slices.Contains(call.args, "--platform") { + t.Errorf("no platform given, yet --platform in %v", call.args) + } + + u.ContextDir = "/ctx" + u.Platform = "linux/arm64" + if err := BuildUIDImage(t.Context(), c, u); err != nil { + t.Fatal(err) + } + call = rr.last() + if got := buildContext(t, call); got != "/ctx" { + t.Errorf("context = %q, want /ctx", got) + } + if i := slices.Index(call.args, "--platform"); i < 0 || call.args[i+1] != "linux/arm64" { + t.Errorf("--platform linux/arm64 missing from %v", call.args) + } +} diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 07a8dc0..8a7d04b 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -203,7 +203,7 @@ func statfsFree(path string) (uint64, error) { // firstLine returns the first non-empty trimmed line of s. func firstLine(s string) string { - for _, line := range strings.Split(s, "\n") { + for line := range strings.SplitSeq(s, "\n") { if t := strings.TrimSpace(line); t != "" { return t } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index fd8c107..c1ed84c 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "slices" "strings" "testing" ) @@ -39,12 +40,7 @@ func healthyDocker(args []string) ([]byte, []byte, int, error) { } func has(args []string, want string) bool { - for _, a := range args { - if a == want { - return true - } - } - return false + return slices.Contains(args, want) } func bigDisk(string) (uint64, error) { return 100 << 30, nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index eafd8e3..ac4fa3d 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -13,8 +13,10 @@ type ContainerError struct { OriginalError error ContainerID string DisallowedFeatureID string - DidStopContainer bool - LearnMoreURL string + // DidStopContainer is set only by the disallowed-Feature check, which TS + // reports as a boolean either way; nil leaves it out of the output. + DidStopContainer *bool + LearnMoreURL string } func (e *ContainerError) Error() string { @@ -66,18 +68,20 @@ func ToErrorOutput(err error) ErrorOutput { } } + // TS ContainerError's message is its original error's, else its description. + message := ce.Description + if ce.OriginalError != nil { + message = ce.OriginalError.Error() + } out := ErrorOutput{ Outcome: "error", - Message: ce.Error(), + Message: message, Description: ce.Description, ContainerID: ce.ContainerID, DisallowedFeatureID: ce.DisallowedFeatureID, + DidStopContainer: ce.DidStopContainer, LearnMoreURL: ce.LearnMoreURL, } - if ce.DidStopContainer { - v := true - out.DidStopContainer = &v - } return out } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 74b41bd..df2141b 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -38,7 +38,7 @@ func TestToErrorOutput(t *testing.T) { Description: "An error occurred setting up the container.", ContainerID: "abc123", DisallowedFeatureID: "ghcr.io/bad/feature", - DidStopContainer: true, + DidStopContainer: new(true), LearnMoreURL: "https://example.com", }, check: func(t *testing.T, out ErrorOutput) { diff --git a/internal/features/advisory.go b/internal/features/advisory.go index 3007ab2..fe48c67 100644 --- a/internal/features/advisory.go +++ b/internal/features/advisory.go @@ -2,17 +2,19 @@ package features import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" + "errors" "fmt" + "io" + "net/http" "os" "path/filepath" + "regexp" + "slices" "strconv" "strings" "time" - "github.com/devcontainers/cli/internal/httpx" "github.com/devcontainers/cli/internal/log" ) @@ -45,7 +47,7 @@ const ( // GetControlManifest fetches the control manifest with 5-minute caching. The // fetch is bound to ctx so a cancelled command aborts the (network) request. -func GetControlManifest(ctx context.Context, cacheFolder string, httpClient *httpx.Client, logger log.Logger) *ControlManifest { +func GetControlManifest(ctx context.Context, cacheFolder string, client *http.Client, logger log.Logger) *ControlManifest { cachePath := filepath.Join(cacheFolder, controlManifestFile) // Check cache @@ -59,14 +61,8 @@ func GetControlManifest(ctx context.Context, cacheFolder string, httpClient *htt } } - // Fetch fresh - resp, err := httpClient.Do(ctx, httpx.RequestOptions{ - URL: controlManifestURL, - Headers: map[string]string{ - "Accept": "application/json", - }, - }) - if err != nil || resp.StatusCode > 299 { + body, err := fetchControlManifest(ctx, client) + if err != nil { logger.Write(fmt.Sprintf("Failed to fetch control manifest: %v", err), log.LevelError) // Return cached if available if data, err := os.ReadFile(cachePath); err == nil { @@ -77,77 +73,115 @@ func GetControlManifest(ctx context.Context, cacheFolder string, httpClient *htt return m } } - return emptyManifest() + // TS updateControlManifest caches the empty manifest, so the fetch is + // not retried for five minutes. + body = []byte("{\n \"disallowedFeatures\": [],\n \"featureAdvisories\": []\n}") } // Write to cache atomically os.MkdirAll(filepath.Dir(cachePath), 0755) - tmpName := cachePath + "-" + randomHex(8) - os.WriteFile(tmpName, resp.Body, 0644) - os.Rename(tmpName, cachePath) + // Written whole, then renamed over the cache, so a concurrent reader never + // sees a partial manifest. + if tmp, err := os.CreateTemp(filepath.Dir(cachePath), controlManifestFile+"-*"); err == nil { + _, werr := tmp.Write(body) + if errors.Join(werr, tmp.Close()) == nil { + os.Rename(tmp.Name(), cachePath) + } else { + os.Remove(tmp.Name()) + } + } - if m := sanitizeManifest(resp.Body); m != nil { + if m := sanitizeManifest(body); m != nil { return m } return emptyManifest() } -// CheckAdvisories returns features that have active advisories. -func CheckAdvisories(manifest *ControlManifest, featureSets []*Set) []WithAdvisory { +// fetchControlManifest GETs the manifest with TS fetchControlManifest's headers. +// A status outside 2xx is an error worded as TS request words it. +func fetchControlManifest(ctx context.Context, client *http.Client) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, controlManifestURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "devcontainers-vscode") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + return io.ReadAll(resp.Body) +} + +// FeatureAdvisories returns the OCI features with an advisory that applies to +// their version, ordered by feature id, as TS fetchFeatureAdvisories does: an +// advisory applies when introducedInVersion <= version < fixedInVersion. A +// feature whose version cannot be parsed is reported to warn and skipped. +func FeatureAdvisories(manifest *ControlManifest, featureSets []*Set, warn func(string)) []WithAdvisory { + type feature struct{ id, version string } + var fs []feature + for _, set := range featureSets { + src, ok := set.SourceInfo.(*OCISource) + if !ok || len(set.Features) == 0 { + continue + } + fs = append(fs, feature{src.Registry + "/" + src.Namespace + "/" + src.ID, set.Features[0].Version}) + } + slices.SortStableFunc(fs, func(a, b feature) int { return strings.Compare(a.id, b.id) }) if len(manifest.FeatureAdvisories) == 0 { return nil } - - // Index advisories by feature ID - advisoryMap := make(map[string][]Advisory) + byID := make(map[string][]Advisory) for _, a := range manifest.FeatureAdvisories { - advisoryMap[a.FeatureID] = append(advisoryMap[a.FeatureID], a) + byID[a.FeatureID] = append(byID[a.FeatureID], a) } - var results []WithAdvisory - for _, fs := range featureSets { - ociSrc, ok := fs.SourceInfo.(*OCISource) - if !ok || len(fs.Features) == 0 { - continue - } - - featureID := fmt.Sprintf("%s/%s/%s", ociSrc.Registry, ociSrc.Namespace, ociSrc.ID) - version := fs.Features[0].Version - - advisories, ok := advisoryMap[featureID] - if !ok { + for _, f := range fs { + version := parseVersion(f.version) + if version == nil { + warn(fmt.Sprintf("Unable to parse version for feature %s: %s", f.id, f.version)) continue } - - featureVer := parseVersion(version) - if featureVer == nil { - continue - } - var matching []Advisory - for _, a := range advisories { - introducedVer := parseVersion(a.IntroducedInVersion) - fixedVer := parseVersion(a.FixedInVersion) - if introducedVer == nil || fixedVer == nil { - continue - } - // Advisory applies if: introduced <= featureVer < fixed - if !isEarlierVersion(featureVer, introducedVer) && isEarlierVersion(featureVer, fixedVer) { + for _, a := range byID[f.id] { + introduced, fixed := parseVersion(a.IntroducedInVersion), parseVersion(a.FixedInVersion) + if introduced != nil && fixed != nil && !isEarlierVersion(version, introduced) && isEarlierVersion(version, fixed) { matching = append(matching, a) } } - if len(matching) > 0 { - results = append(results, WithAdvisory{ - FeatureID: featureID, - Version: version, - Advisories: matching, - }) + results = append(results, WithAdvisory{FeatureID: f.id, Version: f.version, Advisories: matching}) } } return results } +// FormatFeatureAdvisories is the warning TS logFeatureAdvisories writes, or "" +// when there is none. +func FormatFeatureAdvisories(list []WithAdvisory) string { + if len(list) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n\n-----------------------------------------------------------------------------------------------------------\nFEATURE ADVISORIES:") + for _, f := range list { + fmt.Fprintf(&b, "\n- %s:%s:", f.FeatureID, f.Version) + for _, a := range f.Advisories { + fmt.Fprintf(&b, "\n - %s (introduced in %s, fixed in %s", a.Description, a.IntroducedInVersion, a.FixedInVersion) + if a.DocumentationURL != "" { + fmt.Fprintf(&b, ", see %s", a.DocumentationURL) + } + b.WriteString(")") + } + } + b.WriteString("\n\nIt is recommended that you update your configuration to versions of these features with the fixes applied.\n-----------------------------------------------------------------------------------------------------------\n\n") + return b.String() +} + // WithAdvisory pairs a feature with its applicable advisories. type WithAdvisory struct { FeatureID string @@ -186,19 +220,12 @@ func findDisallowedFeatureEntry(manifest *ControlManifest, featureID string) *Di return nil } -// EnsureNoDisallowedFeatures checks config + additional features against the -// control-manifest blocklist and returns a *DisallowedFeatureError for the first -// disallowed one (nil if all are allowed). -func EnsureNoDisallowedFeatures(manifest *ControlManifest, features map[string]interface{}, additionalFeatures map[string]interface{}) error { - if len(manifest.DisallowedFeatures) == 0 { - return nil - } - for id := range features { - if d := findDisallowedFeatureEntry(manifest, id); d != nil { - return &DisallowedFeatureError{FeatureID: id, DocumentationURL: d.DocumentationURL} - } - } - for id := range additionalFeatures { +// EnsureNoDisallowedFeatures returns a *DisallowedFeatureError for the first of +// featureIDs - the config's features then the additional ones, in the order the +// JSON lists them, as TS Object.keys({...config.features, ...additionalFeatures}) +// - that the control manifest disallows. +func EnsureNoDisallowedFeatures(manifest *ControlManifest, featureIDs []string) error { + for _, id := range featureIDs { if d := findDisallowedFeatureEntry(manifest, id); d != nil { return &DisallowedFeatureError{FeatureID: id, DocumentationURL: d.DocumentationURL} } @@ -248,38 +275,41 @@ func emptyManifest() *ControlManifest { } } -// parseVersion parses "1.2.3" into [1, 2, 3]. -func parseVersion(v string) []int { - parts := strings.Split(v, ".") - result := make([]int, 0, len(parts)) - for _, p := range parts { +// versionPrefix is TS commonUtils.parseVersion's: an optional quote and v, then +// dot-separated numbers; anything after them is ignored ("1.2.3-rc" is 1.2.3). +var versionPrefix = regexp.MustCompile(`^'?v?(\d+(\.\d+)*)`) + +// parseVersion is TS commonUtils.parseVersion: nil when s starts with no version. +func parseVersion(s string) []int { + m := versionPrefix.FindStringSubmatch(s) + if m == nil { + return nil + } + var out []int + for p := range strings.SplitSeq(m[1], ".") { n, err := strconv.Atoi(p) if err != nil { return nil } - result = append(result, n) + out = append(out, n) } - if len(result) == 0 { - return nil - } - return result + return out } -// isEarlierVersion returns true if a < b (component-wise). -func isEarlierVersion(a, b []int) bool { - for i := 0; i < len(a) && i < len(b); i++ { - if a[i] < b[i] { - return true +// isEarlierVersion is TS commonUtils.isEarlierVersion: a missing component is 0, +// so 1 and 1.0 are equal and neither is earlier. +func isEarlierVersion(left, right []int) bool { + for i := range max(len(left), len(right)) { + var l, r int + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] } - if a[i] > b[i] { - return false + if l != r { + return l < r } } - return len(a) < len(b) -} - -func randomHex(n int) string { - b := make([]byte, n) - rand.Read(b) - return hex.EncodeToString(b) + return false } diff --git a/internal/features/advisory_nodocker_held_test.go b/internal/features/advisory_nodocker_held_test.go new file mode 100644 index 0000000..c3673e6 --- /dev/null +++ b/internal/features/advisory_nodocker_held_test.go @@ -0,0 +1,109 @@ +package features + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// A failed fetch with nothing cached caches the empty manifest TS +// updateControlManifest writes (JSON.stringify(emptyControlManifest, +// undefined, 2)), so a second call within five minutes reads it instead of +// fetching again. +func TestGetControlManifestCachesTheEmptyManifestOnFailure(t *testing.T) { + dir := t.TempDir() + fetches := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + fetches++ + return respond(503, "")(r) + })} + for range 2 { + m := GetControlManifest(t.Context(), dir, client, log.Null) + if len(m.DisallowedFeatures) != 0 || len(m.FeatureAdvisories) != 0 { + t.Fatalf("manifest = %+v, want empty", m) + } + } + if fetches != 1 { + t.Fatalf("fetches = %d, want 1 (the second call reads the cache)", fetches) + } + data, err := os.ReadFile(filepath.Join(dir, controlManifestFile)) + if err != nil { + t.Fatal(err) + } + if want := "{\n \"disallowedFeatures\": [],\n \"featureAdvisories\": []\n}"; string(data) != want { + t.Fatalf("cache = %q, want %q", data, want) + } +} + +// A cache folder that does not exist yet is created (TS updateControlManifest +// mkdirs it) with mode 0755, the mode the CLI gives every cache folder, less +// the process umask. +func TestGetControlManifestCreatesTheCacheFolder(t *testing.T) { + umask := processUmask(t) + dir := filepath.Join(t.TempDir(), "cache") + client := &http.Client{Transport: respond(200, `{"disallowedFeatures":[{"featureIdPrefix":"p"}]}`)} + m := GetControlManifest(t.Context(), dir, client, log.Null) + if len(m.DisallowedFeatures) != 1 { + t.Fatalf("manifest = %+v", m) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o755)&^umask; got != want { + t.Fatalf("cache folder mode = %v, want %v", got, want) + } +} + +// processUmask reads the umask from /proc, which only Linux has. +func processUmask(t *testing.T) os.FileMode { + t.Helper() + data, err := os.ReadFile("/proc/self/status") + if err != nil { + t.Skipf("no /proc/self/status: %v", err) + } + for line := range strings.Lines(string(data)) { + if v, ok := strings.CutPrefix(line, "Umask:"); ok { + n, err := strconv.ParseUint(strings.TrimSpace(v), 8, 32) + if err != nil { + t.Fatal(err) + } + return os.FileMode(n) + } + } + t.Skip("no Umask in /proc/self/status") + return 0 +} + +// An advisory whose introducedInVersion or fixedInVersion cannot be parsed +// applies to no version, as TS fetchFeatureAdvisories filters it out. +func TestFeatureAdvisoriesSkipUnparsableRanges(t *testing.T) { + manifest := &ControlManifest{FeatureAdvisories: []Advisory{ + {FeatureID: "ghcr.io/o/f/a", IntroducedInVersion: "x", FixedInVersion: "2", Description: "no introduced"}, + {FeatureID: "ghcr.io/o/f/a", IntroducedInVersion: "1", FixedInVersion: "x", Description: "no fixed"}, + }} + sets := []*Set{{SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: "a"}, Features: []Feature{{Version: "1.5.0"}}}} + if got := FeatureAdvisories(manifest, sets, func(string) {}); len(got) != 0 { + t.Fatalf("advisories = %+v, want none", got) + } +} + +// A 300 answer is outside 2xx, so it is a failed fetch worded as TS request +// words it. +func TestGetControlManifestRefusesStatus300(t *testing.T) { + var buf bytes.Buffer + logger := log.New(log.Options{Writer: &buf, Level: log.LevelTrace}) + m := GetControlManifest(t.Context(), t.TempDir(), &http.Client{Transport: respond(300, `{"disallowedFeatures":[{"featureIdPrefix":"p"}]}`)}, logger) + if len(m.DisallowedFeatures) != 0 { + t.Fatalf("manifest = %+v, want empty", m) + } + if !strings.Contains(buf.String(), "Failed to fetch control manifest: HTTP 300: Multiple Choices") { + t.Fatalf("log = %q", buf.String()) + } +} diff --git a/internal/features/advisory_test.go b/internal/features/advisory_test.go index fd5949d..83b7100 100644 --- a/internal/features/advisory_test.go +++ b/internal/features/advisory_test.go @@ -1,47 +1,91 @@ package features import ( + "bytes" "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" "testing" + "time" + + "github.com/devcontainers/cli/internal/log" ) -func TestCheckAdvisories(t *testing.T) { - // One advisory on the go feature, active for [introduced, fixed). Vary the - // installed version and assert whether it is flagged. - tests := []struct { - name string - introduced string - fixed string - version string - wantResults int +// TS featureAdvisories.test.ts: one advisory on feature-with-advisory, introduced +// in 1.0.7 and fixed in 1.1.10, matches the versions in [1.0.7, 1.1.10) and no +// other feature. +func TestFeatureAdvisoriesMatchTheVersionRange(t *testing.T) { + const featureID = "ghcr.io/devcontainers/features/feature-with-advisory" + manifest := &ControlManifest{FeatureAdvisories: []Advisory{{ + FeatureID: featureID, IntroducedInVersion: "1.0.7", FixedInVersion: "1.1.10", Description: "issue", + }}} + set := func(id, version string) []*Set { + return []*Set{{ + SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "devcontainers/features", ID: id}, + Features: []Feature{{Version: version}}, + }} + } + for _, tt := range []struct { + id, version string + want bool }{ - {"version in affected range", "1.0.0", "1.2.0", "1.1.0", 1}, - {"version at/after the fix", "1.0.0", "1.2.0", "1.3.0", 0}, - {"version before introduction", "1.5.0", "1.6.0", "1.4.0", 0}, + {"feature-with-advisory", "1.0.9", true}, + {"feature-with-advisory", "1.1.5", true}, + {"feature-with-advisory", "1.0.7", true}, + {"feature-with-advisory", "1.1.9", true}, + {"feature-with-advisory", "1.0.2", false}, + {"feature-with-advisory", "1.2.1", false}, + {"feature-with-advisory", "1.0.6", false}, + {"feature-with-advisory", "1.1.10", false}, + {"other-feature", "1.0.9", false}, + } { + got := FeatureAdvisories(manifest, set(tt.id, tt.version), func(string) {}) + if (len(got) == 1) != tt.want { + t.Errorf("%s@%s: advisories = %+v, want match=%v", tt.id, tt.version, got, tt.want) + continue + } + if tt.want && (got[0].FeatureID != featureID || got[0].Version != tt.version || len(got[0].Advisories) != 1) { + t.Errorf("%s@%s: got %+v", tt.id, tt.version, got[0]) + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manifest := &ControlManifest{FeatureAdvisories: []Advisory{{ - FeatureID: "ghcr.io/devcontainers/features/go", - IntroducedInVersion: tt.introduced, - FixedInVersion: tt.fixed, - Description: "issue", - }}} - featureSets := []*Set{{ - SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "devcontainers/features", ID: "go"}, - Features: []Feature{{Version: tt.version}}, - }} +} - results := CheckAdvisories(manifest, featureSets) - if len(results) != tt.wantResults { - t.Fatalf("results = %d, want %d", len(results), tt.wantResults) - } - if tt.wantResults > 0 { - if results[0].FeatureID != "ghcr.io/devcontainers/features/go" || len(results[0].Advisories) != 1 { - t.Errorf("unexpected result: %+v", results[0]) - } - } - }) +// Features are reported in id order, and one whose version cannot be parsed is +// warned about, as TS fetchFeatureAdvisories does; the warning text is TS +// logFeatureAdvisories' byte for byte. +func TestFeatureAdvisoriesOrderWarningAndText(t *testing.T) { + manifest := &ControlManifest{FeatureAdvisories: []Advisory{ + {FeatureID: "ghcr.io/o/f/b", IntroducedInVersion: "1", FixedInVersion: "2", Description: "b broke", DocumentationURL: "https://b"}, + {FeatureID: "ghcr.io/o/f/a", IntroducedInVersion: "1", FixedInVersion: "2", Description: "a broke"}, + }} + sets := []*Set{ + {SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: "b"}, Features: []Feature{{Version: "1.5.0"}}}, + {SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: "c"}, Features: []Feature{{Version: "latest"}}}, + {SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: "a"}, Features: []Feature{{Version: "1.0"}}}, + {SourceInfo: &LocalSource{}, Features: []Feature{{Version: "1.0"}}}, + } + var warnings []string + got := FeatureAdvisories(manifest, sets, func(s string) { warnings = append(warnings, s) }) + if len(got) != 2 || got[0].FeatureID != "ghcr.io/o/f/a" || got[1].FeatureID != "ghcr.io/o/f/b" { + t.Fatalf("got %+v, want a then b", got) + } + if len(warnings) != 1 || warnings[0] != "Unable to parse version for feature ghcr.io/o/f/c: latest" { + t.Errorf("warnings = %q", warnings) + } + want := "\n\n-----------------------------------------------------------------------------------------------------------\n" + + "FEATURE ADVISORIES:\n" + + "- ghcr.io/o/f/a:1.0:\n - a broke (introduced in 1, fixed in 2)\n" + + "- ghcr.io/o/f/b:1.5.0:\n - b broke (introduced in 1, fixed in 2, see https://b)\n\n" + + "It is recommended that you update your configuration to versions of these features with the fixes applied.\n" + + "-----------------------------------------------------------------------------------------------------------\n\n" + if text := FormatFeatureAdvisories(got); text != want { + t.Errorf("text:\n%q\nwant:\n%q", text, want) + } + if FormatFeatureAdvisories(nil) != "" { + t.Error("no advisories writes nothing") } } @@ -70,7 +114,7 @@ func TestEnsureNoDisallowedFeatures(t *testing.T) { manifest := &ControlManifest{DisallowedFeatures: []DisallowedFeature{ {FeatureIDPrefix: tt.prefix, DocumentationURL: tt.docURL}, }} - err := EnsureNoDisallowedFeatures(manifest, map[string]interface{}{tt.featureID: true}, nil) + err := EnsureNoDisallowedFeatures(manifest, []string{tt.featureID}) if (err != nil) != tt.wantBlocked { t.Fatalf("blocked=%v, want %v (err=%v)", err != nil, tt.wantBlocked, err) @@ -98,6 +142,13 @@ func TestParseVersion(t *testing.T) { {"1.0", []int{1, 0}}, {"3", []int{3}}, {"abc", nil}, + // TS commonUtils.parseVersion reads a leading quote and v, and stops at the + // first character that is not part of the version. + {"v1.2", []int{1, 2}}, + {"'v2", []int{2}}, + {"1.2.3-rc.1", []int{1, 2, 3}}, + {"1.", []int{1}}, + {"latest", nil}, } for _, tt := range tests { got := parseVersion(tt.input) @@ -126,7 +177,10 @@ func TestIsEarlierVersion(t *testing.T) { {[]int{1, 0, 0}, []int{1, 2, 0}, true}, {[]int{1, 2, 0}, []int{1, 0, 0}, false}, {[]int{1, 2, 0}, []int{1, 2, 0}, false}, - {[]int{1}, []int{1, 0}, true}, + // A missing component is 0 (TS commonUtils.isEarlierVersion): 1 is not + // earlier than 1.0, and 1.9 is earlier than 2. + {[]int{1}, []int{1, 0}, false}, + {[]int{1, 9}, []int{2}, true}, {[]int{2, 0}, []int{1, 9, 9}, false}, } for _, tt := range tests { @@ -136,3 +190,68 @@ func TestIsEarlierVersion(t *testing.T) { } } } + +// roundTripFunc answers requests in-process, so the control manifest fetch is +// tested without the network. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func respond(code int, body string) roundTripFunc { + return func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + } +} + +// A non-2xx answer is logged as TS logs it ("HTTP 503: Service Unavailable"), +// and the cached manifest, however old, is kept with its mtime refreshed. +func TestGetControlManifestFailedFetch(t *testing.T) { + dir := t.TempDir() + cache := filepath.Join(dir, controlManifestFile) + if err := os.WriteFile(cache, []byte(`{"disallowedFeatures":[{"featureIdPrefix":"old"}]}`), 0o644); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Hour) + if err := os.Chtimes(cache, stale, stale); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + logger := log.New(log.Options{Writer: &buf, Level: log.LevelTrace}) + + m := GetControlManifest(t.Context(), dir, &http.Client{Transport: respond(503, "")}, logger) + if !strings.Contains(buf.String(), "Failed to fetch control manifest: HTTP 503: Service Unavailable") { + t.Fatalf("log = %q", buf.String()) + } + if len(m.DisallowedFeatures) != 1 || m.DisallowedFeatures[0].FeatureIDPrefix != "old" { + t.Fatalf("manifest = %+v, want the cached one", m) + } + if info, err := os.Stat(cache); err != nil || !info.ModTime().After(stale) { + t.Fatalf("cache mtime not refreshed: %v", err) + } + + // Without a cache the result is the empty manifest. + m = GetControlManifest(t.Context(), t.TempDir(), &http.Client{Transport: respond(404, "")}, logger) + if len(m.DisallowedFeatures) != 0 || len(m.FeatureAdvisories) != 0 { + t.Fatalf("manifest = %+v, want empty", m) + } +} + +// A fresh fetch sends TS's headers, is cached, and is what is returned. +func TestGetControlManifestFetchesAndCaches(t *testing.T) { + dir := t.TempDir() + var got http.Header + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + got = r.Header + return respond(200, `{"featureAdvisories":[{"featureId":"x","introducedInVersion":"1.0.0","fixedInVersion":"1.1.0","description":"d"}]}`)(r) + })} + m := GetControlManifest(t.Context(), dir, client, log.Null) + if len(m.FeatureAdvisories) != 1 || m.FeatureAdvisories[0].FeatureID != "x" { + t.Fatalf("manifest = %+v", m) + } + if got.Get("User-Agent") != "devcontainers-vscode" || got.Get("Accept") != "application/json" { + t.Fatalf("headers = %v", got) + } + if data, err := os.ReadFile(filepath.Join(dir, controlManifestFile)); err != nil || !strings.Contains(string(data), `"x"`) { + t.Fatalf("cache = %q, %v", data, err) + } +} diff --git a/internal/features/graph.go b/internal/features/graph.go index 6df6658..227148e 100644 --- a/internal/features/graph.go +++ b/internal/features/graph.go @@ -2,7 +2,8 @@ package features import ( "fmt" - "sort" + "maps" + "slices" "github.com/devcontainers/cli/internal/log" ) @@ -29,7 +30,7 @@ type ProcessFeature func(node *FNode) (*Set, error) // are baked into each node's RoundPriority. // // The returned worklist is the "precomputed graph" accepted by -// ComputeInstallationOrder (pass a nil override there — priorities are already +// ComputeInstallationOrder (priorities are already // applied here) and by GenerateMermaidDiagram. func BuildDependencyGraph( logger log.Logger, @@ -97,7 +98,7 @@ func resolveDependencyGraph( // Already in the accumulator? Skip. This stops cycles from looping here; // they are reported by the round-based order calculation instead. - if containsEqual(acc, current) { + if slices.ContainsFunc(acc, func(n *FNode) bool { return nodesEqual(n, current) }) { continue } @@ -111,12 +112,7 @@ func resolveDependencyGraph( // they are resolved recursively. Iterate in a deterministic order (Go // maps are unordered) — the round sort makes the final order stable, but // dedup "first wins" and mermaid edge order depend on this. - depIDs := make([]string, 0, len(feat.DependsOn)) - for id := range feat.DependsOn { - depIDs = append(depIDs, id) - } - sort.Strings(depIDs) - for _, depID := range depIDs { + for _, depID := range slices.Sorted(maps.Keys(feat.DependsOn)) { dep := &FNode{ Type: "resolved", UserFeatureID: depID, @@ -135,7 +131,7 @@ func resolveDependencyGraph( dep := &FNode{ Type: "resolved", UserFeatureID: iaID, - Options: map[string]interface{}{}, + Options: map[string]any{}, DependsOn: []*FNode{}, InstallsAfter: []*FNode{}, } @@ -180,7 +176,7 @@ func applyOverrideFeatureInstallOrder( override := &FNode{ Type: "override", UserFeatureID: overrideID, - Options: map[string]interface{}{}, + Options: map[string]any{}, RoundPriority: roundPriority, DependsOn: []*FNode{}, InstallsAfter: []*FNode{}, @@ -219,12 +215,3 @@ func aliasesFor(feat Feature) []string { aliases = append(aliases, feat.LegacyIds...) return aliases } - -func containsEqual(nodes []*FNode, n *FNode) bool { - for _, existing := range nodes { - if nodesEqual(existing, n) { - return true - } - } - return false -} diff --git a/internal/features/graph_test.go b/internal/features/graph_test.go index 140296b..7da9589 100644 --- a/internal/features/graph_test.go +++ b/internal/features/graph_test.go @@ -14,7 +14,7 @@ type stubFeature struct { id string // canonical feature id (currentId) digest string legacyIds []string - dependsOn map[string]interface{} + dependsOn map[string]any installsAfter []string } @@ -23,16 +23,13 @@ type stubFeature struct { // not present in the registry resolves to (nil, nil) — "could not be processed". func newStubProcessFeature(registry map[string]stubFeature) ProcessFeature { return func(node *FNode) (*Set, error) { - resource := StripVersionFromID(node.UserFeatureID) + // The stub ids carry no registry port, so the tag follows the last colon. + resource, tag, _ := strings.Cut(node.UserFeatureID, ":") sf, ok := registry[resource] if !ok { return nil, nil } reg, ns, id := splitResource(resource) - tag := "" - if idx := strings.LastIndex(node.UserFeatureID, ":"); idx > 0 { - tag = node.UserFeatureID[idx+1:] - } featID := sf.id if featID == "" { featID = id @@ -74,14 +71,14 @@ func splitResource(resource string) (registry, namespace, id string) { func userFeats(ids ...string) []DevContainerFeature { out := make([]DevContainerFeature, 0, len(ids)) for _, id := range ids { - out = append(out, DevContainerFeature{UserFeatureID: id, Options: map[string]interface{}{}}) + out = append(out, DevContainerFeature{UserFeatureID: id, Options: map[string]any{}}) } return out } -func orderIDs(t *testing.T, graph []*FNode, override []string) []string { +func orderIDs(t *testing.T, graph []*FNode) []string { t.Helper() - sets, err := ComputeInstallationOrder(log.Null, graph, override) + sets, err := ComputeInstallationOrder(log.Null, graph) if err != nil { t.Fatalf("ComputeInstallationOrder: %v", err) } @@ -90,8 +87,8 @@ func orderIDs(t *testing.T, graph []*FNode, override []string) []string { func TestBuildDependencyGraph_TransitiveDependsOn(t *testing.T) { registry := map[string]stubFeature{ - "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]interface{}{"ghcr.io/ns/b:1": map[string]interface{}{}}}, - "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]interface{}{"ghcr.io/ns/c:1": map[string]interface{}{}}}, + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]any{"ghcr.io/ns/b:1": map[string]any{}}}, + "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]any{"ghcr.io/ns/c:1": map[string]any{}}}, "ghcr.io/ns/c": {id: "c", digest: "sha256:c"}, } graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/a:1"), nil, nil) @@ -102,19 +99,19 @@ func TestBuildDependencyGraph_TransitiveDependsOn(t *testing.T) { if len(graph) != 3 { t.Fatalf("worklist size = %d, want 3", len(graph)) } - if got, want := orderIDs(t, graph, nil), []string{"c", "b", "a"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"c", "b", "a"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v", got, want) } } func TestBuildDependencyGraph_DiamondDedup(t *testing.T) { registry := map[string]stubFeature{ - "ghcr.io/ns/d": {id: "d", digest: "sha256:d", dependsOn: map[string]interface{}{ - "ghcr.io/ns/b:1": map[string]interface{}{}, - "ghcr.io/ns/c:1": map[string]interface{}{}, + "ghcr.io/ns/d": {id: "d", digest: "sha256:d", dependsOn: map[string]any{ + "ghcr.io/ns/b:1": map[string]any{}, + "ghcr.io/ns/c:1": map[string]any{}, }}, - "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]interface{}{"ghcr.io/ns/a:1": map[string]interface{}{}}}, - "ghcr.io/ns/c": {id: "c", digest: "sha256:c", dependsOn: map[string]interface{}{"ghcr.io/ns/a:1": map[string]interface{}{}}}, + "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]any{"ghcr.io/ns/a:1": map[string]any{}}}, + "ghcr.io/ns/c": {id: "c", digest: "sha256:c", dependsOn: map[string]any{"ghcr.io/ns/a:1": map[string]any{}}}, "ghcr.io/ns/a": {id: "a", digest: "sha256:a"}, } graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/d:1"), nil, nil) @@ -125,7 +122,7 @@ func TestBuildDependencyGraph_DiamondDedup(t *testing.T) { if len(graph) != 4 { t.Fatalf("worklist size = %d, want 4 (a deduped)", len(graph)) } - if got, want := orderIDs(t, graph, nil), []string{"a", "b", "c", "d"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"a", "b", "c", "d"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v", got, want) } } @@ -136,8 +133,8 @@ func TestBuildDependencyGraph_DedupByOptions(t *testing.T) { "ghcr.io/ns/a": {id: "a", digest: "sha256:a"}, } uf := []DevContainerFeature{ - {UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]interface{}{"x": "1"}}, - {UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]interface{}{"x": "2"}}, + {UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]any{"x": "1"}}, + {UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]any{"x": "2"}}, } graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), uf, nil, nil) if err != nil { @@ -162,7 +159,7 @@ func TestBuildDependencyGraph_SoftDepPruned(t *testing.T) { if len(graph) != 1 { t.Fatalf("worklist size = %d, want 1 (soft dep not added)", len(graph)) } - if got, want := orderIDs(t, graph, nil), []string{"b"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"b"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v", got, want) } } @@ -177,7 +174,7 @@ func TestBuildDependencyGraph_SoftDepOrders(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := orderIDs(t, graph, nil), []string{"a", "b"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"a", "b"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v", got, want) } } @@ -194,28 +191,28 @@ func TestBuildDependencyGraph_SoftDepAliasMatch(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := orderIDs(t, graph, nil), []string{"node", "b"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"node", "b"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v (alias match should order node before b)", got, want) } } func TestBuildDependencyGraph_Cycle(t *testing.T) { registry := map[string]stubFeature{ - "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]interface{}{"ghcr.io/ns/b:1": map[string]interface{}{}}}, - "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]interface{}{"ghcr.io/ns/a:1": map[string]interface{}{}}}, + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]any{"ghcr.io/ns/b:1": map[string]any{}}}, + "ghcr.io/ns/b": {id: "b", digest: "sha256:b", dependsOn: map[string]any{"ghcr.io/ns/a:1": map[string]any{}}}, } graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/a:1"), nil, nil) if err != nil { t.Fatal(err) } - if _, err := ComputeInstallationOrder(log.Null, graph, nil); err == nil { + if _, err := ComputeInstallationOrder(log.Null, graph); err == nil { t.Fatal("expected circular dependency error") } } func TestBuildDependencyGraph_UnprocessableAborts(t *testing.T) { registry := map[string]stubFeature{ - "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]interface{}{"ghcr.io/ns/missing:1": map[string]interface{}{}}}, + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]any{"ghcr.io/ns/missing:1": map[string]any{}}}, } _, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/a:1"), nil, nil) if err == nil { @@ -235,7 +232,7 @@ func TestBuildDependencyGraph_OverridePriority(t *testing.T) { t.Fatal(err) } // Override is applied during graph build; pass nil to ComputeInstallationOrder. - if got, want := orderIDs(t, graph, nil), []string{"c", "a", "b"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"c", "a", "b"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v", got, want) } } @@ -252,7 +249,7 @@ func TestBuildDependencyGraph_OverrideAliasMatch(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := orderIDs(t, graph, nil), []string{"node", "b"}; !reflect.DeepEqual(got, want) { + if got, want := orderIDs(t, graph), []string{"node", "b"}; !reflect.DeepEqual(got, want) { t.Fatalf("order = %v, want %v (override alias should prioritize node)", got, want) } } @@ -270,7 +267,7 @@ func TestBuildDependencyGraph_OverrideInvalidAborts(t *testing.T) { func TestGenerateMermaidDiagram(t *testing.T) { registry := map[string]stubFeature{ - "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]interface{}{"ghcr.io/ns/b:1": map[string]interface{}{}}}, + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]any{"ghcr.io/ns/b:1": map[string]any{}}}, "ghcr.io/ns/b": {id: "b", digest: "sha256:b"}, } graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/a:1"), nil, nil) diff --git a/internal/features/lockfile.go b/internal/features/lockfile.go index 3b514a6..10c5954 100644 --- a/internal/features/lockfile.go +++ b/internal/features/lockfile.go @@ -4,9 +4,10 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "path/filepath" - "sort" + "slices" "strings" ) @@ -28,12 +29,7 @@ type LockfileEntry struct { // userFeatureId is in excludeUserFeatureIDs are omitted — the upstream CLI does not // write features supplied only via --additional-features to the lockfile. func GenerateLockfile(config *Config, excludeUserFeatureIDs map[string]bool) *Lockfile { - type entry struct { - id string - entry LockfileEntry - } - - var entries []entry + lf := &Lockfile{Features: map[string]LockfileEntry{}} for _, fs := range config.FeatureSets { src := fs.SourceInfo if src == nil { @@ -60,33 +56,16 @@ func GenerateLockfile(config *Config, excludeUserFeatureIDs map[string]bool) *Lo resolved = s.TarballURI } - var dependsOn []string - if feat.DependsOn != nil { - for k := range feat.DependsOn { - dependsOn = append(dependsOn, k) - } - sort.Strings(dependsOn) - } - e := LockfileEntry{ Version: feat.Version, Resolved: resolved, Integrity: fs.ComputedDigest, } - if len(dependsOn) > 0 { - e.DependsOn = dependsOn + if len(feat.DependsOn) > 0 { + e.DependsOn = slices.Sorted(maps.Keys(feat.DependsOn)) } - - entries = append(entries, entry{id: src.UserFeatureID(), entry: e}) - } - - sort.Slice(entries, func(i, j int) bool { - return entries[i].id < entries[j].id - }) - - lf := &Lockfile{Features: make(map[string]LockfileEntry, len(entries))} - for _, e := range entries { - lf.Features[e.id] = e.entry + // A map: the file is written with its keys sorted (json.MarshalIndent). + lf.Features[src.UserFeatureID()] = e } return lf } diff --git a/internal/features/lockfile_nodocker_held_test.go b/internal/features/lockfile_nodocker_held_test.go new file mode 100644 index 0000000..df00193 --- /dev/null +++ b/internal/features/lockfile_nodocker_held_test.go @@ -0,0 +1,48 @@ +package features + +import ( + "encoding/json" + "testing" +) + +// The lockfile generated for a Feature records its dependsOn ids, and leaves +// the key out for a Feature without any, as TS generateLockfile (lockfile.ts) +// does with `dependsOn.length ? dependsOn : undefined`; the JSON is TS +// JSON.stringify(lockfile, null, 2). +func TestGenerateLockfileRecordsDependsOn(t *testing.T) { + oci := func(id string, dependsOn map[string]any) *Set { + return &Set{ + SourceInfo: &OCISource{Registry: "ghcr.io", Namespace: "o/f", ID: id, UserID: "ghcr.io/o/f/" + id + ":1"}, + Features: []Feature{{ID: id, Version: "1.0.0", DependsOn: dependsOn}}, + ComputedDigest: "sha256:" + id, + } + } + config := &Config{FeatureSets: []*Set{ + oci("a", map[string]any{"ghcr.io/o/f/b:1": map[string]any{}}), + oci("b", nil), + }} + data, err := json.MarshalIndent(GenerateLockfile(config, nil), "", " ") + if err != nil { + t.Fatal(err) + } + want := `{ + "features": { + "ghcr.io/o/f/a:1": { + "version": "1.0.0", + "resolved": "ghcr.io/o/f/a@sha256:a", + "integrity": "sha256:a", + "dependsOn": [ + "ghcr.io/o/f/b:1" + ] + }, + "ghcr.io/o/f/b:1": { + "version": "1.0.0", + "resolved": "ghcr.io/o/f/b@sha256:b", + "integrity": "sha256:b" + } + } +}` + if string(data) != want { + t.Fatalf("lockfile:\n%s\nwant:\n%s", data, want) + } +} diff --git a/internal/features/mermaid.go b/internal/features/mermaid.go index 0b756cc..11dc95b 100644 --- a/internal/features/mermaid.go +++ b/internal/features/mermaid.go @@ -73,9 +73,9 @@ func generateMermaidNode(node *FNode) string { func mermaidNodeHash(node *FNode) string { options := node.Options if options == nil { - options = map[string]interface{}{} + options = map[string]any{} } - payload, _ := json.Marshal(map[string]interface{}{ + payload, _ := json.Marshal(map[string]any{ "type": node.Type, "userFeatureId": node.UserFeatureID, "options": options, diff --git a/internal/features/mermaid_nodocker_held_test.go b/internal/features/mermaid_nodocker_held_test.go new file mode 100644 index 0000000..2e3179d --- /dev/null +++ b/internal/features/mermaid_nodocker_held_test.go @@ -0,0 +1,55 @@ +package features + +import ( + "regexp" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// mermaidNodeLine is one node line of the diagram: TS generateMermaidNode +// (containerFeaturesOrder.ts) names a node by the first 6 hex digits of its +// sha256, then its id, roundPriority and aliases. +var mermaidNodeLine = regexp.MustCompile(`^([0-9a-f]{6})\[(.*)\]$`) + +// Each resolved node is labelled with its aliases, [id, ...legacyIds], which +// TS _buildDependencyGraph stores as featureIdAliases. +func TestGenerateMermaidDiagramLabelsAliases(t *testing.T) { + registry := map[string]stubFeature{ + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", legacyIds: []string{"old-a"}}, + } + graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), userFeats("ghcr.io/ns/a:1"), nil, nil) + if err != nil { + t.Fatal(err) + } + out := GenerateMermaidDiagram(graph) + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if len(lines) != 2 || lines[0] != "flowchart" { + t.Fatalf("diagram = %q", out) + } + m := mermaidNodeLine.FindStringSubmatch(lines[1]) + if m == nil || m[2] != "ghcr.io/ns/a:1
<0>
aliases: a, old-a" { + t.Fatalf("node line = %q", lines[1]) + } +} + +// Two nodes that differ only in their options are two mermaid nodes: TS hashes +// the whole node, options included. +func TestGenerateMermaidDiagramHashesOptions(t *testing.T) { + node := func(v string) *FNode { + return &FNode{Type: "user-provided", UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]any{"v": v}} + } + out := GenerateMermaidDiagram([]*FNode{node("1"), node("2")}) + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("diagram = %q", out) + } + first, second := mermaidNodeLine.FindStringSubmatch(lines[1]), mermaidNodeLine.FindStringSubmatch(lines[2]) + if first == nil || second == nil { + t.Fatalf("node lines = %q", lines[1:]) + } + if first[1] == second[1] { + t.Fatalf("nodes with different options share hash %s", first[1]) + } +} diff --git a/internal/features/order.go b/internal/features/order.go index 00037c6..6171f5e 100644 --- a/internal/features/order.go +++ b/internal/features/order.go @@ -2,7 +2,8 @@ package features import ( "fmt" - "sort" + "maps" + "slices" "strings" "github.com/devcontainers/cli/internal/log" @@ -12,7 +13,7 @@ import ( type FNode struct { Type string // "user-provided", "override", "resolved" UserFeatureID string - Options interface{} + Options any Set *Set DependsOn []*FNode InstallsAfter []*FNode @@ -22,13 +23,13 @@ type FNode struct { // ComputeInstallationOrder takes resolved feature sets and computes the // topological installation order respecting dependsOn (hard) and -// installsAfter (soft) constraints, plus overrideFeatureInstallOrder priority. +// installsAfter (soft) constraints, plus the overrideFeatureInstallOrder +// priority BuildDependencyGraph stored in each node's RoundPriority. // // This is the Go port of computeDependsOnInstallationOrder from containerFeaturesOrder.ts. func ComputeInstallationOrder( logger log.Logger, nodes []*FNode, - overrideOrder []string, ) ([]*Set, error) { if len(nodes) == 0 { @@ -42,17 +43,12 @@ func ComputeInstallationOrder( } } - // Apply override install order priorities - if len(overrideOrder) > 0 { - applyOverridePriority(logger, nodes, overrideOrder) - } - // Prune irrelevant soft dependencies: // Remove installsAfter edges where the soft dep doesn't match any node in the worklist. for _, node := range nodes { pruned := make([]*FNode, 0, len(node.InstallsAfter)) for _, softDep := range node.InstallsAfter { - if anyNodeSatisfiesSoftDep(nodes, softDep) { + if satisfiesSoftDep(nodes, softDep) { pruned = append(pruned, softDep) } else { logger.Write(fmt.Sprintf("Soft-dependency %q is not required. Removing.", softDep.UserFeatureID), log.LevelInfo) @@ -104,10 +100,9 @@ func ComputeInstallationOrder( // Remove round nodes from worklist worklist = removeNodes(worklist, round) - // Sort round lexicographically - sort.Slice(round, func(i, j int) bool { - return compareNodes(round[i], round[j]) < 0 - }) + // Sort round lexicographically, keeping equal nodes in place as the TS + // Array.prototype.sort (stable) does. + slices.SortStableFunc(round, compareNodes) installOrder = append(installOrder, round...) } @@ -120,67 +115,27 @@ func ComputeInstallationOrder( return result, nil } -// --- Override priority --- - -func applyOverridePriority(logger log.Logger, nodes []*FNode, overrideOrder []string) { - for i := len(overrideOrder) - 1; i >= 0; i-- { - overrideID := overrideOrder[i] - priority := len(overrideOrder) - i - - for _, node := range nodes { - if matchesSoftDep(node, overrideID) { - if priority > node.RoundPriority { - logger.Write(fmt.Sprintf("[override]: %q gets priority %d", node.UserFeatureID, priority), log.LevelTrace) - node.RoundPriority = priority - } - } - } - } -} - // --- Dependency checks --- func canInstall(node *FNode, installed []*FNode) bool { // All hard deps must be installed for _, dep := range node.DependsOn { - if !isInstalled(dep, installed) { + if !slices.ContainsFunc(installed, func(n *FNode) bool { return nodesEqual(n, dep) }) { return false } } // All soft deps must be installed (if they are in the worklist) for _, dep := range node.InstallsAfter { - if !isSoftDepSatisfied(dep, installed) { + if !satisfiesSoftDep(installed, dep) { return false } } return true } -func isInstalled(dep *FNode, installed []*FNode) bool { - for _, n := range installed { - if nodesEqual(n, dep) { - return true - } - } - return false -} - -func isSoftDepSatisfied(dep *FNode, installed []*FNode) bool { - for _, n := range installed { - if nodeSatisfiesSoftDep(n, dep) { - return true - } - } - return false -} - -func anyNodeSatisfiesSoftDep(nodes []*FNode, softDep *FNode) bool { - for _, n := range nodes { - if nodeSatisfiesSoftDep(n, softDep) { - return true - } - } - return false +// satisfiesSoftDep reports whether one of nodes satisfies softDep. +func satisfiesSoftDep(nodes []*FNode, softDep *FNode) bool { + return slices.ContainsFunc(nodes, func(n *FNode) bool { return nodeSatisfiesSoftDep(n, softDep) }) } // nodeSatisfiesSoftDep checks if `node` satisfies `softDep` as a soft dependency. @@ -298,7 +253,7 @@ func compareNodes(a, b *FNode) int { // map[string]interface{}), porting the TS optionsCompareTo. It underpins the // resource+options dedup identity so two references to the same Feature with // different options are treated as distinct. -func optionsCompareTo(a, b interface{}) int { +func optionsCompareTo(a, b any) int { as, aStr := a.(string) bs, bStr := b.(string) if aStr && bStr { @@ -311,22 +266,13 @@ func optionsCompareTo(a, b interface{}) int { return boolCompare(ab, bb) } - am, aMap := a.(map[string]interface{}) - bm, bMap := b.(map[string]interface{}) + am, aMap := a.(map[string]any) + bm, bMap := b.(map[string]any) if aMap && bMap { if len(am) != len(bm) { return len(am) - len(bm) } - aKeys := make([]string, 0, len(am)) - for k := range am { - aKeys = append(aKeys, k) - } - bKeys := make([]string, 0, len(bm)) - for k := range bm { - bKeys = append(bKeys, k) - } - sort.Strings(aKeys) - sort.Strings(bKeys) + aKeys, bKeys := slices.Sorted(maps.Keys(am)), slices.Sorted(maps.Keys(bm)) for i := range aKeys { if aKeys[i] != bKeys[i] { return strings.Compare(aKeys[i], bKeys[i]) @@ -347,12 +293,9 @@ func optionsCompareTo(a, b interface{}) int { return c } } - // TS treats a missing/undefined value as sorting after a present one. - if av == nil || bv == nil { - if c := undefinedCompare(av, bv); c != 0 { - return c - } - } + // TS also orders an undefined value after a present one, but a JSON + // option is never undefined: null, the nil here, is an object to + // TS, so like any other pair of differing types it orders nothing. } return 0 } @@ -370,18 +313,8 @@ func boolCompare(a, b bool) int { return -1 } -func undefinedCompare(a, b interface{}) int { - if a == b { - return 0 - } - if a == nil { - return 1 - } - return -1 -} - // typeName mirrors the JS `typeof` used by optionsCompareTo's final fallback. -func typeName(v interface{}) string { +func typeName(v any) string { switch v.(type) { case string: return "string" @@ -394,32 +327,8 @@ func typeName(v interface{}) string { } } -func matchesSoftDep(node *FNode, overrideID string) bool { - if node.Set == nil { - return false - } - src := node.Set.SourceInfo - if src == nil { - return false - } - strippedOverride := StripVersionFromID(overrideID) - strippedNode := StripVersionFromID(src.UserFeatureID()) - return strippedOverride == strippedNode -} - func removeNodes(worklist []*FNode, toRemove []*FNode) []*FNode { - result := make([]*FNode, 0, len(worklist)) - for _, n := range worklist { - remove := false - for _, r := range toRemove { - if nodesEqual(n, r) { - remove = true - break - } - } - if !remove { - result = append(result, n) - } - } - return result + return slices.DeleteFunc(slices.Clone(worklist), func(n *FNode) bool { + return slices.ContainsFunc(toRemove, func(r *FNode) bool { return nodesEqual(n, r) }) + }) } diff --git a/internal/features/order_nodocker_held_test.go b/internal/features/order_nodocker_held_test.go new file mode 100644 index 0000000..f9662de --- /dev/null +++ b/internal/features/order_nodocker_held_test.go @@ -0,0 +1,84 @@ +package features + +import ( + "slices" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// optionsNode is one reference to ghcr.io/ns/a with the given options; label +// tells the nodes apart in the install order. +func optionsNode(label string, options any) *FNode { + n := makeOCINode("ghcr.io/ns/a:1", "ghcr.io", "ns", "a") + n.Options = options + n.Set.Features[0].Version = label + return n +} + +// Two references to one Feature that differ only in their options install in +// the order TS optionsCompareTo (containerFeaturesOrder.ts) gives them, and +// options it finds equal keep their input order (TS sorts each round with the +// stable Array.prototype.sort). +func TestComputeInstallationOrderSortsByOptions(t *testing.T) { + for _, tt := range []struct { + name string + first, second any + want []string + }{ + // Fewer keys sort first: aKeys.length - bKeys.length. + {"fewer keys first", map[string]any{"v": "x"}, map[string]any{}, []string{"second", "first"}}, + // A string value compares with localeCompare. + {"string values", map[string]any{"v": "y"}, map[string]any{"v": "x"}, []string{"second", "first"}}, + // The first differing key decides, even when a later key differs the + // other way. + {"first string key decides", map[string]any{"a": "x", "b": "y"}, map[string]any{"a": "y", "b": "x"}, []string{"first", "second"}}, + // A boolean value: false < true. + {"boolean values", map[string]any{"v": true}, map[string]any{"v": false}, []string{"second", "first"}}, + {"first boolean key decides", map[string]any{"a": false, "b": true}, map[string]any{"a": true, "b": false}, []string{"first", "second"}}, + // A boolean and a string value are not compared: the objects are equal. + {"boolean against string value", map[string]any{"v": true}, map[string]any{"v": "x"}, []string{"first", "second"}}, + // Values of different types compare their typeof: "boolean" < "string". + {"boolean before string", "x", true, []string{"second", "first"}}, + // An empty string is still a string: "boolean" < "string". + {"boolean before empty string", "", true, []string{"second", "first"}}, + // "object" < "string". + {"object before string", "x", map[string]any{}, []string{"second", "first"}}, + } { + t.Run(tt.name, func(t *testing.T) { + first, second := optionsNode("first", tt.first), optionsNode("second", tt.second) + sets, err := ComputeInstallationOrder(log.Null, []*FNode{first, second}) + if err != nil { + t.Fatal(err) + } + var got []string + for _, s := range sets { + got = append(got, s.Features[0].Version) + } + if !slices.Equal(got, tt.want) { + t.Fatalf("order = %v, want %v", got, tt.want) + } + }) + } +} + +// A JSON null option value is an object to TS optionsCompareTo, not undefined, +// so it equals a string value and the dependency on the Feature with it is the +// user's Feature: one node, as TS _buildDependencyGraph dedups it. +func TestBuildDependencyGraphNullOptionEqualsAnyValue(t *testing.T) { + registry := map[string]stubFeature{ + "ghcr.io/ns/a": {id: "a", digest: "sha256:a", dependsOn: map[string]any{"ghcr.io/ns/b:1": map[string]any{"v": nil}}}, + "ghcr.io/ns/b": {id: "b", digest: "sha256:b"}, + } + uf := []DevContainerFeature{ + {UserFeatureID: "ghcr.io/ns/a:1", Options: map[string]any{}}, + {UserFeatureID: "ghcr.io/ns/b:1", Options: map[string]any{"v": "x"}}, + } + graph, err := BuildDependencyGraph(log.Null, newStubProcessFeature(registry), uf, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(graph) != 2 { + t.Fatalf("worklist size = %d, want 2 (b with null option is b)", len(graph)) + } +} diff --git a/internal/features/order_test.go b/internal/features/order_test.go index cf9e4ba..2aefc7d 100644 --- a/internal/features/order_test.go +++ b/internal/features/order_test.go @@ -11,7 +11,7 @@ func makeOCINode(id, registry, namespace, featureID string, deps ...*FNode) *FNo return &FNode{ Type: "user-provided", UserFeatureID: id, - Options: map[string]interface{}{}, + Options: map[string]any{}, Set: &Set{ SourceInfo: &OCISource{ Registry: registry, @@ -31,7 +31,6 @@ func TestComputeInstallOrder(t *testing.T) { tests := []struct { name string build func() []*FNode - override []string wantErr bool wantOrder []string }{ @@ -113,8 +112,8 @@ func TestComputeInstallOrder(t *testing.T) { wantOrder: []string{"a", "b", "c", "d"}, }, { - // a, b, c with no deps. Override order: [c, a]. - // c has priority 2, a has priority 1, b has priority 0. + // a, b, c with no deps; BuildDependencyGraph gave the override + // order [c, a] as c priority 2, a priority 1, b priority 0. // Round 1: all eligible, max priority = 2 → only c. // Round 2: a,b eligible, max priority = 1 → only a. // Round 3: b. @@ -123,9 +122,9 @@ func TestComputeInstallOrder(t *testing.T) { a := makeOCINode("ghcr.io/ns/a:1", "ghcr.io", "ns", "a") b := makeOCINode("ghcr.io/ns/b:1", "ghcr.io", "ns", "b") c := makeOCINode("ghcr.io/ns/c:1", "ghcr.io", "ns", "c") + c.RoundPriority, a.RoundPriority = 2, 1 return []*FNode{a, b, c} }, - override: []string{"ghcr.io/ns/c:1", "ghcr.io/ns/a:1"}, wantOrder: []string{"c", "a", "b"}, }, { @@ -161,7 +160,7 @@ func TestComputeInstallOrder(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := ComputeInstallationOrder(log.Null, tt.build(), tt.override) + result, err := ComputeInstallationOrder(log.Null, tt.build()) if tt.wantErr { if err == nil { t.Fatalf("expected error, got nil") diff --git a/internal/features/resolve.go b/internal/features/resolve.go index 04e9139..2194875 100644 --- a/internal/features/resolve.go +++ b/internal/features/resolve.go @@ -128,18 +128,3 @@ func ResolveID(id string, skipAutoMapping bool) (string, bool) { } return fmt.Sprintf("%s/%s%s", newFeaturePath, target, tag), true } - -// StripVersionFromID removes the version portion (:tag or @digest) from a feature ID. -func StripVersionFromID(id string) string { - // Check for digest first - if idx := strings.LastIndex(id, "@"); idx > 0 { - return id[:idx] - } - // Then tag (colon after last slash) - lastSlash := strings.LastIndex(id, "/") - lastColon := strings.LastIndex(id, ":") - if lastColon > lastSlash { - return id[:lastColon] - } - return id -} diff --git a/internal/features/resolve_test.go b/internal/features/resolve_test.go index d02ca50..6dcf264 100644 --- a/internal/features/resolve_test.go +++ b/internal/features/resolve_test.go @@ -128,29 +128,9 @@ func TestDeprecatedFeatureIntoOptions(t *testing.T) { } } -func TestStripVersionFromFeatureID(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"ghcr.io/devcontainers/features/go:1", "ghcr.io/devcontainers/features/go"}, - {"ghcr.io/devcontainers/features/go:1.2.3", "ghcr.io/devcontainers/features/go"}, - {"ghcr.io/devcontainers/features/go@sha256:abc", "ghcr.io/devcontainers/features/go"}, - {"ghcr.io/devcontainers/features/go", "ghcr.io/devcontainers/features/go"}, - {"./local-feature", "./local-feature"}, - {"node:18", "node"}, - } - for _, tt := range tests { - got := StripVersionFromID(tt.input) - if got != tt.want { - t.Errorf("StripVersionFromID(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - func TestUserFeaturesToArray(t *testing.T) { - features := map[string]interface{}{ - "ghcr.io/devcontainers/features/go:1": map[string]interface{}{"version": "1.21"}, + features := map[string]any{ + "ghcr.io/devcontainers/features/go:1": map[string]any{"version": "1.21"}, "ghcr.io/devcontainers/features/node:1": true, } arr := UserFeaturesToArray(features) diff --git a/internal/features/source_json_test.go b/internal/features/source_json_test.go new file mode 100644 index 0000000..ff22aaa --- /dev/null +++ b/internal/features/source_json_test.go @@ -0,0 +1,40 @@ +package features + +import ( + "encoding/json" + "testing" +) + +// read-configuration reports each Feature set's sourceInformation as TS builds +// it in processFeatureIdentifier: type first, then the location, then the +// user's reference. Go field names must not leak into the output. +func TestSourceInformationJSONMatchesTS(t *testing.T) { + tests := []struct { + name string + src SourceInfo + want string + }{ + { + name: "file-path", + src: &LocalSource{LocalPath: "./localFeatureA", ResolvedPath: "/ws/.devcontainer/localFeatureA", UserID: "./localFeatureA"}, + want: `{"type":"file-path","resolvedFilePath":"/ws/.devcontainer/localFeatureA","userFeatureId":"./localFeatureA"}`, + }, + { + name: "direct-tarball", + src: &TarballSource{TarballURI: "https://example.com/devcontainer-feature-go.tgz", UserID: "https://example.com/devcontainer-feature-go.tgz"}, + want: `{"type":"direct-tarball","tarballUri":"https://example.com/devcontainer-feature-go.tgz","userFeatureId":"https://example.com/devcontainer-feature-go.tgz"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(&Set{SourceInfo: tt.src}) + if err != nil { + t.Fatal(err) + } + want := `{"sourceInformation":` + tt.want + `,"features":null}` + if string(got) != want { + t.Errorf("json = %s\nwant %s", got, want) + } + }) + } +} diff --git a/internal/features/types.go b/internal/features/types.go index 5afe4b2..3076670 100644 --- a/internal/features/types.go +++ b/internal/features/types.go @@ -1,6 +1,8 @@ // Package features resolves, orders, installs and locks dev container Features. package features +import "encoding/json" + // Set represents a resolved feature with its source and metadata. type Set struct { SourceInfo SourceInfo `json:"sourceInformation"` @@ -11,33 +13,33 @@ type Set struct { // Feature represents a single feature within a Set. type Feature struct { - ID string `json:"id"` - Version string `json:"version,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - DocumentationURL string `json:"documentationURL,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` - DependsOn map[string]interface{} `json:"dependsOn,omitempty"` - InstallsAfter []string `json:"installsAfter,omitempty"` - LegacyIds []string `json:"legacyIds,omitempty"` - Included bool `json:"included"` - CachePath string `json:"cachePath,omitempty"` - ConsecutiveId string `json:"consecutiveId,omitempty"` - Value interface{} `json:"value"` // normalized main value - UserOptions map[string]interface{} `json:"-"` // original user option values from config - ContainerEnv map[string]string `json:"containerEnv,omitempty"` - Mounts []interface{} `json:"mounts,omitempty"` - Init *bool `json:"init,omitempty"` - Privileged *bool `json:"privileged,omitempty"` - CapAdd []string `json:"capAdd,omitempty"` - SecurityOpt []string `json:"securityOpt,omitempty"` - Entrypoint string `json:"entrypoint,omitempty"` - OnCreateCommand interface{} `json:"onCreateCommand,omitempty"` - UpdateContentCommand interface{} `json:"updateContentCommand,omitempty"` - PostCreateCommand interface{} `json:"postCreateCommand,omitempty"` - PostStartCommand interface{} `json:"postStartCommand,omitempty"` - PostAttachCommand interface{} `json:"postAttachCommand,omitempty"` - Customizations map[string]interface{} `json:"customizations,omitempty"` + ID string `json:"id"` + Version string `json:"version,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DocumentationURL string `json:"documentationURL,omitempty"` + Options map[string]any `json:"options,omitempty"` + DependsOn map[string]any `json:"dependsOn,omitempty"` + InstallsAfter []string `json:"installsAfter,omitempty"` + LegacyIds []string `json:"legacyIds,omitempty"` + Included bool `json:"included"` + CachePath string `json:"cachePath,omitempty"` + ConsecutiveId string `json:"consecutiveId,omitempty"` + Value any `json:"value"` // normalized main value + UserOptions map[string]any `json:"-"` // original user option values from config + ContainerEnv map[string]string `json:"containerEnv,omitempty"` + Mounts []any `json:"mounts,omitempty"` + Init *bool `json:"init,omitempty"` + Privileged *bool `json:"privileged,omitempty"` + CapAdd []string `json:"capAdd,omitempty"` + SecurityOpt []string `json:"securityOpt,omitempty"` + Entrypoint string `json:"entrypoint,omitempty"` + OnCreateCommand any `json:"onCreateCommand,omitempty"` + UpdateContentCommand any `json:"updateContentCommand,omitempty"` + PostCreateCommand any `json:"postCreateCommand,omitempty"` + PostStartCommand any `json:"postStartCommand,omitempty"` + PostAttachCommand any `json:"postAttachCommand,omitempty"` + Customizations map[string]any `json:"customizations,omitempty"` } // SourceInfo describes where a feature comes from. @@ -48,12 +50,12 @@ type SourceInfo interface { // OCISource is a feature from an OCI registry. type OCISource struct { - Type string `json:"type"` - ManifestDigest string `json:"manifestDigest,omitempty"` - UserID string `json:"userFeatureId"` - UserFeatureIdWithoutVersion string `json:"userFeatureIdWithoutVersion,omitempty"` - FeatureRef interface{} `json:"featureRef,omitempty"` - Manifest interface{} `json:"manifest,omitempty"` + Type string `json:"type"` + ManifestDigest string `json:"manifestDigest,omitempty"` + UserID string `json:"userFeatureId"` + UserFeatureIdWithoutVersion string `json:"userFeatureIdWithoutVersion,omitempty"` + FeatureRef any `json:"featureRef,omitempty"` + Manifest any `json:"manifest,omitempty"` // Internal fields not serialized to JSON (used for dependency resolution) Registry string `json:"-"` Namespace string `json:"-"` @@ -74,6 +76,16 @@ type TarballSource struct { func (s *TarballSource) SourceType() string { return "direct-tarball" } func (s *TarballSource) UserFeatureID() string { return s.UserID } +// MarshalJSON writes TS DirectTarballSourceInformation, as read-configuration +// reports it in featuresConfiguration. +func (s *TarballSource) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + TarballURI string `json:"tarballUri"` + UserFeatureID string `json:"userFeatureId"` + }{s.SourceType(), s.TarballURI, s.UserID}) +} + // LocalSource is a feature from a local file path. type LocalSource struct { LocalPath string @@ -84,6 +96,16 @@ type LocalSource struct { func (s *LocalSource) SourceType() string { return "file-path" } func (s *LocalSource) UserFeatureID() string { return s.UserID } +// MarshalJSON writes TS FilePathSourceInformation, as read-configuration +// reports it in featuresConfiguration. +func (s *LocalSource) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Type string `json:"type"` + ResolvedFilePath string `json:"resolvedFilePath"` + UserFeatureID string `json:"userFeatureId"` + }{s.SourceType(), s.ResolvedPath, s.UserID}) +} + // Config holds the fully resolved features configuration. type Config struct { FeatureSets []*Set @@ -92,12 +114,12 @@ type Config struct { // DevContainerFeature is a user's feature reference from devcontainer.json. type DevContainerFeature struct { UserFeatureID string - Options interface{} // bool | string | map[string]interface{} + Options any // bool | string | map[string]interface{} } // UserFeaturesToArray converts the features map from devcontainer.json // into a slice of DevContainerFeature. -func UserFeaturesToArray(features map[string]interface{}) []DevContainerFeature { +func UserFeaturesToArray(features map[string]any) []DevContainerFeature { if features == nil { return nil } diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go index f856485..3dbfd2a 100644 --- a/internal/httpx/httpx.go +++ b/internal/httpx/httpx.go @@ -1,43 +1,19 @@ -// Package httpx provides a proxy- and custom-CA-aware HTTP client shared by the CLI. +// Package httpx provides the proxy- and custom-CA-aware HTTP transport shared by the CLI. package httpx import ( - "context" "crypto/tls" "crypto/x509" "fmt" - "io" "net/http" "net/url" "os" - "time" "golang.org/x/net/http/httpproxy" ) -// Client wraps http.Client with proxy-awareness and custom CA cert support. -type Client struct { - inner *http.Client - version string -} - -// RequestOptions describes an HTTP request. -type RequestOptions struct { - Method string - URL string - Headers map[string]string - Body io.Reader -} - -// Response captures the HTTP response. -type Response struct { - StatusCode int - Headers http.Header - Body []byte -} - // NewTransport returns the single *http.Transport used by every HTTP path in the -// CLI (plain httpx requests, OCI/registry access via oras, and direct tarball +// CLI (the control manifest, OCI/registry access via oras, and direct tarball // downloads), so proxy and custom-CA behavior is uniform. // // Proxy: it honors the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment @@ -82,65 +58,6 @@ func NewTransport() *http.Transport { return transport } -// New creates a Client configured with proxy env vars and optional extra CA certs. -// It reads NODE_EXTRA_CA_CERTS and SSL_CERT_FILE for TLS CA bundles. -func New(version string) *Client { - return &Client{ - inner: &http.Client{ - Transport: NewTransport(), - Timeout: 30 * time.Second, - }, - version: version, - } -} - -// SetCheckRedirect installs a redirect policy on the underlying http.Client. The -// policy receives the pending request and the chain of requests already made, and -// returning http.ErrUseLastResponse stops following redirects (returning the last -// response). When left unset the Go default is used (follow up to 10 redirects). -func (c *Client) SetCheckRedirect(policy func(req *http.Request, via []*http.Request) error) { - c.inner.CheckRedirect = policy -} - -// Do executes an HTTP request bound to ctx and returns the response. A cancelled -// or deadline-exceeded ctx aborts the request. Redirects are followed per the -// client's CheckRedirect policy (the Go default unless SetCheckRedirect was used). -func (c *Client) Do(ctx context.Context, opts RequestOptions) (*Response, error) { - if ctx == nil { - ctx = context.Background() - } - if opts.Method == "" { - opts.Method = "GET" - } - - req, err := http.NewRequestWithContext(ctx, opts.Method, opts.URL, opts.Body) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - req.Header.Set("User-Agent", fmt.Sprintf("devcontainer-cli/%s", c.version)) - for k, v := range opts.Headers { - req.Header.Set(k, v) - } - - resp, err := c.inner.Do(req) - if err != nil { - return nil, fmt.Errorf("http %s %s: %w", opts.Method, opts.URL, err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - - return &Response{ - StatusCode: resp.StatusCode, - Headers: resp.Header, - Body: body, - }, nil -} - func loadCACerts(path string) (*x509.CertPool, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go deleted file mode 100644 index 1a77183..0000000 --- a/internal/httpx/httpx_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package httpx - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" -) - -func TestClient(t *testing.T) { - tests := []struct { - name string - version string - handler func(t *testing.T) http.HandlerFunc - opts func(url string) RequestOptions - check func(t *testing.T, resp *Response) - }{ - { - name: "GET", - version: "test", - handler: func(t *testing.T) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("method = %s", r.Method) - } - if ua := r.Header.Get("User-Agent"); ua == "" { - t.Error("missing User-Agent") - } - w.Header().Set("X-Custom", "test") - w.WriteHeader(200) - w.Write([]byte(`{"ok": true}`)) - } - }, - opts: func(url string) RequestOptions { - return RequestOptions{URL: url} - }, - check: func(t *testing.T, resp *Response) { - if resp.StatusCode != 200 { - t.Errorf("status = %d", resp.StatusCode) - } - if resp.Headers.Get("X-Custom") != "test" { - t.Error("missing custom header") - } - if string(resp.Body) != `{"ok": true}` { - t.Errorf("body = %q", string(resp.Body)) - } - }, - }, - { - name: "POST", - version: "test", - handler: func(t *testing.T) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("method = %s", r.Method) - } - if ct := r.Header.Get("Content-Type"); ct != "application/json" { - t.Errorf("content-type = %s", ct) - } - w.WriteHeader(201) - } - }, - opts: func(url string) RequestOptions { - return RequestOptions{ - Method: "POST", - URL: url, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - } - }, - check: func(t *testing.T, resp *Response) { - if resp.StatusCode != 201 { - t.Errorf("status = %d", resp.StatusCode) - } - }, - }, - { - name: "CustomHeaders", - version: "test", - handler: func(t *testing.T) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if auth := r.Header.Get("Authorization"); auth != "Bearer token123" { - t.Errorf("authorization = %q", auth) - } - w.WriteHeader(200) - } - }, - opts: func(url string) RequestOptions { - return RequestOptions{ - URL: url, - Headers: map[string]string{ - "Authorization": "Bearer token123", - }, - } - }, - check: func(t *testing.T, resp *Response) { - if resp.StatusCode != 200 { - t.Errorf("status = %d", resp.StatusCode) - } - }, - }, - { - name: "UserAgent", - version: "1.2.3", - handler: func(t *testing.T) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if ua := r.Header.Get("User-Agent"); ua != "devcontainer-cli/1.2.3" { - t.Errorf("user-agent = %q", ua) - } - w.WriteHeader(200) - } - }, - opts: func(url string) RequestOptions { - return RequestOptions{URL: url} - }, - check: nil, - }, - { - name: "Redirect", - version: "test", - handler: func(t *testing.T) http.HandlerFunc { - final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("final")) - })) - t.Cleanup(final.Close) - return func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, final.URL, http.StatusFound) - } - }, - opts: func(url string) RequestOptions { - return RequestOptions{URL: url} - }, - check: func(t *testing.T, resp *Response) { - if string(resp.Body) != "final" { - t.Errorf("body = %q", string(resp.Body)) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srv := httptest.NewServer(tt.handler(t)) - defer srv.Close() - - c := New(tt.version) - resp, err := c.Do(t.Context(), tt.opts(srv.URL)) - if err != nil { - t.Fatal(err) - } - if tt.check != nil { - tt.check(t, resp) - } - }) - } -} - -// TestDoRedirectChain follows a multi-hop redirect chain end to end and confirms -// every hop was visited and the final body is returned. -func TestDoRedirectChain(t *testing.T) { - var hops int32 - mux := http.NewServeMux() - // /a -> /b -> /c -> final body - mux.HandleFunc("/a", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&hops, 1) - http.Redirect(w, r, "/b", http.StatusFound) - }) - mux.HandleFunc("/b", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&hops, 1) - http.Redirect(w, r, "/c", http.StatusMovedPermanently) - }) - mux.HandleFunc("/c", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&hops, 1) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("arrived")) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - c := New("test") - resp, err := c.Do(t.Context(), RequestOptions{URL: srv.URL + "/a"}) - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusOK { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - if string(resp.Body) != "arrived" { - t.Errorf("body = %q, want %q", string(resp.Body), "arrived") - } - if got := atomic.LoadInt32(&hops); got != 3 { - t.Errorf("visited %d hops, want 3 (full chain followed)", got) - } -} - -// TestDoCheckRedirectPolicy verifies SetCheckRedirect can stop the CLI from -// following redirects (returning the 302 itself). -func TestDoCheckRedirectPolicy(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/from", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/to", http.StatusFound) - }) - mux.HandleFunc("/to", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("should-not-reach")) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - c := New("test") - c.SetCheckRedirect(func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }) - resp, err := c.Do(t.Context(), RequestOptions{URL: srv.URL + "/from"}) - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusFound { - t.Errorf("status = %d, want 302 (redirect not followed)", resp.StatusCode) - } - if string(resp.Body) == "should-not-reach" { - t.Error("redirect was followed despite ErrUseLastResponse policy") - } -} - -// TestDoContextCancelled confirms a cancelled context aborts the request rather -// than blocking on a slow/hung server. -func TestDoContextCancelled(t *testing.T) { - release := make(chan struct{}) - started := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(started) - <-release // block until the test releases us - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - defer close(release) - - ctx, cancel := context.WithCancel(t.Context()) - go func() { - <-started - cancel() - }() - - c := New("test") - _, err := c.Do(ctx, RequestOptions{URL: srv.URL}) - if err == nil { - t.Fatal("expected error from cancelled context, got nil") - } - if !errors.Is(err, context.Canceled) { - t.Errorf("error = %v, want context.Canceled", err) - } -} - -// TestDoContextDeadline confirms a context deadline aborts a slow request. -func TestDoContextDeadline(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-release - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - defer close(release) - - ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) - defer cancel() - - c := New("test") - _, err := c.Do(ctx, RequestOptions{URL: srv.URL}) - if err == nil { - t.Fatal("expected error from context deadline, got nil") - } - if !errors.Is(err, context.DeadlineExceeded) { - t.Errorf("error = %v, want context.DeadlineExceeded", err) - } -} diff --git a/internal/imagemeta/extend.go b/internal/imagemeta/extend.go index 32cc038..f72b5df 100644 --- a/internal/imagemeta/extend.go +++ b/internal/imagemeta/extend.go @@ -3,14 +3,32 @@ package imagemeta import ( "encoding/json" "fmt" + "path" "regexp" + "slices" "sort" + "strconv" "strings" - "github.com/devcontainers/cli/internal/docker" "github.com/devcontainers/cli/internal/features" ) +// FeaturesContainerTempDestFolder is where the feature-install layers stage each +// Feature inside the image (TS FEATURES_CONTAINER_TEMP_DEST_FOLDER). +const FeaturesContainerTempDestFolder = "/tmp/dev-container-features" + +// FeaturesBuiltinEnvFile is the file, at the root of the features folder, that +// holds the _CONTAINER_USER/_REMOTE_USER builtins every Feature's install +// wrapper sources (TS getFeaturesBuildOptions). +const FeaturesBuiltinEnvFile = "devcontainer-features.builtin.env" + +// FeatureEnvFile and FeatureInstallWrapperFile are the files written into each +// Feature's folder (TS getFeaturesBuildOptions, internalVersion 2). +const ( + FeatureEnvFile = "devcontainer-features.env" + FeatureInstallWrapperFile = "devcontainer-features-install.sh" +) + // ExtendImageBuildInfo holds the Dockerfile and build context needed // to extend a base image with features. type ExtendImageBuildInfo struct { @@ -18,219 +36,470 @@ type ExtendImageBuildInfo struct { DockerfileContent string OverrideTarget string BuildArgs map[string]string - BuildKitContexts map[string]string - DstFolder string + // BuildKitContexts maps an additional build context name to its folder; "." + // stands for the features folder (TS dstFolder), which the caller resolves. + BuildKitContexts map[string]string + DstFolder string } -// GenerateExtendImageBuild creates the Dockerfile content for installing -// features on top of a base image. Matches the TS getFeaturesBuildOptions(). -func GenerateExtendImageBuild( - baseImage string, - featureSets []*features.Set, - metadata []Entry, - containerUser, remoteUser string, - useBuildKitContexts bool, - configContainerEnv map[string]string, - // secretIDs are Docker build-secret ids mounted into every feature-install RUN - // as `--mount=type=secret,id=`, so a Feature's install.sh can read - // /run/secrets/ (#1078). Empty when no build secrets are in play. - secretIDs []string, -) *ExtendImageBuildInfo { - if len(featureSets) == 0 { - // No features — just add metadata label - df := docker.NewDockerfileBuilder() - df.Arg("_DEV_CONTAINERS_BASE_IMAGE", "scratch") - prefix := df.String() - - df2 := docker.NewDockerfileBuilder() - df2.BlankLine() - df2.From("$_DEV_CONTAINERS_BASE_IMAGE").As("dev_containers_target_stage") - df2.Label(MetadataLabel, GenerateMetadataLabel(metadata)) - - return &ExtendImageBuildInfo{ - DockerfilePrefixContent: prefix, - DockerfileContent: df2.String(), - OverrideTarget: "dev_containers_target_stage", - BuildArgs: map[string]string{"_DEV_CONTAINERS_BASE_IMAGE": baseImage}, - BuildKitContexts: map[string]string{}, - } +// FeaturesBuildParams are the inputs of GenerateFeaturesBuildOptions (TS +// getFeaturesBuildOptions). +type FeaturesBuildParams struct { + // BaseName is the _DEV_CONTAINERS_BASE_IMAGE build argument: the image (or + // the compose service's Dockerfile stage) the Features are installed on. + BaseName string + FeatureSets []*features.Set + Metadata []Entry + // ContainerUser and RemoteUser are TS findContainerUsers' result; they seed + // the _CONTAINER_USER_HOME/_REMOTE_USER_HOME builtins. + ContainerUser, RemoteUser string + // ImageUser is the image's own user (TS imageBuildInfo.user), restored after + // the Features are installed through the _DEV_CONTAINERS_IMAGE_USER argument. + ImageUser string + // UseBuildKitContexts copies the Features' content from the + // dev_containers_feature_content_source additional build context; otherwise + // from the ComposeFeatureContentImage image's /tmp/build-features/. + UseBuildKitContexts bool + // Syntax is the `# syntax=` value the prefix declares ("" for none); the + // caller applies TS's rules (omitSyntaxDirective, Docker >= 23, build + // contexts support of the user's Dockerfile). + Syntax string + // ConfigContainerEnv is devcontainer.json's containerEnv, emitted as ENV + // lines after the metadata label (TS #{containerEnvMetadata}). + ConfigContainerEnv map[string]string + // SecretIDs are Docker build-secret ids mounted into every feature-install + // RUN as `--mount=type=secret,id=`, so a Feature's install.sh can read + // /run/secrets/ (#1078). TS has no equivalent; empty when no build + // secrets are in play. + SecretIDs []string +} + +// ComposeFeatureContentImage is the temporary image the TS CLI builds to hold the +// Features' content when the build cannot use an additional build context +// (containerFeatures.ts getFeaturesBuildOptions buildContentImageName). +const ComposeFeatureContentImage = "dev_container_feature_content_temp" + +// GenerateComposeLabelBuild creates the Dockerfile content that the TS +// buildAndExtendDockerCompose appends to a compose service's Dockerfile when +// there are no Features: only the metadata label (TS getImageBuildOptions). The +// global `ARG _DEV_CONTAINERS_BASE_IMAGE` is part of DockerfilePrefixContent, +// because a FROM can only use an ARG declared before the first FROM of the +// combined Dockerfile. syntax is the `# syntax=` value to declare ("" for none). +func GenerateComposeLabelBuild(baseName string, metadata []Entry, syntax string) *ExtendImageBuildInfo { + prefix := "\n ARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + if syntax != "" { + prefix = "# syntax=" + syntax + prefix } + return &ExtendImageBuildInfo{ + DockerfilePrefixContent: prefix, + DockerfileContent: "\nFROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage\n" + devcontainerMetadataLabel(metadata) + "\n", + OverrideTarget: "dev_containers_target_stage", + BuildArgs: map[string]string{"_DEV_CONTAINERS_BASE_IMAGE": baseName}, + BuildKitContexts: map[string]string{}, + } +} - df := docker.NewDockerfileBuilder() +// containerFeaturesBaseDockerfile is TS getContainerFeaturesBaseDockerFile. +const containerFeaturesBaseDockerfile = ` - // Preamble - df.Arg("_DEV_CONTAINERS_BASE_IMAGE", "scratch") +#{nonBuildKitFeatureContentFallback} - if useBuildKitContexts { - df.From("scratch").As("dev_containers_feature_content_source") - } +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch - // Main stage - df.BlankLine() - df.From("$_DEV_CONTAINERS_BASE_IMAGE").As("dev_containers_target_stage") - df.User("root") +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source #{builtinEnvSource} /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ - secretMounts := make([]string, 0, len(secretIDs)) - for _, id := range secretIDs { - secretMounts = append(secretMounts, "--mount=type=secret,id="+id) - } +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage - // Install each feature - for i, fs := range featureSets { - if len(fs.Features) == 0 { - continue - } - feat := fs.Features[0] - featureDir := fmt.Sprintf("_dev_container_feature_%d", i) - dstPath := fmt.Sprintf("/tmp/build-features/%s", featureDir) - - // Emit the feature's containerEnv as ENV *before* the install RUN, matching - // the TS CLI. Docker expands ${VAR} references (e.g. PATH="...:${PATH}") at - // ENV time, so install.sh runs with a correct PATH. These ENVs also persist - // into the final image (the runtime containerEnv). - for _, env := range generatePersistentContainerEnvVars(feat) { - df.EnvRaw(env) - } +USER root - if useBuildKitContexts { - df.Copy(featureDir+"/", dstPath+"/").From("dev_containers_feature_content_source") - } else { - df.Copy(featureDir+"/", dstPath+"/") - } +RUN mkdir -p ` + FeaturesContainerTempDestFolder + ` +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ ` + FeaturesContainerTempDestFolder + ` - // Feature option env vars must apply to install.sh, not to the preceding - // chmod. In POSIX sh, `KEY=v chmod ... && install.sh` scopes KEY to chmod - // only, so options and _REMOTE_USER/_CONTAINER_USER never reach install.sh - // (features install with empty options — silently wrong images or broken - // builds). Prefix the assignments directly onto install.sh instead. - installCmd := fmt.Sprintf("%s/install.sh", dstPath) - if envs := generateFeatureBuildEnvVars(feat, containerUser, remoteUser); len(envs) > 0 { - installCmd = strings.Join(envs, " ") + " " + installCmd - } - runCmd := fmt.Sprintf("chmod +x %s/install.sh && %s", dstPath, installCmd) - df.RunWithMounts(secretMounts, runCmd) - } +#{featureLayer} - // Config-level containerEnv (overrides feature env vars) - if len(configContainerEnv) > 0 { - keys := make([]string, 0, len(configContainerEnv)) - for k := range configContainerEnv { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - df.Env(k, configContainerEnv[k]) // escaping handled by builder - } - } +#{containerEnv} - // Metadata label — escaping handled by builder - df.BlankLine() - df.Label(MetadataLabel, GenerateMetadataLabel(metadata)) +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER - if containerUser != "root" { - df.User(containerUser) +#{devcontainerMetadata} + +#{containerEnvMetadata} +` + +// GenerateFeaturesBuildOptions generates the Dockerfile that installs the +// Features, as TS getFeaturesBuildOptions does with +// getContainerFeaturesBaseDockerFile and getFeatureLayers: a normalize stage +// copies the builtin env file, then each Feature's folder (named by its +// consecutiveId) is copied or bind-mounted into +// /tmp/dev-container-features/ and its +// devcontainer-features-install.sh wrapper runs, preceded by the Feature's +// containerEnv as ENV lines. The files the Dockerfile refers to are written by +// the caller (FeatureEnvVariables, FeatureInstallWrapperScript, +// FeaturesBuiltinEnvContent). Only internalVersion 2 Features exist here: the +// v1 GitHub Release Features are not supported (docs/DIVERGENCES.md). +func GenerateFeaturesBuildOptions(p FeaturesBuildParams) *ExtendImageBuildInfo { + contentSourceRootPath := "/tmp/build-features/" + fallback := "FROM " + ComposeFeatureContentImage + " as dev_containers_feature_content_source" + if p.UseBuildKitContexts { + contentSourceRootPath = "." + fallback = "" } + replacer := strings.NewReplacer( + "#{nonBuildKitFeatureContentFallback}", fallback, + "#{builtinEnvSource}", path.Join(contentSourceRootPath, FeaturesBuiltinEnvFile), + "#{featureLayer}", featureLayers(p, contentSourceRootPath), + // TS generateContainerEnvsV1: only v1 Features contribute here. + "#{containerEnv}", "", + "#{devcontainerMetadata}", devcontainerMetadataLabel(p.Metadata), + "#{containerEnvMetadata}", generateContainerEnvs(p.ConfigContainerEnv, true), + ) + dockerfile := replacer.Replace(containerFeaturesBaseDockerfile) - buildArgs := map[string]string{"_DEV_CONTAINERS_BASE_IMAGE": baseImage} + prefix := "\nARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" + if p.Syntax != "" { + prefix = "# syntax=" + p.Syntax + prefix + } + imageUser := p.ImageUser + if imageUser == "" { + imageUser = "root" + } buildKitContexts := map[string]string{} - prefix := "" - if useBuildKitContexts { + if p.UseBuildKitContexts { buildKitContexts["dev_containers_feature_content_source"] = "." - // The feature build passes --build-context; that requires a docker/dockerfile - // frontend >= 1.4. Declare it as the first line (matching the TS CLI), or the - // build fails on a Docker whose default frontend predates build contexts. - prefix = "# syntax=docker/dockerfile:1.4\n" } - return &ExtendImageBuildInfo{ DockerfilePrefixContent: prefix, - DockerfileContent: df.String(), + DockerfileContent: dockerfile, OverrideTarget: "dev_containers_target_stage", - BuildArgs: buildArgs, - BuildKitContexts: buildKitContexts, + BuildArgs: map[string]string{ + "_DEV_CONTAINERS_BASE_IMAGE": p.BaseName, + "_DEV_CONTAINERS_IMAGE_USER": imageUser, + "_DEV_CONTAINERS_FEATURE_CONTENT_SOURCE": ComposeFeatureContentImage, + }, + BuildKitContexts: buildKitContexts, } } -// GenerateExtendImageBuildForCompose generates feature Dockerfile content for injection -// into an existing compose Dockerfile. Instead of using ARG/FROM $_DEV_CONTAINERS_BASE_IMAGE, -// it directly references the named base stage. -func GenerateExtendImageBuildForCompose( - baseStageName string, - featureSets []*features.Set, - metadata []Entry, - containerUser, remoteUser string, - configContainerEnv map[string]string, - secretIDs []string, -) string { - df := docker.NewDockerfileBuilder() - - df.BlankLine() - df.From(baseStageName).As("dev_containers_target_stage") - df.User("root") - - secretMounts := make([]string, 0, len(secretIDs)) - for _, id := range secretIDs { - secretMounts = append(secretMounts, "--mount=type=secret,id="+id) - } - - for i, fs := range featureSets { - if len(fs.Features) == 0 { - continue +// featureLayers is TS getFeatureLayers for internalVersion 2 Features. +func featureLayers(p FeaturesBuildParams, contentSourceRootPath string) string { + builtinsEnvFile := path.Join(FeaturesContainerTempDestFolder, FeaturesBuiltinEnvFile) + var b strings.Builder + fmt.Fprintf(&b, "RUN \\\necho \"_CONTAINER_USER_HOME=$(%s | cut -d: -f6)\" >> %s && \\\necho \"_REMOTE_USER_HOME=$(%s | cut -d: -f6)\" >> %s\n\n", + getEntPasswdShellCommand(p.ContainerUser), builtinsEnvFile, getEntPasswdShellCommand(p.RemoteUser), builtinsEnvFile) + + secretMounts := "" + for _, id := range p.SecretIDs { + secretMounts += " --mount=type=secret,id=" + id + } + for _, set := range p.FeatureSets { + for _, feat := range set.Features { + b.WriteString(generateContainerEnvs(feat.ContainerEnv, false)) + source := path.Join(contentSourceRootPath, feat.ConsecutiveId) + dest := path.Join(FeaturesContainerTempDestFolder, feat.ConsecutiveId) + if !p.UseBuildKitContexts { + runMounts := "" + if secretMounts != "" { + runMounts = strings.TrimPrefix(secretMounts, " ") + " " + } + fmt.Fprintf(&b, "\nCOPY --chown=root:root --from=dev_containers_feature_content_source %s %s\nRUN %schmod -R 0755 %s \\\n&& cd %s \\\n&& chmod +x ./%s \\\n&& ./%s\n\n", + source, dest, runMounts, dest, dest, FeatureInstallWrapperFile, FeatureInstallWrapperFile) + continue + } + fmt.Fprintf(&b, "\nRUN --mount=type=bind,from=dev_containers_feature_content_source,source=%s,target=/tmp/build-features-src/%s%s \\\n cp -ar /tmp/build-features-src/%s %s \\\n && chmod -R 0755 %s \\\n && cd %s \\\n && chmod +x ./%s \\\n && ./%s \\\n && rm -rf %s\n\n", + source, feat.ConsecutiveId, secretMounts, feat.ConsecutiveId, FeaturesContainerTempDestFolder, dest, dest, FeatureInstallWrapperFile, FeatureInstallWrapperFile, dest) } - feat := fs.Features[0] - featureDir := fmt.Sprintf("_dev_container_feature_%d", i) - dstPath := fmt.Sprintf("/tmp/build-features/%s", featureDir) - - // Emit the feature's containerEnv as ENV *before* the install RUN (Docker - // expands ${VAR} references like PATH="...:${PATH}" at ENV time), matching - // the TS CLI. Also persists into the final image. - for _, env := range generatePersistentContainerEnvVars(feat) { - df.EnvRaw(env) + } + return b.String() +} + +// generateContainerEnvs is TS generateContainerEnvs: one `ENV k="v"` line per +// entry, escaping double quotes and backslashes (and dollar signs when +// escapeDollar). Go maps carry no insertion order, so keys are sorted. +func generateContainerEnvs(env map[string]string, escapeDollar bool) string { + if len(env) == 0 { + return "" + } + special := `"\` + if escapeDollar { + special += "$" + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, "ENV "+k+`="`+backslashEscape(env[k], special)+`"`) + } + return strings.Join(lines, "\n") +} + +// backslashEscape prefixes every character of s found in special with a +// backslash (TS .replace(/(?=[...])/g, '\\')). +func backslashEscape(s, special string) string { + var b strings.Builder + for _, r := range s { + if strings.ContainsRune(special, r) { + b.WriteByte('\\') } + b.WriteRune(r) + } + return b.String() +} + +// devcontainerMetadataLabel is TS getDevcontainerMetadataLabel: the metadata +// label as a JSON array with one entry per continued line, "" without entries. +func devcontainerMetadataLabel(entries []Entry) string { + entries = nonEmptyEntries(entries) + if len(entries) == 0 { + return "" + } + parts := make([]string, 0, len(entries)) + for _, e := range entries { + data, _ := json.Marshal(e) + parts = append(parts, " \\\n"+backslashEscape(string(data), `"\$`)) + } + return "LABEL " + MetadataLabel + `="[` + strings.Join(parts, ",") + ` \` + "\n]\"" +} - df.Copy(featureDir+"/", dstPath+"/") +// regExpSpecial are the characters TS escapeRegExCharacters escapes. +var regExpSpecial = regexp.MustCompile(`[.*+?^${}()|[\]\\]`) + +// getEntPasswdShellCommand is TS getEntPasswdShellCommand: a shell expression +// printing the /etc/passwd line of a user name or uid, preferring getent and +// falling back to grep on getent-less images (alpine/busybox). The leading +// space keeps `$(` from forming an arithmetic expansion with the `(`. +func getEntPasswdShellCommand(userNameOrID string) string { + escapedForShell := regexp.MustCompile(`['\\]`).ReplaceAllString(userNameOrID, `\$0`) + escapedForRegExp := strings.ReplaceAll(regExpSpecial.ReplaceAllString(userNameOrID, `\$0`), "'", `\'`) + return fmt.Sprintf(` (command -v getent >/dev/null 2>&1 && getent passwd '%s' || grep -E '^%s|^[^:]*:[^:]*:%s:' /etc/passwd || true)`, escapedForShell, escapedForRegExp, escapedForRegExp) +} + +// FeaturesBuiltinEnvContent is the devcontainer-features.builtin.env content +// (TS getFeaturesBuildOptions builtinVariables). +func FeaturesBuiltinEnvContent(containerUser, remoteUser string) string { + return "_CONTAINER_USER=" + containerUser + "\n_REMOTE_USER=" + remoteUser + "\n" +} + +// FeatureFileInfo holds the devcontainer-feature.json properties TS reads from +// the staged file that features.Feature does not keep. +type FeatureFileInfo struct { + // OptionOrder lists the keys of "options" in document order (a JS object + // keeps insertion order; a Go map does not). + OptionOrder []string + Deprecated bool + CurrentID string +} - // Feature option env vars must apply to install.sh, not to the preceding - // chmod. In POSIX sh, `KEY=v chmod ... && install.sh` scopes KEY to chmod - // only, so options and _REMOTE_USER/_CONTAINER_USER never reach install.sh - // (features install with empty options — silently wrong images or broken - // builds). Prefix the assignments directly onto install.sh instead. - installCmd := fmt.Sprintf("%s/install.sh", dstPath) - if envs := generateFeatureBuildEnvVars(feat, containerUser, remoteUser); len(envs) > 0 { - installCmd = strings.Join(envs, " ") + " " + installCmd +// ParseFeatureFileInfo reads FeatureFileInfo from a devcontainer-feature.json +// document. A document that is not a JSON object yields an error. +func ParseFeatureFileInfo(data []byte) (FeatureFileInfo, error) { + var info FeatureFileInfo + var raw struct { + Options json.RawMessage `json:"options"` + Deprecated bool `json:"deprecated"` + CurrentID string `json:"currentId"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return info, err + } + info.Deprecated, info.CurrentID = raw.Deprecated, raw.CurrentID + // Absent options leave nothing to decode (an error) and null or any other + // non-object value is not a '{': either way there are no option keys. + dec := json.NewDecoder(strings.NewReader(string(raw.Options))) + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + return info, nil + } + for dec.More() { + tok, err := dec.Token() + if err != nil { + return info, err + } + key, _ := tok.(string) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + return info, err + } + if !slices.Contains(info.OptionOrder, key) { + info.OptionOrder = append(info.OptionOrder, key) } - runCmd := fmt.Sprintf("chmod +x %s/install.sh && %s", dstPath, installCmd) - df.RunWithMounts(secretMounts, runCmd) } + return info, nil +} - if len(configContainerEnv) > 0 { - keys := make([]string, 0, len(configContainerEnv)) - for k := range configContainerEnv { +// FeatureEnvVariables is TS getFeatureEnvVariables for an internalVersion 2 +// Feature: `=""` for each entry of the Feature's value +// object (option defaults overlaid with the user's values, TS +// getFeatureValueObject), unescaped as in TS. +func FeatureEnvVariables(feat features.Feature, info FeatureFileInfo) []string { + keys, values := featureValueObject(feat, info) + vars := make([]string, 0, len(keys)) + for _, k := range keys { + vars = append(vars, safeID(k)+`="`+jsString(values[k])+`"`) + } + return vars +} + +// featureValueObject is TS getFeatureValueObject, returning the keys in JS +// object order. +func featureValueObject(feat features.Feature, info FeatureFileInfo) ([]string, map[string]any) { + order := append([]string(nil), info.OptionOrder...) + var rest []string + for k := range feat.Options { + if !slices.Contains(order, k) { + rest = append(rest, k) + } + } + sort.Strings(rest) + order = append(order, rest...) + + var keys []string + values := map[string]any{} + set := func(k string, v any) { + if _, ok := values[k]; !ok { keys = append(keys, k) } - sort.Strings(keys) - for _, k := range keys { - df.Env(k, configContainerEnv[k]) + values[k] = v + } + for _, k := range order { + opt, ok := feat.Options[k].(map[string]any) + if !ok { + continue + } + if def, ok := opt["default"]; ok { + set(k, def) + } + } + switch v := feat.Value.(type) { + case map[string]any: + userKeys := make([]string, 0, len(v)) + for k := range v { + userKeys = append(userKeys, k) + } + sort.Strings(userKeys) + for _, k := range userKeys { + set(k, v[k]) + } + case nil: + // typeof null === 'object' in TS: only the defaults. + default: + if _, ok := feat.Options["version"]; ok { + set("version", v) } } + return keys, values +} + +// jsString converts a JSON value to a string as a JS template literal does. +func jsString(v any) string { + switch x := v.(type) { + case nil: + return "null" + case string: + return x + case bool: + return strconv.FormatBool(x) + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) // mutate-exempt: every negative precision means the shortest representation, so -1 and -2 format alike + case json.Number: + return x.String() + case int: + return strconv.Itoa(x) + case []any: + parts := make([]string, len(x)) + for i, e := range x { + if e != nil { + parts[i] = jsString(e) + } + } + return strings.Join(parts, ",") + case map[string]any: + return "[object Object]" + default: + return fmt.Sprint(x) + } +} + +// escapeQuotesForShell is TS escapeQuotesForShell (for single-quoted strings). +func escapeQuotesForShell(s string) string { + return strings.ReplaceAll(s, "'", `'\''`) +} - df.BlankLine() - df.Label(MetadataLabel, GenerateMetadataLabel(metadata)) +// FeatureInstallWrapperScript is TS getFeatureInstallWrapperScript: the +// devcontainer-features-install.sh that prints the Feature's banner, sources +// the builtin and the Feature's env files and runs its install.sh. options are +// the Feature's FeatureEnvVariables. +func FeatureInstallWrapperScript(feat features.Feature, set *features.Set, info FeatureFileInfo, options []string) string { + id := "Unknown" + if src, ok := set.SourceInfo.(*features.OCISource); ok && src.UserFeatureIdWithoutVersion != "" { + id = src.UserFeatureIdWithoutVersion + } + id = escapeQuotesForShell(id) + name := feat.Name + if name == "" { + name = "Unknown" + } + name = escapeQuotesForShell(name) + description := escapeQuotesForShell(feat.Description) + version := escapeQuotesForShell(feat.Version) + documentation := escapeQuotesForShell(feat.DocumentationURL) + indented := make([]string, len(options)) + for i, o := range options { + indented[i] = " " + o + } + optionsIndented := escapeQuotesForShell(strings.Join(indented, "\n")) - if containerUser != "root" { - df.User(containerUser) + warningHeader := "" + if info.Deprecated { + warningHeader += `(!) WARNING: Using the deprecated Feature "` + escapeQuotesForShell(feat.ID) + "\". This Feature will no longer receive any further updates/support.\n" + } + if len(feat.LegacyIds) > 0 && info.CurrentID != "" && feat.ID != info.CurrentID { + warningHeader += `(!) WARNING: This feature has been renamed. Please update the reference in devcontainer.json to "` + escapeQuotesForShell(info.CurrentID) + `".` + } + echoWarning := "" + if warningHeader != "" { + echoWarning = "echo '" + warningHeader + "'" + } + errorMessage := `ERROR: Feature "` + name + `" (` + id + `) failed to install!` + troubleshooting := "" + if documentation != "" { + troubleshooting = " Look at the documentation at " + documentation + " for help troubleshooting this error." } - return df.String() + return `#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo '` + errorMessage + troubleshooting + `' } -// getEntPasswdExpr returns a shell expression that prints the /etc/passwd line -// for a username OR uid, preferring getent and falling back to grep on -// getent-less images (alpine/busybox) — matching the TS getEntPasswdShellCommand. -func getEntPasswdExpr(userNameOrID string) string { - shellEsc := strings.ReplaceAll(userNameOrID, "'", `'\''`) - reEsc := regexp.QuoteMeta(userNameOrID) - return fmt.Sprintf(`(command -v getent >/dev/null 2>&1 && getent passwd '%s' || grep -E '^%s|^[^:]*:[^:]*:%s:' /etc/passwd || true)`, shellEsc, reEsc, reEsc) +trap on_exit EXIT + +echo =========================================================================== +` + echoWarning + ` +echo 'Feature : ` + name + `' +echo 'Description : ` + description + `' +echo 'Id : ` + id + `' +echo 'Version : ` + version + `' +echo 'Documentation : ` + documentation + `' +echo 'Options :' +echo '` + optionsIndented + `' +echo =========================================================================== + +set -a +. ../` + FeaturesBuiltinEnvFile + ` +. ./` + FeatureEnvFile + ` +set +a + +chmod +x ./install.sh +./install.sh +` } // safeID converts a string to a safe environment variable name. @@ -256,112 +525,3 @@ func safeID(s string) string { } return strings.ToUpper(result) } - -// generateFeatureBuildEnvVars produces shell assignments used only while the -// feature install script is running. These should not persist in the final image. -func generateFeatureBuildEnvVars(feat features.Feature, containerUser, remoteUser string) []string { - var envs []string - safeID := safeID(feat.ID) - - envs = append(envs, - fmt.Sprintf("_CONTAINER_USER=%s", shellSingleQuote(containerUser)), - fmt.Sprintf("_REMOTE_USER=%s", shellSingleQuote(remoteUser)), - // Resolve the users' home dirs from /etc/passwd at RUN time (a feature like - // common-utils may have just created the user, so a later feature must see - // its home) — matching the TS CLI's _REMOTE_USER_HOME / _CONTAINER_USER_HOME - // builtins. Command substitution, so these must not be single-quoted. - // Note the space after `$(`: without it the leading `(` makes the shell - // parse `$((` as arithmetic expansion and fail. - fmt.Sprintf(`_CONTAINER_USER_HOME="$( %s | cut -d: -f6)"`, getEntPasswdExpr(containerUser)), - fmt.Sprintf(`_REMOTE_USER_HOME="$( %s | cut -d: -f6)"`, getEntPasswdExpr(remoteUser)), - ) - - // Main value — TS normalizes option objects to true and only emits the - // feature's primary BUILD_ARG plus per-option env vars. - if feat.Value != nil { - switch v := normalizeFeatureValue(feat.Value).(type) { - case bool: - envs = append(envs, fmt.Sprintf("_BUILD_ARG_%s=%s", safeID, shellSingleQuote(fmt.Sprintf("%t", v)))) - case string: - if v != "" { - envs = append(envs, fmt.Sprintf("_BUILD_ARG_%s=%s", safeID, shellSingleQuote(v))) - } - default: - data, _ := json.Marshal(v) - str := string(data) - if str != "{}" && str != "null" { - envs = append(envs, fmt.Sprintf("_BUILD_ARG_%s=%s", safeID, shellSingleQuote(str))) - } - } - } - - // Per-option env vars from feature metadata - // Feature options (from devcontainer-feature.json) are set as env vars - // so install.sh can read them. E.g., VERSION=latest, GREETING=hello - if feat.Options != nil { - optionNames := make([]string, 0, len(feat.Options)) - for optName := range feat.Options { - optionNames = append(optionNames, optName) - } - sort.Strings(optionNames) - for _, optName := range optionNames { - optDef := feat.Options[optName] - optMap, ok := optDef.(map[string]interface{}) - if !ok { - continue - } - // Get user-provided value or default - defaultVal := optMap["default"] - val := defaultVal - - // Check if user provided a value in UserOptions - if feat.UserOptions != nil { - if userVal, ok := feat.UserOptions[optName]; ok { - val = userVal - } - } - - if val != nil { - upperName := strings.ToUpper(optName) - strVal := fmt.Sprintf("%v", val) - envs = append(envs, fmt.Sprintf("%s=%s", upperName, shellSingleQuote(strVal))) - envs = append(envs, fmt.Sprintf("_BUILD_ARG_%s_%s=%s", safeID, strings.ToUpper(optName), shellSingleQuote(strVal))) - } - } - } - - // NOTE: the feature's containerEnv is intentionally NOT added here. It is - // emitted as `ENV KEY="value"` before the install RUN (see the generation - // loops), so Docker expands ${VAR} references (e.g. PATH="...:${PATH}"). - // Inlining it single-quoted broke such values (literal ${PATH} → broken PATH - // → install.sh's `env bash` failed with exit 127). - - return envs -} - -func generatePersistentContainerEnvVars(feat features.Feature) []string { - if len(feat.ContainerEnv) == 0 { - return nil - } - keys := make([]string, 0, len(feat.ContainerEnv)) - for k := range feat.ContainerEnv { - keys = append(keys, k) - } - sort.Strings(keys) - envs := make([]string, 0, len(keys)) - for _, k := range keys { - envs = append(envs, fmt.Sprintf("%s=%q", k, feat.ContainerEnv[k])) - } - return envs -} - -func normalizeFeatureValue(v interface{}) interface{} { - if _, ok := v.(map[string]interface{}); ok { - return true - } - return v -} - -func shellSingleQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" -} diff --git a/internal/imagemeta/extend_nodocker_held_test.go b/internal/imagemeta/extend_nodocker_held_test.go new file mode 100644 index 0000000..f81e8cf --- /dev/null +++ b/internal/imagemeta/extend_nodocker_held_test.go @@ -0,0 +1,92 @@ +package imagemeta + +import ( + "strings" + "testing" + + "github.com/devcontainers/cli/internal/features" +) + +// TestParseFeatureFileInfoIgnoresNonObjectOptions: only an options object has +// keys to order (TS Object.keys of feature.options in getFeatureValueDefaults, +// reference/src/spec-configuration/containerFeaturesConfiguration.ts); absent, +// null or array options yield no order. +func TestParseFeatureFileInfoIgnoresNonObjectOptions(t *testing.T) { + for _, doc := range []string{`{"id":"x"}`, `{"id":"x","options":null}`, `{"id":"x","options":["a","b"]}`} { + info, err := ParseFeatureFileInfo([]byte(doc)) + if err != nil { + t.Fatalf("%s: %v", doc, err) + } + if len(info.OptionOrder) != 0 { + t.Errorf("%s: option order = %q, want none", doc, info.OptionOrder) + } + } +} + +// TestFeatureEnvVariablesJoinArraysAsJS: an array option value is written as a +// JS template literal writes it (TS getFeatureEnvVariables, +// containerFeaturesConfiguration.ts): elements joined by commas, null as empty. +func TestFeatureEnvVariablesJoinArraysAsJS(t *testing.T) { + feat := features.Feature{ + ID: "x", + Value: map[string]any{"list": []any{float64(1), "a", nil, true}}, + Options: map[string]any{"list": map[string]any{"type": "string"}}, + } + got := FeatureEnvVariables(feat, FeatureFileInfo{OptionOrder: []string{"list"}}) + if want := []string{`LIST="1,a,,true"`}; strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestFeatureInstallWrapperScriptRenameWarning: TS getFeatureInstallWrapperScript +// (containerFeaturesConfiguration.ts) warns about a rename only when the Feature +// has legacyIds, a currentId, and an id other than that currentId. +func TestFeatureInstallWrapperScriptRenameWarning(t *testing.T) { + const warning = `(!) WARNING: This feature has been renamed. Please update the reference in devcontainer.json to "new".` + tests := []struct { + name string + legacyIDs []string + id string + currentID string + want bool + }{ + {"renamed", []string{"older"}, "old", "new", true}, + {"no legacy ids", nil, "old", "new", false}, + {"no current id", []string{"older"}, "old", "", false}, + {"already the current id", []string{"older"}, "new", "new", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + feat := features.Feature{ID: tt.id, LegacyIds: tt.legacyIDs} + script := FeatureInstallWrapperScript(feat, &features.Set{}, FeatureFileInfo{CurrentID: tt.currentID}, nil) + if got := strings.Contains(script, "has been renamed"); got != tt.want { + t.Errorf("rename warning = %v, want %v:\n%s", got, tt.want, script) + } + if tt.want && !strings.Contains(script, "echo '"+warning+"'") { + t.Errorf("script lacks %q:\n%s", warning, script) + } + }) + } +} + +// TestGenerateFeaturesBuildOptionsWritesASingleContainerEnv: TS +// generateContainerEnvs (containerFeaturesConfiguration.ts) writes one ENV line +// per variable - a lone variable too - escaping quotes and backslashes, and +// dollar signs only for the config's containerEnv (#{containerEnvMetadata}, +// containerFeatures.ts getFeaturesBuildOptions). +func TestGenerateFeaturesBuildOptionsWritesASingleContainerEnv(t *testing.T) { + fss := []*features.Set{{ + SourceInfo: &features.OCISource{ID: "go"}, + Features: []features.Feature{{ID: "go", ConsecutiveId: "go_0", Value: true, ContainerEnv: map[string]string{"PATH": "/x:${PATH}"}}}, + }} + got := GenerateFeaturesBuildOptions(FeaturesBuildParams{ + BaseName: "base", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true, + ConfigContainerEnv: map[string]string{"GREETING": `a"b\c$d`}, + }).DockerfileContent + if want := "ENV PATH=\"/x:${PATH}\"\nRUN --mount=type=bind"; !strings.Contains(got, want) { + t.Errorf("Dockerfile lacks the Feature's ENV line %q:\n%s", want, got) + } + if want := "\n\nENV GREETING=\"a\\\"b\\\\c\\$d\"\n"; !strings.HasSuffix(got, want) { + t.Errorf("Dockerfile does not end with the config's ENV line %q:\n%s", want, got) + } +} diff --git a/internal/imagemeta/extend_test.go b/internal/imagemeta/extend_test.go index 478f913..823891b 100644 --- a/internal/imagemeta/extend_test.go +++ b/internal/imagemeta/extend_test.go @@ -1,182 +1,15 @@ package imagemeta import ( + "encoding/json" + "os" + "path/filepath" "strings" "testing" "github.com/devcontainers/cli/internal/features" ) -func TestGenerateExtendImageBuild(t *testing.T) { - tests := []struct { - name string - baseImage string - featureSets []*features.Set - metadata []Entry - containerUser string - remoteUser string - useBuildKit bool - check func(t *testing.T, info *ExtendImageBuildInfo) - }{ - { - name: "NoFeatures", - baseImage: "ubuntu:22.04", - featureSets: nil, - metadata: []Entry{{ID: "test", RemoteUser: "vscode"}}, - containerUser: "vscode", - remoteUser: "vscode", - useBuildKit: false, - check: func(t *testing.T, info *ExtendImageBuildInfo) { - if info.OverrideTarget != "dev_containers_target_stage" { - t.Errorf("target = %q", info.OverrideTarget) - } - if info.BuildArgs["_DEV_CONTAINERS_BASE_IMAGE"] != "ubuntu:22.04" { - t.Errorf("build arg = %q", info.BuildArgs["_DEV_CONTAINERS_BASE_IMAGE"]) - } - if !strings.Contains(info.DockerfileContent, "devcontainer.metadata") { - t.Error("should contain metadata label") - } - if !strings.Contains(info.DockerfileContent, "dev_containers_target_stage") { - t.Error("should contain target stage") - } - }, - }, - { - name: "WithFeatures", - baseImage: "ubuntu:22.04", - featureSets: []*features.Set{ - { - SourceInfo: &features.OCISource{UserID: "ghcr.io/devcontainers/features/go:1"}, - Features: []features.Feature{{ID: "go", Version: "1.21", Value: true}}, - }, - { - SourceInfo: &features.OCISource{UserID: "ghcr.io/devcontainers/features/node:1"}, - Features: []features.Feature{{ID: "node", Version: "18", Value: "18"}}, - }, - }, - metadata: []Entry{{ID: "go"}, {ID: "node"}}, - containerUser: "vscode", - remoteUser: "vscode", - useBuildKit: true, - check: func(t *testing.T, info *ExtendImageBuildInfo) { - df := info.DockerfileContent - - // Should have install scripts for both features - if !strings.Contains(df, "_dev_container_feature_0/install.sh") { - t.Error("missing feature 0 install") - } - if !strings.Contains(df, "_dev_container_feature_1/install.sh") { - t.Error("missing feature 1 install") - } - - // Should have USER root - if !strings.Contains(df, "USER root") { - t.Error("should switch to root for install") - } - - // Should restore user - if !strings.Contains(df, "USER vscode") { - t.Error("should restore user after install") - } - - // Build-only env vars should be scoped to RUN, not persisted as final ENV. - if strings.Contains(df, "\nENV _CONTAINER_USER=") { - t.Error("should not persist _CONTAINER_USER as ENV") - } - if strings.Contains(df, "\nENV _REMOTE_USER=") { - t.Error("should not persist _REMOTE_USER as ENV") - } - if !strings.Contains(df, "_CONTAINER_USER='vscode' _REMOTE_USER='vscode'") { - t.Error("missing scoped feature install env vars") - } - // upstream #331: _REMOTE_USER_HOME must be resolved per-RUN from - // /etc/passwd (not omitted), so common-utils-created users resolve. - if !strings.Contains(df, `_REMOTE_USER_HOME="$(`) || !strings.Contains(df, "getent passwd 'vscode'") { - t.Errorf("missing per-RUN _REMOTE_USER_HOME getent resolution:\n%s", df) - } - - // Should have metadata label - if !strings.Contains(df, "devcontainer.metadata") { - t.Error("missing metadata label") - } - - // BuildKit context - if _, ok := info.BuildKitContexts["dev_containers_feature_content_source"]; !ok { - t.Error("missing BuildKit context") - } - }, - }, - { - name: "FeatureEnvScopedToInstall", - // Regression: feature option env vars (and _REMOTE_USER/_CONTAINER_USER) - // must apply to install.sh, not to the preceding chmod. In POSIX sh, - // `KEY=v chmod ... && install.sh` scopes KEY to chmod only, so the options - // never reach install.sh. - baseImage: "ubuntu:22.04", - featureSets: []*features.Set{ - { - SourceInfo: &features.OCISource{UserID: "ghcr.io/devcontainers/features/go:1"}, - Features: []features.Feature{{ID: "go", Version: "1.21", Value: true}}, - }, - }, - metadata: []Entry{{ID: "go"}}, - containerUser: "vscode", - remoteUser: "node", - useBuildKit: true, - check: func(t *testing.T, info *ExtendImageBuildInfo) { - df := info.DockerfileContent - - // Env assignments must sit between "&&" and install.sh (applied to install.sh). - if !strings.Contains(df, "&& _CONTAINER_USER='vscode' _REMOTE_USER='node'") { - t.Errorf("feature env not applied to install.sh:\n%s", df) - } - // And must NOT prefix the chmod (the original bug). - if strings.Contains(df, "_REMOTE_USER='node' chmod") { - t.Errorf("feature env wrongly scoped to chmod:\n%s", df) - } - }, - }, - { - name: "RootUser", - baseImage: "ubuntu", - featureSets: []*features.Set{ - { - SourceInfo: &features.OCISource{UserID: "feat"}, - Features: []features.Feature{{ID: "feat"}}, - }, - }, - metadata: nil, - containerUser: "root", - remoteUser: "root", - useBuildKit: false, - check: func(t *testing.T, info *ExtendImageBuildInfo) { - df := info.DockerfileContent - - // Should NOT have USER root → USER root (redundant) - // Already root, so no "USER root" restore needed - lines := strings.Split(df, "\n") - userCount := 0 - for _, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), "USER ") { - userCount++ - } - } - // Should have exactly 1 USER root (for the install), and NOT restore - if userCount != 1 { - t.Errorf("expected 1 USER statement, got %d", userCount) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - info := GenerateExtendImageBuild(tt.baseImage, tt.featureSets, tt.metadata, tt.containerUser, tt.remoteUser, tt.useBuildKit, nil, nil) - tt.check(t, info) - }) - } -} - func TestGetSafeID(t *testing.T) { tests := []struct { input string @@ -197,112 +30,163 @@ func TestGetSafeID(t *testing.T) { } } -func TestGenerateFeatureBuildEnvVars(t *testing.T) { - feat := features.Feature{ - ID: "go", - Value: true, - ContainerEnv: map[string]string{ - "GOPATH": "/go", - }, - } - - envs := generateFeatureBuildEnvVars(feat, "vscode", "node") +// tsFeaturesBuildCase is testdata/ts-features-build//input.json: the +// arguments the TS functions were called with. The expected files next to it +// were produced by the TS getContainerFeaturesBaseDockerFile, getFeatureLayers, +// getDevcontainerMetadataLabel, generateContainerEnvs, +// getFeatureInstallWrapperScript and getFeatureEnvVariables (internalVersion 2) +// through testdata/ts-features-build/gen.ts: +// +// cd reference && ./node_modules/.bin/esbuild ../internal/imagemeta/testdata/ts-features-build/gen.ts \ +// --bundle --platform=node --main-fields=module,main --outfile=/tmp/gen.js +// node /tmp/gen.js < ../internal/imagemeta/testdata/ts-features-build//input.json +type tsFeaturesBuildCase struct { + UseBuildKit bool `json:"useBuildKit"` + ContainerUser string `json:"containerUser"` + RemoteUser string `json:"remoteUser"` + Metadata []Entry `json:"metadata"` + ConfigContainerEnv map[string]string `json:"configContainerEnv"` + FeatureSets []struct { + SourceInformation struct { + Type string `json:"type"` + UserFeatureID string `json:"userFeatureId"` + UserFeatureIDWithoutVersion string `json:"userFeatureIdWithoutVersion"` + } `json:"sourceInformation"` + Features []json.RawMessage `json:"features"` + } `json:"featureSets"` +} - foundBuildArg := false - foundGopath := false - foundContainerUser := false - for _, e := range envs { - if strings.HasPrefix(e, "_BUILD_ARG_GO=") { - foundBuildArg = true - } - if strings.HasPrefix(e, "GOPATH=") { - foundGopath = true - } - if strings.HasPrefix(e, "_CONTAINER_USER=") { - foundContainerUser = true - } - } - if !foundBuildArg { - t.Error("missing _BUILD_ARG_GO") - } - if !foundContainerUser { - t.Error("missing _CONTAINER_USER") +func loadTSFeaturesBuildCase(t *testing.T, name string) (tsFeaturesBuildCase, []*features.Set, map[string]FeatureFileInfo) { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "ts-features-build", name, "input.json")) + if err != nil { + t.Fatal(err) } - // containerEnv must NOT be inlined into the build env vars — it is emitted as - // an ENV instruction before the install RUN so Docker expands ${VAR} refs. - if foundGopath { - t.Error("containerEnv (GOPATH) should not be in the inline build env vars") + var c tsFeaturesBuildCase + if err := json.Unmarshal(data, &c); err != nil { + t.Fatal(err) } - // ...it is emitted via the persistent-ENV path instead. - persist := generatePersistentContainerEnvVars(feat) - foundGopathEnv := false - for _, e := range persist { - if strings.HasPrefix(e, "GOPATH=") { - foundGopathEnv = true + infos := map[string]FeatureFileInfo{} + var sets []*features.Set + for _, s := range c.FeatureSets { + set := &features.Set{InternalVersion: "2"} + switch s.SourceInformation.Type { + case "oci": + set.SourceInfo = &features.OCISource{Type: "oci", UserID: s.SourceInformation.UserFeatureID, UserFeatureIdWithoutVersion: s.SourceInformation.UserFeatureIDWithoutVersion} + case "file-path": + set.SourceInfo = &features.LocalSource{LocalPath: s.SourceInformation.UserFeatureID, UserID: s.SourceInformation.UserFeatureID} + default: + t.Fatalf("unsupported source type %q", s.SourceInformation.Type) } + for _, raw := range s.Features { + var f features.Feature + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + info, err := ParseFeatureFileInfo(raw) + if err != nil { + t.Fatal(err) + } + infos[f.ConsecutiveId] = info + set.Features = append(set.Features, f) + } + sets = append(sets, set) } - if !foundGopathEnv { - t.Error("containerEnv (GOPATH) should be emitted as a persistent ENV") - } + return c, sets, infos } -func TestGenerateFeatureBuildEnvVars_MapValueNormalizesToTrue(t *testing.T) { - feat := features.Feature{ - ID: "hello", - Value: map[string]interface{}{ - "greeting": "howdy", - }, - Options: map[string]interface{}{ - "greeting": map[string]interface{}{ - "type": "string", - "default": "hello", - }, - }, - UserOptions: map[string]interface{}{ - "greeting": "howdy", - }, +func readGolden(t *testing.T, parts ...string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(append([]string{"testdata", "ts-features-build"}, parts...)...)) + if err != nil { + t.Fatal(err) } + return string(data) +} - envs := generateFeatureBuildEnvVars(feat, "vscode", "node") - all := strings.Join(envs, "\n") - - if !strings.Contains(all, "_BUILD_ARG_HELLO='true'") { - t.Fatalf("expected normalized main build arg, got %q", all) - } - if !strings.Contains(all, "GREETING='howdy'") { - t.Fatalf("expected option env var, got %q", all) - } - if strings.Contains(all, `{"greeting":"howdy"}`) { - t.Fatalf("unexpected raw JSON map in env vars: %q", all) +// TestGenerateFeaturesBuildOptions_MatchesTS compares the generated features +// Dockerfile, each Feature's devcontainer-features.env and its +// devcontainer-features-install.sh with the TS output for the same inputs, with +// BuildKit build contexts and with the temporary content image. +func TestGenerateFeaturesBuildOptions_MatchesTS(t *testing.T) { + for _, name := range []string{"buildkit", "no-buildkit"} { + t.Run(name, func(t *testing.T) { + c, sets, infos := loadTSFeaturesBuildCase(t, name) + info := GenerateFeaturesBuildOptions(FeaturesBuildParams{ + BaseName: "base", + FeatureSets: sets, + Metadata: c.Metadata, + ContainerUser: c.ContainerUser, + RemoteUser: c.RemoteUser, + ImageUser: "imguser", + UseBuildKitContexts: c.UseBuildKit, + ConfigContainerEnv: c.ConfigContainerEnv, + }) + if want := readGolden(t, name, "Dockerfile"); info.DockerfileContent != want { + t.Errorf("Dockerfile differs from TS:\n--- got\n%s\n--- want\n%s", info.DockerfileContent, want) + } + for _, set := range sets { + for _, f := range set.Features { + vars := FeatureEnvVariables(f, infos[f.ConsecutiveId]) + if got, want := strings.Join(vars, "\n"), readGolden(t, name, f.ConsecutiveId, FeatureEnvFile); got != want { + t.Errorf("%s env differs from TS:\n--- got\n%s\n--- want\n%s", f.ConsecutiveId, got, want) + } + got := FeatureInstallWrapperScript(f, set, infos[f.ConsecutiveId], vars) + if want := readGolden(t, name, f.ConsecutiveId, FeatureInstallWrapperFile); got != want { + t.Errorf("%s install wrapper differs from TS:\n--- got\n%s\n--- want\n%s", f.ConsecutiveId, got, want) + } + } + } + wantArgs := map[string]string{ + "_DEV_CONTAINERS_BASE_IMAGE": "base", + "_DEV_CONTAINERS_IMAGE_USER": "imguser", + "_DEV_CONTAINERS_FEATURE_CONTENT_SOURCE": ComposeFeatureContentImage, + } + if len(info.BuildArgs) != len(wantArgs) { + t.Errorf("build args = %v, want %v", info.BuildArgs, wantArgs) + } + for k, v := range wantArgs { + if info.BuildArgs[k] != v { + t.Errorf("build arg %s = %q, want %q", k, info.BuildArgs[k], v) + } + } + _, hasCtx := info.BuildKitContexts["dev_containers_feature_content_source"] + if hasCtx != c.UseBuildKit { + t.Errorf("build contexts = %v with BuildKit %v", info.BuildKitContexts, c.UseBuildKit) + } + if info.OverrideTarget != "dev_containers_target_stage" { + t.Errorf("target = %q", info.OverrideTarget) + } + }) } } -func TestGeneratePersistentContainerEnvVars(t *testing.T) { - envs := generatePersistentContainerEnvVars(features.Feature{ - ContainerEnv: map[string]string{ - "DOCKER_BUILDKIT": "1", - }, - }) - - if len(envs) != 1 { - t.Fatalf("envs len = %d, want 1", len(envs)) +// TS getFeaturesBuildOptions dockerfilePrefixContent: the syntax line (if any) +// followed by a newline and the global ARG. +func TestGenerateFeaturesBuildOptions_Prefix(t *testing.T) { + fss := []*features.Set{{SourceInfo: &features.OCISource{}, Features: []features.Feature{{ID: "go", ConsecutiveId: "go_0"}}}} + if got := GenerateFeaturesBuildOptions(FeaturesBuildParams{FeatureSets: fss, Syntax: "docker/dockerfile:1.4"}).DockerfilePrefixContent; got != "# syntax=docker/dockerfile:1.4\nARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" { + t.Errorf("prefix with syntax = %q", got) } - if envs[0] != `DOCKER_BUILDKIT="1"` { - t.Fatalf("envs[0] = %q", envs[0]) + if got := GenerateFeaturesBuildOptions(FeaturesBuildParams{FeatureSets: fss}).DockerfilePrefixContent; got != "\nARG _DEV_CONTAINERS_BASE_IMAGE=scratch\n" { + t.Errorf("prefix without syntax = %q", got) + } + // The image user defaults to root, as TS imageBuildInfo.user does. + if got := GenerateFeaturesBuildOptions(FeaturesBuildParams{FeatureSets: fss}).BuildArgs["_DEV_CONTAINERS_IMAGE_USER"]; got != "root" { + t.Errorf("image user = %q", got) } } -// TestExtend_SyntaxDirectiveForBuildContexts verifies the fix: when the feature -// build uses --build-context (useBuildKitContexts), the generated Dockerfile must -// declare a frontend that supports build contexts (docker/dockerfile >= 1.4) as -// its first line, or the build fails on an older default frontend. +// TestExtend_SyntaxDirectiveForBuildContexts: the syntax the caller chose is +// the first line, and a build-context build declares the feature-content +// context; without a syntax no directive is forced. func TestExtend_SyntaxDirectiveForBuildContexts(t *testing.T) { fss := []*features.Set{{ SourceInfo: &features.OCISource{ID: "go"}, - Features: []features.Feature{{ID: "go", Version: "1.21", Value: true}}, + Features: []features.Feature{{ID: "go", ConsecutiveId: "go_0", Version: "1.21", Value: true}}, }} - withCtx := GenerateExtendImageBuild("base:img", fss, nil, "root", "root", true, nil, nil) + withCtx := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true, Syntax: "docker/dockerfile:1.4"}) full := withCtx.DockerfilePrefixContent + withCtx.DockerfileContent if !strings.HasPrefix(full, "# syntax=docker/dockerfile:1.4\n") { t.Errorf("build-context build must start with the syntax directive, got:\n%s", full) @@ -311,36 +195,81 @@ func TestExtend_SyntaxDirectiveForBuildContexts(t *testing.T) { t.Error("expected the feature-content build context to be declared") } - // Without build contexts, no syntax directive is forced. - noCtx := GenerateExtendImageBuild("base:img", fss, nil, "root", "root", false, nil, nil) + noCtx := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root"}) if strings.Contains(noCtx.DockerfilePrefixContent, "syntax=") { t.Errorf("non-context build must not force a syntax directive: %q", noCtx.DockerfilePrefixContent) } } -// TestExtend_BuildSecretsMountedIntoInstall guards #1078: build-secret ids are -// mounted into each feature-install RUN as --mount=type=secret,id=, and -// absent when no secrets are provided. +// TestExtend_BuildSecretsMountedIntoInstall guards #1078 (no TS equivalent): +// build-secret ids are mounted into each feature-install RUN as +// --mount=type=secret,id=, and absent when no secrets are provided. func TestExtend_BuildSecretsMountedIntoInstall(t *testing.T) { fss := []*features.Set{{ SourceInfo: &features.OCISource{ID: "go"}, - Features: []features.Feature{{ID: "go", Version: "1.21", Value: true}}, + Features: []features.Feature{{ID: "go", ConsecutiveId: "go_0", Version: "1.21", Value: true}}, }} - withSecrets := GenerateExtendImageBuild("base:img", fss, nil, "root", "root", true, nil, []string{"NPM_TOKEN", "GH_TOKEN"}) - df := withSecrets.DockerfileContent - if !strings.Contains(df, "RUN --mount=type=secret,id=NPM_TOKEN --mount=type=secret,id=GH_TOKEN ") { - t.Errorf("feature install RUN missing secret mounts:\n%s", df) + withSecrets := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true, SecretIDs: []string{"NPM_TOKEN", "GH_TOKEN"}}) + if want := "RUN --mount=type=bind,from=dev_containers_feature_content_source,source=go_0,target=/tmp/build-features-src/go_0 --mount=type=secret,id=NPM_TOKEN --mount=type=secret,id=GH_TOKEN \\\n"; !strings.Contains(withSecrets.DockerfileContent, want) { + t.Errorf("BuildKit feature install RUN missing secret mounts:\n%s", withSecrets.DockerfileContent) + } + noCtx := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", SecretIDs: []string{"NPM_TOKEN"}}) + if want := "RUN --mount=type=secret,id=NPM_TOKEN chmod -R 0755 /tmp/dev-container-features/go_0 \\\n"; !strings.Contains(noCtx.DockerfileContent, want) { + t.Errorf("content-image feature install RUN missing secret mounts:\n%s", noCtx.DockerfileContent) } - noSecrets := GenerateExtendImageBuild("base:img", fss, nil, "root", "root", true, nil, nil) + noSecrets := GenerateFeaturesBuildOptions(FeaturesBuildParams{BaseName: "base:img", FeatureSets: fss, ContainerUser: "root", RemoteUser: "root", UseBuildKitContexts: true}) if strings.Contains(noSecrets.DockerfileContent, "type=secret") { t.Errorf("no secrets provided, but a secret mount leaked in:\n%s", noSecrets.DockerfileContent) } +} + +// TS getFeatureValueObject: option defaults in document order, overlaid with +// the user's object; a string or boolean value sets "version" only when the +// Feature declares that option. +func TestFeatureEnvVariables(t *testing.T) { + opts := map[string]any{ + "version": map[string]any{"type": "string", "default": "lts"}, + "install": map[string]any{"type": "boolean", "default": true}, + "noDef": map[string]any{"type": "string"}, + "list": map[string]any{"type": "string", "default": nil}, + } + info := FeatureFileInfo{OptionOrder: []string{"version", "install", "noDef", "list"}} + tests := []struct { + name string + value any + opts map[string]any + want []string + }{ + {"object", map[string]any{"install": false, "extra-opt": 1.5}, opts, []string{`VERSION="lts"`, `INSTALL="false"`, `LIST="null"`, `EXTRA_OPT="1.5"`}}, + {"string", "18", opts, []string{`VERSION="18"`, `INSTALL="true"`, `LIST="null"`}}, + {"true with version", true, opts, []string{`VERSION="true"`, `INSTALL="true"`, `LIST="null"`}}, + {"string without version", "18", map[string]any{"a": map[string]any{"default": "x"}}, []string{`A="x"`}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + order := info + if _, ok := tt.opts["version"]; !ok { + order = FeatureFileInfo{OptionOrder: []string{"a"}} + } + got := FeatureEnvVariables(features.Feature{ID: "x", Value: tt.value, Options: tt.opts}, order) + if strings.Join(got, "\n") != strings.Join(tt.want, "\n") { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} - // Compose variant mounts secrets too. - compose := GenerateExtendImageBuildForCompose("base_stage", fss, nil, "root", "root", nil, []string{"TOK"}) - if !strings.Contains(compose, "RUN --mount=type=secret,id=TOK ") { - t.Errorf("compose feature install RUN missing secret mount:\n%s", compose) +func TestParseFeatureFileInfo(t *testing.T) { + info, err := ParseFeatureFileInfo([]byte(`{"id":"x","deprecated":true,"currentId":"y","options":{"zeta":{},"alpha":{"default":1},"mid":{}}}`)) + if err != nil { + t.Fatal(err) + } + if strings.Join(info.OptionOrder, ",") != "zeta,alpha,mid" || !info.Deprecated || info.CurrentID != "y" { + t.Errorf("info = %+v", info) + } + if _, err := ParseFeatureFileInfo([]byte(`[]`)); err == nil { + t.Error("a non-object document must be an error") } } diff --git a/internal/imagemeta/merge.go b/internal/imagemeta/merge.go index ffd2f9a..f663656 100644 --- a/internal/imagemeta/merge.go +++ b/internal/imagemeta/merge.go @@ -1,7 +1,11 @@ package imagemeta import ( + "cmp" + "encoding/json" "fmt" + "maps" + "slices" "strconv" "strings" ) @@ -9,53 +13,59 @@ import ( // MergedConfig holds the result of merging devcontainer.json config with // image metadata entries. Lifecycle hooks are accumulated as arrays. type MergedConfig struct { - ConfigFilePath interface{} `json:"configFilePath,omitempty"` + ConfigFilePath any `json:"configFilePath,omitempty"` // Image is part of the mergedConfiguration output (spread from the config in // TS), but NOT part of the devcontainer.metadata label entry — it is set by the // caller from the config, not derived from metadata entries. - Image string `json:"image,omitempty"` - RemoteUser string `json:"remoteUser,omitempty"` - ContainerUser string `json:"containerUser,omitempty"` - UserEnvProbe string `json:"userEnvProbe,omitempty"` - OverrideCommand *bool `json:"overrideCommand,omitempty"` - Init *bool `json:"init"` - Privileged *bool `json:"privileged"` - WaitFor string `json:"waitFor,omitempty"` - ShutdownAction string `json:"shutdownAction,omitempty"` - HostRequirements interface{} `json:"hostRequirements,omitempty"` - UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` - CapAdd []string `json:"capAdd,omitempty"` - SecurityOpt []string `json:"securityOpt,omitempty"` - PortsAttributes map[string]interface{} `json:"portsAttributes"` - OtherPortsAttributes interface{} `json:"otherPortsAttributes,omitempty"` - ForwardPorts []interface{} `json:"forwardPorts,omitempty"` - RunArgs []string `json:"runArgs,omitempty"` - ContainerEnv map[string]string `json:"containerEnv"` - RemoteEnv map[string]*string `json:"remoteEnv"` - Customizations map[string][]interface{} `json:"customizations,omitempty"` - Mounts []interface{} `json:"mounts,omitempty"` - Entrypoints []string `json:"entrypoints,omitempty"` - OnCreateCommands []interface{} `json:"onCreateCommands"` - UpdateContentCommands []interface{} `json:"updateContentCommands"` - PostCreateCommands []interface{} `json:"postCreateCommands"` - PostStartCommands []interface{} `json:"postStartCommands"` - PostAttachCommands []interface{} `json:"postAttachCommands"` + Image string `json:"image,omitempty"` + RemoteUser string `json:"remoteUser,omitempty"` + ContainerUser string `json:"containerUser,omitempty"` + UserEnvProbe string `json:"userEnvProbe,omitempty"` + OverrideCommand *bool `json:"overrideCommand,omitempty"` + Init *bool `json:"init"` + Privileged *bool `json:"privileged"` + WaitFor string `json:"waitFor,omitempty"` + ShutdownAction string `json:"shutdownAction,omitempty"` + HostRequirements any `json:"hostRequirements,omitempty"` + UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` + CapAdd []string `json:"capAdd,omitempty"` + SecurityOpt []string `json:"securityOpt,omitempty"` + PortsAttributes map[string]any `json:"portsAttributes"` + OtherPortsAttributes any `json:"otherPortsAttributes,omitempty"` + ForwardPorts []any `json:"forwardPorts,omitempty"` + RunArgs []string `json:"runArgs,omitempty"` + ContainerEnv map[string]string `json:"containerEnv"` + RemoteEnv map[string]*string `json:"remoteEnv"` + Customizations map[string][]any `json:"customizations,omitempty"` + Mounts []any `json:"mounts,omitempty"` + Entrypoints []string `json:"entrypoints,omitempty"` + OnCreateCommands []any `json:"onCreateCommands"` + // CommandOrigins is, per hook name, where each of its commands comes from - + // a Feature's id or "devcontainer.json" - in the order of the commands, as + // TS lifecycleCommandOriginMapFromMetadata records it for error messages. + CommandOrigins map[string][]string `json:"-"` + UpdateContentCommands []any `json:"updateContentCommands"` + PostCreateCommands []any `json:"postCreateCommands"` + PostStartCommands []any `json:"postStartCommands"` + PostAttachCommands []any `json:"postAttachCommands"` } // MergeConfiguration merges image metadata entries with the devcontainer config. // Entries are processed in order; the last entry has highest priority for scalars. // This matches the TS mergeConfiguration() in imageMetadata.ts. func MergeConfiguration(entries []Entry) *MergedConfig { + entries = nonEmptyEntries(entries) m := &MergedConfig{ ContainerEnv: make(map[string]string), RemoteEnv: make(map[string]*string), - PortsAttributes: make(map[string]interface{}), - Customizations: make(map[string][]interface{}), - OnCreateCommands: []interface{}{}, - UpdateContentCommands: []interface{}{}, - PostCreateCommands: []interface{}{}, - PostStartCommands: []interface{}{}, - PostAttachCommands: []interface{}{}, + PortsAttributes: make(map[string]any), + Customizations: make(map[string][]any), + OnCreateCommands: []any{}, + UpdateContentCommands: []any{}, + PostCreateCommands: []any{}, + PostStartCommands: []any{}, + PostAttachCommands: []any{}, + CommandOrigins: map[string][]string{}, } // Process entries in order. For lifecycle hooks, iterate all. @@ -63,21 +73,18 @@ func MergeConfiguration(entries []Entry) *MergedConfig { for _, e := range entries { // Lifecycle hooks — collect every truthy command (TS `if (command)`), so an // empty-string command is skipped rather than emitted as a spurious "" entry. - if isNonEmptyCommand(e.OnCreateCommand) { - m.OnCreateCommands = append(m.OnCreateCommands, e.OnCreateCommand) - } - if isNonEmptyCommand(e.UpdateContentCommand) { - m.UpdateContentCommands = append(m.UpdateContentCommands, e.UpdateContentCommand) - } - if isNonEmptyCommand(e.PostCreateCommand) { - m.PostCreateCommands = append(m.PostCreateCommands, e.PostCreateCommand) - } - if isNonEmptyCommand(e.PostStartCommand) { - m.PostStartCommands = append(m.PostStartCommands, e.PostStartCommand) - } - if isNonEmptyCommand(e.PostAttachCommand) { - m.PostAttachCommands = append(m.PostAttachCommands, e.PostAttachCommand) + origin := cmp.Or(e.ID, "devcontainer.json") // only Features have ids in the metadata + collect := func(hook string, list *[]any, command any) { + if isNonEmptyCommand(command) { + *list = append(*list, command) + m.CommandOrigins[hook] = append(m.CommandOrigins[hook], origin) + } } + collect("onCreateCommand", &m.OnCreateCommands, e.OnCreateCommand) + collect("updateContentCommand", &m.UpdateContentCommands, e.UpdateContentCommand) + collect("postCreateCommand", &m.PostCreateCommands, e.PostCreateCommand) + collect("postStartCommand", &m.PostStartCommands, e.PostStartCommand) + collect("postAttachCommand", &m.PostAttachCommands, e.PostAttachCommand) // Entrypoints — collect ALL non-empty entrypoints (TS collectOrUndefined has // NO de-dup): two features that register the same entrypoint script must both @@ -94,12 +101,8 @@ func MergeConfiguration(entries []Entry) *MergedConfig { m.Mounts = append(m.Mounts, e.Mounts...) // Maps — merge (later wins) - for k, v := range e.ContainerEnv { - m.ContainerEnv[k] = v - } - for k, v := range e.RemoteEnv { - m.RemoteEnv[k] = v - } + maps.Copy(m.ContainerEnv, e.ContainerEnv) + maps.Copy(m.RemoteEnv, e.RemoteEnv) // customizations are grouped by key into arrays (one entry per metadata // entry that sets that key), matching TS. This preserves every feature's // extensions/settings instead of last-wins-clobbering them. @@ -107,9 +110,7 @@ func MergeConfiguration(entries []Entry) *MergedConfig { m.Customizations[k] = append(m.Customizations[k], v) } // portsAttributes — later entries override matching keys (Object.assign). - for k, v := range e.PortsAttributes { - m.PortsAttributes[k] = v - } + maps.Copy(m.PortsAttributes, e.PortsAttributes) } // init/privileged are OR across all entries (true if any entry requests it), @@ -128,8 +129,8 @@ func MergeConfiguration(entries []Entry) *MergedConfig { m.Privileged = &privilegedVal // Scalars — last non-empty wins (iterate in reverse) - for i := len(entries) - 1; i >= 0; i-- { - e := entries[i] + for _, e := range slices.Backward(entries) { + if m.RemoteUser == "" && e.RemoteUser != "" { m.RemoteUser = e.RemoteUser } @@ -174,20 +175,20 @@ func MergeConfiguration(entries []Entry) *MergedConfig { // isNonEmptyCommand reports whether a lifecycle command is "truthy" the way TS // treats it: a nil or empty-string command is skipped; arrays/objects are kept. -func isNonEmptyCommand(cmd interface{}) bool { +func isNonEmptyCommand(cmd any) bool { return cmd != nil && cmd != "" } // mountTarget returns the container-side target of a mount (object or string // spec), used as the de-dup key. A string with no target= key is its own key so // distinct strings never collide. -func mountTarget(mnt interface{}) string { +func mountTarget(mnt any) string { switch v := mnt.(type) { - case map[string]interface{}: + case map[string]any: t, _ := v["target"].(string) return t case string: - for _, part := range strings.Split(v, ",") { + for part := range strings.SplitSeq(v, ",") { k, val, ok := strings.Cut(part, "=") if !ok { continue @@ -202,15 +203,12 @@ func mountTarget(mnt interface{}) string { return "" } -func dedupeMountsByTarget(mounts []interface{}) []interface{} { - if len(mounts) == 0 { - return mounts - } +func dedupeMountsByTarget(mounts []any) []any { lastIdx := make(map[string]int, len(mounts)) for i, mnt := range mounts { lastIdx[mountTarget(mnt)] = i } - out := make([]interface{}, 0, len(mounts)) + var out []any // nil without mounts, as the merge leaves Mounts then for i, mnt := range mounts { if lastIdx[mountTarget(mnt)] == i { out = append(out, mnt) @@ -219,33 +217,26 @@ func dedupeMountsByTarget(mounts []interface{}) []interface{} { return out } -// forwardPortNumber returns (N, true) for a numeric port (number or numeric -// string is NOT treated as a number by TS — only actual JSON numbers are). -func forwardPortNumber(p interface{}) (int, bool) { +// forwardPortKey returns the de-dup key of a forwarded port: "localhost:N" for +// a numeric port N (a numeric string is NOT treated as a number by TS — only +// actual JSON numbers are), the port itself as a string otherwise. +func forwardPortKey(p any) string { switch v := p.(type) { case float64: - return int(v), true + return fmt.Sprintf("localhost:%d", int(v)) case int: - return v, true + return fmt.Sprintf("localhost:%d", v) case int64: - return int(v), true + return fmt.Sprintf("localhost:%d", v) } - return 0, false + return fmt.Sprintf("%v", p) } -func dedupeForwardPorts(ports []interface{}) []interface{} { - if len(ports) == 0 { - return ports - } +func dedupeForwardPorts(ports []any) []any { seen := make(map[string]bool, len(ports)) var keys []string for _, p := range ports { - key := "" - if n, ok := forwardPortNumber(p); ok { - key = fmt.Sprintf("localhost:%d", n) - } else { - key = fmt.Sprintf("%v", p) - } + key := forwardPortKey(p) if !seen[key] { seen[key] = true keys = append(keys, key) @@ -253,7 +244,7 @@ func dedupeForwardPorts(ports []interface{}) []interface{} { } // Denormalize: a "localhost:N" key becomes the number N; anything else stays a // string — matching TS's final .map(port => /localhost:\d+/.test ? parseInt : port). - out := make([]interface{}, 0, len(keys)) + var out []any // nil without ports, as the merge leaves ForwardPorts then for _, k := range keys { if n, ok := localhostPort(k); ok { out = append(out, float64(n)) @@ -312,7 +303,7 @@ func parseBytes(s string) int64 { return n * unit } -func hrFloat(v interface{}) float64 { +func hrFloat(v any) float64 { switch n := v.(type) { case float64: return n @@ -324,7 +315,7 @@ func hrFloat(v interface{}) float64 { return 0 } -func hrString(v interface{}) string { +func hrString(v any) string { if s, ok := v.(string); ok { return s } @@ -334,12 +325,17 @@ func hrString(v interface{}) string { // mergeHostRequirements takes the per-field max of cpus/memory/storage and merges // gpu across all entries, matching TS mergeHostRequirements. Returns nil when no // entry sets any requirement. -func mergeHostRequirements(entries []Entry) interface{} { +func mergeHostRequirements(entries []Entry) any { + if len(entries) == 0 { + // TS takes Math.max() of no values, -Infinity, which is truthy: cpus + // serializes as null and memory/storage as the string "-Infinity". + return map[string]any{"cpus": nil, "memory": "-Infinity", "storage": "-Infinity"} + } var cpus float64 var memory, storage int64 - var gpu interface{} + var gpu any for _, e := range entries { - hr, ok := e.HostRequirements.(map[string]interface{}) + hr, ok := e.HostRequirements.(map[string]any) if !ok { continue } @@ -357,7 +353,7 @@ func mergeHostRequirements(entries []Entry) interface{} { if cpus == 0 && memory == 0 && storage == 0 && gpu == nil { return nil } - res := map[string]interface{}{"cpus": cpus} + res := map[string]any{"cpus": cpus} if memory != 0 { res["memory"] = strconv.FormatInt(memory, 10) } @@ -373,7 +369,7 @@ func mergeHostRequirements(entries []Entry) interface{} { // mergeGPURequirements ports TS mergeGpuRequirements: undefined/false yields the // other; two "optional" stay "optional"; otherwise take the per-field max of the // object forms (a non-object contributes {}). -func mergeGPURequirements(a, b interface{}) interface{} { +func mergeGPURequirements(a, b any) any { if a == nil || a == false { return b } @@ -383,8 +379,8 @@ func mergeGPURequirements(a, b interface{}) interface{} { if a == "optional" && b == "optional" { return "optional" } - ao, _ := a.(map[string]interface{}) - bo, _ := b.(map[string]interface{}) + ao, _ := a.(map[string]any) + bo, _ := b.(map[string]any) cores := hrFloat(ao["cores"]) if c := hrFloat(bo["cores"]); c > cores { cores = c @@ -393,7 +389,7 @@ func mergeGPURequirements(a, b interface{}) interface{} { if m := parseBytes(hrString(bo["memory"])); m > mem { mem = m } - res := map[string]interface{}{} + res := map[string]any{} if cores != 0 { res["cores"] = cores } @@ -417,3 +413,16 @@ func appendUnique(dst, src []string) []string { } return dst } + +// nonEmptyEntries drops the entries that set no property, as TS +// getDevcontainerMetadata does (`.filter(config => Object.keys(config).length)`): +// a config with nothing to contribute adds no entry to the merge or the label. +func nonEmptyEntries(entries []Entry) []Entry { + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + if data, err := json.Marshal(e); err != nil || string(data) != "{}" { + out = append(out, e) + } + } + return out +} diff --git a/internal/imagemeta/merge_bugs_test.go b/internal/imagemeta/merge_bugs_test.go index bd43a21..389b75b 100644 --- a/internal/imagemeta/merge_bugs_test.go +++ b/internal/imagemeta/merge_bugs_test.go @@ -13,13 +13,13 @@ import ( // REPLACE the earlier one (TS mergeMounts de-dupes by target, keeping last). func TestMerge_MountsDedupByTarget(t *testing.T) { m := MergeConfiguration([]Entry{ - {Mounts: []interface{}{map[string]interface{}{"source": "vol-a", "target": "/data", "type": "volume"}}}, - {Mounts: []interface{}{map[string]interface{}{"source": "vol-b", "target": "/data", "type": "volume"}}}, + {Mounts: []any{map[string]any{"source": "vol-a", "target": "/data", "type": "volume"}}}, + {Mounts: []any{map[string]any{"source": "vol-b", "target": "/data", "type": "volume"}}}, }) if len(m.Mounts) != 1 { t.Fatalf("want 1 mount after target de-dup, got %d: %v", len(m.Mounts), m.Mounts) } - if src := m.Mounts[0].(map[string]interface{})["source"]; src != "vol-b" { + if src := m.Mounts[0].(map[string]any)["source"]; src != "vol-b" { t.Errorf("kept the wrong mount: source=%v, want vol-b (last wins)", src) } } @@ -40,26 +40,26 @@ func TestMerge_EntrypointsNoDedup(t *testing.T) { // N and "localhost:N" as the same port. func TestMerge_ForwardPortsDedup(t *testing.T) { m := MergeConfiguration([]Entry{ - {ForwardPorts: []interface{}{float64(3000)}}, - {ForwardPorts: []interface{}{float64(3000), float64(8080)}}, + {ForwardPorts: []any{float64(3000)}}, + {ForwardPorts: []any{float64(3000), float64(8080)}}, }) - want := []interface{}{float64(3000), float64(8080)} + want := []any{float64(3000), float64(8080)} if !reflect.DeepEqual(m.ForwardPorts, want) { t.Errorf("forwardPorts = %v, want %v", m.ForwardPorts, want) } // number 3000 and string "localhost:3000" are the same port -> one entry (a number). m2 := MergeConfiguration([]Entry{ - {ForwardPorts: []interface{}{float64(3000)}}, - {ForwardPorts: []interface{}{"localhost:3000"}}, + {ForwardPorts: []any{float64(3000)}}, + {ForwardPorts: []any{"localhost:3000"}}, }) - if !reflect.DeepEqual(m2.ForwardPorts, []interface{}{float64(3000)}) { + if !reflect.DeepEqual(m2.ForwardPorts, []any{float64(3000)}) { t.Errorf("forwardPorts = %v, want [3000]", m2.ForwardPorts) } // a host:port string is not a plain port and stays a string. - m3 := MergeConfiguration([]Entry{{ForwardPorts: []interface{}{"db:5432", "db:5432"}}}) - if !reflect.DeepEqual(m3.ForwardPorts, []interface{}{"db:5432"}) { + m3 := MergeConfiguration([]Entry{{ForwardPorts: []any{"db:5432", "db:5432"}}}) + if !reflect.DeepEqual(m3.ForwardPorts, []any{"db:5432"}) { t.Errorf("forwardPorts = %v, want [db:5432]", m3.ForwardPorts) } } @@ -68,11 +68,11 @@ func TestMerge_ForwardPortsDedup(t *testing.T) { // another must both survive, with memory normalized to bytes (TS per-field max). func TestMerge_HostRequirementsPerFieldMax(t *testing.T) { m := MergeConfiguration([]Entry{ - {HostRequirements: map[string]interface{}{"cpus": float64(4)}}, - {HostRequirements: map[string]interface{}{"memory": "8gb"}}, - {HostRequirements: map[string]interface{}{"cpus": float64(2), "memory": "4gb"}}, + {HostRequirements: map[string]any{"cpus": float64(4)}}, + {HostRequirements: map[string]any{"memory": "8gb"}}, + {HostRequirements: map[string]any{"cpus": float64(2), "memory": "4gb"}}, }) - hr, ok := m.HostRequirements.(map[string]interface{}) + hr, ok := m.HostRequirements.(map[string]any) if !ok { t.Fatalf("hostRequirements = %v (%T), want a map", m.HostRequirements, m.HostRequirements) } @@ -84,9 +84,19 @@ func TestMerge_HostRequirementsPerFieldMax(t *testing.T) { } // No entry sets hostRequirements -> nil. - if got := MergeConfiguration([]Entry{{}}).HostRequirements; got != nil { + if got := MergeConfiguration([]Entry{{RemoteUser: "x"}}).HostRequirements; got != nil { t.Errorf("hostRequirements = %v, want nil", got) } + // No entry at all - an empty one is dropped, as TS getDevcontainerMetadata + // drops it - and TS takes Math.max() of nothing, -Infinity: cpus null, + // memory and storage "-Infinity". + want := map[string]any{"cpus": nil, "memory": "-Infinity", "storage": "-Infinity"} + if got := MergeConfiguration([]Entry{{}}).HostRequirements; !reflect.DeepEqual(got, want) { + t.Errorf("hostRequirements = %v, want %v", got, want) + } + if got := GenerateMetadataLabel([]Entry{{}, {RemoteUser: "x"}}); got != `[{"remoteUser":"x"}]` { + t.Errorf("label = %s, want the empty entry dropped", got) + } } // TestMerge_LifecycleSkipsEmptyString: an empty-string command is not collected. diff --git a/internal/imagemeta/merge_nodocker_held_test.go b/internal/imagemeta/merge_nodocker_held_test.go new file mode 100644 index 0000000..302dda5 --- /dev/null +++ b/internal/imagemeta/merge_nodocker_held_test.go @@ -0,0 +1,98 @@ +package imagemeta + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestMergeConfigurationScalarsTakeTheLastEntryThatSetsThem holds every scalar +// of TS mergeConfiguration (reference/src/spec-node/imageMetadata.ts) that is +// `reversed.find(entry => entry.)`: the last entry setting it wins and +// an earlier entry never overrides it, and overrideCommand/updateRemoteUserUID +// (found by `typeof === 'boolean'`) keep a later false over an earlier true. +// runArgs is not a TS metadata property (TS spreads it from the config); Go +// carries it on the config's entry with the same last-entry-wins rule. +func TestMergeConfigurationScalarsTakeTheLastEntryThatSetsThem(t *testing.T) { + m := MergeConfiguration(mustEntries(t, `[ + {"remoteUser": "u0", "containerUser": "c0", "userEnvProbe": "none", "waitFor": "onCreateCommand", + "shutdownAction": "none", "otherPortsAttributes": {"onAutoForward": "ignore"}, "runArgs": ["--a"], + "overrideCommand": true, "updateRemoteUserUID": true}, + {"remoteUser": "u1", "containerUser": "c1", "userEnvProbe": "loginShell", "waitFor": "postCreateCommand", + "shutdownAction": "stopContainer", "otherPortsAttributes": {"onAutoForward": "silent"}, "runArgs": ["--b"], + "overrideCommand": false, "updateRemoteUserUID": false}, + {"containerEnv": {"A": "1"}} + ]`)) + got := map[string]any{ + "remoteUser": m.RemoteUser, + "containerUser": m.ContainerUser, + "userEnvProbe": m.UserEnvProbe, + "waitFor": m.WaitFor, + "shutdownAction": m.ShutdownAction, + "otherPortsAttributes": m.OtherPortsAttributes, + "runArgs": m.RunArgs, + "overrideCommand": m.OverrideCommand, + "updateRemoteUserUID": m.UpdateRemoteUserUID, + } + data, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + want := `{"containerUser":"c1","otherPortsAttributes":{"onAutoForward":"silent"},"overrideCommand":false,` + + `"remoteUser":"u1","runArgs":["--b"],"shutdownAction":"stopContainer","updateRemoteUserUID":false,` + + `"userEnvProbe":"loginShell","waitFor":"postCreateCommand"}` + if string(data) != want { + t.Errorf("merged scalars = %s\nwant %s", data, want) + } +} + +// TestMergeConfigurationHostRequirementsAsTSSerializesThem compares the merged +// hostRequirements, serialized, with what TS mergeHostRequirements and +// mergeGpuRequirements (reference/src/spec-node/imageMetadata.ts) produce for +// the same metadata through JSON.stringify: cpus is always present (0 when no +// entry sets it), memory/storage are the decimal byte count of the largest +// value or absent when 0, and gpu follows mergeGpuRequirements. +func TestMergeConfigurationHostRequirementsAsTSSerializesThem(t *testing.T) { + tests := []struct { + name, entries, want string + }{ + {"largest storage first, no cpus", `[{"hostRequirements": {"memory": "4gb", "storage": "32gb"}}, {"hostRequirements": {"storage": "16gb"}}]`, + `{"cpus":0,"memory":"4294967296","storage":"34359738368"}`}, + {"cpus only", `[{"hostRequirements": {"cpus": 2}}]`, `{"cpus":2}`}, + {"gpu true alone", `[{"hostRequirements": {"gpu": true}}]`, `{"cpus":0,"gpu":true}`}, + {"gpu true then no gpu", `[{"hostRequirements": {"gpu": true}}, {"hostRequirements": {"cpus": 1}}]`, `{"cpus":1,"gpu":true}`}, + {"gpu optional twice", `[{"hostRequirements": {"gpu": "optional"}}, {"hostRequirements": {"gpu": "optional"}}]`, `{"cpus":0,"gpu":"optional"}`}, + {"gpu optional then true", `[{"hostRequirements": {"gpu": "optional"}}, {"hostRequirements": {"gpu": true}}]`, `{"cpus":0,"gpu":{}}`}, + {"gpu true then optional", `[{"hostRequirements": {"gpu": true}}, {"hostRequirements": {"gpu": "optional"}}]`, `{"cpus":0,"gpu":{}}`}, + {"gpu cores only", `[{"hostRequirements": {"gpu": {"cores": 2}}}, {"hostRequirements": {"gpu": true}}]`, `{"cpus":0,"gpu":{"cores":2}}`}, + {"gpu memory only", `[{"hostRequirements": {"gpu": {"memory": "2gb"}}}, {"hostRequirements": {"gpu": true}}]`, `{"cpus":0,"gpu":{"memory":"2147483648"}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(MergeConfiguration(mustEntries(t, tt.entries)).HostRequirements) + if err != nil { + t.Fatal(err) + } + if string(data) != tt.want { + t.Errorf("hostRequirements = %s, want %s", data, tt.want) + } + }) + } +} + +// TestMergeConfigurationLeavesMountsUnsetWithoutMounts: TS mergeMounts and +// mergeForwardPorts (reference/src/spec-node/imageMetadata.ts) yield undefined +// when no entry has mounts or forwardPorts, and a single mount is kept as is. +func TestMergeConfigurationLeavesMountsUnsetWithoutMounts(t *testing.T) { + none := MergeConfiguration([]Entry{{RemoteUser: "x"}}) + if none.Mounts != nil { + t.Errorf("mounts = %#v, want nil", none.Mounts) + } + if none.ForwardPorts != nil { + t.Errorf("forwardPorts = %#v, want nil (TS mergeForwardPorts: undefined)", none.ForwardPorts) + } + one := []any{"type=volume,source=v,target=/data"} + if got := MergeConfiguration([]Entry{{Mounts: one}}).Mounts; !reflect.DeepEqual(got, one) { + t.Errorf("mounts = %#v, want %#v", got, one) + } +} diff --git a/internal/imagemeta/merge_test.go b/internal/imagemeta/merge_test.go index 19b4165..30ce8cb 100644 --- a/internal/imagemeta/merge_test.go +++ b/internal/imagemeta/merge_test.go @@ -142,8 +142,8 @@ func TestMergeConfiguration(t *testing.T) { // Each entry's customizations[key] is collected into an array, matching TS, // so no feature's settings/extensions are clobbered. entries: []Entry{ - {Customizations: map[string]interface{}{"vscode": map[string]interface{}{"extensions": []interface{}{"a"}}}}, - {Customizations: map[string]interface{}{"vscode": map[string]interface{}{"extensions": []interface{}{"b"}}}}, + {Customizations: map[string]any{"vscode": map[string]any{"extensions": []any{"a"}}}}, + {Customizations: map[string]any{"vscode": map[string]any{"extensions": []any{"b"}}}}, }, check: func(t *testing.T, m *MergedConfig) { vscode := m.Customizations["vscode"] diff --git a/internal/imagemeta/metadata.go b/internal/imagemeta/metadata.go index e904714..3cbfd57 100644 --- a/internal/imagemeta/metadata.go +++ b/internal/imagemeta/metadata.go @@ -13,33 +13,33 @@ const MetadataLabel = "devcontainer.metadata" // Entry represents one metadata entry (from config, feature, or base image). // Matches the TS ImageMetadataEntry. type Entry struct { - ID string `json:"id,omitempty"` - Init *bool `json:"init,omitempty"` - Privileged *bool `json:"privileged,omitempty"` - CapAdd []string `json:"capAdd,omitempty"` - SecurityOpt []string `json:"securityOpt,omitempty"` - Mounts []interface{} `json:"mounts,omitempty"` - RunArgs []string `json:"runArgs,omitempty"` - ContainerEnv map[string]string `json:"containerEnv,omitempty"` - ContainerUser string `json:"containerUser,omitempty"` - RemoteEnv map[string]*string `json:"remoteEnv,omitempty"` - RemoteUser string `json:"remoteUser,omitempty"` - UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` - UserEnvProbe string `json:"userEnvProbe,omitempty"` - OverrideCommand *bool `json:"overrideCommand,omitempty"` - ForwardPorts []interface{} `json:"forwardPorts,omitempty"` - PortsAttributes map[string]interface{} `json:"portsAttributes,omitempty"` - OtherPortsAttributes interface{} `json:"otherPortsAttributes,omitempty"` - OnCreateCommand interface{} `json:"onCreateCommand,omitempty"` - UpdateContentCommand interface{} `json:"updateContentCommand,omitempty"` - PostCreateCommand interface{} `json:"postCreateCommand,omitempty"` - PostStartCommand interface{} `json:"postStartCommand,omitempty"` - PostAttachCommand interface{} `json:"postAttachCommand,omitempty"` - WaitFor string `json:"waitFor,omitempty"` - ShutdownAction string `json:"shutdownAction,omitempty"` - HostRequirements interface{} `json:"hostRequirements,omitempty"` - Customizations map[string]interface{} `json:"customizations,omitempty"` - Entrypoint string `json:"entrypoint,omitempty"` + ID string `json:"id,omitempty"` + Init *bool `json:"init,omitempty"` + Privileged *bool `json:"privileged,omitempty"` + CapAdd []string `json:"capAdd,omitempty"` + SecurityOpt []string `json:"securityOpt,omitempty"` + Mounts []any `json:"mounts,omitempty"` + RunArgs []string `json:"runArgs,omitempty"` + ContainerEnv map[string]string `json:"containerEnv,omitempty"` + ContainerUser string `json:"containerUser,omitempty"` + RemoteEnv map[string]*string `json:"remoteEnv,omitempty"` + RemoteUser string `json:"remoteUser,omitempty"` + UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"` + UserEnvProbe string `json:"userEnvProbe,omitempty"` + OverrideCommand *bool `json:"overrideCommand,omitempty"` + ForwardPorts []any `json:"forwardPorts,omitempty"` + PortsAttributes map[string]any `json:"portsAttributes,omitempty"` + OtherPortsAttributes any `json:"otherPortsAttributes,omitempty"` + OnCreateCommand any `json:"onCreateCommand,omitempty"` + UpdateContentCommand any `json:"updateContentCommand,omitempty"` + PostCreateCommand any `json:"postCreateCommand,omitempty"` + PostStartCommand any `json:"postStartCommand,omitempty"` + PostAttachCommand any `json:"postAttachCommand,omitempty"` + WaitFor string `json:"waitFor,omitempty"` + ShutdownAction string `json:"shutdownAction,omitempty"` + HostRequirements any `json:"hostRequirements,omitempty"` + Customizations map[string]any `json:"customizations,omitempty"` + Entrypoint string `json:"entrypoint,omitempty"` } // ReadMetadataFromLabels extracts metadata entries from Docker image/container labels. @@ -69,6 +69,7 @@ func ReadMetadataFromLabels(labels map[string]string, logger log.Logger) []Entry // yields "". ReadMetadataFromLabels still accepts a bare object for backward // compatibility with labels written by older tooling. func GenerateMetadataLabel(entries []Entry) string { + entries = nonEmptyEntries(entries) if len(entries) == 0 { return "" } diff --git a/internal/imagemeta/reference_oracle_test.go b/internal/imagemeta/reference_oracle_test.go index 12cf467..16d5c46 100644 --- a/internal/imagemeta/reference_oracle_test.go +++ b/internal/imagemeta/reference_oracle_test.go @@ -66,10 +66,10 @@ func TestOracle_DeduplicateMounts(t *testing.T) { if s, ok := merged.Mounts[0].(string); !ok || s != "source=source2,target=target2,type=volume" { t.Errorf("mounts[0] = %v, want the source2 string mount", merged.Mounts[0]) } - if m, ok := merged.Mounts[1].(map[string]interface{}); !ok || m["source"] != "source4" { + if m, ok := merged.Mounts[1].(map[string]any); !ok || m["source"] != "source4" { t.Errorf("mounts[1] = %v, want the source4 object", merged.Mounts[1]) } - if m, ok := merged.Mounts[2].(map[string]interface{}); !ok || m["source"] != "source5" { + if m, ok := merged.Mounts[2].(map[string]any); !ok || m["source"] != "source5" { t.Errorf("mounts[2] = %v, want the source5 object", merged.Mounts[2]) } } @@ -84,11 +84,11 @@ func TestOracle_MergeGPURequirements(t *testing.T) { { "hostRequirements": { "gpu": { "memory": "8gb" } } } ]`)) - hr, ok := merged.HostRequirements.(map[string]interface{}) + hr, ok := merged.HostRequirements.(map[string]any) if !ok { t.Fatalf("hostRequirements = %v (%T), want a map", merged.HostRequirements, merged.HostRequirements) } - gpu, ok := hr["gpu"].(map[string]interface{}) + gpu, ok := hr["gpu"].(map[string]any) if !ok { t.Fatalf("gpu = %v (%T), want a map", hr["gpu"], hr["gpu"]) } diff --git a/internal/imagemeta/spec_merge_test.go b/internal/imagemeta/spec_merge_test.go index 1452df4..88a6d3a 100644 --- a/internal/imagemeta/spec_merge_test.go +++ b/internal/imagemeta/spec_merge_test.go @@ -1,9 +1,8 @@ package imagemeta -import "testing" +import "slices" -func specBool(b bool) *bool { return &b } -func specStr(s string) *string { return &s } +import "testing" // TestSpecMetadataMergeRules locks the dev container specification's normative // metadata merge rules (containers.dev/implementors/spec, "Merge logic"): @@ -13,21 +12,21 @@ func specStr(s string) *string { return &s } func TestSpecMetadataMergeRules(t *testing.T) { // A feature entry (earlier) and the devcontainer.json entry (last). feature := Entry{ - Init: specBool(true), - Privileged: specBool(false), + Init: new(true), + Privileged: new(false), RemoteUser: "node", WaitFor: "onCreateCommand", CapAdd: []string{"SYS_PTRACE"}, - RemoteEnv: map[string]*string{"FOO": specStr("from-feature")}, + RemoteEnv: map[string]*string{"FOO": new("from-feature")}, OnCreateCommand: "feature-oncreate", } devcontainer := Entry{ - Init: specBool(false), // spec: booleans are OR — must NOT override the feature's true - Privileged: specBool(true), + Init: new(false), // spec: booleans are OR — must NOT override the feature's true + Privileged: new(true), RemoteUser: "vscode", // spec: scalars — last (devcontainer.json) wins WaitFor: "postCreateCommand", CapAdd: []string{"SYS_ADMIN", "SYS_PTRACE"}, // spec: arrays — union, no duplicates - RemoteEnv: map[string]*string{"FOO": specStr("from-config"), "BAR": specStr("c")}, + RemoteEnv: map[string]*string{"FOO": new("from-config"), "BAR": new("c")}, OnCreateCommand: "config-oncreate", } @@ -70,10 +69,5 @@ func TestSpecMetadataMergeRules(t *testing.T) { } func contains(xs []string, want string) bool { - for _, x := range xs { - if x == want { - return true - } - } - return false + return slices.Contains(xs, want) } diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/Dockerfile b/internal/imagemeta/testdata/ts-features-build/buildkit/Dockerfile new file mode 100644 index 0000000..9d9abc5 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/Dockerfile @@ -0,0 +1,55 @@ + + + + +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'vscode' || grep -E '^vscode|^[^:]*:[^:]*:vscode:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'dev\'o' || grep -E '^dev\'o|^[^:]*:[^:]*:dev\'o:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + +ENV PATH="/usr/local/node/bin:${PATH}" +ENV Q="a\"b\\c" +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=node_0,target=/tmp/build-features-src/node_0 \ + cp -ar /tmp/build-features-src/node_0 /tmp/dev-container-features \ + && chmod -R 0755 /tmp/dev-container-features/node_0 \ + && cd /tmp/dev-container-features/node_0 \ + && chmod +x ./devcontainer-features-install.sh \ + && ./devcontainer-features-install.sh \ + && rm -rf /tmp/dev-container-features/node_0 + + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=hello_1,target=/tmp/build-features-src/hello_1 \ + cp -ar /tmp/build-features-src/hello_1 /tmp/dev-container-features \ + && chmod -R 0755 /tmp/dev-container-features/hello_1 \ + && cd /tmp/dev-container-features/hello_1 \ + && chmod +x ./devcontainer-features-install.sh \ + && ./devcontainer-features-install.sh \ + && rm -rf /tmp/dev-container-features/hello_1 + + + + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER + +LABEL devcontainer.metadata="[ \ +{\"id\":\"ghcr.io/o/f/node:1\"}, \ +{\"remoteEnv\":{\"A\":\"\$x\\\"y\\\\z\"}} \ +]" + +ENV A="1" +ENV B="x\$y\"z" diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features-install.sh b/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features-install.sh new file mode 100644 index 0000000..f320824 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features-install.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "./hello" (Unknown) failed to install!' +} + +trap on_exit EXIT + +echo =========================================================================== +echo '(!) WARNING: Using the deprecated Feature "hello". This Feature will no longer receive any further updates/support. +(!) WARNING: This feature has been renamed. Please update the reference in devcontainer.json to "hello2".' +echo 'Feature : ./hello' +echo 'Description : ' +echo 'Id : Unknown' +echo 'Version : ' +echo 'Documentation : ' +echo 'Options :' +echo ' GREETING="hey" + FLAG="false"' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features.env b/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features.env new file mode 100644 index 0000000..8b011cf --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/hello_1/devcontainer-features.env @@ -0,0 +1,2 @@ +GREETING="hey" +FLAG="false" \ No newline at end of file diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/input.json b/internal/imagemeta/testdata/ts-features-build/buildkit/input.json new file mode 100644 index 0000000..72f2d67 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/input.json @@ -0,0 +1 @@ +{"useBuildKit": true, "containerUser": "vscode", "remoteUser": "dev'o", "label": "LABEL devcontainer.metadata=\"[]\"", "configContainerEnv": {"A": "1", "B": "x$y\"z"}, "featureSets": [{"internalVersion": "2", "sourceInformation": {"type": "oci", "userFeatureId": "ghcr.io/o/f/node:1", "userFeatureIdWithoutVersion": "ghcr.io/o/f/node"}, "features": [{"id": "node", "consecutiveId": "node_0", "name": "Node.js", "description": "Installs 'node'", "version": "1.2.3", "documentationURL": "https://x/doc", "value": "18", "options": {"version": {"type": "string", "default": "lts"}, "nvmVersion": {"type": "string", "default": "0.39"}}, "containerEnv": {"PATH": "/usr/local/node/bin:${PATH}", "Q": "a\"b\\c"}}]}, {"internalVersion": "2", "sourceInformation": {"type": "file-path", "userFeatureId": "./hello"}, "features": [{"id": "hello", "consecutiveId": "hello_1", "name": "./hello", "deprecated": true, "legacyIds": ["hi"], "currentId": "hello2", "value": {"greeting": "hey"}, "options": {"greeting": {"type": "string", "default": "hi"}, "flag": {"type": "boolean", "default": false}, "nodef": {"type": "string"}}}]}], "metadata": [{"id": "ghcr.io/o/f/node:1"}, {"remoteEnv": {"A": "$x\"y\\z"}}]} \ No newline at end of file diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features-install.sh b/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features-install.sh new file mode 100644 index 0000000..3e39307 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features-install.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "Node.js" (ghcr.io/o/f/node) failed to install! Look at the documentation at https://x/doc for help troubleshooting this error.' +} + +trap on_exit EXIT + +echo =========================================================================== + +echo 'Feature : Node.js' +echo 'Description : Installs '\''node'\''' +echo 'Id : ghcr.io/o/f/node' +echo 'Version : 1.2.3' +echo 'Documentation : https://x/doc' +echo 'Options :' +echo ' VERSION="18" + NVMVERSION="0.39"' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh diff --git a/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features.env b/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features.env new file mode 100644 index 0000000..a5621f7 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/buildkit/node_0/devcontainer-features.env @@ -0,0 +1,2 @@ +VERSION="18" +NVMVERSION="0.39" \ No newline at end of file diff --git a/internal/imagemeta/testdata/ts-features-build/gen.ts b/internal/imagemeta/testdata/ts-features-build/gen.ts new file mode 100644 index 0000000..db2bcf5 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/gen.ts @@ -0,0 +1,30 @@ +import { getContainerFeaturesBaseDockerFile, getFeatureLayers, getFeatureInstallWrapperScript, generateContainerEnvs, getFeatureValueObject, FeaturesConfig } from '../../../../reference/src/spec-configuration/containerFeaturesConfiguration'; +import { getDevcontainerMetadataLabel } from '../../../../reference/src/spec-node/imageMetadata'; +import { getSafeId } from '../../../../reference/src/spec-node/containerFeatures'; + +function getFeatureEnvVariables(f: any) { + const values: any = getFeatureValueObject(f); + const variables: string[] = []; + if (values) { + variables.push(...Object.keys(values).map(name => `${getSafeId(name)}="${values[name]}"`)); + } + return variables; +} + +const input = JSON.parse(require('fs').readFileSync(0, 'utf8')); +const fc: FeaturesConfig = { featureSets: input.featureSets, dstFolder: '/dst' }; +const useBK = input.useBuildKit; +const root = useBK ? '.' : '/tmp/build-features/'; +const dockerfile = getContainerFeaturesBaseDockerFile(root) + .replace('#{nonBuildKitFeatureContentFallback}', useBK ? '' : `FROM dev_container_feature_content_temp as dev_containers_feature_content_source`) + .replace('#{featureLayer}', getFeatureLayers(fc, input.containerUser, input.remoteUser, useBK, root)) + .replace('#{containerEnv}', '') + .replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel({ config: input.metadata, raw: input.metadata, substitute: (x: any) => x } as any)) + .replace('#{containerEnvMetadata}', generateContainerEnvs(input.configContainerEnv, true)); +const files: any = {}; +for (const s of fc.featureSets) for (const f of s.features) { + const vars = getFeatureEnvVariables(f); + files[f.consecutiveId + '/devcontainer-features.env'] = vars.join('\n'); + files[f.consecutiveId + '/devcontainer-features-install.sh'] = getFeatureInstallWrapperScript(f, s, vars); +} +process.stdout.write(JSON.stringify({ dockerfile, files })); diff --git a/internal/imagemeta/testdata/ts-features-build/no-buildkit/Dockerfile b/internal/imagemeta/testdata/ts-features-build/no-buildkit/Dockerfile new file mode 100644 index 0000000..3393dee --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/no-buildkit/Dockerfile @@ -0,0 +1,39 @@ + + +FROM dev_container_feature_content_temp as dev_containers_feature_content_source + +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source /tmp/build-features/devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/go_0 /tmp/dev-container-features/go_0 +RUN chmod -R 0755 /tmp/dev-container-features/go_0 \ +&& cd /tmp/dev-container-features/go_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh + + + + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER + + + + diff --git a/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features-install.sh b/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features-install.sh new file mode 100644 index 0000000..ca03bdf --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features-install.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "Go" (ghcr.io/devcontainers/features/go) failed to install!' +} + +trap on_exit EXIT + +echo =========================================================================== + +echo 'Feature : Go' +echo 'Description : ' +echo 'Id : ghcr.io/devcontainers/features/go' +echo 'Version : 1.3.0' +echo 'Documentation : ' +echo 'Options :' +echo ' VERSION="true" + GOLANGCILINTVERSION="latest"' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh diff --git a/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features.env b/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features.env new file mode 100644 index 0000000..0fa22eb --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/no-buildkit/go_0/devcontainer-features.env @@ -0,0 +1,2 @@ +VERSION="true" +GOLANGCILINTVERSION="latest" \ No newline at end of file diff --git a/internal/imagemeta/testdata/ts-features-build/no-buildkit/input.json b/internal/imagemeta/testdata/ts-features-build/no-buildkit/input.json new file mode 100644 index 0000000..bdd79e0 --- /dev/null +++ b/internal/imagemeta/testdata/ts-features-build/no-buildkit/input.json @@ -0,0 +1 @@ +{"useBuildKit": false, "containerUser": "root", "remoteUser": "root", "metadata": [], "configContainerEnv": null, "featureSets": [{"internalVersion": "2", "sourceInformation": {"type": "oci", "userFeatureId": "ghcr.io/devcontainers/features/go:1", "userFeatureIdWithoutVersion": "ghcr.io/devcontainers/features/go"}, "features": [{"id": "go", "consecutiveId": "go_0", "name": "Go", "version": "1.3.0", "value": true, "options": {"version": {"type": "string", "default": "latest"}, "golangciLintVersion": {"type": "string", "default": "latest"}}}]}]} \ No newline at end of file diff --git a/internal/jsonc/jsonc.go b/internal/jsonc/jsonc.go index b619d5c..cb9daa4 100644 --- a/internal/jsonc/jsonc.go +++ b/internal/jsonc/jsonc.go @@ -26,17 +26,38 @@ func Unmarshal(data []byte, v any) error { return json.Unmarshal(std, v) } -// Parse parses JSONC into a generic map. -func Parse(data []byte) (map[string]any, error) { - var m map[string]any - if err := Unmarshal(data, &m); err != nil { - return nil, err - } - return m, nil -} - // StripComments removes JSONC comments and trailing commas, // returning valid standard JSON bytes. func StripComments(data []byte) ([]byte, error) { return hujson.Standardize(stripBOM(data)) } + +// MemberNames returns the names of the members of the object at field of the +// top-level JSONC object, in the order the document lists them; nil when the +// document or the field is not an object. +func MemberNames(data []byte, field string) []string { + v, err := hujson.Parse(stripBOM(data)) + if err != nil { + return nil + } + top, ok := v.Value.(*hujson.Object) + if !ok { + return nil + } + for _, m := range top.Members { + if lit, ok := m.Name.Value.(hujson.Literal); ok && lit.String() == field { + obj, ok := m.Value.Value.(*hujson.Object) + if !ok { + return nil + } + names := make([]string, 0, len(obj.Members)) + for _, f := range obj.Members { + if lit, ok := f.Name.Value.(hujson.Literal); ok { + names = append(names, lit.String()) + } + } + return names + } + } + return nil +} diff --git a/internal/jsonc/jsonc_test.go b/internal/jsonc/jsonc_test.go index 718c07e..28053ff 100644 --- a/internal/jsonc/jsonc_test.go +++ b/internal/jsonc/jsonc_test.go @@ -76,17 +76,6 @@ func TestUnmarshal(t *testing.T) { } } -func TestParse(t *testing.T) { - input := []byte(`{"image": "ubuntu"}`) - m, err := Parse(input) - if err != nil { - t.Fatal(err) - } - if m["image"] != "ubuntu" { - t.Errorf("image = %v", m["image"]) - } -} - func TestStripComments(t *testing.T) { input := []byte(`{"image": "ubuntu" /* comment */}`) out, err := StripComments(input) diff --git a/internal/jsonc/members_test.go b/internal/jsonc/members_test.go new file mode 100644 index 0000000..a875477 --- /dev/null +++ b/internal/jsonc/members_test.go @@ -0,0 +1,26 @@ +package jsonc + +import ( + "slices" + "testing" +) + +// MemberNames keeps the document's order, which the disallowed-Feature check +// needs to report the first feature as TS Object.keys does; a field that is not +// an object, a missing field, or a document that is not an object gives nil. +func TestMemberNamesKeepsDocumentOrder(t *testing.T) { + doc := []byte("\ufeff{\n // comment\n \"image\": \"x\",\n \"features\": {\"z\": {}, \"a\": true, \"m\": {},},\n}") + if got := MemberNames(doc, "features"); !slices.Equal(got, []string{"z", "a", "m"}) { + t.Errorf("MemberNames = %v, want [z a m]", got) + } + for name, d := range map[string]string{ + "field not an object": `{"features": []}`, + "no field": `{"image": "x"}`, + "not an object": `[]`, + "not JSON": `{`, + } { + if got := MemberNames([]byte(d), "features"); got != nil { + t.Errorf("%s: MemberNames = %v, want nil", name, got) + } + } +} diff --git a/internal/lifecycle/hooks.go b/internal/lifecycle/hooks.go index 6a75697..4b68e00 100644 --- a/internal/lifecycle/hooks.go +++ b/internal/lifecycle/hooks.go @@ -4,6 +4,8 @@ package lifecycle import ( "errors" "fmt" + "maps" + "slices" "strings" "sync" @@ -23,18 +25,6 @@ const ( PhasePostAttach Phase = "postAttachCommand" ) -// AllPhases returns lifecycle phases in execution order. -func AllPhases() []Phase { - return []Phase{ - PhaseInitialize, - PhaseOnCreate, - PhaseUpdateContent, - PhasePostCreate, - PhasePostStart, - PhasePostAttach, - } -} - // RunOptions controls which lifecycle phases to execute. type RunOptions struct { SkipPostCreate bool @@ -53,12 +43,39 @@ type CommandExecutor interface { Exec(command string) error } -// HookError records which lifecycle phase failed while preserving the -// underlying command error for CLI-specific error envelopes. +// HookError records which lifecycle command failed - its phase, its name when +// it is one of an object's parallel commands, and the Feature id or +// "devcontainer.json" it comes from - while preserving the underlying command +// error for CLI-specific error envelopes. type HookError struct { - Phase Phase - Name string - Err error + Phase Phase + Name string + Origin string + Err error +} + +// Description is TS runLifecycleCommand's error description: +// "[ of ] from '> failed." +func (e *HookError) Description() string { + return e.label() + " from " + displayOrigin(e.Origin) + " failed." +} + +func (e *HookError) label() string { + if e.Name != "" { + return e.Name + " of " + string(e.Phase) + } + return string(e.Phase) +} + +// displayOrigin is how TS runLifecycleCommands names a command's origin. +func displayOrigin(origin string) string { + switch origin { + case "": + return "devcontainer.json" + case "devcontainer.json": + return origin + } + return "Feature '" + origin + "'" } func (e *HookError) Error() string { @@ -90,7 +107,7 @@ func RunHooks( phases := []struct { phase Phase - commands []interface{} + commands []any }{ {PhaseOnCreate, merged.OnCreateCommands}, {PhaseUpdateContent, merged.UpdateContentCommands}, @@ -100,7 +117,7 @@ func RunHooks( } for _, p := range phases { - if opts.SkipPostCreate && isPostCreatePhase(p.phase) { + if opts.SkipPostCreate { logger.Write(fmt.Sprintf("Skipping %s (skipPostCreate)", p.phase), log.LevelInfo) continue } @@ -116,8 +133,12 @@ func RunHooks( Status: "running", }) - for _, cmd := range p.commands { - if err := executeCommand(executor, logger, p.phase, cmd); err != nil { + for i, cmd := range p.commands { + var origin string + if origins := merged.CommandOrigins[string(p.phase)]; i < len(origins) { + origin = origins[i] + } + if err := executeCommand(executor, logger, p.phase, cmd, origin); err != nil { logger.Event(log.Event{ Type: "progress", Name: "Running " + string(p.phase) + "...", @@ -140,11 +161,9 @@ func RunHooks( } // Check stop conditions - if opts.Prebuild && (p.phase == PhaseOnCreate || p.phase == PhaseUpdateContent) { - if p.phase == PhaseUpdateContent { - logger.Write("Stopping after updateContentCommand (prebuild)", log.LevelInfo) - return nil - } + if opts.Prebuild && p.phase == PhaseUpdateContent { + logger.Write("Stopping after updateContentCommand (prebuild)", log.LevelInfo) + return nil } if opts.SkipNonBlocking && p.phase == waitFor { logger.Write(fmt.Sprintf("Stopping after %s (skipNonBlocking)", waitFor), log.LevelInfo) @@ -155,73 +174,68 @@ func RunHooks( return nil } -func isPostCreatePhase(p Phase) bool { - return p == PhaseOnCreate || p == PhaseUpdateContent || - p == PhasePostCreate || p == PhasePostStart || p == PhasePostAttach -} - // executeCommand runs a single lifecycle command entry. If the command is a // map (object syntax), entries are executed in parallel matching TS behavior -// (Promise.allSettled). All commands run to completion; errors are collected. -func executeCommand(executor CommandExecutor, logger log.Logger, phase Phase, cmd interface{}) error { - m, isMap := cmd.(map[string]interface{}) +// (Promise.allSettled): all commands run to completion, and the error names the +// first that failed. TS takes the first in the object's key order; the decoded +// map has none, so it is the first in sorted order (docs/DIVERGENCES.md). +func executeCommand(executor CommandExecutor, logger log.Logger, phase Phase, cmd any, origin string) error { + from := displayOrigin(origin) + m, isMap := cmd.(map[string]any) if !isMap { cmdStr := commandToString(cmd) if cmdStr == "" { return nil } - logger.Write(fmt.Sprintf("Running %s: %s", phase, cmdStr), log.LevelInfo) + logger.Write(fmt.Sprintf("Running the %s from %s...", phase, from), log.LevelInfo) if err := executor.Exec(cmdStr); err != nil { - return &HookError{Phase: phase, Err: err} + he := &HookError{Phase: phase, Origin: origin, Err: err} + logFailure(logger, he) + return he } return nil } - // Parallel execution for object syntax, matching TS Promise.allSettled. - type result struct { - name string - err error - } + logger.Write(fmt.Sprintf("Running the %s from %s...", phase, from), log.LevelInfo) + names := slices.Sorted(maps.Keys(m)) + errs := make([]error, len(names)) var wg sync.WaitGroup - results := make(chan result, len(m)) - - for name, val := range m { - cmdStr := commandToString(val) + for i, name := range names { + cmdStr := commandToString(m[name]) if cmdStr == "" { continue } - wg.Add(1) - go func(name, cmdStr string) { - defer wg.Done() - logger.Write(fmt.Sprintf("Running %s (%s): %s", phase, name, cmdStr), log.LevelInfo) - results <- result{name: name, err: executor.Exec(cmdStr)} - }(name, cmdStr) + wg.Go(func() { + // TS prints a parallel command's header with its output once it succeeded. + if errs[i] = executor.Exec(cmdStr); errs[i] == nil { + logger.Write(fmt.Sprintf("Running %s of %s from %s...", name, phase, from), log.LevelInfo) + } + }) } - wg.Wait() - close(results) - - var errs []error - for r := range results { - if r.err != nil { - // Wrap with %w (not %v) so the underlying *CommandError chain survives: - // errors.Join exposes Unwrap() []error, which errors.As walks, letting - // run_user_commands reach the CommandError just like the sequential path. - errs = append(errs, fmt.Errorf("%s: %w", r.name, r.err)) + for i, err := range errs { + if err != nil { + he := &HookError{Phase: phase, Name: names[i], Origin: origin, Err: err} + logFailure(logger, he) + return he } } - if len(errs) > 0 { - return &HookError{Phase: phase, Err: errors.Join(errs...)} - } return nil } +// logFailure writes TS's line for a command that exited non-zero. +func logFailure(logger log.Logger, he *HookError) { + if ce, ok := errors.AsType[*CommandError](he.Err); ok && ce.ExitCode != 0 { + logger.Write(fmt.Sprintf("%s from %s failed with exit code %d. Skipping any further user-provided commands.", he.label(), displayOrigin(he.Origin), ce.ExitCode), log.LevelError) + } +} + // commandToString converts a lifecycle command value to a shell string. -func commandToString(cmd interface{}) string { +func commandToString(cmd any) string { switch v := cmd.(type) { case string: return v - case []interface{}: + case []any: // Array form is argv: execute the arguments directly without shell // interpretation (no re-tokenization, no variable expansion). Since the // executor runs through /bin/sh -c, shell-quote each argument to preserve @@ -231,7 +245,7 @@ func commandToString(cmd interface{}) string { parts[i] = shellQuote(fmt.Sprintf("%v", p)) } return strings.Join(parts, " ") - case map[string]interface{}: + case map[string]any: // Maps are handled by executeCommand for parallel execution. // This case only fires if commandToString is called recursively // from within a map value. diff --git a/internal/lifecycle/hooks_nodocker_held_test.go b/internal/lifecycle/hooks_nodocker_held_test.go new file mode 100644 index 0000000..ac645d7 --- /dev/null +++ b/internal/lifecycle/hooks_nodocker_held_test.go @@ -0,0 +1,120 @@ +package lifecycle + +import ( + "slices" + "strings" + "sync" + "testing" + + "github.com/devcontainers/cli/internal/imagemeta" + "github.com/devcontainers/cli/internal/log" +) + +// lockedLog records Write lines; parallel commands write from goroutines. +type lockedLog struct { + log.Logger + mu sync.Mutex + lines []string +} + +func (l *lockedLog) Write(text string, _ ...log.Level) { + l.mu.Lock() + defer l.mu.Unlock() + l.lines = append(l.lines, text) +} + +func (l *lockedLog) has(s string) bool { + l.mu.Lock() + defer l.mu.Unlock() + return slices.Contains(l.lines, s) +} + +// orderExecutor records every command, and every AfterPostCreate call as +// "", in the order they happen. +type orderExecutor struct{ seen []string } + +func (o *orderExecutor) Exec(command string) error { + o.seen = append(o.seen, command) + return nil +} + +// skipPostAttach skips postAttachCommand alone, and the dotfiles hook runs once, +// right after postCreateCommand (TS runLifecycleHooks / installDotfiles). +func TestRunHooksSkipsOnlyPostAttachAndInstallsDotfilesAfterPostCreate(t *testing.T) { + merged := &imagemeta.MergedConfig{ + OnCreateCommands: []any{"onCreate"}, + UpdateContentCommands: []any{"updateContent"}, + PostCreateCommands: []any{"postCreate"}, + PostStartCommands: []any{"postStart"}, + PostAttachCommands: []any{"postAttach"}, + } + exec := &orderExecutor{} + opts := RunOptions{SkipPostAttach: true, AfterPostCreate: func() { exec.seen = append(exec.seen, "") }} + if err := RunHooks(log.Null, exec, merged, opts); err != nil { + t.Fatal(err) + } + want := []string{"onCreate", "updateContent", "postCreate", "", "postStart"} + if !slices.Equal(exec.seen, want) { + t.Errorf("ran %v, want %v", exec.seen, want) + } +} + +// A parallel command's "Running of from ..." header is +// printed once it succeeded, never for one that failed (TS runLifecycleCommand +// prints it with the command's output on success); the failure gets TS's +// "failed with exit code" line. +func TestParallelCommandHeaderOnlyForSuccess(t *testing.T) { + l := &lockedLog{Logger: log.Null} + cmd := map[string]any{"a": "ok-a", "b": "fails-b"} + if err := executeCommand(&cmdErrExecutor{failOn: "fails"}, l, PhasePostCreate, cmd, ""); err == nil { + t.Fatal("expected b's failure") + } + if !l.has("Running a of postCreateCommand from devcontainer.json...") { + t.Errorf("no header for the command that succeeded: %q", l.lines) + } + if l.has("Running b of postCreateCommand from devcontainer.json...") { + t.Errorf("header printed for the command that failed: %q", l.lines) + } + if !l.has("b of postCreateCommand from devcontainer.json failed with exit code 1. Skipping any further user-provided commands.") { + t.Errorf("no failure line: %q", l.lines) + } +} + +// codeExecutor fails every command with the given exit code. +type codeExecutor struct{ code int } + +func (c codeExecutor) Exec(command string) error { + return &CommandError{Command: command, ExitCode: c.code} +} + +// TS runLifecycleCommand writes "failed with exit code N" only when the error +// carries a code (`if (err?.code)`): exit code 1 is reported, 0 is not. +func TestFailureLineNeedsANonZeroExitCode(t *testing.T) { + const line = "postStartCommand from devcontainer.json failed with exit code 1. Skipping any further user-provided commands." + l := &lockedLog{Logger: log.Null} + _ = executeCommand(codeExecutor{code: 1}, l, PhasePostStart, "x", "") + if !l.has(line) { + t.Errorf("exit code 1: lines = %q, want %q", l.lines, line) + } + l = &lockedLog{Logger: log.Null} + _ = executeCommand(codeExecutor{code: 0}, l, PhasePostStart, "x", "") + for _, got := range l.lines { + if strings.Contains(got, "failed with exit code") { + t.Errorf("exit code 0 reported a failure line: %q", got) + } + } +} + +// commandToString drops empty and null values: a nested object value joins its +// non-empty commands with " && ", and null is no command at all. +func TestCommandToStringSkipsEmptyAndNull(t *testing.T) { + if got := commandToString(nil); got != "" { + t.Errorf("commandToString(nil) = %q, want empty", got) + } + if got := commandToString(map[string]any{"a": "", "b": "echo hi", "c": nil}); got != "echo hi" { + t.Errorf("commandToString with empty entries = %q, want %q", got, "echo hi") + } + if got := commandToString(map[string]any{"a": "x", "b": "y"}); got != "x && y" && got != "y && x" { + t.Errorf("commandToString of two commands = %q, want them joined by \" && \"", got) + } +} diff --git a/internal/lifecycle/hooks_test.go b/internal/lifecycle/hooks_test.go index bf84c75..f9a8f43 100644 --- a/internal/lifecycle/hooks_test.go +++ b/internal/lifecycle/hooks_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "errors" "fmt" + "slices" "strings" "testing" @@ -35,11 +36,11 @@ func TestRunHooks(t *testing.T) { { name: "all phases", merged: &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"echo onCreate"}, - UpdateContentCommands: []interface{}{"echo updateContent"}, - PostCreateCommands: []interface{}{"echo postCreate"}, - PostStartCommands: []interface{}{"echo postStart"}, - PostAttachCommands: []interface{}{"echo postAttach"}, + OnCreateCommands: []any{"echo onCreate"}, + UpdateContentCommands: []any{"echo updateContent"}, + PostCreateCommands: []any{"echo postCreate"}, + PostStartCommands: []any{"echo postStart"}, + PostAttachCommands: []any{"echo postAttach"}, }, opts: RunOptions{}, wantCount: 5, @@ -47,9 +48,9 @@ func TestRunHooks(t *testing.T) { { name: "skip post create", merged: &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"echo onCreate"}, - PostCreateCommands: []interface{}{"echo postCreate"}, - PostStartCommands: []interface{}{"echo postStart"}, + OnCreateCommands: []any{"echo onCreate"}, + PostCreateCommands: []any{"echo postCreate"}, + PostStartCommands: []any{"echo postStart"}, }, opts: RunOptions{SkipPostCreate: true}, wantCount: 0, @@ -57,8 +58,8 @@ func TestRunHooks(t *testing.T) { { name: "skip post attach", merged: &imagemeta.MergedConfig{ - PostAttachCommands: []interface{}{"echo postAttach"}, - PostStartCommands: []interface{}{"echo postStart"}, + PostAttachCommands: []any{"echo postAttach"}, + PostStartCommands: []any{"echo postStart"}, }, opts: RunOptions{SkipPostAttach: true}, // postStart runs, postAttach skipped @@ -67,9 +68,9 @@ func TestRunHooks(t *testing.T) { { name: "skip non-blocking", merged: &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"echo onCreate"}, - UpdateContentCommands: []interface{}{"echo updateContent"}, - PostCreateCommands: []interface{}{"echo postCreate"}, + OnCreateCommands: []any{"echo onCreate"}, + UpdateContentCommands: []any{"echo updateContent"}, + PostCreateCommands: []any{"echo postCreate"}, }, opts: RunOptions{SkipNonBlocking: true}, // Default waitFor is updateContentCommand → stops after that @@ -78,9 +79,9 @@ func TestRunHooks(t *testing.T) { { name: "prebuild", merged: &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"echo onCreate"}, - UpdateContentCommands: []interface{}{"echo updateContent"}, - PostCreateCommands: []interface{}{"echo postCreate"}, + OnCreateCommands: []any{"echo onCreate"}, + UpdateContentCommands: []any{"echo updateContent"}, + PostCreateCommands: []any{"echo postCreate"}, }, opts: RunOptions{Prebuild: true}, // Prebuild stops after updateContentCommand @@ -89,7 +90,7 @@ func TestRunHooks(t *testing.T) { { name: "command failure", merged: &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"echo ok", "fail-here"}, + OnCreateCommands: []any{"echo ok", "fail-here"}, }, opts: RunOptions{}, failOn: "fail-here", @@ -125,12 +126,12 @@ func TestRunHooks(t *testing.T) { func TestCommandToString(t *testing.T) { tests := []struct { - input interface{} + input any want string }{ {"echo hello", "echo hello"}, - {[]interface{}{"npm", "install"}, "'npm' 'install'"}, - {map[string]interface{}{"a": "cmd1", "b": "cmd2"}, ""}, // contains both but order varies + {[]any{"npm", "install"}, "'npm' 'install'"}, + {map[string]any{"a": "cmd1", "b": "cmd2"}, ""}, // contains both but order varies {nil, ""}, } @@ -141,7 +142,7 @@ func TestCommandToString(t *testing.T) { } if tt.want != "" && got != tt.want { // For map case, just verify both commands are present - if _, ok := tt.input.(map[string]interface{}); ok { + if _, ok := tt.input.(map[string]any); ok { if !strings.Contains(got, "cmd1") || !strings.Contains(got, "cmd2") { t.Errorf("commandToString(%v) = %q, expected both cmd1 and cmd2", tt.input, got) } @@ -229,7 +230,7 @@ func TestInstallDotfiles(t *testing.T) { func TestCommandToString_ArrayQuoted(t *testing.T) { // Array form must preserve each argument literally (no re-tokenization, no // variable expansion) via shell quoting. - got := commandToString([]interface{}{"echo", "hello world", "$HOME", "a'b"}) + got := commandToString([]any{"echo", "hello world", "$HOME", "a'b"}) want := `'echo' 'hello world' '$HOME' 'a'\''b'` if got != want { t.Errorf("commandToString(array) = %q, want %q", got, want) @@ -255,16 +256,15 @@ func (e *cmdErrExecutor) Exec(command string) error { // aggregate must still expose the underlying *CommandError via errors.As, so // run_user_commands can special-case it exactly like the sequential path. func TestExecuteCommand_ParallelPreservesCommandError(t *testing.T) { - cmd := map[string]interface{}{ + cmd := map[string]any{ "one": "run-one", "two": "run-two-fails", } - err := executeCommand(&cmdErrExecutor{failOn: "fails"}, log.Null, PhasePostCreate, cmd) + err := executeCommand(&cmdErrExecutor{failOn: "fails"}, log.Null, PhasePostCreate, cmd, "") if err == nil { t.Fatal("expected an error from the failing parallel hook") } - var hook *HookError - if !errors.As(err, &hook) { + if _, ok := errors.AsType[*HookError](err); !ok { t.Fatalf("expected a *HookError, got %T", err) } var cmdErr *CommandError @@ -275,3 +275,51 @@ func TestExecuteCommand_ParallelPreservesCommandError(t *testing.T) { t.Errorf("unexpected exit code: %d", cmdErr.ExitCode) } } + +// A failed command's error says which command it was and where it comes from, +// as TS runLifecycleCommand's ContainerError: the hook alone for a single +// command, " of " for one of an object's parallel commands, and +// "Feature ''" for a Feature's command. +func TestHookErrorDescribesTheCommandAndItsOrigin(t *testing.T) { + merged := &imagemeta.MergedConfig{ + PostCreateCommands: []any{"ok", map[string]any{"a": "ok", "b": "fails-b", "c": "fails-c"}}, + CommandOrigins: map[string][]string{"postCreateCommand": {"./myfeat", "devcontainer.json"}}, + } + err := RunHooks(log.Null, &cmdErrExecutor{failOn: "fails"}, merged, RunOptions{}) + he, ok := errors.AsType[*HookError](err) + if !ok || he.Description() != "b of postCreateCommand from devcontainer.json failed." { + t.Fatalf("parallel: err = %v, description = %q", err, he.Description()) + } + + merged = &imagemeta.MergedConfig{ + OnCreateCommands: []any{"fails-feature"}, + CommandOrigins: map[string][]string{"onCreateCommand": {"ghcr.io/o/f/x:1"}}, + } + err = RunHooks(log.Null, &cmdErrExecutor{failOn: "fails"}, merged, RunOptions{}) + if he, ok := errors.AsType[*HookError](err); !ok || he.Description() != "onCreateCommand from Feature 'ghcr.io/o/f/x:1' failed." { + t.Fatalf("feature: err = %v", err) + } + + // A command appended without an origin (the config's own) is devcontainer.json's. + merged = &imagemeta.MergedConfig{PostStartCommands: []any{"fails"}} + err = RunHooks(log.Null, &cmdErrExecutor{failOn: "fails"}, merged, RunOptions{}) + if he, ok := errors.AsType[*HookError](err); !ok || he.Description() != "postStartCommand from devcontainer.json failed." { + t.Fatalf("config: err = %v", err) + } +} + +// MergeConfiguration records each command's origin: a Feature entry's id, or +// devcontainer.json for the entry without one (TS lifecycleCommandOriginMapFromMetadata). +func TestMergeRecordsCommandOrigins(t *testing.T) { + m := imagemeta.MergeConfiguration([]imagemeta.Entry{ + {ID: "./myfeat", PostCreateCommand: "a"}, + {ID: "ghcr.io/o/f/x:1", PostCreateCommand: ""}, + {PostCreateCommand: "c", OnCreateCommand: "d"}, + }) + if got := m.CommandOrigins["postCreateCommand"]; !slices.Equal(got, []string{"./myfeat", "devcontainer.json"}) { + t.Errorf("postCreate origins = %v", got) + } + if got := m.CommandOrigins["onCreateCommand"]; !slices.Equal(got, []string{"devcontainer.json"}) { + t.Errorf("onCreate origins = %v", got) + } +} diff --git a/internal/lifecycle/initialize.go b/internal/lifecycle/initialize.go index 7f66555..1141997 100644 --- a/internal/lifecycle/initialize.go +++ b/internal/lifecycle/initialize.go @@ -3,8 +3,10 @@ package lifecycle import ( "context" "fmt" + "maps" "os" "os/exec" + "slices" "strings" "sync" @@ -42,7 +44,7 @@ func RunInitializeCommand(ctx context.Context, logger log.Logger, cmd *config.Li return err } logger.Write(fmt.Sprintf("Running initializeCommand: %s", resolved), log.LevelInfo) - return runHostCommand(ctx, resolved, workspaceFolder) + return runHostExec(ctx, []string{"/bin/sh", "-c", resolved}, workspaceFolder) } // Array form @@ -61,35 +63,33 @@ func RunInitializeCommand(ctx context.Context, logger log.Logger, cmd *config.Li // Object form (parallel commands) if m, ok := cmd.AsMap(); ok { logger.Write(fmt.Sprintf("Running %d parallel initializeCommand(s)...", len(m)), log.LevelInfo) + // All commands run to completion; the error is the first failure in name + // order, as executeCommand reports a parallel hook's. + names := slices.Sorted(maps.Keys(m)) + errs := make([]error, len(names)) var wg sync.WaitGroup - var errs []error - var mu sync.Mutex - for name, c := range m { - cmdStr, ok := c.(string) + for i, name := range names { + cmdStr, ok := m[name].(string) if !ok { continue } - wg.Add(1) - go func(name, cmdStr string) { - defer wg.Done() + wg.Go(func() { resolved, err := resolve(cmdStr) if err != nil { - mu.Lock() - errs = append(errs, fmt.Errorf("%s: %w", name, err)) - mu.Unlock() + errs[i] = fmt.Errorf("%s: %w", name, err) return } logger.Write(fmt.Sprintf(" [%s] %s", name, resolved), log.LevelInfo) - if err := runHostCommand(ctx, resolved, workspaceFolder); err != nil { - mu.Lock() - errs = append(errs, fmt.Errorf("%s: %w", name, err)) - mu.Unlock() + if err := runHostExec(ctx, []string{"/bin/sh", "-c", resolved}, workspaceFolder); err != nil { + errs[i] = fmt.Errorf("%s: %w", name, err) } - }(name, cmdStr) + }) } wg.Wait() - if len(errs) > 0 { - return errs[0] + for _, err := range errs { + if err != nil { + return err + } } return nil } @@ -97,14 +97,6 @@ func RunInitializeCommand(ctx context.Context, logger log.Logger, cmd *config.Li return nil } -func runHostCommand(ctx context.Context, command string, workDir string) error { - cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) - cmd.Dir = workDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - func runHostExec(ctx context.Context, args []string, workDir string) error { cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Dir = workDir diff --git a/internal/lifecycle/initialize_test.go b/internal/lifecycle/initialize_test.go index 622db14..874704e 100644 --- a/internal/lifecycle/initialize_test.go +++ b/internal/lifecycle/initialize_test.go @@ -3,6 +3,8 @@ package lifecycle import ( "context" "encoding/json" + "os" + "strings" "testing" "github.com/devcontainers/cli/internal/config" @@ -67,3 +69,23 @@ func TestRunInitializeCommandSubstitutesLocalWorkspaceFolder(t *testing.T) { t.Fatalf("substitution not applied to command: %v", err) } } + +// Every parallel initializeCommand runs to completion, and with several failing +// the error names the first in name order - the same every run, not whichever +// goroutine finished first. +func TestRunInitializeCommandParallelFirstFailureByName(t *testing.T) { + dir := t.TempDir() + var cmd config.LifecycleCommand + if err := json.Unmarshal([]byte(`{"zz": "exit 3", "bb": "sleep 0.2; exit 2", "aa": "touch `+dir+`/ran"}`), &cmd); err != nil { + t.Fatal(err) + } + for range 5 { + err := RunInitializeCommand(t.Context(), log.Null, &cmd, config.HostSubContext{LocalWorkspaceFolder: dir}) + if err == nil || !strings.HasPrefix(err.Error(), "bb: ") { + t.Fatalf("err = %v, want the failure of bb (first failing name)", err) + } + } + if _, err := os.Stat(dir + "/ran"); err != nil { + t.Errorf("aa did not run: %v", err) + } +} diff --git a/internal/lifecycle/probe.go b/internal/lifecycle/probe.go index 503dcf8..ffce0df 100644 --- a/internal/lifecycle/probe.go +++ b/internal/lifecycle/probe.go @@ -1,8 +1,10 @@ package lifecycle import ( + "context" "encoding/json" "fmt" + "slices" "strings" "github.com/devcontainers/cli/internal/log" @@ -28,16 +30,13 @@ type shellExec interface { // ProbeRemoteEnv runs a shell command in the container to capture environment variables. // If sessionDataFolder is provided, results are cached in a JSON file inside the container. -func ProbeRemoteEnv(logger log.Logger, shellServer shellExec, strategy UserEnvProbeStrategy, remoteUser string, sessionDataFolder ...string) (map[string]string, error) { +func ProbeRemoteEnv(logger log.Logger, shellServer shellExec, strategy UserEnvProbeStrategy, remoteUser, sessionDataFolder string) (map[string]string, error) { if strategy == ProbeNone || strategy == "" { return map[string]string{}, nil } // Try cache first - cacheDir := "" - if len(sessionDataFolder) > 0 { - cacheDir = sessionDataFolder[0] - } + cacheDir := sessionDataFolder if cacheDir != "" { if cached := readEnvCache(shellServer, strategy, cacheDir); cached != nil { logger.Write(fmt.Sprintf("Using cached env probe (%s)", strategy), log.LevelTrace) @@ -115,21 +114,12 @@ func ProbeRemoteEnv(logger log.Logger, shellServer shellExec, strategy UserEnvPr func mergePaths(shellPath, containerPath string, rootUser bool) string { result := strings.Split(shellPath, ":") insertAt := 0 - for _, entry := range strings.Split(containerPath, ":") { - idx := -1 - for i, e := range result { - if e == entry { - idx = i - break - } - } - if idx == -1 { - if rootUser || !isSbinPath(entry) { - result = append(result[:insertAt], append([]string{entry}, result[insertAt:]...)...) - insertAt++ - } - } else { + for entry := range strings.SplitSeq(containerPath, ":") { + if idx := slices.Index(result, entry); idx != -1 { insertAt = idx + 1 + } else if rootUser || !isSbinPath(entry) { + result = slices.Insert(result, insertAt, entry) + insertAt++ } } return strings.Join(result, ":") @@ -210,3 +200,20 @@ func parseEnvOutput(output string) map[string]string { return env } + +// ProbedRemoteEnv probes the remote user's environment in the container with a +// shell of its own, as the up, set-up, run-user-commands and exec commands do +// before running anything there. A probe that fails or is disabled yields an +// empty environment. +func ProbedRemoteEnv(ctx context.Context, exec ContainerExecutor, logger log.Logger, containerID, remoteUser string, strategy UserEnvProbeStrategy, sessionDataFolder string) map[string]string { + if strategy == ProbeNone { + return map[string]string{} + } + server := NewShellServer(ctx, exec, containerID, remoteUser) + defer server.Close() + env, _ := ProbeRemoteEnv(logger, server, strategy, remoteUser, sessionDataFolder) + if env == nil { + env = map[string]string{} + } + return env +} diff --git a/internal/lifecycle/probe_exec_test.go b/internal/lifecycle/probe_exec_test.go index 3128169..2bd8ae8 100644 --- a/internal/lifecycle/probe_exec_test.go +++ b/internal/lifecycle/probe_exec_test.go @@ -54,7 +54,7 @@ func TestProbeRemoteEnv_None(t *testing.T) { t.Fatal("Exec must not be called for ProbeNone") return "", 0, nil }} - env, err := ProbeRemoteEnv(log.Null, fs, ProbeNone, "root") + env, err := ProbeRemoteEnv(log.Null, fs, ProbeNone, "root", "") if err != nil { t.Fatal(err) } @@ -62,7 +62,7 @@ func TestProbeRemoteEnv_None(t *testing.T) { t.Errorf("expected empty env, got %v", env) } // Empty strategy behaves like ProbeNone. - if env, _ = ProbeRemoteEnv(log.Null, fs, "", "root"); len(env) != 0 { + if env, _ = ProbeRemoteEnv(log.Null, fs, "", "root", ""); len(env) != 0 { t.Errorf("empty strategy: expected empty env, got %v", env) } } @@ -110,7 +110,7 @@ func TestProbeRemoteEnv_TimeoutFallback(t *testing.T) { return "", 0, nil }} lg := newCaptureLog() - env, err := ProbeRemoteEnv(lg, fs, ProbeLoginInteractiveShell, "root") + env, err := ProbeRemoteEnv(lg, fs, ProbeLoginInteractiveShell, "root", "") if err != nil { t.Fatal(err) } diff --git a/internal/lifecycle/probe_nodocker_held_test.go b/internal/lifecycle/probe_nodocker_held_test.go new file mode 100644 index 0000000..b7606aa --- /dev/null +++ b/internal/lifecycle/probe_nodocker_held_test.go @@ -0,0 +1,74 @@ +package lifecycle + +import ( + "strings" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// probeShell answers the env probe with probed and the base $PATH query with +// basePath. +func probeShell(t *testing.T, probed, basePath string) *fakeShell { + return &fakeShell{handler: func(cmd string) (string, int, error) { + switch { + case strings.Contains(cmd, "getent passwd"): + return "/bin/sh\n", 0, nil + case strings.HasPrefix(cmd, "timeout 10 "): + return probed, 0, nil + case strings.Contains(cmd, `printf %s "$PATH"`): + return basePath, 0, nil + case strings.HasPrefix(cmd, "mkdir -p"), strings.HasPrefix(cmd, "cat '"): + return "", 1, nil + } + t.Fatalf("unexpected command: %q", cmd) + return "", 0, nil + }} +} + +// The container's sbin entries join the probed PATH for root, by name or as +// uid 0 (TS mergePaths(shellPath, containerPath, user === 'root' || user === '0')), +// and for no user, which the exec runs as root; never for another user. +func TestProbeRemoteEnvMergesSbinForRootOnly(t *testing.T) { + for user, want := range map[string]string{ + "": "/usr/sbin:/usr/bin", + "root": "/usr/sbin:/usr/bin", + "0": "/usr/sbin:/usr/bin", + "vscode": "/usr/bin", + } { + env, err := ProbeRemoteEnv(log.Null, probeShell(t, "PATH=/usr/bin\n", "/usr/sbin:/usr/bin"), ProbeLoginShell, user, "") + if err != nil { + t.Fatal(err) + } + if env["PATH"] != want { + t.Errorf("user %q: PATH = %q, want %q", user, env["PATH"], want) + } + } +} + +// An empty container PATH merges nothing: TS mergePaths runs only when both +// PATHs are non-empty. +func TestProbeRemoteEnvKeepsThePathWhenTheContainerHasNone(t *testing.T) { + env, err := ProbeRemoteEnv(log.Null, probeShell(t, "PATH=/usr/bin\n", " "), ProbeLoginShell, "root", "") + if err != nil { + t.Fatal(err) + } + if env["PATH"] != "/usr/bin" { + t.Errorf("PATH = %q, want /usr/bin", env["PATH"]) + } +} + +// A probe that found a single variable is still cached in the session folder. +func TestProbeRemoteEnvCachesASingleVariable(t *testing.T) { + fs := probeShell(t, "HOME=/root\n", "") + env, err := ProbeRemoteEnv(log.Null, fs, ProbeLoginShell, "root", "/tmp/sess") + if err != nil { + t.Fatal(err) + } + if len(env) != 1 || env["HOME"] != "/root" { + t.Fatalf("env = %v, want only HOME", env) + } + if !fs.called("mkdir -p '/tmp/sess'") { + t.Errorf("one probed variable was not cached: %v", fs.calls) + } +} diff --git a/internal/lifecycle/shell.go b/internal/lifecycle/shell.go index fbfb7d4..0865fff 100644 --- a/internal/lifecycle/shell.go +++ b/internal/lifecycle/shell.go @@ -33,16 +33,14 @@ type ShellServer struct { // remoteEnv entries ("KEY=VALUE", docker exec -e) are applied to every command, // matching the TS CLI which runs lifecycle hooks and the userEnvProbe as the // remote user. -// The logger param is accepted for call-site stability and future use; the -// server currently logs nothing itself (callers surface command output). -func NewShellServer(ctx context.Context, exec ContainerExecutor, containerID, user string, _ log.Logger, remoteEnv ...string) (*ShellServer, error) { +func NewShellServer(ctx context.Context, exec ContainerExecutor, containerID, user string, remoteEnv ...string) *ShellServer { return &ShellServer{ ctx: ctx, exec: exec, containerID: containerID, user: user, env: remoteEnv, - }, nil + } } // Exec runs a command inside the container and returns its stdout and exit code. diff --git a/internal/lifecycle/shell_test.go b/internal/lifecycle/shell_test.go index 966dc40..38e2057 100644 --- a/internal/lifecycle/shell_test.go +++ b/internal/lifecycle/shell_test.go @@ -31,10 +31,7 @@ func TestShellServerExec(t *testing.T) { } return "hello\n", "", 0, nil }} - s, err := NewShellServer(t.Context(), fe, "cid", "vscode", log.Null, "FOO=bar") - if err != nil { - t.Fatal(err) - } + s := NewShellServer(t.Context(), fe, "cid", "vscode", "FOO=bar") stdout, code, err := s.Exec("echo hello") if err != nil { @@ -66,7 +63,7 @@ func TestShellServerExecError(t *testing.T) { fe := &fakeExecutor{fn: func(string) (string, string, int, error) { return "", "", -1, errors.New("attach failed") }} - s, _ := NewShellServer(t.Context(), fe, "cid", "", log.Null) + s := NewShellServer(t.Context(), fe, "cid", "") if _, _, err := s.Exec("echo hi"); err == nil { t.Fatal("expected the underlying exec error to propagate") } @@ -74,7 +71,7 @@ func TestShellServerExecError(t *testing.T) { func TestShellExecutorWorkdirAndFailure(t *testing.T) { fe := &fakeExecutor{fn: func(string) (string, string, int, error) { return "", "", 0, nil }} - s, _ := NewShellServer(t.Context(), fe, "cid", "", log.Null) + s := NewShellServer(t.Context(), fe, "cid", "") ex := &ShellExecutor{Server: s, Log: log.Null, WorkDir: "/workspaces/project"} if err := ex.Exec("make build"); err != nil { @@ -112,3 +109,26 @@ func TestShellSingleQuote(t *testing.T) { }) } } + +// ProbedRemoteEnv runs nothing for the none strategy, and otherwise returns +// what the probe reads in the container as the remote user; a probe whose +// command fails is an empty environment, not an error. +func TestProbedRemoteEnv(t *testing.T) { + fe := &fakeExecutor{fn: func(string) (string, string, int, error) { return "", "", 0, nil }} + if env := ProbedRemoteEnv(t.Context(), fe, log.Null, "cid", "vscode", ProbeNone, ""); len(env) != 0 || fe.calls != 0 { + t.Fatalf("none: env = %v, calls = %d", env, fe.calls) + } + + fe = &fakeExecutor{fn: func(command string) (string, string, int, error) { + return "PATH=/usr/bin\nHOME=/home/vscode\n", "", 0, nil + }} + env := ProbedRemoteEnv(t.Context(), fe, log.Null, "cid", "vscode", ProbeLoginShell, "") + if env["HOME"] != "/home/vscode" || fe.lastUser != "vscode" { + t.Fatalf("probe: env = %v, user = %q", env, fe.lastUser) + } + + fe = &fakeExecutor{fn: func(string) (string, string, int, error) { return "", "", 1, errors.New("no shell") }} + if env := ProbedRemoteEnv(t.Context(), fe, log.Null, "cid", "vscode", ProbeLoginShell, ""); env == nil || len(env) != 0 { + t.Fatalf("failed probe: env = %v, want empty", env) + } +} diff --git a/internal/lifecycle/spec_order_test.go b/internal/lifecycle/spec_order_test.go index 166cde2..2b00c9a 100644 --- a/internal/lifecycle/spec_order_test.go +++ b/internal/lifecycle/spec_order_test.go @@ -13,23 +13,14 @@ import ( // finalization phases run onCreateCommand → updateContentCommand → // postCreateCommand, then on start/attach postStartCommand → postAttachCommand. func TestSpecLifecycleOrder(t *testing.T) { - // AllPhases advertises the canonical order, initializeCommand (host) first. - wantPhases := []Phase{ - PhaseInitialize, PhaseOnCreate, PhaseUpdateContent, - PhasePostCreate, PhasePostStart, PhasePostAttach, - } - if got := AllPhases(); !reflect.DeepEqual(got, wantPhases) { - t.Fatalf("AllPhases() = %v, want %v", got, wantPhases) - } - // RunHooks runs the in-container phases in exactly that order. Each phase has // a single command; the recording executor captures the execution sequence. merged := &imagemeta.MergedConfig{ - OnCreateCommands: []interface{}{"cmd-onCreate"}, - UpdateContentCommands: []interface{}{"cmd-updateContent"}, - PostCreateCommands: []interface{}{"cmd-postCreate"}, - PostStartCommands: []interface{}{"cmd-postStart"}, - PostAttachCommands: []interface{}{"cmd-postAttach"}, + OnCreateCommands: []any{"cmd-onCreate"}, + UpdateContentCommands: []any{"cmd-updateContent"}, + PostCreateCommands: []any{"cmd-postCreate"}, + PostStartCommands: []any{"cmd-postStart"}, + PostAttachCommands: []any{"cmd-postAttach"}, } exec := &mockExecutor{} if err := RunHooks(log.Null, exec, merged, RunOptions{}); err != nil { @@ -48,9 +39,9 @@ func TestSpecLifecycleOrder(t *testing.T) { func TestSpecWaitForGatesExecution(t *testing.T) { merged := &imagemeta.MergedConfig{ WaitFor: "updateContentCommand", - OnCreateCommands: []interface{}{"cmd-onCreate"}, - UpdateContentCommands: []interface{}{"cmd-updateContent"}, - PostCreateCommands: []interface{}{"cmd-postCreate"}, + OnCreateCommands: []any{"cmd-onCreate"}, + UpdateContentCommands: []any{"cmd-updateContent"}, + PostCreateCommands: []any{"cmd-postCreate"}, } exec := &mockExecutor{} if err := RunHooks(log.Null, exec, merged, RunOptions{SkipNonBlocking: true}); err != nil { diff --git a/internal/log/log.go b/internal/log/log.go index cc7e1dc..29c51fa 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -2,12 +2,13 @@ package log import ( + "cmp" "encoding/json" "fmt" "io" "os" "runtime" - "sort" + "slices" "strings" "time" ) @@ -62,12 +63,6 @@ func ParseLevel(text string) Level { } } -// Dimensions holds terminal size for formatting. -type Dimensions struct { - Columns int `json:"columns"` - Rows int `json:"rows"` -} - // Event represents a structured log event, matching the TS LogEvent union. type Event struct { Type string `json:"type"` // "text", "raw", "start", "stop", "progress" @@ -88,7 +83,6 @@ type Logger interface { Start(text string, level ...Level) int64 Stop(text string, startTimestamp int64, level ...Level) Event(e Event) - Dimensions() *Dimensions } // Handler processes log events. Multiple handlers can be combined. @@ -98,12 +92,11 @@ type Handler interface { // Options configures a new Logger instance. type Options struct { - Level Level - Format string // "text" or "json" - Writer io.Writer - Dimensions *Dimensions - Version string - StartTime time.Time + Level Level + Format string // "text" or "json" + Writer io.Writer + Version string + StartTime time.Time // Secrets, when set, are replaced with "********" in every emitted log line // (matching the TS CLI maskSecrets). Pass the secret *values*. Secrets []string @@ -112,7 +105,6 @@ type Options struct { type logger struct { handler Handler defaultLevel Level - dimensions *Dimensions secrets []string } @@ -145,7 +137,6 @@ func New(opts Options) Logger { l := &logger{ handler: h, defaultLevel: opts.Level, - dimensions: opts.Dimensions, secrets: prepareSecrets(opts.Secrets), } @@ -187,13 +178,8 @@ func osRelease() string { // prepareSecrets drops empty values and sorts by length descending so that // longer secrets are masked before any shorter substring of them. func prepareSecrets(values []string) []string { - var out []string - for _, v := range values { - if v != "" { - out = append(out, v) - } - } - sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) }) + out := slices.DeleteFunc(slices.Clone(values), func(v string) bool { return v == "" }) + slices.SortFunc(out, func(a, b string) int { return cmp.Compare(len(b), len(a)) }) return out } @@ -252,10 +238,6 @@ func (l *logger) Event(e Event) { l.handler.HandleEvent(e) } -func (l *logger) Dimensions() *Dimensions { - return l.dimensions -} - // Null is a no-op logger. var Null Logger = &nullLog{} @@ -266,7 +248,6 @@ func (n *nullLog) Raw(string, ...Level) {} func (n *nullLog) Start(string, ...Level) int64 { return nowMillis() } func (n *nullLog) Stop(string, int64, ...Level) {} func (n *nullLog) Event(Event) {} -func (n *nullLog) Dimensions() *Dimensions { return nil } // --- Terminal handler --- @@ -276,10 +257,8 @@ type terminalHandler struct { minLevel Level } +// newTerminalHandler's minLevel is never 0: New defaults it to info. func newTerminalHandler(w io.Writer, start time.Time, minLevel Level) Handler { - if minLevel == 0 { - minLevel = LevelInfo - } return &terminalHandler{w: w, start: start.UnixMilli(), minLevel: minLevel} } diff --git a/internal/log/log_nodocker_held_test.go b/internal/log/log_nodocker_held_test.go new file mode 100644 index 0000000..839b3fd --- /dev/null +++ b/internal/log/log_nodocker_held_test.go @@ -0,0 +1,19 @@ +package log + +import ( + "bytes" + "strings" + "testing" +) + +// Every secret is replaced whole, the longer first, and an empty one masks +// nothing, as TS replaceAllLog (spec-utils/log.ts) filters out empty values and +// sorts the rest longest first. +func TestSecretMaskingReplacesEachSecretWhole(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Format: "text", Writer: &buf, Secrets: []string{"", "tok", "tok3n"}}) + l.Write("use tok3n and tok", LevelInfo) + if out := buf.String(); !strings.HasSuffix(out, " use ******** and ********\n") { + t.Fatalf("output = %q", out) + } +} diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 42f8189..534366d 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -162,16 +162,18 @@ func TestJSONHandler_Progress(t *testing.T) { } } +// Null accepts every call and writes nothing; what can go wrong is a panic. func TestNullLog(t *testing.T) { - // Should not panic + defer func() { + if r := recover(); r != nil { + t.Fatalf("Null panicked: %v", r) + } + }() Null.Write("test") Null.Raw("test") ts := Null.Start("test") Null.Stop("test", ts) Null.Event(Event{Type: "text"}) - if Null.Dimensions() != nil { - t.Error("Null dimensions should be nil") - } } func TestLogLevelFilter_TerminalVsJSON(t *testing.T) { diff --git a/internal/oci/auth.go b/internal/oci/auth.go index 4608c62..7988ef4 100644 --- a/internal/oci/auth.go +++ b/internal/oci/auth.go @@ -27,13 +27,17 @@ type credential struct { func getCredential(env map[string]string, registry string, logger log.Logger) *credential { // 1. DEVCONTAINERS_OCI_AUTH env var if ociAuth := env["DEVCONTAINERS_OCI_AUTH"]; ociAuth != "" { - for _, entry := range strings.Split(ociAuth, ",") { - parts := strings.SplitN(entry, "|", 3) - if len(parts) == 3 && parts[0] == registry { - logger.Write(fmt.Sprintf("[httpOci] Using match from DEVCONTAINERS_OCI_AUTH for registry %q", registry), log.LevelTrace) - userToken := parts[1] + ":" + parts[2] - return &credential{base64Encoded: base64.StdEncoding.EncodeToString([]byte(userToken))} + for entry := range strings.SplitSeq(ociAuth, ",") { + parts := strings.Split(entry, "|") + if parts[0] != registry { + continue } + logger.Write(fmt.Sprintf("[httpOci] Using match from DEVCONTAINERS_OCI_AUTH for registry '%s'", registry), log.LevelTrace) + // TS joins the second and third fields, ignoring any after them; a + // missing field is JS undefined. + parts = append(parts, "undefined", "undefined") + userToken := parts[1] + ":" + parts[2] + return &credential{base64Encoded: base64.StdEncoding.EncodeToString([]byte(userToken))} } } diff --git a/internal/oci/auth_nodocker_held_test.go b/internal/oci/auth_nodocker_held_test.go new file mode 100644 index 0000000..104779f --- /dev/null +++ b/internal/oci/auth_nodocker_held_test.go @@ -0,0 +1,44 @@ +package oci + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// TS getCredential (httpOCIRegistry.ts) takes the first DEVCONTAINERS_OCI_AUTH +// entry whose first `|` field is the registry, and joins its second and third +// fields as `${split[1]}:${split[2]}`: fields after the third are dropped and a +// missing one reads as undefined. +func TestGetCredentialFromOCIAuthEntryFields(t *testing.T) { + for _, tt := range []struct { + name, ociAuth, want string + }{ + {"fields after the third are dropped", "reg.io|user|pa|ss", "user:pa"}, + {"a missing token is undefined", "reg.io|user,reg.io|other|token", "user:undefined"}, + {"a missing user and token are undefined", "reg.io", "undefined:undefined"}, + } { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + logger := log.New(log.Options{Writer: &buf, Level: log.LevelTrace}) + env := map[string]string{"DEVCONTAINERS_OCI_AUTH": tt.ociAuth, "DOCKER_CONFIG": t.TempDir()} + cred := getCredential(env, "reg.io", logger) + if cred == nil { + t.Fatal("credential is nil") + } + raw, err := base64.StdEncoding.DecodeString(cred.base64Encoded) + if err != nil { + t.Fatal(err) + } + if string(raw) != tt.want { + t.Fatalf("credential = %q, want %q", raw, tt.want) + } + if !strings.Contains(buf.String(), "[httpOci] Using match from DEVCONTAINERS_OCI_AUTH for registry 'reg.io'") { + t.Fatalf("log = %q", buf.String()) + } + }) + } +} diff --git a/internal/oci/authpolicy.go b/internal/oci/authpolicy.go index cb898cc..623a622 100644 --- a/internal/oci/authpolicy.go +++ b/internal/oci/authpolicy.go @@ -349,7 +349,7 @@ func bearerRealm(challenge string) string { if !found || !strings.EqualFold(scheme, "Bearer") { return "" } - for _, part := range strings.Split(params, ",") { + for part := range strings.SplitSeq(params, ",") { key, value, ok := strings.Cut(strings.TrimSpace(part), "=") if !ok || !strings.EqualFold(strings.TrimSpace(key), "realm") { continue diff --git a/internal/oci/client.go b/internal/oci/client.go index 058533e..3d583dc 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -59,13 +59,7 @@ type Client struct { authPolicy *AuthPolicy } -// NewClient creates an OCI client. Auth and retries are handled by oras-go (see -// repository()); the HTTP transport is the shared proxy/CA-aware transport. -func NewClient(logger log.Logger, env map[string]string) *Client { - return NewClientWithAuthPolicy(logger, env, DefaultAuthPolicy()) -} - -// NewClientWithAuthPolicy is NewClient with an explicit OCI auth policy, so every +// NewClientWithAuthPolicy creates an OCI client with the given auth policy, so every // client built for one CLI invocation shares its hardening settings and // diagnostics. func NewClientWithAuthPolicy(logger log.Logger, env map[string]string, policy *AuthPolicy) *Client { @@ -93,8 +87,7 @@ func IsNotFound(err error) bool { if errors.Is(err, errdef.ErrNotFound) { return true } - var respErr *errcode.ErrorResponse - if errors.As(err, &respErr) { + if respErr, ok := errors.AsType[*errcode.ErrorResponse](err); ok { return respErr.StatusCode == http.StatusNotFound } return false diff --git a/internal/oci/client_helpers_test.go b/internal/oci/client_helpers_test.go new file mode 100644 index 0000000..640f32c --- /dev/null +++ b/internal/oci/client_helpers_test.go @@ -0,0 +1,9 @@ +package oci + +import "github.com/devcontainers/cli/internal/log" + +// NewClient is the tests' shorthand for a client with the default auth policy; +// the CLI always builds one with the invocation's policy (NewClientWithAuthPolicy). +func NewClient(logger log.Logger, env map[string]string) *Client { + return NewClientWithAuthPolicy(logger, env, DefaultAuthPolicy()) +} diff --git a/internal/oci/image_inspect.go b/internal/oci/image_inspect.go new file mode 100644 index 0000000..2de6568 --- /dev/null +++ b/internal/oci/image_inspect.go @@ -0,0 +1,207 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + orascontent "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "github.com/devcontainers/cli/internal/log" +) + +// dockerManifestListMediaType is the Docker counterpart of an OCI image index. +const dockerManifestListMediaType = "application/vnd.docker.distribution.manifest.list.v2+json" + +// ImageConfig is the part of an image's config blob ("config" object) the CLI +// reads: the default user, environment and labels (devcontainer.metadata). +type ImageConfig struct { + User string `json:"User,omitempty"` + Env []string `json:"Env,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` +} + +// ImageDetails is the TS ImageDetails built by inspectImageInRegistry +// (utils.ts): the config digest as Id, the image config, and the platform that +// was asked for. +type ImageDetails struct { + ID string + Config ImageConfig + OS string + Architecture string + Variant string +} + +// QualifyImageName is TS qualifyImageName (utils.ts): a bare name is a Docker +// Hub library image, a two-segment name is a Docker Hub user image, anything +// longer already names its registry. +func QualifyImageName(name string) string { + segments := strings.Split(name, "/") + switch len(segments) { + case 1: + return "docker.io/library/" + name + case 2: + if strings.HasPrefix(name, "docker.io/") { + return "docker.io/library/" + segments[1] + } + return "docker.io/" + name + default: + return name + } +} + +// registryServer is the host the registry API is served from: Docker Hub's +// API lives on registry-1.docker.io, not docker.io (TS inspectImageInRegistry). +func registryServer(registry string) string { + if registry == "docker.io" { + return "registry-1.docker.io" + } + return registry +} + +// InspectImageInRegistry is TS inspectImageInRegistry (utils.ts): it reads an +// image's config straight from its registry, without pulling it. The reference +// resolves to an image manifest (its config is used whatever its platform, as +// TS does) or to an image index / manifest list, whose entry for platform is +// then fetched. The config blob's "config" object becomes ImageDetails.Config. +func (c *Client) InspectImageInRegistry(ctx context.Context, name string, platform ocispec.Platform) (*ImageDetails, error) { + resourceAndVersion := QualifyImageName(name) + ref, err := ParseRef(resourceAndVersion) + if err != nil { + return nil, fmt.Errorf("Could not parse image name '%s'", name) + } + ctx, cancel := context.WithTimeout(ctx, metadataOpTimeout) + defer cancel() + + server := ref + if s := registryServer(ref.Registry); s != ref.Registry { + cp := *ref + cp.Registry = s + server = &cp + } + repo, err := c.repository(server) + if err != nil { + return nil, err + } + if server != ref { + // Credentials are looked up for the registry as named (docker.io), like + // TS requestEnsureAuthenticated does with ref.registry. + if ac, ok := repo.Client.(*auth.Client); ok { + named, credential := ref.Registry, ac.Credential + ac.Credential = func(ctx context.Context, host string) (auth.Credential, error) { + if host == server.Registry { + host = named + } + return credential(ctx, host) + } + } + } + c.log.Write(fmt.Sprintf("manifest url: %s://%s/v2/%s/manifests/%s", ref.Scheme(), server.Registry, ref.Path, ref.Version), log.LevelTrace) + + noManifest := fmt.Errorf("No manifest found for %s.", resourceAndVersion) + desc, body, err := fetchManifestBytes(ctx, repo, ref.Version) + if err != nil { + if IsNotFound(err) { + return nil, noManifest + } + return nil, fmt.Errorf("fetch manifest for %s: %w", resourceAndVersion, err) + } + + var configDesc ocispec.Descriptor + switch desc.MediaType { + case ocispec.MediaTypeImageIndex, dockerManifestListMediaType: + var index ocispec.Index + if err := json.Unmarshal(body, &index); err != nil { + return nil, fmt.Errorf("parse image index for %s: %w", resourceAndVersion, err) + } + entry, ok := indexEntryForPlatform(index, platform) + if !ok { + return nil, noManifest + } + _, body, err = fetchManifestBytes(ctx, repo, entry.Digest.String()) + if err != nil { + if IsNotFound(err) { + return nil, noManifest + } + return nil, fmt.Errorf("fetch manifest for %s: %w", resourceAndVersion, err) + } + if configDesc, err = manifestConfig(body); err != nil { + return nil, fmt.Errorf("parse manifest for %s: %w", resourceAndVersion, err) + } + default: + if configDesc, err = manifestConfig(body); err != nil { + return nil, fmt.Errorf("parse manifest for %s: %w", resourceAndVersion, err) + } + } + if configDesc.Digest == "" { + return nil, noManifest + } + + c.log.Write(fmt.Sprintf("blob url: %s://%s/v2/%s/blobs/%s", ref.Scheme(), server.Registry, ref.Path, configDesc.Digest), log.LevelTrace) + rc, err := repo.Fetch(ctx, configDesc) + if err != nil { + return nil, fmt.Errorf("Failed to fetch blob for %s: %w", resourceAndVersion, err) + } + defer rc.Close() + blob, err := orascontent.ReadAll(rc, configDesc) + if err != nil { + return nil, fmt.Errorf("Failed to fetch blob for %s: %w", resourceAndVersion, err) + } + var cfg struct { + Config ImageConfig `json:"config"` + } + if err := json.Unmarshal(blob, &cfg); err != nil { + return nil, fmt.Errorf("parse image config for %s: %w", resourceAndVersion, err) + } + return &ImageDetails{ + ID: configDesc.Digest.String(), + Config: cfg.Config, + OS: platform.OS, + Architecture: platform.Architecture, + Variant: platform.Variant, + }, nil +} + +// fetchManifestBytes fetches a manifest by tag or digest and reads it, verifying +// the bytes against the returned descriptor. +func fetchManifestBytes(ctx context.Context, repo *remote.Repository, reference string) (ocispec.Descriptor, []byte, error) { + desc, rc, err := repo.FetchReference(ctx, reference) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + defer rc.Close() + body, err := orascontent.ReadAll(rc, desc) + if err != nil { + return ocispec.Descriptor{}, nil, err + } + return desc, body, nil +} + +// manifestConfig returns the config descriptor of an image manifest (OCI or +// Docker v2 schema 2); it is empty when the document has none. +func manifestConfig(body []byte) (ocispec.Descriptor, error) { + var m ocispec.Manifest + if err := json.Unmarshal(body, &m); err != nil { + return ocispec.Descriptor{}, err + } + return m.Config, nil +} + +// indexEntryForPlatform is TS getImageIndexEntryForPlatform +// (containerCollectionsOCI.ts): the first entry whose os and architecture match, +// and whose variant matches when a variant is asked for. +func indexEntryForPlatform(index ocispec.Index, platform ocispec.Platform) (ocispec.Descriptor, bool) { + for _, m := range index.Manifests { + if m.Platform == nil || m.Platform.Architecture != platform.Architecture || m.Platform.OS != platform.OS { + continue + } + if platform.Variant == "" || m.Platform.Variant == platform.Variant { + return m, true + } + } + return ocispec.Descriptor{}, false +} diff --git a/internal/oci/image_inspect_nodocker_held_test.go b/internal/oci/image_inspect_nodocker_held_test.go new file mode 100644 index 0000000..90e8e43 --- /dev/null +++ b/internal/oci/image_inspect_nodocker_held_test.go @@ -0,0 +1,86 @@ +package oci + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// A Docker Hub image is read from registry-1.docker.io with the credentials +// stored for docker.io, the registry as named: TS inspectImageInRegistry +// (utils.ts) authenticates with ref.registry while requesting the API host. +func TestInspectImageInRegistryDockerHubUsesDockerIOCredentials(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if user, pass, ok := r.BasicAuth(); !ok || user != "hubuser" || pass != "hubtoken" { + w.Header().Set("WWW-Authenticate", `Basic realm="hub"`) + http.Error(w, `{"errors":[{"code":"UNAUTHORIZED"}]}`, http.StatusUnauthorized) + return + } + reg.ServeHTTP(w, r) + })) + defer srv.Close() + target, _ := url.Parse(srv.URL) + reg.addImage(t, "library/node", "18", ocispec.MediaTypeImageManifest, ImageConfig{User: "node"}) + + c := testImageClient() + c.env["DEVCONTAINERS_OCI_AUTH"] = "docker.io|hubuser|hubtoken" + c.retryClient = &http.Client{Transport: originKeeper{hostRewriter{target}}} + got, err := c.InspectImageInRegistry(context.Background(), "node:18", ocispec.Platform{OS: "linux", Architecture: "amd64"}) + if err != nil { + t.Fatalf("InspectImageInRegistry: %v", err) + } + if got.Config.User != "node" { + t.Fatalf("user = %q, want node", got.Config.User) + } +} + +// originKeeper answers with the request as it was addressed, so the auth +// client sees the challenge come from the registry it asked, not the test +// server the request was rewritten to. +type originKeeper struct{ next http.RoundTripper } + +func (o originKeeper) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := o.next.RoundTrip(req) + if resp != nil { + resp.Request = req + } + return resp, err +} + +// An index entry is chosen as TS getImageIndexEntryForPlatform +// (containerCollectionsOCI.ts) finds it: the first entry for the os and +// architecture when no variant is asked for, else the first that also has the +// variant. +func TestInspectImageInRegistryIndexVariant(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(reg) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") + v6 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "v6"}) + v7 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "v7"}) + v6.Platform = &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v6"} + v7.Platform = &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v7"} + body, err := json.Marshal(ocispec.Index{Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: ocispec.MediaTypeImageIndex, Manifests: []ocispec.Descriptor{v6, v7}}) + if err != nil { + t.Fatal(err) + } + reg.manifests["org/img/latest"] = servedManifest{ocispec.MediaTypeImageIndex, body} + + for variant, want := range map[string]string{"": "v6", "v6": "v6", "v7": "v7"} { + got, err := testImageClient().InspectImageInRegistry(context.Background(), host+"/org/img", ocispec.Platform{OS: "linux", Architecture: "arm", Variant: variant}) + if err != nil { + t.Fatalf("variant %q: %v", variant, err) + } + if got.Config.User != want { + t.Errorf("variant %q: user = %q, want %q", variant, got.Config.User, want) + } + } +} diff --git a/internal/oci/image_inspect_test.go b/internal/oci/image_inspect_test.go new file mode 100644 index 0000000..62098cc --- /dev/null +++ b/internal/oci/image_inspect_test.go @@ -0,0 +1,246 @@ +package oci + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + + godigest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/devcontainers/cli/internal/log" +) + +// imageRegistry is a read-only httptest registry serving manifests (by tag or +// digest) and blobs, recording the host and path of every request. +type imageRegistry struct { + mu sync.Mutex + manifests map[string]servedManifest // "/" + blobs map[string][]byte // "/" + requests []string // "" +} + +type servedManifest struct { + mediaType string + body []byte +} + +func newImageRegistry() *imageRegistry { + return &imageRegistry{manifests: map[string]servedManifest{}, blobs: map[string][]byte{}} +} + +func (r *imageRegistry) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req.Host+req.URL.Path) + path := strings.TrimPrefix(req.URL.Path, "/v2/") + if repo, ref, ok := strings.Cut(path, "/manifests/"); ok { + m, found := r.manifests[repo+"/"+ref] + if !found { + http.Error(w, `{"errors":[{"code":"MANIFEST_UNKNOWN"}]}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", m.mediaType) + w.Header().Set("Docker-Content-Digest", godigest.FromBytes(m.body).String()) + w.Header().Set("Content-Length", strconv.Itoa(len(m.body))) + if req.Method != http.MethodHead { + w.Write(m.body) + } + return + } + if repo, dgst, ok := strings.Cut(path, "/blobs/"); ok { + b, found := r.blobs[repo+"/"+dgst] + if !found { + http.Error(w, `{"errors":[{"code":"BLOB_UNKNOWN"}]}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(b))) + if req.Method != http.MethodHead { + w.Write(b) + } + return + } + http.NotFound(w, req) +} + +// addImage stores a config blob and an image manifest for it, returning the +// manifest descriptor. tag may be empty (manifest reachable by digest only). +func (r *imageRegistry) addImage(t *testing.T, repo, tag, manifestType string, cfg ImageConfig) ocispec.Descriptor { + t.Helper() + blob, err := json.Marshal(map[string]any{"architecture": "amd64", "os": "linux", "config": cfg}) + if err != nil { + t.Fatal(err) + } + cfgDesc := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageConfig, Digest: godigest.FromBytes(blob), Size: int64(len(blob))} + r.blobs[repo+"/"+cfgDesc.Digest.String()] = blob + manifest, err := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": manifestType, "config": cfgDesc, "layers": []any{}}) + if err != nil { + t.Fatal(err) + } + desc := ocispec.Descriptor{MediaType: manifestType, Digest: godigest.FromBytes(manifest), Size: int64(len(manifest))} + r.manifests[repo+"/"+desc.Digest.String()] = servedManifest{manifestType, manifest} + if tag != "" { + r.manifests[repo+"/"+tag] = servedManifest{manifestType, manifest} + } + return desc +} + +func (r *imageRegistry) addIndex(t *testing.T, repo, tag, indexType string, entries map[string]ocispec.Descriptor) { + t.Helper() + var manifests []ocispec.Descriptor + for plat, d := range entries { + parts := strings.Split(plat, "/") + p := &ocispec.Platform{OS: parts[0], Architecture: parts[1]} + if len(parts) > 2 { + p.Variant = parts[2] + } + d.Platform = p + manifests = append(manifests, d) + } + body, err := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": indexType, "manifests": manifests}) + if err != nil { + t.Fatal(err) + } + r.manifests[repo+"/"+tag] = servedManifest{indexType, body} +} + +func testImageClient() *Client { + return &Client{log: log.Null, env: map[string]string{"DOCKER_CONFIG": "/nonexistent"}} +} + +func TestInspectImageInRegistryManifest(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(reg) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") + want := ImageConfig{User: "node", Env: []string{"PATH=/usr/bin"}, Labels: map[string]string{"devcontainer.metadata": `[{"remoteUser":"node"}]`}} + reg.addImage(t, "devcontainers/base", "1", "application/vnd.docker.distribution.manifest.v2+json", want) + + got, err := testImageClient().InspectImageInRegistry(context.Background(), host+"/devcontainers/base:1", ocispec.Platform{OS: "linux", Architecture: "amd64"}) + if err != nil { + t.Fatalf("InspectImageInRegistry: %v", err) + } + if got.Config.User != "node" || got.Config.Labels["devcontainer.metadata"] != want.Labels["devcontainer.metadata"] || len(got.Config.Env) != 1 { + t.Fatalf("config = %+v, want %+v", got.Config, want) + } + if !strings.HasPrefix(got.ID, "sha256:") || got.OS != "linux" || got.Architecture != "amd64" { + t.Fatalf("details = %+v", got) + } +} + +func TestInspectImageInRegistryIndexSelectsPlatform(t *testing.T) { + for _, indexType := range []string{ocispec.MediaTypeImageIndex, dockerManifestListMediaType} { + t.Run(indexType, func(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(reg) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") + amd := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "amd"}) + arm := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "arm"}) + armV7 := reg.addImage(t, "org/img", "", ocispec.MediaTypeImageManifest, ImageConfig{User: "armv7"}) + reg.addIndex(t, "org/img", "latest", indexType, map[string]ocispec.Descriptor{"linux/amd64": amd, "linux/arm64": arm, "linux/arm/v7": armV7}) + + c := testImageClient() + for plat, wantUser := range map[string]string{"linux/amd64": "amd", "linux/arm64": "arm", "linux/arm/v7": "armv7"} { + parts := strings.Split(plat, "/") + p := ocispec.Platform{OS: parts[0], Architecture: parts[1]} + if len(parts) > 2 { + p.Variant = parts[2] + } + got, err := c.InspectImageInRegistry(context.Background(), host+"/org/img", p) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + if got.Config.User != wantUser { + t.Errorf("%s: user = %q, want %q", plat, got.Config.User, wantUser) + } + } + + _, err := c.InspectImageInRegistry(context.Background(), host+"/org/img:latest", ocispec.Platform{OS: "windows", Architecture: "amd64"}) + if err == nil || err.Error() != "No manifest found for "+host+"/org/img:latest." { + t.Fatalf("no matching platform: err = %v", err) + } + }) + } +} + +func TestInspectImageInRegistryMissingManifest(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(reg) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") + _, err := testImageClient().InspectImageInRegistry(context.Background(), host+"/org/missing:1", ocispec.Platform{OS: "linux", Architecture: "amd64"}) + if err == nil || err.Error() != "No manifest found for "+host+"/org/missing:1." { + t.Fatalf("err = %v", err) + } +} + +// hostRewriter sends every request to the test server, keeping the original +// host in the Host header so the registry sees which host was addressed. +type hostRewriter struct{ target *url.URL } + +func (h hostRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + r.Host = req.URL.Host + r.URL.Scheme = h.target.Scheme + r.URL.Host = h.target.Host + return http.DefaultTransport.RoundTrip(r) +} + +func TestInspectImageInRegistryDockerHub(t *testing.T) { + reg := newImageRegistry() + srv := httptest.NewServer(reg) + defer srv.Close() + target, _ := url.Parse(srv.URL) + reg.addImage(t, "library/node", "18", ocispec.MediaTypeImageManifest, ImageConfig{User: "node"}) + reg.addImage(t, "someuser/tool", "latest", ocispec.MediaTypeImageManifest, ImageConfig{User: "tool"}) + + c := testImageClient() + c.retryClient = &http.Client{Transport: hostRewriter{target}} + for name, wantUser := range map[string]string{"node:18": "node", "docker.io/node:18": "node", "someuser/tool": "tool"} { + got, err := c.InspectImageInRegistry(context.Background(), name, ocispec.Platform{OS: "linux", Architecture: "amd64"}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got.Config.User != wantUser { + t.Errorf("%s: user = %q, want %q", name, got.Config.User, wantUser) + } + } + for _, r := range reg.requests { + if !strings.HasPrefix(r, "registry-1.docker.io/v2/") { + t.Errorf("request to %q, want registry-1.docker.io", r) + } + } + if len(reg.requests) == 0 { + t.Fatal("no registry requests") + } +} + +func TestQualifyImageName(t *testing.T) { + for in, want := range map[string]string{ + "node": "docker.io/library/node", + "node:18": "docker.io/library/node:18", + "docker.io/node": "docker.io/library/node", + "user/img:1": "docker.io/user/img:1", + "mcr.microsoft.com/devcontainers/base:1": "mcr.microsoft.com/devcontainers/base:1", + "localhost:5000/img": "docker.io/localhost:5000/img", + } { + if got := QualifyImageName(in); got != want { + t.Errorf("QualifyImageName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestInspectImageInRegistryUnparsableName(t *testing.T) { + _, err := testImageClient().InspectImageInRegistry(context.Background(), "Bad Name", ocispec.Platform{OS: "linux", Architecture: "amd64"}) + if err == nil || err.Error() != "Could not parse image name 'Bad Name'" { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/oci/push.go b/internal/oci/push.go index 8101a02..b3eaadd 100644 --- a/internal/oci/push.go +++ b/internal/oci/push.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" "github.com/Masterminds/semver/v3" @@ -25,10 +26,8 @@ import ( // new version is the highest within their range, so a re-publish of an older // version never moves latest/major/major.minor backwards. func SemanticTags(version string, publishedTags []string) (tags []string, skip bool, err error) { - for _, t := range publishedTags { - if t == version { - return nil, true, nil - } + if slices.Contains(publishedTags, version) { + return nil, true, nil } v, err := semver.StrictNewVersion(version) if err != nil { @@ -78,10 +77,6 @@ type PushResult struct { // handles auth (pull,push scope negotiation), blob-existence checks and retries. func (c *Client) PushArtifact(ctx context.Context, ref *Ref, tgzPath string, tags []string, collectionType string, annotations map[string]string) (*PushResult, error) { c.log.Write(fmt.Sprintf("Pushing %s to %s (tags: %s)...", ref.ID, ref.Resource, strings.Join(tags, ", ")), log.LevelInfo) - if collectionType == "" { - collectionType = "feature" - } - repo, err := c.repository(ref) if err != nil { return nil, err @@ -93,57 +88,10 @@ func (c *Client) PushArtifact(ctx context.Context, ref *Ref, tgzPath string, tag return nil, fmt.Errorf("read tarball: %w", err) } - // Upload the layer blob. - layerDigest := godigest.FromBytes(tgzData) - layerDesc := ocispec.Descriptor{ - MediaType: TarLayerMediaType, - Digest: layerDigest, - Size: int64(len(tgzData)), - Annotations: map[string]string{ - "org.opencontainers.image.title": fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, ref.ID), - }, - } - if err := pushBlobIfAbsent(ctx, repo, layerDesc, tgzData); err != nil { - return nil, fmt.Errorf("upload blob: %w", err) - } - - // Upload the empty config blob (required by the OCI spec). - configData := []byte("{}") - configDigest := godigest.FromBytes(configData) - configDesc := ocispec.Descriptor{ - MediaType: ManifestMediaType, - Digest: configDigest, - Size: int64(len(configData)), - } - if err := pushBlobIfAbsent(ctx, repo, configDesc, configData); err != nil { - return nil, fmt.Errorf("upload config: %w", err) - } - - // Build the manifest with the local type to keep the exact byte layout. - manifest := Manifest{ - SchemaVersion: 2, - MediaType: OCIManifestMediaType, - Config: Descriptor{ - Digest: configDigest.String(), - MediaType: ManifestMediaType, - Size: int64(len(configData)), - }, - Layers: []Layer{ - { - MediaType: TarLayerMediaType, - Digest: layerDigest.String(), - Size: int64(len(tgzData)), - Annotations: layerDesc.Annotations, - }, - }, - Annotations: annotations, - } - - manifestBytes, _ := json.Marshal(manifest) - manifestDesc := ocispec.Descriptor{ - MediaType: OCIManifestMediaType, - Digest: godigest.FromBytes(manifestBytes), - Size: int64(len(manifestBytes)), + manifestDesc, manifestBytes, err := pushArtifactManifest(ctx, repo, TarLayerMediaType, + fmt.Sprintf("devcontainer-%s-%s.tgz", collectionType, ref.ID), tgzData, annotations) + if err != nil { + return nil, err } // Push the manifest under each version tag (PushReference does PUT @@ -185,55 +133,10 @@ func (c *Client) PushCollectionMetadata(ctx context.Context, ref *Ref, collectio return nil, fmt.Errorf("read collection metadata: %w", err) } - layerDigest := godigest.FromBytes(data) - layerDesc := ocispec.Descriptor{ - MediaType: CollectionLayerMediaType, - Digest: layerDigest, - Size: int64(len(data)), - Annotations: map[string]string{ - "org.opencontainers.image.title": "devcontainer-collection.json", - }, - } - if err := pushBlobIfAbsent(ctx, repo, layerDesc, data); err != nil { - return nil, fmt.Errorf("upload collection layer: %w", err) - } - - configData := []byte("{}") - configDigest := godigest.FromBytes(configData) - configDesc := ocispec.Descriptor{ - MediaType: ManifestMediaType, - Digest: configDigest, - Size: int64(len(configData)), - } - if err := pushBlobIfAbsent(ctx, repo, configDesc, configData); err != nil { - return nil, fmt.Errorf("upload config: %w", err) - } - - manifest := Manifest{ - SchemaVersion: 2, - MediaType: OCIManifestMediaType, - Config: Descriptor{ - Digest: configDigest.String(), - MediaType: ManifestMediaType, - Size: int64(len(configData)), - }, - Layers: []Layer{ - { - MediaType: CollectionLayerMediaType, - Digest: layerDigest.String(), - Size: int64(len(data)), - Annotations: layerDesc.Annotations, - }, - }, - Annotations: map[string]string{ - "com.github.package.type": "devcontainer_collection", - }, - } - manifestBytes, _ := json.Marshal(manifest) - manifestDesc := ocispec.Descriptor{ - MediaType: OCIManifestMediaType, - Digest: godigest.FromBytes(manifestBytes), - Size: int64(len(manifestBytes)), + manifestDesc, manifestBytes, err := pushArtifactManifest(ctx, repo, CollectionLayerMediaType, + "devcontainer-collection.json", data, map[string]string{"com.github.package.type": "devcontainer_collection"}) + if err != nil { + return nil, err } if err := repo.PushReference(ctx, manifestDesc, bytes.NewReader(manifestBytes), "latest"); err != nil { return nil, fmt.Errorf("push collection manifest: %w", err) @@ -250,16 +153,34 @@ func pushBlobIfAbsent(ctx context.Context, repo *remote.Repository, desc ocispec return repo.Blobs().Push(ctx, desc, bytes.NewReader(data)) } -// computeTags derives the version tags to publish (X.Y.Z, X.Y, X, latest). -func computeTags(version string) []string { - parts := strings.Split(version, ".") - tags := []string{version} - if len(parts) >= 3 { - tags = append(tags, strings.Join(parts[:2], ".")) +// pushArtifactManifest uploads data as the artifact's one layer (titled title) +// and the empty config the OCI spec requires, and returns the manifest that +// references them, built with the local types to keep the TS byte layout. +func pushArtifactManifest(ctx context.Context, repo *remote.Repository, layerMediaType, title string, data []byte, annotations map[string]string) (ocispec.Descriptor, []byte, error) { + layerDesc := ocispec.Descriptor{ + MediaType: layerMediaType, + Digest: godigest.FromBytes(data), + Size: int64(len(data)), + Annotations: map[string]string{"org.opencontainers.image.title": title}, + } + if err := pushBlobIfAbsent(ctx, repo, layerDesc, data); err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("upload layer: %w", err) + } + configData := []byte("{}") + configDesc := ocispec.Descriptor{MediaType: ManifestMediaType, Digest: godigest.FromBytes(configData), Size: int64(len(configData))} + if err := pushBlobIfAbsent(ctx, repo, configDesc, configData); err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("upload config: %w", err) } - if len(parts) >= 2 { - tags = append(tags, parts[0]) + manifest := Manifest{ + SchemaVersion: 2, + MediaType: OCIManifestMediaType, + Config: Descriptor{Digest: configDesc.Digest.String(), MediaType: ManifestMediaType, Size: configDesc.Size}, + Layers: []Layer{{MediaType: layerMediaType, Digest: layerDesc.Digest.String(), Size: layerDesc.Size, Annotations: layerDesc.Annotations}}, + Annotations: annotations, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("encode manifest: %w", err) } - tags = append(tags, "latest") - return tags + return ocispec.Descriptor{MediaType: OCIManifestMediaType, Digest: godigest.FromBytes(manifestBytes), Size: int64(len(manifestBytes))}, manifestBytes, nil } diff --git a/internal/oci/push_nodocker_held_test.go b/internal/oci/push_nodocker_held_test.go new file mode 100644 index 0000000..b6705a0 --- /dev/null +++ b/internal/oci/push_nodocker_held_test.go @@ -0,0 +1,62 @@ +package oci + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/devcontainers/cli/internal/log" +) + +// The major.minor tag follows the highest published version with the same +// major and the same minor (TS getSemanticTags' `${major}.${minor}.x` range, +// publishCommandImpl.ts): neither a published 2.3.5 nor a published 1.4.0 +// holds back 1.3. +func TestSemanticTagsMajorMinorRangeIsBothParts(t *testing.T) { + for _, tt := range []struct { + published []string + want []string + }{ + {[]string{"2.3.5"}, []string{"1", "1.3", "1.3.0"}}, + {[]string{"1.4.0"}, []string{"1.3", "1.3.0"}}, + } { + tags, skip, err := SemanticTags("1.3.0", tt.published) + if err != nil || skip { + t.Fatalf("SemanticTags(%v): skip=%v err=%v", tt.published, skip, err) + } + if !slices.Equal(tags, tt.want) { + t.Errorf("published %v: tags = %v, want %v", tt.published, tags, tt.want) + } + } +} + +// The pushed manifest is the one TS calculateManifestAndContentDigest +// (containerCollectionsOCIPush.ts) builds: schemaVersion 2, and a layer titled +// devcontainer--.tgz. +func TestPushArtifactManifest(t *testing.T) { + reg := newFakeOCIRegistry() + registry := reg.start(t) + ref, err := ParseRef(registry + "/ns/hello:1.0.0") + if err != nil { + t.Fatal(err) + } + client := NewClient(log.Null, map[string]string{"DOCKER_CONFIG": t.TempDir()}) + if _, err := client.PushArtifact(t.Context(), ref, writeTarball(t, "layer"), []string{"1.0.0"}, "template", nil); err != nil { + t.Fatal(err) + } + reg.mu.Lock() + body := reg.manifests["1.0.0"] + reg.mu.Unlock() + var m struct { + SchemaVersion int `json:"schemaVersion"` + Layers []struct { + Annotations map[string]string `json:"annotations"` + } `json:"layers"` + } + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("manifest %q: %v", body, err) + } + if m.SchemaVersion != 2 || len(m.Layers) != 1 || m.Layers[0].Annotations["org.opencontainers.image.title"] != "devcontainer-template-hello.tgz" { + t.Fatalf("manifest = %s", body) + } +} diff --git a/internal/oci/push_test.go b/internal/oci/push_test.go index a551348..5eb3425 100644 --- a/internal/oci/push_test.go +++ b/internal/oci/push_test.go @@ -5,32 +5,6 @@ import ( "testing" ) -func TestComputeTags(t *testing.T) { - tests := []struct { - version string - want []string - }{ - {"1.2.3", []string{"1.2.3", "1.2", "1", "latest"}}, - {"2.0", []string{"2.0", "2", "latest"}}, - {"3", []string{"3", "latest"}}, - {"1.0.0", []string{"1.0.0", "1.0", "1", "latest"}}, - {"1.2.3.4", []string{"1.2.3.4", "1.2", "1", "latest"}}, // unusual four-part version - } - - for _, tt := range tests { - got := computeTags(tt.version) - if len(got) != len(tt.want) { - t.Errorf("computeTags(%q) = %v, want %v", tt.version, got, tt.want) - continue - } - for i, g := range got { - if g != tt.want[i] { - t.Errorf("computeTags(%q)[%d] = %q, want %q", tt.version, i, g, tt.want[i]) - } - } - } -} - func TestGetSemanticTags(t *testing.T) { tests := []struct { name string diff --git a/internal/oci/ref.go b/internal/oci/ref.go index 2b298e4..a1d38ef 100644 --- a/internal/oci/ref.go +++ b/internal/oci/ref.go @@ -138,7 +138,13 @@ func ParseRef(input string) (*Ref, error) { }, nil } -// ParseCollectionRef parses registry + namespace into a CollectionRef. +// Ref is the collection as the repository the registry client pushes to. +func (r *CollectionRef) Ref() *Ref { + return &Ref{Registry: r.Registry, Path: r.Path, Resource: r.Resource, Tag: r.Tag, Version: r.Version} +} + +// ParseCollectionRef parses registry + namespace into a CollectionRef, as TS +// getCollectionRef does: the namespace, lowercased, is the whole path. func ParseCollectionRef(registry, namespace string) (*CollectionRef, error) { registry = strings.ToLower(registry) namespace = strings.ToLower(namespace) @@ -157,24 +163,3 @@ func ParseCollectionRef(registry, namespace string) (*CollectionRef, error) { Version: "latest", }, nil } - -// ManifestURL returns the URL for fetching the manifest. -func (r *Ref) ManifestURL() string { - ref := r.Version - return fmt.Sprintf("https://%s/v2/%s/manifests/%s", r.Registry, r.Path, ref) -} - -// BlobURL returns the URL for fetching a blob by digest. -func (r *Ref) BlobURL(digest string) string { - return fmt.Sprintf("https://%s/v2/%s/blobs/%s", r.Registry, r.Path, digest) -} - -// TagsURL returns the URL for listing tags. -func (r *Ref) TagsURL() string { - return fmt.Sprintf("https://%s/v2/%s/%s/tags/list", r.Registry, r.Namespace, r.ID) -} - -// ManifestURL returns the URL for fetching the collection manifest. -func (r *CollectionRef) ManifestURL() string { - return fmt.Sprintf("https://%s/v2/%s/manifests/%s", r.Registry, r.Path, r.Tag) -} diff --git a/internal/oci/ref_test.go b/internal/oci/ref_test.go index 97602f2..b87a0a2 100644 --- a/internal/oci/ref_test.go +++ b/internal/oci/ref_test.go @@ -132,27 +132,6 @@ func TestParseRef(t *testing.T) { } } -func TestParseRef_URLs(t *testing.T) { - ref, err := ParseRef("ghcr.io/devcontainers/features/go:1.2.3") - if err != nil { - t.Fatal(err) - } - if got := ref.ManifestURL(); got != "https://ghcr.io/v2/devcontainers/features/go/manifests/1.2.3" { - t.Errorf("manifest url = %q", got) - } - if got := ref.BlobURL("sha256:abc"); got != "https://ghcr.io/v2/devcontainers/features/go/blobs/sha256:abc" { - t.Errorf("blob url = %q", got) - } - if got := ref.TagsURL(); got != "https://ghcr.io/v2/devcontainers/features/go/tags/list" { - t.Errorf("tags url = %q", got) - } - // A digest ref pins the manifest by digest instead of tag. - digestRef := &Ref{Registry: "ghcr.io", Path: "devcontainers/features/go", Version: "sha256:abc123", Digest: "sha256:abc123"} - if got := digestRef.ManifestURL(); got != "https://ghcr.io/v2/devcontainers/features/go/manifests/sha256:abc123" { - t.Errorf("digest manifest url = %q", got) - } -} - func TestParseCollectionRef(t *testing.T) { tests := []struct { name string diff --git a/internal/oci/registry_auth_test.go b/internal/oci/registry_auth_test.go index 97e59c0..a2e1fe1 100644 --- a/internal/oci/registry_auth_test.go +++ b/internal/oci/registry_auth_test.go @@ -38,23 +38,21 @@ func TestRegistryBasicAuthLoop(t *testing.T) { ctx := t.Context() container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "registry:3", - ExposedPorts: []string{"5000/tcp"}, - Env: map[string]string{ - "REGISTRY_AUTH": "htpasswd", - "REGISTRY_AUTH_HTPASSWD_REALM": "Registry Realm", - "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", - }, - Files: []testcontainers.ContainerFile{{ - Reader: strings.NewReader(htpasswd), - ContainerFilePath: "/auth/htpasswd", - FileMode: 0o444, - }}, - WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp"). - WithStatusCodeMatcher(func(status int) bool { return status == 401 }). - WithStartupTimeout(60 * time.Second), + Image: "registry:3", + ExposedPorts: []string{"5000/tcp"}, + Env: map[string]string{ + "REGISTRY_AUTH": "htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM": "Registry Realm", + "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", }, + Files: []testcontainers.ContainerFile{{ + Reader: strings.NewReader(htpasswd), + ContainerFilePath: "/auth/htpasswd", + FileMode: 0o444, + }}, + WaitingFor: wait.ForHTTP("/v2/").WithPort("5000/tcp"). + WithStatusCodeMatcher(func(status int) bool { return status == 401 }). + WithStartupTimeout(60 * time.Second), Started: true, }) if err != nil { diff --git a/internal/pfs/fs.go b/internal/pfs/fs.go index 478295a..6755c50 100644 --- a/internal/pfs/fs.go +++ b/internal/pfs/fs.go @@ -9,9 +9,6 @@ import ( // templates and writing workspaces. It lists EXACTLY the methods consumers use // (ReadFile/WriteFile/MkdirAll/Stat/Walk/Remove) so a fake can drive failure // paths (e.g. a WriteFile that fails mid-apply) without touching real disk. -// -// The package's free functions (ReadFile, WriteFile, ...) remain for callers -// that don't need injection; OSFS delegates to them so behavior is identical. type FS interface { ReadFile(path string) ([]byte, error) WriteFile(path string, data []byte) error @@ -24,12 +21,19 @@ type FS interface { // OSFS is the default FS, backed by the real filesystem. type OSFS struct{} -func (OSFS) ReadFile(path string) ([]byte, error) { return ReadFile(path) } -func (OSFS) WriteFile(path string, data []byte) error { return WriteFile(path, data) } -func (OSFS) MkdirAll(path string) error { return MkdirAll(path) } +func (OSFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } +func (OSFS) WriteFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) } +func (OSFS) MkdirAll(path string) error { return os.MkdirAll(path, 0o755) } func (OSFS) Stat(path string) (os.FileInfo, error) { return os.Stat(path) } func (OSFS) Walk(root string, fn filepath.WalkFunc) error { return filepath.Walk(root, fn) } -func (OSFS) Remove(path string, recursive bool) error { return Remove(path, recursive) } + +// Remove removes path; recursive removes its children too. +func (OSFS) Remove(path string, recursive bool) error { + if recursive { + return os.RemoveAll(path) + } + return os.Remove(path) +} // DefaultFS returns the default OS-backed FS. func DefaultFS() FS { return OSFS{} } diff --git a/internal/pfs/pfs.go b/internal/pfs/pfs.go index 5a931d7..3d06326 100644 --- a/internal/pfs/pfs.go +++ b/internal/pfs/pfs.go @@ -76,29 +76,6 @@ func ExtractTarGz(archivePath, destDir string) error { return nil } -// ReadFile reads the contents of the file at path. -func ReadFile(path string) ([]byte, error) { - return os.ReadFile(path) -} - -// WriteFile writes data to path, creating or truncating it. -func WriteFile(path string, data []byte) error { - return os.WriteFile(path, data, 0644) -} - -// MkdirAll creates a directory path and all parents. -func MkdirAll(path string) error { - return os.MkdirAll(path, 0755) -} - -// Remove removes path. If recursive, removes all children. -func Remove(path string, recursive bool) error { - if recursive { - return os.RemoveAll(path) - } - return os.Remove(path) -} - // IsFile returns true if path exists and is a regular file. func IsFile(path string) bool { info, err := os.Stat(path) @@ -117,12 +94,6 @@ func IsDir(path string) bool { return info.IsDir() } -// Exists returns true if path exists. -func Exists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // Resolve resolves a relative path against a base. func Resolve(base string, rel string) string { if filepath.IsAbs(rel) { diff --git a/internal/pfs/pfs_test.go b/internal/pfs/pfs_test.go index a14e06d..fe7b06e 100644 --- a/internal/pfs/pfs_test.go +++ b/internal/pfs/pfs_test.go @@ -6,45 +6,15 @@ import ( "testing" ) -func TestReadWriteFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.txt") - - err := WriteFile(path, []byte("hello")) - if err != nil { - t.Fatal(err) - } - - data, err := ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(data) != "hello" { - t.Errorf("got %q", string(data)) - } -} - -func TestMkdirAll(t *testing.T) { - dir := t.TempDir() - nested := filepath.Join(dir, "a", "b", "c") - - if err := MkdirAll(nested); err != nil { - t.Fatal(err) - } - if !IsDir(nested) { - t.Error("directory not created") - } -} - func TestRemove(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "file.txt") os.WriteFile(path, []byte("x"), 0644) - if err := Remove(path, false); err != nil { + if err := (OSFS{}).Remove(path, false); err != nil { t.Fatal(err) } - if Exists(path) { + if _, err := os.Stat(path); !os.IsNotExist(err) { t.Error("file still exists") } } @@ -55,10 +25,10 @@ func TestRemoveRecursive(t *testing.T) { os.MkdirAll(filepath.Join(nested, "deep"), 0755) os.WriteFile(filepath.Join(nested, "deep", "f.txt"), []byte("x"), 0644) - if err := Remove(nested, true); err != nil { + if err := (OSFS{}).Remove(nested, true); err != nil { t.Fatal(err) } - if Exists(nested) { + if _, err := os.Stat(nested); !os.IsNotExist(err) { t.Error("directory still exists") } } diff --git a/internal/templates/apply.go b/internal/templates/apply.go index 112be4b..dd28273 100644 --- a/internal/templates/apply.go +++ b/internal/templates/apply.go @@ -4,11 +4,14 @@ package templates import ( "encoding/json" "fmt" + "maps" "os" "path/filepath" + "strconv" "strings" "github.com/devcontainers/cli/internal/config" + "github.com/devcontainers/cli/internal/jsonc" "github.com/devcontainers/cli/internal/log" "github.com/devcontainers/cli/internal/oci" "github.com/devcontainers/cli/internal/pfs" @@ -100,8 +103,8 @@ func FetchAndApply(params ApplyParams, selected SelectedTemplate) ([]string, err "NOTES.md": true, } for _, p := range selected.OmitPaths { - if strings.HasSuffix(p, "/*") { - omitDirs[strings.TrimSuffix(p, "/*")+"/"] = true + if before, ok := strings.CutSuffix(p, "/*"); ok { + omitDirs[before+"/"] = true } else { omitFiles[p] = true } @@ -215,19 +218,19 @@ func mergeFeatures(fsys pfs.FS, workspaceFolder string, featureOpts []TemplateFe // Build a JSON Patch and apply it via hujson so the edit preserves the // original formatting and comments (JSONC), matching the TS CLI which uses a // text-preserving jsonc edit instead of a reformatting re-serialize. - var ops []map[string]interface{} + var ops []map[string]any if !hasFeatures { - ops = append(ops, map[string]interface{}{"op": "add", "path": "/features", "value": map[string]interface{}{}}) + ops = append(ops, map[string]any{"op": "add", "path": "/features", "value": map[string]any{}}) } for _, f := range featureOpts { if existing[f.ID] { continue } - var value interface{} = "latest" + var value any = "latest" if len(f.Options) > 0 { value = f.Options } - ops = append(ops, map[string]interface{}{ + ops = append(ops, map[string]any{ "op": "add", "path": "/features/" + escapeJSONPointer(f.ID), "value": value, @@ -262,28 +265,22 @@ func escapeJSONPointer(s string) string { // option defaults are supported. func applyOptionDefaults(fsys pfs.FS, extractDir string, userOptions map[string]string, logger log.Logger) map[string]string { merged := make(map[string]string, len(userOptions)) - for k, v := range userOptions { - merged[k] = v - } + maps.Copy(merged, userOptions) metaPath := filepath.Join(extractDir, "devcontainer-template.json") data, err := fsys.ReadFile(metaPath) if err != nil { return merged } - standardized, err := hujson.Standardize(data) - if err != nil { - return merged - } var meta TemplateMetadata - if err := json.Unmarshal(standardized, &meta); err != nil { + if err := jsonc.Unmarshal(data, &meta); err != nil { return merged } for key, raw := range meta.Options { if _, ok := merged[key]; ok { continue } - def, ok := raw.(map[string]interface{}) + def, ok := raw.(map[string]any) if !ok { continue } @@ -291,11 +288,7 @@ func applyOptionDefaults(fsys pfs.FS, extractDir string, userOptions map[string] case string: merged[key] = d case bool: - if d { - merged[key] = "true" - } else { - merged[key] = "false" - } + merged[key] = strconv.FormatBool(d) default: continue } diff --git a/internal/templates/apply_fetch_test.go b/internal/templates/apply_fetch_test.go index 8c21df0..733fdda 100644 --- a/internal/templates/apply_fetch_test.go +++ b/internal/templates/apply_fetch_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" "testing" @@ -345,12 +346,7 @@ func TestFetchAndApply_MergeFeaturesWriteError(t *testing.T) { } func contains(xs []string, x string) bool { - for _, v := range xs { - if v == x { - return true - } - } - return false + return slices.Contains(xs, x) } // TestFetchAndApply_FetchSideErrors covers cross-layer error propagation on the diff --git a/internal/templates/apply_merge_test.go b/internal/templates/apply_merge_test.go index 6958b87..7f37a18 100644 --- a/internal/templates/apply_merge_test.go +++ b/internal/templates/apply_merge_test.go @@ -54,7 +54,7 @@ func TestMergeFeatures_PreservesJSONC(t *testing.T) { os.WriteFile(cfgPath, []byte(original), 0644) feats := []TemplateFeatureOption{ - {ID: "ghcr.io/devcontainers/features/node:1", Options: map[string]interface{}{"version": "20"}}, + {ID: "ghcr.io/devcontainers/features/node:1", Options: map[string]any{"version": "20"}}, {ID: "ghcr.io/devcontainers/features/git:1"}, } if err := mergeFeatures(pfs.OSFS{}, dir, feats, log.Null); err != nil { @@ -101,7 +101,7 @@ func TestMergeFeatures_SkipsExisting(t *testing.T) { `), 0644) feats := []TemplateFeatureOption{ - {ID: "ghcr.io/devcontainers/features/node:1", Options: map[string]interface{}{"version": "20"}}, + {ID: "ghcr.io/devcontainers/features/node:1", Options: map[string]any{"version": "20"}}, } if err := mergeFeatures(pfs.OSFS{}, dir, feats, log.Null); err != nil { t.Fatal(err) diff --git a/internal/templates/types.go b/internal/templates/types.go index 1946cc4..da92337 100644 --- a/internal/templates/types.go +++ b/internal/templates/types.go @@ -2,12 +2,12 @@ package templates // TemplateMetadata represents a devcontainer-template.json. type TemplateMetadata struct { - ID string `json:"id"` - Version string `json:"version,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` - Platforms []string `json:"platforms,omitempty"` + ID string `json:"id"` + Version string `json:"version,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Options map[string]any `json:"options,omitempty"` + Platforms []string `json:"platforms,omitempty"` } // SelectedTemplate holds user selections for applying a template. @@ -20,12 +20,12 @@ type SelectedTemplate struct { // TemplateFeatureOption is a feature to add when applying a template. type TemplateFeatureOption struct { - ID string `json:"id"` - Options map[string]interface{} `json:"options,omitempty"` + ID string `json:"id"` + Options map[string]any `json:"options,omitempty"` } // CollectionMetadata is the metadata for a collection of templates/features. type CollectionMetadata struct { Templates []TemplateMetadata `json:"templates,omitempty"` - Features []interface{} `json:"features,omitempty"` + Features []any `json:"features,omitempty"` } diff --git a/versions.yaml b/versions.yaml new file mode 100644 index 0000000..9a4683c --- /dev/null +++ b/versions.yaml @@ -0,0 +1,24 @@ +# Every input this repository pins, written once: `go tool versions check` says what is behind, +# `go tool versions bump ` moves one, and internal/devtools/versions_gate_test.go fails on a +# pin written anywhere else. GitHub Actions are pinned by commit in the workflows; Dependabot +# moves those, as go.sum is Go's. + +- name: golangci-lint + kind: image + source: golangci/golangci-lint + version: v2.14.0 + pin: sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f + track: github-release https://github.com/golangci/golangci-lint + note: >- + task lint, with this repository's .golangci.yml. go-tools' version, so the spin-stack + repositories are measured by the same linter; the image's Go must be at least go.mod's. + +- name: distroless + kind: image + source: gcr.io/distroless/static + version: nonroot + pin: sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99 + track: digest + note: >- + The released image's base (Dockerfile's DISTROLESS_IMAGE, handed to GoReleaser by task + release:env). A bump is checked by task release --snapshot and task release:smoke.