From 41e83fa322b97fa1e5487df5e9b47dfb30397eb0 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 18:26:23 -0300 Subject: [PATCH] refactor: drop comments that restate the code Delete about 6,400 comment lines across cmd/ and internal/: doc comments that repeat a name, step narration, banners, "X is TS Y" notes the parity and oracle tests already pin, history of fixed bugs, issue tags, and divergence notes that docs/DIVERGENCES.md or docs/go-only-features.md already record. Kept: comments about behaviour we cannot change (Docker, BuildKit, Compose, git, POSIX shell, Go's standard library, JavaScript and V8 semantics the reference forces), the recipe that regenerates the ts-features-build goldens, and divergences recorded nowhere else. Every //go:, //nolint, mutate-lane and mutate-exempt comment is untouched. Only comments change: each file parses to the same AST once comments are stripped. Co-Authored-By: Claude Opus 5.5 --- cmd/devtool/main.go | 5 - cmd/devtool/main_test.go | 5 - internal/cli/build.go | 155 +--------- internal/cli/build_auth.go | 11 - internal/cli/build_auth_held_test.go | 5 - internal/cli/build_auth_test.go | 24 +- internal/cli/build_cachefrom_test.go | 6 - internal/cli/build_compose_path_test.go | 9 - internal/cli/build_held_test.go | 11 - internal/cli/build_nodocker_held_test.go | 3 - internal/cli/build_repo2_held_test.go | 3 - internal/cli/build_repo_held_test.go | 9 - internal/cli/cache_key.go | 25 -- internal/cli/cache_key_repo_held_test.go | 12 - internal/cli/cache_key_test.go | 5 - internal/cli/check.go | 25 -- internal/cli/check_held_test.go | 15 - internal/cli/check_repo_held_test.go | 7 - internal/cli/check_terminal_held_test.go | 2 - internal/cli/check_test.go | 4 - internal/cli/cli_parity_test.go | 7 - internal/cli/cli_test.go | 5 - internal/cli/collection_commands.go | 67 +---- .../cli/collection_commands_docs_held_test.go | 3 - internal/cli/collection_commands_held_test.go | 21 +- .../cli/collection_commands_repo_held_test.go | 15 - internal/cli/collection_commands_test.go | 16 - internal/cli/collection_package.go | 41 --- internal/cli/collection_package_held_test.go | 7 - .../collection_package_nodocker_held_test.go | 4 - internal/cli/collection_package_test.go | 18 -- .../cli/collection_publish_partial_test.go | 28 -- internal/cli/commands.go | 2 - internal/cli/compose_override.go | 60 +--- internal/cli/compose_override_held_test.go | 8 - internal/cli/compose_override_test.go | 19 -- internal/cli/compose_up.go | 81 +---- internal/cli/compose_up_held_test.go | 73 +---- internal/cli/container_lookup.go | 14 - internal/cli/container_lookup_test.go | 7 +- internal/cli/crosslayer_error_test.go | 31 +- internal/cli/differential_runtime_test.go | 53 +--- internal/cli/differential_test.go | 24 -- internal/cli/disallowed.go | 24 -- internal/cli/disallowed_held_test.go | 4 - internal/cli/disallowed_repo_held_test.go | 2 - internal/cli/disallowed_test.go | 11 +- internal/cli/dockerfile_path_test.go | 5 - internal/cli/dockerignore_test.go | 3 - internal/cli/e2e_ca_bundle_test.go | 27 +- internal/cli/e2e_fixes_test.go | 37 --- internal/cli/e2e_test.go | 17 +- internal/cli/env.go | 11 - internal/cli/exec.go | 66 +---- internal/cli/exec_held_test.go | 9 - internal/cli/exec_repo_held_test.go | 10 - internal/cli/exec_test.go | 19 +- internal/cli/extract_targz_test.go | 9 - internal/cli/feature_advisories_test.go | 3 - internal/cli/feature_deps.go | 23 +- internal/cli/feature_deps_held_test.go | 13 - internal/cli/feature_hostsub_test.go | 2 - internal/cli/feature_install.go | 221 ++------------ internal/cli/feature_install_held_test.go | 71 +---- .../cli/feature_install_nodocker_held_test.go | 7 - .../cli/feature_install_repo_held_test.go | 5 - internal/cli/feature_install_test.go | 35 --- internal/cli/feature_tarball_cap_held_test.go | 2 - internal/cli/feature_tarball_test.go | 26 +- internal/cli/features_info.go | 9 - internal/cli/features_info_held_test.go | 13 +- internal/cli/features_info_repo_held_test.go | 2 - internal/cli/features_resolve_deps.go | 18 -- .../cli/features_resolve_deps_held_test.go | 9 - internal/cli/features_test_runner.go | 54 ---- .../cli/features_test_runner_held_test.go | 9 - .../features_test_runner_repo_held_test.go | 19 -- internal/cli/flag_inventory_test.go | 55 ---- internal/cli/forward_ports_merge_test.go | 4 - internal/cli/gpu.go | 16 +- internal/cli/gpu_repo_held_test.go | 3 - internal/cli/helpers.go | 13 +- internal/cli/helpers_held_test.go | 3 - internal/cli/helpers_test.go | 12 +- internal/cli/image_build_info.go | 34 +-- internal/cli/image_build_info_held_test.go | 12 +- .../image_build_info_nodocker_held_test.go | 2 - internal/cli/image_build_info_test.go | 9 +- internal/cli/lockfile_flags_test.go | 5 - internal/cli/logfile.go | 19 -- internal/cli/logfile_test.go | 20 +- internal/cli/messages.go | 8 - internal/cli/metadata_interop_test.go | 57 ---- internal/cli/metadata_label.go | 34 --- internal/cli/metadata_label_test.go | 18 -- internal/cli/mounts.go | 5 - internal/cli/mounts_held_test.go | 5 - internal/cli/mounts_nodocker_held_test.go | 2 - internal/cli/ociauth.go | 20 -- internal/cli/ociauth_held_test.go | 5 - internal/cli/ociauth_test.go | 21 +- internal/cli/open.go | 12 +- internal/cli/open_repo_held_test.go | 16 - internal/cli/open_test.go | 8 - internal/cli/oracle_flag_coverage_test.go | 27 +- internal/cli/outdated.go | 87 +----- internal/cli/outdated_held_test.go | 16 - internal/cli/outdated_repo_held_test.go | 55 ---- internal/cli/outdated_tty_linux_test.go | 4 - internal/cli/output.go | 15 - internal/cli/parity_key_order_test.go | 2 - internal/cli/parity_lock_test.go | 11 +- internal/cli/parity_matrix_helpers_test.go | 20 +- internal/cli/parity_matrix_test.go | 277 ++--------------- internal/cli/parity_retry_test.go | 13 - internal/cli/parity_setup_binary_test.go | 18 -- internal/cli/parity_setup_retry_test.go | 2 - internal/cli/parity_sharding_workflow_test.go | 29 -- internal/cli/parity_ts_cli_test.go | 2 - internal/cli/publish_parity_test.go | 9 +- internal/cli/pure_helpers_test.go | 49 +-- internal/cli/read_configuration.go | 116 -------- internal/cli/read_configuration_held_test.go | 72 +---- .../read_configuration_nodocker_held_test.go | 7 - internal/cli/result_order.go | 38 +-- internal/cli/result_order_test.go | 12 - internal/cli/root.go | 15 - internal/cli/run_user_commands.go | 33 +-- internal/cli/run_user_commands_held_test.go | 10 +- internal/cli/seams_test.go | 26 +- internal/cli/secrets.go | 9 - internal/cli/secrets_held_test.go | 2 - internal/cli/secrets_repo_held_test.go | 3 - internal/cli/setup.go | 34 --- internal/cli/setup_held_test.go | 22 +- internal/cli/stop.go | 14 +- internal/cli/stop_held_test.go | 9 +- internal/cli/stop_repo2_held_test.go | 3 - internal/cli/stop_repo_held_test.go | 4 - internal/cli/stringify_test.go | 1 - internal/cli/templates_apply.go | 8 +- internal/cli/templates_apply_held_test.go | 2 - internal/cli/templates_metadata.go | 2 - .../cli/templates_metadata_repo_held_test.go | 6 - internal/cli/ts_oracle_test.go | 8 - internal/cli/up.go | 279 +----------------- internal/cli/up_held_test.go | 18 +- internal/cli/up_nodocker_held_test.go | 3 - internal/cli/up_remote_uid_held_test.go | 2 - internal/cli/up_repo_held_test.go | 11 - internal/cli/upstream_fixes_test.go | 24 -- internal/cli/validate.go | 17 -- internal/cli/validate_repo_held_test.go | 10 - internal/cli/workflow_permissions_test.go | 15 +- internal/config/compose_workspace_test.go | 5 - internal/config/devcontainerid_parity_test.go | 5 - internal/config/gitroot_test.go | 12 - internal/config/key_order_test.go | 2 - internal/config/loader.go | 169 +---------- internal/config/loader_held_test.go | 8 - internal/config/loader_nodocker_held_test.go | 15 - internal/config/loader_object_held_test.go | 10 - internal/config/loader_test.go | 28 +- internal/config/override_merge_test.go | 4 +- internal/config/reference_oracle_test.go | 11 - internal/config/spec_behavior_test.go | 12 - internal/config/spec_compliance_test.go | 43 +-- internal/config/types.go | 90 +----- internal/config/types_held_test.go | 7 - internal/config/types_nodocker_held_test.go | 3 - internal/config/types_test.go | 9 - internal/config/varsub.go | 27 +- internal/config/varsub_held_test.go | 7 - internal/config/varsub_nodocker_held_test.go | 5 - internal/config/varsub_test.go | 9 - internal/config/varsub_win_held_test.go | 2 - internal/config/workspacemount_test.go | 4 - internal/config/worktree_test.go | 30 -- internal/devtools/command.go | 58 +--- internal/devtools/command_test.go | 26 +- internal/devtools/coverage.go | 31 +- internal/devtools/coverage_test.go | 6 - internal/devtools/daily.go | 5 - internal/devtools/daily_test.go | 3 - internal/devtools/metrics.go | 11 - internal/devtools/metrics_test.go | 6 - internal/devtools/parityaffected.go | 26 -- internal/devtools/parityaffected_test.go | 7 - internal/devtools/release.go | 24 +- internal/devtools/release_test.go | 4 - internal/devtools/runnerdisk.go | 7 - internal/devtools/runnerdisk_test.go | 2 - internal/devtools/tsoracle.go | 6 - internal/devtools/tsoracle_test.go | 12 - internal/devtools/versions_gate_test.go | 15 +- internal/docker/argbuilders_test.go | 33 +-- internal/docker/builder_detect_held_test.go | 2 - internal/docker/client.go | 64 +--- internal/docker/client_held_test.go | 2 - internal/docker/client_nodocker_held_test.go | 2 - internal/docker/client_test.go | 9 - internal/docker/compose.go | 64 +--- internal/docker/compose_held_test.go | 9 - internal/docker/compose_log_test.go | 7 - internal/docker/compose_nodocker_held_test.go | 4 - internal/docker/compose_test.go | 4 +- internal/docker/composeoverride.go | 4 - internal/docker/composeoverride_test.go | 2 - internal/docker/context.go | 14 - internal/docker/context_held_test.go | 2 - internal/docker/context_test.go | 7 - internal/docker/dockerfile.go | 43 +-- internal/docker/dockerfile_edge_test.go | 4 - internal/docker/dockerfile_held_test.go | 14 - internal/docker/dockerfile_oracle_test.go | 3 - internal/docker/dockerfile_test.go | 13 +- internal/docker/engine.go | 44 +-- internal/docker/engine_held_test.go | 16 - internal/docker/engine_test.go | 34 +-- internal/docker/exec.go | 7 - internal/docker/exec_test.go | 1 - internal/docker/inline_cache_test.go | 8 - internal/docker/resolve.go | 21 +- internal/docker/resolve_held_test.go | 3 - internal/docker/runargs.go | 8 - internal/docker/runargs_test.go | 9 - internal/docker/runner_seam_test.go | 34 +-- internal/docker/updateuid.go | 17 +- .../docker/updateuid_nodocker_held_test.go | 6 - internal/doctor/checks.go | 30 -- internal/doctor/checks_held_test.go | 1 - internal/doctor/doctor.go | 53 +--- internal/doctor/doctor_held_test.go | 1 - internal/doctor/doctor_test.go | 9 +- internal/doctor/setup.go | 20 +- internal/doctor/setup_held_test.go | 4 - internal/doctor/setup_test.go | 8 +- internal/doctor/state.go | 9 - internal/errors/errors.go | 19 +- internal/errors/errors_test.go | 2 - internal/exec/exec.go | 20 -- internal/exec/exec_test.go | 9 - internal/features/advisory.go | 49 +-- internal/features/advisory_held_test.go | 6 - .../features/advisory_nodocker_held_test.go | 11 - internal/features/advisory_test.go | 15 - internal/features/graph.go | 53 ---- internal/features/graph_test.go | 18 +- internal/features/jsobject.go | 11 - internal/features/jsobject_test.go | 7 - internal/features/lockfile.go | 83 +----- internal/features/lockfile_frozen_test.go | 2 - internal/features/lockfile_held_test.go | 17 -- .../features/lockfile_nodocker_held_test.go | 4 - internal/features/lockfile_oracle_test.go | 5 - internal/features/lockfile_read_test.go | 11 - internal/features/lockfile_test.go | 6 - internal/features/mermaid.go | 11 - internal/features/mermaid_held_test.go | 3 - .../features/mermaid_nodocker_held_test.go | 7 - internal/features/order.go | 44 +-- internal/features/order_held_test.go | 28 -- internal/features/order_nodocker_held_test.go | 18 -- internal/features/order_test.go | 11 - internal/features/resolve.go | 30 +- internal/features/resolve_held_test.go | 3 - internal/features/resolve_test.go | 12 - internal/features/source_json_test.go | 3 - internal/features/ts_oracle_test.go | 5 - internal/features/types.go | 98 ++---- internal/features/types_order_test.go | 17 +- internal/features/v8json.go | 29 -- internal/httpx/httpx.go | 16 - internal/httpx/httpx_held_test.go | 16 +- internal/httpx/transport_test.go | 18 +- internal/imagemeta/extend.go | 120 +------- internal/imagemeta/extend_held_test.go | 5 - .../imagemeta/extend_nodocker_held_test.go | 15 - internal/imagemeta/extend_test.go | 16 - internal/imagemeta/merge.go | 121 ++------ internal/imagemeta/merge_bugs_test.go | 21 +- internal/imagemeta/merge_held_test.go | 12 - .../imagemeta/merge_nodocker_held_test.go | 14 - internal/imagemeta/merge_test.go | 5 - internal/imagemeta/metadata.go | 29 +- internal/imagemeta/metadata_held_test.go | 3 - internal/imagemeta/metadata_test.go | 4 - internal/imagemeta/reference_oracle_test.go | 5 - internal/imagemeta/spec_merge_test.go | 18 +- internal/jsonc/jsonc.go | 10 - internal/jsonc/jsonc_test.go | 5 - internal/jsonc/members_test.go | 5 - internal/lifecycle/dotfiles.go | 10 +- internal/lifecycle/dotfiles_held_test.go | 3 - internal/lifecycle/hooks.go | 39 +-- internal/lifecycle/hooks_held_test.go | 3 - .../lifecycle/hooks_nodocker_held_test.go | 11 - internal/lifecycle/hooks_test.go | 32 +- internal/lifecycle/initialize.go | 14 - internal/lifecycle/initialize_held_test.go | 5 - internal/lifecycle/initialize_test.go | 11 - internal/lifecycle/probe.go | 40 --- internal/lifecycle/probe_exec_test.go | 25 +- internal/lifecycle/probe_held_test.go | 6 - .../lifecycle/probe_nodocker_held_test.go | 8 - internal/lifecycle/probe_test.go | 10 +- internal/lifecycle/shell.go | 38 +-- internal/lifecycle/shell_held_test.go | 5 - internal/lifecycle/shell_test.go | 9 - internal/lifecycle/spec_order_test.go | 11 +- internal/log/eol_other.go | 1 - internal/log/eol_windows.go | 1 - internal/log/log.go | 92 +----- internal/log/log_held_test.go | 11 - internal/log/log_nodocker_held_test.go | 3 - internal/log/log_test.go | 5 - internal/log/log_tty_linux_test.go | 5 - internal/log/ts_oracle_test.go | 4 - internal/oci/auth.go | 27 +- internal/oci/auth_branches_test.go | 11 - internal/oci/auth_held_test.go | 13 - internal/oci/auth_helper_test.go | 17 -- internal/oci/auth_nodocker_held_test.go | 4 - internal/oci/authpolicy.go | 49 +-- internal/oci/authpolicy_held_test.go | 23 +- internal/oci/authpolicy_oracle_test.go | 5 - internal/oci/authpolicy_test.go | 12 - internal/oci/buildauth.go | 20 -- internal/oci/buildauth_test.go | 19 -- internal/oci/client.go | 55 +--- internal/oci/client_held_test.go | 6 - internal/oci/client_helpers_test.go | 2 - internal/oci/client_timeout_test.go | 8 +- internal/oci/image_inspect.go | 25 -- .../oci/image_inspect_nodocker_held_test.go | 7 - internal/oci/image_inspect_test.go | 12 +- internal/oci/orasclient.go | 16 - internal/oci/orasclient_test.go | 4 - internal/oci/push.go | 25 -- internal/oci/push_nodocker_held_test.go | 7 - internal/oci/push_partial_test.go | 27 -- internal/oci/push_registry_test.go | 28 +- internal/oci/push_test.go | 2 - internal/oci/ref.go | 46 +-- internal/oci/ref_held_test.go | 3 - internal/oci/ref_test.go | 7 - internal/oci/registry.go | 10 - internal/oci/registry_auth_test.go | 19 -- internal/oci/types.go | 8 +- internal/oci/whatwghost.go | 43 +-- internal/pfs/extract_paths_test.go | 16 - internal/pfs/extract_test.go | 1 - internal/pfs/fs.go | 8 - internal/pfs/pfs.go | 13 - internal/pfs/pfs_held_test.go | 8 +- internal/product/product.go | 1 - internal/stringify/stringify.go | 9 - internal/stringify/stringify_test.go | 3 - internal/templates/apply.go | 46 +-- internal/templates/apply_fetch_test.go | 54 +--- internal/templates/apply_merge_test.go | 8 - internal/templates/apply_pure_test.go | 6 - internal/templates/apply_test.go | 2 - internal/templates/ts_oracle_test.go | 2 - internal/templates/types.go | 4 - internal/tsoracle/oracletest/oracletest.go | 20 -- .../tsoracle/oracletest/oracletest_test.go | 2 +- internal/tsoracle/tsoracle.go | 29 +- 368 files changed, 489 insertions(+), 6874 deletions(-) diff --git a/cmd/devtool/main.go b/cmd/devtool/main.go index a81ef62..e872c1d 100644 --- a/cmd/devtool/main.go +++ b/cmd/devtool/main.go @@ -1,8 +1,3 @@ -// Command devtool is the repo's CI/dev helper, replacing the former scripts/*.sh. -// Its commands live in internal/devtools (devtools.Main), which wires stdin/stdout, -// git, `go tool`, and docker around the pure, unit-tested logic there. Invoked -// from the Taskfile and the CI workflow (e.g. `go run ./cmd/devtool coverage-gate -// coverage.out TOTAL=48`). package main import ( diff --git a/cmd/devtool/main_test.go b/cmd/devtool/main_test.go index 21d25f3..4472459 100644 --- a/cmd/devtool/main_test.go +++ b/cmd/devtool/main_test.go @@ -8,8 +8,6 @@ import ( "testing" ) -// TestMain runs the command itself, instead of the tests, in the child process -// TestTheCommandIsWired starts. func TestMain(m *testing.M) { if os.Getenv("DEVTOOL_RUN_MAIN") == "1" { main() @@ -17,7 +15,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// devtool runs this test binary as the devtool command, with args and stdin. func devtool(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) { t.Helper() cmd := exec.Command(os.Args[0], args...) @@ -35,8 +32,6 @@ func devtool(t *testing.T, stdin string, args ...string) (code int, stdout, stde return cmd.ProcessState.ExitCode(), o.String(), e.String() } -// TestTheCommandIsWired holds what main adds to devtools.Main: the process's -// arguments, standard streams and exit code. func TestTheCommandIsWired(t *testing.T) { if code, out, errOut := devtool(t, "internal/cli/exec.go\n", "parity-affected"); code != 0 || out != "exec\n" || errOut != "" { t.Errorf("parity-affected: code=%d stdout=%q stderr=%q, want 0 \"exec\\n\" \"\"", code, out, errOut) diff --git a/internal/cli/build.go b/internal/cli/build.go index 9cc861b..9a04663 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -45,9 +45,7 @@ type buildOpts struct { frozenLockfile bool secretsFile string caBundle string - // lockfileExcludeIDs is populated after merging --additional-features; it lists - // userFeatureIds supplied only via that flag, which 0.88 keeps out of the lockfile. - lockfileExcludeIDs map[string]bool + lockfileExcludeIDs map[string]bool } func newBuildCmd() *cobra.Command { @@ -105,20 +103,15 @@ func newBuildCmd() *cobra.Command { return cmd } -// buildRunner carries the dependencies shared across the `build` flow so they -// don't have to be threaded through every helper by hand. type buildRunner struct { - log log.Logger - docker *docker.Client - engine *docker.EngineClient - opts *buildOpts - // configLabel is the configuration's entry in the devcontainer.metadata - // label: its properties as written (TS pick(config.raw, ...)). + log log.Logger + docker *docker.Client + engine *docker.EngineClient + opts *buildOpts configLabel imagemeta.Entry } func runBuild(ctx context.Context, out Output, opts *buildOpts) error { - // 0.88: --workspace-folder defaults to the current directory when not given. if opts.workspaceFolder == "" { opts.workspaceFolder, _ = os.Getwd() } @@ -134,8 +127,6 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { return writeValidationError(out, err.Error()) } - // --no-lockfile is mutually exclusive with every frozen/write lockfile flag - // (TS devContainersSpecCLI check for the build command). if opts.noLockfile { switch { case opts.frozenLockfile: @@ -149,30 +140,24 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { warnDeprecatedLockfileFlags(out.Stderr(), opts.experimentalLockfile, opts.experimentalFrozenLockfile) - // Non-blocking hint (interactive TTY only) if the host was never checked or a - // previous `devcontainer check` found a failing configuration. warnUncheckedHost(out) opts.foldFrozenLockfile() - // Resolve paths workspaceFolder := resolvePath(opts.workspaceFolder) configPath := "" if opts.configPath != "" { configPath = resolvePath(opts.configPath) } - // Setup logger logger := log.New(log.Options{ Version: cliVersion(), Level: log.ParseLevel(opts.logLevel), Format: opts.logFormat, Writer: os.Stderr, }) - // Route the OCI auth diagnostic lines at this command's logger. ociAuthPolicy(ctx).SetLogger(logger) - // Load config loadResult, err := config.LoadDevContainerConfig(workspaceFolder, configPath, "") if err != nil { return writeErrorResult(out, err.Error()) @@ -184,12 +169,10 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { return writeErrorResult(out, err.Error()) } - // 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 := newBuildDockerClient(opts.dockerPath, opts.logFormat, logger) engine, err := docker.NewEngineClient(logger) @@ -203,7 +186,6 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { return writeErrorResult(out, err.Error()) } - // Detect BuildKit var useBuildx bool if opts.buildkit != "never" { bk := dockerClient.DetectBuildKit(ctx) @@ -221,7 +203,6 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error { } else if cfg.IsComposeConfig() { imageNameResult, err = run.buildCompose(ctx, cfg, loadResult, useBuildx) } else { - // Image-based config imageNameResult, err = run.buildImage(ctx, cfg, loadResult, useBuildx) } @@ -246,10 +227,6 @@ func buildResultImageName(compose bool, argImageNames, built []string) any { return built } -// warnDeprecatedLockfileFlags writes the warnings TS up and build write -// straight to stderr for the deprecated lockfile flags -// (devContainersSpecCLI.ts warnDeprecatedLockfileFlags): the lockfile is -// written by default, so --experimental-lockfile does nothing more. func warnDeprecatedLockfileFlags(w io.Writer, experimentalLockfile, experimentalFrozenLockfile bool) { if experimentalLockfile { fmt.Fprint(w, "Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n") @@ -259,26 +236,16 @@ func warnDeprecatedLockfileFlags(w io.Writer, experimentalLockfile, experimental } } -// 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: @@ -297,16 +264,13 @@ func buildFlagConflict(opts *buildOpts, useBuildx, compose bool) string { return "" } -// dockerfileBuildPrep holds the resolved Dockerfile inputs shared by the `up` -// and `build` Dockerfile flows. type dockerfileBuildPrep struct { - Path string // resolved Dockerfile path (a temp .devcontainer.build file when a stage name had to be injected) - StageName string // build target: the config target, or the auto-added final stage name - Parsed *docker.Dockerfile // the parsed original Dockerfile + Path string + StageName string + Parsed *docker.Dockerfile cleanup func() } -// Cleanup removes any temporary Dockerfile written by prepareDockerfileBuild. func (p *dockerfileBuildPrep) Cleanup() { p.cleanup() } // copyDockerignore copies a Dockerfile's sibling `.dockerignore` next to a @@ -322,9 +286,6 @@ func copyDockerignore(origDockerfile, newDockerfile string) { _ = os.WriteFile(newDockerfile+".dockerignore", data, 0644) } -// configRelativePath is TS getConfigFilePath (configuration.ts): a path the -// configuration names, resolved against the configuration's folder, so an -// absolute one stays as it is. func configRelativePath(cfg *config.DevContainer, p string) string { if filepath.IsAbs(p) { return filepath.Clean(p) @@ -332,13 +293,10 @@ func configRelativePath(cfg *config.DevContainer, p string) string { return filepath.Join(filepath.Dir(cfg.ConfigFilePath), p) } -// dockerfileConfigPath is TS getDockerfilePath: the configuration's Dockerfile. func dockerfileConfigPath(cfg *config.DevContainer) string { return configRelativePath(cfg, cfg.Dockerfile()) } -// dockerContextPath is TS getDockerContextPath (spec-node/utils.ts): the -// configured build context, else the Dockerfile's folder. func dockerContextPath(cfg *config.DevContainer) string { if c := cfg.BuildContext(); c != "" { return configRelativePath(cfg, c) @@ -346,11 +304,6 @@ func dockerContextPath(cfg *config.DevContainer) string { return filepath.Dir(dockerfileConfigPath(cfg)) } -// prepareDockerfileBuild resolves cfg's Dockerfile relative to the config dir, -// reads it, ensures the final stage has a name (writing a temp Dockerfile when a -// name must be injected), and parses it. The caller must defer prep.Cleanup(). -// It deliberately stops before base-image resolution and metadata, which differ -// between the up and build flows. func prepareDockerfileBuild(cfg *config.DevContainer) (*dockerfileBuildPrep, error) { dockerfilePath := cfg.Dockerfile() if dockerfilePath == "" { @@ -382,8 +335,6 @@ func prepareDockerfileBuild(cfg *config.DevContainer) (*dockerfileBuildPrep, err if err := os.WriteFile(tmpDockerfile, []byte(modified), 0644); err != nil { return nil, fmt.Errorf("write build dockerfile: %w", err) } - // Mirror the user's sibling .dockerignore next to the - // renamed file so BuildKit still applies it (upstream #969). copyDockerignore(dockerfilePath, tmpDockerfile) prep.cleanup = func() { os.Remove(tmpDockerfile) @@ -397,8 +348,6 @@ 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, @@ -416,9 +365,6 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai dockerfilePath := prep.Path stageName := prep.StageName - // The base image is the FROM of the config's target, else of the last stage - // (TS getImageBuildInfoFromDockerfile with config.build.target), not of the - // stage name added to the Dockerfile. findTarget := "" if cfg.Build != nil { findTarget = cfg.Build.Target @@ -426,35 +372,24 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai baseImage := docker.FindBaseImage(prep.Parsed, buildArgsFromConfig(cfg), findTarget, buildxPlatformArgs(opts.platform)) logger.Write(fmt.Sprintf("Base image: %s", baseImage), log.LevelInfo) - // Generate base-image metadata. Ensure the base image is available locally so - // metadata from the published image label is preserved. baseLabels := engine.ImageLabelsEnsuringPresent(ctx, baseImage) baseMetadata := imagemeta.ReadMetadataFromLabels(baseLabels, logger) stageMetadata := imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger) - // Determine image names (match TS getFolderImageName: vsc-{basename}-{hash}) imageNames := imageNamesOr(opts.imageNames, folderImageName(resolvePath(opts.workspaceFolder))) contextPath := dockerContextPath(cfg) - // Build args from config buildArgs := buildArgsFromConfig(cfg) - // Add metadata label allLabels := append(slices.Clone(opts.labels), imageLabelArgs(dockerfileImageMetadata(cfg, baseMetadata, stageMetadata, r.configLabel))...) baseOutput, basePush := dockerfileBaseExport(opts, cfg) cacheFrom := cacheFromForDockerfileBuild(opts.cacheFrom, cfg) - // Bridge the CLI credential chain to the build subprocess (private base pull / - // --push / --cache-to) via a temporary DOCKER_CONFIG. authEnv, authCleanup := bridgeBuildAuth(logger, baseImage, imageNames, cacheFrom, opts.cacheTo) defer authCleanup() - // Build secrets (Go extension; TS build has no --secrets-file). Passed to - // BuildKit as `--secret id=KEY,env=KEY`, so a Dockerfile can `RUN - // --mount=type=secret,id=KEY` without baking the value into a layer. Requires - // buildx; ignored (with a warning) otherwise. var buildSecrets []string if opts.secretsFile != "" { secrets, secErr := readSecretsFile(opts.secretsFile) @@ -493,13 +428,10 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai return nil, fmt.Errorf(msgDockerBuildFailed, result.ExitCode, string(result.Stderr)) } - // When streaming, the build output already reached the terminal live; only - // emit the buffered copy when it did not (e.g. --log-format json). if dockerClient.ProgressWriter == nil { logger.Write(string(result.Stderr), log.LevelInfo) } - // 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, r.featureBuildOptions(cfg, &loadResult.HostSub)) @@ -508,9 +440,6 @@ func (r *buildRunner) buildDockerfile(ctx context.Context, cfg *config.DevContai 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, @@ -523,8 +452,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return nil, fmt.Errorf("no image specified in devcontainer.json") } - // Pre-validate features before pulling the image, so local feature errors - // surface immediately instead of after an expensive pull. if err := checkLegacyFeatures(cfg.Features); err != nil { return nil, err } @@ -535,7 +462,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, imageNames := imageNamesOr(opts.imageNames, folderImage) featureImageNames := imageNamesOr(opts.imageNames, folderImage+"-features") - // Ensure image exists locally info, err := engine.InspectImage(ctx, cfg.Image) if err != nil { logger.Write(fmt.Sprintf("Pulling image %s...", cfg.Image), log.LevelInfo) @@ -546,8 +472,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return nil, fmt.Errorf("inspect image %q: %w", cfg.Image, err) } } - // The label of an image build without Features (TS getImageBuildOptions): - // the image's metadata, then the configuration's entry as written. var imageMetadata []imagemeta.Entry if info.Config != nil { imageMetadata = imagemeta.ReadMetadataFromLabels(info.Config.Labels, logger) @@ -559,8 +483,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return extendImageWithFeatures(ctx, logger, dockerClient, engine, cfg.Image, cfg.Features, useBuildx, featureImageNames, r.featureBuildOptions(cfg, &loadResult.HostSub)) case imageExported: - // --platform, --push, or --output still need a build: a minimal FROM - // Dockerfile carrying the metadata (TS getImageBuildOptions). allLabels := append(slices.Clone(opts.labels), imageLabelArgs(imageMetadata)...) tmpDir, tmpErr := os.MkdirTemp("", "devcontainer-image-build-") @@ -573,8 +495,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return nil, fmt.Errorf("write temp Dockerfile: %w", err) } - // Bridge auth for the push target / registry cache (base is local: cfg.Image - // was already pulled above via the engine). authEnv, authCleanup := bridgeBuildAuth(logger, "", featureImageNames, opts.cacheFrom, opts.cacheTo) defer authCleanup() @@ -603,7 +523,6 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return featureImageNames, nil case imageRelabeled: - // No platform/push — use temp Dockerfile when imageNames require labeling allLabels := append(slices.Clone(opts.labels), imageLabelArgs(imageMetadata)...) tmpDir, tmpErr := os.MkdirTemp("", "devcontainer-image-build-") @@ -636,22 +555,18 @@ func (r *buildRunner) buildImage(ctx context.Context, cfg *config.DevContainer, return imageNames, nil } - // 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 + imageAsIs imageBuild = iota + imageWithFeatures + imageExported + imageRelabeled ) -// 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: @@ -664,8 +579,6 @@ func imageBuildFor(cfg *config.DevContainer, opts *buildOpts) imageBuild { 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 { @@ -682,7 +595,6 @@ func checkLegacyFeatures(feats map[string]any) error { 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} @@ -703,11 +615,6 @@ func dockerfileImageMetadata(cfg *config.DevContainer, base, stage []imagemeta.E 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 @@ -715,8 +622,6 @@ func dockerfileBaseExport(opts *buildOpts, cfg *config.DevContainer) (output str 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 @@ -727,12 +632,6 @@ func buildxSecrets(secrets []string, useBuildx bool, logger log.Logger) []string 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 -// (string or array). It applies only to the build of the user's Dockerfile; the -// feature layers take the flag values alone (matching TS containerFeatures, which -// uses additionalCacheFroms only). func cacheFromForDockerfileBuild(flagCacheFrom []string, cfg *config.DevContainer) []string { if cfg == nil || cfg.Build == nil || len(cfg.Build.CacheFrom) == 0 { return flagCacheFrom @@ -750,8 +649,6 @@ func buildArgsFromConfig(cfg *config.DevContainer) map[string]string { return cfg.Build.Args } -// --- Output helpers --- - func resolvePath(p string) string { if filepath.IsAbs(p) { return p @@ -767,8 +664,6 @@ func buildOptionsFromConfig(cfg *config.DevContainer) []string { return cfg.Build.Options } -// writeSuccessJSON writes a command's result with its keys in order (see -// orderedObject). func writeSuccessJSON(out Output, data map[string]any, order ...string) error { b, err := stringify.JSON(orderedObject{data, order}, "") if err != nil { @@ -786,11 +681,6 @@ func writeErrorResult(out Output, description string) error { }) } -// writeValidationError reports a CLI argument/usage error the way yargs does in -// the TS CLI: the reason is printed to stderr and stdout is left empty (no JSON -// envelope). Reserved for the validation phase (flag parsing, enum checks, -// required args, format/implication checks) that runs before any runtime work. -// Runtime failures keep using writeErrorResult (JSON envelope on stdout). func writeValidationError(out Output, message string) error { fmt.Fprintln(out.Stderr(), message) return &coreerrors.ExitCodeError{Code: 1, Err: errors.New(message)} @@ -802,31 +692,16 @@ func writeErrorJSON(out Output, errOutput coreerrors.ErrorOutput) error { return &coreerrors.ExitCodeError{Code: 1, Err: errors.New(errOutput.Description)} } -// commandErrorOutput is the error result of up and build: a ContainerError (TS -// throws one with its own description, which the commands report as it is) -// as it is; an error carrying the reference's own message as TS reports any -// error it wraps, that message under "."; any other error -// described as ": ". func commandErrorOutput(err error, description string) coreerrors.ErrorOutput { if ce, ok := coreerrors.AsContainerError(err); ok { return coreerrors.ToErrorOutput(ce) } - // TS wraps a plain Error it throws as its originalError, so the message is - // that error's own text (a frozen lockfile's "Lockfile does not match.", a - // lockfile's JSON.parse error). if re, ok := errors.AsType[coreerrors.ReferenceError](err); ok { return coreerrors.ErrorOutput{Outcome: "error", Message: re.Error(), Description: description + "."} } return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: fmt.Sprintf("%s: %v", description, err)}) } -// buildCompose is TS build for a Docker Compose configuration: the service's -// image is built by `docker compose build` through the build override TS -// buildAndExtendDockerCompose writes, with the metadata label on the image -// (with or without Features, an image-only service included), so the image -// carries compose's project and service labels; then it is tagged with each -// --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, @@ -923,8 +798,6 @@ func (r *buildRunner) buildCompose(ctx context.Context, cfg *config.DevContainer return tags, nil } -// labelComposeImage builds `FROM imageName` with labels, tagged tags. -// // mutate-lane: TestE2E_ComposeBuildLabel - builds a compose image with --label // and reads the label back. func labelComposeImage(ctx context.Context, dockerClient *docker.Client, imageName string, tags, labels []string, useBuildx bool) error { @@ -953,10 +826,6 @@ func labelComposeImage(ctx context.Context, dockerClient *docker.Client, imageNa return nil } -// 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). The -// configuration's entry follows the Features' in the image's metadata label. func (r *buildRunner) featureBuildOptions(cfg *config.DevContainer, hostSub *config.HostSubContext) *FeatureBuildOptions { opts := r.opts return &FeatureBuildOptions{ diff --git a/internal/cli/build_auth.go b/internal/cli/build_auth.go index e9fa873..06fff50 100644 --- a/internal/cli/build_auth.go +++ b/internal/cli/build_auth.go @@ -7,14 +7,7 @@ import ( "github.com/devcontainers/cli/internal/oci" ) -// bridgeBuildAuth resolves credentials for every registry a build touches (base -// image, push tags, cache import/export) via the CLI's credential chain and, when -// any resolve, returns the subprocess env (DOCKER_CONFIG=) plus a cleanup -// func. It returns (nil, no-op) when there is nothing to bridge, leaving the -// 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()) { - // With no registry to bridge, ResolveBuildAuth resolves nothing and is not ok. regs := collectBuildRegistries(baseImage, tags, cacheFrom, cacheTo) dir, clean, ok, err := oci.ResolveBuildAuth(osEnvMap(), regs, logger) if err != nil || !ok { @@ -23,7 +16,6 @@ func bridgeBuildAuth(logger log.Logger, baseImage string, tags, cacheFrom []stri return []string{"DOCKER_CONFIG=" + dir}, clean } -// collectBuildRegistries returns the deduped registry hosts referenced by a build. func collectBuildRegistries(baseImage string, tags, cacheFrom []string, cacheTo string) []string { var out []string seen := map[string]bool{} @@ -64,9 +56,6 @@ func registryHostFromImageRef(ref string) string { return "" } -// registryCacheRef pulls the image reference out of a buildx cache spec. A -// registry cache is written as "type=registry,ref=[,...]"; anything else -// (type=local/inline/gha, or a bare image) is returned as-is. func registryCacheRef(spec string) string { spec = strings.TrimSpace(spec) if spec == "" || !strings.Contains(spec, "=") { diff --git a/internal/cli/build_auth_held_test.go b/internal/cli/build_auth_held_test.go index 5a6be44..76c821c 100644 --- a/internal/cli/build_auth_held_test.go +++ b/internal/cli/build_auth_held_test.go @@ -9,8 +9,6 @@ import ( "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() @@ -22,8 +20,6 @@ func isolateBuildAuth(t *testing.T, ociAuth string) { 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, "") @@ -33,7 +29,6 @@ func TestBridgeBuildAuthWithoutCredentials(t *testing.T) { } } -// 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, "") diff --git a/internal/cli/build_auth_test.go b/internal/cli/build_auth_test.go index f24ec06..4df5008 100644 --- a/internal/cli/build_auth_test.go +++ b/internal/cli/build_auth_test.go @@ -12,12 +12,11 @@ func TestRegistryHostFromImageRef(t *testing.T) { "myreg.example.com:5000/team/app": "myreg.example.com:5000", "localhost:5000/app": "localhost:5000", "123456.dkr.ecr.us-east-1.amazonaws.com/repo:1": "123456.dkr.ecr.us-east-1.amazonaws.com", - // Docker Hub forms → no explicit registry → "" (ambient auth applies). - "ubuntu:22.04": "", - "library/ubuntu": "", - "org/img:tag": "", - "mcr": "", - "": "", + "ubuntu:22.04": "", + "library/ubuntu": "", + "org/img:tag": "", + "mcr": "", + "": "", } for ref, want := range cases { if got := registryHostFromImageRef(ref); got != want { @@ -30,9 +29,9 @@ func TestRegistryCacheRef(t *testing.T) { cases := map[string]string{ "type=registry,ref=ghcr.io/org/cache:main": "ghcr.io/org/cache:main", "type=registry,ref=myreg.com/c,mode=max": "myreg.com/c", - "type=local,dest=/tmp/c": "", // not a registry cache + "type=local,dest=/tmp/c": "", "type=inline": "", - "ghcr.io/org/cache:main": "ghcr.io/org/cache:main", // bare ref + "ghcr.io/org/cache:main": "ghcr.io/org/cache:main", "": "", } for spec, want := range cases { @@ -44,10 +43,10 @@ func TestRegistryCacheRef(t *testing.T) { func TestCollectBuildRegistries(t *testing.T) { got := collectBuildRegistries( - "ghcr.io/base/img:1", // base - []string{"ghcr.io/base/img:1", "reg.example.com/app:2", "ubuntu"}, // tags (dup base, hub) - []string{"type=registry,ref=cache.example.com/c:main", "type=local,dest=/x"}, // cacheFrom - "type=registry,ref=cache.example.com/c:main", // cacheTo (dup of cacheFrom) + "ghcr.io/base/img:1", + []string{"ghcr.io/base/img:1", "reg.example.com/app:2", "ubuntu"}, + []string{"type=registry,ref=cache.example.com/c:main", "type=local,dest=/x"}, + "type=registry,ref=cache.example.com/c:main", ) want := []string{"ghcr.io", "reg.example.com", "cache.example.com"} if !reflect.DeepEqual(got, want) { @@ -56,7 +55,6 @@ func TestCollectBuildRegistries(t *testing.T) { } func TestCollectBuildRegistriesEmpty(t *testing.T) { - // Only Docker Hub / local caches → nothing to bridge. if got := collectBuildRegistries("ubuntu:22.04", []string{"org/app"}, []string{"type=inline"}, ""); len(got) != 0 { t.Fatalf("expected no registries, got %v", got) } diff --git a/internal/cli/build_cachefrom_test.go b/internal/cli/build_cachefrom_test.go index bddd86d..7317f65 100644 --- a/internal/cli/build_cachefrom_test.go +++ b/internal/cli/build_cachefrom_test.go @@ -7,9 +7,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// TestCacheFromForDockerfileBuild pins the merge of the --cache-from flag with -// devcontainer.json build.cacheFrom: flag values first, then config values, in -// order — matching TS singleContainer (additionalCacheFroms, then config.build.cacheFrom). func TestCacheFromForDockerfileBuild(t *testing.T) { cfgWith := func(cf ...string) *config.DevContainer { return &config.DevContainer{Build: &config.Build{CacheFrom: config.StringOrStrings(cf)}} @@ -38,9 +35,6 @@ func TestCacheFromForDockerfileBuild(t *testing.T) { } } -// TestCacheFromForDockerfileBuildDoesNotMutateFlag guards against the helper -// aliasing/appending into the caller's flag slice (which extendImageWithFeatures -// still passes verbatim for the feature layers). func TestCacheFromForDockerfileBuildDoesNotMutateFlag(t *testing.T) { flagBacking := []string{"flag1", "sentinel"} flag := flagBacking[:1] diff --git a/internal/cli/build_compose_path_test.go b/internal/cli/build_compose_path_test.go index e88bc63..3e6f906 100644 --- a/internal/cli/build_compose_path_test.go +++ b/internal/cli/build_compose_path_test.go @@ -9,13 +9,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// TestBuildComposePathWiring verifies that the `build` command threads -// --docker-compose-path into docker.NewComposeClient. When the call site -// hardcodes "" instead, the operator-supplied path could never appear in the -// compose-client detection error ("neither 'docker compose' nor '' -// found"). The test is hermetic: dockerPath points at a nonexistent binary so -// the `docker compose` (v2) probe always fails regardless of the host, forcing -// NewComposeClient down the custom-path branch. func TestBuildComposePathWiring(t *testing.T) { tests := []struct { name string @@ -39,8 +32,6 @@ func TestBuildComposePathWiring(t *testing.T) { r := &buildRunner{ log: log.New(log.Options{Writer: io.Discard}), opts: &buildOpts{ - // Force the `docker compose` (v2) probe to fail hermetically, - // independent of whether Docker is installed on the host. dockerPath: "/nonexistent/docker-binary", dockerComposePath: tt.dockerComposePath, }, diff --git a/internal/cli/build_held_test.go b/internal/cli/build_held_test.go index a36af09..5fc40df 100644 --- a/internal/cli/build_held_test.go +++ b/internal/cli/build_held_test.go @@ -17,8 +17,6 @@ import ( 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 @@ -187,9 +185,6 @@ func TestBuildxSecrets(t *testing.T) { } } -// --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") @@ -227,9 +222,6 @@ func TestRunBuildBuildKitNeverIgnoresBuildx(t *testing.T) { } } -// The Dockerfile build targets the config's target when it names one, else -// the final stage, named when it has no name; a Dockerfile without FROM is -// TS's error. func TestPrepareDockerfileBuildStage(t *testing.T) { ws := writeConfigFixture(t, map[string]string{ ".devcontainer/Dockerfile": "FROM alpine:3 AS base\nFROM base\n", @@ -257,9 +249,6 @@ func TestPrepareDockerfileBuildStage(t *testing.T) { } } -// A Compose build without --image-name reports its image as a string, as TS -// doBuild does (imageNameResult = originalImageName); with --image-name, and -// for any other configuration, the names are an array. func TestBuildResultImageName(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/cli/build_nodocker_held_test.go b/internal/cli/build_nodocker_held_test.go index 7083a41..e3d0409 100644 --- a/internal/cli/build_nodocker_held_test.go +++ b/internal/cli/build_nodocker_held_test.go @@ -7,9 +7,6 @@ import ( "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 diff --git a/internal/cli/build_repo2_held_test.go b/internal/cli/build_repo2_held_test.go index 5505316..0491a50 100644 --- a/internal/cli/build_repo2_held_test.go +++ b/internal/cli/build_repo2_held_test.go @@ -10,9 +10,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// The Dockerfile written to name the final stage sits next to the user's with -// a copy of its .dockerignore, and Cleanup removes both and leaves the user's -// files alone. func TestPrepareDockerfileBuildCleanupRemovesTheWrittenFiles(t *testing.T) { dir := t.TempDir() dockerfile := filepath.Join(dir, "Dockerfile") diff --git a/internal/cli/build_repo_held_test.go b/internal/cli/build_repo_held_test.go index ce55dfa..72d0335 100644 --- a/internal/cli/build_repo_held_test.go +++ b/internal/cli/build_repo_held_test.go @@ -10,8 +10,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// buildConfigNotFound runs build with args and returns the path its "config -// not found" error names. func buildConfigNotFound(t *testing.T, args ...string) string { t.Helper() cmd := newBuildCmd() @@ -31,8 +29,6 @@ func buildConfigNotFound(t *testing.T, args ...string) string { return got.Message } -// build [path] builds the workspace the argument names, not the current -// directory; --workspace-folder wins over the argument. func TestBuildPositionalPathIsTheWorkspace(t *testing.T) { ws := t.TempDir() t.Chdir(t.TempDir()) @@ -45,8 +41,6 @@ func TestBuildPositionalPathIsTheWorkspace(t *testing.T) { } } -// A build target names the stage to build, and the Dockerfile is used as it -// is, with no stage name added. func TestPrepareDockerfileBuildUsesTheConfiguredTarget(t *testing.T) { dir := t.TempDir() dockerfile := filepath.Join(dir, "Dockerfile") @@ -67,9 +61,6 @@ func TestPrepareDockerfileBuildUsesTheConfiguredTarget(t *testing.T) { } } -// Without a target, the final stage gets a name to build, in a Dockerfile -// written next to the user's, whether the Dockerfile is named under build or -// at the top level. func TestPrepareDockerfileBuildNamesTheFinalStageWithoutATarget(t *testing.T) { for name, cfg := range map[string]config.DevContainer{ "build": {Build: &config.Build{Dockerfile: "Dockerfile"}}, diff --git a/internal/cli/cache_key.go b/internal/cli/cache_key.go index 0d71e29..7a47e47 100644 --- a/internal/cli/cache_key.go +++ b/internal/cli/cache_key.go @@ -11,18 +11,11 @@ import ( "github.com/devcontainers/cli/internal/features" ) -// proxyEnvKeys are the proxy-related environment variables folded into the cache -// key: they materially change what a build produces (proxies baked into package -// installs), so two otherwise-identical configs built behind different proxies -// must get different keys. var proxyEnvKeys = []string{ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "FTP_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "no_proxy", "ftp_proxy", "all_proxy", } -// cacheKeyInput is the canonical, deterministic pre-image of the cache key. Field -// order is fixed and every nested map is marshaled by encoding/json with sorted -// keys, so the same inputs always hash to the same value. type cacheKeyInput struct { Config json.RawMessage `json:"config"` Dockerfile string `json:"dockerfile,omitempty"` @@ -31,14 +24,6 @@ type cacheKeyInput struct { Proxy map[string]string `json:"proxy,omitempty"` } -// computeCacheKey returns a deterministic, content-addressed cache key for a -// resolved configuration: sha256 over the normalized devcontainer.json, the -// Dockerfile (when Dockerfile-based), the features lockfile (when present, which -// pins resolved digests), the build context path, and the proxy environment. -// -// It is hermetic — no network, no registry resolution — so the same inputs on -// any host yield the same key. Feature refs are hashed as declared; pin them (via -// @sha256 or a committed lockfile) for the key to track the exact feature bits. func computeCacheKey(result *config.LoadResult, env map[string]string) (string, error) { cfg := result.Config cfgJSON, err := json.Marshal(cfg) @@ -48,9 +33,6 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, in := cacheKeyInput{Config: cfgJSON, Context: cfg.BuildContext()} if cfg.Dockerfile() != "" { - // The Dockerfile build reads (prepareDockerfileBuild). One that cannot - // be read fails the key, which would otherwise not track the image's - // recipe. content, err := os.ReadFile(dockerfileConfigPath(cfg)) if err != nil { return "", fmt.Errorf("read Dockerfile: %w", err) @@ -58,11 +40,6 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, in.Dockerfile = string(content) } - // ReadLockfile's bool reports an empty marker file, which pins nothing; a - // lockfile with content comes back as a non-nil lock. A lockfile that - // cannot be read or parsed fails the key, as it fails every reference - // command that reads it (lockfile.ts readLockfile rethrows all but ENOENT): - // a key computed without it would not track the pinned digests. lock, _, err := features.ReadLockfile(cfg.ConfigFilePath) if err != nil { return "", fmt.Errorf("read features lockfile %s: %w", features.LockfilePath(cfg.ConfigFilePath), err) @@ -93,8 +70,6 @@ func computeCacheKey(result *config.LoadResult, env map[string]string) (string, return "sha256:" + hex.EncodeToString(sum[:]), nil } -// proxyEnvFromEnviron snapshots only the proxy-related variables from the process -// environment (keeps computeCacheKey injectable in tests). func proxyEnvFromEnviron() map[string]string { m := map[string]string{} for _, k := range proxyEnvKeys { diff --git a/internal/cli/cache_key_repo_held_test.go b/internal/cli/cache_key_repo_held_test.go index 983946c..6218a65 100644 --- a/internal/cli/cache_key_repo_held_test.go +++ b/internal/cli/cache_key_repo_held_test.go @@ -9,9 +9,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// A committed features lockfile pins resolved digests, so changing it changes -// the cache key (docs/go-only-features.md, read-configuration --cache-key); an -// empty lockfile, which pins nothing, keys like no lockfile at all. func TestComputeCacheKeyFoldsInTheLockfile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "devcontainer.json") @@ -51,8 +48,6 @@ func TestComputeCacheKeyFoldsInTheLockfile(t *testing.T) { } } -// A proxy variable that is set but empty configures no proxy, so it keys like -// an unset one. func TestComputeCacheKeyIgnoresEmptyProxy(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "devcontainer.json") base, err := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil) @@ -64,9 +59,6 @@ func TestComputeCacheKeyIgnoresEmptyProxy(t *testing.T) { } } -// A lockfile that is not JSON fails the key instead of keying as if no -// lockfile were committed: the reference's readLockfile rethrows the parse -// error, and a key without the lockfile would not track its pinned digests. func TestComputeCacheKeyFailsOnAMalformedLockfile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "devcontainer.json") @@ -83,8 +75,6 @@ func TestComputeCacheKeyFailsOnAMalformedLockfile(t *testing.T) { } } -// A lockfile that exists but cannot be read fails the key too: only a missing -// lockfile (ENOENT) means none is committed. func TestComputeCacheKeyFailsOnAnUnreadableLockfile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "devcontainer.json") @@ -97,8 +87,6 @@ func TestComputeCacheKeyFailsOnAnUnreadableLockfile(t *testing.T) { } } -// A Dockerfile-based configuration whose Dockerfile cannot be read fails the -// key instead of keying as if it had no Dockerfile, naming the file. func TestComputeCacheKeyFailsOnAMissingDockerfile(t *testing.T) { dir := t.TempDir() r := &config.LoadResult{Config: &config.DevContainer{ diff --git a/internal/cli/cache_key_test.go b/internal/cli/cache_key_test.go index 1128737..e028f31 100644 --- a/internal/cli/cache_key_test.go +++ b/internal/cli/cache_key_test.go @@ -25,7 +25,6 @@ func TestComputeCacheKeyFormatAndDeterminism(t *testing.T) { if !regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(k1) { t.Fatalf("bad key format: %q", k1) } - // Recomputing the same inputs yields the same key. k2, _ := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil) if k1 != k2 { t.Fatalf("non-deterministic: %q != %q", k1, k2) @@ -37,15 +36,12 @@ func TestComputeCacheKeyChangesWithInputs(t *testing.T) { cfgPath := filepath.Join(dir, "devcontainer.json") base, _ := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), nil) - // Different image → different key. if k, _ := computeCacheKey(imageResult("ubuntu:24.04", cfgPath), nil); k == base { t.Error("image change did not affect key") } - // Proxy env → different key. if k, _ := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), map[string]string{"HTTP_PROXY": "http://p:3128"}); k == base { t.Error("proxy env did not affect key") } - // A non-proxy env var must NOT affect the key. if k, _ := computeCacheKey(imageResult("ubuntu:22.04", cfgPath), map[string]string{"UNRELATED": "x"}); k != base { t.Error("unrelated env changed the key") } @@ -67,7 +63,6 @@ func TestComputeCacheKeyIncludesDockerfile(t *testing.T) { t.Fatal(err) } - // Mutating the Dockerfile flips the key. if err := os.WriteFile(dfPath, []byte("FROM ubuntu:22.04\nRUN echo v2\n"), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/cli/check.go b/internal/cli/check.go index 6f23b89..c202784 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -20,9 +20,6 @@ type checkOpts struct { dockerPath string } -// newCheckCmd builds `devcontainer check`: it diagnoses the host's Docker / -// BuildKit environment, prints a ✔/⚠/✖ table with remediation hints, persists -// the result to the state file, and exits non-zero when a hard check fails. func newCheckCmd() *cobra.Command { var opts checkOpts cmd := &cobra.Command{ @@ -45,8 +42,6 @@ func runCheck(ctx context.Context, out Output, opts *checkOpts) error { env := &doctor.Env{DockerPath: opts.dockerPath, CLIVersion: product.Get().Version} report := doctor.Run(ctx, env) - // Persist best-effort: a diagnostics run must not fail because state could - // not be written (read-only HOME, etc.). Surface it as a note in text mode. saveErr := doctor.Save(report) if opts.json { @@ -72,12 +67,6 @@ type setupOpts struct { dockerPath string } -// newSetupCmd builds `devcontainer setup`: it runs the same diagnostics and then -// applies the automatic remediations (creating a cache-capable buildx builder), -// reporting manual steps for anything it cannot fix without sudo. -// -// Note: this is distinct from `set-up` (hyphenated), which sets up an existing -// container as a dev container. func newSetupCmd() *cobra.Command { var opts setupOpts cmd := &cobra.Command{ @@ -102,8 +91,6 @@ func runSetup(ctx context.Context, out Output, opts *setupOpts) error { report := doctor.Run(ctx, env) actions := doctor.Setup(ctx, env, report, opts.dryRun) - // 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 setupNeedsRecheck(opts.dryRun, actions) { final = doctor.Run(ctx, env) @@ -111,15 +98,10 @@ func runSetup(ctx context.Context, out Output, opts *setupOpts) error { 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 { @@ -145,7 +127,6 @@ func writeSetupResult(out Output, opts *setupOpts, actions []doctor.Action, fina return nil } -// writeReportTable renders the ✔/⚠/✖ table plus per-line remediation hints. func writeReportTable(w io.Writer, report doctor.Report) { // mutate-exempt: a minwidth of 1 is below every cell's width plus the padding of 2, tabwidth counts only with '\t' padding, and flag 1 (FilterHTML) re-measures only HTML, which no status symbol or check name holds const minwidth, tabwidth, flags = 0, 0, 0 @@ -162,7 +143,6 @@ func writeReportTable(w io.Writer, report doctor.Report) { fmt.Fprintf(w, "\noverall: %s %s\n", report.Overall.Symbol(), report.Overall) } -// writeSetupActions renders the remediation steps taken (or planned in dry-run). func writeSetupActions(w io.Writer, actions []doctor.Action, dryRun bool) { if len(actions) == 0 { fmt.Fprintln(w, "Nothing to do — the host is already configured.") @@ -191,10 +171,6 @@ func writeJSON(w io.Writer, v any) error { return enc.Encode(v) } -// warnUncheckedHost emits a one-line, non-blocking hint on interactive stderr -// when the host was never checked, or a previous `check` found a hard failure. -// It is gated on a TTY so it never perturbs captured output (parity harness, -// scripts) — only humans at a terminal see it. func warnUncheckedHost(out Output) { if !isTerminalWriter(out.Stderr()) { return @@ -212,7 +188,6 @@ func warnUncheckedHost(out Output) { } } -// isTerminalWriter reports whether w is a character device (a real terminal). func isTerminalWriter(w any) bool { f, ok := w.(*os.File) if !ok { diff --git a/internal/cli/check_held_test.go b/internal/cli/check_held_test.go index 87a027c..63df913 100644 --- a/internal/cli/check_held_test.go +++ b/internal/cli/check_held_test.go @@ -12,7 +12,6 @@ import ( "github.com/devcontainers/cli/internal/doctor" ) -// brokenStdout is an Output whose stdout refuses every write. type brokenStdout struct{ captureOutput } type refusingWriter struct{} @@ -21,8 +20,6 @@ func (refusingWriter) Write([]byte) (int, error) { return 0, errors.New("stdout 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"}} @@ -44,8 +41,6 @@ func TestSetupNeedsRecheck(t *testing.T) { } } -// --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"}} @@ -69,7 +64,6 @@ func TestWriteSetupResultJSON(t *testing.T) { } } -// 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") { @@ -77,8 +71,6 @@ func TestWriteSetupResultJSONWriteError(t *testing.T) { } } -// 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"}} @@ -101,7 +93,6 @@ func TestWriteSetupResultText(t *testing.T) { } } -// The table pads its columns two spaces past their widest cell. func TestWriteReportTableAlignsTheColumns(t *testing.T) { var buf strings.Builder writeReportTable(&buf, sampleReport()) @@ -114,15 +105,12 @@ func TestWriteReportTableAlignsTheColumns(t *testing.T) { } } -// runCheckWithoutDocker runs check against a docker CLI that does not exist, -// so the daemon check fails, with the check state under stateHome. func runCheckWithoutDocker(t *testing.T, out Output, json bool, stateHome string) error { t.Helper() t.Setenv("XDG_STATE_HOME", stateHome) return runCheck(t.Context(), out, &checkOpts{json: json, dockerPath: filepath.Join(t.TempDir(), "docker")}) } -// A failing host prints the table, saves the state and exits 1. func TestRunCheckFailingHostExitsOne(t *testing.T) { state := t.TempDir() out := &captureOutput{} @@ -142,7 +130,6 @@ func TestRunCheckFailingHostExitsOne(t *testing.T) { } } -// A state that cannot be saved is a note on stderr, not a failure of its own. func TestRunCheckNotesAStateItCouldNotSave(t *testing.T) { blocker := filepath.Join(t.TempDir(), "file") if err := os.WriteFile(blocker, nil, 0o644); err != nil { @@ -158,8 +145,6 @@ func TestRunCheckNotesAStateItCouldNotSave(t *testing.T) { } } -// --json prints the report as JSON and still exits 1 for a failing host; a -// stdout that cannot be written fails with that error. func TestRunCheckJSON(t *testing.T) { out := &captureOutput{} err := runCheckWithoutDocker(t, out, true, t.TempDir()) diff --git a/internal/cli/check_repo_held_test.go b/internal/cli/check_repo_held_test.go index 827cff1..568256e 100644 --- a/internal/cli/check_repo_held_test.go +++ b/internal/cli/check_repo_held_test.go @@ -16,8 +16,6 @@ import ( "github.com/devcontainers/cli/internal/doctor" ) -// openPTY opens a pseudo-terminal: what is written to the returned terminal -// can be read back from the returned master. func openPTY(t *testing.T) (master, terminal *os.File) { t.Helper() master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) @@ -46,8 +44,6 @@ type terminalStderr struct{ terminal *os.File } func (terminalStderr) Stdout() io.Writer { return io.Discard } func (o terminalStderr) Stderr() io.Writer { return o.terminal } -// warnOnTerminal runs warnUncheckedHost with a terminal for stderr and the -// check state under stateHome, and returns what it wrote there. func warnOnTerminal(t *testing.T, stateHome string) string { t.Helper() t.Setenv("XDG_STATE_HOME", stateHome) @@ -79,9 +75,6 @@ func saveCheckState(t *testing.T, overall doctor.Status) string { return home } -// On a terminal, up and build hint at `check` when the host was never checked, -// warn when the last check failed, and say nothing after a passing check or -// when the state cannot be read. func TestWarnUncheckedHostOnATerminal(t *testing.T) { unreadable := t.TempDir() if err := os.MkdirAll(filepath.Join(unreadable, "devcontainer", "check.json"), 0o755); err != nil { diff --git a/internal/cli/check_terminal_held_test.go b/internal/cli/check_terminal_held_test.go index c0909e1..820eb34 100644 --- a/internal/cli/check_terminal_held_test.go +++ b/internal/cli/check_terminal_held_test.go @@ -6,8 +6,6 @@ import ( "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 { diff --git a/internal/cli/check_test.go b/internal/cli/check_test.go index b8393c1..0947515 100644 --- a/internal/cli/check_test.go +++ b/internal/cli/check_test.go @@ -60,9 +60,6 @@ func TestWriteSetupActions(t *testing.T) { } } -// TestWarnUncheckedHostSilentOnNonTerminal proves the up/build hint never writes -// to a captured (non-TTY) stderr — the invariant that keeps it out of the parity -// harness / scripted output. func TestWarnUncheckedHostSilentOnNonTerminal(t *testing.T) { var stdout, stderr bytes.Buffer out := &bufOutput{stdout: &stdout, stderr: &stderr} @@ -78,7 +75,6 @@ func TestIsTerminalWriterFalseForBuffer(t *testing.T) { } } -// bufOutput adapts two buffers to the Output seam. type bufOutput struct{ stdout, stderr *bytes.Buffer } func (b *bufOutput) Stdout() io.Writer { return b.stdout } diff --git a/internal/cli/cli_parity_test.go b/internal/cli/cli_parity_test.go index 0add28a..f2cc284 100644 --- a/internal/cli/cli_parity_test.go +++ b/internal/cli/cli_parity_test.go @@ -9,7 +9,6 @@ import ( "testing" ) -// TestBuildCommand_HelpOutput verifies the build command has expected flags. func TestBuildCommand_HelpOutput(t *testing.T) { root := NewRootCommand() root.SetArgs([]string{"build", "--help"}) @@ -26,16 +25,12 @@ 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]any { t.Helper() fixtureDir := filepath.Join("..", "..", "src", "test", "configs", fixtureName) if _, err := os.Stat(fixtureDir); os.IsNotExist(err) { t.Skip("fixture not found: " + fixtureName) } - // read-configuration looks for the workspace's container, so it needs an - // engine to answer; this one has none. startFakeEngine(t, nil) old := os.Stdout @@ -60,8 +55,6 @@ func readConfigurationResult(t *testing.T, fixtureName string) map[string]any { } 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]any) { return func(t *testing.T, result map[string]any) { cfg, _ := result["configuration"].(map[string]any) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8c76107..b4dfb35 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -28,7 +28,6 @@ func TestRootCommand(t *testing.T) { check func(t *testing.T, out string) }{ {"version is bare", []string{"--version"}, func(t *testing.T, out string) { - // Bare version output (like the TS CLI), no " version" prefix. got := strings.TrimSpace(out) if got == "" || strings.Contains(got, " ") { t.Errorf("expected bare version output, got %q", out) @@ -95,7 +94,6 @@ func TestReadConfigurationFixtures(t *testing.T) { } func TestReadConfiguration_AllFixtures(t *testing.T) { - // read-configuration looks for the container; this engine has none. startFakeEngine(t, nil) fixturesDir := filepath.Join("..", "..", "src", "test", "configs") if _, err := os.Stat(fixturesDir); os.IsNotExist(err) { @@ -111,7 +109,6 @@ func TestReadConfiguration_AllFixtures(t *testing.T) { } dir := filepath.Join(fixturesDir, entry.Name()) - // Check if fixture has a devcontainer.json hasConfig := false for _, candidate := range []string{ filepath.Join(dir, ".devcontainer", "devcontainer.json"), @@ -127,8 +124,6 @@ func TestReadConfiguration_AllFixtures(t *testing.T) { continue } - // Capture stdout via the cli.Output seam (cmd.SetOut) instead of swapping - // the global os.Stdout, which is racy across parallel tests. var buf bytes.Buffer root := NewRootCommand() root.SetOut(&buf) diff --git a/internal/cli/collection_commands.go b/internal/cli/collection_commands.go index 0ed8ef2..0e61fd5 100644 --- a/internal/cli/collection_commands.go +++ b/internal/cli/collection_commands.go @@ -24,8 +24,6 @@ import ( "github.com/spf13/cobra" ) -// --- Features package --- - func realFeaturesPackageCmd() *cobra.Command { var ( outputFolder string @@ -50,10 +48,6 @@ func realFeaturesPackageCmd() *cobra.Command { return cmd } -// --- 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)) @@ -85,8 +79,6 @@ func realFeaturesPublishCmd() *cobra.Command { return cmd } -// --- Templates publish --- - func realTemplatesPublishCmd() *cobra.Command { var ( registry string @@ -111,8 +103,6 @@ func realTemplatesPublishCmd() *cobra.Command { return cmd } -// --- Generate docs --- - func realFeaturesGenerateDocsCmd() *cobra.Command { var ( projectFolder string @@ -182,8 +172,6 @@ func realTemplatesGenerateDocsCmd() *cobra.Command { return cmd } -// --- Features test --- - func realFeaturesTestCmd() *cobra.Command { var ( projectFolder string @@ -206,7 +194,6 @@ func realFeaturesTestCmd() *cobra.Command { Short: "Test Features", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Validate conflicting flags (matches TS behavior) if globalOnly && len(featuresList) > 0 { return fmt.Errorf("Cannot combine --global-scenarios-only and --features") } @@ -267,20 +254,10 @@ func realFeaturesTestCmd() *cobra.Command { return cmd } -// --- Shared implementation --- - -// 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 -// error path hermetically. func publishCollection(ctx context.Context, targetFolder, registry, namespace, collectionType, logLevelStr string) error { - // TS parity: yargs demandOption → "Missing required argument: namespace" - // (canonicalized by the harness), not cobra's "required flag(s) ... not set". if namespace == "" { return fmt.Errorf("Missing required argument: namespace") } - // 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, logLevels); err != nil { return err } @@ -300,8 +277,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta Writer: out.Stderr(), }) - // Package first. A failed MkdirTemp would otherwise leave tmpDir="" and scatter - // the packaged artifacts into the current directory. tmpDir, err := os.MkdirTemp("", fmt.Sprintf("%s-output-", collectionType)) if err != nil { return fmt.Errorf("create output dir: %w", err) @@ -309,7 +284,6 @@ 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) } @@ -318,7 +292,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta logger.Write(fmt.Sprintf("Publishing %ss from %s to %s/%s...", collectionType, targetFolder, registry, namespace), log.LevelInfo) - // Read collection metadata collMetaPath := filepath.Join(tmpDir, "devcontainer-collection.json") collData, err := os.ReadFile(collMetaPath) if err != nil { @@ -328,8 +301,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta var collMeta map[string]any json.Unmarshal(collData, &collMeta) - // 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].([]any) orderedColl := orderedmap.New() @@ -344,9 +315,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta } kind := strings.ToUpper(collectionType[:1]) + collectionType[1:] - // 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) @@ -410,8 +378,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta 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) @@ -438,8 +404,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta result[id] = this } - // 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)) @@ -456,9 +420,6 @@ func publishCollectionWith(ctx context.Context, out Output, reg oci.Registry, ta 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"` @@ -466,20 +427,12 @@ type publishItemResult struct { 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 -// buried in escaped Go string literals. -// //go:embed templates/feature-readme.tmpl var featuresReadmeTemplate string //go:embed templates/template-readme.tmpl var templatesReadmeTemplate string -// generateDocs is a faithful port of the TS _generateDocumentation: it iterates -// the DIRECT children of projectFolder (no auto-descent into src/), and for each -// child writes /README.md from the metadata file, preserving option order. func generateDocs(projectFolder, registry, namespace, githubOwner, githubRepo, collectionType, logLevelStr string) error { logger := log.New(log.Options{ Level: log.ParseLevel(logLevelStr), @@ -607,9 +560,6 @@ func renderDocsTemplate(template string, values map[string]string) string { return strings.NewReplacer(pairs...).Replace(template) } -// 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]any) string { optsVal, hasOptions := meta["options"] if !hasOptions { @@ -671,8 +621,6 @@ func generateDocsHeader(meta map[string]any) string { return h } -// jsTruthyString mirrors JS `value || fallback` for string cells: an empty or -// non-string value falls back. func jsTruthyString(v any, fallback string) string { if s, ok := v.(string); ok && s != "" { return s @@ -680,9 +628,6 @@ func jsTruthyString(v any, fallback string) string { return fallback } -// 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]any) string { d, ok := ov["default"] if !ok { @@ -706,7 +651,6 @@ func defaultCell(ov map[string]any) string { } } -// orderedObjectKeys returns the keys of a JSON object in source order. func orderedObjectKeys(raw json.RawMessage) []string { dec := json.NewDecoder(bytes.NewReader(raw)) t, err := dec.Token() @@ -745,14 +689,10 @@ func skipJSONValue(dec *json.Decoder) error { return err } } - _, err = dec.Token() // closing delim + _, err = dec.Token() return err } -// createTarArchive creates a plain (uncompressed) tar archive from a directory, -// with entries rooted at "./" — matching the TS CLI (tar.create({cwd}, ["."])) -// and the `+tar` layer media type. Not byte-reproducible (tar headers embed -// mtimes), mirroring the TS CLI, which is likewise non-deterministic. func createTarArchive(archivePath, sourceDir string) error { file, err := os.Create(archivePath) if err != nil { @@ -777,8 +717,6 @@ func createTarArchive(archivePath, sourceDir string) error { // empty for a closer byte match. header.Uname = "" header.Gname = "" - // Root as "./", children as "./" with forward slashes; directories - // keep a trailing slash. if relPath == "." { header.Name = "./" } else { @@ -809,9 +747,6 @@ func createTarArchive(archivePath, sourceDir string) error { return walkErr } - // Close flushes the tar footer/padding; a deferred Close would drop that - // error and let us push a truncated archive to the registry as if it were - // whole. if err := tw.Close(); err != nil { return fmt.Errorf("finalize tar: %w", err) } diff --git a/internal/cli/collection_commands_docs_held_test.go b/internal/cli/collection_commands_docs_held_test.go index 8b60276..bb3c081 100644 --- a/internal/cli/collection_commands_docs_held_test.go +++ b/internal/cli/collection_commands_docs_held_test.go @@ -66,9 +66,6 @@ func TestOptionCells(t *testing.T) { } } -// 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"}`) diff --git a/internal/cli/collection_commands_held_test.go b/internal/cli/collection_commands_held_test.go index fb7c31b..9278d41 100644 --- a/internal/cli/collection_commands_held_test.go +++ b/internal/cli/collection_commands_held_test.go @@ -12,13 +12,10 @@ import ( "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 + annotations map[string]string } func newPushStub() *pushStub { @@ -38,8 +35,6 @@ func (s *pushStub) PushArtifact(_ context.Context, ref *oci.Ref, _ string, tags 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) @@ -69,7 +64,6 @@ func TestRequireTarget(t *testing.T) { } } -// 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"}`) @@ -84,8 +78,6 @@ func TestFeaturesPackageCommandPackagesTarget(t *testing.T) { } } -// 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() @@ -107,8 +99,6 @@ func TestPublishCommandsRequireNamespace(t *testing.T) { } } -// 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 @@ -126,8 +116,6 @@ func TestPublishCollectionPackagingFailureLine(t *testing.T) { } } -// 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"}`) @@ -141,7 +129,6 @@ func TestPublishCollectionUnparsableResource(t *testing.T) { } } -// 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"}`) @@ -154,9 +141,6 @@ func TestPublishCollectionAnnotationKeepsKeyOrder(t *testing.T) { } } -// 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"]}`) @@ -187,8 +171,6 @@ func TestPublishCollectionPublishedNeedsDigestAndTags(t *testing.T) { } } -// 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"]}`) @@ -205,7 +187,6 @@ func TestPublishCollectionLegacyIDs(t *testing.T) { } } -// 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") diff --git a/internal/cli/collection_commands_repo_held_test.go b/internal/cli/collection_commands_repo_held_test.go index 7183d1e..e0f8a87 100644 --- a/internal/cli/collection_commands_repo_held_test.go +++ b/internal/cli/collection_commands_repo_held_test.go @@ -15,9 +15,6 @@ import ( "github.com/spf13/cobra" ) -// generate-docs documents the folder its positional argument names, else the -// one --project-folder names; the working directory (the default ".") is left -// alone either way. func TestGenerateDocsCommandsProjectFolder(t *testing.T) { t.Chdir(t.TempDir()) writeFeature(t, ".", "decoy", `{"id":"decoy","version":"1.0.0","name":"Decoy"}`) @@ -69,8 +66,6 @@ func runFeaturesTestCmd(t *testing.T, args ...string) (string, error) { return out.String(), err } -// features test refuses the flag combinations TS refuses, before it looks at -// the collection; each flag alone is accepted and reaches the collection check. func TestFeaturesTestCommandFlagConflicts(t *testing.T) { empty := t.TempDir() conflicts := map[string][]string{ @@ -89,9 +84,6 @@ func TestFeaturesTestCommandFlagConflicts(t *testing.T) { } } -// features test checks the collection its positional target names, or -// --project-folder's when the target is missing or "."; a collection without -// src/ and test/ exits 1 naming the folder on stdout. func TestFeaturesTestCommandTarget(t *testing.T) { project, target := t.TempDir(), t.TempDir() cases := []struct { @@ -113,8 +105,6 @@ func TestFeaturesTestCommandTarget(t *testing.T) { } } -// orderedObjectKeys lists an object's own keys in source order, whatever its -// values nest, and nothing for anything but an object. func TestOrderedObjectKeys(t *testing.T) { raw := `{"z":{"x":"y","k":{"deep":[1,{"q":"r"}]}},"a":["s",{"t":"u"}],"m":"v","b":null}` if got, want := orderedObjectKeys(json.RawMessage(raw)), []string{"z", "a", "m", "b"}; !slices.Equal(got, want) { @@ -127,9 +117,6 @@ func TestOrderedObjectKeys(t *testing.T) { } } -// createTarArchive roots the entries at "./", with forward slashes and a -// trailing slash on directories, and records no user or group names, as -// node-tar does. func TestCreateTarArchiveEntries(t *testing.T) { src := t.TempDir() if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil { @@ -170,8 +157,6 @@ func TestCreateTarArchiveEntries(t *testing.T) { if want := []string{"./", "./sub/", "./sub/install.sh"}; !slices.Equal(names, want) { t.Errorf("entries = %q, want %q", names, want) } - // The archive is finished: whole 512-byte blocks ending in the two zero - // blocks that mark the end of a tar. data, err := os.ReadFile(archive) if err != nil { t.Fatal(err) diff --git a/internal/cli/collection_commands_test.go b/internal/cli/collection_commands_test.go index 45ee34c..d541151 100644 --- a/internal/cli/collection_commands_test.go +++ b/internal/cli/collection_commands_test.go @@ -17,7 +17,6 @@ func TestRenderDocsTemplate(t *testing.T) { } } -// writeFeature writes a devcontainer-feature.json into //. func writeFeature(t *testing.T, base, name, json string) { t.Helper() dir := filepath.Join(base, name) @@ -31,9 +30,6 @@ func writeFeature(t *testing.T, base, name, json string) { func TestGenerateDocs_FeatureReadme(t *testing.T) { base := t.TempDir() - // Options are deliberately NOT alphabetical: zeta before alpha. TS preserves the - // JSON insertion order, so the rows must come out zeta then alpha (a sorted map - // would flip them). writeFeature(t, base, "mytool", `{ "id": "mytool", "version": "1.2.3", @@ -55,8 +51,6 @@ func TestGenerateDocs_FeatureReadme(t *testing.T) { } readme := string(got) - // The insertion-order guarantee is a positional check, kept separate: the zeta - // row must precede the alpha row (a sorted map would flip them). zeta := strings.Index(readme, "| zeta |") alpha := strings.Index(readme, "| alpha |") if zeta < 0 || alpha < 0 || zeta > alpha { @@ -78,16 +72,12 @@ func TestGenerateDocs_FeatureReadme(t *testing.T) { } } -// TestGenerateDocs_SingleFeatureFolder covers upstream #876: pointing at a -// folder that directly holds devcontainer-feature.json documents THAT folder, -// instead of iterating its own files as if each were a separate feature. func TestGenerateDocs_SingleFeatureFolder(t *testing.T) { base := t.TempDir() if err := os.WriteFile(filepath.Join(base, "devcontainer-feature.json"), []byte(`{"id":"solo","version":"1.0.0","name":"Solo"}`), 0o644); err != nil { t.Fatal(err) } - // A sibling file that used to be (wrongly) treated as a sub-feature directory. if err := os.WriteFile(filepath.Join(base, "install.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { t.Fatal(err) } @@ -102,14 +92,11 @@ func TestGenerateDocs_SingleFeatureFolder(t *testing.T) { if !strings.Contains(string(got), "# Solo (solo)") { t.Errorf("#876 README heading missing:\n%s", got) } - // The sibling file must not have been treated as a feature directory. if _, err := os.Stat(filepath.Join(base, "install.sh", "README.md")); err == nil { t.Error("#876 a non-directory sibling was treated as a feature folder") } } -// TestGenerateDocs_IgnoresTopLevelFiles covers upstream #876: top-level files -// (not directories) must never be treated as collection members. func TestGenerateDocs_IgnoresTopLevelFiles(t *testing.T) { base := t.TempDir() writeFeature(t, base, "realfeat", `{"id":"realfeat","version":"1.0.0","name":"Real"}`) @@ -120,11 +107,9 @@ func TestGenerateDocs_IgnoresTopLevelFiles(t *testing.T) { if err := generateDocs(base, "ghcr.io", "o/r", "", "", "feature", "info"); err != nil { t.Fatalf("stray top-level file must not error: %v", err) } - // The real feature still got its README... if _, err := os.Stat(filepath.Join(base, "realfeat", "README.md")); err != nil { t.Errorf("real feature README missing: %v", err) } - // ...and the top-level README.md was left untouched (not overwritten from a template). if b, _ := os.ReadFile(filepath.Join(base, "README.md")); string(b) != "top-level readme\n" { t.Errorf("#876 top-level README.md was clobbered: %q", b) } @@ -132,7 +117,6 @@ func TestGenerateDocs_IgnoresTopLevelFiles(t *testing.T) { func TestGenerateDocs_SkipsMissingMetadata(t *testing.T) { base := t.TempDir() - // A directory without a devcontainer-feature.json must be skipped, not error. if err := os.MkdirAll(filepath.Join(base, "not-a-feature"), 0o755); err != nil { t.Fatal(err) } diff --git a/internal/cli/collection_package.go b/internal/cli/collection_package.go index d446da6..a3831fe 100644 --- a/internal/cli/collection_package.go +++ b/internal/cli/collection_package.go @@ -20,15 +20,7 @@ import ( "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 } @@ -65,8 +57,6 @@ func packageCollection(targetFolder, outputDir, collectionType string, forceClea 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) @@ -95,11 +85,6 @@ func packageSingleItem(logger log.Logger, targetFolder, outputDir, collectionTyp 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) @@ -161,9 +146,6 @@ func packageCollectionItem(logger log.Logger, folder, name, outputDir, collectio 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": @@ -175,9 +157,6 @@ func completeItemMetadata(logger log.Logger, srcFolder, jsonPath, collectionType 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 { @@ -193,10 +172,6 @@ func addFeatureCurrentID(logger log.Logger, jsonPath string) { 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) { @@ -273,10 +248,6 @@ func addTemplateProps(logger log.Logger, srcFolder, jsonPath string) bool { 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 { @@ -294,10 +265,6 @@ func templateFiles(root, rel string) []any { 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 { @@ -316,16 +283,12 @@ 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)) @@ -427,8 +390,6 @@ func hasKey(m map[string]any, key string) bool { 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 { @@ -447,8 +408,6 @@ func jsTruthy(m *orderedmap.OrderedMap, key string) bool { 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 { diff --git a/internal/cli/collection_package_held_test.go b/internal/cli/collection_package_held_test.go index 197e5fc..a26a894 100644 --- a/internal/cli/collection_package_held_test.go +++ b/internal/cli/collection_package_held_test.go @@ -6,8 +6,6 @@ import ( "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) @@ -24,8 +22,6 @@ func TestPackageSingleFeature(t *testing.T) { } } -// 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"}`, @@ -51,7 +47,6 @@ func TestPackageRequiredProperties(t *testing.T) { } } -// 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) @@ -65,8 +60,6 @@ func TestPackageEmptyLegacyIDsAddsNoCurrentID(t *testing.T) { } } -// 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") diff --git a/internal/cli/collection_package_nodocker_held_test.go b/internal/cli/collection_package_nodocker_held_test.go index a2812dd..cf864c3 100644 --- a/internal/cli/collection_package_nodocker_held_test.go +++ b/internal/cli/collection_package_nodocker_held_test.go @@ -9,10 +9,6 @@ import ( "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 diff --git a/internal/cli/collection_package_test.go b/internal/cli/collection_package_test.go index b9e6d18..366dd3c 100644 --- a/internal/cli/collection_package_test.go +++ b/internal/cli/collection_package_test.go @@ -33,7 +33,6 @@ func readCollection(t *testing.T, outDir string) string { 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) @@ -43,7 +42,6 @@ func tarFile(t *testing.T, archive, name string) string { 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) } @@ -71,11 +69,6 @@ func exitCode(err error) int { 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) @@ -119,17 +112,11 @@ func TestPackageFeatureCollectionAsTS(t *testing.T) { 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") } @@ -169,10 +156,6 @@ func TestPackageFailuresAsTS(t *testing.T) { } } -// 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") @@ -217,7 +200,6 @@ func TestPackageTemplateAddsTSProps(t *testing.T) { } } -// 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 432a7e1..96db612 100644 --- a/internal/cli/collection_publish_partial_test.go +++ b/internal/cli/collection_publish_partial_test.go @@ -13,10 +13,6 @@ import ( "oras.land/oras-go/v2/registry/remote/errcode" ) -// TestPublishCollection_AbortsOnTagListError covers the finding that publish must -// NOT treat a failed tag lookup as an empty registry: a non-404 error (auth, 5xx, -// network) has to abort the item — otherwise mobile aliases would be recomputed -// off an empty list and could clobber existing ones. func TestPublishCollection_AbortsOnTagListError(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") @@ -35,15 +31,11 @@ func TestPublishCollection_AbortsOnTagListError(t *testing.T) { 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 { t.Fatalf("artifact pushed despite a tag-list error: %v", reg.pushed) } } -// TestPublishCollection_TreatsNotFoundAsFirstPublish covers the complementary -// case: a 404 from the tag listing means the repository does not exist yet, so -// the item publishes normally as a first publish. func TestPublishCollection_TreatsNotFoundAsFirstPublish(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") @@ -66,10 +58,6 @@ func TestPublishCollection_TreatsNotFoundAsFirstPublish(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") @@ -97,10 +85,6 @@ func TestPublishCollection_CollectionMetadataFailureExits(t *testing.T) { } } -// 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") @@ -114,8 +98,6 @@ func TestPublishCollection_SingleSegmentNamespacePublishesMetadata(t *testing.T) } } -// 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") @@ -130,17 +112,12 @@ func TestPublishCollection_InvalidCollectionNamespaceExits(t *testing.T) { } } -// 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 -// (as {}), matching the TS CLI's "already exists, skipping" path. func TestPublishCollection_SkipsAlreadyPublishedVersion(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") writeSeamFeature(t, src, "good", "1.0.0") reg := &fakeRegistry{ - // The registry already has 1.0.0 for this resource. published: map[string][]string{"ghcr.io/me/features/good": {"1.0.0"}}, failIDs: map[string]bool{}, } @@ -153,15 +130,11 @@ func TestPublishCollection_SkipsAlreadyPublishedVersion(t *testing.T) { t.Fatalf("artifact was pushed despite the version already existing: %v", reg.pushed) } - // 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) } } -// TestPublishCollection_RepublishesLegacyIds covers the feature-only legacyIds -// aliasing: a feature with legacyIds is republished under each alias resource, so -// old references keep resolving. Templates do not get this treatment. func TestPublishCollection_RepublishesLegacyIds(t *testing.T) { target := t.TempDir() src := filepath.Join(target, "src") @@ -193,7 +166,6 @@ func TestPublishCollection_RepublishesLegacyIds(t *testing.T) { t.Errorf("pushed %d resources, want %d: %v", len(reg.pushed), len(want), reg.pushed) } - // 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) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 5587619..6fcaa27 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -2,7 +2,6 @@ package cli import "github.com/spf13/cobra" -// newFeaturesCmd groups the `features` subcommands. func newFeaturesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "features", @@ -19,7 +18,6 @@ func newFeaturesCmd() *cobra.Command { return cmd } -// newTemplatesCmd groups the `templates` subcommands. func newTemplatesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "templates", diff --git a/internal/cli/compose_override.go b/internal/cli/compose_override.go index dc0e0e5..9155aeb 100644 --- a/internal/cli/compose_override.go +++ b/internal/cli/compose_override.go @@ -12,9 +12,6 @@ import ( "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" @@ -26,8 +23,6 @@ const ( // CRLF file's version line has no trailing \r). var composeVersionLineRe = regexp.MustCompile(`(?m)(?:^|[\r\x{2028}\x{2029}])[\t\n\v\f\r \x{a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]*(version:[^\n\r\x{2028}\x{2029}]*)`) -// 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 @@ -43,19 +38,14 @@ func readComposeVersionPrefix(composeFiles []string) (string, error) { 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 != "" { @@ -67,8 +57,6 @@ func composeVolumeSpec(m composeMount) string { return volume } -// composeTopLevelVolume is TS convertMountToVolumeTopLevelElement: a named -// volume's entry under the override's top-level volumes. func composeTopLevelVolume(m composeMount) string { volume := "\n " + m.Source + ":" if m.External { @@ -77,11 +65,6 @@ func composeTopLevelVolume(m composeMount) string { 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 { @@ -116,8 +99,6 @@ func composeMountsFromMetadata(entries []any, resolve func(string) (string, erro 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, ",") { @@ -141,26 +122,21 @@ func parseComposeMountString(spec string) composeMount { 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 + 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) @@ -169,8 +145,6 @@ func jsonString(v any) string { 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, "$", "$$") } @@ -191,7 +165,7 @@ func generateComposeFeaturesOverride(in composeFeaturesOverride) string { userCommand := []string{} if !overrideCommand { if in.ServiceEntrypoint != nil { - userEntrypoint = in.ServiceEntrypoint // $ already escaped. + userEntrypoint = in.ServiceEntrypoint } else { for _, c := range in.ImageEntrypoint { userEntrypoint = append(userEntrypoint, escapeComposeDollar(c)) @@ -199,7 +173,7 @@ func generateComposeFeaturesOverride(in composeFeaturesOverride) string { } switch { case in.ServiceCommand != nil: - userCommand = in.ServiceCommand // $ already escaped. + userCommand = in.ServiceCommand case in.ServiceEntrypoint != nil: // Ignore image CMD per docker-compose.yml spec. default: @@ -208,7 +182,6 @@ func generateComposeFeaturesOverride(in composeFeaturesOverride) string { } } } - // TS emits `command:` unless userCommand is the compose command itself. emitCommand := overrideCommand || in.ServiceCommand == nil var b strings.Builder @@ -297,17 +270,12 @@ func generateComposeFeaturesOverride(in composeFeaturesOverride) string { 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). + VersionPrefix string + Service string + Image string + Dockerfile string + Target string Context string BuildKitInlineCache bool BuildArgs [][2]string @@ -315,7 +283,6 @@ type composeBuildOverride struct { CacheFrom []string } -// buildContent is TS buildOverrideContent; empty when there is no feature build. func (in composeBuildOverride) buildContent() string { if in.Dockerfile == "" { return "" @@ -350,9 +317,6 @@ func (in composeBuildOverride) buildContent() string { 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 { diff --git a/internal/cli/compose_override_held_test.go b/internal/cli/compose_override_held_test.go index 3d918ea..68ec9ab 100644 --- a/internal/cli/compose_override_held_test.go +++ b/internal/cli/compose_override_held_test.go @@ -8,10 +8,6 @@ import ( "github.com/devcontainers/cli/internal/imagemeta" ) -// TS parseMount keeps every key=value pair. external is a JavaScript string, so -// any value, "false" included, makes the volume external -// (testdata/ts-oracle/compose.json); readonly is true only for the spellings TS -// accepts. func TestParseComposeMountStringBooleans(t *testing.T) { tests := []struct { spec string @@ -31,7 +27,6 @@ func TestParseComposeMountStringBooleans(t *testing.T) { } } -// 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 { @@ -49,7 +44,6 @@ func TestReadComposeVersionPrefixSingleFile(t *testing.T) { } } -// One entry is enough for each list section of the override to be written. func TestGenerateComposeFeaturesOverrideSingleEntries(t *testing.T) { got := generateComposeFeaturesOverride(composeFeaturesOverride{ Service: "app", @@ -69,8 +63,6 @@ func TestGenerateComposeFeaturesOverrideSingleEntries(t *testing.T) { } } -// 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 { diff --git a/internal/cli/compose_override_test.go b/internal/cli/compose_override_test.go index e21b845..a4febc8 100644 --- a/internal/cli/compose_override_test.go +++ b/internal/cli/compose_override_test.go @@ -8,9 +8,6 @@ import ( "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 { @@ -19,9 +16,6 @@ func TestGenerateComposeFeaturesOverrideMatchesTS(t *testing.T) { 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", @@ -68,8 +62,6 @@ func TestGenerateComposeFeaturesOverrideMatchesTS(t *testing.T) { 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", @@ -83,8 +75,6 @@ func TestGenerateComposeFeaturesOverrideMatchesTS(t *testing.T) { 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", @@ -118,9 +108,6 @@ func TestGenerateComposeBuildOverride(t *testing.T) { 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", @@ -180,10 +167,6 @@ func TestComposeMountsFromMetadata(t *testing.T) { } } -// 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" @@ -220,8 +203,6 @@ func TestComposeFeaturesBuild(t *testing.T) { 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 { diff --git a/internal/cli/compose_up.go b/internal/cli/compose_up.go index fd43e63..51d49b3 100644 --- a/internal/cli/compose_up.go +++ b/internal/cli/compose_up.go @@ -22,38 +22,26 @@ import ( "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 - // The build command's settings (zero for up): metadataOnImage puts the - // metadata label on an image-only service's image even without Features (TS - // canAddLabelsToContainer false), buildServices are the services `compose - // build` builds (nil for TS up's runServices filter), skipPersistCustoms - // leaves the Features' customizations out of the metadata. + cfg *config.DevContainer + loadResult *config.LoadResult + workspaceFolder string + idLabels []string + useBuildx bool + projectName string + composeFiles []string + composeConfig map[string]any + client *docker.ComposeClient + container *container.InspectResponse metadataOnImage bool buildServices []string skipPersistCustoms bool } -// 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 @@ -61,15 +49,12 @@ func (r *upRunner) persistedFolder() string { 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: @@ -84,8 +69,6 @@ func composeStringList(v any) []string { 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 @@ -125,7 +108,6 @@ func splitShellWords(s string) []string { return words } -// newRandomUUID returns a random (version 4) UUID string. func newRandomUUID() string { var b [16]byte _, _ = rand.Read(b[:]) @@ -136,7 +118,6 @@ func newRandomUUID() string { 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 @@ -158,9 +139,6 @@ func (r *upRunner) checkPersistedComposeFile(files []string, prefix string) pers 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 @@ -197,7 +175,6 @@ func (r *upRunner) startComposeContainer(ctx context.Context, s composeStart) (s } 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 { @@ -256,9 +233,6 @@ func (r *upRunner) startComposeContainer(ctx context.Context, s composeStart) (s 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 @@ -270,7 +244,6 @@ func composeServicesFilter(cfg *config.DevContainer) []string { 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 { @@ -282,28 +255,18 @@ func (r *upRunner) inspectImagePulling(ctx context.Context, ref string) (image.I 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 + 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 if len(cfg.Features) > 0 { r.lockfileRead = !opts.noLockfile } - // A lockfile that cannot be read fails the compose flow as it fails TS's - // (readLockfile rethrows), reported by up and build as their error result. var lockfile *features.Lockfile if cfg.ConfigFilePath != "" && !opts.noLockfile { var err error @@ -321,8 +284,6 @@ func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContai if err := substituteFeatureHostVars(res.FeatureSets, loadResult.HostSub); err != nil { return nil, "", err } - // Written by default, as TS generateFeaturesConfig writes it unless - // --no-lockfile is given. if !opts.noLockfile && cfg.ConfigFilePath != "" { lf := features.GenerateLockfile(&features.Config{FeatureSets: res.FeatureSets}, opts.lockfileExcludeIDs) if err := features.WriteLockfile(cfg.ConfigFilePath, lf, features.WriteOptions{Frozen: opts.experimentalFrozenLockfile}); err != nil { @@ -332,9 +293,6 @@ func (r *upRunner) composeFeatureSets(ctx context.Context, cfg *config.DevContai 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 @@ -359,9 +317,6 @@ func dockerfileSupportsBuildContexts(df *docker.Dockerfile) bool { 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 @@ -439,9 +394,6 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe return nil, err } - // TS getDevcontainerMetadata: the label records the image's entries, the - // Features' and the configuration's as written (labelMetadata); the merged - // configuration reads them with their variables substituted (metadata). var featureEntries []imagemeta.Entry for _, fs := range featureSets { featureEntries = append(featureEntries, featureMetadataEntry(fs, s.skipPersistCustoms)) @@ -463,11 +415,8 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe dstFolder := featuresFolder switch { case len(featureSets) == 0 && !svcBuild.HasBuild && !s.metadataOnImage: - // TS getExtendImageBuildInfo: no Features and an image (no Dockerfile) → - // only the metadata label, added to the container. result.labels = []string{imagemeta.MetadataLabel + "=" + containerMetadataLabel(labelMetadata)} case len(featureSets) == 0: - // TS getImageBuildOptions: extend the Dockerfile with the metadata label. syntax := userSyntax if opts.omitSyntaxDirective { syntax = "" @@ -478,7 +427,6 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe } info = imagemeta.GenerateComposeLabelBuild(baseName, labelMetadata, 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). @@ -516,7 +464,6 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe 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 @@ -534,7 +481,6 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe 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 @@ -587,9 +533,6 @@ func (r *upRunner) buildAndExtendCompose(ctx context.Context, s composeStart, pe 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" @@ -647,8 +590,6 @@ func (r *upRunner) updateRemoteUserUID(ctx context.Context, merged *imagemeta.Me 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 diff --git a/internal/cli/compose_up_held_test.go b/internal/cli/compose_up_held_test.go index dd826b6..d9a0a16 100644 --- a/internal/cli/compose_up_held_test.go +++ b/internal/cli/compose_up_held_test.go @@ -30,7 +30,6 @@ import ( 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 @@ -75,8 +74,6 @@ func TestSortedStringKeys(t *testing.T) { } } -// 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}}} @@ -107,7 +104,6 @@ func TestDockerfileSupportsBuildContexts(t *testing.T) { } } -// 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{} @@ -123,8 +119,6 @@ func TestNewRandomUUID(t *testing.T) { } } -// 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") @@ -155,9 +149,6 @@ func TestCheckPersistedComposeFile(t *testing.T) { } } -// 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 @@ -204,8 +195,6 @@ 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 @@ -219,7 +208,6 @@ func (f *composeFakeRunner) Run(_ context.Context, _ io.Writer, name string, arg 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 { @@ -230,7 +218,6 @@ func (f *composeFakeRunner) compose(sub string) [][]string { return out } -// dockerBuilds returns the arguments of each `docker build` run. func (f *composeFakeRunner) dockerBuilds() [][]string { var out [][]string for _, c := range f.calls { @@ -241,8 +228,6 @@ func (f *composeFakeRunner) dockerBuilds() [][]string { 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 @@ -292,17 +277,15 @@ func newComposeHarness(t *testing.T) *composeHarness { 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{workspaceFolder: ws, userDataFolder: persisted, updateRemoteUserUIDDefault: "off", gpuAvailability: "none"}, - // The fixtures' metadata names no variables. + log: logger, + docker: &docker.Client{DockerPath: "docker", Log: logger, Runner: run}, + engine: &docker.EngineClient{API: engine, Log: logger}, + opts: &upOpts{workspaceFolder: ws, userDataFolder: persisted, updateRemoteUserUIDDefault: "off", gpuAvailability: "none"}, substitute: func(*[]imagemeta.Entry) error { return nil }, }, } } -// 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" @@ -334,7 +317,6 @@ func (h *composeHarness) write(rel, content string) string { 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") @@ -376,8 +358,6 @@ func TestComposeServicesFilter(t *testing.T) { } } -// 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() @@ -394,8 +374,6 @@ func TestInspectImagePulling(t *testing.T) { } } -// 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") @@ -443,9 +421,6 @@ func TestUpdateRemoteUserUIDImageUserAndPlatform(t *testing.T) { } } -// 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 } @@ -590,7 +565,6 @@ func TestWriteComposeFeaturesOverride(t *testing.T) { } } -// featureTarball is a gzipped Feature tarball holding id. func featureTarball(t *testing.T, id string) []byte { t.Helper() var buf bytes.Buffer @@ -616,9 +590,6 @@ func featureTarball(t *testing.T, id string) []byte { 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"}}} @@ -651,8 +622,6 @@ func TestComposeFeatureSets(t *testing.T) { if st, err := os.Stat(dir); err != nil || !st.IsDir() { t.Errorf("features folder %q: %v", dir, err) } - // The lockfile is written by default (TS generateFeaturesConfig); a - // local Feature leaves its features map empty. if got := readFile(t, filepath.Join(h.dc, "devcontainer-lock.json")); got != "{\n \"features\": {}\n}\n" { t.Errorf("lockfile = %q", got) } @@ -688,7 +657,6 @@ func TestComposeFeatureSets(t *testing.T) { } }) t.Run("existing lockfile", func(t *testing.T) { - // Rewritten by default; --no-lockfile leaves it alone. for noLockfile, want := range map[bool]string{false: "{\n \"features\": {}\n}\n", true: `{"features":{"stale":{}}}`} { h := newComposeHarness(t) h.r.opts.noLockfile = noLockfile @@ -703,7 +671,6 @@ func TestComposeFeatureSets(t *testing.T) { } }) t.Run("malformed lockfile", func(t *testing.T) { - // TS readLockfile rethrows the JSON.parse error; --no-lockfile skips it. for _, noLockfile := range []bool{false, true} { h := newComposeHarness(t) h.r.opts.noLockfile = noLockfile @@ -734,7 +701,6 @@ func TestComposeFeatureSets(t *testing.T) { }) } -// 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) @@ -744,13 +710,10 @@ func overrideDockerfile(t *testing.T, override string) string { 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) @@ -786,8 +749,6 @@ func TestBuildAndExtendComposeDockerfileService(t *testing.T) { } } -// 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} { @@ -822,8 +783,6 @@ func TestBuildAndExtendComposeTargetAndDockerfilePath(t *testing.T) { } } -// 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) { @@ -884,9 +843,6 @@ func TestBuildAndExtendComposeImageService(t *testing.T) { }) } -// 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 @@ -964,8 +920,6 @@ func TestBuildAndExtendComposeImageServiceWithFeatures(t *testing.T) { } } -// 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) @@ -1027,8 +981,6 @@ func TestBuildAndExtendComposeErrors(t *testing.T) { } } -// 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"}} @@ -1060,12 +1012,10 @@ func TestStartComposeContainerCreates(t *testing.T) { } } -// 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 + build string start string config bool labels bool @@ -1233,9 +1183,6 @@ func TestStartComposeContainerErrors(t *testing.T) { } } -// 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") @@ -1272,8 +1219,6 @@ func TestStartComposeContainerBuildServiceUpdatesUID(t *testing.T) { } } -// 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) @@ -1288,7 +1233,6 @@ func TestStartComposeContainerImageServiceWithFeatures(t *testing.T) { 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) } @@ -1297,11 +1241,6 @@ func TestStartComposeContainerImageServiceWithFeatures(t *testing.T) { } } -// For the build command (TS canAddLabelsToContainer false) an image-only -// service without Features is still built, with the metadata label on its -// image: the image's entries and the configuration's as written, while the -// merged metadata has it substituted; compose builds the dev container -// service alone. func TestBuildAndExtendComposeForTheBuildCommand(t *testing.T) { h := newComposeHarness(t) h.r.configLabel = imagemeta.Entry{RemoteUser: "${localEnv:ME}"} @@ -1325,8 +1264,6 @@ func TestBuildAndExtendComposeForTheBuildCommand(t *testing.T) { t.Errorf("compose build runs %q, want %q", got, want) } - // --skip-persisting-customizations-from-features leaves a Feature's - // customizations out of the metadata. h.write("custom/devcontainer-feature.json", `{"id":"custom","version":"1.0.0","customizations":{"vscode":{"extensions":["x"]}}}`) h.write("custom/install.sh", "#!/bin/sh\n") for _, skip := range []bool{false, true} { diff --git a/internal/cli/container_lookup.go b/internal/cli/container_lookup.go index 98daf55..8feb890 100644 --- a/internal/cli/container_lookup.go +++ b/internal/cli/container_lookup.go @@ -6,8 +6,6 @@ import ( "strings" ) -// containerLister is the narrow seam resolveContainerID needs (satisfied by -// *docker.EngineClient), so the lookup is unit-testable without a daemon. type containerLister interface { ListContainers(ctx context.Context, all bool, labelFilters []string) ([]string, error) } @@ -32,7 +30,6 @@ func (e *containerListError) Error() string { func (e *containerListError) Unwrap() error { return e.err } -// listContainers is ListContainers with a failure as a containerListError. func listContainers(ctx context.Context, engine containerLister, labels []string) ([]string, error) { ids, err := engine.ListContainers(ctx, true, labels) if err != nil { @@ -41,15 +38,6 @@ func listContainers(ctx context.Context, engine containerLister, labels []string return ids, nil } -// resolveContainerID finds a workspace's dev container. Explicit idLabels win. -// Otherwise it prefers the [local_folder, config_file] label filter so that -// multiple devcontainer.json configs in one project (e.g. .devcontainer/frontend -// and .devcontainer/backend, each its own container) resolve to the RIGHT one, -// and falls back to local_folder alone for a container created without a -// config_file label (older runs / --id-label-only) — mirroring the TS CLI's -// old/new id-label scheme. Returns "" when nothing matches, and the first -// lookup that fails as a containerListError, as TS's findContainerAndIdLabels -// throws. func resolveContainerID(ctx context.Context, engine containerLister, workspaceFolder, configFile string, idLabels []string) (string, error) { if len(idLabels) > 0 { ids, err := listContainers(ctx, engine, idLabels) @@ -79,8 +67,6 @@ func resolveContainerID(ctx context.Context, engine containerLister, workspaceFo return ids[0], nil } -// failingLister is the containerLister without a Docker engine: every lookup -// fails with the engine's error. type failingLister struct{ err error } func (f failingLister) ListContainers(context.Context, bool, []string) ([]string, error) { diff --git a/internal/cli/container_lookup_test.go b/internal/cli/container_lookup_test.go index 61fb3f2..cc2560d 100644 --- a/internal/cli/container_lookup_test.go +++ b/internal/cli/container_lookup_test.go @@ -7,7 +7,6 @@ import ( "testing" ) -// fakeLister answers ListContainers from a map of "sorted,label,filter" -> ids. type fakeLister struct{ byFilter map[string][]string } func (f *fakeLister) ListContainers(_ context.Context, _ bool, labels []string) ([]string, error) { @@ -27,7 +26,7 @@ func TestResolveContainerID(t *testing.T) { f := &fakeLister{byFilter: map[string][]string{ key(local, "devcontainer.config_file="+cfgA): {"idA"}, key(local, "devcontainer.config_file="+cfgB): {"idB"}, - key(local): {"idA", "idB"}, // both share local_folder + key(local): {"idA", "idB"}, }} if got, err := resolveContainerID(context.Background(), f, ws, cfgB, nil); err != nil || got != "idB" { t.Errorf("cfgB -> %q, want idB", got) @@ -39,7 +38,7 @@ func TestResolveContainerID(t *testing.T) { t.Run("falls back to local_folder for legacy container (no config_file match)", func(t *testing.T) { f := &fakeLister{byFilter: map[string][]string{ - key(local): {"legacy"}, // no config_file-filtered entry + key(local): {"legacy"}, }} if got, err := resolveContainerID(context.Background(), f, ws, cfgA, nil); err != nil || got != "legacy" { t.Errorf("-> %q, want legacy (fallback)", got) @@ -79,7 +78,6 @@ func TestResolveContainerID(t *testing.T) { t.Errorf("%s: %q, %v; want %q wrapping the engine's error", name, got, err, tc.want) } } - // The fallback lookup failing after a config-file miss fails too. half := &halfLister{fail: key(local), err: boom} if _, err := resolveContainerID(context.Background(), half, ws, cfgA, nil); err == nil || err.Error() != "Command failed: docker ps -q -a --filter label="+local { t.Errorf("fallback lookup: err = %v", err) @@ -87,7 +85,6 @@ func TestResolveContainerID(t *testing.T) { }) } -// halfLister finds nothing, and fails the lookup by the fail filter. type halfLister struct { fail string err error diff --git a/internal/cli/crosslayer_error_test.go b/internal/cli/crosslayer_error_test.go index 1c40cb2..7a7b139 100644 --- a/internal/cli/crosslayer_error_test.go +++ b/internal/cli/crosslayer_error_test.go @@ -8,21 +8,6 @@ import ( coreerrors "github.com/devcontainers/cli/internal/errors" ) -// Cross-layer error propagation. -// -// These tests exercise the command runners (build/up/exec) and assert that a -// hermetically-reachable pre-Docker error — argument validation or a config-load -// failure — propagates with the right exit code BEFORE any Docker engine/daemon -// is constructed. They inject nothing Docker-related: the runner must return -// early. Asserting on the SPECIFIC pre-Docker message (never "Docker engine") -// proves the runner short-circuited before docker.NewEngineClient, regardless of -// whether Docker happens to be installed on the host. -// -// NOTE (out of scope): context cancellation cannot be tested here because the -// commands do not thread cmd.Context() through a cancellation seam into the -// pre-Docker phase; wiring that is a separate change. - -// asExitCode unwraps an *errors.ExitCodeError, failing if err is not one. func asExitCode(t *testing.T, err error) *coreerrors.ExitCodeError { t.Helper() if err == nil { @@ -39,7 +24,7 @@ func TestRunBuild_InvalidEnum_PreDocker(t *testing.T) { out := &captureOutput{} err := runBuild(t.Context(), out, &buildOpts{ workspaceFolder: t.TempDir(), - logLevel: "bogus", // fails validateEnum before any Docker work + logLevel: "bogus", logFormat: "text", buildkit: "auto", }) @@ -48,7 +33,6 @@ func TestRunBuild_InvalidEnum_PreDocker(t *testing.T) { if ece.Code != 1 { t.Errorf("exit code = %d, want 1", ece.Code) } - // yargs-style validation writes to stderr and leaves stdout empty. if !strings.Contains(out.err.String(), "log-level") { t.Errorf("stderr = %q, want the invalid --log-level message", out.err.String()) } @@ -61,9 +45,6 @@ func TestRunBuild_InvalidEnum_PreDocker(t *testing.T) { } func TestRunBuild_ConfigLoadError_PreDocker(t *testing.T) { - // An empty workspace has no devcontainer.json → config load fails, and that - // failure is reported (JSON envelope on stdout, exit 1) before the engine is - // built. out := &captureOutput{} err := runBuild(t.Context(), out, &buildOpts{ workspaceFolder: t.TempDir(), @@ -113,7 +94,7 @@ func TestRunUp_InvalidEnum_PreDocker(t *testing.T) { workspaceFolder: t.TempDir(), logLevel: "info", logFormat: "text", - buildkit: "sideways", // not in {auto, never} + buildkit: "sideways", gpuAvailability: "detect", }) @@ -127,11 +108,9 @@ func TestRunUp_InvalidEnum_PreDocker(t *testing.T) { } func TestRunExec_InvalidEnum_PreDocker(t *testing.T) { - // runExec returns validation errors as plain errors (the top-level command - // maps them to exit 1). The point is they surface before the engine. err := runExec(t.Context(), &execOpts{ workspaceFolder: t.TempDir(), - logLevel: "verbose", // not in {info, debug, trace} + logLevel: "verbose", logFormat: "text", }, []string{"echo", "hi"}) @@ -147,10 +126,8 @@ func TestRunExec_InvalidEnum_PreDocker(t *testing.T) { } func TestRunExec_ConfigLoadError_PreDocker(t *testing.T) { - // With a workspace folder and no --container-id, an unloadable config is - // returned before the engine is constructed. err := runExec(t.Context(), &execOpts{ - workspaceFolder: t.TempDir(), // empty: no devcontainer.json + workspaceFolder: t.TempDir(), logLevel: "info", logFormat: "text", }, []string{"echo", "hi"}) diff --git a/internal/cli/differential_runtime_test.go b/internal/cli/differential_runtime_test.go index fbafa3f..df88ee5 100644 --- a/internal/cli/differential_runtime_test.go +++ b/internal/cli/differential_runtime_test.go @@ -15,13 +15,7 @@ import ( "testing" ) -// differentialRuntimeKnown lists, per command, the fixtures whose result still -// differs from the oracle's, each with the reason. Like differentialKnown it can -// only shrink: a listed fixture that matches fails the test, so a fix removes -// its line. var differentialRuntimeKnown = map[string]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. "up": { "dockerfile-with-syntax": "legacy GitHub Release Feature (accepted limitation)", "dockerfile-with-target": "legacy GitHub Release Feature (accepted limitation)", @@ -38,27 +32,14 @@ var differentialRuntimeKnown = map[string]map[string]string{ }, } -// TestDifferentialUp runs `up` on every upstream fixture with the TS oracle and -// with this CLI, one after the other on copies of the fixture, and compares what -// a user of the container sees: the command's JSON result, the container's and -// its image's configuration (user, env, entrypoint, command, labels with the -// parsed devcontainer.metadata, mounts, init, privileged, capabilities, security -// options) and the remote user's `id` inside the container. Hand-written parity -// cases compare what someone thought of; this compares everything a fixture -// exercises. Like TestDifferentialReadConfiguration it needs the compiled -// oracle, Docker and the network, and runs when PARITY_DIFFERENTIAL is set. func TestDifferentialUp(t *testing.T) { runDifferentialRuntime(t, "up", differentialUpSnapshot) } -// TestDifferentialBuild runs `build` on every upstream fixture with both CLIs -// and compares the command's JSON result and the built image's configuration. func TestDifferentialBuild(t *testing.T) { runDifferentialRuntime(t, "build", differentialBuildSnapshot) } -// differentialSnapshot runs one CLI's command on the workspace ws and returns -// what is compared, normalized for that workspace. type differentialSnapshot func(t *testing.T, cli []string, ws string) map[string]any func runDifferentialRuntime(t *testing.T, command string, snapshot differentialSnapshot) { @@ -108,9 +89,6 @@ func runDifferentialRuntime(t *testing.T, command string, snapshot differentialS } } -// differentialWorkspace copies the fixture to //ws: both sides see a -// workspace folder named ws, so names derived from its basename only differ by -// the path hash, which the snapshots normalize. func differentialWorkspace(t *testing.T, fixture, side string) string { t.Helper() ws := filepath.Join(t.TempDir(), side, "ws") @@ -144,8 +122,6 @@ func differentialUpSnapshot(t *testing.T, cli []string, ws string) map[string]an return snap } -// differentialImageName is the --image-name both sides build, one after the -// other: the image is removed between them. const differentialImageName = "devcontainer-differential:latest" func differentialBuildSnapshot(t *testing.T, cli []string, ws string) map[string]any { @@ -160,8 +136,6 @@ func differentialBuildSnapshot(t *testing.T, cli []string, ws string) map[string return snap } -// runDifferentialCLI runs the CLI and returns its exit code and the last stdout -// line decoded as JSON (the raw line when it is not JSON). func runDifferentialCLI(t *testing.T, cli []string, args ...string) (int, any) { t.Helper() cmd := exec.CommandContext(t.Context(), cli[0], append(cli[1:], args...)...) @@ -187,9 +161,6 @@ func runDifferentialCLI(t *testing.T, cli []string, args ...string) (int, any) { return code, out } -// differentialCleanup removes what a run left behind for the workspace: its -// containers, their compose projects (containers, networks, volumes) and the -// images built for them, so the other side starts from the same state. func differentialCleanup(t *testing.T, ws string) { t.Helper() out, _ := exec.Command("docker", "ps", "-aq", "--filter", "label=devcontainer.local_folder="+ws).Output() @@ -232,8 +203,6 @@ func dockerInspect(t *testing.T, kind, id string) map[string]any { return v[0] } -// containerView is the part of a container's configuration a dev container's -// user depends on. func containerView(c map[string]any) map[string]any { cfg, host := asMap(c["Config"]), asMap(c["HostConfig"]) var mounts []any @@ -261,7 +230,6 @@ func containerView(c map[string]any) map[string]any { } } -// imageView is the part of an image's configuration the container inherits. func imageView(img map[string]any) map[string]any { cfg := asMap(img["Config"]) return map[string]any{ @@ -273,9 +241,6 @@ func imageView(img map[string]any) map[string]any { } } -// viewLabels keeps the labels with the devcontainer.metadata JSON parsed, and -// drops compose's bookkeeping labels whose values are hashes, versions or -// absolute paths of compose's own. func viewLabels(labels map[string]any) map[string]any { out := map[string]any{} for k, v := range labels { @@ -297,28 +262,18 @@ func viewLabels(labels map[string]any) map[string]any { } var ( - differentialHashRe = regexp.MustCompile(`(vsc-ws-)[0-9a-f]{64}`) - differentialDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`) - differentialHexIDRe = regexp.MustCompile(`\b[0-9a-f]{64}\b`) - // The compose override files both CLIs write are named - // docker-compose.devcontainer.build-.yml and - // docker-compose.devcontainer.containerFeatures--.yml. - differentialOverrideRe = regexp.MustCompile(`(docker-compose\.devcontainer\.\w+)-\d+(?:-[0-9a-f-]{36})?`) - // ${devcontainerId} hashes the id labels, which hold the workspace path. + differentialHashRe = regexp.MustCompile(`(vsc-ws-)[0-9a-f]{64}`) + differentialDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`) + differentialHexIDRe = regexp.MustCompile(`\b[0-9a-f]{64}\b`) + differentialOverrideRe = regexp.MustCompile(`(docker-compose\.devcontainer\.\w+)-\d+(?:-[0-9a-f-]{36})?`) differentialDevcontainerIDRe = regexp.MustCompile(`(dind-var-lib-docker-)[0-9a-v]{52}`) ) -// normalizeDifferential replaces what legitimately differs between the two -// runs: the workspace path, the path hash in image names, the features folder's -// version-and-time segment, the compose override file's time and uuid, the -// devcontainerId (a hash of the workspace's id labels), and digests and ids. func normalizeDifferential(v any, ws string) any { data, err := json.Marshal(v) if err != nil { return v } - // The workspace first, then its parent (/), which a fixture can - // mount through ${localWorkspaceFolder}/.. s := strings.ReplaceAll(string(data), ws, "") s = strings.ReplaceAll(s, filepath.Dir(ws), "/..") s = differentialHashRe.ReplaceAllString(s, "${1}") diff --git a/internal/cli/differential_test.go b/internal/cli/differential_test.go index 11cf574..4c03731 100644 --- a/internal/cli/differential_test.go +++ b/internal/cli/differential_test.go @@ -14,12 +14,7 @@ import ( "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)", @@ -27,14 +22,6 @@ var differentialKnown = map[string]string{ "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") @@ -76,9 +63,6 @@ func TestDifferentialReadConfiguration(t *testing.T) { } } -// 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) @@ -100,10 +84,6 @@ func differentialFixtures(t *testing.T, root string) []string { 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") @@ -134,10 +114,6 @@ func runDifferential(t *testing.T, fixture string, cli ...string) (int, any) { 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 { diff --git a/internal/cli/disallowed.go b/internal/cli/disallowed.go index 882a58a..bd4481e 100644 --- a/internal/cli/disallowed.go +++ b/internal/cli/disallowed.go @@ -22,8 +22,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// cacheFolder mirrors the TS getCacheFolder: /devcontainercli- on -// Linux, /devcontainercli elsewhere. func cacheFolder() string { base := "devcontainercli" if runtime.GOOS == "linux" { @@ -34,15 +32,6 @@ func cacheFolder() string { return filepath.Join(os.TempDir(), base) } -// 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), 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 @@ -73,10 +62,6 @@ func enforceDisallowedFeatures(ctx context.Context, cfg *config.DevContainer, ad } } -// 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{} @@ -98,9 +83,6 @@ func featureIDsInOrder(cfg *config.DevContainer, additionalFeatures string) []st 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) @@ -115,10 +97,6 @@ func stopRunningContainer(engine *docker.EngineClient, idLabels []string) func(c } } -// 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) @@ -138,8 +116,6 @@ func writeFeatureAdvisories(logger log.Logger, manifest *features.ControlManifes } } -// 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 index da8fda0..301adf8 100644 --- a/internal/cli/disallowed_held_test.go +++ b/internal/cli/disallowed_held_test.go @@ -13,8 +13,6 @@ import ( "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}} @@ -46,8 +44,6 @@ func TestStopRunningContainer(t *testing.T) { } } -// 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{}}} { diff --git a/internal/cli/disallowed_repo_held_test.go b/internal/cli/disallowed_repo_held_test.go index b4fe652..aebf6dd 100644 --- a/internal/cli/disallowed_repo_held_test.go +++ b/internal/cli/disallowed_repo_held_test.go @@ -8,8 +8,6 @@ import ( "testing" ) -// On Linux the cache folder carries the user's name, as TS getCacheFolder's -// does, so two users of one machine do not share it. func TestCacheFolderNamesTheLinuxUser(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("the per-user folder is Linux's") diff --git a/internal/cli/disallowed_test.go b/internal/cli/disallowed_test.go index 10c3e11..994afef 100644 --- a/internal/cli/disallowed_test.go +++ b/internal/cli/disallowed_test.go @@ -14,17 +14,13 @@ import ( "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 + "y/add:1": true, "b/add:1": true, + "o/override:1": true, }} 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"} @@ -33,9 +29,6 @@ func TestFeatureIDsInOrderFollowsTheDocuments(t *testing.T) { } } -// 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) diff --git a/internal/cli/dockerfile_path_test.go b/internal/cli/dockerfile_path_test.go index f51e811..d1e874a 100644 --- a/internal/cli/dockerfile_path_test.go +++ b/internal/cli/dockerfile_path_test.go @@ -8,9 +8,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// The Dockerfile and build context resolve as TS getDockerfilePath and -// getDockerContextPath do: against the configuration's folder, an absolute -// path as it is, and without a context the Dockerfile's own folder. func TestDockerfileAndContextPaths(t *testing.T) { cfgPath := "/ws/.devcontainer/devcontainer.json" for _, tc := range []struct { @@ -39,8 +36,6 @@ func TestDockerfileAndContextPaths(t *testing.T) { } } -// An absolute build.dockerfile is read where it is by the build and by -// --cache-key, not joined onto the configuration's folder. func TestAbsoluteDockerfileIsReadWhereItIs(t *testing.T) { dockerfile := filepath.Join(t.TempDir(), "Dockerfile") if err := os.WriteFile(dockerfile, []byte("FROM alpine:3 AS base\n"), 0o644); err != nil { diff --git a/internal/cli/dockerignore_test.go b/internal/cli/dockerignore_test.go index 721af7d..29fe481 100644 --- a/internal/cli/dockerignore_test.go +++ b/internal/cli/dockerignore_test.go @@ -6,8 +6,6 @@ import ( "testing" ) -// upstream #969: a renamed build Dockerfile must keep the sibling -// .dockerignore so BuildKit still applies it. func TestCopyDockerignore(t *testing.T) { dir := t.TempDir() orig := filepath.Join(dir, "Dockerfile") @@ -23,7 +21,6 @@ func TestCopyDockerignore(t *testing.T) { if string(got) != "node_modules\n*.log\n" { t.Errorf("content = %q", got) } - // No sibling ignore file → no-op, no error. copyDockerignore(filepath.Join(dir, "Other"), filepath.Join(dir, "Other.tmp")) if _, err := os.Stat(filepath.Join(dir, "Other.tmp.dockerignore")); !os.IsNotExist(err) { t.Error("should not create an ignore file when none exists") diff --git a/internal/cli/e2e_ca_bundle_test.go b/internal/cli/e2e_ca_bundle_test.go index af4a565..86228be 100644 --- a/internal/cli/e2e_ca_bundle_test.go +++ b/internal/cli/e2e_ca_bundle_test.go @@ -24,10 +24,8 @@ import ( const e2eBaseImage = "mcr.microsoft.com/devcontainers/base:ubuntu" -// startTLSServer runs `openssl s_server -www` on the default bridge, where -// builds' RUNs and dev containers reach it by its address, with a certificate -// for that address signed by ca. The host's firewall may drop a container's -// traffic to the host, so the server is a container too. +// The host's firewall may drop a container's traffic to the host, so the +// server is a container too. func startTLSServer(t *testing.T, ca *testCA) string { t.Helper() certs := t.TempDir() @@ -84,10 +82,6 @@ func serverDiagnostics(id string) string { return fmt.Sprintf("state: %s\nlogs: %s", state, logs) } -// imageLeaks lists the files of image's own layers among files, and where the -// markers appear in what image adds to base: the content of its own layers' -// files and its config, which holds -// the history and labels. A layer of base is skipped by its diff ID. func imageLeaks(t *testing.T, image, base string, files []string, markers ...string) []string { t.Helper() baseLayers := map[string]bool{} @@ -137,7 +131,7 @@ func imageLeaks(t *testing.T, image, base string, files []string, markers ...str } layer := tar.NewReader(bytes.NewReader(data)) if _, err := layer.Next(); err != nil { - check(hdr.Name, data) // config, manifest or index + check(hdr.Name, data) continue } layer = tar.NewReader(bytes.NewReader(data)) @@ -170,19 +164,12 @@ func uniqueImageName(t *testing.T, prefix string) string { return name } -// TestE2E_CABundle: with --ca-bundle a Feature's install and a Dockerfile RUN -// that mounts the secret reach an HTTPS server signed by a CA the image does -// not trust, and fail without it; the image carries neither the bundle nor its -// content; the container trusts the server in curl, node and its lifecycle -// commands, run-user-commands' included, which takes no flag of its own. func TestE2E_CABundle(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") } ca := newTestCA(t) bundle := writeTestFile(t, filepath.Join(t.TempDir(), "bundle.crt"), ca.pem) - // A line of the CA's base64 body: in a file, a layer or a config only if - // the bundle was copied there. marker := strings.Split(string(ca.pem), "\n")[1] url := startTLSServer(t, ca) // The server answers any path; a path of this run's own keeps BuildKit from @@ -222,9 +209,6 @@ func TestE2E_CABundle(t *testing.T) { if env, err := exec.Command("docker", "image", "inspect", "-f", "{{json .Config.Env}}", image).Output(); err != nil || strings.Contains(string(env), "ca-bundle") { t.Errorf("the image's env names the bundle: %s %v", env, err) } - // A bundle of another CA cannot reach the server, so this build - // passes only if the bundle's content is outside the cache key, and - // then it makes the same image. // The image's ID names a manifest list whose attestation carries a // timestamp; its layers are what the build made. before := dockerOutput(t, "image", "inspect", "-f", "{{json .RootFS.Layers}}", image) @@ -269,11 +253,6 @@ func TestE2E_CABundle(t *testing.T) { }) } -// TestE2E_UpSecretsFile: up --secrets-file hands a value to a Dockerfile's -// `RUN --mount=type=secret,id=K` and to postCreateCommand, and the value is in -// no layer, no image config (which holds `docker history`) and no part of the -// container's inspect, its labels included. Both sides record a hash, so the -// test compares without writing the value anywhere itself. func TestE2E_UpSecretsFile(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") diff --git a/internal/cli/e2e_fixes_test.go b/internal/cli/e2e_fixes_test.go index 7f44a97..3abd1b1 100644 --- a/internal/cli/e2e_fixes_test.go +++ b/internal/cli/e2e_fixes_test.go @@ -9,8 +9,6 @@ import ( "testing" ) -// writeFixture materializes a self-contained workspace from a path->content map -// under a temp dir and returns the workspace root. func writeFixture(t *testing.T, files map[string]string) string { t.Helper() root := t.TempDir() @@ -26,7 +24,6 @@ func writeFixture(t *testing.T, files map[string]string) string { return root } -// dockerExec runs `docker exec sh -lc ` and returns trimmed combined output. func dockerExec(t *testing.T, id, cmd string) string { t.Helper() out, err := exec.Command("docker", "exec", id, "sh", "-c", cmd).CombinedOutput() @@ -42,10 +39,6 @@ func removeByID(id string) { } } -// TestE2E_FeatureUserHomeAndLocalEnv validates two upstream fixes on a real -// build+up with a local Feature: -// - #331: the Feature install script sees a non-empty _REMOTE_USER_HOME. -// - #308: a Feature's containerEnv ${localEnv:VAR} is resolved (not literal). func TestE2E_FeatureUserHomeAndLocalEnv(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") @@ -76,19 +69,14 @@ func TestE2E_FeatureUserHomeAndLocalEnv(t *testing.T) { } defer removeByID(id) - // #331: install.sh captured the remote user's home at build time. if got := dockerExec(t, id, "cat /e2e-feature-marker"); !strings.Contains(got, "REMOTE_USER_HOME=[/home/vscode]") { t.Errorf("#331 _REMOTE_USER_HOME not resolved in Feature build: %q", got) } - // #308: the Feature's containerEnv ${localEnv:...} was substituted. if got := dockerExec(t, id, "printenv MY_LOCAL_ENV"); got != "resolved-localenv-1234" { t.Errorf("#308 Feature containerEnv ${localEnv} = %q, want resolved-localenv-1234", got) } } -// TestE2E_RemoveExistingBeforeInitialize validates #844: with -// --remove-existing-container, the old container is removed BEFORE -// initializeCommand, so a failing initializeCommand does not leave it running. func TestE2E_RemoveExistingBeforeInitialize(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") @@ -97,7 +85,6 @@ func TestE2E_RemoveExistingBeforeInitialize(t *testing.T) { ".devcontainer/devcontainer.json": `{"image":"mcr.microsoft.com/devcontainers/base:ubuntu","overrideCommand":true}`, }) - // 1) Provision the container. upOut := runCLI(t, "up", "--workspace-folder", ws, "--skip-post-create") assertOutcome(t, upOut, "success") oldID, _ := parseJSON(t, upOut)["containerId"].(string) @@ -106,8 +93,6 @@ func TestE2E_RemoveExistingBeforeInitialize(t *testing.T) { } defer removeByID(oldID) - // 2) Add a failing initializeCommand, then up --remove-existing-container. - // The up must fail (init exits 1) — but the old container must be gone. if err := os.WriteFile(filepath.Join(ws, ".devcontainer", "devcontainer.json"), []byte(`{"image":"mcr.microsoft.com/devcontainers/base:ubuntu","overrideCommand":true,"initializeCommand":"exit 1"}`), 0o644); err != nil { t.Fatal(err) @@ -117,14 +102,11 @@ func TestE2E_RemoveExistingBeforeInitialize(t *testing.T) { t.Fatalf("second up outcome = %v, want error (initializeCommand fails)", oc) } - // The old container must have been removed before initializeCommand ran. if exec.Command("docker", "inspect", oldID).Run() == nil { t.Errorf("#844 old container %s still exists after a failed rebuild; removal did not precede initializeCommand", oldID[:12]) } } -// TestE2E_ComposeBuildLabel validates #930: `build --label` on a Compose config -// applies the label even without --image-name. func TestE2E_ComposeBuildLabel(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") @@ -141,7 +123,6 @@ func TestE2E_ComposeBuildLabel(t *testing.T) { buildOut := runCLI(t, "build", "--workspace-folder", ws, "--label", "e2e.compose.label=applied") assertOutcome(t, buildOut, "success") - // Without --image-name, TS reports a Compose build's image as a string. img, _ := parseJSON(t, buildOut)["imageName"].(string) if img == "" { t.Fatalf("imageName is not the image's name as a string:\n%s", buildOut) @@ -157,15 +138,10 @@ func TestE2E_ComposeBuildLabel(t *testing.T) { } } -// TestE2E_SiblingDockerignore validates #969: when the Dockerfile is renamed to -// inject a final stage name, the user's sibling .dockerignore still -// applies, so excluded files do not leak into the build context. func TestE2E_SiblingDockerignore(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") } - // The Dockerfile has an UNNAMED final stage, so the CLI rewrites it to a temp - // file (the path that used to drop the sibling .dockerignore). ws := writeFixture(t, map[string]string{ ".devcontainer/devcontainer.json": `{"build":{"dockerfile":"Dockerfile"}}`, ".devcontainer/Dockerfile": "FROM mcr.microsoft.com/devcontainers/base:ubuntu\nCOPY . /ctx-check/\nRUN if [ -e /ctx-check/secret.txt ]; then echo LEAKED > /dockerignore-result; else echo IGNORED_OK > /dockerignore-result; fi\n", @@ -186,8 +162,6 @@ func TestE2E_SiblingDockerignore(t *testing.T) { } } -// TestE2E_ReadonlyMountObject validates #881: a top-level mount OBJECT with -// readonly:true produces a genuinely read-only bind mount in the container. func TestE2E_ReadonlyMountObject(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") @@ -211,7 +185,6 @@ func TestE2E_ReadonlyMountObject(t *testing.T) { } defer removeByID(id) - // Writing into the mount must fail because it is read-only. out, err := exec.Command("docker", "exec", id, "sh", "-c", "echo x > /ro-mount/probe 2>&1; echo rc=$?").CombinedOutput() if err != nil { t.Fatalf("docker exec failed: %v\n%s", err, out) @@ -221,11 +194,6 @@ func TestE2E_ReadonlyMountObject(t *testing.T) { } } -// TestE2E_UpdateUIDSystemUser validates #109: updateRemoteUserUID's chown no -// longer aborts the remap build when the remote user has no home dir on disk -// (system user). Pre-fix the chown failed, the wrapper swallowed it, and the -// UID/GID remap silently did NOT happen; post-fix the remap succeeds. We assert -// the remap actually took effect (svcuser's uid == the host uid). func TestE2E_UpdateUIDSystemUser(t *testing.T) { if !dockerAvailable() { t.Skip("Docker not available") @@ -235,11 +203,6 @@ func TestE2E_UpdateUIDSystemUser(t *testing.T) { } ws := writeFixture(t, map[string]string{ ".devcontainer/devcontainer.json": `{"build":{"dockerfile":"Dockerfile"},"remoteUser":"svcuser","updateRemoteUserUID":true,"overrideCommand":true}`, - // A system user (uid 999) with a home path in /etc/passwd that does NOT - // exist on disk — the case where the pre-fix chown -R aborted the build. - // The base MUST NOT already own uid/gid 1000 (the host uid/gid), or the - // remap short-circuits on "user with UID exists" and never reaches the - // chown. Plain ubuntu:22.04 has neither, so the remap proceeds. ".devcontainer/Dockerfile": "FROM ubuntu:22.04\n" + "RUN useradd --system --no-create-home --home-dir /home/svcuser --shell /bin/sh svcuser\n", }) diff --git a/internal/cli/e2e_test.go b/internal/cli/e2e_test.go index 0362c16..e5ead69 100644 --- a/internal/cli/e2e_test.go +++ b/internal/cli/e2e_test.go @@ -10,9 +10,6 @@ import ( "testing" ) -// These tests require Docker and run real containers. -// Skip if Docker is not available. - func dockerAvailable() bool { cmd := exec.Command("docker", "info") return cmd.Run() == nil @@ -28,11 +25,9 @@ func TestE2E_BuildUpExec_Image(t *testing.T) { t.Skip("fixtures not found") } - // Build buildOut := runCLI(t, "build", "--workspace-folder", fixture) assertOutcome(t, buildOut, "success") - // Up upOut := runCLI(t, "up", "--workspace-folder", fixture, "--skip-post-create") assertOutcome(t, upOut, "success") @@ -42,11 +37,9 @@ func TestE2E_BuildUpExec_Image(t *testing.T) { t.Fatal("no containerId in up output") } - // Exec - cmd := exec.Command(os.Args[0], "-test.run=^$") // dummy — we use docker directly + cmd := exec.Command(os.Args[0], "-test.run=^$") _ = cmd - // Direct docker exec to verify execCmd := exec.Command("docker", "exec", containerID, "echo", "e2e-test-ok") execOut, err := execCmd.CombinedOutput() if err != nil { @@ -56,7 +49,6 @@ func TestE2E_BuildUpExec_Image(t *testing.T) { t.Errorf("exec output = %q", string(execOut)) } - // Cleanup exec.Command("docker", "rm", "-f", containerID).Run() } @@ -70,13 +62,11 @@ func TestE2E_BuildUpExec_Dockerfile(t *testing.T) { t.Skip("fixtures not found") } - // Build buildOut := runCLI(t, "build", "--workspace-folder", fixture) assertOutcome(t, buildOut, "success") buildResult := parseJSON(t, buildOut) t.Logf("Built image: %v", buildResult["imageName"]) - // Up upOut := runCLI(t, "up", "--workspace-folder", fixture, "--skip-post-create") assertOutcome(t, upOut, "success") @@ -86,19 +76,16 @@ func TestE2E_BuildUpExec_Dockerfile(t *testing.T) { t.Fatal("no containerId") } - // Verify container is running inspectCmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", containerID) inspectOut, _ := inspectCmd.Output() if strings.TrimSpace(string(inspectOut)) != "true" { t.Errorf("container not running: %s", string(inspectOut)) } - // Cleanup exec.Command("docker", "rm", "-f", containerID).Run() } func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { - // read-configuration looks for the container; this engine has none. startFakeEngine(t, nil) fixtures := []struct { name string @@ -152,8 +139,6 @@ func TestE2E_ReadConfiguration_AllVariants(t *testing.T) { } } -// --- Helpers --- - func runCLI(t *testing.T, args ...string) string { t.Helper() diff --git a/internal/cli/env.go b/internal/cli/env.go index 74ff903..595e77a 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -7,22 +7,12 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// resolveContainerVariables is TS containerSubstitute: ${containerEnv:…} -// resolved from the container's environment. A ${containerEnv} without a name -// is TS's ContainerError, which names configFile (the configuration's file, -// "" without one). func resolveContainerVariables(containerEnv map[string]string, configFile string, value any) (any, error) { - // The container's platform is not win32, so variable names are matched as - // written (the zero Platform). ctx := config.SubstitutionContext{ContainerEnv: containerEnv} ctx.ConfigFilePath = configFile return config.NewVariableResolver().AfterContainer(ctx, value) } -// checkContainerSubstitution substitutes the container's environment into -// each value (the configuration, the merged configuration) for the failure -// alone, as TS's commands run containerSubstitute over them before they use -// the container: the first error, a ContainerError, is returned. func checkContainerSubstitution(containerEnv map[string]string, configFile string, values ...any) error { for _, v := range values { data, _ := json.Marshal(v) @@ -35,5 +25,4 @@ func checkContainerSubstitution(containerEnv map[string]string, configFile strin return nil } -// osEnvMap returns the current process environment as a map. func osEnvMap() map[string]string { return config.EnvMap(os.Environ()) } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index e4a7fe8..24b5535 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -49,9 +49,8 @@ func newExecCmd() *cobra.Command { cmd := &cobra.Command{ Use: "exec [cmd] [args...]", Short: "Execute a command on a running dev container", - DisableFlagParsing: true, // Manual parsing to support `exec docker --version` + DisableFlagParsing: true, RunE: func(cmd *cobra.Command, rawArgs []string) error { - // Split: flags (--workspace-folder etc.) go to Cobra, rest is the command flagArgs, cmdArgs := splitExecArgs(rawArgs) // `exec --help`/`-h` (before the command) prints usage and exits 0, @@ -63,7 +62,6 @@ func newExecCmd() *cobra.Command { } } - // Re-enable flag parsing for our flags only cmd.DisableFlagParsing = false if err := cmd.ParseFlags(flagArgs); err != nil { return err @@ -98,7 +96,7 @@ func newExecCmd() *cobra.Command { f.Bool("mount-git-worktree-common-dir", false, "") 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.MarkHidden("skip-feature-auto-mapping") f.String("user-data-folder", "", "") f.Var(&opts.terminalColumns, "terminal-columns", "") f.Var(&opts.terminalRows, "terminal-rows", "") @@ -107,11 +105,6 @@ 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, @@ -124,7 +117,6 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { if err := validateRemoteEnvs(opts.remoteEnvs); err != nil { return err } - // 0.88: default --workspace-folder to cwd when no --container-id/--id-label/--workspace-folder. if execDefaultsToCwd(opts) { opts.workspaceFolder, _ = os.Getwd() } @@ -151,9 +143,6 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { Writer: logDst, }) - // 1. The configuration, read before the container is looked for, as TS - // exec does: the workspace's, --config's or --override-config's, with or - // without --container-id. One that is named but cannot be read fails exec. cwd, _ := os.Getwd() loadResult, loadErr := execLoadConfig(opts, cwd) if loadErr != nil { @@ -166,7 +155,6 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { } defer engine.Close() - // 2. Find the container containerID := opts.containerID if containerID == "" { configFile := "" @@ -183,7 +171,6 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { return fmt.Errorf("Dev container not found.") } - // 3. Inspect container inspect, err := engine.InspectContainer(ctx, containerID) if err != nil { return fmt.Errorf("Dev container not found.") @@ -193,23 +180,18 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { } containerCfg := cmp.Or(inspect.Config, &container.Config{}) - // 4-6. The container's env, and its image metadata from the labels. containerEnv := config.EnvMap(containerCfg.Env) var cfg *config.DevContainer hostSub, configFile := config.EnvHostSubContext(), "" if loadResult != nil { cfg, hostSub, configFile = loadResult.Config, loadResult.HostSub, loadResult.Config.ConfigFilePath } - // TS getImageMetadataFromContainer, with the host substitutions alone. idLabels := containerIDLabels(opts.idLabels, containerCfg.Labels, resolvePath(opts.workspaceFolder), configFile) entries, err := imageMetadataFromContainer(containerCfg.Labels, cfg, nil, idLabels, hostMetadataSubstitution(hostSub, nil), logger) if err != nil { return err } merged := imagemeta.MergeConfiguration(entries) - // TS exec substitutes the container's environment into the merged - // configuration before it probes the user environment; a variable that - // cannot be resolved fails exec, naming the configuration's file. var rawConfig any if loadResult != nil { rawConfig = loadResult.Raw @@ -217,20 +199,14 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { if err := checkContainerSubstitution(containerEnv, configFile, rawConfig, merged); err != nil { return err } - // The user is the merged configuration's remoteUser - the configuration's, - // else the image metadata's - else the container's, else root; the - // environment is probed as that user (TS createContainerProperties with - // mergedConfig.remoteUser). remoteUser := execRemoteUser(cmp.Or(merged.RemoteUser, containerCfg.User, "root"), cfg) - // 7-8. Probe the user environment, then merge and substitute it. probed := lifecycle.ProbedRemoteEnv(ctx, engine, logger, containerID, remoteUser, execProbeStrategy(opts.defaultUserEnvProbe, cfg), "") resolvedRemoteEnv, err := execRemoteEnv(probed, merged.RemoteEnv, cfg, containerEnv) if err != nil { return err } - // 9-11. Build the docker exec args. remoteWorkspace := "" if loadResult != nil && loadResult.WorkspaceConfig != nil { remoteWorkspace = loadResult.WorkspaceConfig.WorkspaceFolder @@ -248,13 +224,10 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error { return execRunError(execCmd.Run()) } -// 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) @@ -262,10 +235,6 @@ func execConfigPath(configPath, cwd string) string { return configPath } -// execLoadConfig is TS exec's configuration: none without --workspace-folder, -// --config or --override-config, else the one they name, which must exist -// ("Dev container config (…) not found." names --config, else the workspace's -// default path), even with --container-id. func execLoadConfig(opts *execOpts, cwd string) (*config.LoadResult, error) { if opts.workspaceFolder == "" && opts.configPath == "" && opts.overrideConfig == "" { return nil, nil @@ -282,13 +251,10 @@ func execLoadConfig(opts *execOpts, cwd string) (*config.LoadResult, error) { return loaded, err } -// containerRunning reports whether an inspected container is running. func containerRunning(state *container.State) bool { return state != nil && state.Running } -// execRemoteUser is the user exec probes the environment as and runs as: the -// configuration's remoteUser, else metadataUser. func execRemoteUser(metadataUser string, cfg *config.DevContainer) string { if cfg != nil && cfg.RemoteUser != "" { return cfg.RemoteUser @@ -296,7 +262,6 @@ func execRemoteUser(metadataUser string, cfg *config.DevContainer) string { return metadataUser } -// 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) @@ -304,10 +269,6 @@ func execProbeStrategy(flag string, cfg *config.DevContainer) lifecycle.UserEnvP return lifecycle.UserEnvProbeStrategy(flag) } -// 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) @@ -338,11 +299,6 @@ func execRemoteEnv(probed map[string]string, metadata map[string]*string, cfg *c return resolved, nil } -// 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 { @@ -364,11 +320,6 @@ func execDockerArgs(tty bool, remoteUser, remoteWorkspace string, remoteEnv map[ return append(args, cmdArgs...) } -// 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} @@ -377,8 +328,6 @@ func execOutput(logFormat string, logger log.Logger) (stdout, stderr io.Writer) return os.Stdout, os.Stderr } -// 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 @@ -389,8 +338,6 @@ func execRunError(err error) error { return &coreerrors.ExitCodeError{Code: 1, Err: err} } -// execExitCode derives the process exit code for the 128+N contract. -// // os/exec reports ExitCode()==-1 when the child was terminated by a signal // rather than exiting normally. That happens here when the host `docker` // process itself is signaled (e.g. the terminal delivers SIGINT/SIGTERM to @@ -409,8 +356,6 @@ func execExitCode(exitErr *exec.ExitError) int { return code } -// rawLogWriter emits everything written to it as `raw` log events at debug -// level, matching how the TS CLI streams exec output under --log-format json. // os/exec copies a command's stdout and stderr on separate goroutines, so the // mutex serializes writes into whole, non-interleaved raw events. type rawLogWriter struct { @@ -426,9 +371,6 @@ func (w *rawLogWriter) Write(p []byte) (int, error) { } func findContainerByOpts(ctx context.Context, engine *docker.EngineClient, opts *execOpts, configFile string, logger log.Logger) (string, error) { - // [local_folder, config_file] disambiguates when a project has multiple - // devcontainer.json configs; falls back to local_folder for legacy containers. - // A failed lookup fails exec, as TS's findContainerAndIdLabels throws. id, err := resolveContainerID(ctx, engine, opts.workspaceFolder, configFile, opts.idLabels) if err != nil || id != "" { return id, err @@ -465,8 +407,6 @@ func findComposeContainer(ctx context.Context, engine *docker.EngineClient, opts return "" } -// 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) { valueFlags := map[string]bool{ "--workspace-folder": true, "--config": true, "--override-config": true, @@ -490,7 +430,6 @@ func splitExecArgs(args []string) (flags []string, cmd []string) { } flags = append(flags, arg) i++ - // If this flag takes a value, consume the next arg too if valueFlags[arg] && i < len(args) { flags = append(flags, args[i]) i++ @@ -499,6 +438,5 @@ func splitExecArgs(args []string) (flags []string, cmd []string) { return flags, nil } -// Keep imports used var _ = json.Marshal var _ = strings.Join diff --git a/internal/cli/exec_held_test.go b/internal/cli/exec_held_test.go index f782883..42b12f6 100644 --- a/internal/cli/exec_held_test.go +++ b/internal/cli/exec_held_test.go @@ -27,8 +27,6 @@ func runExecCommand(t *testing.T, args ...string) (string, error) { 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) @@ -70,9 +68,6 @@ func TestExecConfigPath(t *testing.T) { } } -// TS exec reads the configuration the flags name, with or without -// --container-id, and fails when it cannot: --config without -// --workspace-folder is read, and a missing one is not found. func TestExecLoadConfig(t *testing.T) { dir := t.TempDir() cfg := filepath.Join(dir, "c.json") @@ -156,7 +151,6 @@ func TestExecRemoteEnv(t *testing.T) { if _, err := execRemoteEnv(nil, map[string]*string{"X": str("${containerEnv}")}, nil, nil); err == nil { t.Error("an unresolvable ${containerEnv}: want an error") } - // TS's ContainerError text, naming the configuration's file. withFile := &config.DevContainer{ConfigFilePath: "/ws/c.json", RemoteEnv: map[string]*string{"X": str("${containerEnv}")}} wantErr := "'${containerEnv}' in c.json can not be resolved because no environment variable name is given." if _, err := execRemoteEnv(nil, nil, withFile, nil); err == nil || err.Error() != wantErr { @@ -164,9 +158,6 @@ func TestExecRemoteEnv(t *testing.T) { } } -// TS runs containerSubstitute over the whole configuration and merged -// configuration: an unnamed ${containerEnv} anywhere is its ContainerError, -// named ones resolve. func TestCheckContainerSubstitution(t *testing.T) { env := map[string]string{"P": "/bin"} ok := map[string]any{"postCreateCommand": "echo ${containerEnv:P}"} diff --git a/internal/cli/exec_repo_held_test.go b/internal/cli/exec_repo_held_test.go index 32657c1..e44d6c9 100644 --- a/internal/cli/exec_repo_held_test.go +++ b/internal/cli/exec_repo_held_test.go @@ -8,8 +8,6 @@ import ( "github.com/devcontainers/cli/internal/docker" ) -// composeWorkspace writes a workspace named proj whose devcontainer.json runs -// the app service of a Compose project. func composeWorkspace(t *testing.T) string { t.Helper() ws := filepath.Join(t.TempDir(), "proj") @@ -39,8 +37,6 @@ func fakeEngineClient(t *testing.T) *docker.EngineClient { return engine } -// exec finds the workspace's container by its local_folder label first, and -// only then looks for the service of its Compose project. func TestFindContainerByOptsPrefersTheLabeledContainer(t *testing.T) { ws := composeWorkspace(t) startFakeEngine(t, map[string]map[string]string{ @@ -52,8 +48,6 @@ func TestFindContainerByOptsPrefersTheLabeledContainer(t *testing.T) { } } -// A container lookup that fails fails exec with TS's failed `docker ps`, -// rather than looking further or reporting no container. func TestFindContainerByOptsFailedLookup(t *testing.T) { ws := composeWorkspace(t) t.Setenv("DOCKER_HOST", "unix:///nonexistent/devcontainer-cli-test.sock") @@ -64,8 +58,6 @@ func TestFindContainerByOptsFailedLookup(t *testing.T) { } } -// A Compose workspace's container without devcontainer labels is found by the -// project (the workspace folder's name, or COMPOSE_PROJECT_NAME) and service. func TestFindContainerByOptsFindsTheComposeService(t *testing.T) { ws := composeWorkspace(t) startFakeEngine(t, map[string]map[string]string{ @@ -84,8 +76,6 @@ func TestFindContainerByOptsFindsTheComposeService(t *testing.T) { } } -// No Compose container is found without a workspace, without a config, for a -// config that is not Compose, or when the project has no such service. func TestFindComposeContainerFindsNothing(t *testing.T) { startFakeEngine(t, map[string]map[string]string{ "app": {"com.docker.compose.project": "proj_devcontainer", "com.docker.compose.service": "app"}, diff --git a/internal/cli/exec_test.go b/internal/cli/exec_test.go index 355fe37..0444dae 100644 --- a/internal/cli/exec_test.go +++ b/internal/cli/exec_test.go @@ -6,24 +6,15 @@ import ( "testing" ) -// TestExecExitCode_SignaledDerives128PlusN verifies the 128+N contract for the -// edge where the host process (the CLI's `docker` child) is terminated by a -// signal. os/exec reports ExitCode()==-1 in that case; execExitCode must -// recover the signal from the WaitStatus and return 128+N, matching the TS CLI -// (e.g. 143 for SIGTERM), instead of surfacing -1/255. -// -// Hermetic: runs /bin/sleep only, no Docker. The test signals the child -// directly and then Waits, producing a real *exec.ExitError with a signaled -// WaitStatus. func TestExecExitCode_SignaledDerives128PlusN(t *testing.T) { cases := []struct { name string signal syscall.Signal want int }{ - {"SIGTERM", syscall.SIGTERM, 128 + int(syscall.SIGTERM)}, // 143 - {"SIGKILL", syscall.SIGKILL, 128 + int(syscall.SIGKILL)}, // 137 - {"SIGINT", syscall.SIGINT, 128 + int(syscall.SIGINT)}, // 130 + {"SIGTERM", syscall.SIGTERM, 128 + int(syscall.SIGTERM)}, + {"SIGKILL", syscall.SIGKILL, 128 + int(syscall.SIGKILL)}, + {"SIGINT", syscall.SIGINT, 128 + int(syscall.SIGINT)}, } for _, tc := range cases { @@ -42,7 +33,6 @@ func TestExecExitCode_SignaledDerives128PlusN(t *testing.T) { t.Fatalf("expected *exec.ExitError from a signaled process, got %T: %v", err, err) } - // Sanity: os/exec reports -1 for a signaled child. if got := exitErr.ExitCode(); got != -1 { t.Fatalf("precondition: expected ExitCode()==-1 for signaled child, got %d", got) } @@ -54,9 +44,6 @@ func TestExecExitCode_SignaledDerives128PlusN(t *testing.T) { } } -// TestExecExitCode_NormalExitPassthrough verifies that a normal (non-signaled) -// exit code is passed through unchanged, i.e. the signal-derivation path only -// triggers on ExitCode()==-1. func TestExecExitCode_NormalExitPassthrough(t *testing.T) { cmd := exec.Command("/bin/sh", "-c", "exit 7") err := cmd.Run() diff --git a/internal/cli/extract_targz_test.go b/internal/cli/extract_targz_test.go index ae43eef..98fe3b5 100644 --- a/internal/cli/extract_targz_test.go +++ b/internal/cli/extract_targz_test.go @@ -9,8 +9,6 @@ import ( "testing" ) -// writeTarGz builds a .tar.gz from name->content entries (a name ending in "/" is -// a directory entry) and returns its path. func writeTarGz(t *testing.T, entries [][2]string) string { t.Helper() var buf bytes.Buffer @@ -59,14 +57,11 @@ func TestExtractTarGz_Normal(t *testing.T) { if _, err := os.Stat(filepath.Join(dest, "sub", "install.sh")); err != nil { t.Errorf("nested file missing: %v", err) } - // extractTarGz removes the archive on success. if _, err := os.Stat(arc); !os.IsNotExist(err) { t.Errorf("archive should have been removed, stat err = %v", err) } } -// TestExtractTarGz_ZipSlip proves the path-traversal guard: an entry that would -// escape the destination directory is rejected and no file is written outside. func TestExtractTarGz_ZipSlip(t *testing.T) { arc := writeTarGz(t, [][2]string{ {"../escaped.txt", "pwned"}, @@ -81,15 +76,11 @@ func TestExtractTarGz_ZipSlip(t *testing.T) { if err == nil { t.Fatal("expected a zip-slip rejection, got nil") } - // The escaping file must NOT have been created in the parent. if _, statErr := os.Stat(filepath.Join(parent, "escaped.txt")); statErr == nil { t.Error("zip-slip guard failed: a file was written outside the destination") } } -// TestExtractTarGz_SymlinkEscape proves that lexical path checks are not the -// only boundary: an existing link below the extraction directory must not let -// a regular archive entry write outside it. func TestExtractTarGz_SymlinkEscape(t *testing.T) { arc := writeTarGz(t, [][2]string{ {"link/escaped.txt", "pwned"}, diff --git a/internal/cli/feature_advisories_test.go b/internal/cli/feature_advisories_test.go index 46bcb8e..62ed1d4 100644 --- a/internal/cli/feature_advisories_test.go +++ b/internal/cli/feature_advisories_test.go @@ -9,9 +9,6 @@ import ( "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", diff --git a/internal/cli/feature_deps.go b/internal/cli/feature_deps.go index 3cbdc2a..b8d4a99 100644 --- a/internal/cli/feature_deps.go +++ b/internal/cli/feature_deps.go @@ -13,11 +13,6 @@ import ( "github.com/devcontainers/cli/internal/oci" ) -// renderDependencyMermaid resolves the dependency graph rooted at the given -// feature ids through the unified features.BuildDependencyGraph builder and -// renders it as a mermaid flowchart (TS generateMermaidDiagram). It is used by -// `features info` (single OCI root). Node hashes are internally consistent but -// need not match the TS hashes byte-for-byte — the parity harness scrubs them. func renderDependencyMermaid(client oci.Registry, logger log.Logger, roots []string) string { userFeatures := make([]features.DevContainerFeature, 0, len(roots)) for _, r := range roots { @@ -34,11 +29,6 @@ func renderDependencyMermaid(client oci.Registry, logger log.Logger, roots []str return features.GenerateMermaidDiagram(graph) } -// newMetadataProcessFeature returns a read-only processFeature seam that resolves -// a feature only far enough to read its dependency metadata (id, legacyIds, -// dependsOn, installsAfter). It does not stage install content, so it is cheap -// enough for the resolve-dependencies and mermaid consumers. OCI metadata is read -// annotation-first with a blob fallback, matching the TS getOCIFeatureMetadata. func newMetadataProcessFeature(client oci.Registry, logger log.Logger, local localFeatureRoots, lockfile *features.Lockfile) features.ProcessFeature { return func(node *features.FNode) (*features.Set, error) { id := node.UserFeatureID @@ -77,7 +67,7 @@ func newMetadataProcessFeature(client oci.Registry, logger log.Logger, local loc Features: []features.Feature{meta}, }, nil - default: // OCI (and legacy shorthand resolved to OCI) + default: resolvedID, _ := features.ResolveID(id, false) ref, err := oci.ParseRef(resolvedID) if err != nil { @@ -119,16 +109,12 @@ func newMetadataProcessFeature(client oci.Registry, logger log.Logger, local loc } } -// readOCIFeatureMetadata reads a feature's metadata annotation-first, falling -// back to the extracted devcontainer-feature.json blob when the manifest omits -// the dev.containers.metadata annotation (TS getOCIFeatureMetadata). func readOCIFeatureMetadata(client oci.Registry, ref *oci.Ref, manifest *oci.ManifestContainer) features.Feature { if mj, ok := manifest.Manifest.Annotations["dev.containers.metadata"]; ok && mj != "" { var meta features.Feature json.Unmarshal([]byte(mj), &meta) return meta } - // Blob fallback: fetch and extract the layer to read devcontainer-feature.json. if len(manifest.Manifest.Layers) == 0 { return features.Feature{} } @@ -140,8 +126,6 @@ func readOCIFeatureMetadata(client oci.Registry, ref *oci.Ref, manifest *oci.Man 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 { @@ -162,8 +146,6 @@ func featureMetadataFromTarball(blob []byte) (features.Feature, error) { return meta, nil } -// ociAnnotationID is the id in the manifest's metadata annotation, or "" when -// the annotation is missing, empty or not JSON (none of which unmarshal). func ociAnnotationID(manifest *oci.ManifestContainer) string { var meta features.Feature if json.Unmarshal([]byte(manifest.Manifest.Annotations["dev.containers.metadata"]), &meta) != nil { @@ -188,9 +170,6 @@ func readLocalFeatureMetadata(dir string) (features.Feature, error) { return meta, nil } -// 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 { diff --git a/internal/cli/feature_deps_held_test.go b/internal/cli/feature_deps_held_test.go index 1001455..11a3ebe 100644 --- a/internal/cli/feature_deps_held_test.go +++ b/internal/cli/feature_deps_held_test.go @@ -18,8 +18,6 @@ func resolveMetadata(t *testing.T, reg oci.Registry, local localFeatureRoots, lo return newMetadataProcessFeature(reg, log.Null, local, 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) { roots := devcontainerRoots(t) base := roots.configDir @@ -46,10 +44,6 @@ func TestMetadataProcessFeatureLocal(t *testing.T) { } } -// resolve-dependencies refuses a local Feature outside the workspace's -// .devcontainer folder as TS processFeatureIdentifier does: the reason is -// logged and no Feature set comes back, which the graph fails; without a -// configuration (features info's graph) a local Feature is refused too. func TestMetadataProcessFeatureRefusesLocalFeaturesOutsideDevcontainer(t *testing.T) { roots := devcontainerRoots(t) writeFeature(t, roots.workspaceRoot, "feat", `{"version":"1.0.0"}`) @@ -72,7 +66,6 @@ func TestMetadataProcessFeatureRefusesLocalFeaturesOutsideDevcontainer(t *testin } } -// 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" @@ -93,9 +86,6 @@ func TestMetadataProcessFeatureTarball(t *testing.T) { } } -// 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":{}}}`} @@ -180,8 +170,6 @@ func TestMetadataProcessFeatureOCI(t *testing.T) { } } -// `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":{}}}` @@ -197,7 +185,6 @@ func TestRenderDependencyMermaid(t *testing.T) { } } -// 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{}) { diff --git a/internal/cli/feature_hostsub_test.go b/internal/cli/feature_hostsub_test.go index cc0f150..574bc67 100644 --- a/internal/cli/feature_hostsub_test.go +++ b/internal/cli/feature_hostsub_test.go @@ -7,8 +7,6 @@ import ( "github.com/devcontainers/cli/internal/features" ) -// upstream #308: a Feature's containerEnv/mounts must get the same host-side -// variable substitution as devcontainer.json's own values. func TestSubstituteFeatureHostVars(t *testing.T) { hs := config.HostSubContext{ Platform: "linux", diff --git a/internal/cli/feature_install.go b/internal/cli/feature_install.go index afc6134..6d2f1ff 100644 --- a/internal/cli/feature_install.go +++ b/internal/cli/feature_install.go @@ -36,84 +36,45 @@ import ( mobyclient "github.com/moby/moby/client" ) -// FeatureBuildOptions holds extra build options to thread through feature install. type FeatureBuildOptions struct { - NoCache bool - // HostSub, when set, resolves ${localEnv:…}/${localWorkspaceFolder}/… in each - // Feature's containerEnv/mounts — same as the config's own substitution. - HostSub *config.HostSubContext - CacheFrom []string - CacheTo string - Labels []string - Platform string - Push bool - Output string - // Secrets are Docker build secrets ("KEY=VALUE") forwarded to the - // feature-install image build as buildx `--secret id=KEY,env=KEY`, so a - // Feature's install.sh can use `RUN --mount=type=secret` (#1078). Requires - // buildx; ignored by the legacy builder. - Secrets []string - CABundle string - ContainerEnv map[string]string - SkipFeatureAutoMapping bool - SkipPersistCustoms bool - FeaturesBasePath string - // WorkspaceFolder is the workspace root local Features must lie under - // (in its .devcontainer folder); "" stands for the working directory. - WorkspaceFolder string - // AdditionalMetadata follows the Features' entries in the metadata the - // Features' users are found in (TS findContainerUsers), variables - // substituted; LabelMetadata follows them in the image's - // devcontainer.metadata label, as written (TS getDevcontainerMetadata's raw). - AdditionalMetadata []imagemeta.Entry - LabelMetadata []imagemeta.Entry - // devcontainer-lock.json is written with the resolved feature digests - // unless NoLockfile is set, as TS generateFeaturesConfig writes it; - // FrozenLockfile validates the resolved features against the committed - // lockfile instead and aborts on drift. ConfigPath is the devcontainer.json - // path used to locate the lockfile. - FrozenLockfile bool - // NoLockfile disables lockfile reading (pinning-on-read) and writing, matching - // TS --no-lockfile ("Disable lockfile generation and verification."). - NoLockfile bool - ConfigPath string - // LockfileExcludeIDs holds userFeatureIds supplied only via - // --additional-features; 0.88 (#11616) keeps these out of the lockfile. - LockfileExcludeIDs map[string]bool - // OverrideFeatureInstallOrder is cfg.overrideFeatureInstallOrder, threaded - // into the dependency graph builder so install order honors it. + NoCache bool + HostSub *config.HostSubContext + CacheFrom []string + CacheTo string + Labels []string + Platform string + Push bool + Output string + Secrets []string + CABundle string + ContainerEnv map[string]string + SkipFeatureAutoMapping bool + SkipPersistCustoms bool + FeaturesBasePath string + WorkspaceFolder string + AdditionalMetadata []imagemeta.Entry + LabelMetadata []imagemeta.Entry + FrozenLockfile bool + NoLockfile bool + ConfigPath string + LockfileExcludeIDs map[string]bool OverrideFeatureInstallOrder []string } -// fetchFeatureResult holds the fetched features and their features folder. type fetchFeatureResult struct { FeatureSets []*features.Set - // TmpDir is the features folder (TS dstFolder): - // /container-features/-, holding each Feature - // at its consecutiveId. Like TS, the build paths keep it. - TmpDir string + TmpDir string } -// 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, local localFeatureRoots, skipAutoMapping bool) (*fetchFeatureResult, error) { return fetchFeatureSetsWithOrder(ctx, logger, featuresCfg, local, skipAutoMapping, nil, nil) } -// localFeatureRoots are what a local Feature reference (./x) resolves -// against: the folder of the configuration that lists it ("" when there is no -// configuration) and the workspace root (TS params.cwd: the workspace folder, -// or the working directory without one), whose .devcontainer folder the -// Feature must lie in. type localFeatureRoots struct { configDir string workspaceRoot string } -// resolve returns a local Feature's folder. A reference TS -// processFeatureIdentifier refuses is logged as the error TS writes and -// reported as no Feature set (nil, nil), which the dependency graph fails as -// a Feature that could not be processed, as TS does. func (r localFeatureRoots) resolve(logger log.Logger, id string) (string, bool, error) { path, err := features.LocalFeaturePath(id, r.configDir, r.workspaceRoot) if invalid, ok := errors.AsType[*features.InvalidLocalFeatureError](err); ok { @@ -126,12 +87,6 @@ func (r localFeatureRoots) resolve(logger log.Logger, id string) (string, bool, return path, true, nil } -// createFeaturesFolder creates the folder the Features are staged in, TS -// createFeaturesTempFolder: /container-features/-. -// The folder is this run's alone: runs started in the same millisecond would -// otherwise share it, and one run's realignFeatureDirs renames and removes the -// other's .stage- folders. A name another run holds moves to the next -// millisecond. func createFeaturesFolder() (string, error) { parent := filepath.Join(cacheFolder(), "container-features") if err := os.MkdirAll(parent, 0o755); err != nil { @@ -149,17 +104,11 @@ func createFeaturesFolder() (string, error) { } } -// 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 in the features folder at its -// consecutiveId, `_`, as TS fetchFeatures does. func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, featuresCfg map[string]any, local localFeatureRoots, skipAutoMapping bool, lockfile *features.Lockfile, overrideOrder []string) (*fetchFeatureResult, error) { if len(featuresCfg) == 0 { return nil, nil } - // Deterministic user-feature order (Go maps iterate randomly). userFeatures := features.UserFeaturesToArray(featuresCfg) sort.Slice(userFeatures, func(i, j int) bool { return userFeatures[i].UserFeatureID < userFeatures[j].UserFeatureID @@ -174,11 +123,6 @@ func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, featuresC return nil, err } - // processFeature is the injected seam (TS processFeature): given a node's - // UserFeatureID + Options, fetch/extract the feature and read its metadata - // (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. var stageIdx int nextStageDir := func() string { dir := filepath.Join(dstFolder, fmt.Sprintf("%s%d", featureStagePrefix, stageIdx)) @@ -216,15 +160,8 @@ func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, featuresC 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 any, cachePath string) features.Feature { return features.Feature{ ID: id, @@ -259,10 +196,6 @@ func featureFromMetadata(m features.Feature, id string, opts any, cachePath stri } } -// processInstallFeature is the install-path implementation of the processFeature -// seam. It resolves and extracts a single feature (local / direct-tarball / OCI) -// into a staging directory and returns a Set whose Features[0] carries the -// feature metadata, so the graph builder can expand its dependencies. func processInstallFeature( logger log.Logger, ociClient oci.Registry, @@ -285,8 +218,6 @@ func processInstallFeature( if !features.IsKnownLegacyFeature(name) { return nil, &coreerrors.ContainerError{Description: fmt.Sprintf(msgLegacyFeature, id)} } - // gradle/maven/jupyterlab folded into java/python with an option - // (installGradle, ...) — matching the TS deprecatedFeaturesIntoOptions. if m, ok := features.DeprecatedFeatureIntoOptions[name]; ok { logger.Write(fmt.Sprintf("(!) WARNING: Falling back to the deprecated '%s' Feature. It is now part of the '%s' Feature.", name, m.MapTo), log.LevelWarning) id = fmt.Sprintf("ghcr.io/devcontainers/features/%s:1", m.MapTo) @@ -317,13 +248,10 @@ func processInstallFeature( if readErr != nil { return nil, fmt.Errorf("read local feature %q metadata: %w", id, readErr) } - // devcontainer-feature.json is JSONC (comments/trailing commas allowed). if err := jsonc.Unmarshal(metaData, &m); err != nil { return nil, fmt.Errorf("parse local feature %q metadata: %w", id, err) } - // 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) @@ -375,23 +303,17 @@ func processInstallFeature( SourceInfo: &features.TarballSource{TarballURI: id, UserID: id}, Features: []features.Feature{feat}, InternalVersion: "2", - // Record the tarball digest so the lockfile pins it (TS parity: a - // direct-tarball feature is locked by its computed sha256). - ComputedDigest: computedDigest, + ComputedDigest: computedDigest, }, nil - default: // OCI (and legacy shorthand resolved to OCI) + default: resolvedID, _ := features.ResolveID(id, skipAutoMapping) ref, err := oci.ParseRef(resolvedID) if err != nil { return nil, fmt.Errorf("ERR: Feature '%s' could not be processed. %w", id, err) } - // featureRef is the reference as written, even when the lockfile pins - // the manifest (TS tryGetOCIFeatureSet parses the user's id). userRef := ref - // 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 integrity := lockfile.Features[id].Integrity; integrity != "" { if pinned, perr := oci.ParseRef(ref.Resource + "@" + integrity); perr == nil { @@ -427,15 +349,11 @@ func processInstallFeature( } logger.Write(fmt.Sprintf("Extracted feature %s (%d bytes)", ref.ID, len(blobData)), log.LevelTrace) - // Blob-fallback metadata: the extracted devcontainer-feature.json, JSONC - // as TS's jsonc.parse reads it. var m features.Feature if metaData, readErr := os.ReadFile(filepath.Join(featureDir, "devcontainer-feature.json")); readErr == nil { jsonc.Unmarshal(metaData, &m) } - // TS's Feature takes its id from the metadata it spreads over - // {id: , ...}: the layer's here, the annotation's below. feat := features.Feature{ ID: cmp.Or(m.ID, ref.ID), Version: m.Version, @@ -495,7 +413,6 @@ func processInstallFeature( InternalVersion: "2", } - // Annotation-first metadata: overrides the blob-derived dependency fields. if manifest.Manifest.Annotations != nil { if metaJSON, ok := manifest.Manifest.Annotations["dev.containers.metadata"]; ok { var am features.Feature @@ -512,8 +429,6 @@ func processInstallFeature( set.Features[0].Entrypoint = am.Entrypoint set.Features[0].Mounts = am.Mounts set.Features[0].Customizations = am.Customizations - // TS reads the dependencies from the annotation and - // spreads it over the Feature (buildDependencyGraph). set.Features[0].Metadata = am.Metadata set.Features[0].ID = cmp.Or(am.ID, ref.ID) } @@ -523,8 +438,6 @@ func processInstallFeature( } } -// 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]] @@ -534,9 +447,6 @@ func featureIDWithoutVersion(id string) string { 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 { @@ -549,12 +459,6 @@ func tarballFeatureID(uri string) string { 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 == "" { @@ -575,7 +479,6 @@ func realignFeatureDirs(dstFolder string, sets []*features.Set) error { } set.Features[0].ConsecutiveId = consecutiveID } - // Remove any leftover staging directories. entries, _ := os.ReadDir(dstFolder) for _, e := range entries { if e.IsDir() && strings.HasPrefix(e.Name(), featureStagePrefix) { @@ -585,12 +488,6 @@ func realignFeatureDirs(dstFolder string, sets []*features.Set) error { 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) @@ -625,9 +522,6 @@ func stageFeatureBuildFiles(dstFolder string, sets []*features.Set, containerUse 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 { @@ -647,8 +541,6 @@ func buildFeatureContentImage(ctx context.Context, dockerClient *docker.Client, 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 @@ -662,9 +554,6 @@ func engineAtLeast23(ctx context.Context, engine *docker.EngineClient) bool { return major >= 23 } -// extendImageWithFeatures fetches features from OCI, generates the extended -// Dockerfile, and builds the image with features installed. -// Returns the new image name. // substituteFeatureHostVars resolves host-side variables (${localEnv:…}, // ${localWorkspaceFolder}, …) in each Feature's containerEnv values and mounts, // so a Feature mount like "${localEnv:HOME}/.cfg" reaches Docker resolved — @@ -710,9 +599,6 @@ func extendImageWithFeatures( imageNames []string, fbOpts *FeatureBuildOptions, ) ([]string, error) { - // Read the lockfile (if any) to pin feature resolution to recorded digests. - // One that cannot be read fails the build, as TS generateFeaturesConfig's - // readLockfile rethrows. var lockfile *features.Lockfile if fbOpts.ConfigPath != "" && !fbOpts.NoLockfile { var err error @@ -728,20 +614,14 @@ func extendImageWithFeatures( return imageNames, nil } featureSets := result.FeatureSets - // The features folder is kept, as TS keeps it. tmpDir := result.TmpDir - // Resolve ${localEnv:…} / ${localWorkspaceFolder} / … in each Feature's - // containerEnv and mounts, like the config's own values (upstream #308). if fbOpts.HostSub != nil { if err := substituteFeatureHostVars(featureSets, *fbOpts.HostSub); err != nil { return nil, err } } - // Write (or, frozen, validate) the features lockfile unless --no-lockfile - // is given, as TS generateFeaturesConfig does (containerFeaturesConfiguration.ts - // `if (!params.noLockfile) await writeLockfile(...)`). if !fbOpts.NoLockfile && fbOpts.ConfigPath != "" { lf := features.GenerateLockfile(&features.Config{FeatureSets: featureSets}, fbOpts.LockfileExcludeIDs) if err := features.WriteLockfile(fbOpts.ConfigPath, lf, features.WriteOptions{Frozen: fbOpts.FrozenLockfile}); err != nil { @@ -749,7 +629,6 @@ func extendImageWithFeatures( } } - // Inspect base image once — used for metadata labels and user resolution. baseImageInfo, _ := engine.InspectImage(ctx, baseImage) metadata := []imagemeta.Entry{} @@ -764,15 +643,12 @@ func extendImageWithFeatures( labelMetadata := append(slices.Clone(metadata), fbOpts.LabelMetadata...) metadata = append(metadata, fbOpts.AdditionalMetadata...) - // 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 { secretIDs = buildSecretIDs(fbOpts.Secrets) @@ -807,7 +683,6 @@ func extendImageWithFeatures( } } - // Write the Dockerfile dockerfilePath := filepath.Join(tmpDir, "Dockerfile.extended") content := buildInfo.DockerfilePrefixContent + buildInfo.DockerfileContent if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil { @@ -817,13 +692,11 @@ func extendImageWithFeatures( logger.Write("Generated feature Dockerfile:", log.LevelTrace) logger.Write(content, log.LevelTrace) - // Determine output image name outputName := baseImage + "-features" if len(imageNames) > 0 { outputName = imageNames[0] } - // Build — thread through extra options tags := []string{outputName} if len(imageNames) > 1 { tags = imageNames @@ -847,7 +720,6 @@ func extendImageWithFeatures( FileSecrets: caSecrets, } - // Add BuildKit contexts — "." is the features folder for ctx, ctxPath := range buildInfo.BuildKitContexts { if ctxPath == "." { ctxPath = tmpDir @@ -870,8 +742,6 @@ func extendImageWithFeatures( } } - // Bridge the CLI credential chain for the push target / registry cache. The - // base here is the locally-built intermediate image, so it needs no auth. authEnv, authCleanup := bridgeBuildAuth(logger, "", tags, buildOpts.CacheFrom, buildOpts.CacheTo) defer authCleanup() buildOpts.Env = authEnv @@ -889,9 +759,6 @@ func extendImageWithFeatures( 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 != "" { @@ -915,8 +782,6 @@ func findContainerUsers(metadata []imagemeta.Entry, composeServiceUser, imageUse 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 fs.SourceInfo != nil && fs.SourceInfo.UserFeatureID() != "" { id = fs.SourceInfo.UserFeatureID() @@ -938,7 +803,6 @@ func featureMetadataEntry(fs *features.Set, skipPersistCustoms bool) imagemeta.E if !skipPersistCustoms { entry.Customizations = feat.Customizations } - // TS pickFeatureLifecycleHookProperties: every hook a Feature declares. entry.OnCreateCommand = feat.OnCreateCommand entry.UpdateContentCommand = feat.UpdateContentCommand entry.PostCreateCommand = feat.PostCreateCommand @@ -947,15 +811,12 @@ func featureMetadataEntry(fs *features.Set, skipPersistCustoms bool) imagemeta.E return entry } -// extractTarGz extracts a .tar or .tgz to a directory. -// Tries gzip first; falls back to plain tar. func extractTarGz(archivePath, destDir string) error { f, err := os.Open(archivePath) if err != nil { return err } - // Try gzip first var reader io.Reader gzr, gzErr := gzip.NewReader(f) if gzErr == nil { @@ -963,7 +824,6 @@ func extractTarGz(archivePath, destDir string) error { defer gzr.Close() defer f.Close() } else { - // Not gzip — reopen as plain tar f.Close() f2, err := os.Open(archivePath) if err != nil { @@ -1013,8 +873,6 @@ func extractTarGz(archivePath, destDir string) error { if err != nil { return fmt.Errorf("create tar entry %q: %w", header.Name, err) } - // A truncated copy must fail the install, not silently produce a - // corrupt Feature that we report as success. if _, err := io.Copy(out, tr); err != nil { out.Close() return fmt.Errorf("extract tar entry %q: %w", header.Name, err) @@ -1033,7 +891,6 @@ func extractTarGz(archivePath, destDir string) error { return nil } -// extractUserOptions pulls out the user-provided options from the feature value. func extractUserOptions(v any) map[string]any { if m, ok := v.(map[string]any); ok { return m @@ -1041,9 +898,6 @@ func extractUserOptions(v any) map[string]any { 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 @@ -1060,17 +914,6 @@ 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; -// - attaches Authorization: Bearer $GITHUB_TOKEN for github.com / api.github.com -// URLs, so private release assets resolve (the naive GET 404'd on them); -// - accepts any 2xx (TS treats 200-299 as success), not only 200; -// - warns when downloading over plaintext HTTP; -// - goes through the shared proxy- and custom-CA-aware transport. -// -// It returns the tarball bytes and their computed "sha256:" digest so the -// caller can pin/verify integrity (TS records and checks this digest). func downloadFeatureTarball(ctx context.Context, logger log.Logger, tarballURL string, env map[string]string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarballURL, nil) if err != nil { @@ -1100,12 +943,8 @@ func downloadFeatureTarball(ctx context.Context, logger log.Logger, tarballURL s 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 { @@ -1117,11 +956,8 @@ func readCapped(r io.Reader, max int64) ([]byte, error) { 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 — -// Go's http.Client strips Authorization on a cross-host redirect, which is the -// correct behavior for GitHub release assets (the redirect target is presigned). +// Go's http.Client strips Authorization on a cross-host redirect, so a GitHub +// release asset's redirect to its presigned CDN URL does not carry the token. func featureRequestHeaders(tarballURL string, env map[string]string, logger log.Logger) map[string]string { headers := map[string]string{"User-Agent": "devcontainer"} if isGitHubTarballURI(tarballURL) { @@ -1137,13 +973,10 @@ func featureRequestHeaders(tarballURL string, env map[string]string, logger log. return headers } -// isGitHubTarballURI matches the TS isGitHubUri check (raw prefix, no parsing). func isGitHubTarballURI(u string) bool { return strings.HasPrefix(u, "https://github.com") || strings.HasPrefix(u, "https://api.github.com") } -// isPlainHTTPURL reports whether the URL would travel unencrypted (TS warns on -// this): a http:// scheme, or a localhost host regardless of scheme. func isPlainHTTPURL(u string) bool { parsed, err := url.Parse(u) if err != nil { @@ -1152,8 +985,6 @@ func isPlainHTTPURL(u string) bool { return parsed.Scheme == "http" || parsed.Hostname() == "localhost" } -// 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 any, key string) map[string]any { m := map[string]any{} if existing, ok := opts.(map[string]any); ok { diff --git a/internal/cli/feature_install_held_test.go b/internal/cli/feature_install_held_test.go index 7f12abc..5b151b7 100644 --- a/internal/cli/feature_install_held_test.go +++ b/internal/cli/feature_install_held_test.go @@ -30,7 +30,6 @@ import ( "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 @@ -49,9 +48,6 @@ func sha256Digest(data []byte) string { 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 @@ -71,8 +67,6 @@ func ociRefKey(ref *oci.Ref) string { 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) @@ -108,8 +102,6 @@ func (r *ociFeatureRegistry) FetchBlob(_ *oci.Ref, digest string) ([]byte, error 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 @@ -124,10 +116,6 @@ func installFeature(t *testing.T, reg oci.Registry, id string, opts any, lockfil return processInstallFeature(log.Null, reg, &features.FNode{UserFeatureID: id, Options: opts}, localFeatureRoots{}, 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"], @@ -201,8 +189,6 @@ func TestProcessInstallFeatureOCI(t *testing.T) { }) } -// 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) @@ -231,7 +217,6 @@ func TestProcessInstallFeatureOCIPinsToTheLockfile(t *testing.T) { } } -// 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) @@ -244,8 +229,6 @@ func TestProcessInstallFeatureLegacyShorthandWithVersion(t *testing.T) { } } -// 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"}`} @@ -297,7 +280,6 @@ func TestProcessInstallFeatureOCIErrors(t *testing.T) { } } -// 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) { @@ -308,9 +290,6 @@ func serveBytes(t *testing.T, body []byte, status int) string { 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 @@ -381,8 +360,6 @@ func TestProcessInstallFeatureTarballErrors(t *testing.T) { }) } -// 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", @@ -395,8 +372,6 @@ func TestTarballFeatureIDUsesTheURLPath(t *testing.T) { } } -// 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}, devcontainerRoots(t), false) @@ -406,7 +381,6 @@ func TestFetchFeatureSetsReportsTheFirstSortedFailure(t *testing.T) { } } -// 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")}}} @@ -436,8 +410,6 @@ func TestFindContainerUsers(t *testing.T) { } } -// 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 @@ -494,11 +466,8 @@ func TestEngineAtLeast23(t *testing.T) { } } -// 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) @@ -556,8 +525,6 @@ func TestBuildFeatureContentImage(t *testing.T) { } } -// extendRun is one extendImageWithFeatures call over a local Feature with a -// fake docker CLI and engine. type extendRun struct { useBuildx bool imageNames []string @@ -565,15 +532,15 @@ type extendRun struct { reply func([]string) (string, int, error) fbOpts FeatureBuildOptions features map[string]any - engine string // the daemon's version, 25.0.0 when empty + engine string } type extendResult struct { names []string err error calls []dockerCall - build dockerCall // the extended image's build - features string // the features folder + build dockerCall + features string } func (e extendRun) run(t *testing.T) extendResult { @@ -604,7 +571,6 @@ func (e extendRun) run(t *testing.T) extendResult { return res } -// tagsOf lists the -t values of a build. func tagsOf(c dockerCall) []string { var tags []string for i, a := range c { @@ -615,8 +581,6 @@ func tagsOf(c dockerCall) []string { 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 @@ -637,8 +601,6 @@ func TestExtendImageWithFeaturesTags(t *testing.T) { } } -// 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 @@ -668,8 +630,6 @@ func TestExtendImageWithFeaturesUsesTheImageUser(t *testing.T) { } } -// 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 { @@ -689,9 +649,6 @@ func TestExtendImageWithFeaturesContentImage(t *testing.T) { } } -// 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) { @@ -718,9 +675,6 @@ func TestExtendImageWithFeaturesSwitchesToADockerDriverBuilder(t *testing.T) { } } -// The lockfile is written by default, as TS generateFeaturesConfig writes it -// unless --no-lockfile is given; --frozen-lockfile fails when the resolved -// Features differ from it. An existing lockfile pins the Features it records. func TestExtendImageWithFeaturesLockfile(t *testing.T) { tgz := featureTgzBytes(t, map[string]string{"devcontainer-feature.json": `{"id":"tb","version":"1.2.3"}`, "install.sh": "#!/bin/sh\n"}) sum := sha256.Sum256(tgz) @@ -750,7 +704,6 @@ func TestExtendImageWithFeaturesLockfile(t *testing.T) { } }) t.Run("local Features only", func(t *testing.T) { - // generateLockfile records OCI and tarball Features only: an empty map. configPath := filepath.Join(t.TempDir(), "devcontainer.json") if res := (extendRun{fbOpts: FeatureBuildOptions{ConfigPath: configPath}}).run(t); res.err != nil { t.Fatal(res.err) @@ -798,8 +751,6 @@ func TestExtendImageWithFeaturesLockfile(t *testing.T) { } }) t.Run("malformed", func(t *testing.T) { - // TS readLockfile rethrows the JSON.parse error: the build fails with - // its message before anything is built; --no-lockfile does not read it. configPath := filepath.Join(t.TempDir(), "devcontainer.json") if err := os.WriteFile(features.LockfilePath(configPath), []byte("not json"), 0o644); err != nil { t.Fatal(err) @@ -824,7 +775,6 @@ func TestExtendImageWithFeaturesLockfile(t *testing.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) @@ -832,8 +782,6 @@ func TestExtendImageWithFeaturesLockfile(t *testing.T) { }) } -// 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") @@ -874,8 +822,6 @@ func TestExtendImageWithFeaturesBridgesRegistryAuth(t *testing.T) { } } -// 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 @@ -901,8 +847,6 @@ func TestExtendImageWithFeaturesSyntaxDirective(t *testing.T) { } } -// 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, @@ -915,7 +859,6 @@ func TestFeatureMetadataEntryFallsBackToTheFeatureID(t *testing.T) { } } -// 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"}}} @@ -927,11 +870,6 @@ func TestRealignFeatureDirsSkipsUnstagedSets(t *testing.T) { } } -// An OCI Feature set is written as TS's featuresConfiguration has it: the -// sourceInformation properties in tryGetOCIFeatureSet's order, the manifest -// as the registry served it, featureRef parsed from the reference as written -// even when the lockfile pins the manifest, and the Feature built from the -// annotation's metadata spread over the layer's devcontainer-feature.json. func TestProcessInstallFeatureOCISetJSON(t *testing.T) { const id = "ghcr.io/owner/coll/hello:1" digest := "sha256:" + strings.Repeat("cd", 32) @@ -962,9 +900,6 @@ func TestProcessInstallFeatureOCISetJSON(t *testing.T) { } } -// An OCI Feature's id is its metadata's, as TS spreads the metadata over -// {id: , ...}: the annotation's when the manifest has one, -// else the layer's, else the reference's. consecutiveId follows it. func TestProcessInstallFeatureOCIIDIsTheMetadatas(t *testing.T) { const id = "ghcr.io/owner/coll/hello:1" renamed, anonymous := `{"id":"new-hello"}`, `{"version":"1.0.0"}` diff --git a/internal/cli/feature_install_nodocker_held_test.go b/internal/cli/feature_install_nodocker_held_test.go index af527b3..dd3861f 100644 --- a/internal/cli/feature_install_nodocker_held_test.go +++ b/internal/cli/feature_install_nodocker_held_test.go @@ -14,9 +14,6 @@ import ( "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) { roots := devcontainerRoots(t) base := roots.configDir @@ -61,8 +58,6 @@ func TestExtendImageWithFeaturesResolvesFeatureHostVars(t *testing.T) { } } -// 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 { @@ -74,8 +69,6 @@ func TestExtendImageWithFeaturesBuildContext(t *testing.T) { } } -// 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) diff --git a/internal/cli/feature_install_repo_held_test.go b/internal/cli/feature_install_repo_held_test.go index 3bb6e64..1f8257e 100644 --- a/internal/cli/feature_install_repo_held_test.go +++ b/internal/cli/feature_install_repo_held_test.go @@ -12,9 +12,6 @@ import ( "testing" ) -// A regular file keeps the mode its tar entry records, so a Feature's -// install.sh stays executable; an entry recording no mode keeps the mode the -// file was created with instead of becoming unreadable. func TestExtractTarGzFileModes(t *testing.T) { var buf bytes.Buffer tw := tar.NewWriter(&buf) @@ -45,8 +42,6 @@ func TestExtractTarGzFileModes(t *testing.T) { } } -// A tarball download succeeds only on a 2xx answer, as TS treats only -// 200-299 as success: a 300 fails it. func TestDownloadFeatureTarballStatus(t *testing.T) { for status, ok := range map[int]bool{200: true, 299: true, 300: false} { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/cli/feature_install_test.go b/internal/cli/feature_install_test.go index 08716e4..5161691 100644 --- a/internal/cli/feature_install_test.go +++ b/internal/cli/feature_install_test.go @@ -15,9 +15,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// devcontainerRoots creates a workspace with an empty .devcontainer folder and -// returns the roots of a configuration in that folder, where local Features -// must lie. func devcontainerRoots(t *testing.T) localFeatureRoots { t.Helper() ws := t.TempDir() @@ -99,7 +96,6 @@ func TestFetchFeatureSets(t *testing.T) { if err != nil { t.Fatal(err) } - // 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) } @@ -222,12 +218,6 @@ func TestFeatureMetadataEntry_UsesOCIUserID(t *testing.T) { } } -// 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) { roots := devcontainerRoots(t) baseDir := roots.configDir @@ -289,10 +279,6 @@ func TestFetchFeatureSets_StagingLayout(t *testing.T) { } } -// 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") @@ -338,8 +324,6 @@ func TestStageFeatureBuildFiles(t *testing.T) { } } - // 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) } @@ -348,8 +332,6 @@ func TestStageFeatureBuildFiles(t *testing.T) { } } -// 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", @@ -363,8 +345,6 @@ func TestFeatureIDWithoutVersion(t *testing.T) { } } -// 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" { @@ -379,9 +359,6 @@ func TestFeatureMetadataEntry_UsesUserFeatureIDForLocalAndTarball(t *testing.T) } } -// 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"}, @@ -408,10 +385,6 @@ func TestFeatureMetadataEntryCarriesEveryLifecycleHook(t *testing.T) { } } -// A local Feature outside the workspace's .devcontainer folder, or given by an -// absolute path, is refused as TS processFeatureIdentifier refuses it: the -// reason is logged as an error and the Feature fails as one that could not be -// processed, the plain Error build and up report as their message. func TestFetchFeatureSetsRefusesLocalFeaturesOutsideDevcontainer(t *testing.T) { roots := devcontainerRoots(t) writeLocalFeature(t, roots.workspaceRoot, "feat", nil) @@ -434,7 +407,6 @@ func TestFetchFeatureSetsRefusesLocalFeaturesOutsideDevcontainer(t *testing.T) { if !strings.Contains(logs.String(), tc.logged+"\n") { t.Errorf("logs = %q, want %q", logs.String(), tc.logged) } - // up wraps it; both report the message TS's plain Error has. for _, e := range []error{err, fmt.Errorf("install features: %w", err)} { want := coreerrors.ErrorOutput{Outcome: "error", Message: msg, Description: "An error occurred building the container."} if got := commandErrorOutput(e, "An error occurred building the container"); got != want { @@ -445,9 +417,6 @@ func TestFetchFeatureSetsRefusesLocalFeaturesOutsideDevcontainer(t *testing.T) { } } -// read-configuration resolves local Features against its workspace folder: -// a configuration at the workspace root (.devcontainer.json) has its Features -// outside .devcontainer, and the command fails with no result. func TestReadConfigurationRefusesALocalFeatureOutsideDevcontainer(t *testing.T) { startFakeEngine(t, nil) ws := writeConfigFixture(t, map[string]string{ @@ -461,10 +430,6 @@ func TestReadConfigurationRefusesALocalFeatureOutsideDevcontainer(t *testing.T) wantExitCode(t, err, 1) } -// Runs started in the same millisecond each get a features folder of their -// own: a shared one let one run's realignFeatureDirs remove the other's -// .stage- folders ("stage feature h_0: lstat .../.stage-0: no such file or -// directory" in the parity lane). func TestCreateFeaturesFolderIsTheRunsAlone(t *testing.T) { t.Setenv("TMPDIR", t.TempDir()) seen := map[string]bool{} diff --git a/internal/cli/feature_tarball_cap_held_test.go b/internal/cli/feature_tarball_cap_held_test.go index b51ae8a..5a0a475 100644 --- a/internal/cli/feature_tarball_cap_held_test.go +++ b/internal/cli/feature_tarball_cap_held_test.go @@ -5,8 +5,6 @@ import ( "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" { diff --git a/internal/cli/feature_tarball_test.go b/internal/cli/feature_tarball_test.go index 1ab6087..7cc2242 100644 --- a/internal/cli/feature_tarball_test.go +++ b/internal/cli/feature_tarball_test.go @@ -17,9 +17,9 @@ func TestIsGitHubTarballURI(t *testing.T) { cases := map[string]bool{ "https://github.com/owner/repo/releases/download/v1/feat.tgz": true, "https://api.github.com/repos/owner/repo/releases/assets/1": true, - "https://raw.githubusercontent.com/owner/repo/main/feat.tgz": false, // not github.com host + "https://raw.githubusercontent.com/owner/repo/main/feat.tgz": false, "https://example.com/feat.tgz": false, - "http://github.com/owner/repo/feat.tgz": false, // http, not https + "http://github.com/owner/repo/feat.tgz": false, } for u, want := range cases { if got := isGitHubTarballURI(u); got != want { @@ -32,9 +32,9 @@ func TestIsPlainHTTPURL(t *testing.T) { cases := map[string]bool{ "http://example.com/feat.tgz": true, "https://example.com/feat.tgz": false, - "https://localhost:8080/x.tgz": true, // localhost even over https + "https://localhost:8080/x.tgz": true, "http://localhost/x.tgz": true, - "https://127.0.0.1:5000/x.tgz": false, // only the literal "localhost" host is special + "https://127.0.0.1:5000/x.tgz": false, } for u, want := range cases { if got := isPlainHTTPURL(u); got != want { @@ -44,7 +44,6 @@ func TestIsPlainHTTPURL(t *testing.T) { } func TestFeatureRequestHeaders(t *testing.T) { - // Always a devcontainer User-Agent. h := featureRequestHeaders("https://example.com/x.tgz", nil, nil) if h["User-Agent"] != "devcontainer" { t.Errorf("User-Agent = %q, want devcontainer", h["User-Agent"]) @@ -53,20 +52,17 @@ func TestFeatureRequestHeaders(t *testing.T) { t.Error("non-GitHub URL must not get an Authorization header") } - // GitHub URL + token → bearer auth. h = featureRequestHeaders("https://github.com/o/r/releases/download/v1/f.tgz", map[string]string{"GITHUB_TOKEN": "secret"}, nil) if h["Authorization"] != "Bearer secret" { t.Errorf("Authorization = %q, want 'Bearer secret'", h["Authorization"]) } - // GitHub URL without a token → no auth header (not an empty bearer). h = featureRequestHeaders("https://github.com/o/r/f.tgz", map[string]string{}, nil) if _, ok := h["Authorization"]; ok { t.Error("GitHub URL without GITHUB_TOKEN must not set Authorization") } - // Non-GitHub URL never leaks the token. h = featureRequestHeaders("https://example.com/f.tgz", map[string]string{"GITHUB_TOKEN": "secret"}, nil) if _, ok := h["Authorization"]; ok { @@ -74,9 +70,6 @@ func TestFeatureRequestHeaders(t *testing.T) { } } -// TestDownloadFeatureTarball_HeadersAndDigest exercises the real HTTP path -// against a local server: it must send the devcontainer UA + bearer token and -// return the correct sha256 digest. func TestDownloadFeatureTarball_HeadersAndDigest(t *testing.T) { payload := []byte("fake-tarball-bytes") var gotUA, gotAuth string @@ -88,8 +81,6 @@ func TestDownloadFeatureTarball_HeadersAndDigest(t *testing.T) { })) defer srv.Close() - // The test server is not a github.com host, so no auth is expected here; use - // the header helper test above for the GitHub-auth branch. Verify UA + digest. data, digest, err := downloadFeatureTarball(context.Background(), nil, srv.URL, map[string]string{"GITHUB_TOKEN": "secret"}) if err != nil { t.Fatal(err) @@ -110,7 +101,6 @@ func TestDownloadFeatureTarball_HeadersAndDigest(t *testing.T) { } } -// TestDownloadFeatureTarball_Non2xx verifies non-2xx responses surface an error. func TestDownloadFeatureTarball_Non2xx(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -123,11 +113,9 @@ func TestDownloadFeatureTarball_Non2xx(t *testing.T) { } } -// TestDownloadFeatureTarball_2xxAccepted verifies a 2xx that is not 200 (e.g. 206) -// is accepted, matching the TS 200-299 range. func TestDownloadFeatureTarball_2xxAccepted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) // 204, still 2xx + w.WriteHeader(http.StatusNoContent) })) defer srv.Close() @@ -136,10 +124,6 @@ func TestDownloadFeatureTarball_2xxAccepted(t *testing.T) { } } -// 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")) diff --git a/internal/cli/features_info.go b/internal/cli/features_info.go index 582ae11..00fb439 100644 --- a/internal/cli/features_info.go +++ b/internal/cli/features_info.go @@ -67,7 +67,6 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, jsonOutput := make(map[string]any) - // Manifest if mode == "manifest" || mode == "verbose" { manifest, err := client.FetchManifest(ref, "") if err != nil { @@ -98,8 +97,6 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, } } - // Tags. TS parity: verbose text order is Manifest → Canonical → Published Tags - // → Dependency Tree (featuresCLI/info.ts), so Tags precedes Dependencies. if mode == "tags" || mode == "verbose" { tags, err := client.GetPublishedTags(ref) if err != nil || len(tags) == 0 { @@ -111,15 +108,12 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, if outputFormat == "text" { fmt.Fprintln(out.Stdout(), encloseStringInBox("Published Tags")) - // TS joins with "\n ": the first tag is flush, the rest indented by 3. fmt.Fprintln(out.Stdout(), strings.Join(tags, "\n ")) } else { jsonOutput["publishedTags"] = tags } } - // Dependencies — text mode only: the boxed title then the mermaid dependency - // graph (built by the shared renderer). Emitted AFTER Published Tags (TS order). if (mode == "dependencies" || mode == "verbose") && outputFormat == "text" { logger.Write(fmt.Sprintf("Building dependency graph for '%s'...", featureID), log.LevelInfo) fmt.Fprintln(out.Stdout(), encloseStringInBox("Dependency Tree (Render with https://mermaid.live/)")) @@ -134,9 +128,6 @@ func runFeaturesInfo(ctx context.Context, out Output, mode, featureID, logLevel, return nil } -// encloseStringInBox renders a single-line title inside a box sized exactly to the -// title (matching the TS encloseStringInBox): the ANSI bold codes are not counted -// toward the width. func encloseStringInBox(str string) string { w := len([]rune(str)) bar := strings.Repeat("─", w) diff --git a/internal/cli/features_info_held_test.go b/internal/cli/features_info_held_test.go index 9c56bc1..ec5e1a7 100644 --- a/internal/cli/features_info_held_test.go +++ b/internal/cli/features_info_held_test.go @@ -13,14 +13,11 @@ import ( "testing" ) -// readRegistry is a read-only OCI distribution registry over plain HTTP, -// addressed as localhost (which the OCI client reaches 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 - blobs map[string][]byte // digest -> blob, from any repository + tags map[string][]string + manifests map[string][]byte + blobs map[string][]byte } func newReadRegistry(t *testing.T) *readRegistry { @@ -75,8 +72,6 @@ func (r *readRegistry) serve(w http.ResponseWriter, req *http.Request) { _, _ = 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},` + @@ -146,8 +141,6 @@ func TestFeaturesInfoModes(t *testing.T) { } } -// 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") diff --git a/internal/cli/features_info_repo_held_test.go b/internal/cli/features_info_repo_held_test.go index 08af65e..cf13475 100644 --- a/internal/cli/features_info_repo_held_test.go +++ b/internal/cli/features_info_repo_held_test.go @@ -6,8 +6,6 @@ import ( "testing" ) -// info takes the mode from its first argument and the -// Feature from its second. func TestFeaturesInfoCommandArguments(t *testing.T) { reg := newReadRegistry(t) reg.manifests["ns/tool"] = featureManifest("tool") diff --git a/internal/cli/features_resolve_deps.go b/internal/cli/features_resolve_deps.go index 87d1a62..0e28eec 100644 --- a/internal/cli/features_resolve_deps.go +++ b/internal/cli/features_resolve_deps.go @@ -31,8 +31,6 @@ func realFeaturesResolveDepsCmd() *cobra.Command { 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 { @@ -42,18 +40,12 @@ func realFeaturesResolveDepsCmd() *cobra.Command { cfg := loadResult.Config userFeatures := features.UserFeaturesToArray(cfg.Features) if len(userFeatures) == 0 { - // TS parity: no parseable features → stderr error + exit 1 - // (featuresCLI/resolveDependencies.ts:92-93), NOT an empty - // installOrder on stdout. logger.Write(fmt.Sprintf("Could not parse features object in configuration '%s'", cfg.ConfigFilePath), log.LevelError) return &coreerrors.ExitCodeError{Code: 1} } ociClient := newOCIClient(cmd.Context(), logger) - // Read the lockfile (if any) so tarball/OCI resolution can pin digests. - // One that cannot be read fails the command as an uncaught error does - // in TS (runAsyncHandler): console.error(err) on stderr, exit code 1. var lockfile *features.Lockfile if cfg.ConfigFilePath != "" { lockfile, _, err = features.ReadLockfile(cfg.ConfigFilePath) @@ -64,11 +56,6 @@ func realFeaturesResolveDepsCmd() *cobra.Command { } basePath := filepath.Dir(cfg.ConfigFilePath) - // Build the dependency graph once (with dependency edges and - // overrideFeatureInstallOrder priorities), then derive both the mermaid - // diagram and the install order from it via a single builder. The - // previous edge-less node list ignored transitive dependencies in - // the install order. processFeature := newMetadataProcessFeature(ociClient, logger, localFeatureRoots{basePath, ws}, lockfile) graph, err := features.BuildDependencyGraph(logger, processFeature, userFeatures, cfg.OverrideFeatureInstallOrder, lockfile) if err != nil { @@ -99,8 +86,6 @@ func realFeaturesResolveDepsCmd() *cobra.Command { entries = append(entries, installEntry{ID: id, Options: opts}) } - // The TS CLI prints the mermaid dependency graph to stdout before the - // install-order JSON. fmt.Fprintln(out.Stdout(), features.GenerateMermaidDiagram(graph)) data, _ := stringify.JSON(map[string]any{"installOrder": entries}, " ") @@ -115,9 +100,6 @@ func realFeaturesResolveDepsCmd() *cobra.Command { return cmd } -// errorInspect is what console.error prints for an error the reference -// throws, frames left out: a lockfile error as Node.js shows the JavaScript -// error, any other with its message. func errorInspect(err error) string { if lerr, ok := errors.AsType[*features.LockfileError](err); ok { return lerr.Inspect() diff --git a/internal/cli/features_resolve_deps_held_test.go b/internal/cli/features_resolve_deps_held_test.go index 56fc496..86537e1 100644 --- a/internal/cli/features_resolve_deps_held_test.go +++ b/internal/cli/features_resolve_deps_held_test.go @@ -16,9 +16,6 @@ import ( "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() @@ -39,7 +36,6 @@ func resolveDepsWorkspace(t *testing.T, featuresJSON string, local map[string]st 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) @@ -49,8 +45,6 @@ func runResolveDeps(t *testing.T, args ...string) (string, error) { 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"}}}`, @@ -136,9 +130,6 @@ func TestFeaturesResolveDependenciesFailures(t *testing.T) { }) } -// A lockfile the reference's readLockfile throws on fails the command as an -// uncaught error does in TS: what console.error prints for it on stderr -// (frames left out), nothing on stdout, exit code 1. func TestFeaturesResolveDependenciesFailsOnAnUnreadableLockfile(t *testing.T) { for name, tc := range map[string]struct { write func(path string) error diff --git a/internal/cli/features_test_runner.go b/internal/cli/features_test_runner.go index 933d092..62c5475 100644 --- a/internal/cli/features_test_runner.go +++ b/internal/cli/features_test_runner.go @@ -15,8 +15,6 @@ import ( "github.com/devcontainers/cli/internal/pfs" ) -// testLibraryScript is the bash test helper library that provides check() and reportResults(). -// Matches TS testLibraryScript from featuresCLI/utils.ts. const testLibraryScript = `#!/bin/bash SCRIPT_FOLDER="$(cd "$(dirname $0)" && pwd)" USERNAME=${1:-root} @@ -62,9 +60,6 @@ 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 @@ -85,8 +80,6 @@ const ( testError testStatus = "error" ) -// runFeaturesTestCommand implements the features test runner. -// It creates temporary projects, spins up containers, and runs test scripts. func runFeaturesTestCommand( out Output, logger log.Logger, @@ -102,14 +95,8 @@ func runFeaturesTestCommand( preserveContainers bool, quiet bool, permitRandomization bool, - // globalArgs carries the invocation's global flags (e.g. --oci-auth-hardening) - // so the `up` the runner spawns for each test project honors them too. globalArgs []string, ) int { - // Print the banner before validating the collection layout so that a - // project folder lacking src/ and test/ still emits the header. This matches - // TS (testCommandImpl.ts), which writes the banner to stdout first and only - // then checks for the required folders. fmt.Fprintf(out.Stdout(), ` ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | Dev Container Features | @@ -122,9 +109,6 @@ func runFeaturesTestCommand( testDir := filepath.Join(collectionFolder, "test") if !pfs.IsDir(srcDir) || !pfs.IsDir(testDir) { - // TS reports this failure on stdout (chalk.red with a '[-]' prefix) and - // exits 1 — not via the logger on stderr. Match the exact wording: single - // quotes around the path and both folder names, and "the required". fmt.Fprintf(out.Stdout(), "[-] Folder '%s' does not contain the required 'src' and 'test' folders.\n", collectionFolder) return 1 } @@ -134,7 +118,6 @@ func runFeaturesTestCommand( if globalOnly { results = runGlobalTests(logger, collectionFolder, filter, baseImage, remoteUser, quiet, globalArgs) } else { - // Discover features to test features := featuresList if len(features) == 0 { entries, err := os.ReadDir(testDir) @@ -161,12 +144,10 @@ func runFeaturesTestCommand( logger.Write(fmt.Sprintf("Features: %s", strings.Join(features, ", ")), log.LevelInfo) logger.Write(fmt.Sprintf("Base image: %s", baseImage), log.LevelInfo) - // Run autogenerated tests (test.sh for each feature) if !skipAuto { results = append(results, runAutoTests(logger, collectionFolder, features, baseImage, remoteUser, quiet, globalArgs)...) } - // Run scenario tests if !skipScenarios { for _, feature := range features { featureTestDir := filepath.Join(testDir, feature) @@ -174,13 +155,11 @@ func runFeaturesTestCommand( } } - // Run global tests (unless features were explicitly specified) if featuresList == nil { results = append(results, runGlobalTests(logger, collectionFolder, filter, baseImage, remoteUser, quiet, globalArgs)...) } } - // Cleanup if !preserveContainers { cleanupTestContainers(logger) } @@ -191,7 +170,6 @@ func runFeaturesTestCommand( func runAutoTests(logger log.Logger, collectionFolder string, features []string, baseImage, remoteUser string, quiet bool, globalArgs []string) []testResult { var results []testResult - // Create a single container with all features installed tmpDir, err := os.MkdirTemp("", "devcontainer-test-") if err != nil { logger.Write(fmt.Sprintf("Failed to create temp dir: %v", err), log.LevelError) @@ -199,14 +177,12 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, } defer os.RemoveAll(tmpDir) - // Generate devcontainer.json with local feature paths dcDir := filepath.Join(tmpDir, ".devcontainer") if err := os.MkdirAll(dcDir, 0o755); err != nil { return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} } for _, f := range features { - // Copy feature source to temp dir srcPath := filepath.Join(collectionFolder, "src", f) destPath := filepath.Join(dcDir, f) if err := copyDir(srcPath, destPath); err != nil { @@ -222,7 +198,6 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} } - // Build and start container logger.Write("Building test container...", log.LevelInfo) containerId, err := startTestContainer(tmpDir, quiet, globalArgs) if err != nil { @@ -230,7 +205,6 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, return []testResult{{Name: "autogenerated setup", Status: testError, Detail: err.Error()}} } - // Run test.sh for each feature for _, feature := range features { testScript := filepath.Join(collectionFolder, "test", feature, "test.sh") if !pfs.IsFile(testScript) { @@ -239,7 +213,6 @@ func runAutoTests(logger log.Logger, collectionFolder string, features []string, continue } - // Copy test files and library into workspace if err := copyDir(filepath.Join(collectionFolder, "test", feature), filepath.Join(tmpDir, feature)); err != nil { results = append(results, testResult{Name: feature, Status: testError, Detail: err.Error()}) continue @@ -297,7 +270,6 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu logger.Write(fmt.Sprintf("Running scenario: %s", scenarioName), log.LevelInfo) - // Create temp project for this scenario tmpDir, err := os.MkdirTemp("", "devcontainer-scenario-") if err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) @@ -310,7 +282,6 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu continue } - // Parse scenario config and update feature paths var config map[string]any if err := json.Unmarshal(scenarioConfig, &config); err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) @@ -318,12 +289,10 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu continue } - // Copy feature sources for any local features 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 srcPath := filepath.Join(collectionFolder, "src", fid) if pfs.IsDir(srcPath) { if err := copyDir(srcPath, filepath.Join(dcDir, fid)); err != nil { @@ -362,7 +331,6 @@ func runScenarioTests(logger log.Logger, collectionFolder, featureTestDir, featu continue } - // Copy test files if err := copyDir(featureTestDir, filepath.Join(tmpDir, filepath.Base(featureTestDir))); err != nil { results = append(results, testResult{Name: scenarioName, Status: testError, Detail: err.Error()}) os.RemoveAll(tmpDir) @@ -398,16 +366,9 @@ 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, - // per-workspace label keeps each autotest/scenario container distinct: `up` - // matches an existing container by --id-label, so without a unique label the - // scenario up would reuse (and fail to start) the autotest's container. args := append(append([]string{}, globalArgs...), []string{ "up", "--workspace-folder", workspaceFolder, "--skip-post-create", "--log-level", "info", "--id-label", "devcontainer.is_test_run=true", @@ -417,7 +378,6 @@ func upTestContainer(workspaceFolder string, quiet bool, globalArgs []string) (s args = append(args, "--log-level", "error") } - // Use ourselves to run the container self, _ := os.Executable() cmd := exec.Command(self, args...) cmd.Stderr = os.Stderr @@ -429,8 +389,6 @@ func upTestContainer(workspaceFolder string, quiet bool, globalArgs []string) (s return containerIDFromUpOutput(out) } -// 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 { @@ -445,9 +403,6 @@ func containerIDFromUpOutput(out []byte) (string, error) { 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 { @@ -483,9 +438,6 @@ func makeWorkspaceAccessible(dir string) { }) } -// execTestInContainer runs a test script in the test container with this -// binary's `exec`, and reports whether it passed. -// // mutate-lane: TestParityMatrix (features.test-single-scenario-success passes // only when the scenario script's success reaches the exit code through it). func execTestInContainer(containerId, testScript, workspaceFolder string) bool { @@ -493,11 +445,7 @@ func execTestInContainer(containerId, testScript, workspaceFolder string) bool { return testScriptCommand(self, containerId, testScript, workspaceFolder).Run() == nil } -// testScriptCommand is the ` exec` that sources the test library and a -// test script in the container, with the script's output on this process's. func testScriptCommand(self, containerId, testScript, workspaceFolder string) *exec.Cmd { - // The workspace is mounted at /workspaces/; the test lib and scripts live - // there, so cd into it (not its parent) before sourcing them. containerWs := "/workspaces/" + filepath.Base(workspaceFolder) cmd := exec.Command(self, "exec", "--container-id", containerId, "--", "bash", "-c", fmt.Sprintf("cd %s && source dev-container-features-test-lib && source %s && reportResults", containerWs, testScript)) @@ -520,8 +468,6 @@ func cleanupTestContainers(logger log.Logger) { func reportTestResults(out Output, results []testResult) int { fmt.Fprintln(out.Stdout()) - // Format matches the TS test report: `${prefix} ${msg}` with a leading-space - // prefix on the header and ✅/❌ prefixes on the result lines. fmt.Fprintln(out.Stdout(), " ================== TEST REPORT ==================") allPassed := true for _, r := range results { diff --git a/internal/cli/features_test_runner_held_test.go b/internal/cli/features_test_runner_held_test.go index ba4579e..57b54e6 100644 --- a/internal/cli/features_test_runner_held_test.go +++ b/internal/cli/features_test_runner_held_test.go @@ -11,9 +11,6 @@ import ( "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 @@ -74,9 +71,6 @@ func TestContainerIDFromUpOutput(t *testing.T) { } } -// 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"} { @@ -129,9 +123,6 @@ func TestRunAutoTestsReportsContainerFailure(t *testing.T) { } } -// 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) diff --git a/internal/cli/features_test_runner_repo_held_test.go b/internal/cli/features_test_runner_repo_held_test.go index c8e87a5..7083a58 100644 --- a/internal/cli/features_test_runner_repo_held_test.go +++ b/internal/cli/features_test_runner_repo_held_test.go @@ -11,9 +11,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// fakeDockerOnPath puts a `docker` first on PATH whose `ps` prints ids and -// whose `rm -f ` appends the id to the returned file, the way the runner's -// cleanup calls the real one. func fakeDockerOnPath(t *testing.T, ids string) (psArgs, removed string) { t.Helper() dir := t.TempDir() @@ -40,7 +37,6 @@ func bufferLogger(buf *bytes.Buffer) log.Logger { return log.New(log.Options{Level: log.LevelInfo, Format: "text", Writer: buf}) } -// Cleanup removes every container the test run labelled, and says how many. func TestCleanupTestContainersRemovesLabelledContainers(t *testing.T) { psArgs, removed := fakeDockerOnPath(t, `c1\n`) var logs bytes.Buffer @@ -56,7 +52,6 @@ func TestCleanupTestContainersRemovesLabelledContainers(t *testing.T) { } } -// With no labelled containers there is nothing to remove or announce. func TestCleanupTestContainersWithNone(t *testing.T) { _, removed := fakeDockerOnPath(t, ``) var logs bytes.Buffer @@ -69,8 +64,6 @@ func TestCleanupTestContainersWithNone(t *testing.T) { } } -// The workspace a test script enters is opened to every user, files and -// folders alike, so a non-root remote user can read it. func TestMakeWorkspaceAccessible(t *testing.T) { dir := t.TempDir() sub := filepath.Join(dir, "feature") @@ -90,8 +83,6 @@ func TestMakeWorkspaceAccessible(t *testing.T) { } } -// A test script runs through ` exec` in the test container, from the -// mounted workspace, with its output on this process's stdout and stderr. func TestTestScriptCommand(t *testing.T) { cmd := testScriptCommand("/bin/devcontainer", "cid", "color/test.sh", "/tmp/devcontainer-test-1") want := []string{"/bin/devcontainer", "exec", "--container-id", "cid", "--", "bash", "-c", @@ -104,8 +95,6 @@ func TestTestScriptCommand(t *testing.T) { } } -// testCollection writes a collection with Features a and b (each with a -// test.sh), a _global scenario g, and a stray file in test/. func testCollection(t *testing.T) string { t.Helper() base := t.TempDir() @@ -128,7 +117,6 @@ func runTestCommand(t *testing.T, collection string, featuresList []string) (cod return code, out.out.String(), buf.String() } -// reportedNames lists the quoted names of the report's result lines, in order. func reportedNames(stdout string) []string { var names []string for _, line := range strings.Split(stdout, "\n") { @@ -140,8 +128,6 @@ func reportedNames(stdout string) []string { return names } -// Without --features every Feature folder under test/ is tested (not _global, -// not a stray file), then the global scenarios; containers are cleaned up. func TestRunFeaturesTestCommandDiscoversFeatures(t *testing.T) { _, removed := fakeDockerOnPath(t, `leftover\n`) fakeTestContainers(t, nil, nil, func(string, string) bool { return true }) @@ -157,7 +143,6 @@ func TestRunFeaturesTestCommandDiscoversFeatures(t *testing.T) { } } -// --features tests exactly the named Features, and skips the global scenarios. func TestRunFeaturesTestCommandNamedFeatures(t *testing.T) { fakeDockerOnPath(t, ``) fakeTestContainers(t, nil, nil, func(string, string) bool { return true }) @@ -170,8 +155,6 @@ func TestRunFeaturesTestCommandNamedFeatures(t *testing.T) { } } -// A collection without both src/ and test/ fails with exit 1 and TS's message -// on stdout, whichever of the two is missing. func TestRunFeaturesTestCommandRequiresSrcAndTest(t *testing.T) { for _, present := range []string{"src", "test"} { base := t.TempDir() @@ -186,7 +169,6 @@ func TestRunFeaturesTestCommandRequiresSrcAndTest(t *testing.T) { } } -// With no Feature folder to test, the command fails with exit 1. func TestRunFeaturesTestCommandWithoutFeatures(t *testing.T) { base := t.TempDir() for _, d := range []string{"src", filepath.Join("test", "_global")} { @@ -201,7 +183,6 @@ func TestRunFeaturesTestCommandWithoutFeatures(t *testing.T) { } } -// A test/ folder that cannot be listed fails the command with exit 1. func TestRunFeaturesTestCommandUnreadableTestFolder(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root lists any folder") diff --git a/internal/cli/flag_inventory_test.go b/internal/cli/flag_inventory_test.go index 3151882..6a8b471 100644 --- a/internal/cli/flag_inventory_test.go +++ b/internal/cli/flag_inventory_test.go @@ -13,42 +13,7 @@ import ( "gopkg.in/yaml.v3" ) -// TestFlagInventoryParity validates docs/parity/cli-flags-inventory.yaml against -// the live Cobra command tree so the inventory cannot silently drift from the code. -// -// The Cobra tree is the source of truth: the test walks NewRootCommand().Commands() -// recursively and, for every command, uses cmd.Flags().VisitAll to collect the -// *structural* shape of each flag — name, shorthand (alias), value type -// (pflag flag.Value.Type()), default value (flag.DefValue) and hidden. It then -// unmarshals the YAML into a mirror struct and diffs BOTH directions (a flag -// missing from the YAML, a flag present in the YAML but absent from the tree, and -// any field that differs). Any drift fails the test. -// -// Type mapping YAML -> pflag value type: -// -// array: true -> stringArray | stringSlice (either is accepted) -// type: choice -> string (choices are asserted to be present in the YAML, -// but the enum *enforcement* is NOT reflected — -// it lives in RunE via validateEnum) -// type: boolean -> bool -// type: number -> int -// type: string -> string -// -// What is intentionally NOT reflected (and therefore excluded from the diff): -// behavioural validations that live in RunE and are not visible on the FlagSet — -// enum enforcement (validateEnum), --terminal-columns/--terminal-rows implications, -// mount / id-label / remote-env format regexes, and required/positional semantics. -// Those belong to the behavioural parity matrix, not to a reflective flag test. -// -// Notes on tree quirks handled here: -// - `exec` sets DisableFlagParsing:true, but its flags are still declared on the -// FlagSet, so VisitAll observes them exactly like any other command. -// - Commands with no flags (the `features` / `templates` group parents) have no -// `flags:` block in the YAML; the walk recurses into their subcommands. -// - "declared but hidden" flags (e.g. the several experimental-* booleans) are -// reconciled via flag.Hidden against the YAML `hidden:` field. func TestFlagInventoryParity(t *testing.T) { - // --- reflect the live Cobra tree ----------------------------------------- actual := map[string]map[string]reflectedFlag{} root := NewRootCommand() var walk func(cmd *cobra.Command, parent string) @@ -73,13 +38,10 @@ func TestFlagInventoryParity(t *testing.T) { walk(sub, path) } } - // The root command carries no user-facing flags; start from its subcommands so - // the YAML keys map cleanly onto command paths ("up", "features test", ...). for _, sub := range root.Commands() { walk(sub, "") } - // --- load the YAML mirror ------------------------------------------------ repoRoot := findRepoRoot(t) yamlPath := filepath.Join(repoRoot, "docs", "parity", "cli-flags-inventory.yaml") data, err := os.ReadFile(yamlPath) @@ -95,11 +57,9 @@ func TestFlagInventoryParity(t *testing.T) { flattenYAMLCommand(name, cmd, expected) } - // --- diff both directions ------------------------------------------------ var problems []string report := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } - // Commands present in the tree but missing from the YAML (and vice-versa). for path := range actual { if _, ok := expected[path]; !ok { report("command %q is in the Cobra tree but missing from the YAML inventory", path) @@ -133,8 +93,6 @@ func TestFlagInventoryParity(t *testing.T) { } } - // Global flags live on the root's persistent flag set and are mirrored by the - // YAML `global_flags` block, diffed in both directions like the per-command ones. actualGlobals := map[string]reflectedFlag{} root.PersistentFlags().VisitAll(func(f *pflag.Flag) { actualGlobals[f.Name] = reflectedFlag{ @@ -164,7 +122,6 @@ func TestFlagInventoryParity(t *testing.T) { } } -// reflectedFlag is the structural shape read off a live *pflag.Flag. type reflectedFlag struct { shorthand string typ string @@ -172,8 +129,6 @@ type reflectedFlag struct { hidden bool } -// yamlFlag mirrors a single flag entry in the inventory. Only the structural -// fields are decoded; description/required/implies/positional/notes are ignored. type yamlFlag struct { Type string `yaml:"type"` Alias string `yaml:"alias"` @@ -193,8 +148,6 @@ type yamlInventory struct { GlobalFlags map[string]yamlFlag `yaml:"global_flags"` } -// flattenYAMLCommand walks the YAML command/subcommand tree into a flat -// path -> flags map keyed the same way as the reflected Cobra tree. func flattenYAMLCommand(path string, cmd yamlCommand, out map[string]map[string]yamlFlag) { if len(cmd.Flags) > 0 { out[path] = cmd.Flags @@ -204,31 +157,24 @@ func flattenYAMLCommand(path string, cmd yamlCommand, out map[string]map[string] } } -// compareFlag diffs the structural fields of one flag and appends any drift. func compareFlag(path, name string, want yamlFlag, got reflectedFlag, report func(string, ...any)) { - // alias / shorthand if want.Alias != got.shorthand { report("%s: flag --%s alias mismatch: yaml=%q tree=%q", path, name, want.Alias, got.shorthand) } - // hidden if want.Hidden != got.hidden { report("%s: flag --%s hidden mismatch: yaml=%v tree=%v", path, name, want.Hidden, got.hidden) } - // value type if !typeMatches(want, got.typ) { report("%s: flag --%s type mismatch: yaml=%q(array=%v) => tree=%q", path, name, want.Type, want.Array, got.typ) } - // choices must be present for choice flags (enum *enforcement* is not reflected). if want.Type == "choice" && len(want.Choices) == 0 { report("%s: flag --%s is type choice but has no `choices` listed in the YAML", path, name) } - // default value if exp := expectedDefault(want); exp != got.defValue { report("%s: flag --%s default mismatch: yaml=%q tree=%q", path, name, exp, got.defValue) } } -// typeMatches maps a YAML flag onto the acceptable pflag value type(s). func typeMatches(f yamlFlag, treeType string) bool { if f.Array { return treeType == "stringArray" || treeType == "stringSlice" @@ -245,7 +191,6 @@ func typeMatches(f yamlFlag, treeType string) bool { } } -// expectedDefault computes the pflag DefValue string implied by a YAML flag. func expectedDefault(f yamlFlag) string { if f.Array { return "[]" // pflag renders a nil string slice/array default as "[]" diff --git a/internal/cli/forward_ports_merge_test.go b/internal/cli/forward_ports_merge_test.go index abfe402..c3698ac 100644 --- a/internal/cli/forward_ports_merge_test.go +++ b/internal/cli/forward_ports_merge_test.go @@ -9,10 +9,6 @@ import ( "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 { diff --git a/internal/cli/gpu.go b/internal/cli/gpu.go index 51218a6..760602c 100644 --- a/internal/cli/gpu.go +++ b/internal/cli/gpu.go @@ -9,12 +9,6 @@ import ( "github.com/devcontainers/cli/internal/docker" ) -// nvidiaContainerCLIInfo probes for a real, usable GPU via `nvidia-container-cli -// info`. It returns found=false when the tool is not installed (so callers fall -// back to the docker-info check). When found, gpuPresent reflects whether the -// tool reported a GPU: it exits non-zero with "nvml error: driver not loaded" -// when the runtime is installed but no GPU is actually present. Overridable in -// tests. var nvidiaContainerCLIInfo = func(ctx context.Context) (found, gpuPresent bool) { path, err := exec.LookPath("nvidia-container-cli") if err != nil { @@ -23,18 +17,13 @@ var nvidiaContainerCLIInfo = func(ctx context.Context) (found, gpuPresent bool) return true, exec.CommandContext(ctx, path, "info").Run() == nil } -// checkGPUAvailability determines if GPU should be enabled based on the flag value. func checkGPUAvailability(ctx context.Context, gpuFlag string, dockerClient *docker.Client) bool { switch gpuFlag { case "all": return true case "none": return false - default: // "detect" - // Match the TS CLI: the nvidia runtime is present only when docker info - // reports "nvidia-container-runtime". When absent, the Go template renders - // "", which is non-empty — so an emptiness check wrongly enabled - // GPUs and made `up` fail on hosts without nvidia. + default: res, err := dockerClient.Run(ctx, "info", "-f", "{{.Runtimes.nvidia}}") runtimePresent := err == nil && strings.Contains(string(res.Stdout), "nvidia-container-runtime") return detectGPU(ctx, runtimePresent) @@ -57,9 +46,6 @@ func detectGPU(ctx context.Context, runtimePresent bool) bool { return true } -// gpuRequested interprets hostRequirements.gpu (bool | "optional" | object) with -// JS truthiness, matching the TS CLI: false/null/absent → no GPU; true, "optional" -// or an object → GPU requested. func gpuRequested(raw json.RawMessage) bool { s := strings.TrimSpace(string(raw)) return s != "" && s != "null" && s != "false" diff --git a/internal/cli/gpu_repo_held_test.go b/internal/cli/gpu_repo_held_test.go index f41731e..835c3c5 100644 --- a/internal/cli/gpu_repo_held_test.go +++ b/internal/cli/gpu_repo_held_test.go @@ -7,9 +7,6 @@ import ( "testing" ) -// In detect mode the GPU is enabled only when `docker info` ran and reported -// the nvidia runtime; a failed docker info or one without the runtime ("") leaves it off. func TestCheckGPUAvailabilityDetectsTheNvidiaRuntime(t *testing.T) { orig := nvidiaContainerCLIInfo t.Cleanup(func() { nvidiaContainerCLIInfo = orig }) diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 2fe0a23..ab8fd19 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -10,11 +10,6 @@ import ( "github.com/devcontainers/cli/internal/product" ) -// mergeAdditionalFeatures parses a JSON(C) string of additional features and -// merges them into cfg.Features. Config features have priority — additional -// features are only added if their key doesn't already exist. -// The returned set holds the keys that originated only from --additional-features -// (not present in config.features). These are excluded from the lockfile. func mergeAdditionalFeatures(cfg *config.DevContainer, additionalFeaturesJSON string) (map[string]bool, error) { if additionalFeaturesJSON == "" { return nil, nil @@ -39,11 +34,8 @@ func mergeAdditionalFeatures(cfg *config.DevContainer, additionalFeaturesJSON st return additionalOnly, nil } -// appPortPublishArgs turns the config `appPort` (number | string | array) into -// `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 { - var v any // an absent appPort (no bytes) fails to decode and publishes nothing + var v any if err := json.Unmarshal(raw, &v); err != nil { return nil } @@ -68,13 +60,10 @@ func appPortPublishArgs(raw json.RawMessage) []string { return args } -// cliVersion returns the CLI version for the log banner (set via ldflags). func cliVersion() string { return product.Get().Version } -// findPlatformArg extracts the target platform from runArgs, supporting both -// "--platform=linux/amd64" and "--platform linux/amd64". Returns "" when absent. func findPlatformArg(runArgs []string) string { for i, a := range runArgs { if v, ok := strings.CutPrefix(a, "--platform="); ok { diff --git a/internal/cli/helpers_held_test.go b/internal/cli/helpers_held_test.go index 82c03e9..634ca37 100644 --- a/internal/cli/helpers_held_test.go +++ b/internal/cli/helpers_held_test.go @@ -7,9 +7,6 @@ import ( "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, `{ diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 725e325..0d4fcb3 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -10,12 +10,12 @@ func TestGPURequested(t *testing.T) { raw string want bool }{ - {``, false}, // absent - {`null`, false}, // null - {`false`, false}, // explicit false must NOT enable GPUs - {`true`, true}, // required - {`"optional"`, true}, // optional → requested (availability decides) - {`{"cores":1}`, true}, // object form + {``, false}, + {`null`, false}, + {`false`, false}, + {`true`, true}, + {`"optional"`, true}, + {`{"cores":1}`, true}, } for _, tt := range tests { got := gpuRequested(json.RawMessage(tt.raw)) diff --git a/internal/cli/image_build_info.go b/internal/cli/image_build_info.go index 6dd7db0..2af1db1 100644 --- a/internal/cli/image_build_info.go +++ b/internal/cli/image_build_info.go @@ -20,40 +20,26 @@ import ( "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 }). + 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 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, @@ -122,10 +108,8 @@ func (i *imageInspector) inspect(ctx context.Context, name string) (*oci.ImageCo 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 { @@ -185,7 +169,6 @@ func getImageBuildInfo(ctx context.Context, cfg *config.DevContainer, insp *imag } } -// 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 { @@ -198,10 +181,6 @@ func imageBuildInfoFromImage(ctx context.Context, insp *imageInspector, name str 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 := docker.NewResolver(df, buildArgs, globalBuildxPlatformArgs(targetPlatform(), targetPlatform())) @@ -230,9 +209,6 @@ func imageBuildInfoFromDockerfile(ctx context.Context, insp *imageInspector, doc return info, nil } -// buildxPlatformArgs are the automatic platform ARGs of a build on this host -// for platform, os/arch[/variant] as --platform takes it, the host's when -// empty (TS devContainers.ts targetPlatformInfo). func buildxPlatformArgs(platform string) map[string]string { target := targetPlatform() if platform != "" { @@ -243,8 +219,6 @@ func buildxPlatformArgs(platform string) map[string]string { return globalBuildxPlatformArgs(targetPlatform(), target) } -// 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 == "" }), "/") diff --git a/internal/cli/image_build_info_held_test.go b/internal/cli/image_build_info_held_test.go index 0bcea1b..7706218 100644 --- a/internal/cli/image_build_info_held_test.go +++ b/internal/cli/image_build_info_held_test.go @@ -17,16 +17,13 @@ import ( "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 + images map[string]string pullable map[string]string pulls []string - containers map[string]string // id -> container inspect JSON - listed [][]string // label filters of each ContainerList + containers map[string]string + listed [][]string list func(labels []string) []string } @@ -78,7 +75,6 @@ func (f *fakeEngineAPI) ContainerList(_ context.Context, opts mobyclient.Contain 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 } @@ -87,7 +83,6 @@ 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) @@ -132,7 +127,6 @@ func TestNewImageInspectorPullsWhatTheRegistryCannotInspect(t *testing.T) { } 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")}} diff --git a/internal/cli/image_build_info_nodocker_held_test.go b/internal/cli/image_build_info_nodocker_held_test.go index aca3ddc..ab748b0 100644 --- a/internal/cli/image_build_info_nodocker_held_test.go +++ b/internal/cli/image_build_info_nodocker_held_test.go @@ -7,8 +7,6 @@ import ( "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{} diff --git a/internal/cli/image_build_info_test.go b/internal/cli/image_build_info_test.go index 1a93cb1..6f19c7a 100644 --- a/internal/cli/image_build_info_test.go +++ b/internal/cli/image_build_info_test.go @@ -16,11 +16,9 @@ import ( "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 + pullFailures int localCalls, registryCalls int pulls int } @@ -98,8 +96,6 @@ func TestImageInspectorFallbackChain(t *testing.T) { }) } -// globalBuildxPlatformArgs feeds docker.Resolver the automatic platform ARGs -// (its resolution is asserted against TS in testdata/ts-oracle/dockerfile.json). func TestGlobalBuildxPlatformArgs(t *testing.T) { got := globalBuildxPlatformArgs(ocispec.Platform{OS: "linux", Architecture: "amd64"}, ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v7"}) want := map[string]string{ @@ -213,9 +209,6 @@ func TestImageBuildInfoImagePullFailurePropagates(t *testing.T) { } } -// The platform ARGs up and build resolve a Dockerfile's FROM with target the -// --platform value (os/arch[/variant]), else this host, as TS -// devContainers.ts targetPlatformInfo; the build platform is always the host. func TestBuildxPlatformArgs(t *testing.T) { host := runtime.GOOS + "/" + runtime.GOARCH if got := buildxPlatformArgs(""); got["TARGETPLATFORM"] != host || got["BUILDPLATFORM"] != host || got["TARGETARCH"] != runtime.GOARCH { diff --git a/internal/cli/lockfile_flags_test.go b/internal/cli/lockfile_flags_test.go index 89f6993..76fb666 100644 --- a/internal/cli/lockfile_flags_test.go +++ b/internal/cli/lockfile_flags_test.go @@ -5,10 +5,6 @@ import ( "testing" ) -// up and build warn on stderr, before anything else, about the deprecated -// lockfile flags, as TS warnDeprecatedLockfileFlags (devContainersSpecCLI.ts) -// does at the start of both handlers; the lockfile is written by default, so -// --experimental-lockfile has nothing left to turn on. func TestDeprecatedLockfileFlagsWarn(t *testing.T) { const ( write = "Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n" @@ -41,7 +37,6 @@ func TestDeprecatedLockfileFlagsWarn(t *testing.T) { for cmd, fn := range run { t.Run(cmd+"/"+tc.name, func(t *testing.T) { out := &captureOutput{} - // An empty workspace fails loading its configuration, before Docker. _ = fn(out) stderr := out.err.String() if !strings.HasPrefix(stderr, tc.want) || strings.Count(stderr, "is deprecated") != strings.Count(tc.want, "is deprecated") { diff --git a/internal/cli/logfile.go b/internal/cli/logfile.go index 5ab8dad..e412a64 100644 --- a/internal/cli/logfile.go +++ b/internal/cli/logfile.go @@ -9,26 +9,11 @@ import ( "github.com/spf13/cobra" ) -// addLogFileFlags adds --log-file and --terminal-log-file to a command, binding -// them to the given string targets. These match the TS CLI flag surface. -// -// --log-file is wired: when set, the command's log stream is teed to the file (see -// logWriter). --terminal-log-file is also teed to its path, but note the CLI keeps -// a single log stream — it has no self-managed PTY/terminal stream distinct from -// the log stream (exec inherits the terminal). So both flags -// capture the same, non-ANSI, log output; there is no separate terminal-formatted -// capture. This is a deliberate, documented divergence from the TS CLI, which -// produces distinct plain and terminal-formatted files. func addLogFileFlags(cmd *cobra.Command, logFile, terminalLogFile *string) { cmd.Flags().StringVar(logFile, "log-file", "", "Log file path. When set, logs are written to this file in addition to stderr.") cmd.Flags().StringVar(terminalLogFile, "terminal-log-file", "", "Terminal log file path. Captures the same log stream as --log-file (the CLI has no separate terminal stream).") } -// logWriter builds the io.Writer for a command's logger, teeing os.Stderr to each -// non-empty file path (--log-file, --terminal-log-file). It returns the writer and -// a cleanup func that closes any opened files; the cleanup is always non-nil and -// safe to defer. If a path cannot be opened, the error is returned and the writer -// falls back to os.Stderr (logs are never silently dropped). func logWriter(paths ...string) (io.Writer, func(), error) { noop := func() {} writers := []io.Writer{os.Stderr} @@ -61,10 +46,6 @@ func logWriter(paths ...string) (io.Writer, func(), error) { return io.MultiWriter(writers...), cleanup, nil } -// plainFileWriter keeps a log file free of the terminal format: on a terminal -// stdout the log is colored and ends its lines in \r\n (TS createTerminalLog), -// which a file reader should not have to filter. The logger writes one event per -// Write, so no escape sequence is split across two calls. type plainFileWriter struct{ w io.Writer } func (p plainFileWriter) Write(b []byte) (int, error) { diff --git a/internal/cli/logfile_test.go b/internal/cli/logfile_test.go index a6d17cf..75b3223 100644 --- a/internal/cli/logfile_test.go +++ b/internal/cli/logfile_test.go @@ -9,12 +9,9 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// TestLogWriterTeesToFile is the hermetic contract test for --log-file: a log -// line emitted through a logger built on the returned writer must land in the -// file (in addition to stderr). func TestLogWriterTeesToFile(t *testing.T) { dir := t.TempDir() - logPath := filepath.Join(dir, "sub", "run.log") // nested dir exercises MkdirAll + logPath := filepath.Join(dir, "sub", "run.log") w, cleanup, err := logWriter(logPath, "") if err != nil { @@ -34,8 +31,6 @@ func TestLogWriterTeesToFile(t *testing.T) { } } -// TestLogWriterTeesToBothFiles confirms both --log-file and --terminal-log-file -// paths receive the same log stream (the CLI has a single log stream). func TestLogWriterTeesToBothFiles(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "log.txt") @@ -60,9 +55,6 @@ func TestLogWriterTeesToBothFiles(t *testing.T) { } } -// A log file holds the log without the terminal format: on a terminal stdout -// the log is colored and ends its lines in \r\n (TS createTerminalLog), and -// the file keeps only the text, as --log-file and --terminal-log-file document. func TestLogWriterKeepsTheTerminalFormatOutOfTheFile(t *testing.T) { logPath := filepath.Join(t.TempDir(), "log.txt") w, cleanup, err := logWriter(logPath, "") @@ -84,7 +76,6 @@ func TestLogWriterKeepsTheTerminalFormatOutOfTheFile(t *testing.T) { } } -// A log file that fails to take a line reports the failure, not a write. func TestPlainFileWriterReportsAFailedWrite(t *testing.T) { f, err := os.Create(filepath.Join(t.TempDir(), "log.txt")) if err != nil { @@ -96,8 +87,6 @@ func TestPlainFileWriterReportsAFailedWrite(t *testing.T) { } } -// TestLogWriterNoFileFallsBackToStderr confirms that with no paths the writer is -// os.Stderr and the cleanup is a harmless no-op. func TestLogWriterNoFileFallsBackToStderr(t *testing.T) { w, cleanup, err := logWriter("", "") if err != nil { @@ -106,14 +95,11 @@ func TestLogWriterNoFileFallsBackToStderr(t *testing.T) { if w != os.Stderr { t.Fatalf("writer = %v, want os.Stderr when no log file requested", w) } - cleanup() // must not panic + cleanup() } -// TestLogWriterOpenError surfaces an error (rather than silently dropping logs) -// when the log file path cannot be opened, and falls back to os.Stderr. func TestLogWriterOpenError(t *testing.T) { dir := t.TempDir() - // A path whose parent is a regular file cannot be created as a directory. blocker := filepath.Join(dir, "notadir") if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { t.Fatal(err) @@ -127,5 +113,5 @@ func TestLogWriterOpenError(t *testing.T) { if w != os.Stderr { t.Fatalf("writer = %v, want os.Stderr fallback on error", w) } - cleanup() // must not panic + cleanup() } diff --git a/internal/cli/messages.go b/internal/cli/messages.go index 539f74b..62dfe7b 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -1,15 +1,7 @@ package cli -// User-facing messages that are matched verbatim against the reference -// TypeScript CLI by the parity matrix. They are intentionally capitalized and -// punctuated — contrary to Go's error-string convention — because their exact -// text is a compatibility contract, not a Go-internal detail. The ones that -// were previously written out at more than one call site live here so the -// copies cannot drift apart. Do not reword without updating the parity matrix. const ( - // msgLegacyFeature is returned when a bare-id (legacy) Feature is requested. msgLegacyFeature = "Legacy feature '%s' not supported. Please check https://containers.dev/features for replacements.\nIf you were hoping to use local Features, remember to prepend your Feature name with \"./\". Please check https://containers.dev/implementors/features-distribution/#addendum-locally-referenced for more information." - // msgDockerBuildFailed is returned when `docker build` exits non-zero. msgDockerBuildFailed = "Command failed: docker build (exit %d): %s" ) diff --git a/internal/cli/metadata_interop_test.go b/internal/cli/metadata_interop_test.go index 852ff37..e90d88c 100644 --- a/internal/cli/metadata_interop_test.go +++ b/internal/cli/metadata_interop_test.go @@ -13,27 +13,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// Bidirectional metadata interop. -// -// Go always emits the devcontainer.metadata label as a JSON *array* even for a -// single entry (imagemeta.GenerateMetadataLabel). TS and Go serialize with -// different whitespace and key ordering, but the PARSED JSON is identical. -// Therefore every assertion here compares NORMALIZED (parsed) JSON, never the -// raw label bytes. -// -// Live/hermetic half: build a real image whose devcontainer.metadata label was -// produced by the Go label generator (imagemeta.GenerateExtendImageBuild — the -// exact path build.go uses), read it back through docker inspect + -// imagemeta.ReadMetadataFromLabels + MergeConfiguration, and assert the merge. -// -// TS->Go half: guarded to t.Skip unless the compiled reference oracle is -// present. - -// interopEntries returns metadata entries exercising every merge dimension the -// interop test cares about: mounts, forwardPorts, users, lifecycle hooks, and -// customizations. It mirrors how build.go assembles the []Entry slice: a -// base/feature-contributed entry followed by the config entry (last = highest -// priority for scalars). func interopEntries() []imagemeta.Entry { yes := true return []imagemeta.Entry{ @@ -52,7 +31,6 @@ func interopEntries() []imagemeta.Entry { }, }, { - // Config-level entry (no id), highest priority for scalars. Mounts: []any{"source=cfg,target=/cfg,type=volume"}, ForwardPorts: []any{float64(8080)}, RemoteUser: "node", @@ -69,10 +47,6 @@ func interopEntries() []imagemeta.Entry { } } -// jsonNormEqual reports whether two JSON documents are equal after parsing, -// ignoring whitespace and object key ordering. This is the comparator the -// interop parity claim requires: TS and Go differ in raw bytes but not in -// parsed structure. func jsonNormEqual(t *testing.T, a, b []byte) bool { t.Helper() var av, bv any @@ -85,11 +59,6 @@ func jsonNormEqual(t *testing.T, a, b []byte) bool { return reflect.DeepEqual(av, bv) } -// TestMetadataInteropWhitespaceInvariant is a pure, always-run test that encodes -// the core parity claim hermetically: the SAME logical metadata serialized with -// Go's compact array format vs a TS-style layout (spaces after ':'/',' and a -// different key order) parses to the same normalized JSON AND produces an -// identical MergeConfiguration result. No docker, no network. func TestMetadataInteropWhitespaceInvariant(t *testing.T) { entries := interopEntries() @@ -98,10 +67,6 @@ func TestMetadataInteropWhitespaceInvariant(t *testing.T) { t.Fatalf("Go must emit a JSON array label even for multiple entries, got: %s", goLabel) } - // Simulate a TS-serialized label: same values, but re-encoded with indentation - // (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 any if err := json.Unmarshal([]byte(goLabel), &parsed); err != nil { t.Fatalf("parse go label: %v", err) @@ -117,14 +82,12 @@ func TestMetadataInteropWhitespaceInvariant(t *testing.T) { t.Fatalf("normalized JSON differs:\n--- go\n%s\n--- ts-style\n%s", goLabel, tsStyle) } - // Both label forms must read back to the same merged configuration. goMerged := mergeFromLabel(t, goLabel) tsMerged := mergeFromLabel(t, string(tsStyle)) if !jsonNormEqual(t, goMerged, tsMerged) { t.Fatalf("merged config differs across label whitespace:\n--- go\n%s\n--- ts\n%s", goMerged, tsMerged) } - // Spot-check the merge semantics so a silently-empty merge can't pass. var mc imagemeta.MergedConfig if err := json.Unmarshal(goMerged, &mc); err != nil { t.Fatal(err) @@ -141,21 +104,17 @@ func TestMetadataInteropWhitespaceInvariant(t *testing.T) { if mc.Init == nil || !*mc.Init { t.Errorf("init must be OR'd true from the feature entry") } - // Lifecycle hooks accumulate across entries. if len(mc.PostCreateCommands) != 2 { t.Errorf("expected 2 postCreate commands, got %d", len(mc.PostCreateCommands)) } if len(mc.OnCreateCommands) != 1 { t.Errorf("expected 1 onCreate command, got %d", len(mc.OnCreateCommands)) } - // customizations grouped by key, one array element per contributing entry. if got := len(mc.Customizations["vscode"]); got != 2 { t.Errorf("expected 2 vscode customization contributions, got %d", got) } } -// mergeFromLabel parses a devcontainer.metadata label value the way the runtime -// does and returns the merged configuration as JSON bytes. func mergeFromLabel(t *testing.T, label string) []byte { t.Helper() entries := imagemeta.ReadMetadataFromLabels( @@ -171,14 +130,6 @@ func mergeFromLabel(t *testing.T, label string) []byte { return b } -// TestMetadataInteropGoRoundTrip is the live/hermetic half: it builds a real -// image (FROM scratch) whose devcontainer.metadata label was written by the Go -// label generator through the exact Dockerfile path build.go uses -// (imagemeta.GenerateExtendImageBuild). It then reads the label back via -// `docker inspect` + ReadMetadataFromLabels + MergeConfiguration and asserts the -// merge matches the in-memory expectation as normalized JSON. This validates -// that LABEL escaping survives a real docker round-trip. No registry/network: -// the base image is `scratch`. func TestMetadataInteropGoRoundTrip(t *testing.T) { if !isDockerAvailable() { t.Skip("docker required") @@ -186,11 +137,8 @@ func TestMetadataInteropGoRoundTrip(t *testing.T) { ctx := t.Context() entries := interopEntries() - // Expected merge computed purely in-memory from the same entries. expected := mergeFromLabel(t, imagemeta.GenerateMetadataLabel(entries)) - // 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 @@ -226,11 +174,6 @@ func TestMetadataInteropGoRoundTrip(t *testing.T) { } } -// 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", "devcontainer.js") diff --git a/internal/cli/metadata_label.go b/internal/cli/metadata_label.go index b4bff2d..343789a 100644 --- a/internal/cli/metadata_label.go +++ b/internal/cli/metadata_label.go @@ -10,8 +10,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// labelConfigProperties is TS pickConfigProperties: what of devcontainer.json -// the devcontainer.metadata label records, in that order. var labelConfigProperties = []string{ "onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand", "postAttachCommand", "waitFor", "customizations", "mounts", "containerEnv", "containerUser", "init", "privileged", "capAdd", @@ -19,11 +17,6 @@ var labelConfigProperties = []string{ "otherPortsAttributes", "forwardPorts", "shutdownAction", "updateRemoteUserUID", "hostRequirements", } -// configLabelEntry is the configuration's entry in the devcontainer.metadata -// label: TS pick(config.raw, pickConfigProperties), the properties as written -// with their variables unresolved (raw is config.LoadResult.RawConfig). The -// merged configuration reads the substituted entry instead (configMetadataEntry). -// omitRemoteEnv is --omit-config-remote-env-from-metadata. func configLabelEntry(raw map[string]any, omitRemoteEnv bool) (imagemeta.Entry, error) { picked := map[string]any{} for _, k := range labelConfigProperties { @@ -42,9 +35,6 @@ func configLabelEntry(raw map[string]any, omitRemoteEnv bool) (imagemeta.Entry, return entry, nil } -// containerMetadataLabel is the devcontainer.metadata label TS adds to a -// container created from an image with no Features: JSON.stringify of the -// entries, "[]" without any. func containerMetadataLabel(entries []imagemeta.Entry) string { if label := imagemeta.GenerateMetadataLabel(entries); label != "" { return label @@ -52,8 +42,6 @@ func containerMetadataLabel(entries []imagemeta.Entry) string { return "[]" } -// imageLabelArgs are the --label values that put the metadata label on a built -// image: none without entries, where TS's Dockerfile has no LABEL line. func imageLabelArgs(entries []imagemeta.Entry) []string { if label := imagemeta.GenerateMetadataLabel(entries); label != "" { return []string{imagemeta.MetadataLabel + "=" + label} @@ -61,13 +49,8 @@ func imageLabelArgs(entries []imagemeta.Entry) []string { return nil } -// metadataSubstitution resolves the variables of metadata entries read from a -// devcontainer.metadata label, which records them as written (TS -// SubstitutedConfig.substitute applied to the label's raw entries). type metadataSubstitution func(entries *[]imagemeta.Entry) error -// hostMetadataSubstitution substitutes the host variables (${localEnv:…}, -// ${localWorkspaceFolder}, …) of hostSub and, with idLabels, ${devcontainerId}. func hostMetadataSubstitution(hostSub config.HostSubContext, idLabels []string) metadataSubstitution { return func(entries *[]imagemeta.Entry) error { resolver := config.NewVariableResolver() @@ -80,15 +63,6 @@ func hostMetadataSubstitution(hostSub config.HostSubContext, idLabels []string) } } -// imageMetadataFromContainer is TS getImageMetadataFromContainer(...).config: -// the entries of the container's devcontainer.metadata label (its own, or its -// image's, which the container inherits) with their variables substituted. -// For a container that carries every id label, which the CLI created with the -// configuration's entry in its metadata, the configuration's updateable -// properties (TS pickUpdateableConfigProperties: remoteUser, userEnvProbe, -// remoteEnv) follow; for any other, the features entries and the -// configuration's entry. A container without the -// label has those alone. cfg nil adds nothing of a configuration. func imageMetadataFromContainer(labels map[string]string, cfg *config.DevContainer, features []imagemeta.Entry, idLabels []string, substitute metadataSubstitution, logger log.Logger) ([]imagemeta.Entry, error) { var entries []imagemeta.Entry hasLabel := labels[imagemeta.MetadataLabel] != "" @@ -109,12 +83,6 @@ func imageMetadataFromContainer(labels map[string]string, cfg *config.DevContain return append(entries, configMetadataEntry(cfg, false)), nil } -// containerIDLabels is TS findContainerAndIdLabels' idLabels for a container -// found without the CLI's own lookup: the --id-label values, else the -// workspace folder's and the config file's labels, the config file's only when -// the container has one (an older container has the workspace folder's alone). -// A missing workspace folder or config file is written as TS's template -// literal writes undefined. func containerIDLabels(provided []string, containerLabels map[string]string, workspaceFolder, configFile string) []string { if len(provided) > 0 { return provided @@ -126,8 +94,6 @@ func containerIDLabels(provided []string, containerLabels map[string]string, wor return labels } -// hasEveryLabel reports whether labels has every "name=value" of idLabels (TS -// hasIdLabels: every name is set); false for nil id labels (none known). func hasEveryLabel(labels map[string]string, idLabels []string) bool { if idLabels == nil { return false diff --git a/internal/cli/metadata_label_test.go b/internal/cli/metadata_label_test.go index daa2070..dd69fca 100644 --- a/internal/cli/metadata_label_test.go +++ b/internal/cli/metadata_label_test.go @@ -10,10 +10,6 @@ import ( "github.com/devcontainers/cli/internal/imagemeta" ) -// The configuration's label entry is TS pick(config.raw, pickConfigProperties): -// variables stay unresolved, properties the label does not record (runArgs, -// image, features) are left out, and --omit-config-remote-env-from-metadata drops -// remoteEnv. func TestConfigLabelEntryPicksTheRawConfig(t *testing.T) { var raw map[string]any if err := json.Unmarshal([]byte(`{ @@ -49,8 +45,6 @@ func TestConfigLabelEntryPicksTheRawConfig(t *testing.T) { } } -// A container gets "[]" without metadata (TS JSON.stringify of no entries); an -// image build gets no label at all, as TS's Dockerfile has no LABEL line. func TestMetadataLabelsWithoutEntries(t *testing.T) { if got := containerMetadataLabel(nil); got != "[]" { t.Errorf("container label = %q, want []", got) @@ -66,11 +60,6 @@ func TestMetadataLabelsWithoutEntries(t *testing.T) { } } -// TS getImageMetadataFromContainer: the container's label entries, substituted; -// for a container with every id label, which records the configuration's entry, -// only the configuration's updateable properties follow, so its hooks do not -// run twice; for any other the Features' entries and the configuration's; no -// label gives the configuration's entry alone. func TestImageMetadataFromContainer(t *testing.T) { cfg := &config.DevContainer{RemoteUser: "me", UserEnvProbe: "none", RemoteEnv: map[string]*string{"A": strPtr("b")}} cfg.PostCreateCommand = config.LifecycleCommand{} @@ -119,9 +108,6 @@ func TestImageMetadataFromContainer(t *testing.T) { } } -// TS findContainerAndIdLabels: the --id-label values, else the workspace -// folder's and config file's labels, the config file's only for a container -// that has one. func TestContainerIDLabels(t *testing.T) { if got := containerIDLabels([]string{"a=b"}, nil, "/ws", "/ws/c.json"); !reflect.DeepEqual(got, []string{"a=b"}) { t.Errorf("provided = %v", got) @@ -133,15 +119,11 @@ func TestContainerIDLabels(t *testing.T) { if got := containerIDLabels(nil, map[string]string{}, "/ws", "/ws/c.json"); !reflect.DeepEqual(got, []string{"devcontainer.local_folder=/ws"}) { t.Errorf("older container = %v", got) } - // Without a workspace folder or a config file, TS's template literal - // writes undefined (--container-id alone). if got := containerIDLabels(nil, withConfig, "", ""); !reflect.DeepEqual(got, []string{"devcontainer.local_folder=undefined", "devcontainer.config_file=undefined"}) { t.Errorf("no workspace folder or config file = %v", got) } } -// The label's entries get the host substitutions, and ${devcontainerId} only -// with id labels to compute it from. func TestHostMetadataSubstitution(t *testing.T) { hostSub := config.HostSubContext{Env: map[string]string{"U": "me"}} entries := []imagemeta.Entry{{RemoteUser: "${localEnv:U}", ContainerEnv: map[string]string{"ID": "${devcontainerId}"}}} diff --git a/internal/cli/mounts.go b/internal/cli/mounts.go index 25e6257..e040887 100644 --- a/internal/cli/mounts.go +++ b/internal/cli/mounts.go @@ -83,9 +83,6 @@ func mountFromMetadata(entry any, devcontainerID string) (dockermount.Mount, err return docker.ParseMountSpec(spec) 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 - // (e.g. target "/cache/${devcontainerId}"). target, err := resolve(target) if err != nil { return dockermount.Mount{}, err @@ -118,8 +115,6 @@ func mountFromMetadata(entry any, devcontainerID string) (dockermount.Mount, err } } -// folderImageName generates a deterministic image name from a workspace folder path. -// Matches the TS CLI's getFolderImageName: "vsc-{basename}-{sha256hex}" func folderImageName(folderPath string) string { basename := filepath.Base(folderPath) hash := sha256.Sum256([]byte(folderPath)) diff --git a/internal/cli/mounts_held_test.go b/internal/cli/mounts_held_test.go index 0d005e2..eceedc5 100644 --- a/internal/cli/mounts_held_test.go +++ b/internal/cli/mounts_held_test.go @@ -7,8 +7,6 @@ import ( "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 { @@ -19,7 +17,6 @@ func TestMountFromMetadataStringSpec(t *testing.T) { } } -// 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 { @@ -37,8 +34,6 @@ func TestMountFromMetadataObjectType(t *testing.T) { } } -// 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 { diff --git a/internal/cli/mounts_nodocker_held_test.go b/internal/cli/mounts_nodocker_held_test.go index 7e3dc38..2602b9f 100644 --- a/internal/cli/mounts_nodocker_held_test.go +++ b/internal/cli/mounts_nodocker_held_test.go @@ -4,8 +4,6 @@ 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", diff --git a/internal/cli/ociauth.go b/internal/cli/ociauth.go index e7c21be..4d44288 100644 --- a/internal/cli/ociauth.go +++ b/internal/cli/ociauth.go @@ -9,27 +9,19 @@ import ( "github.com/spf13/cobra" ) -// Global OCI authentication flags, declared on the root command so every -// subcommand accepts them (yargs `.option(..., { global: true })`). const ( flagOCIAuthHardening = "oci-auth-hardening" flagAllowCrossOriginAuthHos = "allow-cross-origin-auth-host" ) -// ociAuthContextKey keys the per-invocation OCI auth policy in the command context. type ociAuthContextKey struct{} -// addOCIAuthFlags declares the global OCI auth flags on the root command. func addOCIAuthFlags(root *cobra.Command) { f := root.PersistentFlags() f.Bool(flagOCIAuthHardening, false, "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects.") f.StringArray(flagAllowCrossOriginAuthHos, nil, "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.") } -// buildOCIAuthPolicy validates the global OCI auth flags and builds the policy -// for this invocation. It mirrors the reference CLI's yargs `.check()`: -// --allow-cross-origin-auth-host requires --oci-auth-hardening, and every entry -// must be a '=' pair of bare authorities. func buildOCIAuthPolicy(cmd *cobra.Command, logger log.Logger) (*oci.AuthPolicy, error) { hardening, _ := cmd.Flags().GetBool(flagOCIAuthHardening) hosts, _ := cmd.Flags().GetStringArray(flagAllowCrossOriginAuthHos) @@ -39,8 +31,6 @@ func buildOCIAuthPolicy(cmd *cobra.Command, logger log.Logger) (*oci.AuthPolicy, return oci.NewAuthPolicy(hardening, hosts, logger) } -// applyOCIAuthPolicy validates the global flags and stores the resulting policy on -// the command's context, where the command's OCI clients pick it up. func applyOCIAuthPolicy(cmd *cobra.Command) error { policy, err := buildOCIAuthPolicy(cmd, log.Null) if err != nil { @@ -57,8 +47,6 @@ func withOCIAuthPolicy(ctx context.Context, policy *oci.AuthPolicy) context.Cont return context.WithValue(ctx, ociAuthContextKey{}, policy) } -// ociAuthPolicy returns the policy stored on ctx, or a default (hardening off) -// policy when a caller runs outside the command tree, e.g. a unit test. func ociAuthPolicy(ctx context.Context) *oci.AuthPolicy { if ctx != nil { if policy, ok := ctx.Value(ociAuthContextKey{}).(*oci.AuthPolicy); ok && policy != nil { @@ -68,22 +56,14 @@ func ociAuthPolicy(ctx context.Context) *oci.AuthPolicy { return oci.DefaultAuthPolicy() } -// newOCIClient builds an OCI client bound to this invocation's auth policy, so -// every registry request of the command honors the hardening flags and feeds the -// same diagnostics. func newOCIClient(ctx context.Context, logger log.Logger) *oci.Client { return oci.NewClientWithAuthPolicy(logger, osEnvMap(), ociAuthPolicy(ctx)) } -// ociAuthDiagnostics returns the diagnostics recorded for this invocation, for -// the `ociAuthDiagnostics` field of the command's JSON output. func ociAuthDiagnostics(ctx context.Context) oci.AuthDiagnostics { return ociAuthPolicy(ctx).Diagnostics() } -// ociAuthGlobalArgs re-renders the global OCI auth flags as CLI arguments, so a -// command that re-invokes this binary (e.g. `features test` spawning `up` for each -// test project) passes the same policy on to the child process. func ociAuthGlobalArgs(cmd *cobra.Command) []string { var args []string if hardening, _ := cmd.Flags().GetBool(flagOCIAuthHardening); hardening { diff --git a/internal/cli/ociauth_held_test.go b/internal/cli/ociauth_held_test.go index ade3562..25196fc 100644 --- a/internal/cli/ociauth_held_test.go +++ b/internal/cli/ociauth_held_test.go @@ -8,9 +8,6 @@ import ( "github.com/devcontainers/cli/internal/oci" ) -// Storing the policy keeps the command context it is stored on: a cancelled -// command (Ctrl-C) must still cancel the registry traffic that reads the -// policy from the same context. A caller without a context gets one. func TestWithOCIAuthPolicyKeepsTheContext(t *testing.T) { policy, err := oci.NewAuthPolicy(true, nil, log.Null) if err != nil { @@ -31,8 +28,6 @@ func TestWithOCIAuthPolicyKeepsTheContext(t *testing.T) { } } -// A context holding a nil policy reads as the default policy (hardening off, -// as the reference CLI defaults --oci-auth-hardening), never as nil. func TestOCIAuthPolicyIgnoresAStoredNil(t *testing.T) { policy := ociAuthPolicy(withOCIAuthPolicy(context.Background(), nil)) if policy == nil { diff --git a/internal/cli/ociauth_test.go b/internal/cli/ociauth_test.go index f8b49b1..2272338 100644 --- a/internal/cli/ociauth_test.go +++ b/internal/cli/ociauth_test.go @@ -12,17 +12,12 @@ import ( "github.com/spf13/cobra" ) -// TestOCIAuthGlobalFlagValidation pins the yargs `.check()` behavior the reference -// CLI applies to the global OCI auth flags: --allow-cross-origin-auth-host needs -// --oci-auth-hardening, entries must be '=' pairs of -// bare authorities, and the error message is the one users see (exit code 1). func TestOCIAuthGlobalFlagValidation(t *testing.T) { - // read-configuration looks for the container; this engine has none. startFakeEngine(t, nil) tests := []struct { name string args []string - want string // "" means the flags are accepted + want string }{ { name: "allow list requires hardening", @@ -93,9 +88,6 @@ func TestOCIAuthGlobalFlagValidation(t *testing.T) { } } -// TestExecAcceptsGlobalOCIAuthFlags guards the `exec` path: it parses its own -// flags (DisableFlagParsing), so it must both accept the global flags and apply -// the same validation, without swallowing the command that follows them. func TestExecAcceptsGlobalOCIAuthFlags(t *testing.T) { flags, cmd := splitExecArgs([]string{ "--oci-auth-hardening", @@ -111,7 +103,6 @@ func TestExecAcceptsGlobalOCIAuthFlags(t *testing.T) { t.Errorf("command = %v, want [echo hello]", cmd) } - // The check runs for exec too, even though the root hook is skipped there. root := NewRootCommand() root.SetArgs([]string{"exec", "--allow-cross-origin-auth-host", "registry.example=auth.example", "echo", "hi"}) var out bytes.Buffer @@ -123,11 +114,7 @@ func TestExecAcceptsGlobalOCIAuthFlags(t *testing.T) { } } -// TestReadConfigurationEmitsOCIAuthDiagnostics pins the new output field: the -// reference CLI emits ociAuthDiagnostics on every read-configuration, even when -// no registry was contacted. func TestReadConfigurationEmitsOCIAuthDiagnostics(t *testing.T) { - // read-configuration looks for the container; this engine has none. startFakeEngine(t, nil) ws := t.TempDir() if err := os.MkdirAll(filepath.Join(ws, ".devcontainer"), 0o755); err != nil { @@ -160,8 +147,6 @@ func TestReadConfigurationEmitsOCIAuthDiagnostics(t *testing.T) { } } -// TestOCIAuthPolicyFlowsFromCommandContext guards the plumbing: the policy built -// from the global flags must reach the OCI clients a command creates. func TestOCIAuthPolicyFlowsFromCommandContext(t *testing.T) { probe := &cobra.Command{ Use: "probe", @@ -181,7 +166,6 @@ func TestOCIAuthPolicyFlowsFromCommandContext(t *testing.T) { t.Fatalf("probe: %v", err) } - // Without the flag the default policy applies (hardening off). plain := NewRootCommand() plain.AddCommand(&cobra.Command{ Use: "probe", @@ -200,9 +184,6 @@ func TestOCIAuthPolicyFlowsFromCommandContext(t *testing.T) { } } -// TestOCIAuthGlobalArgsForwarding pins the re-invocation path: `features test` -// spawns this binary for each test project's `up`, so the global auth flags must -// be rendered back into arguments (upstream passes them in-process instead). func TestOCIAuthGlobalArgsForwarding(t *testing.T) { tests := []struct { name string diff --git a/internal/cli/open.go b/internal/cli/open.go index efa33d8..692757b 100644 --- a/internal/cli/open.go +++ b/internal/cli/open.go @@ -20,9 +20,6 @@ type openOpts struct { dryRun bool } -// fileURIJSON is VS Code's serialized `file:` URI for the dev container config, -// as embedded in the dev-container remote authority. On Linux there is no -// authority component (it only appears for WSL/UNC paths on Windows). type fileURIJSON struct { Scheme string `json:"scheme"` Authority string `json:"authority,omitempty"` @@ -81,8 +78,6 @@ func runOpen(ctx context.Context, out Output, opts openOpts) error { configPath = resolvePath(opts.configPath) } - // Load the config to discover the resolved devcontainer.json path and the - // workspace folder *inside* the container (the URI's trailing path). loadResult, err := config.LoadDevContainerConfig(workspaceFolder, configPath, "") if err != nil { return err @@ -123,17 +118,12 @@ func runOpen(ctx context.Context, out Output, opts openOpts) error { return nil } -// devcontainerFolderURI builds the vscode-remote:// folder URI that opens -// containerFolder inside the dev container for the given local workspace and -// config. The remote authority is `dev-container+` where is the -// hex-encoded JSON {hostPath, configFile:{scheme,path}}. func devcontainerFolderURI(localWorkspaceFolder, localConfigPath, containerFolder string) (string, error) { payload := devcontainerURIJSON{ HostPath: localWorkspaceFolder, ConfigFile: fileURIJSON{ Scheme: "file", - // Linux fs paths already use forward slashes; use the absolute path as-is. - Path: filepath.ToSlash(localConfigPath), + Path: filepath.ToSlash(localConfigPath), }, } data, err := json.Marshal(payload) diff --git a/internal/cli/open_repo_held_test.go b/internal/cli/open_repo_held_test.go index bc9ca20..a76bf49 100644 --- a/internal/cli/open_repo_held_test.go +++ b/internal/cli/open_repo_held_test.go @@ -9,8 +9,6 @@ import ( "testing" ) -// openWorkspace writes a workspace whose devcontainer.json names an image, and -// returns its folder and config path. func openWorkspace(t *testing.T) (string, string) { t.Helper() ws := t.TempDir() @@ -25,7 +23,6 @@ func openWorkspace(t *testing.T) (string, string) { return ws, cfg } -// wantOpenURI is the folder URI open builds for ws and its config. func wantOpenURI(t *testing.T, ws, cfg string) string { t.Helper() uri, err := devcontainerFolderURI(ws, cfg, "/workspaces/"+filepath.Base(ws)) @@ -47,8 +44,6 @@ func runOpenCmd(t *testing.T, args ...string) (string, string, error) { return out.String(), errOut.String(), err } -// open [path] --dry-run prints the folder URI of the workspace the argument -// names, and on stderr the editor command that would open it. func TestOpenDryRunOpensThePositionalWorkspace(t *testing.T) { ws, cfg := openWorkspace(t) t.Chdir(t.TempDir()) @@ -65,7 +60,6 @@ func TestOpenDryRunOpensThePositionalWorkspace(t *testing.T) { } } -// --workspace-folder wins over the positional path. func TestOpenWorkspaceFlagWinsOverThePositionalPath(t *testing.T) { ws, cfg := openWorkspace(t) stdout, _, err := runOpenCmd(t, t.TempDir(), "--workspace-folder", ws, "--dry-run") @@ -77,8 +71,6 @@ func TestOpenWorkspaceFlagWinsOverThePositionalPath(t *testing.T) { } } -// Without a path open opens the current directory, through the editor -// --editor names. func TestOpenDefaultsToTheCurrentDirectory(t *testing.T) { ws, cfg := openWorkspace(t) t.Chdir(ws) @@ -95,7 +87,6 @@ func TestOpenDefaultsToTheCurrentDirectory(t *testing.T) { } } -// An empty editor falls back to code. func TestOpenEmptyEditorIsCode(t *testing.T) { ws, cfg := openWorkspace(t) var out captureOutput @@ -107,7 +98,6 @@ func TestOpenEmptyEditorIsCode(t *testing.T) { } } -// --config names the devcontainer.json the URI carries. func TestOpenConfigFlagNamesTheConfigFile(t *testing.T) { ws, _ := openWorkspace(t) other := filepath.Join(ws, "other.json") @@ -123,7 +113,6 @@ func TestOpenConfigFlagNamesTheConfigFile(t *testing.T) { } } -// A workspace without a devcontainer.json fails before any editor runs. func TestOpenWithoutAConfigFails(t *testing.T) { stdout, _, err := runOpenCmd(t, t.TempDir(), "--dry-run") if err == nil { @@ -134,8 +123,6 @@ func TestOpenWithoutAConfigFails(t *testing.T) { } } -// writeEditor writes an editor launcher that prints its arguments on stdout, a -// line on stderr, and exits with code. func writeEditor(t *testing.T, code int) string { t.Helper() path := filepath.Join(t.TempDir(), "editor") @@ -146,8 +133,6 @@ func writeEditor(t *testing.T, code int) string { return path } -// Without --dry-run open runs the editor with the folder URI, passing its -// output through. func TestOpenRunsTheEditor(t *testing.T) { ws, cfg := openWorkspace(t) editor := writeEditor(t, 0) @@ -163,7 +148,6 @@ func TestOpenRunsTheEditor(t *testing.T) { } } -// An editor that fails fails open. func TestOpenFailsWhenTheEditorFails(t *testing.T) { ws, _ := openWorkspace(t) editor := writeEditor(t, 3) diff --git a/internal/cli/open_test.go b/internal/cli/open_test.go index 5a27e77..f8b9684 100644 --- a/internal/cli/open_test.go +++ b/internal/cli/open_test.go @@ -7,10 +7,6 @@ import ( "testing" ) -// TestDevcontainerFolderURI locks the vscode-remote dev-container URI format: -// vscode-remote://dev-container+, where is the -// hex-encoded JSON {hostPath, configFile:{scheme,path}}. The shape is verified -// against the working vscli launcher; a drift here silently breaks `open`. func TestDevcontainerFolderURI(t *testing.T) { const ( localWS = "/home/me/project" @@ -29,7 +25,6 @@ func TestDevcontainerFolderURI(t *testing.T) { } rest := strings.TrimPrefix(uri, prefix) - // The URI ends with the container workspace folder, verbatim. if !strings.HasSuffix(rest, container) { t.Fatalf("uri does not end with container folder %q: %q", container, uri) } @@ -53,13 +48,10 @@ func TestDevcontainerFolderURI(t *testing.T) { if got.ConfigFile.Path != localConfig { t.Errorf("configFile.path = %q, want %q", got.ConfigFile.Path, localConfig) } - // On Linux there is no authority component. if got.ConfigFile.Authority != "" { t.Errorf("configFile.authority = %q, want empty on Linux", got.ConfigFile.Authority) } - // The exact hex is stable for a fixed input (guards accidental field reorder - // or key renames that would change what VS Code receives). wantJSON := `{"hostPath":"/home/me/project","configFile":{"scheme":"file","path":"/home/me/project/.devcontainer/devcontainer.json"}}` if string(raw) != wantJSON { t.Errorf("payload JSON =\n %s\nwant\n %s", raw, wantJSON) diff --git a/internal/cli/oracle_flag_coverage_test.go b/internal/cli/oracle_flag_coverage_test.go index 7ece658..1edabdf 100644 --- a/internal/cli/oracle_flag_coverage_test.go +++ b/internal/cli/oracle_flag_coverage_test.go @@ -10,15 +10,6 @@ import ( "gopkg.in/yaml.v3" ) -// TestOracleFlagCoverage guards the flag inventory against the ACTUAL TypeScript -// oracle, not a hand-maintained copy: for every top-level command the oracle -// defines, each of its flags must appear in docs/parity/cli-flags-inventory.yaml -// (which TestFlagInventoryParity in turn pins to the live Cobra tree). Together -// the two tests make oracle → inventory → Cobra tree a closed loop, so a flag the -// upstream CLI adds cannot silently go missing from `up`/`build`/… again. -// -// The oracle lives in the reference/ submodule, present only in the parity CI -// lane; the hermetic unit lane skips this test. func TestOracleFlagCoverage(t *testing.T) { const oraclePath = "../../reference/src/spec-node/devContainersSpecCLI.ts" src, err := os.ReadFile(oraclePath) @@ -26,9 +17,6 @@ func TestOracleFlagCoverage(t *testing.T) { t.Skipf("oracle not checked out (%s); run `task reference`", oraclePath) } - // deliberatelyUnmodeled: oracle flags we intentionally do not expose, with the - // reason. Keep this tiny and justified — a real new upstream flag must be added - // to the CLI, not silenced here. deliberatelyUnmodeled := map[string]string{} oracle := oracleCommandFlags(string(src)) @@ -36,8 +24,6 @@ func TestOracleFlagCoverage(t *testing.T) { var problems []string - // Global options (yargs `.option(..., { global: true })`) belong to every - // command; the inventory tracks them under `global_flags`. inventoryGlobals := inventoryGlobalFlags(t) for f := range oracleGlobalFlags(string(src)) { if !inventoryGlobals[f] { @@ -47,7 +33,7 @@ func TestOracleFlagCoverage(t *testing.T) { for cmd, flags := range oracle { invFlags, ok := inventory[cmd] if !ok { - continue // command not modeled in the inventory (e.g. features/* live elsewhere) + continue } for f := range flags { if _, skip := deliberatelyUnmodeled[f]; skip { @@ -64,9 +50,6 @@ func TestOracleFlagCoverage(t *testing.T) { } } -// oracleGlobalFlags returns the flag names the oracle declares as global options -// on the root yargs chain (before `.strict()`), i.e. the ones every command -// accepts. func oracleGlobalFlags(src string) map[string]bool { start := strings.Index(src, ".scriptName('devcontainer')") end := strings.Index(src, ".strict();") @@ -82,7 +65,6 @@ func oracleGlobalFlags(src string) map[string]bool { return out } -// inventoryGlobalFlags loads the global flag names declared in the YAML. func inventoryGlobalFlags(t *testing.T) map[string]bool { t.Helper() data, err := os.ReadFile("../../docs/parity/cli-flags-inventory.yaml") @@ -102,12 +84,8 @@ func inventoryGlobalFlags(t *testing.T) map[string]bool { return out } -// oracleCommandFlags maps each top-level command name to the set of flag names -// its options function declares in the oracle source. func oracleCommandFlags(src string) map[string]map[string]bool { - // y.command('up', 'desc', provisionOptions, provisionHandler) cmdRe := regexp.MustCompile(`y\.command\('([a-z-]+)',\s*'[^']*',\s*(\w+Options),`) - // A quoted kebab/lower flag key mapping to an option object: 'no-lockfile': { flagRe := regexp.MustCompile(`'([a-z][a-z0-9-]*)':\s*\{`) fnBodies := oracleOptionFnBodies(src) @@ -129,8 +107,6 @@ func oracleCommandFlags(src string) map[string]map[string]bool { return out } -// oracleOptionFnBodies returns the source text of each `function Options(...)` -// block, split at top-level function boundaries. func oracleOptionFnBodies(src string) map[string]string { lines := strings.Split(src, "\n") fnStart := regexp.MustCompile(`^(?:async )?function (\w+)\(`) @@ -155,7 +131,6 @@ func oracleOptionFnBodies(src string) map[string]string { return bodies } -// inventoryCommandFlags loads the flag names declared per command in the YAML. func inventoryCommandFlags(t *testing.T) map[string]map[string]bool { t.Helper() data, err := os.ReadFile("../../docs/parity/cli-flags-inventory.yaml") diff --git a/internal/cli/outdated.go b/internal/cli/outdated.go index cf091f0..e79f59d 100644 --- a/internal/cli/outdated.go +++ b/internal/cli/outdated.go @@ -27,17 +27,10 @@ import ( "golang.org/x/term" ) -// bareVersion is a bare semver-ish target: x / x.y / x.y.z -// (TS: /^\d+(\.\d+(\.\d+)?)?$/ in upgradeCommand.ts). 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, /[:@][^/]*$/). -// The delimiter is the first ':' or '@' after the last '/', so a registry port -// (localhost:5000/...) is preserved and a digest '@' wins over a later ':'. func getFeatureIdWithoutVersion(featureID string) string { start := strings.LastIndexByte(featureID, '/') + 1 for i := start; i < len(featureID); i++ { @@ -58,7 +51,6 @@ func newOutdatedCmd() *cobra.Command { if err := validateTerminalImplications(opts.terminalColumns, opts.terminalRows); err != nil { return err } - // 0.88: workspace-folder defaults to cwd when not provided. if opts.workspaceFolder == "" { opts.workspaceFolder, _ = os.Getwd() } @@ -80,7 +72,6 @@ func newOutdatedCmd() *cobra.Command { return cmd } -// outdatedOpts holds the flags for the `outdated` command. type outdatedOpts struct { workspaceFolder string configPath string @@ -101,9 +92,6 @@ type outdatedEntry struct { LatestMajor string `json:"latestMajor,omitempty"` } -// highestSatisfyingTag returns the newest published version a feature tag resolves -// to: for "latest"/empty the newest overall, for a version tag (e.g. "2") the newest -// within that major. versions must be sorted ascending. "" if none match. func highestSatisfyingTag(versions []*semver.Version, tag string) string { if len(versions) == 0 { return "" @@ -127,8 +115,6 @@ func highestSatisfyingTag(versions []*semver.Version, tag string) string { return "" } -// resolvePublishedVersions fetches and returns a feature ref's published semver -// tags, sorted ascending. func resolvePublishedVersions(ociClient oci.Registry, ref *oci.Ref) []*semver.Version { tags, err := ociClient.GetPublishedTags(ref) if err != nil { @@ -144,19 +130,13 @@ func resolvePublishedVersions(ociClient oci.Registry, ref *oci.Ref) []*semver.Ve return versions } -// outdatedHeader is the header row of outdated's text table. var outdatedHeader = []string{"Feature", "Current", "Wanted", "Latest"} -// writeOutdatedJSON writes outdated's JSON result as TS does: -// JSON.stringify(outdated, undefined, process.stdout.isTTY ? ' ' : undefined), -// indented only when stdout is a terminal. func writeOutdatedJSON(w io.Writer, payload map[string]any) { data, _ := stringify.JSON(payload, outdatedIndent(w)) fmt.Fprintln(w, string(data)) } -// outdatedIndent is the indent of outdated's JSON on w: two spaces when w is -// a terminal (Node's isTTY, an isatty check), none otherwise. func outdatedIndent(w io.Writer) string { if f, ok := w.(*os.File); ok && term.IsTerminal(int(f.Fd())) { return " " @@ -164,7 +144,6 @@ func outdatedIndent(w io.Writer) string { return "" } -// majorOf returns the major version of a semver string, or "" if unparseable. func majorOf(v string) string { if v == "" { return "" @@ -205,7 +184,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { if opts.outputFormat == "json" { writeOutdatedJSON(out.Stdout(), map[string]any{"features": map[string]any{}}) } else { - // TS writes its table with no rows: the header alone. fmt.Fprintln(out.Stdout(), textTable([][]string{outdatedHeader})) } return nil @@ -213,10 +191,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { ociClient := newOCIClient(ctx, logger) - // Lockfile pins the concrete "current" version when present (matches TS - // loadVersionInfo: current = lockfileVersion || wanted). One that cannot be - // read fails the command as TS outdated's catch does: the error's stack - // logged at debug level, nothing on stdout, exit code 1. lockfile, _, err := features.ReadLockfile(cfg.ConfigFilePath) if err != nil { logger.Write(errorStack(err), log.LevelDebug) @@ -230,10 +204,8 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { ref, err := oci.ParseRef(resolvedID) if err != nil { - // Not a versionable OCI feature (local path, etc.) — TS omits these. continue } - // Published versions. Features with no versions are omitted (as TS does). versions := resolvePublishedVersions(ociClient, ref) if len(versions) == 0 { continue @@ -242,7 +214,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { latest := versions[len(versions)-1].Original() wanted := highestSatisfyingTag(versions, ref.Tag) - // current = lockfile version if pinned, else the resolved wanted version. current := wanted if lockfile != nil { if e, ok := lockfile.Features[id]; ok && e.Version != "" { @@ -260,8 +231,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { } if opts.outputFormat == "json" { - // TS reorders resolved.features to the order the configuration lists - // the Features in (loadVersionInfo), which JSON.stringify keeps. fields := make(map[string]any, len(result)) for id, e := range result { fields[id] = e @@ -269,11 +238,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { ordered := orderedObject{fields: fields, order: orderedFeatureKeys(cfg.ConfigFilePath, cfg.Features)} writeOutdatedJSON(out.Stdout(), map[string]any{"features": ordered}) } else { - // Text table. Rows follow the order features were declared in the config - // (TS reorders resolved.features back to config order), and the Feature - // column shows the full user id without its version tag/digest. Formatting - // mirrors npm's text-table (min column widths, 2-space separator, left - // aligned, trailing whitespace stripped per row). rows := [][]string{outdatedHeader} for _, id := range orderedFeatureKeys(cfg.ConfigFilePath, cfg.Features) { e, ok := result[id] @@ -293,7 +257,6 @@ func runOutdated(ctx context.Context, out Output, opts outdatedOpts) error { return nil } -// dashIfEmpty renders an empty column value as "-" (TS maps undefined → '-'). func dashIfEmpty(s string) string { if s == "" { return "-" @@ -301,10 +264,6 @@ func dashIfEmpty(s string) string { return s } -// orderedFeatureKeys returns the feature ids in the order they appear in the -// 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]any) []string { ordered, _ := featureKeyOrderFromFile(configFilePath) @@ -329,8 +288,6 @@ func orderedFeatureKeys(configFilePath string, features map[string]any) []string return result } -// featureKeyOrderFromFile parses configFilePath and returns the keys of its -// top-level "features" object in document order. func featureKeyOrderFromFile(configFilePath string) ([]string, error) { if configFilePath == "" { return nil, nil @@ -344,7 +301,6 @@ func featureKeyOrderFromFile(configFilePath string) ([]string, error) { return nil, err } dec := json.NewDecoder(bytes.NewReader(std)) - // Opening brace of the top-level object. if _, err := dec.Token(); err != nil { return nil, err } @@ -361,7 +317,6 @@ func featureKeyOrderFromFile(configFilePath string) ([]string, error) { } continue } - // "features" value must be an object; read its keys in order. tok, err := dec.Token() if err != nil { return nil, err @@ -388,9 +343,6 @@ func featureKeyOrderFromFile(configFilePath string) ([]string, error) { return nil, nil } -// textTable formats rows the way npm's text-table does: each column padded to -// its max cell width, cells separated by two spaces, left-aligned, with trailing -// whitespace trimmed from each line. func textTable(rows [][]string) string { var widths []int for _, row := range rows { @@ -421,8 +373,6 @@ func textTable(rows [][]string) string { return strings.Join(lines, "\n") } -// upgradeLogLevels are the choices TS featuresUpgradeOptions declares for -// --log-level, which, unlike the other commands', include error. var upgradeLogLevels = []string{"error", "info", "debug", "trace"} func newUpgradeCmd() *cobra.Command { @@ -443,14 +393,9 @@ func newUpgradeCmd() *cobra.Command { Use: "upgrade", Short: "Upgrade lockfile", RunE: func(cmd *cobra.Command, args []string) error { - // --log-level is a yargs choice, validated before .check() runs. if err := validateEnum("log-level", logLevel, upgradeLogLevels); err != nil { return err } - // --feature/--target-version are the dependabot-oriented flags: they - // must be supplied together, and --target-version must be a bare - // x / x.y / x.y.z version. TS validates this in yargs .check() before - // the handler runs (upgradeCommand.ts), so exit 1 with the same text. if (feature != "") != (targetVersion != "") { return fmt.Errorf("The '--target-version' and '--feature' flag must be used together.") } @@ -458,7 +403,6 @@ func newUpgradeCmd() *cobra.Command { return fmt.Errorf("Invalid version '%s'. Must be in the form of 'x', 'x.y', or 'x.y.z'", targetVersion) } - // 0.88: workspace-folder defaults to cwd when not provided. if workspaceFolder == "" { workspaceFolder, _ = os.Getwd() } @@ -477,16 +421,12 @@ func newUpgradeCmd() *cobra.Command { loadResult, err := config.LoadDevContainerConfig(ws, cp, "") if errors.Is(err, fs.ErrNotExist) { - // A --config file that does not exist is TS getConfig's "not found". return &coreerrors.ContainerError{Description: fmt.Sprintf("Dev container config (%s) not found.", cp)} } if err != nil { return err } - // TS writes its log header with the first event, which every run - // that gets past loading the configuration logs (at trace level - // at least), and none that fails to load it. logger := log.New(log.Options{ Version: cliVersion(), Level: log.ParseLevel(logLevel), @@ -496,9 +436,6 @@ func newUpgradeCmd() *cobra.Command { cfg := loadResult.Config - // --feature/--target-version first pin the Feature's version in - // devcontainer.json, with or without --dry-run, and the lockfile - // is then generated from the configuration read again. if feature != "" { logger.Write(fmt.Sprintf("Updating '%s' to '%s' in devcontainer.json", feature, targetVersion), log.LevelInfo) if err := updateFeatureVersionInConfig(logger, cfg, feature, targetVersion); err != nil { @@ -510,16 +447,8 @@ func newUpgradeCmd() *cobra.Command { cfg = loadResult.Config } - // The lockfile sits next to the configuration, whose path a loaded - // configuration always carries. lockfilePath := cfg.ConfigFilePath - // The Features are resolved as build resolves them, without the - // lockfile (TS readFeaturesConfig: generateFeaturesConfig with - // noLockfile): dependencies included, and a Feature that cannot be - // resolved fails the command. A configuration without Features has - // no Features config, which TS reports as a failure rather than - // writing an empty lockfile. res, err := fetchFeatureSetsWithOrder(cmd.Context(), logger, cfg.Features, localFeatureRoots{filepath.Dir(lockfilePath), ws}, false, nil, nil) if err != nil { return err @@ -535,9 +464,6 @@ func newUpgradeCmd() *cobra.Command { return nil } - // TS truncates any existing lockfile before writing it - // (upgradeCommand.ts), so the new one is always written in its - // canonical form, even over one that differs only cosmetically. if err := os.WriteFile(features.LockfilePath(lockfilePath), nil, 0o666); err != nil { return err } @@ -554,8 +480,8 @@ func newUpgradeCmd() *cobra.Command { f.BoolVar(&dryRun, "dry-run", false, "Write to stdout.") f.StringVarP(&feature, "feature", "f", "", "") f.StringVarP(&targetVersion, "target-version", "v", "", "") - _ = f.MarkHidden("feature") // hidden aliased flag (TS parity: alias -f) - _ = f.MarkHidden("target-version") // hidden aliased flag (TS parity: alias -v) + _ = f.MarkHidden("feature") + _ = f.MarkHidden("target-version") _ = dockerPath _ = composePath @@ -563,12 +489,6 @@ func newUpgradeCmd() *cobra.Command { return cmd } -// updateFeatureVersionInConfig pins the configured Feature that is feature, -// whatever its version, to targetVersion in the configuration file, as TS -// updateFeatureVersionInConfig does: the first Feature key, in the order the -// file lists them, whose id without its version is feature's is replaced, -// read as a regular expression, everywhere in the file's text by -// ":". func updateFeatureVersionInConfig(logger log.Logger, cfg *config.DevContainer, feature, targetVersion string) error { configPath := cfg.ConfigFilePath if cfg.Features == nil { @@ -608,9 +528,6 @@ func updateFeatureVersionInConfig(logger log.Logger, cfg *config.DevContainer, f return os.WriteFile(configPath, []byte(updated), 0o644) } -// errorStack is how the reference logs an error it catches (err.stack, frames -// left out): a lockfile error as the JavaScript error TS throws, any other -// with its message. func errorStack(err error) string { if lerr, ok := errors.AsType[*features.LockfileError](err); ok { return lerr.Stack() diff --git a/internal/cli/outdated_held_test.go b/internal/cli/outdated_held_test.go index 916a5fb..23a8d1b 100644 --- a/internal/cli/outdated_held_test.go +++ b/internal/cli/outdated_held_test.go @@ -11,8 +11,6 @@ import ( "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() @@ -33,9 +31,6 @@ func runOutdatedFor(t *testing.T, opts outdatedOpts) (string, error) { 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"} @@ -51,7 +46,6 @@ func TestOutdatedReportsVersions(t *testing.T) { if err != nil { t.Fatal(err) } - // stdout is no terminal: TS's JSON.stringify writes it on one line. if strings.Count(got, "\n") != 1 || strings.Contains(got, " ") { t.Errorf("json output = %q, want one unindented line", got) } @@ -66,8 +60,6 @@ func TestOutdatedReportsVersions(t *testing.T) { 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"}, } - // The Features follow the configuration's order, as TS loadVersionInfo - // reorders them, not the ids' sorted order. if keys, err := jsonKeyOrder(got, "features"); err != nil || !slices.Equal(keys, []string{h + "/ns/multi:1", h + "/ns/single:3", h + "/ns/blank:1"}) { t.Errorf("features order = %v, %v; want the configuration's", keys, err) } @@ -90,8 +82,6 @@ func TestOutdatedReportsVersions(t *testing.T) { } } -// With no Features there is nothing to look up: an empty result in JSON, and -// in text the table TS writes with no rows, its header alone. func TestOutdatedWithoutFeatures(t *testing.T) { ws := writeOutdatedWorkspace(t, `{}`, "") if got, err := runOutdatedFor(t, outdatedOpts{workspaceFolder: ws, outputFormat: "json"}); err != nil || got != "{\"features\":{}}\n" { @@ -102,8 +92,6 @@ func TestOutdatedWithoutFeatures(t *testing.T) { } } -// --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"} @@ -132,7 +120,6 @@ func TestHighestSatisfyingTagSingleVersion(t *testing.T) { } } -// 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 { @@ -144,9 +131,6 @@ func TestOrderedFeatureKeysOnlyListsFeatures(t *testing.T) { } } -// A lockfile that is not JSON fails outdated as it fails the reference's: -// exit code 1 and nothing on stdout, the SyntaxError's stack logged at debug -// level only (outdated's catch writes it with output.write). func TestOutdatedFailsOnAMalformedLockfile(t *testing.T) { ws := writeOutdatedWorkspace(t, `{"ghcr.io/devcontainers/features/node:1":{}}`, "not json") const stack = `SyntaxError: Unexpected token 'o', "not json" is not valid JSON` diff --git a/internal/cli/outdated_repo_held_test.go b/internal/cli/outdated_repo_held_test.go index 8b05ffc..184a6ef 100644 --- a/internal/cli/outdated_repo_held_test.go +++ b/internal/cli/outdated_repo_held_test.go @@ -22,7 +22,6 @@ import ( func executeCmd(t *testing.T, cmd *cobra.Command, args ...string) (string, error) { t.Helper() - // 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) @@ -32,8 +31,6 @@ func executeCmd(t *testing.T, cmd *cobra.Command, args ...string) (string, error return out.String(), err } -// outdated reads the workspace given by --workspace-folder, and the current -// directory without it. func TestOutdatedCmdWorkspaceFolderDefaultsToCwd(t *testing.T) { ws := writeOutdatedWorkspace(t, `{}`, "") t.Chdir(t.TempDir()) @@ -46,9 +43,6 @@ func TestOutdatedCmdWorkspaceFolderDefaultsToCwd(t *testing.T) { } } -// addFeature publishes repo as a Feature whose devcontainer-feature.json is -// meta (JSON): its manifest carries meta as its metadata annotation and its -// layer is the Feature's tarball. func (r *readRegistry) addFeature(t *testing.T, repo, meta string) { t.Helper() tgz := featureTgzBytes(t, map[string]string{"devcontainer-feature.json": meta, "install.sh": "#!/bin/sh\n"}) @@ -73,10 +67,6 @@ func parseLockfile(t *testing.T, data string) features.Lockfile { return lf } -// upgrade --dry-run resolves the Features as build does (TS readFeaturesConfig) -// and prints their lockfile: each OCI Feature at the version its -// devcontainer-feature.json records, pinned to its manifest digest, the -// Features it depends on included, local Features left out. func TestUpgradeDryRunPrintsLockfile(t *testing.T) { reg := newReadRegistry(t) h := reg.host @@ -104,9 +94,6 @@ func TestUpgradeDryRunPrintsLockfile(t *testing.T) { } } -// A Feature that cannot be resolved fails upgrade, as it fails TS -// generateFeaturesConfig, rather than being left out of the lockfile: nothing -// is printed or written. func TestUpgradeFailsOnAnUnresolvableFeature(t *testing.T) { reg := newReadRegistry(t) reg.addFeature(t, "ns/tool", `{"id":"tool","version":"1.0.0"}`) @@ -128,8 +115,6 @@ func TestUpgradeFailsOnAnUnresolvableFeature(t *testing.T) { } } -// Without --dry-run the lockfile is written next to the configuration -// --config names; one that cannot be written fails the command. func TestUpgradeWritesLockfileNextToConfig(t *testing.T) { reg := newReadRegistry(t) reg.addFeature(t, "ns/tool", `{"id":"tool","version":"1.0.0"}`) @@ -157,9 +142,6 @@ func TestUpgradeWritesLockfileNextToConfig(t *testing.T) { } } -// upgrade truncates the lockfile before writing it (TS upgradeCommand.ts), so a -// lockfile with the same content in another layout is rewritten canonically, -// where build and up would leave it alone. func TestUpgradeRewritesACosmeticallyDifferentLockfile(t *testing.T) { reg := newReadRegistry(t) reg.addFeature(t, "ns/tool", `{"id":"tool","version":"1.0.0"}`) @@ -187,8 +169,6 @@ func TestUpgradeRewritesACosmeticallyDifferentLockfile(t *testing.T) { } } -// A lockfile upgrade can truncate but not read back fails the command, as TS -// writeLockfile rethrows readLocalFile's EACCES after the truncation. func TestUpgradeFailsOnALockfileItCannotReadBack(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root reads a write-only file") @@ -203,8 +183,6 @@ func TestUpgradeFailsOnALockfileItCannotReadBack(t *testing.T) { } } -// upgrade reads the workspace given by --workspace-folder, and the current -// directory without it. func TestUpgradeWorkspaceFolderDefaultsToCwd(t *testing.T) { reg := newReadRegistry(t) reg.addFeature(t, "ns/tool", `{"id":"tool","version":"1.0.0"}`) @@ -219,9 +197,6 @@ func TestUpgradeWorkspaceFolderDefaultsToCwd(t *testing.T) { } } -// --feature and --target-version go together, and the target is a bare -// x / x.y / x.y.z version (TS upgradeCommand.ts .check()); a configuration that -// cannot be loaded, or a log file that cannot be opened, fails the command. func TestUpgradeRejectsBadInvocations(t *testing.T) { ws := writeOutdatedWorkspace(t, `{}`, "") together := "The '--target-version' and '--feature' flag must be used together." @@ -246,9 +221,6 @@ func TestUpgradeRejectsBadInvocations(t *testing.T) { } } -// A configuration without Features has no Features config to lock, which TS -// upgrade fails on ("Failed to update lockfile") rather than writing an empty -// lockfile, with or without --dry-run. func TestUpgradeFailsWithoutFeatures(t *testing.T) { for _, feats := range []string{`{}`, `null`} { for _, dryRun := range []bool{false, true} { @@ -268,7 +240,6 @@ func TestUpgradeFailsWithoutFeatures(t *testing.T) { } } -// The keys of a "features" value that is not an object are not Feature ids. func TestFeatureKeyOrderFromFileNonObjectFeatures(t *testing.T) { cfg := filepath.Join(t.TempDir(), "devcontainer.json") writeFile(t, cfg, `{"features":["a","b"]}`, 0o644) @@ -277,8 +248,6 @@ func TestFeatureKeyOrderFromFileNonObjectFeatures(t *testing.T) { } } -// A column is as wide as its widest cell, an empty column has no width, and a -// cell one shorter than its column is padded by one. func TestTextTableWidths(t *testing.T) { got := textTable([][]string{{"", "ab", "x"}, {"", "a", "y"}}) if want := " ab x\n a y"; got != want { @@ -286,8 +255,6 @@ func TestTextTableWidths(t *testing.T) { } } -// The tag or digest is cut at the first ':' or '@' after the last '/', even -// right after it (TS /[:@][^/]*$/). func TestGetFeatureIdWithoutVersionRightAfterSlash(t *testing.T) { for in, want := range map[string]string{"o/@sha256:x": "o/", "@sha256:x": "", "o/:1": "o/"} { if got := getFeatureIdWithoutVersion(in); got != want { @@ -296,8 +263,6 @@ func TestGetFeatureIdWithoutVersionRightAfterSlash(t *testing.T) { } } -// --log-level is one of TS upgrade's choices, error among them, and yargs -// rejects any other before .check() looks at --feature/--target-version. func TestUpgradeLogLevelChoices(t *testing.T) { ws := writeOutdatedWorkspace(t, `{}`, "") want := `Invalid value "warn" for --log-level. Choose from: error, info, debug, trace` @@ -309,8 +274,6 @@ func TestUpgradeLogLevelChoices(t *testing.T) { } } -// A --config file that does not exist is TS getConfig's "not found" error, -// naming the resolved path. func TestUpgradeMissingConfigIsNotFound(t *testing.T) { missing := filepath.Join(t.TempDir(), "nope.json") want := "Dev container config (" + missing + ") not found." @@ -319,8 +282,6 @@ func TestUpgradeMissingConfigIsNotFound(t *testing.T) { } } -// upgrade logs TS's header once the configuration loads, none when it does -// not, and nothing of its own after writing the lockfile. func TestUpgradeLogsAsTheReference(t *testing.T) { reg := newReadRegistry(t) reg.addFeature(t, "ns/tool", `{"id":"tool","version":"1.0.0"}`) @@ -344,10 +305,6 @@ func TestUpgradeLogsAsTheReference(t *testing.T) { } } -// --feature/--target-version pin the Feature in devcontainer.json before the -// lockfile is generated, as TS updateFeatureVersionInConfig does, also with -// --dry-run: the first key, in file order, naming the Feature whatever its -// version is replaced, as a pattern, everywhere in the file's text. func TestUpgradePinsTheFeatureVersionInConfig(t *testing.T) { reg := newReadRegistry(t) h := reg.host @@ -382,9 +339,6 @@ func TestUpgradePinsTheFeatureVersionInConfig(t *testing.T) { } } -// Of two keys naming the Feature only the first in file order is replaced; -// the key is read as a regular expression, so a '.' in it matches any -// character, and a key that is not a valid one fails the command. func TestUpgradeFeatureKeyIsAPattern(t *testing.T) { reg := newReadRegistry(t) h := reg.host @@ -409,9 +363,6 @@ func TestUpgradeFeatureKeyIsAPattern(t *testing.T) { } } -// A configuration with no Feature to pin is left as it is: without Features -// TS logs so at debug level, and with Features none of which is the one -// asked for, the unchanged text at trace level. func TestUpgradeLeavesAConfigWithoutTheFeature(t *testing.T) { reg := newReadRegistry(t) h := reg.host @@ -441,9 +392,6 @@ func TestUpgradeLeavesAConfigWithoutTheFeature(t *testing.T) { } } -// A local Feature outside the .devcontainer folder fails upgrade as TS -// processFeatureIdentifier fails it, with or without --dry-run, rather than -// being written to the lockfile. func TestUpgradeRefusesALocalFeatureOutsideDevcontainer(t *testing.T) { for _, dryRun := range []bool{false, true} { ws := writeOutdatedWorkspace(t, `{"../f":{}}`, "") @@ -462,9 +410,6 @@ func TestUpgradeRefusesALocalFeatureOutsideDevcontainer(t *testing.T) { } } -// A lockfile upgrade creates gets Node's default mode, 0666 minus the umask, -// as TS writeLocalFile(lockfilePath, "") gives it before the lockfile is -// written. func TestUpgradeCreatesTheLockfileWithNodesMode(t *testing.T) { old := syscall.Umask(0o002) t.Cleanup(func() { syscall.Umask(old) }) diff --git a/internal/cli/outdated_tty_linux_test.go b/internal/cli/outdated_tty_linux_test.go index 5c86122..3db116c 100644 --- a/internal/cli/outdated_tty_linux_test.go +++ b/internal/cli/outdated_tty_linux_test.go @@ -14,10 +14,6 @@ type terminalStdout struct{ terminal *os.File } func (o terminalStdout) Stdout() io.Writer { return o.terminal } func (terminalStdout) Stderr() io.Writer { return io.Discard } -// outdated indents its JSON only on a terminal, as TS's -// JSON.stringify(outdated, undefined, process.stdout.isTTY ? ' ' : undefined); -// a character device that is no terminal (/dev/null) or a regular file gets -// none. func TestOutdatedIndentsOnlyOnATerminal(t *testing.T) { master, terminal := openPTY(t) ws := writeOutdatedWorkspace(t, `{}`, "") diff --git a/internal/cli/output.go b/internal/cli/output.go index 545a5f1..5d77aef 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -7,40 +7,25 @@ import ( "github.com/spf13/cobra" ) -// Output is the seam for a command's stdout/stderr. Routing every result and -// progress write through it lets tests capture output with an injected buffer -// instead of swapping the global os.Stdout (which is racy and defeats parallel -// tests). It intentionally exposes only the two writers consumers use. type Output interface { Stdout() io.Writer Stderr() io.Writer } -// osOutput writes to the process's real stdout/stderr. It is the default when no -// command-scoped Output is available. type osOutput struct{} func (osOutput) Stdout() io.Writer { return os.Stdout } func (osOutput) Stderr() io.Writer { return os.Stderr } -// OSOutput returns the default Output backed by os.Stdout/os.Stderr. func OSOutput() Output { return osOutput{} } -// cmdOutput adapts a *cobra.Command to Output, reusing its OutOrStdout / -// ErrOrStderr. Those default to os.Stdout/os.Stderr but can be overridden in -// tests via cmd.SetOut / cmd.SetErr, giving a race-free capture point. type cmdOutput struct{ cmd *cobra.Command } func (c cmdOutput) Stdout() io.Writer { return c.cmd.OutOrStdout() } func (c cmdOutput) Stderr() io.Writer { return c.cmd.ErrOrStderr() } -// outputFor returns an Output backed by the given cobra command. func outputFor(cmd *cobra.Command) Output { return cmdOutput{cmd: cmd} } -// progressWriter returns the writer that live subprocess progress (e.g. docker -// build) should stream to: w in text mode, or nil under --log-format json where -// a raw byte stream would corrupt the structured event stream (callers treat a -// nil writer as "buffer and emit as a log event instead"). func progressWriter(logFormat string, w io.Writer) io.Writer { if logFormat == "json" { return nil diff --git a/internal/cli/parity_key_order_test.go b/internal/cli/parity_key_order_test.go index 1910d29..5e3468f 100644 --- a/internal/cli/parity_key_order_test.go +++ b/internal/cli/parity_key_order_test.go @@ -5,8 +5,6 @@ import ( "testing" ) -// A key_order path reaches into arrays by index, so a case can pin the key -// order of an element (featuresConfiguration.featureSets.0.features.0). func TestJSONKeyOrderIndexesArrays(t *testing.T) { doc := `{"a":[{"x":1},{"z":1,"y":2}]}` if keys, err := jsonKeyOrder(doc, "a.1"); err != nil || !slices.Equal(keys, []string{"z", "y"}) { diff --git a/internal/cli/parity_lock_test.go b/internal/cli/parity_lock_test.go index 2438af3..8b0067c 100644 --- a/internal/cli/parity_lock_test.go +++ b/internal/cli/parity_lock_test.go @@ -15,16 +15,9 @@ var ( parityWorkspaceFlag = regexp.MustCompile(`--workspace-folder\s+(src/test/configs/[^\s"';]+)`) parityConfigFlag = regexp.MustCompile(`--config\s+(src/test/configs/[^\s"';]+)`) nonEmptyFeaturesJSON = regexp.MustCompile(`"features"\s*:\s*\{\s*"`) - // setupWritesFeatures matches a setup_cmd that writes a configuration with - // Features itself, its quotes possibly escaped for the shell. - setupWritesFeatures = regexp.MustCompile(`\\?"features\\?"\s*:\s*\{\s*\\?"`) + setupWritesFeatures = regexp.MustCompile(`\\?"features\\?"\s*:\s*\{\s*\\?"`) ) -// A case that builds Features without BuildKit, from a fixture or from a -// configuration its setup_cmd writes, goes through the shared -// dev_container_feature_content_temp image, so it must hold the -// feature-content-image lock; a case that does not, must not, so the lock -// serializes no more than it has to. func TestParityMatrixLocksTheFeatureContentImage(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "parity", "parity-matrix.yaml")) if err != nil { @@ -61,8 +54,6 @@ func TestParityMatrixLocksTheFeatureContentImage(t *testing.T) { } } -// parityCaseConfigs is every devcontainer.json the commands name, by -// --config or as a --workspace-folder's. func parityCaseConfigs(t *testing.T, cmds string) []string { var configs []string for _, m := range parityConfigFlag.FindAllStringSubmatch(cmds, -1) { diff --git a/internal/cli/parity_matrix_helpers_test.go b/internal/cli/parity_matrix_helpers_test.go index c13ad8f..487d68d 100644 --- a/internal/cli/parity_matrix_helpers_test.go +++ b/internal/cli/parity_matrix_helpers_test.go @@ -54,14 +54,14 @@ func TestSelected_CommaList(t *testing.T) { value, filter string want bool }{ - {"up", "", true}, // empty matches all - {"up", "all", true}, // "all" matches all - {"up", "up", true}, // single exact - {"up", "build", false}, // single mismatch - {"up", "up,build", true}, // list contains - {"build", "up,build", true}, // list contains (2nd) - {"exec", "up,build", false}, // list excludes - {"build", " up , build ", true}, // whitespace tolerated + {"up", "", true}, + {"up", "all", true}, + {"up", "up", true}, + {"up", "build", false}, + {"up", "up,build", true}, + {"build", "up,build", true}, + {"exec", "up,build", false}, + {"build", " up , build ", true}, } for _, c := range cases { if got := selected(c.value, c.filter); got != c.want { @@ -184,9 +184,6 @@ func TestComposeProjectName(t *testing.T) { } } -// TestInShardPartitions proves the shard split is a proper partition: with N -// shards, every case is claimed by exactly one shard and the union is the whole -// set (no case dropped, none run twice). func TestInShardPartitions(t *testing.T) { const cases = 189 for _, total := range []int{1, 2, 3, 4, 7} { @@ -206,7 +203,6 @@ func TestInShardPartitions(t *testing.T) { } } -// TestInShardNoShardingRunsEverything proves total<=1 disables sharding. func TestInShardNoShardingRunsEverything(t *testing.T) { for i := range 10 { if !inShard(i, 0, 1) { diff --git a/internal/cli/parity_matrix_test.go b/internal/cli/parity_matrix_test.go index 1e7e82e..0de96ef 100644 --- a/internal/cli/parity_matrix_test.go +++ b/internal/cli/parity_matrix_test.go @@ -1,20 +1,3 @@ -// Package cli parity matrix. -// -// Runtime cases run in parallel, bounded by `go test -parallel N`. Isolation is -// per case: single-container cases use unique --id-label parity.case values, and -// compose cases get a unique COMPOSE_PROJECT_NAME (see parityEnv); build cache/ -// output cases use a unique BUILDX_BUILDER instead of touching the global buildx -// default. A few cases that assert a specific compose project name opt out via -// no_compose_isolation, and cases that can't be isolated set serial: true. -// -// Recommended invocation for the full runtime lane (docker required): -// -// PARITY_LANE=all go test ./internal/cli -run TestParityMatrix -parallel 2 -timeout 40m -// -// Higher -parallel is faster but overloads the docker daemon on contended runners -// and transiently fails compose/heavy cases (which match in isolation), so the gate -// (task parity:runtime) defaults to -parallel 2 for determinism; raise via -// PARITY_PARALLEL on idle/beefy runners. package cli import ( @@ -39,66 +22,33 @@ import ( "gopkg.in/yaml.v3" ) -// --- YAML types --- - type parityMatrix struct { InitialCases []parityCase `yaml:"initial_cases"` } type parityCase struct { - ID string `yaml:"id"` - Lane string `yaml:"lane"` - Command string `yaml:"command"` - Priority string `yaml:"priority"` - DockerRequired bool `yaml:"docker_required"` - NetworkRequired bool `yaml:"network_required"` - TSCmd string `yaml:"ts_cmd"` - SetupCmd string `yaml:"setup_cmd"` - VerifyCmd string `yaml:"verify_cmd"` - CleanupCmd string `yaml:"cleanup_cmd"` - Asserts []string `yaml:"asserts"` - Class string `yaml:"class"` - CurrentStatus string `yaml:"current_status"` - Notes string `yaml:"notes"` - // NoComposeIsolation opts a case out of the injected per-case - // COMPOSE_PROJECT_NAME (used to isolate parallel runtime cases). Set it for - // cases that exercise compose project-name resolution, or whose reuse logic - // is sensitive to the project name. - NoComposeIsolation bool `yaml:"no_compose_isolation"` - // Serial forces a case to run sequentially (no t.Parallel). Used for the few - // runtime cases that can't be isolated for parallel execution. - Serial bool `yaml:"serial"` - // Lock names a Docker resource the case shares with other cases and cannot - // isolate; cases with the same lock run one at a time, still in parallel - // with every other case. See parityLocks. - Lock string `yaml:"lock"` - // CompareHashes keeps sha256/hex digests UNSCRUBBED so they are compared - // exactly between TS and Go. Set it for cases whose digests are deterministic - // and meaningful (resolve-dependencies installOrder resource@digest, - // read-configuration featuresConfiguration manifest/computed digests, lockfile - // integrity) — otherwise a wrong Go digest normalizes to and passes. - // Leave it false where digests are non-deterministic (tarball push mtime). - CompareHashes bool `yaml:"compare_hashes"` - // CompareNulls keeps JSON null values instead of dropping them, so a field TS - // emits as null but Go omits (or vice versa) is caught instead of comparing - // equal. Set it for result-envelope cases where present-null vs absent-key is - // a real shape divergence. - CompareNulls bool `yaml:"compare_nulls"` - // Arm64Required marks a case that needs arm64 container emulation (QEMU/binfmt - // on an amd64 host). arm64 runtime is EXPERIMENTAL and unsupported for now, so - // these are skipped (skipped-arm64) unless PARITY_ARM64=true is set explicitly — - // they never fail the strict gate. - Arm64Required bool `yaml:"arm64_required"` - // ExpectInfraFailure marks a failure-intended case whose TS side fails with an - // infra-looking error that IS the expected behavior (e.g. an invalid --platform - // makes docker fail at the metadata layer). Without this, isInfraError would - // classify TS's failure as an unusable-oracle skip → inconclusive; with it, when - // Go also failed the harness compares exit codes instead. - ExpectInfraFailure bool `yaml:"expect_infra_failure"` - // KeyOrder names JSON objects in the result on stdout whose keys must come in - // TS's order, which stdout_normalized cannot see (it sorts keys): "" is the - // result itself, "configuration" its configuration property, and so on. - KeyOrder []string `yaml:"key_order"` + ID string `yaml:"id"` + Lane string `yaml:"lane"` + Command string `yaml:"command"` + Priority string `yaml:"priority"` + DockerRequired bool `yaml:"docker_required"` + NetworkRequired bool `yaml:"network_required"` + TSCmd string `yaml:"ts_cmd"` + SetupCmd string `yaml:"setup_cmd"` + VerifyCmd string `yaml:"verify_cmd"` + CleanupCmd string `yaml:"cleanup_cmd"` + Asserts []string `yaml:"asserts"` + Class string `yaml:"class"` + CurrentStatus string `yaml:"current_status"` + Notes string `yaml:"notes"` + NoComposeIsolation bool `yaml:"no_compose_isolation"` + Serial bool `yaml:"serial"` + Lock string `yaml:"lock"` + CompareHashes bool `yaml:"compare_hashes"` + CompareNulls bool `yaml:"compare_nulls"` + Arm64Required bool `yaml:"arm64_required"` + ExpectInfraFailure bool `yaml:"expect_infra_failure"` + KeyOrder []string `yaml:"key_order"` } type paritySideResult struct { @@ -197,18 +147,10 @@ func writeParityReport(path string, snapshot map[parityOutcome][]string) error { return nil } -// parityLocks are the shared resources a case can name in `lock`. -// -// feature-content-image: without BuildKit, both CLIs build a Feature's files -// into the fixed tag dev_container_feature_content_temp (TS containerFeatures.ts -// getFeaturesBuildOptions) and build FROM it, so two such builds at once can -// take each other's Features. var parityLocks = map[string]*sync.Mutex{ "feature-content-image": {}, } -// --- Main test --- - func TestParityMatrix(t *testing.T) { repoRoot := findRepoRoot(t) matrixPath := filepath.Join(repoRoot, "docs", "parity", "parity-matrix.yaml") @@ -243,9 +185,6 @@ func TestParityMatrix(t *testing.T) { 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") - // so CI can grant headroom to QEMU-emulated arm64 cases without slowing the - // amd64 cases here. runtimeTimeout := 300 * time.Second if v := os.Getenv("PARITY_RUNTIME_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { @@ -253,36 +192,21 @@ func TestParityMatrix(t *testing.T) { } } - // Check TS CLI exists if _, err := os.Stat(filepath.Join(repoRoot, "reference", "dist", "spec-node", "devContainersSpecCLI.js")); err != nil { t.Skip("TS reference not compiled (reference/dist not found). Run: git submodule update --init && task ts:compile") } - // Check Go CLI exists if _, err := os.Stat(cliGO); err != nil { t.Skip("Go CLI not built. Run: task build") } dockerAvailable := isDockerAvailable() - // The runtime lane's update-UID and cache-export builds run - // `docker build --platform` against locally-built images, which the legacy - // graph-driver builder cannot resolve — only the containerd image store can. - // Without it the TS oracle fails those builds while the Go CLI (which omits - // --platform) succeeds, producing false TS-vs-Go divergences. Enforce the - // store up front — including on a direct `go test` that skips the Taskfile - // precondition — so a misconfigured daemon fails loudly with a fix, not as a - // confusing per-case divergence. runtimeLane := os.Getenv("PARITY_LANE") != "" && os.Getenv("PARITY_SKIP_DOCKER") != "true" if runtimeLane && dockerAvailable && !hasContainerdImageStore() { t.Fatal("parity runtime lane requires the containerd image store (docker build --platform against local images). " + "Enable it with `task ci:prepare-runner`, or add {\"features\":{\"containerd-snapshotter\":true}} to /etc/docker/daemon.json and restart Docker.") } - // Sharding: split the selected cases round-robin across parallel CI jobs, so - // N runners (with independent docker daemons) share the wall-clock. Inert when - // PARITY_SHARD_TOTAL is unset or <= 1. Cases in other shards stay not-selected, - // which the strict gate ignores (it only fails on inconclusive cases), so each - // shard gates its own subset and the union covers everything exactly once. shardIndex, shardTotal := parityShard(t) selected := 0 @@ -290,13 +214,9 @@ func TestParityMatrix(t *testing.T) { if !matchesFilter(tc) { continue } - // With no explicit lane, runtime is intentionally outside this invocation. - // Keep it as not-selected rather than creating a misleading skipped subtest. if tc.Lane == "runtime" && os.Getenv("PARITY_LANE") == "" { continue } - // Round-robin over the selected set (stable matrix order); consecutive - // cases land on different shards, which spreads the heavy build.* cases. caseIndex := selected selected++ if !inShard(caseIndex, shardIndex, shardTotal) { @@ -311,9 +231,6 @@ func TestParityMatrix(t *testing.T) { } report.record(tc.ID, outcome) }() - // Cases run in parallel (bounded by `go test -parallel N`); per-case - // COMPOSE_PROJECT_NAME + id-labels isolate runtime cases. A few cases - // that can't be isolated opt into serial execution. if !tc.Serial { t.Parallel() } @@ -326,8 +243,6 @@ func TestParityMatrix(t *testing.T) { outcome = paritySkippedNetwork t.Skip("network required") } - // arm64 runtime is experimental and unsupported for now; skip unless - // explicitly opted in (needs arm64 emulation, e.g. QEMU/binfmt). if tc.Arm64Required && os.Getenv("PARITY_ARM64") != "true" { outcome = paritySkippedArm64 t.Skip("arm64 experimental (set PARITY_ARM64=true to run; requires arm64 emulation)") @@ -349,9 +264,6 @@ func TestParityMatrix(t *testing.T) { tsStatus := classifyParitySide(tsCtx.Err(), tsRes.Stdout, tsRes.Stderr, tsRes.ExitCode) tsCancel() - // Always run the Go side, even when TS is skipped — a TS-side infra/timeout - // must never silently delete Go's coverage. We decide what to do with - // the results afterwards. goCtx, goCancel := context.WithTimeout(t.Context(), perCaseTimeout) goRes := runParitySide(t, goCtx, repoRoot, cliGO, tc, "go") goStatus := classifyParitySide(goCtx.Err(), goRes.Stdout, goRes.Stderr, goRes.ExitCode) @@ -360,14 +272,7 @@ func TestParityMatrix(t *testing.T) { t.Fatalf("Go timed out") } - // TS unavailable as an oracle (infra/timeout): record it (auditable) but - // don't compare. Go still ran, so its coverage is not silently lost. - // When Go hit the SAME environment limit (e.g. arm64 on an amd64 host) that - // is a shared skip, not a Go bug. if tsStatus.Skip { - // A failure-intended case whose TS side fails with an infra-looking - // error IS the expected behavior (e.g. invalid --platform). When Go - // also failed, compare exit codes instead of marking inconclusive. if !tc.ExpectInfraFailure || !tsStatus.Infra || goRes.ExitCode == 0 { outcome = parityInconclusive t.Skipf("TS %s (Go exit %d) [case=%s]", tsStatus.Reason, goRes.ExitCode, tc.ID) @@ -380,18 +285,12 @@ func TestParityMatrix(t *testing.T) { asserts := setFrom(tc.Asserts) - // Skip when TS fails due to infra but Go succeeds; this is not a product mismatch. if tsRes.ExitCode != 0 && goRes.ExitCode == 0 && tsStatus.Infra { outcome = parityInconclusive t.Skipf("TS infra error (exit %d) [case=%s]", tsRes.ExitCode, tc.ID) return } - // Outcome-intent check, now that TS is a usable oracle: - // - success case, TS exit 0, Go non-zero → real regression (RED). - // - success case, BOTH failed → we can't tell a product bug from an - // environment limit; mark inconclusive (visible SKIP, not a silent pass). - // - failure case, TS non-zero, Go exit 0 → real regression (RED). if expectSuccess, known := caseExpectsOutcome(tc.ID); known { if expectSuccess && goRes.ExitCode != 0 { if tsRes.ExitCode == 0 { @@ -408,14 +307,12 @@ func TestParityMatrix(t *testing.T) { } } - // Exit code if asserts["exit_code"] && tsRes.ExitCode != goRes.ExitCode { t.Errorf("exit code: TS=%d Go=%d", tsRes.ExitCode, goRes.ExitCode) } scrubHashes := !tc.CompareHashes - // Non-zero exit path if tsRes.ExitCode != 0 && goRes.ExitCode != 0 { if asserts["stdout_normalized"] { tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes, tc.CompareNulls) @@ -425,8 +322,6 @@ func TestParityMatrix(t *testing.T) { } } if asserts["stderr_normalized"] { - // 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 { @@ -436,7 +331,6 @@ func TestParityMatrix(t *testing.T) { return } - // Success path if asserts["stdout_normalized"] { tsNorm := normalizeOutputOpts(tsRes.Stdout, scrubHashes, tc.CompareNulls) goNorm := normalizeOutputOpts(goRes.Stdout, scrubHashes, tc.CompareNulls) @@ -445,9 +339,6 @@ func TestParityMatrix(t *testing.T) { } } - // stdout_text compares stdout as text, only scrubbing paths and hashes, - // so what stdout_normalized's JSON parse hides (how a string is - // escaped) is compared byte for byte. if asserts["stdout_text"] { tsText := normalizeStringOpts(tsRes.Stdout, scrubHashes) goText := normalizeStringOpts(goRes.Stdout, scrubHashes) @@ -508,10 +399,6 @@ func TestParityMatrix(t *testing.T) { } } -// caseExpectsOutcome infers the intended outcome from the case id naming -// convention: "...-success" is success-intended (exit 0), while the failure/ -// invalid-invocation family is failure-intended (non-zero). The second return is -// false when the id carries no clear intent (don't assert an outcome then). func caseExpectsOutcome(id string) (expectSuccess bool, known bool) { switch { case strings.Contains(id, "-success"): @@ -528,8 +415,6 @@ func caseExpectsOutcome(id string) (expectSuccess bool, known bool) { return false, false } -// --- CLI execution --- - func runParitySide(t *testing.T, ctx context.Context, repoRoot, cli string, tc parityCase, side string) paritySideResult { t.Helper() @@ -574,14 +459,7 @@ func runParitySide(t *testing.T, ctx context.Context, repoRoot, cli string, tc p return result } -// parityInfraRetries bounds how many times a side is re-run when it fails with a -// transient build/environment error. The runtime lane's docker builds flake on -// contended CI runners (BuildKit "failed to solve", a registry hiccup, or a -// container-setup failure surfaced as `Command failed: docker build`), which then -// shows up as a false TS-vs-Go divergence (one side's build flaked while the other -// succeeded). A single retry absorbs the flake. Retrying is safe: a deterministic -// product failure reproduces on every attempt and still fails the case. -const parityInfraRetries = 2 // total attempts, not extra retries +const parityInfraRetries = 2 func runParityCLI(ctx context.Context, repoRoot, cli, cmdArgs string, env map[string]string) (stdout, stderr string, exitCode int) { return runWithInfraRetry(ctx, parityInfraRetries, func() (string, string, int) { @@ -589,16 +467,11 @@ func runParityCLI(ctx context.Context, repoRoot, cli, cmdArgs string, env map[st }) } -// runWithInfraRetry re-runs run while it fails with a retryable transient error, -// up to maxAttempts total. It stops early on success, on a non-retryable failure, -// or once the context is done (so a retry never runs past the per-case deadline). func runWithInfraRetry(ctx context.Context, maxAttempts int, run func() (stdout, stderr string, exitCode int)) (stdout, stderr string, exitCode int) { if maxAttempts < 1 { maxAttempts = 1 } for attempt := 1; ; attempt++ { - // Always allow the first attempt; after that, don't start new work once the - // per-case context is done. if attempt > 1 && ctx.Err() != nil { return } @@ -631,11 +504,6 @@ 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) { var err error stdout, stderr, exitCode := runWithInfraRetry(ctx, parityInfraRetries, func() (string, string, int) { @@ -697,16 +565,8 @@ func runShellCommand(ctx context.Context, repoRoot, shellCmd string, env map[str return outBuf.String(), errBuf.String(), exitCode, err } -// --- Normalization --- - var ( - // Both CLIs stamp a piped log line with its ISO time (TS createPlainLog); - // only that shape is scrubbed, so a line in another shape stays a mismatch. - reTimestampTS = regexp.MustCompile(`\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]`) - // Host/tmp path scrubbers stop at structural delimiters (comma, '=', ';', ')') - // so a mount spec like source=/home/x,target=/y,type=bind keeps its - // target/type for comparison instead of collapsing everything after the host - // prefix into one token. + reTimestampTS = regexp.MustCompile(`\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]`) reHostPath = regexp.MustCompile(`/Users/[^\s"'\n,;=)]+`) reHomePath = regexp.MustCompile(`/home/[^\s"'\n,;=)]+`) reTmpPath = regexp.MustCompile(`/tmp/[^\s"'\n,;=)]+`) @@ -736,8 +596,6 @@ func normalizeStringOpts(s string, scrubHashes bool) string { s = reVarFolders.ReplaceAllString(s, "") if scrubHashes { s = reSHA256.ReplaceAllString(s, "sha256:") - // Only scrub hex-looking runs that actually contain a hex letter — a pure - // decimal run (epoch-ms, large uid) is a real value and must stay comparable. s = reHexID.ReplaceAllStringFunc(s, func(m string) string { if strings.ContainsAny(m, "abcdef") { return "" @@ -745,10 +603,7 @@ func normalizeStringOpts(s string, scrubHashes bool) string { return m }) } - // Mermaid node hashes are internally-consistent but not byte-identical across - // TS/Go, so they are always scrubbed (independent of scrubHashes). s = reMermaidNode.ReplaceAllString(s, "[") - // Normalize parity side identifiers in image/container names s = reParitySideSuffix.ReplaceAllString(s, "-$2") return s } @@ -761,14 +616,12 @@ func normalizeOutputOpts(raw string, scrubHashes, keepNulls bool) string { return "" } - // Try JSON parse (UseNumber preserves numeric precision) if parsed, ok := decodeJSONNumber(raw); ok { normalized := normalizeValueOpts(parsed, scrubHashes, keepNulls) out, _ := json.Marshal(normalized) return string(out) } - // TS occasionally prefixes JSON with banner/noise on stderr/stdout; recover the payload. if candidate := extractJSONCandidate(raw); candidate != "" && candidate != raw { if parsed, ok := decodeJSONNumber(candidate); ok { normalized := normalizeValueOpts(parsed, scrubHashes, keepNulls) @@ -777,7 +630,6 @@ func normalizeOutputOpts(raw string, scrubHashes, keepNulls bool) string { } } - // Fallback: normalize as text return normalizeStringOpts(raw, scrubHashes) } @@ -830,18 +682,12 @@ func normalizeEmbeddedJSONOpts(raw string, scrubHashes, keepNulls bool) any { 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 - // into a number/bool and hide string-vs-typed divergences that Docker and - // downstream tooling care about. if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") { return nil, false } return decodeJSONNumber(t) } -// decodeJSONNumber parses JSON with UseNumber so large/precise numbers survive -// the round-trip instead of being coerced through float64. func decodeJSONNumber(raw string) (any, bool) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() @@ -852,9 +698,7 @@ func decodeJSONNumber(raw string) (any, bool) { return nil, false } -// sortedMap returns a json.RawMessage with sorted keys. func sortedMap(m map[string]any) map[string]any { - // json.Marshal already sorts keys in Go 1.12+ return m } @@ -863,7 +707,6 @@ func normalizeTextOpts(raw string, scrubHashes bool) string { for line := range strings.SplitSeq(raw, "\n") { line = strings.TrimRight(line, "\r") - // Strip noise lines skip := false for _, pattern := range stripLines { if strings.Contains(line, pattern) { @@ -875,16 +718,12 @@ func normalizeTextOpts(raw string, scrubHashes bool) string { continue } - // Normalize timestamps line = reTimestampTS.ReplaceAllString(line, "[X ms]") - // Normalize paths and IDs line = normalizeStringOpts(line, scrubHashes) - // Normalize dockerfile size line = reDockerfileB.ReplaceAllString(line, "transferring dockerfile: B") - // Skip blank lines if strings.TrimSpace(line) == "" { continue } @@ -894,12 +733,9 @@ func normalizeTextOpts(raw string, scrubHashes bool) string { return strings.Join(lines, "\n") } -// --- Error reason extraction --- - func extractErrorReason(stdout, stderr string) string { text := stderr - // Try JSON stdout for error outcome var parsed map[string]any jsonStdout := strings.TrimSpace(stdout) if candidate := extractJSONCandidate(jsonStdout); candidate != "" { @@ -917,7 +753,6 @@ func extractErrorReason(stdout, stderr string) string { text = strings.ReplaceAll(text, "\r\n", "\n") - // Invalid choice patterns if m := matchChoiceYargs(text); m != "" { return m } @@ -928,42 +763,32 @@ func extractErrorReason(stdout, stderr string) string { return m } - // Missing required re := regexp.MustCompile(`(?m)Missing required argument:\s*(.+)$`) if match := re.FindStringSubmatch(text); len(match) > 1 { return "missing-required|" + normalizeRequired(strings.Split(match[1], "\n")[0]) } - // Unmatched format re = regexp.MustCompile(`(?m)Unmatched argument format:\s*(.+)$`) if match := re.FindStringSubmatch(text); len(match) > 1 { return "invalid-format|" + strings.TrimSpace(match[1]) } - // Parse error — match both Go and TS variants if strings.Contains(text, "Failed to parse Feature identifier") || strings.Contains(text, "failed validation") { return "parse-error|feature-identifier" } - // Implications re = regexp.MustCompile(`Implications failed:\s*\n?\s*(.+)`) if match := re.FindStringSubmatch(text); len(match) > 1 { return "implications|" + strings.TrimSpace(match[1]) } - // Fallback: last meaningful line, ignoring the version banner (only TS emits - // it) and Node.js stack frames (0.88 lets a few error paths throw with a - // 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 _, line := range slices.Backward(lines) { line := strings.TrimSpace(line) if line == "" || isBannerLine(line) || isStackFrame(line) { continue } - // Strip the TS log timestamp prefix ("[2026-...Z] ") and a leading - // "Error: " so a TS-thrown error lines up with Go's plain message. line = reLogTimestamp.ReplaceAllString(line, "") return strings.TrimPrefix(line, "Error: ") } @@ -972,13 +797,10 @@ func extractErrorReason(stdout, stderr string) string { var reLogTimestamp = regexp.MustCompile(`^\[[^\]]*\]\s*`) -// isBannerLine reports whether a line is the CLI version banner -// ("[ts] @devcontainers/cli X. Node.js ...") that only the TS CLI prints. func isBannerLine(line string) bool { return strings.Contains(line, "@devcontainers/cli") && strings.Contains(line, "Node.js") } -// isStackFrame reports whether a line is a Node.js stack frame ("at ..."). func isStackFrame(line string) bool { return strings.HasPrefix(line, "at ") } @@ -1051,11 +873,6 @@ func normalizeRequired(raw string) string { return strings.Join(clean, ",") } -// --- Helpers --- - -// jsonKeyOrder is the keys of the JSON object at path in the document data, in -// the order data lists them: "" is the document itself, "a.b" the object at -// property b of property a, and "a.0" the first element of the array a. func jsonKeyOrder(data, path string) ([]string, error) { raw := json.RawMessage(data) if path != "" { @@ -1169,14 +986,8 @@ func parityEnv(caseID, side, repoRoot string, isolateCompose bool) map[string]st "PARITY_CASE_ID": sanitizeEnvValue(caseID), "PARITY_SIDE": side, "PARITY_REPO_ROOT": repoRoot, - // The CLI a setup_cmd must invoke is the SAME binary under test for this - // lane, not a hardcoded path: the coverage lane sets CLI_GO to the - // instrumented binary and never builds ./devcontainer, so a setup_cmd that - // hard-coded ${PARITY_REPO_ROOT}/devcontainer fails there (missing binary → - // 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", defaultParityTSCLI(repoRoot)), + "PARITY_CLI_GO": envOr("CLI_GO", filepath.Join(repoRoot, "devcontainer")), + "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 @@ -1186,21 +997,11 @@ func parityEnv(caseID, side, repoRoot string, isolateCompose bool) map[string]st "BUILDX_NO_DEFAULT_ATTESTATIONS": "1", } if isolateCompose { - // Give each case its own compose project so parallel runtime cases that - // share a fixture don't manage each other's containers. The name is the - // SAME for both sides: ts and go run sequentially within a case (ts's - // deferred cleanup runs before go starts), so they never overlap, and a - // side-independent name keeps the emitted composeProjectName identical - // across sides for output comparison. Cases that assert a specific - // project name (compose `name:`, env-var, derived) opt out via - // no_compose_isolation. env["COMPOSE_PROJECT_NAME"] = composeProjectName(caseID) } return env } -// composeProjectName builds a valid, unique-per-case compose project name -// ([a-z0-9_-]). func composeProjectName(caseID string) string { var b strings.Builder b.WriteString("dc") @@ -1278,17 +1079,10 @@ func envOr(key, fallback string) string { return fallback } -// inShard reports whether the case at caseIndex (its position in the selected -// stream) belongs to shard index of total. total <= 1 means no sharding (every -// case runs). The modulo partitions the stream into disjoint shards whose union -// is the whole set. func inShard(caseIndex, index, total int) bool { return total <= 1 || caseIndex%total == index } -// parityShard reads PARITY_SHARD_INDEX / PARITY_SHARD_TOTAL for CI sharding. -// Total defaults to 1 (no sharding). An invalid pair fails the test so a -// misconfigured matrix is loud rather than silently skipping cases. func parityShard(t *testing.T) (index, total int) { total = 1 if v := os.Getenv("PARITY_SHARD_TOTAL"); v != "" { @@ -1315,9 +1109,6 @@ func isDockerAvailable() bool { return cmd.Run() == nil } -// hasContainerdImageStore reports whether the Docker daemon has the containerd -// image store enabled (daemon.json {"features":{"containerd-snapshotter":true}}), -// which surfaces as an io.containerd.snapshotter driver in `docker info`. func hasContainerdImageStore() bool { out, err := exec.Command("docker", "info", "--format", "{{json .DriverStatus}}").Output() if err != nil { @@ -1337,10 +1128,6 @@ func matchesFilter(tc parityCase) bool { selected(tc.CurrentStatus, os.Getenv("PARITY_STATUS")) } -// selected reports whether value passes a filter. An empty filter or "all" -// matches everything; otherwise the filter is a comma-separated allowlist and -// value must equal one of its (trimmed) entries. The list form lets CI run the -// cases affected by a change, e.g. PARITY_COMMAND="up,build". func selected(value, filter string) bool { if filter == "" || filter == "all" { return true @@ -1359,9 +1146,6 @@ func selectedSubstring(value, filter string) bool { func isInfraError(stdout, stderr string) bool { combined := stdout + stderr - // Anchored to unambiguous environment signals. Note "docker buildx" was - // removed: it also appears in the build command's yargs --output help text, so - // any build flag-validation error was being misclassified as infra and skipped. infraPatterns := []string{ "no match for platform", "failed to solve", @@ -1376,15 +1160,6 @@ func isInfraError(stdout, stderr string) bool { return false } -// isRetryableFailure reports whether a non-zero CLI result looks like a transient -// build/environment failure worth re-running, as opposed to a stable product -// divergence. It is deliberately BROADER than isInfraError: a retry is safe (a -// deterministic failure reproduces on the next attempt and still goes RED), so it -// also matches the container-setup / `docker build` wrapper a flaky BuildKit or -// registry hiccup surfaces without a recognizable low-level signal. Do NOT use it -// to mark a side an unusable oracle — classifyParitySide/isInfraError must stay -// narrow so a real divergence is never silently skipped; use this only to decide a -// retry. func isRetryableFailure(stdout, stderr string) bool { if isInfraError(stdout, stderr) { return true diff --git a/internal/cli/parity_retry_test.go b/internal/cli/parity_retry_test.go index d173340..697a6d3 100644 --- a/internal/cli/parity_retry_test.go +++ b/internal/cli/parity_retry_test.go @@ -5,13 +5,6 @@ import ( "testing" ) -// TestIsRetryableFailure guards the classifier that decides whether a non-zero -// parity side is a transient build/environment flake (retryable) or a stable -// product divergence (not). The two positive fixtures are the exact signatures -// that turned dependabot PRs #9/#10 red: a BuildKit "failed to solve" on one -// side and the reference's container-setup wrapper on the other, while the other -// side succeeded. The negative fixtures must stay non-retryable so a real CLI -// contract failure is never masked by a retry. func TestIsRetryableFailure(t *testing.T) { cases := []struct { name string @@ -30,8 +23,6 @@ func TestIsRetryableFailure(t *testing.T) { wantRetryable: true, }, { - // PR #10 / main 07-14 signature: TS reference build flaked and the - // low-level BuildKit error is hidden behind the generic wrapper. name: "reference container-setup wrapper", stdout: `{"description":"An error occurred setting up the container.","message":"Command failed: docker build -f /tmp/x -t vsc-parity-dotfiles --platform linux/amd64 /tmp/x","outcome":"error"}`, wantRetryable: true, @@ -60,10 +51,6 @@ func TestIsRetryableFailure(t *testing.T) { } } -// TestRunWithInfraRetry asserts the retry loop absorbs a transient build flake -// (success on the second attempt) while never masking a stable failure: a -// deterministic contract error runs exactly once, and a persistent transient -// error is retried up to the cap and then surfaced as the final non-zero result. func TestRunWithInfraRetry(t *testing.T) { const transient = "ERROR: failed to solve: connection reset by peer" const stable = `{"outcome":"error","message":"Dev container config not found."}` diff --git a/internal/cli/parity_setup_binary_test.go b/internal/cli/parity_setup_binary_test.go index 292492a..f566ca2 100644 --- a/internal/cli/parity_setup_binary_test.go +++ b/internal/cli/parity_setup_binary_test.go @@ -9,22 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -// TestParitySetupUsesLaneBinary guards the root cause of a red daily parity run: -// the runtime matrix runs in two lanes with DIFFERENT binaries — the push/PR lane -// (`task parity:runtime`, deps build → ./devcontainer) and the daily coverage lane -// (`task coverage:parity-runtime`, which builds ONLY the instrumented binary at -// artifacts/coverage/bin and points CLI_GO at it, never building ./devcontainer). -// A setup_cmd/cleanup_cmd/verify_cmd that hard-codes ${PARITY_REPO_ROOT}/devcontainer -// (or node .../reference/devcontainer.js) therefore invokes a missing binary in the -// coverage lane: setup's `up` fails, no container is created, and the measured -// command reports "Dev container not found". It passes in the plain lane and -// locally, so the divergence only ever shows up in the daily. -// -// The fix routes every setup-side CLI call through ${PARITY_CLI_GO} / ${PARITY_CLI_TS}, -// which parityEnv binds to the same binary the measured command uses in each lane. -// This test asserts both halves so a re-hardcoded path can't regress the daily green. func TestParitySetupUsesLaneBinary(t *testing.T) { - // Half 1: parityEnv must export the CLI vars the matrix now depends on. env := parityEnv("some.case", "go", "/repo", false) for _, key := range []string{"PARITY_CLI_GO", "PARITY_CLI_TS"} { if env[key] == "" { @@ -34,13 +19,10 @@ 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) } - // 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) } - // Half 2: no matrix command may hard-code the binary path — it must go through - // the lane-aware vars, or the coverage lane breaks again. path := filepath.Join("docs", "parity", "parity-matrix.yaml") raw, err := os.ReadFile(filepath.Join("..", "..", path)) if err != nil { diff --git a/internal/cli/parity_setup_retry_test.go b/internal/cli/parity_setup_retry_test.go index d538680..e531561 100644 --- a/internal/cli/parity_setup_retry_test.go +++ b/internal/cli/parity_setup_retry_test.go @@ -7,8 +7,6 @@ import ( "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` diff --git a/internal/cli/parity_sharding_workflow_test.go b/internal/cli/parity_sharding_workflow_test.go index 7fc5214..35acf81 100644 --- a/internal/cli/parity_sharding_workflow_test.go +++ b/internal/cli/parity_sharding_workflow_test.go @@ -11,8 +11,6 @@ import ( "gopkg.in/yaml.v3" ) -// stringOrSlice decodes a GitHub Actions field that may be a scalar ("needs: x") -// or a sequence ("needs: [x, y]"). type stringOrSlice []string func (s *stringOrSlice) UnmarshalYAML(n *yaml.Node) error { @@ -64,22 +62,9 @@ func loadWorkflow(t *testing.T) workflow { return wf } -// TestWorkflowShardMatrixMatchesShardTotal guards the CI wiring that -// TestInShardPartitions cannot see: the round-robin partition is only a true -// cover-everything-once split when the runner matrix and PARITY_SHARD_TOTAL agree. -// If a job declares matrix.shard: [0,1,2,3] but PARITY_SHARD_TOTAL: "2", shards 2 -// and 3 select an empty slice while cases at index 2,3 (mod 4) run in no shard at -// all — the daily backstop goes green having silently skipped a quarter of the -// matrix. This asserts, for every sharded parity job, that: -// - PARITY_SHARD_TOTAL == len(matrix.shard), -// - matrix.shard is exactly 0..total-1, and -// - PARITY_SHARD_INDEX threads the matrix value through. func TestWorkflowShardMatrixMatchesShardTotal(t *testing.T) { wf := loadWorkflow(t) - // Every job that sets PARITY_SHARD_TOTAL is a sharded lane and must be checked; - // discovering them from the env (rather than a hardcoded name list) means a new - // sharded job is covered automatically. sharded := 0 for name, job := range wf.Jobs { total, ok := job.Env["PARITY_SHARD_TOTAL"] @@ -105,19 +90,11 @@ func TestWorkflowShardMatrixMatchesShardTotal(t *testing.T) { } } - // The unified runtime lane (parity-runtime) is sharded; if it isn't found the - // parsing (or the job) regressed. if sharded < 1 { t.Errorf("found %d sharded parity jobs, want at least 1 (parity-runtime)", sharded) } } -// TestWorkflowRuntimeJobsShareCompositeAction locks in the reuse for the runtime -// lanes: they must set up their runner through the shared composite action -// (toolchain + ci:prepare-runner + reference) so the environments can't drift apart. -// (A lane may also add a bare setup-go before the composite — parity-runtime needs -// the Go toolchain for the devtool plan step that decides whether the composite even -// runs — but node/task setup belongs only in the shared action, never re-inlined.) func TestWorkflowRuntimeJobsShareCompositeAction(t *testing.T) { wf := loadWorkflow(t) const action = "./.github/actions/setup-parity-runtime" @@ -133,7 +110,6 @@ func TestWorkflowRuntimeJobsShareCompositeAction(t *testing.T) { if s.Uses == action { used = true } - // node/task setup is the composite's job; re-inlining it defeats the reuse. if strings.HasPrefix(s.Uses, "actions/setup-node") || strings.HasPrefix(s.Uses, "go-task/setup-task") { t.Errorf("job %q re-inlines %q; use the composite action instead", name, s.Uses) } @@ -144,11 +120,6 @@ func TestWorkflowRuntimeJobsShareCompositeAction(t *testing.T) { } } -// TestWorkflowDailyLanesGatedOnChanges asserts the expensive daily lanes only run -// when the repo changed that day: each must `needs: daily-changes` and gate on its -// output. Dropping either turns the change-detection into a no-op and the matrix -// runs (and bills) every day regardless. (parity-runtime also runs on push/PR; its -// `if` gates the daily path on the same output.) func TestWorkflowDailyLanesGatedOnChanges(t *testing.T) { wf := loadWorkflow(t) diff --git a/internal/cli/parity_ts_cli_test.go b/internal/cli/parity_ts_cli_test.go index dda1145..53eb3df 100644 --- a/internal/cli/parity_ts_cli_test.go +++ b/internal/cli/parity_ts_cli_test.go @@ -7,8 +7,6 @@ import ( "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") diff --git a/internal/cli/publish_parity_test.go b/internal/cli/publish_parity_test.go index 1ea5bbd..35c3e8a 100644 --- a/internal/cli/publish_parity_test.go +++ b/internal/cli/publish_parity_test.go @@ -15,11 +15,6 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -// TestPublishParity publishes the same feature/template collection through the TS -// oracle and the Go CLI into a throwaway registry:3 container (via testcontainers) -// under separate namespaces, and asserts the normalized result JSON matches. The -// per-item layer digest differs (tar headers embed non-deterministic mtimes) but is -// scrubbed by normalizeOutput, so publishedTags + version are what get compared. func TestPublishParity(t *testing.T) { repoRoot := findRepoRoot(t) @@ -58,7 +53,7 @@ func TestPublishParity(t *testing.T) { cases := []struct { name string - subcommand string // "features" | "templates" + subcommand string collection string itemIDs []string }{ @@ -89,8 +84,6 @@ func TestPublishParity(t *testing.T) { t.Errorf("publish result differs:\n--- TS\n%s\n--- Go\n%s", tsOut, goOut) } - // Output parity alone is insufficient for a mutating command. Verify the - // registry state for every item and the collection metadata artifact. client := oci.NewClientWithAuthPolicy(log.Null, osEnvMap(), oci.DefaultAuthPolicy()) for _, id := range tc.itemIDs { tagsFor := func(namespace string) []string { diff --git a/internal/cli/pure_helpers_test.go b/internal/cli/pure_helpers_test.go index a857bfb..3544641 100644 --- a/internal/cli/pure_helpers_test.go +++ b/internal/cli/pure_helpers_test.go @@ -19,13 +19,11 @@ func TestGetFeatureIdWithoutVersion(t *testing.T) { cases := map[string]string{ "ghcr.io/devcontainers/features/go:1.2.3": "ghcr.io/devcontainers/features/go", "ghcr.io/devcontainers/features/go:1": "ghcr.io/devcontainers/features/go", - "ghcr.io/devcontainers/features/go": "ghcr.io/devcontainers/features/go", // no version - // A digest pin: the leftmost delimiter is '@', so the whole @sha256:... goes. - "ghcr.io/o/r@sha256:abcdef": "ghcr.io/o/r", - // A registry port colon is NOT the version (it is followed by a '/'). - "localhost:5000/o/r:2": "localhost:5000/o/r", - "localhost:5000/o/r": "localhost:5000/o/r", - "": "", + "ghcr.io/devcontainers/features/go": "ghcr.io/devcontainers/features/go", + "ghcr.io/o/r@sha256:abcdef": "ghcr.io/o/r", + "localhost:5000/o/r:2": "localhost:5000/o/r", + "localhost:5000/o/r": "localhost:5000/o/r", + "": "", } for in, want := range cases { if got := getFeatureIdWithoutVersion(in); got != want { @@ -44,7 +42,7 @@ func mustVersions(t *testing.T, tags ...string) []*semver.Version { } vs = append(vs, v) } - return vs // caller supplies them already ascending + return vs } func TestHighestSatisfyingTag(t *testing.T) { @@ -77,7 +75,7 @@ func TestMajorOf(t *testing.T) { "0.5.0": "0", "not-semver": "", "10.20.30": "10", - "v3.1.0": "3", // semver tolerates a leading v + "v3.1.0": "3", } for in, want := range cases { if got := majorOf(in); got != want { @@ -114,7 +112,6 @@ func TestAppPortPublishArgs(t *testing.T) { } func TestFolderImageName(t *testing.T) { - // Deterministic: same path -> same name; prefixed with vsc--. a := folderImageName("/home/user/My Project") b := folderImageName("/home/user/My Project") if a != b { @@ -123,11 +120,9 @@ func TestFolderImageName(t *testing.T) { if !strings.HasPrefix(a, "vsc-") { t.Errorf("missing vsc- prefix: %q", a) } - // Different paths -> different names. if folderImageName("/a") == folderImageName("/b") { t.Error("distinct paths produced the same image name") } - // Docker image names are lowercase; the sanitizer must not leave spaces/upper. if a != strings.ToLower(a) || strings.ContainsAny(a, " ") { t.Errorf("image name not sanitized: %q", a) } @@ -146,7 +141,7 @@ func TestEnvSliceToMap(t *testing.T) { "FOO": "bar", "EMPTY": "", "HAS": "eq=in=value", - "DUP": "second", // last wins + "DUP": "second", } if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) @@ -229,17 +224,14 @@ func TestTextTable(t *testing.T) { if len(lines) != 3 { t.Fatalf("want 3 lines, got %d: %q", len(lines), got) } - // Columns are padded to the widest cell and separated by two spaces. if lines[0] != "ID CURRENT WANTED" { t.Errorf("header padding wrong: %q", lines[0]) } - // Trailing whitespace on the last column is trimmed. for _, l := range lines { if l != strings.TrimRight(l, " ") { t.Errorf("line has trailing spaces: %q", l) } } - // A ragged row (fewer columns) must not panic. _ = textTable([][]string{{"a", "b"}, {"c"}}) if textTable(nil) != "" { t.Error("empty table should be empty string") @@ -247,7 +239,6 @@ func TestTextTable(t *testing.T) { } func TestBuildArgsAndOptionsFromConfig(t *testing.T) { - // nil Build -> empty/nil. if m := buildArgsFromConfig(&config.DevContainer{}); len(m) != 0 { t.Errorf("buildArgsFromConfig(nil Build) = %v, want empty", m) } @@ -266,10 +257,6 @@ func TestBuildArgsAndOptionsFromConfig(t *testing.T) { } } -// up and build report a ContainerError as it is, as TS does for the errors it -// throws with their own description (a legacy Feature id, a failing compose -// config), wherever it is wrapped; any other error gets the command's -// description with the error appended, except a frozen lockfile's error. func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { legacy := checkLegacyFeatures(map[string]any{"myfeature": true}) out := commandErrorOutput(fmt.Errorf("install features: %w", legacy), "An error occurred setting up the container") @@ -277,8 +264,6 @@ func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { if out.Description != want || out.Message != want { t.Errorf("legacy Feature error = %+v", out) } - // A frozen lockfile's plain error is TS's originalError: its text is the - // message, the command's sentence the description. out = commandErrorOutput(fmt.Errorf("install features: lockfile: %w", features.ErrLockfileMismatch), "An error occurred setting up the container") if out.Message != "Lockfile does not match." || out.Description != "An error occurred setting up the container." { t.Errorf("frozen lockfile error = %+v", out) @@ -291,8 +276,6 @@ func TestCommandErrorOutputKeepsAContainerError(t *testing.T) { if want := "An error occurred building the container: some internal failure"; out.Description != want || out.Message != want { t.Errorf("other error = %+v", out) } - // An error carrying the reference's own message (a lockfile it cannot - // read) is reported as TS reports any error it wraps in a ContainerError. lockErr := &features.LockfileError{Name: "SyntaxError", Message: "Unexpected end of JSON input"} out = commandErrorOutput(fmt.Errorf("fetch features: %w", lockErr), "An error occurred setting up the container") if out.Message != "Unexpected end of JSON input" || out.Description != "An error occurred setting up the container." { @@ -305,14 +288,12 @@ type errorString string func (e errorString) Error() string { return string(e) } func TestEncloseStringInBox(t *testing.T) { - // Box width must match the rune count, not the byte count (unicode-safe). out := encloseStringInBox("héllo") lines := strings.Split(out, "\n") if len(lines) != 3 { t.Fatalf("want 3 lines, got %d", len(lines)) } top, bottom := []rune(lines[0]), []rune(lines[2]) - // "┌" + 5 bars + "┐" if len(top) != 7 || len(bottom) != 7 { t.Errorf("box borders not sized to rune width: top=%d bottom=%d", len(top), len(bottom)) } @@ -336,12 +317,10 @@ 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]any{"version": "1"}, "gradle") if got["version"] != "1" || got["gradle"] != true { t.Errorf("addFeatureOption merge wrong: %v", got) } - // A non-map option value (e.g. bool/string) is replaced by a fresh map. got = addFeatureOption(true, "maven") if got["maven"] != true || len(got) != 1 { t.Errorf("addFeatureOption(non-map) = %v", got) @@ -369,7 +348,6 @@ func TestOCIAnnotationID(t *testing.T) { func TestFeatureKeyOrderFromFile(t *testing.T) { dir := t.TempDir() cfg := filepath.Join(dir, "devcontainer.json") - // JSONC with comments + deliberately non-alphabetical feature order. os.WriteFile(cfg, []byte(`{ // a comment "image": "x", @@ -387,7 +365,6 @@ func TestFeatureKeyOrderFromFile(t *testing.T) { t.Errorf("insertion order not preserved: %v", keys) } - // Empty path -> nil, no error. if ks, err := featureKeyOrderFromFile(""); err != nil || ks != nil { t.Errorf("empty path: got %v, %v", ks, err) } @@ -398,21 +375,17 @@ func TestOrderedFeatureKeys_AppendsMissingSorted(t *testing.T) { cfg := filepath.Join(dir, "devcontainer.json") os.WriteFile(cfg, []byte(`{"features":{"b":{},"a":{}}}`), 0o644) - // 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]any{"a": struct{}{}, "b": struct{}{}, "z": struct{}{}} got := orderedFeatureKeys(cfg, feats) if len(got) != 3 { t.Fatalf("want 3 keys, got %v", got) } - // File order first (b, a), then the missing one appended. if got[0] != "b" || got[1] != "a" || got[2] != "z" { t.Errorf("order = %v, want [b a z]", got) } } func TestProxyEnvFromEnviron(t *testing.T) { - // Only the known proxy keys are captured; others are ignored. t.Setenv("HTTPS_PROXY", "http://proxy:8080") t.Setenv("NO_PROXY", "localhost") t.Setenv("UNRELATED_VAR", "x") @@ -438,8 +411,6 @@ func TestIsBareVersion(t *testing.T) { } } -// TestFindPlatformArg covers #1241: extracting --platform from runArgs in both -// "--platform=X" and "--platform X" forms. func TestFindPlatformArg(t *testing.T) { cases := []struct { args []string @@ -450,7 +421,7 @@ func TestFindPlatformArg(t *testing.T) { {[]string{"--cap-add=SYS_PTRACE", "--platform=linux/amd64", "--rm"}, "linux/amd64"}, {[]string{"--rm"}, ""}, {nil, ""}, - {[]string{"--platform"}, ""}, // dangling flag, no value + {[]string{"--platform"}, ""}, } for _, tc := range cases { if got := findPlatformArg(tc.args); got != tc.want { @@ -459,8 +430,6 @@ func TestFindPlatformArg(t *testing.T) { } } -// TestBuildSecretIDs covers #1078: extracting the KEY from each "KEY=VALUE" -// build secret for mounting as --mount=type=secret,id=KEY. func TestBuildSecretIDs(t *testing.T) { got := buildSecretIDs([]string{"NPM_TOKEN=abc", "GH_TOKEN=xyz=with=eq", "=novalue", "noeq"}) want := []string{"NPM_TOKEN", "GH_TOKEN"} diff --git a/internal/cli/read_configuration.go b/internal/cli/read_configuration.go index 6f01f5a..b1477af 100644 --- a/internal/cli/read_configuration.go +++ b/internal/cli/read_configuration.go @@ -19,10 +19,6 @@ import ( "github.com/spf13/cobra" ) -// 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, @@ -78,7 +74,6 @@ func newReadConfigurationCmd() *cobra.Command { f.StringVar(&opts.logLevel, "log-level", "info", "Log level.") f.StringVar(&opts.logFormat, "log-format", "text", "Log format.") - // Container-related flags for containerEnv substitution f.StringVar(&opts.dockerPath, "docker-path", "", "Docker CLI path.") f.String("docker-compose-path", "", "Docker Compose CLI path.") f.String("user-data-folder", "", "Host path to persisted data.") @@ -89,9 +84,6 @@ func newReadConfigurationCmd() *cobra.Command { f.StringVar(&opts.additionalFeatures, "additional-features", "", "Additional features JSON.") f.BoolVar(&opts.skipFeatureAutoMapping, "skip-feature-auto-mapping", false, "") cmd.Flags().MarkHidden("skip-feature-auto-mapping") - // Go-only: emit a deterministic content-addressed cache key alongside the - // configuration (for prebuild reuse by external orchestrators). Additive — the - // default output is unchanged from TS. f.BoolVar(&opts.cacheKey, "cache-key", false, "Also emit a deterministic content-addressed cache key for the configuration.") addLogFileFlags(cmd, &opts.logFile, &opts.terminalLogFile) @@ -132,11 +124,9 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) 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}) engine, engineErr := docker.NewEngineClient(lgr) if engineErr != nil { - // Logged here; a container lookup then fails the command (readConfigurationContainerID). lgr.Write(fmt.Sprintf("Warning: Docker engine: %v", engineErr), log.LevelWarning) } if engine != nil { @@ -163,8 +153,6 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) if result != nil { configFile = result.Config.ConfigFilePath } - // TS readConfiguration fails like any other error: logged, exit 1, nothing - // on stdout. cfgMap, subErr := substituteContainerEnv(containerCfg, cfgMap, configFile) if subErr != nil { return readConfigurationFailure(lgr, subErr) @@ -174,7 +162,6 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) "configuration": cfgMap, } - // Go-only: deterministic content-addressed cache key (additive; off by default). if opts.cacheKey && result != nil { key, keyErr := computeCacheKey(result, proxyEnvFromEnviron()) if keyErr != nil { @@ -187,11 +174,8 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) output["workspace"] = orderedObject{ws, workspaceKeys} } - // Include features configuration if requested (or needed for merged config without container) var featResult *fetchFeatureResult if featuresWanted(opts, containerID) { - // TS generateFeaturesConfig (containerFeaturesConfiguration.ts) traces - // the folder it resolves the Features from, the command's cwd. lgr.Write("workspace root: "+cmp.Or(workspaceFolder, cwd), log.LevelTrace) if result == nil || len(result.Config.Features) == 0 { lgr.Write("No user features to update", log.LevelTrace) @@ -202,8 +186,6 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) var featErr error featResult, featErr = fetchFeatureSets(ctx, lgr, result.Config.Features, localFeatureRoots{filepath.Dir(result.Config.ConfigFilePath), workspaceFolder}, 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. return readConfigurationFailure(lgr, featErr) } if featResult != nil { @@ -215,18 +197,9 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) } } - // Include merged configuration if requested if opts.includeMergedCfg { 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 { return readConfigurationFailure(lgr, biErr) @@ -250,8 +223,6 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) output["mergedConfiguration"] = merged } - // OCI auth diagnostics for this invocation (TS readConfiguration emits it - // unconditionally, even when no registry was contacted). output["ociAuthDiagnostics"] = ociAuthDiagnostics(ctx) orderConfigurations(output, result) @@ -264,17 +235,11 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts) return nil } -// readConfigurationFailure is how read-configuration fails, as TS -// readConfiguration does: the error is logged and the command exits 1 with no -// result. func readConfigurationFailure(lgr log.Logger, err error) error { lgr.Write(err.Error(), log.LevelError) return &coreerrors.ExitCodeError{Code: 1, Err: err} } -// 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) { @@ -289,26 +254,16 @@ func readConfigurationPaths(opts *readConfigOpts, cwd string) (workspaceFolder, return abs(workspaceFolder), abs(opts.configPath), abs(opts.overrideConfig) } -// readConfigurationHasWorkspace reports whether read-configuration has a -// workspace folder: --workspace-folder, else cwd unless --container-id or -// --id-label is given (TS readConfigurationOptions' check). func readConfigurationHasWorkspace(opts *readConfigOpts) bool { return opts.workspaceFolder != "" || opts.containerID == "" && len(opts.idLabels) == 0 } -// 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 { @@ -322,12 +277,6 @@ func loadReadConfiguration(workspaceFolder, configPath, overridePath string, opt return result, nil } -// 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 with --config. func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[string]any { cfgMap := map[string]any{} if result == nil { @@ -346,18 +295,10 @@ func configurationOutput(result *config.LoadResult, opts *readConfigOpts) map[st } cfgMap[k] = v } - // TS makes a file URI of --config, and without a workspace of - // --override-config, which is then the configuration (configPath = - // overrideConfigFile); a configuration found in the workspace, or its - // default path when only --override-config is given, has the - // vscode-fileHost scheme, with or without --container-id - // (devContainersSpecCLI.ts readConfiguration). scheme := "vscode-fileHost" if opts.configPath != "" || opts.overrideConfig != "" && !readConfigurationHasWorkspace(opts) { scheme = "file" } - // TS findContainerAndIdLabels reads the URI's fsPath (devContainersSpecCLI.ts - // readConfiguration), so read-configuration always writes it. cfgMap["configFilePath"] = configFileURI(result.Config.ConfigFilePath, scheme, true) return cfgMap } @@ -378,11 +319,6 @@ func configFileURI(path, scheme string, withFSPath bool) map[string]any { return uri } -// readConfigurationContainerID is the container read-configuration reads: the -// --container-id, else the one the id labels or the workspace folder and -// config file find. A lookup that fails, also for want of a Docker engine -// (lister is then one that fails with engineErr), fails the command, as TS's -// findContainerAndIdLabels throws. func readConfigurationContainerID(ctx context.Context, lister containerLister, opts *readConfigOpts, workspaceFolder string, result *config.LoadResult) (string, error) { if opts.containerID != "" { return opts.containerID, nil @@ -391,14 +327,9 @@ func readConfigurationContainerID(ctx context.Context, lister containerLister, o 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, lister, workspaceFolder, configFile, opts.idLabels) } -// inspectContainerConfig is the config of container cid, nil when there is no -// container to inspect. A container that cannot be inspected is an error, as -// TS findContainerAndIdLabels' inspectContainer throws. func inspectContainerConfig(ctx context.Context, engine *docker.EngineClient, cid string) (*container.Config, error) { if cid == "" { return nil, nil @@ -413,10 +344,6 @@ func inspectContainerConfig(ctx context.Context, engine *docker.EngineClient, ci return inspect.Config, nil } -// substituteDevContainerID resolves ${devcontainerId} in cfgMap from the id -// labels of the container read-configuration found, as TS readConfiguration -// adds beforeContainerSubstitute(idLabels) before the container's environment; -// without a container cfgMap is returned as it is. func substituteDevContainerID(containerCfg *container.Config, cfgMap map[string]any, providedIDLabels []string, workspaceFolder string, result *config.LoadResult) (map[string]any, error) { if containerCfg == nil { return cfgMap, nil @@ -435,10 +362,6 @@ func substituteDevContainerID(containerCfg *container.Config, cfgMap map[string] return substituted.(map[string]any), nil } -// substituteContainerEnv resolves ${containerEnv:...} in cfgMap with the -// container's environment; without a container cfgMap is returned as it is. -// configFile is the configuration's file, which an unresolvable variable's -// error names. func substituteContainerEnv(containerCfg *container.Config, cfgMap map[string]any, configFile string) (map[string]any, error) { if containerCfg == nil { return cfgMap, nil @@ -453,10 +376,6 @@ func substituteContainerEnv(containerCfg *container.Config, cfgMap map[string]an return cfgMap, nil } -// workspaceOutput is read-configuration's "workspace"; ok is false when the -// configuration gives none. Without a workspace folder it has only the -// configuration's own workspaceFolder and workspaceMount, as TS -// getWorkspaceConfiguration leaves them for an undefined workspace. func workspaceOutput(result *config.LoadResult) (ws map[string]any, ok bool) { if result == nil || result.WorkspaceConfig == nil { return nil, false @@ -465,41 +384,25 @@ func workspaceOutput(result *config.LoadResult) (ws map[string]any, ok bool) { if result.WorkspaceConfig.WorkspaceFolder != "" { ws["workspaceFolder"] = result.WorkspaceConfig.WorkspaceFolder } - // A workspaceMount the configuration sets is written as it is set, even - // empty or null (TS readDevContainerConfigFile's `'workspaceMount' in - // config`); otherwise the computed one, if any. if v, set := result.Raw["workspaceMount"]; set { ws["workspaceMount"] = v } else if result.WorkspaceConfig.WorkspaceMount != "" { ws["workspaceMount"] = result.WorkspaceConfig.WorkspaceMount } - // The loader's additional mounts are at most the Git worktree common dir's, - // TS's additionalMountString (getWorkspaceConfiguration). if mounts := result.WorkspaceConfig.AdditionalMounts; len(mounts) > 0 { ws["additionalMountString"] = mounts[0] } 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 { return featuresWanted(opts, containerID) && result != nil && len(result.Config.Features) > 0 } -// featuresWanted reports whether TS readConfiguration reads the Features -// configuration, which it does even for a configuration with no Features. func featuresWanted(opts *readConfigOpts, containerID string) bool { return opts.includeFeaturesCfg || (opts.includeMergedCfg && containerID == "") } -// readConfigurationMetadata is the metadata read-configuration merges. With a -// container it is TS getImageMetadataFromContainer: the container's -// devcontainer.metadata entries, substituted, then the configuration's -// updateable properties for a container with every id label, else the -// Features' entries and the configuration's entry. Without one it is base (the -// image's and the Features' metadata) and the configuration's entry. func readConfigurationMetadata(base, featureEntries []imagemeta.Entry, containerCfg *container.Config, result *config.LoadResult, providedIDLabels []string, workspaceFolder string, lgr log.Logger) ([]imagemeta.Entry, error) { var cfg *config.DevContainer hostSub, configFile := config.EnvHostSubContext(), "" @@ -516,12 +419,6 @@ func readConfigurationMetadata(base, featureEntries []imagemeta.Entry, container return imageMetadataFromContainer(containerCfg.Labels, cfg, featureEntries, idLabels, hostMetadataSubstitution(hostSub, idLabels), lgr) } -// mergedConfigurationOutput is read-configuration's "mergedConfiguration": the -// merge of entries (readConfigurationMetadata). 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(entries []imagemeta.Entry, containerCfg *container.Config, cfgMap map[string]any, configFile string) (map[string]any, error) { merged := imagemeta.MergeConfiguration(entries) @@ -553,9 +450,6 @@ func mergedConfigurationOutput(entries []imagemeta.Entry, containerCfg *containe return mergedMap, 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 { @@ -571,12 +465,6 @@ func readConfigurationImageBuildInfo(ctx context.Context, lgr log.Logger, engine return getImageBuildInfo(ctx, cfg, newImageInspector(ctx, lgr, engine), composeConfig, lgr) } -// configToMetadataEntry converts a DevContainer to a metadata Entry -// for merging. When omitRemoteEnv is true, remoteEnv is excluded from the -// entry (used for image metadata labels with --omit-config-remote-env-from-metadata). -// configToMetadataEntry builds the devcontainer.metadata entry for cfg, -// including its remoteEnv. Use configMetadataEntry when remoteEnv must be -// omitted (a runtime decision on some up/build paths). func configToMetadataEntry(cfg *config.DevContainer) imagemeta.Entry { return configMetadataEntry(cfg, false) } @@ -598,9 +486,6 @@ func configMetadataEntry(cfg *config.DevContainer, omitRemoteEnv bool) imagemeta Mounts: metadataMounts(cfg), Customizations: cfg.Customizations, } - // Port and host-requirement properties are part of the config metadata entry - // (pickConfigProperties in TS). Omitting them made the devcontainer.metadata - // label lossy vs the TS CLI. if len(cfg.ForwardPorts) > 0 { fp := make([]any, len(cfg.ForwardPorts)) for i, p := range cfg.ForwardPorts { @@ -624,7 +509,6 @@ func configMetadataEntry(cfg *config.DevContainer, omitRemoteEnv bool) imagemeta if cfg.RemoteEnv != nil && !omitRemoteEnv { entry.RemoteEnv = cfg.RemoteEnv } - // Lifecycle commands if !cfg.OnCreateCommand.IsEmpty() { entry.OnCreateCommand = cfg.OnCreateCommand.Raw() } diff --git a/internal/cli/read_configuration_held_test.go b/internal/cli/read_configuration_held_test.go index 5cd6a9c..9d2fabd 100644 --- a/internal/cli/read_configuration_held_test.go +++ b/internal/cli/read_configuration_held_test.go @@ -24,8 +24,6 @@ import ( "github.com/devcontainers/cli/internal/stringify" ) -// 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} @@ -127,20 +125,15 @@ func TestConfigurationOutput(t *testing.T) { t.Errorf("configFilePath = %v, want %v", got["configFilePath"], want) } - // TS makes a file URI of --config only; the configuration found in the - // workspace keeps the vscode-fileHost scheme with --override-config or - // --container-id. for name, tc := range map[string]struct { opts readConfigOpts scheme string }{ - "--config": {readConfigOpts{configPath: cfp}, "file"}, - "--override-config": {readConfigOpts{overrideConfig: cfp}, "vscode-fileHost"}, - "--container-id": {readConfigOpts{containerID: "abc"}, "vscode-fileHost"}, - // Without a workspace the override is the configuration, a file URI - // (configPath = overrideConfigFile). - "--override-config, --container-id": {readConfigOpts{overrideConfig: cfp, containerID: "abc"}, "file"}, - "--override-config, --id-label": {readConfigOpts{overrideConfig: cfp, idLabels: []string{"a=b"}}, "file"}, + "--config": {readConfigOpts{configPath: cfp}, "file"}, + "--override-config": {readConfigOpts{overrideConfig: cfp}, "vscode-fileHost"}, + "--container-id": {readConfigOpts{containerID: "abc"}, "vscode-fileHost"}, + "--override-config, --container-id": {readConfigOpts{overrideConfig: cfp, containerID: "abc"}, "file"}, + "--override-config, --id-label": {readConfigOpts{overrideConfig: cfp, idLabels: []string{"a=b"}}, "file"}, "--override-config, --container-id, --workspace-folder": {readConfigOpts{overrideConfig: cfp, containerID: "abc", workspaceFolder: "/p"}, "vscode-fileHost"}, } { got := configurationOutput(result, &tc.opts) @@ -183,8 +176,6 @@ func TestReadConfigurationContainerID(t *testing.T) { if got, err := readConfigurationContainerID(ctx, engine, &readConfigOpts{containerID: "given"}, ws, result); err != nil || got != "given" || len(api.listed) != 0 { t.Errorf("--container-id = %q (listed %v), want it as given without a lookup", got, api.listed) } - // A lookup that fails fails the command, as TS's findContainerAndIdLabels - // throws: without an engine too, though --container-id needs no lookup. noEngine := failingLister{errors.New("no engine")} if got, err := readConfigurationContainerID(ctx, noEngine, &readConfigOpts{}, ws, result); got != "" || err == nil || !strings.HasPrefix(err.Error(), "Command failed: docker ps -q -a --filter label=devcontainer.local_folder=/some/workspace") { t.Errorf("without an engine = %q, %v; want the failed lookup", got, err) @@ -197,8 +188,6 @@ func TestReadConfigurationContainerID(t *testing.T) { } } -// A container read-configuration cannot inspect fails it, as TS's -// inspectContainer throws; with no container there is nothing to inspect. func TestInspectContainerConfig(t *testing.T) { ctx := context.Background() api := &fakeEngineAPI{containers: map[string]string{ @@ -232,7 +221,6 @@ func TestSubstituteContainerEnv(t *testing.T) { 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) } - // A reference without a name is TS's ContainerError naming the file. _, err = substituteContainerEnv(&container.Config{}, map[string]any{"x": "${containerEnv}"}, "/ws/c.json") want := "'${containerEnv}' in c.json can not be resolved because no environment variable name is given." if ce, ok := coreerrors.AsContainerError(err); !ok || ce.Description != want { @@ -240,9 +228,6 @@ func TestSubstituteContainerEnv(t *testing.T) { } } -// TS readConfiguration resolves ${devcontainerId} in the configuration from -// the found container's id labels (beforeContainerSubstitute); without a -// container it stays as written. func TestSubstituteDevContainerID(t *testing.T) { cfgMap := map[string]any{"name": "${devcontainerId}", "remoteEnv": map[string]any{"P": "${containerEnv:PATH}"}} got, err := substituteDevContainerID(nil, cfgMap, nil, "", nil) @@ -278,8 +263,6 @@ 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"}} worktree := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{WorkspaceFolder: "/workspaces/wt", WorkspaceMount: "type=bind,source=/r/wt,target=/workspaces/wt", AdditionalMounts: []string{"type=bind,source=/r/repo/.git,target=/workspaces/repo/.git"}}} - // Without a workspace the loader leaves the configuration's own folder and - // mount, possibly empty: TS writes what is set (undefined is left out). noWorkspace := &config.LoadResult{WorkspaceConfig: &config.WorkspaceConfig{}} for _, tc := range []struct { name string @@ -303,7 +286,6 @@ func TestWorkspaceOutput(t *testing.T) { } } -// TS writes the workspace as getWorkspaceConfiguration's literal lists it. func TestWorkspaceOutputKeepsTSOrder(t *testing.T) { got, err := stringify.JSON(orderedObject{map[string]any{"additionalMountString": "a", "workspaceMount": "m", "workspaceFolder": "f"}, workspaceKeys}, "") if err != nil { @@ -397,8 +379,6 @@ func TestMergedConfigurationOutput(t *testing.T) { }) t.Run("with a container the CLI created", func(t *testing.T) { - // Its label records the configuration's entry: only the configuration's - // updateable properties follow, so its commands are not merged twice. cc := &container.Config{Labels: map[string]string{ "devcontainer.metadata": `[{"postStartCommand":"from-config"}]`, "devcontainer.local_folder": "/ws", @@ -467,13 +447,8 @@ func TestConfigMetadataEntry(t *testing.T) { } } -// 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() - // read-configuration looks for the container (TS findContainerAndIdLabels), - // and a lookup that fails fails the command, so an engine with no - // containers answers it. startFakeEngine(t, nil) stdout, err := readConfigurationStdout(args...) if err != nil { @@ -486,8 +461,6 @@ func runReadConfigurationCmd(t *testing.T, args ...string) (map[string]any, erro return out, nil } -// readConfigurationStdout runs read-configuration with args and returns its -// stdout as written, or the command's error. func readConfigurationStdout(args ...string) (string, error) { var stdout bytes.Buffer root := NewRootCommand() @@ -506,8 +479,6 @@ func wantExitCode(t *testing.T, err error, code int) { } } -// 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", @@ -519,8 +490,6 @@ func TestReadConfigurationCommandWithoutConfigExitsOne(t *testing.T) { } func TestReadConfigurationCommandContainerIDWithoutWorkspace(t *testing.T) { - // --container-id alone loads no configuration; a container that cannot be - // inspected fails the command with no result, as TS's inspectContainer throws. _, err := runReadConfigurationCmd(t, "--container-id", "devcontainer-cli-test-no-such-container") wantExitCode(t, err, 1) if !strings.Contains(err.Error(), "inspect container devcontainer-cli-test-no-such-container") { @@ -528,10 +497,6 @@ func TestReadConfigurationCommandContainerIDWithoutWorkspace(t *testing.T) { } } -// Without a workspace folder (--id-label, no --workspace-folder), TS reads -// --override-config as the configuration (configPath = overrideConfigFile): -// it is printed, its configFilePath the override's file URI. Go printed an -// empty configuration. func TestReadConfigurationCommandOverrideConfigWithoutWorkspace(t *testing.T) { override := filepath.Join(t.TempDir(), "override.json") if err := os.WriteFile(override, []byte(`{"image":"img:1","name":"${localWorkspaceFolderBasename}"}`), 0o644); err != nil { @@ -554,10 +519,6 @@ func TestReadConfigurationCommandOverrideConfigWithoutWorkspace(t *testing.T) { } } -// Without a workspace folder, TS getWorkspaceConfiguration keeps the -// configuration's own workspaceFolder and workspaceMount, which -// read-configuration prints as the workspace. Go printed an empty workspace -// with --container-id, and an empty workspaceFolder without one. func TestReadConfigurationCommandWorkspaceWithoutWorkspaceFolder(t *testing.T) { cfg := filepath.Join(t.TempDir(), "devcontainer.json") if err := os.WriteFile(cfg, []byte(`{"image":"img:1","workspaceFolder":"/wf","workspaceMount":"type=bind,source=/a,target=/wf"}`), 0o644); err != nil { @@ -573,9 +534,6 @@ func TestReadConfigurationCommandWorkspaceWithoutWorkspaceFolder(t *testing.T) { } } -// An unresolvable ${containerEnv} fails read-configuration as TS's -// ContainerError does: logged, naming the configuration's file, exit 1 and -// nothing on stdout. func TestReadConfigurationCommandUnresolvableContainerVariable(t *testing.T) { startFakeEngine(t, map[string]map[string]string{"c1": {}}) cfg := filepath.Join(t.TempDir(), "c.json") @@ -597,10 +555,6 @@ func TestReadConfigurationCommandUnresolvableContainerVariable(t *testing.T) { } } -// --cache-key over a malformed devcontainer-lock.json is a runtime failure: -// the error envelope on stdout naming the lockfile, exit 1. Without the flag -// the command does not read the lockfile, as the reference's -// read-configuration does not, and succeeds. func TestReadConfigurationCacheKeyFailsOnAMalformedLockfile(t *testing.T) { ws := writeConfigFixture(t, map[string]string{ ".devcontainer/devcontainer.json": `{"image": "img:1"}`, @@ -626,9 +580,6 @@ func TestReadConfigurationCacheKeyFailsOnAMalformedLockfile(t *testing.T) { } } -// --cache-key over a Dockerfile configuration whose Dockerfile is missing -// fails with the error result naming the file; without the flag the command -// does not read the Dockerfile, as the reference's does not, and succeeds. func TestReadConfigurationCacheKeyFailsOnAMissingDockerfile(t *testing.T) { ws := writeConfigFixture(t, map[string]string{ ".devcontainer/devcontainer.json": `{"build": {"dockerfile": "Dockerfile"}}`, @@ -695,8 +646,6 @@ func TestReadConfigurationCommandUnresolvableFeatureExitsOne(t *testing.T) { } 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", @@ -731,9 +680,6 @@ func TestReadConfigurationCommandMergedWithoutImageExitsOne(t *testing.T) { } func TestReadConfigurationCommandWithoutDockerEngine(t *testing.T) { - // Without a Docker engine the container lookup fails, and so does the - // command, with nothing on stdout: TS's findContainerAndIdLabels runs - // `docker ps`, whose failure exits read-configuration 1. 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") @@ -753,9 +699,6 @@ func TestReadConfigurationCommandWithoutDockerEngine(t *testing.T) { } } -// A container lookup by --id-label that fails exits 1 with nothing on stdout, -// as TS's findDevContainer `docker ps` failure does. Go printed the -// configuration and exited 0. func TestReadConfigurationCommandFailedIDLabelLookup(t *testing.T) { t.Setenv("DOCKER_HOST", "unix:///nonexistent/docker.sock") cfg := filepath.Join(t.TempDir(), "devcontainer.json") @@ -772,13 +715,8 @@ func TestReadConfigurationCommandFailedIDLabelLookup(t *testing.T) { } } -// Reading the Features configuration writes TS generateFeaturesConfig's trace -// lines (containerFeaturesConfiguration.ts): the workspace root, then, with no -// Features, that there are no user Features. Without a request for the -// Features nothing is read. func TestReadConfigurationCommandTracesReadingTheFeatures(t *testing.T) { t.Setenv("DOCKER_HOST", "not a docker host") - // traced is the log's lines without their timestamps. traced := func(ws string, args ...string) []string { t.Helper() logFile := filepath.Join(t.TempDir(), "log.txt") diff --git a/internal/cli/read_configuration_nodocker_held_test.go b/internal/cli/read_configuration_nodocker_held_test.go index 9df1f71..76de93f 100644 --- a/internal/cli/read_configuration_nodocker_held_test.go +++ b/internal/cli/read_configuration_nodocker_held_test.go @@ -8,12 +8,6 @@ import ( "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", @@ -45,7 +39,6 @@ func TestConfigurationOutputKeepsUnmigratedLegacyProperties(t *testing.T) { } } -// 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 { diff --git a/internal/cli/result_order.go b/internal/cli/result_order.go index 0142ed4..cdd1a2d 100644 --- a/internal/cli/result_order.go +++ b/internal/cli/result_order.go @@ -9,10 +9,6 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// orderedObject writes fields as a JSON object in the order TS writes it: -// JSON.stringify follows property insertion order, which order gives. The keys -// order names come first, each once, then any other key sorted, as json.Marshal -// writes a map (a Go-only property, such as read-configuration's cacheKey). type orderedObject struct { fields map[string]any order []string @@ -49,42 +45,26 @@ func (o orderedObject) MarshalJSON() ([]byte, error) { return out.Bytes(), nil } -// The order of each command's result, as the TS object literal that builds it -// lists its properties. var ( - // doProvision: outcome, then launch's result (devContainers.ts). - upResultKeys = []string{"outcome", "containerId", "composeProjectName", "remoteUser", "remoteWorkspaceFolder", "configuration", "mergedConfiguration", "ociAuthDiagnostics"} - // doBuild. - buildResultKeys = []string{"outcome", "imageName", "ociAuthDiagnostics"} - // doSetUp. - setUpResultKeys = []string{"outcome", "configuration", "mergedConfiguration"} - // doRunUserCommands. + upResultKeys = []string{"outcome", "containerId", "composeProjectName", "remoteUser", "remoteWorkspaceFolder", "configuration", "mergedConfiguration", "ociAuthDiagnostics"} + buildResultKeys = []string{"outcome", "imageName", "ociAuthDiagnostics"} + setUpResultKeys = []string{"outcome", "configuration", "mergedConfiguration"} runUserCommandsResultKeys = []string{"outcome", "result"} - // readConfiguration. - readConfigurationKeys = []string{"configuration", "workspace", "featuresConfiguration", "mergedConfiguration", "ociAuthDiagnostics"} - // getWorkspaceConfiguration's WorkspaceConfiguration (utils.ts). - workspaceKeys = []string{"workspaceFolder", "workspaceMount", "additionalMountString"} - // generateFeaturesConfig's FeaturesConfig. + readConfigurationKeys = []string{"configuration", "workspace", "featuresConfiguration", "mergedConfiguration", "ociAuthDiagnostics"} + workspaceKeys = []string{"workspaceFolder", "workspaceMount", "additionalMountString"} featuresConfigurationKeys = []string{"featureSets", "dstFolder"} - // The properties mergeConfiguration (imageMetadata.ts) sets after spreading - // the configuration without its replaceProperties. - mergedConfigurationKeys = []string{ + mergedConfigurationKeys = []string{ "init", "privileged", "capAdd", "securityOpt", "entrypoints", "mounts", "customizations", "onCreateCommands", "updateContentCommands", "postCreateCommands", "postStartCommands", "postAttachCommands", "waitFor", "remoteUser", "containerUser", "userEnvProbe", "remoteEnv", "containerEnv", "overrideCommand", "portsAttributes", "otherPortsAttributes", "forwardPorts", "shutdownAction", "updateRemoteUserUID", "hostRequirements", } - // mergeConfiguration's replaceProperties, which it deletes from the spread - // configuration. mergedReplacedKeys = []string{ "customizations", "entrypoint", "onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand", "postAttachCommand", "shutdownAction", } ) -// configurationKeys is the order of a configuration's properties: its -// document's, then customizations, which updateFromOldProperties adds when it -// moves a deprecated property, then the configFilePath TS sets last. func configurationKeys(result *config.LoadResult) []string { var keys []string if result != nil { @@ -93,10 +73,6 @@ func configurationKeys(result *config.LoadResult) []string { return append(keys, "customizations", "configFilePath") } -// mergedKeys is the order of a merged configuration's properties: the -// configuration's, without the ones mergeConfiguration replaces, then the ones -// it sets; a property the configuration already has keeps its place, as in a -// JavaScript object literal. func mergedKeys(configurationKeys []string) []string { keys := slices.DeleteFunc(slices.Clone(configurationKeys), func(k string) bool { return slices.Contains(mergedReplacedKeys, k) @@ -104,8 +80,6 @@ func mergedKeys(configurationKeys []string) []string { return append(keys, mergedConfigurationKeys...) } -// orderConfigurations gives result's configuration and mergedConfiguration, -// when it has them as objects, their TS order. func orderConfigurations(result map[string]any, loaded *config.LoadResult) { cfgKeys := configurationKeys(loaded) if m, ok := result["configuration"].(map[string]any); ok { diff --git a/internal/cli/result_order_test.go b/internal/cli/result_order_test.go index b161ed4..71f7ddc 100644 --- a/internal/cli/result_order_test.go +++ b/internal/cli/result_order_test.go @@ -8,13 +8,7 @@ import ( "github.com/devcontainers/cli/internal/config" ) -// read-configuration writes its result, the configuration and the Features -// configuration in TS's order (readConfiguration's object literal, the -// document's order with the deprecated properties' customizations and then -// configFilePath last, FeaturesConfig's literal), as the TS oracle printed -// for these documents. func TestReadConfigurationWritesTSKeyOrder(t *testing.T) { - // read-configuration looks for the container; this engine has none. startFakeEngine(t, nil) files := map[string]string{".devcontainer/devcontainer.json": `{ // a comment @@ -56,10 +50,6 @@ func TestReadConfigurationWritesTSKeyOrder(t *testing.T) { } } -// A merged configuration has the configuration's properties but the ones -// mergeConfiguration replaces, each in its place, then the ones it sets, as TS -// printed for up and set-up with this configuration; customizations moves to -// mergeConfiguration's place. Unknown keys follow, sorted. func TestOrderConfigurationsWritesTSMergedOrder(t *testing.T) { loaded := &config.LoadResult{KeyOrder: []string{"name", "customizations", "image", "remoteUser", "postCreateCommand", "containerEnv"}} fields := func(keys ...string) map[string]any { @@ -94,8 +84,6 @@ func TestOrderConfigurationsWritesTSMergedOrder(t *testing.T) { } } -// A value encoding/json cannot write is the result's error, not a truncated -// result. func TestOrderedObjectReportsAValueItCannotWrite(t *testing.T) { if _, err := json.Marshal(orderedObject{map[string]any{"x": make(chan int)}, nil}); err == nil { t.Error("a channel value was written") diff --git a/internal/cli/root.go b/internal/cli/root.go index 08c396f..6baa481 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" ) -// NewRootCommand creates the root `devcontainer` command with all subcommands. func NewRootCommand() *cobra.Command { cfg := product.Get() @@ -21,17 +20,11 @@ func NewRootCommand() *cobra.Command { Use: cfg.Name, Short: "Dev Container CLI", Version: cfg.Version, - // Disable default completion command CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, }, - // Match yargs: boolean-negation disabled, strict mode SilenceUsage: true, SilenceErrors: true, - // Validate the global OCI auth flags before any subcommand runs and stash - // the resulting policy on the context (yargs `.check()` equivalent). - // `exec` parses its own flags (DisableFlagParsing), so it applies the - // policy itself once its flags are parsed. PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if cmd.DisableFlagParsing { return nil @@ -40,13 +33,10 @@ func NewRootCommand() *cobra.Command { }, } - // Print just the bare version (e.g. "0.74.0"), matching the TS CLI (yargs - // .version()) instead of Cobra's " version " template. root.SetVersionTemplate("{{.Version}}\n") addOCIAuthFlags(root) - // Register subcommands root.AddCommand( newReadConfigurationCmd(), newBuildCmd(), @@ -68,11 +58,6 @@ func NewRootCommand() *cobra.Command { return root } -// 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). diff --git a/internal/cli/run_user_commands.go b/internal/cli/run_user_commands.go index 666cfea..2fa61dd 100644 --- a/internal/cli/run_user_commands.go +++ b/internal/cli/run_user_commands.go @@ -75,7 +75,7 @@ func newRunUserCommandsCmd() *cobra.Command { f.StringVar(&opts.defaultUserEnvProbe, "default-user-env-probe", "loginInteractiveShell", "") f.Bool("stop-for-personalization", false, "") f.Bool("skip-feature-auto-mapping", false, "") - _ = f.MarkHidden("skip-feature-auto-mapping") // hidden testing flag (TS parity) + _ = f.MarkHidden("skip-feature-auto-mapping") f.StringVar(&opts.containerSessionDataFolder, "container-session-data-folder", "", "") f.StringVar(&opts.secretsFile, "secrets-file", "", "") f.String("user-data-folder", "", "") @@ -105,7 +105,6 @@ func runUserCommands(ctx context.Context, out Output, opts *runUserCommandsOpts) Secrets: secretValuesFromFile(opts.secretsFile), }) - // Try to load config first — provides better error messages var loadResult *config.LoadResult if opts.workspaceFolder != "" { var loadErr error @@ -120,9 +119,6 @@ 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 @@ -143,9 +139,6 @@ func prepareRunUserCommandsOpts(opts *runUserCommandsOpts) error { 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, @@ -163,9 +156,6 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo containerID := opts.containerID if containerID == "" { - // TS looks the container up by the workspace folder and the - // configuration's file (findContainerAndIdLabels); a failed lookup is - // its error, described as running the user commands. lookupConfigFile := "" if loadResult != nil { lookupConfigFile = loadResult.Config.ConfigFilePath @@ -187,14 +177,10 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo cfg = configFromFlag(opts.configPath, opts.overrideConfig) } - // Read metadata from container inspect, err := engine.InspectContainer(ctx, containerID) if err != nil { return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) } - // The merged configuration is the container's metadata (TS - // getImageMetadataFromContainer), which records the configuration's entry - // for a container the CLI created. hostSub, configFile := config.EnvHostSubContext(), "" if loadResult != nil { hostSub, configFile = loadResult.HostSub, loadResult.Config.ConfigFilePath @@ -209,10 +195,6 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo return writeErrorResult(out, err.Error()) } merged := imagemeta.MergeConfiguration(allEntries) - // TS doRunUserCommands substitutes the container's environment into the - // configuration and the merged configuration before it runs anything; a - // variable that cannot be resolved is the ContainerError it reports, - // naming the configuration's file. var rawConfig any = cfg if loadResult != nil { rawConfig = loadResult.Raw @@ -254,7 +236,6 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo }) if hookErr != nil { - // 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."))) } @@ -264,10 +245,6 @@ func runUserCommandsInContainer(ctx context.Context, out Output, opts *runUserCo }, runUserCommandsResultKeys...) } -// 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 @@ -279,8 +256,6 @@ func configFromFlag(configPath, overrideConfig string) *config.DevContainer { 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 @@ -288,8 +263,6 @@ func lifecycleRemoteUser(merged *imagemeta.MergedConfig, cfg *config.DevContaine 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) @@ -297,10 +270,6 @@ func lifecycleUserEnvProbe(defaultProbe string, cfg *config.DevContainer) lifecy 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 { diff --git a/internal/cli/run_user_commands_held_test.go b/internal/cli/run_user_commands_held_test.go index 1f96155..4c577c7 100644 --- a/internal/cli/run_user_commands_held_test.go +++ b/internal/cli/run_user_commands_held_test.go @@ -45,8 +45,6 @@ func TestPrepareRunUserCommandsOptsRejectsInvalidFlags(t *testing.T) { } } -// 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) @@ -96,7 +94,7 @@ func TestRunUserCommands_InvalidFlag_PreDocker(t *testing.T) { func TestRunUserCommands_ConfigLoadError_PreDocker(t *testing.T) { opts := validRunUserCommandsOpts() - opts.workspaceFolder = t.TempDir() // no devcontainer.json + opts.workspaceFolder = t.TempDir() out := &captureOutput{} err := runUserCommands(t.Context(), out, opts) ece := asExitCode(t, err) @@ -111,10 +109,6 @@ func TestRunUserCommands_ConfigLoadError_PreDocker(t *testing.T) { } } -// A container lookup that fails is TS's error: the failed `docker ps` by the -// workspace folder's and the configuration's labels as the message, running -// the user commands as the description. Go reported "Dev container not -// found.". func TestRunUserCommandsFailedContainerLookup(t *testing.T) { t.Setenv("DOCKER_HOST", "unix:///nonexistent/devcontainer-cli-test.sock") opts := validRunUserCommandsOpts() @@ -190,8 +184,6 @@ func TestLifecycleUserEnvProbe(t *testing.T) { } } -// 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"} diff --git a/internal/cli/seams_test.go b/internal/cli/seams_test.go index e0e6ec3..4ca8921 100644 --- a/internal/cli/seams_test.go +++ b/internal/cli/seams_test.go @@ -14,8 +14,6 @@ import ( "github.com/devcontainers/cli/internal/oci" ) -// captureOutput is a test Output that routes both streams into buffers, letting -// tests assert on command output without swapping the global os.Stdout. type captureOutput struct { out bytes.Buffer err bytes.Buffer @@ -24,18 +22,15 @@ type captureOutput struct { func (c *captureOutput) Stdout() io.Writer { return &c.out } func (c *captureOutput) Stderr() io.Writer { return &c.err } -// fakeRegistry is a hand-written oci.Registry double. PushArtifact fails for any -// resource whose id is in failIDs, exercising the partial-publish error path in -// publishCollectionWith without contacting a real registry. type fakeRegistry struct { - published map[string][]string // resource -> already-published tags - tagListErr map[string]error // resource -> error to return from GetPublishedTags* + published map[string][]string + tagListErr map[string]error failIDs map[string]bool - 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 + pushed []string + pushedTags map[string][]string + collectionPush int + collectionRef string + failCollection bool } func (f *fakeRegistry) FetchManifest(ref *oci.Ref, expectedDigest string) (*oci.ManifestContainer, error) { @@ -79,8 +74,6 @@ func (f *fakeRegistry) PushCollectionMetadata(ctx context.Context, ref *oci.Ref, return &oci.PushResult{Digest: "sha256:cafef00d"}, nil } -// writeSeamFeature writes a minimal feature source folder (metadata + install -// script) under /. func writeSeamFeature(t *testing.T, srcDir, id, version string) { t.Helper() writeFeature(t, srcDir, id, fmt.Sprintf(`{"id":%q,"version":%q,"name":%q}`, id, version, id)) @@ -89,11 +82,6 @@ func writeSeamFeature(t *testing.T, srcDir, id, version string) { } } -// 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") diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index 3adfaa5..ca2404c 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -7,7 +7,6 @@ import ( "strings" ) -// readSecretsFile reads a JSON file {"KEY": "VALUE", ...} and returns ["KEY=VALUE", ...]. func readSecretsFile(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { @@ -24,9 +23,6 @@ func readSecretsFile(path string) ([]string, error) { return envs, nil } -// secretValuesFromFile returns just the secret values from a secrets file, for -// log masking. Best-effort: returns nil on any error (the eventual load of the -// same file surfaces real problems). func secretValuesFromFile(path string) []string { if path == "" { return nil @@ -44,9 +40,6 @@ func secretValuesFromFile(path string) []string { return values } -// buildSecretsFromFile reads build secrets ("KEY=VALUE") from a --secrets-file -// path, or nil when unset/unreadable (a read error is non-fatal — the build -// proceeds without secrets, matching the main build path). func buildSecretsFromFile(path string) []string { if path == "" { return nil @@ -58,8 +51,6 @@ func buildSecretsFromFile(path string) []string { return secrets } -// buildSecretIDs extracts the secret id (the KEY of each "KEY=VALUE") so it can -// be mounted into a feature-install RUN as --mount=type=secret,id=KEY. func buildSecretIDs(secrets []string) []string { ids := make([]string, 0, len(secrets)) for _, s := range secrets { diff --git a/internal/cli/secrets_held_test.go b/internal/cli/secrets_held_test.go index bcd5ecf..0aac15e 100644 --- a/internal/cli/secrets_held_test.go +++ b/internal/cli/secrets_held_test.go @@ -7,8 +7,6 @@ import ( "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") diff --git a/internal/cli/secrets_repo_held_test.go b/internal/cli/secrets_repo_held_test.go index 5c70955..48e7dac 100644 --- a/internal/cli/secrets_repo_held_test.go +++ b/internal/cli/secrets_repo_held_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -// --secrets-file feeds the build its secrets as KEY=VALUE; no file, an -// unreadable one or a malformed one gives the build none. func TestBuildSecretsFromFile(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "secrets.json") @@ -31,7 +29,6 @@ func TestBuildSecretsFromFile(t *testing.T) { } } -// A one-letter secret name is still a name: only an empty one is dropped. func TestBuildSecretIDsOneLetterName(t *testing.T) { if got, want := buildSecretIDs([]string{"A=1", "=2"}), []string{"A"}; !slices.Equal(got, want) { t.Errorf("ids = %q, want %q", got, want) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 15e8c36..3627aef 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -77,8 +77,6 @@ func newSetUpCmd() *cobra.Command { return cmd } -// 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") @@ -93,8 +91,6 @@ func runSetUp(ctx context.Context, out Output, opts *setUpOpts) 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()) } @@ -118,7 +114,6 @@ func runSetUp(ctx context.Context, out Output, opts *setUpOpts) error { } defer engine.Close() - // TS doSetUp reads the --config file before it looks for the container. loaded, err := setUpConfig(opts.configPath) if err != nil { return writeErrorJSON(out, setUpErrorOutput(err)) @@ -131,9 +126,6 @@ func runSetUp(ctx context.Context, out Output, opts *setUpOpts) error { return setUpContainer(ctx, out, engine, logger, opts, loaded, inspect) } -// setUpErrorOutput is TS doSetUp's error result: a ContainerError as it is -// (a NotObjectError is one in TS), any other error described as "An error -// occurred running user commands in the container.". func setUpErrorOutput(err error) coreerrors.ErrorOutput { if _, ok := errors.AsType[*config.NotObjectError](err); ok { return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: err.Error()}) @@ -144,9 +136,6 @@ func setUpErrorOutput(err error) coreerrors.ErrorOutput { return coreerrors.ToErrorOutput(&coreerrors.ContainerError{Description: "An error occurred running user commands in the container.", OriginalError: err}) } -// 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. @@ -168,9 +157,6 @@ func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient 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)...) @@ -189,7 +175,6 @@ func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient }), }) 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."))) } } @@ -198,11 +183,6 @@ func setUpContainer(ctx context.Context, out Output, engine *docker.EngineClient return writeSuccessJSON(out, result, setUpResultKeys...) } -// setUpConfig loads the --config file set-up merges over the container's -// metadata (nil without --config) as TS doSetUp reads it: with no workspace, -// so ${localWorkspaceFolder} and its basename stay as written, and so does -// ${containerWorkspaceFolder} unless the config sets workspaceFolder. A file -// that does not exist is TS's "Dev container config (...) not found.". func setUpConfig(configPath string) (*config.LoadResult, error) { if configPath == "" { return nil, nil @@ -215,9 +195,6 @@ func setUpConfig(configPath string) (*config.LoadResult, error) { return loaded, err } -// setUpEntries are the metadata entries set-up merges: the container's -// metadata label entries, then the config (when there is one) as the last -// entry. func setUpEntries(labels map[string]string, cfg *config.DevContainer, logger log.Logger) []imagemeta.Entry { entries := imagemeta.ReadMetadataFromLabels(labels, logger) if cfg != nil { @@ -226,17 +203,6 @@ func setUpEntries(labels map[string]string, cfg *config.DevContainer, logger log return entries } -// setUpResult is set-up's success result as TS doSetUp reports it: -// configuration (--include-configuration) is the loaded configuration ({} -// without one) and mergedConfiguration (--include-merged-configuration) the -// merge of entries over it, both with ${containerEnv:...} resolved from the -// container's environment. configFilePath is the file URI, which TS never -// substitutes. -// -// TS substitutes both before it runs the lifecycle hooks, whether or not they -// are reported, so set-up computes the result first: a variable that cannot be -// resolved is the ContainerError it fails with, naming the configuration's -// file. func setUpResult(loaded *config.LoadResult, entries []imagemeta.Entry, containerCfg *container.Config, includeConfig, includeMerged bool) (map[string]any, error) { cfgMap := map[string]any{} var configFileURI any diff --git a/internal/cli/setup_held_test.go b/internal/cli/setup_held_test.go index 57650f8..d5a8b1a 100644 --- a/internal/cli/setup_held_test.go +++ b/internal/cli/setup_held_test.go @@ -17,7 +17,6 @@ import ( "github.com/moby/moby/api/types/container" ) -// 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() @@ -32,8 +31,6 @@ func runSetUpCmd(t *testing.T, args ...string) (string, string, error) { } 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 @@ -99,9 +96,6 @@ func TestSetUpReportsAnUnusableDockerHost(t *testing.T) { } } -// TS doSetUp reads --config with no workspace: a file that does not exist is -// "Dev container config (...) not found.", one that does not load is an error, -// and only a config's own workspaceFolder gives ${containerWorkspaceFolder}. func TestSetUpConfigLoadsTheGivenConfigWithoutAWorkspace(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "devcontainer.json") @@ -152,8 +146,6 @@ func TestSetUpConfigLoadsTheGivenConfigWithoutAWorkspace(t *testing.T) { } } -// set-up reads --config before it looks for the container, as TS doSetUp -// does, so a missing config is reported even when the container is missing. func TestSetUpReportsAMissingConfigBeforeTheContainer(t *testing.T) { startFakeEngine(t, map[string]map[string]string{}) missing := filepath.Join(t.TempDir(), "nope.json") @@ -192,7 +184,6 @@ func TestSetUpMergedPutsTheConfigOverTheContainerMetadata(t *testing.T) { } } -// setUpResultJSON is setUpResult's result as set-up prints it. func setUpResultJSON(t *testing.T, loaded *config.LoadResult, entries []imagemeta.Entry, env []string, includeConfig, includeMerged bool) map[string]any { t.Helper() result, err := setUpResult(loaded, entries, &container.Config{Env: env}, includeConfig, includeMerged) @@ -212,8 +203,6 @@ func setUpResultJSON(t *testing.T, loaded *config.LoadResult, entries []imagemet func strPtr(s string) *string { return &s } -// loadedConfig is the LoadResult of a config with the given raw properties, -// read from configFile. func loadedConfig(t *testing.T, configFile string, raw map[string]any) *config.LoadResult { t.Helper() var cfg config.DevContainer @@ -230,10 +219,8 @@ func loadedConfig(t *testing.T, configFile string, raw map[string]any) *config.L func TestSetUpResultReportsTheConfigurationAsTSDoes(t *testing.T) { loaded := loadedConfig(t, "/ws/.devcontainer/devcontainer.json", map[string]any{ - "image": "alpine:3", - "remoteEnv": map[string]any{"FROM_CONTAINER": "${containerEnv:FOO}"}, - // Properties the typed config has no field for, or leaves behind, - // are reported as written, as TS reports its configuration. + "image": "alpine:3", + "remoteEnv": map[string]any{"FROM_CONTAINER": "${containerEnv:FOO}"}, "foo": map[string]any{"b": 1.0}, "extensions": "", }) @@ -275,8 +262,6 @@ func TestSetUpResultLeavesOutWhatWasNotAskedFor(t *testing.T) { } } -// TS substitutes no URI (substitute0), so configFilePath is the file's path -// as it is, whatever ${containerEnv:...} it reads like. func TestSetUpResultLeavesTheConfigFilePathUnsubstituted(t *testing.T) { file := "/ws/${containerEnv:UNSET}/devcontainer.json" loaded := loadedConfig(t, file, map[string]any{"image": "alpine:3"}) @@ -291,9 +276,6 @@ func TestSetUpResultLeavesTheConfigFilePathUnsubstituted(t *testing.T) { } } -// A ${containerEnv} without a name is TS's ContainerError, naming the -// configuration's file, and TS substitutes the configuration and the merged -// configuration whether or not they are reported. func TestSetUpResultReportsAContainerEnvReferenceWithoutAName(t *testing.T) { loaded := loadedConfig(t, "/ws/devcontainer.json", map[string]any{"remoteEnv": map[string]any{"X": "${containerEnv}"}}) want := "'${containerEnv}' in devcontainer.json can not be resolved because no environment variable name is given." diff --git a/internal/cli/stop.go b/internal/cli/stop.go index 841b19b..5c441e6 100644 --- a/internal/cli/stop.go +++ b/internal/cli/stop.go @@ -16,7 +16,7 @@ type stopOpts struct { idLabels []string containerID string dockerPath string - removeVolumes bool // down only + removeVolumes bool } func newStopCmd() *cobra.Command { @@ -76,8 +76,6 @@ func addStopFlags(cmd *cobra.Command, opts *stopOpts) { } func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) error { - // 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() } @@ -91,12 +89,9 @@ 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]any{"outcome": "success", "result": "no-container-found"}) } - // Compose services carry the project label; operate on the whole project so - // sibling services (db, cache, …) are handled too, not just the dev container. project := "" if insp, ierr := engine.InspectContainer(ctx, id); ierr == nil && insp.Config != nil { project = insp.Config.Labels["com.docker.compose.project"] @@ -134,11 +129,6 @@ func runStopOrDown(ctx context.Context, out Output, opts stopOpts, remove bool) }) } -// resolveWorkspaceContainer finds the dev container for a workspace: a directly -// given --container-id, else --id-label, else the workspace's container — -// disambiguated by --config (config_file label) when a project has multiple -// devcontainer.json configs, falling back to local_folder. all=true so a stopped -// container is still found (down after a prior stop). func resolveWorkspaceContainer(ctx context.Context, engine *docker.EngineClient, opts stopOpts) string { if opts.containerID != "" { return opts.containerID @@ -147,8 +137,6 @@ func resolveWorkspaceContainer(ctx context.Context, engine *docker.EngineClient, if opts.configPath != "" { configFile = resolvePath(opts.configPath) } - // A failed lookup is no container here, as it was before lookups reported - // their failure. id, _ := resolveContainerID(ctx, engine, opts.workspaceFolder, configFile, opts.idLabels) return id } diff --git a/internal/cli/stop_held_test.go b/internal/cli/stop_held_test.go index 7b9c979..630aa23 100644 --- a/internal/cli/stop_held_test.go +++ b/internal/cli/stop_held_test.go @@ -15,20 +15,16 @@ import ( "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 + containers map[string]map[string]string 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} @@ -120,9 +116,6 @@ func hasLabels(labels map[string]string, want []string) bool { 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() diff --git a/internal/cli/stop_repo2_held_test.go b/internal/cli/stop_repo2_held_test.go index 7f5fe36..70ae05c 100644 --- a/internal/cli/stop_repo2_held_test.go +++ b/internal/cli/stop_repo2_held_test.go @@ -7,9 +7,6 @@ import ( "github.com/spf13/cobra" ) -// The commands that take an optional positional path refuse a second one, as -// the TS CLI's yargs parser (.strict()) refuses arguments its commands do not -// declare; nothing is run. func TestPathCommandsRefuseASecondPositional(t *testing.T) { for name, newCmd := range map[string]func() *cobra.Command{ "stop": newStopCmd, diff --git a/internal/cli/stop_repo_held_test.go b/internal/cli/stop_repo_held_test.go index 954ddd8..42aac1b 100644 --- a/internal/cli/stop_repo_held_test.go +++ b/internal/cli/stop_repo_held_test.go @@ -25,9 +25,6 @@ func runStopCommand(t *testing.T, cmd *cobra.Command, args ...string) map[string return got } -// stop [path] and down [path] act on the container of the workspace the -// argument names, not the current directory's; --workspace-folder wins over -// the argument. func TestStopAndDownActOnThePositionalWorkspace(t *testing.T) { for _, tc := range []struct { name string @@ -52,7 +49,6 @@ func TestStopAndDownActOnThePositionalWorkspace(t *testing.T) { } } -// --config looks first for the workspace's container of that config file. func TestStopConfigFlagLooksForThatConfigsContainer(t *testing.T) { ws := t.TempDir() cfg := filepath.Join(ws, ".devcontainer", "devcontainer.json") diff --git a/internal/cli/stringify_test.go b/internal/cli/stringify_test.go index 7a60fd2..a508d69 100644 --- a/internal/cli/stringify_test.go +++ b/internal/cli/stringify_test.go @@ -6,7 +6,6 @@ import ( coreerrors "github.com/devcontainers/cli/internal/errors" ) -// A command's error result is written as JSON.stringify writes it. func TestWriteErrorJSONDoesNotEscapeHTML(t *testing.T) { var out captureOutput _ = writeErrorJSON(&out, coreerrors.ErrorOutput{Outcome: "error", Message: "a && ", Description: "a && "}) diff --git a/internal/cli/templates_apply.go b/internal/cli/templates_apply.go index 3178c12..7a2ad3a 100644 --- a/internal/cli/templates_apply.go +++ b/internal/cli/templates_apply.go @@ -69,24 +69,18 @@ func realTemplatesApplyCmd() *cobra.Command { 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 + _ = jsonc.Unmarshal([]byte(omitPaths), &omit) var featureOpts []templates.TemplateFeatureOption if featuresJSON != "" { if err := json.Unmarshal([]byte(featuresJSON), &featureOpts); err != nil { diff --git a/internal/cli/templates_apply_held_test.go b/internal/cli/templates_apply_held_test.go index ac5e395..4f7cbd9 100644 --- a/internal/cli/templates_apply_held_test.go +++ b/internal/cli/templates_apply_held_test.go @@ -21,7 +21,6 @@ func TestTemplateSelection(t *testing.T) { 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) @@ -48,7 +47,6 @@ func TestTemplateSelectionRejects(t *testing.T) { } } -// A template that cannot be fetched fails the command without printing files. func TestTemplatesApplyReportsFetchError(t *testing.T) { cmd := realTemplatesApplyCmd() out := &captureOutput{} diff --git a/internal/cli/templates_metadata.go b/internal/cli/templates_metadata.go index 4bf9c8e..49d3c5a 100644 --- a/internal/cli/templates_metadata.go +++ b/internal/cli/templates_metadata.go @@ -67,8 +67,6 @@ func runTemplatesMetadata(ctx context.Context, out Output, templateID, logLevel return nil } -// compactTemplateMetadata matches JSON.stringify(JSON.parse(annotation)): it -// removes insignificant whitespace while retaining the source object's key order. func compactTemplateMetadata(metadataJSON string) (string, error) { var buf bytes.Buffer if err := json.Compact(&buf, []byte(metadataJSON)); err != nil { diff --git a/internal/cli/templates_metadata_repo_held_test.go b/internal/cli/templates_metadata_repo_held_test.go index 9d32c70..f7ff9f6 100644 --- a/internal/cli/templates_metadata_repo_held_test.go +++ b/internal/cli/templates_metadata_repo_held_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -// templateManifest is a Template manifest whose annotations are the given -// JSON object body. func templateManifest(annotations string) []byte { return []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` + `"config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` + @@ -27,8 +25,6 @@ func runTemplatesMetadataCmd(t *testing.T, templateID string) (string, error) { return out.String(), err } -// templates metadata prints the manifest's metadata annotation as -// JSON.stringify(JSON.parse(annotation)) does: compacted, keys in source order. func TestTemplatesMetadataPrintsTheAnnotation(t *testing.T) { reg := newReadRegistry(t) metadata := `{ "id": "tpl", "version": "1.0.0", "a": [1, 2] }` @@ -43,8 +39,6 @@ func TestTemplatesMetadataPrintsTheAnnotation(t *testing.T) { } } -// Every failure prints an empty object on stdout, as TS does before exiting 1, -// and returns the reason. func TestTemplatesMetadataFailures(t *testing.T) { reg := newReadRegistry(t) reg.manifests["ns/none"] = templateManifest(`"other":"x"`) diff --git a/internal/cli/ts_oracle_test.go b/internal/cli/ts_oracle_test.go index 9e385a8..c472ca9 100644 --- a/internal/cli/ts_oracle_test.go +++ b/internal/cli/ts_oracle_test.go @@ -11,9 +11,6 @@ import ( "github.com/devcontainers/cli/internal/tsoracle/oracletest" ) -// The ts-oracle goldens are the results of the reference functions -// themselves (go tool task testdata:ts-oracle). - func tsFindBaseImage(args []json.RawMessage) (any, error) { var content, target string var buildArgs, platformArgs map[string]string @@ -41,21 +38,16 @@ func TestTSOracleDockerfile(t *testing.T) { if err := oracletest.Args(args, &content); err != nil { return nil, err } - // TS answers true, false or 'unknown'; every caller tests it for truthiness. return oracletest.Truthiness(dockerfileSupportsBuildContexts(docker.ExtractDockerfile(content))), nil }, - // internal/docker's test asserts these. "ensureDockerfileHasFinalStageName": nil, "isBuildxCacheToInline": nil, } oracletest.Run(t, "testdata/ts-oracle/dockerfile.json", impls) - // The same findBaseImage cases internal/docker's FindBaseImage answers. oracletest.Run(t, "../docker/testdata/ts-oracle/dockerfile.json", impls) } func TestTSOracleCompose(t *testing.T) { - // mount is a TS Mount or mount string, as composeMountsFromMetadata takes - // the merged configuration's mounts. mount := func(args []json.RawMessage) (composeMount, error) { var m any if err := oracletest.Args(args, &m); err != nil { diff --git a/internal/cli/up.go b/internal/cli/up.go index 0cdb0d6..bf437d8 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -25,11 +25,9 @@ import ( ) type upOpts struct { - workspaceFolder string - configPath string - overrideConfig string - // lockfileExcludeIDs lists userFeatureIds supplied only via --additional-features; - // 0.88 keeps these out of the lockfile. + workspaceFolder string + configPath string + overrideConfig string lockfileExcludeIDs map[string]bool dockerPath string dockerComposePath string @@ -77,30 +75,18 @@ type upOpts struct { defaultUserEnvProbe string containerSessionDataFolder string skipFeatureAutoMapping bool - // Set by upFromCompose, read when building result - composeProjectName string + composeProjectName string } -// upRunner carries the dependencies shared across the whole `up` flow. docker is -// nil on the reattach path (set once BuildKit detection has run). The context is -// passed explicitly as the first argument of each method, not stored here, so -// cancellation is visible in every signature that does I/O. type upRunner struct { - out Output - log log.Logger - docker *docker.Client - engine *docker.EngineClient - opts *upOpts - // idLabels are the labels the dev container is found by. - idLabels []string - // substitute resolves the variables of entries read from a - // devcontainer.metadata label (TS configWithRaw.substitute). - substitute metadataSubstitution - // configLabel is the configuration's entry in the devcontainer.metadata - // label: its properties as written (TS pick(config.raw, ...)). - configLabel imagemeta.Entry - // lockfileRead records that Features were processed with the lockfile on, - // which in TS reads the configuration URI's fsPath (getLockfilePath). + out Output + log log.Logger + docker *docker.Client + engine *docker.EngineClient + opts *upOpts + idLabels []string + substitute metadataSubstitution + configLabel imagemeta.Entry lockfileRead bool } @@ -129,9 +115,6 @@ func newUpCmd() *cobra.Command { f.BoolVar(&opts.skipNonBlocking, "skip-non-blocking-commands", false, "Stop after waitFor command.") f.BoolVar(&opts.skipPostAttach, "skip-post-attach", false, "Skip postAttachCommand.") f.BoolVar(&opts.prebuild, "prebuild", false, "Stop after updateContentCommand.") - // Go-only: start from a prebuilt image (features already baked in) instead of - // building + installing features. The image's devcontainer.metadata label - // supplies the merged configuration. f.StringVar(&opts.cacheImage, "cache-image", "", "Start from this prebuilt image, skipping build and feature install (not for Compose configs).") f.StringVar(&opts.buildkit, "buildkit", "auto", "BuildKit mode.") f.StringArrayVar(&opts.idLabels, "id-label", nil, "Id label(s).") @@ -167,7 +150,6 @@ func newUpCmd() *cobra.Command { f.BoolVar(&opts.mountGitWorktreeCommonDir, "mount-git-worktree-common-dir", false, "Mount the Git worktree common dir for Git operations to work in the container (requires the worktree created with `git worktree add --relative-paths`).") f.BoolVar(&opts.omitSyntaxDirective, "omit-syntax-directive", false, "") f.BoolVar(&opts.omitConfigRemoteEnvFromMeta, "omit-config-remote-env-from-metadata", false, "") - // Hidden experimental/testing flags (match the TS CLI's hidden: true). for _, h := range []string{ "skip-feature-auto-mapping", "experimental-lockfile", @@ -184,8 +166,6 @@ func newUpCmd() *cobra.Command { return cmd } -// validateUpOpts checks the up command's flag values, returning the first -// invalid one (nil if all are valid). Enum choices mirror the TS CLI. func validateUpOpts(opts *upOpts) error { if err := validateIDLabels(opts.idLabels); err != nil { return err @@ -210,10 +190,6 @@ func validateUpOpts(opts *upOpts) error { 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, 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() @@ -230,17 +206,10 @@ 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, @@ -256,8 +225,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { if opts.caBundle, err = resolveCABundle(opts.caBundle); err != nil { return writeValidationError(out, err.Error()) } - // --no-lockfile is mutually exclusive with every frozen/write lockfile flag - // (matching the TS CLI up command's yargs check). if opts.noLockfile { switch { case opts.frozenLockfile: @@ -269,10 +236,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } } warnDeprecatedLockfileFlags(out.Stderr(), opts.experimentalLockfile, opts.experimentalFrozenLockfile) - // TS folds --experimental-frozen-lockfile into --frozen-lockfile. opts.experimentalFrozenLockfile = opts.experimentalFrozenLockfile || opts.frozenLockfile - // Non-blocking hint (interactive TTY only) if the host was never checked or a - // previous `devcontainer check` found a failing configuration. warnUncheckedHost(out) workspaceFolder, configPath, overridePath := resolveUpPaths(opts) @@ -290,10 +254,8 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { Writer: logDst, Secrets: secretValuesFromFile(opts.secretsFile), }) - // Route the OCI auth diagnostic lines at this command's logger. ociAuthPolicy(ctx).SetLogger(logger) - // Engine SDK client for container/image operations engine, err := docker.NewEngineClient(logger) if err != nil { return writeErrorResult(out, fmt.Sprintf("Docker engine: %v", err)) @@ -302,15 +264,12 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { run := &upRunner{out: out, log: logger, engine: engine, opts: opts} - // When --id-label is provided, use those labels directly for container lookup. - // Otherwise, derive labels from workspace folder + config file path. idLabels := opts.idLabels if upHasNothingToRead(workspaceFolder, configPath, overridePath) { return writeErrorResult(out, "No dev container config and no workspace found.") } - // Load config loadResult, err := config.LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overridePath, config.MountOptions{ MountWorkspaceGitRoot: opts.mountWorkspaceGitRoot, MountGitWorktreeCommonDir: opts.mountGitWorktreeCommonDir, @@ -320,8 +279,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } cfg := loadResult.Config - // Derive id labels before consuming configuration fields so the complete - // pre-container pipeline can run first. if len(idLabels) == 0 { idLabels = []string{ fmt.Sprintf("devcontainer.local_folder=%s", workspaceFolder), @@ -331,7 +288,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } } - // Merge --additional-features into config (config features have priority) var mergeErr error opts.lockfileExcludeIDs, mergeErr = mergeAdditionalFeatures(cfg, opts.additionalFeatures) if mergeErr != nil { @@ -355,10 +311,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeErrorResult(out, err.Error()) } - // 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). 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) @@ -370,9 +322,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { 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. if !cfg.InitializeCommand.IsEmpty() { if err := lifecycle.RunInitializeCommand(ctx, logger, &cfg.InitializeCommand, loadResult.HostSub); err != nil { return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{ @@ -382,12 +331,9 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } dockerClient := docker.NewClient(opts.dockerPath, nil, logger) - // Stream `docker build` progress live to the log destination (text mode only; - // under --log-format json it would corrupt the structured event stream). dockerClient.ProgressWriter = progressWriter(opts.logFormat, logDst) run.docker = dockerClient - // Detect BuildKit var useBuildx bool if opts.buildkit != "never" { bk := dockerClient.DetectBuildKit(ctx) @@ -397,7 +343,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { var containerID string if len(existingIDs) > 0 && !cfg.IsComposeConfig() { - // Single-container reuse: start the existing container directly. containerID = existingIDs[0] logger.Write(fmt.Sprintf("Using existing container %s", containerID), log.LevelInfo) @@ -436,7 +381,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { } } - // Get container info inspectResp, err := engine.InspectContainer(ctx, containerID) if err != nil { return writeErrorResult(out, fmt.Sprintf("Failed to inspect container: %v", err)) @@ -447,10 +391,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { remoteWorkspaceFolder := loadResult.WorkspaceConfig.WorkspaceFolder - // TS setupInContainer substitutes the container's environment into the - // configuration and the merged configuration before the hooks, even with - // --skip-post-create: a variable that cannot be resolved fails up, with - // the container reported. if subErr := run.checkContainerSubstitution(inspectResp, cfg, loadResult.Raw); subErr != nil { ce, ok := coreerrors.AsContainerError(subErr) if !ok { @@ -460,8 +400,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeErrorJSON(out, coreerrors.ToErrorOutput(ce)) } - // Run lifecycle hooks (unless skipped). A hook failure aborts `up` with an - // error outcome (matching the TS CLI), instead of reporting success. if !opts.skipPostCreate { if lcErr := run.runLifecycleForUp(ctx, containerID, cfg, remoteWorkspaceFolder, remoteUser); lcErr != nil { return writeErrorJSON(out, coreerrors.ToErrorOutput(lcErr)) @@ -474,8 +412,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { "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 } @@ -491,9 +427,6 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error { return writeSuccessJSON(out, result, upResultKeys...) } -// 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. @@ -508,13 +441,6 @@ func (r *upRunner) imageLabels(ctx context.Context, ref string) map[string]strin return img.Config.Labels } -// 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 @@ -536,34 +462,15 @@ func upRemoteUser(cfgUser string, ctr container.InspectResponse, imageLabels fun return cmp.Or(ctrUser, "root") } -// upConfigFileFromArgument reports whether the URI TS's up keeps as -// configFilePath is one URI.file made from an argument, with the file scheme: -// --config's, or without a workspace --override-config's -// (configContainer.ts resolveWithLocalFolder). One found in the workspace, or -// its default path, has the vscode-fileHost scheme. func upConfigFileFromArgument(opts *upOpts) bool { return opts.configPath != "" || opts.workspaceFolder == "" && opts.overrideConfig != "" } -// upConfigFileFSPathRead reports whether TS's up read the configuration URI's -// fsPath, which decides whether the URI it writes has one: readDocument reads -// a file URI's (editableFiles.ts) when it is the document read -// (overrideConfigFile ?? configFile); resolveWithLocalFolder passes it to -// findContainerAndIdLabels without --id-label (configContainer.ts); and -// getLockfilePath reads it when Features are processed with the lockfile on -// (lockfile.ts). func upConfigFileFSPathRead(opts *upOpts, lockfileRead bool) bool { readDocument := upConfigFileFromArgument(opts) && (opts.configPath == "" || opts.overrideConfig == "") return readDocument || len(opts.idLabels) == 0 || lockfileRead } -// 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 merges the container's metadata (metadata, TS -// getImageMetadataFromContainer), which records the configuration's entry, as -// TS's up does for an existing container. func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cfg *config.DevContainer, ctr container.InspectResponse, metadata func(ctr container.InspectResponse, cfg *config.DevContainer) ([]imagemeta.Entry, error), logger log.Logger) error { if !opts.includeConfig && !opts.includeMergedConfig { return nil @@ -611,11 +518,8 @@ func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cf return err } 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 - // Substitute containerEnv into merged config mergedJSON, _ := json.Marshal(merged) var mergedGeneric any json.Unmarshal(mergedJSON, &mergedGeneric) @@ -623,9 +527,6 @@ func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cf 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 { @@ -639,9 +540,6 @@ func addUpConfiguration(result map[string]any, opts *upOpts, fsPathRead bool, cf 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 @@ -660,18 +558,12 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, imageName := folderImageName(workspaceFolder) - // 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 { findTarget = cfg.Build.Target } baseImage := docker.FindBaseImage(prep.Parsed, buildArgsFromConfig(cfg), findTarget, buildxPlatformArgs(opts.platform)) - // Read the FROM image's baked devcontainer.metadata (e.g. remoteUser=node on - // the mcr devcontainers images). The metadata is computed before the build, so - // on a fresh run the base image is otherwise absent and its metadata would be - // silently lost — making exec/lifecycle fall back to root. baseLabels := engine.ImageLabelsEnsuringPresent(ctx, baseImage) baseMetadata := imagemeta.ReadMetadataFromLabels(baseLabels, logger) stageMetadata := imagemeta.ReadMetadataFromLabels(prep.Parsed.StageLabels(stageName), logger) @@ -679,8 +571,6 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, contextPath := dockerContextPath(cfg) upCacheFrom := cacheFromForDockerfileBuild(opts.cacheFrom, cfg) - // Bridge the CLI credential chain to the build subprocess via a temporary - // DOCKER_CONFIG (private base pull / --cache-to). authEnv, authCleanup := bridgeBuildAuth(logger, baseImage, []string{imageName}, upCacheFrom, opts.cacheTo) defer authCleanup() @@ -707,13 +597,10 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, return "", fmt.Errorf(msgDockerBuildFailed, buildResult.ExitCode, string(buildResult.Stderr)) } - // When streaming, the build output already reached the terminal live; only - // emit the buffered copy when it did not (e.g. --log-format json). if dockerClient.ProgressWriter == nil { logger.Write(string(buildResult.Stderr), log.LevelInfo) } - // Extend with features if any if len(cfg.Features) > 0 { r.lockfileRead = !opts.noLockfile names, err := extendImageWithFeatures(ctx, logger, dockerClient, engine, imageName, cfg.Features, useBuildx, nil, r.featureBuildOptions(cfg, loadResult, "")) @@ -733,9 +620,6 @@ func (r *upRunner) fromDockerfile(ctx context.Context, cfg *config.DevContainer, return r.runContainer(ctx, imageName, cfg, loadResult, workspaceFolder, idLabels, true) } -// 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. @@ -745,15 +629,11 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load return "", fmt.Errorf("no image specified in devcontainer.json") } - // Target platform: the --platform flag wins, else a --platform in runArgs, so - // the image is pulled and (feature-)built for the platform it will run on — - // e.g. an amd64 image forced via runArgs on an arm64 host (#1241). platform := opts.platform if platform == "" { platform = findPlatformArg(cfg.RunArgs) } - // Pull if needed _, err := engine.InspectImage(ctx, cfg.Image) if err != nil { logger.Write(fmt.Sprintf("Pulling image %s...", cfg.Image), log.LevelInfo) @@ -762,7 +642,6 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load } } - // Extend with features if any imageName := cfg.Image if len(cfg.Features) > 0 { r.lockfileRead = !opts.noLockfile @@ -780,18 +659,9 @@ func (r *upRunner) fromImage(ctx context.Context, cfg *config.DevContainer, load return "", err } - // TS getExtendImageBuildInfo: an image with no Features is not built, so the - // metadata label, with the configuration's entry, goes on the container. return r.runContainer(ctx, imageName, cfg, loadResult, workspaceFolder, idLabels, len(cfg.Features) > 0) } -// fromCacheImage starts the container from a prebuilt image (--cache-image), -// skipping the image build and feature installation entirely. The prebuilt image -// is expected to already carry the features and a devcontainer.metadata label -// (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). @@ -811,16 +681,10 @@ func (r *upRunner) fromCacheImage(ctx context.Context, cfg *config.DevContainer, if err != nil { return "", err } - // A prebuilt image with a metadata label is a build of the configuration, - // whose label records the configuration's entry; one without is used as an - // image config's image is. _, configInImage := r.imageLabels(ctx, image)[imagemeta.MetadataLabel] return r.runContainer(ctx, image, cfg, loadResult, workspaceFolder, idLabels, configInImage) } -// 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 @@ -832,7 +696,6 @@ func (r *upRunner) singleContainerRemoteUserUID(ctx context.Context, imageName s 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] @@ -848,9 +711,6 @@ func findUserArg(runArgs []string) string { 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 @@ -858,7 +718,6 @@ func findUserArg(runArgs []string) string { // TestE2E_CABundle (the bundle's mount and variables). func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *config.DevContainer, loadResult *config.LoadResult, workspaceFolder string, idLabels []string, configInImage bool) (string, error) { logger, dockerClient, engine, opts := r.log, r.docker, r.engine, r.opts - // Inspect image once — used for metadata and containerUser resolution. imageInspect, _ := engine.InspectImage(ctx, imageName) var labelEntries []imagemeta.Entry @@ -870,26 +729,16 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf return "", fmt.Errorf("substitute the image's metadata: %w", err) } containerLabels := append([]string{}, idLabels...) - // A Dockerfile or Features build put the metadata, with the configuration's - // entry, on the image (configInImage); otherwise TS adds the label, the - // image's entries and the configuration's as written, to the container. if !configInImage { metaEntries = append(metaEntries, configMetadataEntry(cfg, opts.omitConfigRemoteEnvFromMeta)) containerLabels = append(containerLabels, imagemeta.MetadataLabel+"="+containerMetadataLabel(append(labelEntries, r.configLabel))) } merged := imagemeta.MergeConfiguration(metaEntries) - // Build CMD script with feature entrypoints cmdScript := docker.BuildEntrypointScript(merged.Entrypoints) 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 - // image metadata's containerEnv too (prebuilds), matching the compose path - // and the TS CLI. A feature's own containerEnv is NOT in the metadata (it is - // baked as ENV during the build — see featureMetadataEntry), so this no - // longer re-applies a literal ${PATH} and breaks PATH. var envVars []string for k, v := range withCABundleEnv(merged.ContainerEnv, opts.caBundle) { envVars = append(envVars, fmt.Sprintf("%s=%s", k, v)) @@ -916,8 +765,6 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf mounts = append(mounts, caBundleMount(opts.caBundle)) } - // 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). runArgs := cfg.RunArgs if cfg.HostRequirements != nil && gpuRequested(cfg.HostRequirements.GPU) { gpuAvail := checkGPUAvailability(ctx, opts.gpuAvailability, dockerClient) @@ -925,13 +772,8 @@ func (r *upRunner) runContainer(ctx context.Context, imageName string, cfg *conf runArgs = append([]string{"--gpus", "all"}, runArgs...) } } - // appPort → publish flags (127.0.0.1:N:N for numbers, verbatim for strings). runArgs = append(runArgs, appPortPublishArgs(cfg.AppPort)...) - // Command: the keep-alive script wrapping any feature entrypoints. When - // 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, "-"} if merged.OverrideCommand != nil && !*merged.OverrideCommand && imageInspect.Config != nil { cmdArgs = append(cmdArgs, imageInspect.Config.Entrypoint...) @@ -973,8 +815,6 @@ 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 { @@ -983,14 +823,6 @@ func containerRunUser(metadataUser string, img image.InspectResponse) string { 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), or with an empty -// or null workspaceMount, 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; !wc.NoWorkspaceMount && (spec != "" || workspaceFolder != "") { @@ -1019,7 +851,6 @@ func upWorkspaceMounts(wc *config.WorkspaceConfig, workspaceFolder, consistency return mounts, nil } -// parseFlagMounts parses the --mount flags. func parseFlagMounts(specs []string) ([]dockermount.Mount, error) { var mounts []dockermount.Mount for _, m := range specs { @@ -1032,19 +863,13 @@ func parseFlagMounts(specs []string) ([]dockermount.Mount, error) { 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 -// fallback. func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, env map[string]string, composeFiles []string, composeClient *docker.ComposeClient) string { newNames := composeClient.UsesNewProjectNames() - // 1. COMPOSE_PROJECT_NAME env if envName, ok := env["COMPOSE_PROJECT_NAME"]; ok && envName != "" { return docker.ToProjectName(envName, newNames) } - // 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.SplitSeq(string(data), "\n") { @@ -1058,14 +883,11 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en } } - // 3. name: from compose config (via docker compose config) if composeConfig, configErr := composeClient.Config(ctx, composeFiles, ""); configErr == nil { if name, ok := composeConfig["name"].(string); ok && name != "" { if name != "devcontainer" { return docker.ToProjectName(name, newNames) } - // "devcontainer" might be the default from docker compose config. - // Check if it's explicitly in any compose file. for _, composeFile := range slices.Backward(composeFiles) { raw, readErr := os.ReadFile(composeFile) if readErr == nil && strings.Contains(string(raw), "name:") { @@ -1075,7 +897,6 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en } } - // 4. Directory-based fallback workingDir := filepath.Dir(composeFiles[0]) if filepath.Base(configDir) == ".devcontainer" { return docker.ToProjectName(filepath.Base(filepath.Dir(configDir))+"_devcontainer", newNames) @@ -1083,9 +904,6 @@ func resolveComposeProjectName(ctx context.Context, cfg *config.DevContainer, en return docker.ToProjectName(filepath.Base(workingDir), newNames) } -// 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. type composeServiceBuild struct { Image string HasBuild bool @@ -1131,9 +949,6 @@ func parseComposeServiceBuild(composeConfig map[string]any, service string) comp 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 @@ -1144,19 +959,16 @@ func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, lo return "", fmt.Errorf("dockerComposeFile config requires 'service' property") } - // Resolve compose files env := osEnvMap() composeFiles, err := config.DockerComposeFilePaths(cfg, env, filepath.Dir(cfg.ConfigFilePath)) if err != nil { return "", fmt.Errorf("resolve compose files: %w", err) } - // Create compose client composeClient, err := docker.NewComposeClient(opts.dockerPath, opts.dockerComposePath, nil, logger) if err != nil { return "", fmt.Errorf("compose client: %w", err) } - // Stream compose build/up progress to the same destination as docker builds. composeClient.ProgressWriter = dockerClient.ProgressWriter composeConfig, err := composeClient.Config(ctx, composeFiles, "") @@ -1185,8 +997,6 @@ func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, lo } 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 } @@ -1213,10 +1023,6 @@ func (r *upRunner) fromCompose(ctx context.Context, cfg *config.DevContainer, lo 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 @@ -1225,7 +1031,6 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf logger, engine, opts := r.log, r.engine, r.opts 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) @@ -1234,9 +1039,6 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf executor := &lifecycle.ShellExecutor{Server: shellServer, Log: logger, WorkDir: workDir} - // The hooks are the container's metadata's (TS getImageMetadataFromContainer), - // which records the configuration's entry; a container the engine cannot - // inspect has the configuration's alone. inspectResp, _ := engine.InspectContainer(ctx, containerID) entries, err := r.metadataOfContainer(inspectResp, cfg) if err != nil { @@ -1250,8 +1052,6 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf SkipNonBlocking: opts.skipNonBlocking, SkipPostAttach: opts.skipPostAttach, 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: dotfilesAfterPostCreate(logger, executor, lifecycle.DotfilesConfig{ Repository: opts.dotfilesRepo, InstallCommand: opts.dotfilesCommand, @@ -1264,10 +1064,6 @@ func (r *upRunner) runLifecycleForUp(ctx context.Context, containerID string, cf return nil } -// checkContainerSubstitution is TS setupInContainer's containerSubstitute -// over the configuration (raw) and the merged configuration (the container's -// metadata), for its failure alone. -// // mutate-lane: TestParityMatrix - up.container-env-without-name-error fails // up through it against a real container. func (r *upRunner) checkContainerSubstitution(inspectResp container.InspectResponse, cfg *config.DevContainer, raw map[string]any) error { @@ -1282,9 +1078,6 @@ func (r *upRunner) checkContainerSubstitution(inspectResp container.InspectRespo return checkContainerSubstitution(containerEnv, cfg.ConfigFilePath, raw, imagemeta.MergeConfiguration(entries)) } -// 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 @@ -1297,9 +1090,6 @@ func secretsRemoteEnv(path string, logger log.Logger) []string { 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 { @@ -1314,10 +1104,6 @@ func hookRemoteEnv(probed map[string]string, extra []string, cfgRemoteEnv map[st return env } -// 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 { @@ -1326,13 +1112,6 @@ func hookContainerError(hookErr error, containerID, fallback string) *coreerrors 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 @@ -1347,11 +1126,6 @@ func applyLifecycleMarkers(server *lifecycle.ShellServer, merged *imagemeta.Merg }) } -// 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 { @@ -1371,10 +1145,6 @@ func dropRanHooks(merged *imagemeta.MergedConfig, inspect container.InspectRespo } } -// 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 { @@ -1383,9 +1153,6 @@ func dotfilesAfterPostCreate(logger log.Logger, executor lifecycle.CommandExecut } } -// resolveContainerUserDataFolder returns the in-container folder where lifecycle -// marker files live: /.devcontainer (matching TS getUserDataFolder with the -// default containerDataFolder). Returns "" if HOME cannot be resolved. func resolveContainerUserDataFolder(server *lifecycle.ShellServer) string { home, code, err := server.Exec(`printf %s "$HOME"`) home = strings.TrimSpace(home) @@ -1395,11 +1162,6 @@ func resolveContainerUserDataFolder(server *lifecycle.ShellServer) string { return home + "/.devcontainer" } -// markerShouldRun reports whether a lifecycle hook should run, updating its marker -// file as a side effect. It runs (and rewrites the marker) only when the marker's -// content differs from the container's createdAt/startedAt timestamp — matching -// the TS updateMarkerFile — so a repeated `up` on the same container skips hooks -// that already ran. An empty timestamp (unknown) defaults to running. func markerShouldRun(server *lifecycle.ShellServer, folder, hookName, content string) bool { if content == "" { return true @@ -1412,8 +1174,6 @@ func markerShouldRun(server *lifecycle.ShellServer, folder, hookName, content st 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{ @@ -1429,21 +1189,12 @@ func (r *upRunner) featureBuildOptions(cfg *config.DevContainer, loadResult *con ConfigPath: cfg.ConfigFilePath, LockfileExcludeIDs: opts.lockfileExcludeIDs, HostSub: &loadResult.HostSub, - // TS getFeaturesBuildOptions writes the config's containerEnv into the - // Features image as ENV lines (#{containerEnvMetadata}). - ContainerEnv: cfg.ContainerEnv, - // The configuration's entry: substituted for the Features' users, as - // written in the image's metadata label. - AdditionalMetadata: []imagemeta.Entry{configMetadataEntry(cfg, false)}, - LabelMetadata: []imagemeta.Entry{r.configLabel}, + ContainerEnv: cfg.ContainerEnv, + AdditionalMetadata: []imagemeta.Entry{configMetadataEntry(cfg, false)}, + LabelMetadata: []imagemeta.Entry{r.configLabel}, } } -// metadataOfContainer is the container's metadata, TS -// getImageMetadataFromContainer with up's id labels and substitutions. A -// container without a config (the engine could not inspect it) has the -// configuration's entry alone. -// // mutate-lane: TestParityMatrix - up.feature-hook-failure-error and // up.dockerfile-with-parallel-commands-success run the hooks it reads from a // real container; also TestDifferentialUp. diff --git a/internal/cli/up_held_test.go b/internal/cli/up_held_test.go index 940dd01..d0bdd4a 100644 --- a/internal/cli/up_held_test.go +++ b/internal/cli/up_held_test.go @@ -83,7 +83,7 @@ func TestUpRemoteUserFallsBackInTSOrder(t *testing.T) { ctr container.InspectResponse imageLabels map[string]string want string - wantImage bool // whether the image had to be asked + wantImage bool }{ {"config", "cfg", withUser("ctr", metadataLabels(`[{"remoteUser":"meta"}]`)), metadataLabels(`[{"remoteUser":"img"}]`), "cfg", false}, {"container metadata", "", withUser("ctr", metadataLabels(`[{"remoteUser":"meta"}]`)), metadataLabels(`[{"remoteUser":"img"}]`), "meta", false}, @@ -112,16 +112,12 @@ func TestUpRemoteUserFallsBackInTSOrder(t *testing.T) { } } -// containerMetadata reads a container's metadata labels without substitution -// or id labels: its entries, then the configuration's. func containerMetadata(labels map[string]string) func(container.InspectResponse, *config.DevContainer) ([]imagemeta.Entry, error) { return func(_ container.InspectResponse, cfg *config.DevContainer) ([]imagemeta.Entry, error) { return imageMetadataFromContainer(labels, cfg, nil, nil, func(*[]imagemeta.Entry) error { return nil }, quietLogger()) } } -// upConfigurationJSON is what addUpConfiguration adds to up's result, as up -// prints it, for a container whose metadata labels are containerLabels. func upConfigurationJSON(t *testing.T, opts *upOpts, cfg *config.DevContainer, ctr container.InspectResponse, containerLabels map[string]string) map[string]any { t.Helper() result := map[string]any{} @@ -378,8 +374,6 @@ func TestParseFlagMounts(t *testing.T) { } } -// 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) { @@ -408,8 +402,6 @@ func TestResolveComposeProjectNameFollowsTheTSChain(t *testing.T) { 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)}} @@ -563,8 +555,6 @@ func TestDropRanHooksWithoutAStartTimeAsksWithNone(t *testing.T) { } } -// recordingExecutor records the commands it is asked to run and fails them -// with err. type recordingExecutor struct { commands []string err error @@ -604,8 +594,6 @@ func TestDotfilesAfterPostCreateInstallsAndOnlyWarnsOnFailure(t *testing.T) { } } -// 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") @@ -641,8 +629,6 @@ func TestE2E_UpCacheImage(t *testing.T) { } } -// 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") @@ -687,8 +673,6 @@ func TestAddUpConfigurationDropsAConfigFilePathThatResolvesToNothing(t *testing. } } -// up's Features build writes the configuration's containerEnv into the image -// as ENV lines, as TS getFeaturesBuildOptions does and build already did. func TestUpFeatureBuildOptionsCarryTheContainerEnv(t *testing.T) { cfg := &config.DevContainer{ContainerEnv: map[string]string{"VAR_WITH_SPACES": "value with spaces"}} got := (&upRunner{opts: &upOpts{}}).featureBuildOptions(cfg, &config.LoadResult{}, "").ContainerEnv diff --git a/internal/cli/up_nodocker_held_test.go b/internal/cli/up_nodocker_held_test.go index c14749a..7ceb552 100644 --- a/internal/cli/up_nodocker_held_test.go +++ b/internal/cli/up_nodocker_held_test.go @@ -11,9 +11,6 @@ import ( "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") diff --git a/internal/cli/up_remote_uid_held_test.go b/internal/cli/up_remote_uid_held_test.go index 87d6a29..69f7fbd 100644 --- a/internal/cli/up_remote_uid_held_test.go +++ b/internal/cli/up_remote_uid_held_test.go @@ -9,8 +9,6 @@ import ( "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": `{}`}} diff --git a/internal/cli/up_repo_held_test.go b/internal/cli/up_repo_held_test.go index 4e572a8..90d5102 100644 --- a/internal/cli/up_repo_held_test.go +++ b/internal/cli/up_repo_held_test.go @@ -12,9 +12,6 @@ import ( "github.com/devcontainers/cli/internal/lifecycle" ) -// hostShell is a lifecycle.ContainerExecutor that runs each command with the -// host's sh, HOME set to home, so the marker scripts up runs in a container run -// for real. err, when set, is returned instead of running anything. type hostShell struct { home string err error @@ -42,8 +39,6 @@ func hostShellServer(t *testing.T, h *hostShell) *lifecycle.ShellServer { return lifecycle.NewShellServer(t.Context(), h, "c1", "") } -// The markers live in the container user's $HOME/.devcontainer; a HOME that -// cannot be read, or is empty, leaves no folder. func TestResolveContainerUserDataFolder(t *testing.T) { home := t.TempDir() if got := resolveContainerUserDataFolder(hostShellServer(t, &hostShell{home: home})); got != home+"/.devcontainer" { @@ -57,8 +52,6 @@ func TestResolveContainerUserDataFolder(t *testing.T) { } } -// failingShell answers every command with exit code 1 and HOME's value on -// stdout, as a shell whose printf failed after writing would. type failingShell struct{} func (failingShell) ExecInContainer(context.Context, string, string, []string, string) (string, string, int, error) { @@ -71,9 +64,6 @@ func TestResolveContainerUserDataFolderOfAFailedProbe(t *testing.T) { } } -// A hook runs once per timestamp: the first ask writes the marker and runs it, -// the same timestamp again does not, and a new one (a new container or start) -// runs it again. An unknown timestamp runs it without touching the marker. func TestMarkerShouldRunOncePerTimestamp(t *testing.T) { folder := filepath.Join(t.TempDir(), ".devcontainer") h := &hostShell{home: t.TempDir()} @@ -102,7 +92,6 @@ func TestMarkerShouldRunOncePerTimestamp(t *testing.T) { } } -// A marker that cannot be checked does not run the hook. func TestMarkerShouldRunWhenTheExecFails(t *testing.T) { server := hostShellServer(t, &hostShell{err: errors.New("exec failed")}) if markerShouldRun(server, "/nowhere", "postCreateCommand", "ts") { diff --git a/internal/cli/upstream_fixes_test.go b/internal/cli/upstream_fixes_test.go index 6600489..6786647 100644 --- a/internal/cli/upstream_fixes_test.go +++ b/internal/cli/upstream_fixes_test.go @@ -10,8 +10,6 @@ import ( "github.com/devcontainers/cli/internal/docker" ) -// mountOrString builds a config.MountOrString from a JSON literal (its internal -// representation is unexported, so this also exercises the unmarshal path). func mountOrString(t *testing.T, jsonLit string) config.MountOrString { t.Helper() var m config.MountOrString @@ -21,9 +19,6 @@ func mountOrString(t *testing.T, jsonLit string) config.MountOrString { return m } -// TestMetadataMounts_ReadonlyObject covers upstream #881: a top-level mount -// OBJECT with readonly:true must carry the flag through so both the docker and -// compose renderers emit a read-only mount (previously the struct dropped it). func TestMetadataMounts_ReadonlyObject(t *testing.T) { cfg := &config.DevContainer{ Mounts: []config.MountOrString{ @@ -42,7 +37,6 @@ func TestMetadataMounts_ReadonlyObject(t *testing.T) { t.Fatalf("#881 readonly not serialized: %v", m) } - // docker path: CreateContainerArgs must render `readonly` in the --mount spec. mounts, err := mountsFromMetadata(entries, "devid") if err != nil { t.Fatal(err) @@ -52,7 +46,6 @@ func TestMetadataMounts_ReadonlyObject(t *testing.T) { t.Errorf("#881 docker --mount missing readonly: %v", args) } - // compose path: the volume spec must end in :ro. composeMounts, err := composeMountsFromMetadata(entries, func(s string) (string, error) { return s, nil }) if err != nil { t.Fatal(err) @@ -62,8 +55,6 @@ func TestMetadataMounts_ReadonlyObject(t *testing.T) { } } -// TestMetadataMounts_ReadonlyOmittedByDefault guards that a mount without the -// property does not spuriously become read-only. func TestMetadataMounts_ReadonlyOmittedByDefault(t *testing.T) { cfg := &config.DevContainer{ Mounts: []config.MountOrString{ @@ -76,29 +67,19 @@ func TestMetadataMounts_ReadonlyOmittedByDefault(t *testing.T) { } } -// TestCheckGPUAvailability_NvidiaCLIProbe covers upstream #319: when the nvidia -// runtime is reported by docker info but nvidia-container-cli reports NO GPU -// (driver loaded, no device), detect mode must NOT enable the GPU. func TestCheckGPUAvailability_NvidiaCLIProbe(t *testing.T) { - // checkGPUAvailability's detect path needs a docker info result; without a - // real daemon we can only exercise the "all"/"none" shortcuts here plus the - // probe override in isolation. Verify the probe override is honored by the - // detect branch's decision function. orig := nvidiaContainerCLIInfo t.Cleanup(func() { nvidiaContainerCLIInfo = orig }) ctx := context.Background() - // Tool present, no GPU -> the refined signal wins (false). nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return true, false } if detectGPU(ctx, true) { t.Error("#319 GPU enabled despite nvidia-container-cli reporting no GPU") } - // Tool present, GPU present -> true. nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return true, true } if !detectGPU(ctx, true) { t.Error("#319 GPU not enabled despite a present GPU") } - // Tool absent -> fall back to the docker-info runtime signal (TS parity). nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return false, false } if !detectGPU(ctx, true) { t.Error("#319 without nvidia-container-cli, must keep TS runtime-only behavior") @@ -108,8 +89,6 @@ func TestCheckGPUAvailability_NvidiaCLIProbe(t *testing.T) { } } -// TestBuildPositionalPath covers upstream #8: the advertised [path] positional -// is honored as the workspace folder when --workspace-folder is not set. func TestBuildPositionalPath(t *testing.T) { cmd := newBuildCmd() if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { @@ -120,9 +99,6 @@ func TestBuildPositionalPath(t *testing.T) { } } -// TestMountFromMetadata_DevcontainerIdInTarget covers the fix: ${devcontainerId} -// 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]any{ "type": "volume", diff --git a/internal/cli/validate.go b/internal/cli/validate.go index dc0c0a7..9b2bfc7 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -10,7 +10,6 @@ import ( var mountSpecPattern = regexp.MustCompile(`^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$`) -// validateIDLabels checks that all --id-label values match = format. func validateIDLabels(labels []string) error { for _, l := range labels { if !strings.Contains(l, "=") || strings.HasPrefix(l, "=") || strings.HasSuffix(l, "=") { @@ -20,7 +19,6 @@ func validateIDLabels(labels []string) error { return nil } -// validateRemoteEnvs checks that all --remote-env values match = format. func validateRemoteEnvs(envs []string) error { for _, e := range envs { if !strings.Contains(e, "=") || strings.HasPrefix(e, "=") { @@ -30,7 +28,6 @@ func validateRemoteEnvs(envs []string) error { return nil } -// validateMounts checks that all --mount values match the same format enforced by TS. func validateMounts(mounts []string) error { for _, m := range mounts { if !mountSpecPattern.MatchString(m) { @@ -40,9 +37,6 @@ func validateMounts(mounts []string) error { return nil } -// validateTerminalImplications checks that --terminal-columns and -// --terminal-rows are given together. Like yargs .implies(), it tests whether -// each flag is present, not its value: --terminal-rows 0 alone fails too. func validateTerminalImplications(columns, rows givenInt) error { if columns.given && !rows.given { return fmt.Errorf("Implications failed:\n terminal-columns -> terminal-rows") @@ -53,17 +47,12 @@ func validateTerminalImplications(columns, rows givenInt) error { return nil } -// givenInt is a number flag that remembers whether it was given, for checks -// that test a flag's presence (yargs .implies()). It parses its value as -// pflag's IntVar does. type givenInt struct { value int given bool } func (g *givenInt) Set(s string) error { - // Parsed at int's size, so a value int cannot hold is an error rather - // than truncated. v, err := strconv.ParseInt(s, 0, strconv.IntSize) if err != nil { return err @@ -76,22 +65,16 @@ func (g *givenInt) String() string { return strconv.Itoa(g.value) } func (g *givenInt) Type() string { return "int" } -// validateEnum checks that a flag value is one of the allowed choices. -// 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 { diff --git a/internal/cli/validate_repo_held_test.go b/internal/cli/validate_repo_held_test.go index 8f55674..83f6620 100644 --- a/internal/cli/validate_repo_held_test.go +++ b/internal/cli/validate_repo_held_test.go @@ -6,8 +6,6 @@ import ( "testing" ) -// An --id-label is =: a name and a value on both sides of an '='. -// TS rejects a label without '=', with an empty name or with an empty value. func TestValidateIDLabels(t *testing.T) { for _, ok := range []string{"a=b", "devcontainer.local_folder=/x", "a=b=c"} { if err := validateIDLabels([]string{ok}); err != nil { @@ -25,7 +23,6 @@ func TestValidateIDLabels(t *testing.T) { } } -// A --remote-env needs a name before its '='; an empty value is allowed. func TestValidateRemoteEnvs(t *testing.T) { for _, ok := range []string{"A=b", "A=", "A=b=c"} { if err := validateRemoteEnvs([]string{ok}); err != nil { @@ -56,9 +53,6 @@ func TestValidateMounts(t *testing.T) { } } -// --terminal-columns and --terminal-rows imply each other, as yargs .implies(): -// a flag counts when it is given, whatever its value, so a 0 counts too -// (yargs validation.js keyExists tests hasOwnProperty). func TestValidateTerminalImplications(t *testing.T) { absent := givenInt{} given := func(n int) givenInt { return givenInt{value: n, given: true} } @@ -86,8 +80,6 @@ func TestValidateTerminalImplications(t *testing.T) { } } -// A givenInt reads its value as pflag's IntVar does and records that it was -// given; a value that is no number is an error and leaves it not given. func TestGivenIntParsesLikeIntVar(t *testing.T) { var g givenInt if g.String() != "0" || g.Type() != "int" { @@ -100,7 +92,6 @@ func TestGivenIntParsesLikeIntVar(t *testing.T) { if err := bad.Set("abc"); err == nil || bad.given { t.Errorf("Set(\"abc\") = %v, givenInt %+v; want an error and not given", err, bad) } - // A value int cannot hold is an error, never truncated. tooBig := strconv.FormatUint(uint64(math.MaxInt)+1, 10) var big givenInt if err := big.Set(tooBig); err == nil || big.given { @@ -108,7 +99,6 @@ func TestGivenIntParsesLikeIntVar(t *testing.T) { } } -// The first flag whose value is not one of its choices is the one reported. func TestValidateEnums(t *testing.T) { if err := validateEnums(enumFlag{"log-level", "debug", logLevels}, enumFlag{"log-format", "json", logFormats}); err != nil { t.Errorf("valid choices: %v", err) diff --git a/internal/cli/workflow_permissions_test.go b/internal/cli/workflow_permissions_test.go index cc27ae2..fe33103 100644 --- a/internal/cli/workflow_permissions_test.go +++ b/internal/cli/workflow_permissions_test.go @@ -8,13 +8,6 @@ import ( "gopkg.in/yaml.v3" ) -// TestWorkflowsDeclarePermissions pins least privilege for GITHUB_TOKEN: every -// workflow must set `permissions`, either at the top level (covering all of its -// jobs) or on each job. Without it the token inherits the repository default, -// which CodeQL reports as "Workflow does not contain permissions" — six jobs of -// go-cli.yml were flagged that way. -// -// A Go test cannot exercise CI, so this asserts the workflow files themselves. func TestWorkflowsDeclarePermissions(t *testing.T) { dir := filepath.Join("..", "..", ".github", "workflows") entries, err := os.ReadDir(dir) @@ -26,9 +19,7 @@ func TestWorkflowsDeclarePermissions(t *testing.T) { Permissions yaml.Node `yaml:"permissions"` Jobs map[string]struct { Permissions yaml.Node `yaml:"permissions"` - // A job that only calls a reusable workflow declares `uses`; its - // permissions belong to the called workflow. - Uses string `yaml:"uses"` + Uses string `yaml:"uses"` } `yaml:"jobs"` } @@ -48,7 +39,7 @@ func TestWorkflowsDeclarePermissions(t *testing.T) { t.Fatalf("parse %s: %v", name, err) } if !wf.Permissions.IsZero() { - continue // a top-level block covers every job + continue } if len(wf.Jobs) == 0 { t.Errorf("%s: no jobs found (parse problem?)", name) @@ -65,8 +56,6 @@ func TestWorkflowsDeclarePermissions(t *testing.T) { } } -// TestGoCLIWorkflowIsReadOnlyByDefault pins the specific grant: the CI workflow's -// default is read-only, so a job added later cannot silently inherit write access. func TestGoCLIWorkflowIsReadOnlyByDefault(t *testing.T) { path := filepath.Join("..", "..", ".github", "workflows", "go-cli.yml") data, err := os.ReadFile(path) diff --git a/internal/config/compose_workspace_test.go b/internal/config/compose_workspace_test.go index f68d518..382a0f8 100644 --- a/internal/config/compose_workspace_test.go +++ b/internal/config/compose_workspace_test.go @@ -6,11 +6,6 @@ import ( "testing" ) -// A Docker Compose configuration's container workspace folder is its own -// workspaceFolder or `/`, never /workspaces/ (TS -// getWorkspaceConfiguration -> getRemoteWorkspaceFolder, dockerCompose.ts). -// ${containerWorkspaceFolder} resolves to the same folder, with or without a -// workspace. func TestLoadComposeWorkspaceFolder(t *testing.T) { tests := []struct { name string diff --git a/internal/config/devcontainerid_parity_test.go b/internal/config/devcontainerid_parity_test.go index bfe84ac..6184785 100644 --- a/internal/config/devcontainerid_parity_test.go +++ b/internal/config/devcontainerid_parity_test.go @@ -7,7 +7,6 @@ import ( "github.com/devcontainers/cli/internal/tsoracle/oracletest" ) -// tsSubstitutionContext is a TS SubstitutionContext as the golden writes it. type tsSubstitutionContext struct { Platform string `json:"platform"` ConfigFile string `json:"configFile"` @@ -16,10 +15,6 @@ type tsSubstitutionContext struct { Env map[string]string `json:"env"` } -// TestTSOracleVariableSubstitution asserts varsub.go against the reference -// functions' results (go tool task testdata:ts-oracle). A divergence in -// ${devcontainerId} would break every use of it in up/build/exec, and the -// parity matrix cannot see it: each side runs with its own --id-labels. func TestTSOracleVariableSubstitution(t *testing.T) { resolve := func(ctx SubstitutionContext, phase SubstitutionPhase, value any) (any, error) { got, err := NewVariableResolver().Resolve(ctx, phase, value) diff --git a/internal/config/gitroot_test.go b/internal/config/gitroot_test.go index 1572095..71f171a 100644 --- a/internal/config/gitroot_test.go +++ b/internal/config/gitroot_test.go @@ -8,7 +8,6 @@ import ( func TestDetectGitRoot(t *testing.T) { root := t.TempDir() - // root/.git (dir) + nested sub/dir if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } @@ -17,16 +16,13 @@ func TestDetectGitRoot(t *testing.T) { t.Fatal(err) } - // From a nested dir, the root is found by walking up. if got := detectGitRoot(nested); got != root { t.Errorf("from nested = %q, want %q", got, root) } - // From the root itself. if got := detectGitRoot(root); got != root { t.Errorf("from root = %q, want %q", got, root) } - // A `.git` gitlink FILE (worktree/submodule) is also accepted as a root. wt := filepath.Join(t.TempDir(), "wt") if err := os.MkdirAll(wt, 0o755); err != nil { t.Fatal(err) @@ -40,9 +36,6 @@ func TestDetectGitRoot(t *testing.T) { } func TestDetectGitRootNotARepo(t *testing.T) { - // Walk within an isolated tree only: bound the search at `root` so the result - // does not depend on whether an ancestor of the system temp dir (e.g. /tmp) - // happens to contain a `.git`. root := t.TempDir() dir := filepath.Join(root, "x", "y") if err := os.MkdirAll(dir, 0o755); err != nil { @@ -53,11 +46,8 @@ func TestDetectGitRootNotARepo(t *testing.T) { } } -// TestDetectGitRootUntilStopsAtCeiling verifies the ceiling itself: a `.git` -// ABOVE stopAt must not be found, while one AT or BELOW stopAt is. func TestDetectGitRootUntilStopsAtCeiling(t *testing.T) { root := t.TempDir() - // A repo marker above the ceiling. if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatal(err) } @@ -66,11 +56,9 @@ func TestDetectGitRootUntilStopsAtCeiling(t *testing.T) { if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatal(err) } - // root/.git is above `ceiling`, so bounded walk must not find it. if got := detectGitRootUntil(nested, ceiling); got != "" { t.Errorf("bounded walk found a repo above the ceiling: %q", got) } - // The unbounded walk still finds root/.git. if got := detectGitRootUntil(nested, ""); got != root { t.Errorf("unbounded walk = %q, want %q", got, root) } diff --git a/internal/config/key_order_test.go b/internal/config/key_order_test.go index 6c7a1c1..b857fbe 100644 --- a/internal/config/key_order_test.go +++ b/internal/config/key_order_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -// KeyOrder is the configuration document's top-level order, then the override -// document's. func TestLoadKeepsTheDocumentKeyOrder(t *testing.T) { dir := t.TempDir() base := filepath.Join(dir, "devcontainer.json") diff --git a/internal/config/loader.go b/internal/config/loader.go index 1444218..0660af5 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1,4 +1,3 @@ -// Package config loads, parses and variable-substitutes devcontainer.json configuration. package config import ( @@ -18,9 +17,6 @@ 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 } @@ -29,8 +25,6 @@ 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 @@ -38,8 +32,6 @@ func wrapParseError(what, path string, err error) error { 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 { @@ -52,9 +44,6 @@ func parseConfigObject(data []byte, path string) (map[string]any, error) { 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. type NotFoundError struct { Path string } @@ -63,42 +52,22 @@ func (e *NotFoundError) Error() string { return fmt.Sprintf("Dev container config (%s) not found.", e.Path) } -// LoadResult contains a loaded and substituted devcontainer configuration. type LoadResult struct { - 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. + Config *DevContainer + Raw map[string]any 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. - HostSub HostSubContext - // KeyOrder is the configuration's top-level properties in the order its - // document lists them (the override's after the base's), the order TS - // writes them in (jsonc.parse keeps it). - KeyOrder []string + HostSub HostSubContext + KeyOrder []string } -// WorkspaceConfig describes the workspace mount and folder inside the container. type WorkspaceConfig struct { - WorkspaceFolder string `json:"workspaceFolder"` - WorkspaceMount string `json:"workspaceMount,omitempty"` - // AdditionalMounts holds extra bind mounts the workspace needs beyond the - // workspace mount itself — currently the Git worktree common dir, so Git - // operations work inside the container for `git worktree add --relative-paths` - // checkouts (--mount-git-worktree-common-dir). + WorkspaceFolder string `json:"workspaceFolder"` + WorkspaceMount string `json:"workspaceMount,omitempty"` AdditionalMounts []string `json:"-"` - // NoWorkspaceMount is set when the configuration's workspaceMount is empty - // or null: the container gets no workspace mount (TS spawnDevContainer - // passes no --mount for a falsy workspaceMount). - NoWorkspaceMount bool `json:"-"` + NoWorkspaceMount bool `json:"-"` } -// Workspace describes the local workspace. type Workspace struct { IsWorkspaceFile bool WorkspaceOrFolderPath string @@ -106,7 +75,6 @@ type Workspace struct { ConfigFolderPath string } -// WorkspaceFromPath creates a Workspace from a local folder path. func WorkspaceFromPath(folderPath string) *Workspace { ext := filepath.Ext(folderPath) if ext == ".code-workspace" { @@ -126,9 +94,6 @@ func WorkspaceFromPath(folderPath string) *Workspace { } } -// FindConfigFile discovers the devcontainer.json path using the standard search order: -// 1. .devcontainer/devcontainer.json -// 2. .devcontainer.json func FindConfigFile(configFolderPath string) string { candidates := []string{ filepath.Join(configFolderPath, ".devcontainer", "devcontainer.json"), @@ -142,37 +107,18 @@ func FindConfigFile(configFolderPath string) string { return "" } -// MountOptions controls how the workspace is bind-mounted into the container. type MountOptions struct { - // MountWorkspaceGitRoot mounts the workspace's Git root rather than the - // workspace folder itself (--mount-workspace-git-root, default true). - MountWorkspaceGitRoot bool - // MountGitWorktreeCommonDir additionally mounts a Git worktree's common dir so - // Git works in the container for `git worktree add --relative-paths` checkouts - // (--mount-git-worktree-common-dir, default false). + MountWorkspaceGitRoot bool MountGitWorktreeCommonDir bool } -// LoadDevContainerConfig loads and parses a devcontainer.json file with the -// default mount behavior (mount the Git root, no worktree common dir). If -// configPath is empty, it discovers the config file from workspaceFolder. func LoadDevContainerConfig(workspaceFolder, configPath, overrideConfigPath string) (*LoadResult, error) { return LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfigPath, MountOptions{MountWorkspaceGitRoot: true}) } -// LoadDevContainerConfigWithMounts is LoadDevContainerConfig with explicit mount -// options, so commands that create the container (up) or report its config -// (read-configuration) can honor --mount-workspace-git-root / -// --mount-git-worktree-common-dir. func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfigPath string, mounts MountOptions) (*LoadResult, error) { workspace := WorkspaceFromPath(workspaceFolder) - // Discover config path. As TS does (getDevContainerConfigPathIn, then - // getDefaultDevContainerConfigPath), a workspace without a devcontainer.json - // names its default path when --override-config is given: that path is the - // configFilePath relative paths in the override resolve against. Without a - // workspace, only the override names the configuration (configPath = - // overrideConfigFile), so nothing is discovered in the current directory. if configPath == "" && (workspaceFolder != "" || overrideConfigPath == "") { configPath = FindConfigFile(workspace.ConfigFolderPath) if configPath == "" && overrideConfigPath != "" { @@ -184,15 +130,6 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi return nil, &NotFoundError{Path: defaultPath} } - // Read the base config and (when given) deep-merge the override on top. - // - // NOTE: deliberate Go divergence. TS replaces the config wholesale with the - // override file (readDocument(overrideConfigFile ?? configFile)); Go deep-merges - // 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). - // 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 var keyOrder []string @@ -211,9 +148,6 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi if overrideConfigPath != "" { overrideData, oerr := os.ReadFile(overrideConfigPath) if errors.Is(oerr, fs.ErrNotExist) { - // TS reads the override in place of the configuration - // (readDocument(overrideConfigFile ?? configFile)): a missing one is the - // configuration not found, named by the configuration's path. return nil, &NotFoundError{Path: objectPath} } if oerr != nil { @@ -227,12 +161,9 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi keyOrder = append(keyOrder, jsonc.Names(overrideData)...) } - // 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 if err := remarshal(raw, &config); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) @@ -245,11 +176,8 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi _, config.workspaceMountSet = raw["workspaceMount"] - // Compute workspace config using the caller's mount options. wsConfig := computeWorkspaceConfig(workspace, &config, mounts.MountWorkspaceGitRoot, mounts.MountGitWorktreeCommonDir) - // Apply host-side variable substitution - // Trim trailing slash from paths to avoid double-slash in substitution localWS := strings.TrimRight(workspace.RootFolderPath, "/\\") ctx := HostSubContext{ Platform: currentPlatform(), @@ -259,8 +187,6 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi ConfigFilePath: configFilePath, } - // A variable that cannot be resolved is TS's ContainerError, reported as - // it is (no prefix of Go's own). substituted, err := NewVariableResolver().Resolve(SubstitutionContext{HostSubContext: ctx}, PhaseHost, raw) if err != nil { return nil, err @@ -269,20 +195,13 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi raw = m } - // Re-unmarshal substituted values into the config struct - // We do this by marshaling the substituted raw and unmarshaling again - // This ensures all string fields have variables resolved if err := remarshal(raw, &config); err != nil { return nil, fmt.Errorf("apply substitutions: %w", err) } - // Update workspace folder from config if specified if config.WorkspaceFolder != "" { wsConfig.WorkspaceFolder = config.WorkspaceFolder } - // A workspaceMount the configuration sets replaces the computed one, even - // when it is empty or null, which mounts no workspace (TS - // readDevContainerConfigFile's `'workspaceMount' in config`). if config.workspaceMountSet { wsConfig.WorkspaceMount = config.WorkspaceMount wsConfig.NoWorkspaceMount = config.WorkspaceMount == "" @@ -300,10 +219,6 @@ func LoadDevContainerConfigWithMounts(workspaceFolder, configPath, overrideConfi }, nil } -// deepMergeConfig deep-merges the override map onto the base map: nested objects -// 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]any) map[string]any { if base == nil { return override @@ -327,12 +242,9 @@ func deepMergeConfig(base, override map[string]any) map[string]any { return out } -// DockerComposeFilePaths resolves the docker-compose file paths. -// Follows the TS fallback chain: config → COMPOSE_FILE env → .env file → defaults. func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd string) ([]string, error) { configDir := filepath.Dir(config.ConfigFilePath) - // 1. From config property if len(config.DockerComposeFile) > 0 { paths := make([]string, len(config.DockerComposeFile)) for i, f := range config.DockerComposeFile { @@ -341,7 +253,6 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str return paths, nil } - // 2. COMPOSE_FILE env var if composeFile, ok := env["COMPOSE_FILE"]; ok && composeFile != "" { sep := string(os.PathListSeparator) parts := strings.Split(composeFile, sep) @@ -352,7 +263,6 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str return paths, nil } - // 3. .env file envFilePath := filepath.Join(cwd, ".env") if pfs.IsFile(envFilePath) { data, err := os.ReadFile(envFilePath) @@ -376,7 +286,6 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str } } - // 4. Defaults defaults := []string{pfs.Resolve(cwd, "docker-compose.yml")} override := pfs.Resolve(cwd, "docker-compose.override.yml") if pfs.IsFile(override) { @@ -385,12 +294,7 @@ func DockerComposeFilePaths(config *DevContainer, env map[string]string, cwd str return defaults, nil } -// --- Helpers --- - func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWorkspaceGitRoot, mountGitWorktreeCommonDir bool) *WorkspaceConfig { - // Docker Compose manages its own mounts, so the workspace folder is the - // config's own or `/`, never derived from the host folder (TS - // getWorkspaceConfiguration via getRemoteWorkspaceFolder). if config.IsComposeConfig() { folder := config.WorkspaceFolder if folder == "" { @@ -398,38 +302,24 @@ func computeWorkspaceConfig(workspace *Workspace, config *DevContainer, mountWor } return &WorkspaceConfig{WorkspaceFolder: folder} } - // 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 == "" { return &WorkspaceConfig{WorkspaceFolder: config.WorkspaceFolder, WorkspaceMount: config.WorkspaceMount} } sourceFolder := workspace.RootFolderPath - // Detect git root for workspace mounting (matches TS getHostMountFolder) if mountWorkspaceGitRoot { if gitRoot := detectGitRoot(sourceFolder); gitRoot != "" { sourceFolder = gitRoot } } - // 0.88 only appends consistency on non-Linux hosts (macOS/Windows); on Linux - // the bind mount carries no consistency= suffix (getWorkspaceConfiguration). consistency := "" if runtime.GOOS != "linux" { consistency = ",consistency=consistent" } - // The workspace mounts at /workspaces/. A Git worktree created with - // `git worktree add --relative-paths` remaps this so the worktree's relative - // gitdir still resolves to the mounted common dir inside the container. containerMountFolder := "/workspaces/" + filepath.Base(sourceFolder) var additionalMounts []string - // TS getWorkspaceConfiguration computes the workspace mount, and with it the - // worktree common-dir mount, only when the config leaves workspaceFolder or - // workspaceMount to it: a config that sets both gets neither. The common dir - // lands relative to the computed container mount folder, whatever target a - // custom workspaceMount names. hasWorkspaceMount := config.WorkspaceMount != "" || config.workspaceMountSet computed := config.WorkspaceFolder == "" || !hasWorkspaceMount if computed && mountWorkspaceGitRoot && mountGitWorktreeCommonDir { @@ -439,8 +329,6 @@ 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 sourceFolder != workspace.RootFolderPath { if rel, err := filepath.Rel(sourceFolder, workspace.RootFolderPath); err == nil { @@ -478,14 +366,6 @@ func bindMount(source, target, consistency string) string { srcQuote, source, srcQuote, tgtQuote, target, tgtQuote, consistency) } -// gitWorktreeCommonDirMount ports getWorkspaceConfiguration's worktree handling: -// when hostMountFolder is a Git worktree whose `.git` gitlink file points at a -// RELATIVE gitdir (i.e. created with `git worktree add --relative-paths`), it -// returns a remapped container mount folder that preserves enough of the host -// path structure for that relative path to resolve, plus a bind mount that maps -// the shared common dir (the main repo's `.git`) into the container. ok is false -// for a normal clone (`.git` is a directory), an absolute gitdir, or a missing -// gitlink — all cases where no extra mount is needed. func gitWorktreeCommonDirMount(hostMountFolder, consistency string) (containerMountFolder, additionalMount string, ok bool) { info, err := os.Stat(filepath.Join(hostMountFolder, ".git")) if err != nil || !info.Mode().IsRegular() { @@ -503,9 +383,6 @@ func gitWorktreeCommonDirMount(hostMountFolder, consistency string) (containerMo // gitdir points at .git/worktrees/; the common dir is .git, two levels up. gitCommonDir := filepath.Clean(filepath.Join(hostMountFolder, gitdir, "..", "..")) - // Collect the host path segments from hostMountFolder up to (but not past) the - // directory that also contains gitCommonDir, so the container mount keeps just - // enough structure for the relative gitdir to resolve. sep := string(os.PathSeparator) var segments []string for current := hostMountFolder; !strings.HasPrefix(gitCommonDir, current+sep) && current != filepath.Dir(current); current = filepath.Dir(current) { @@ -517,8 +394,6 @@ func gitWorktreeCommonDirMount(hostMountFolder, consistency string) (containerMo return containerMountFolder, bindMount(gitCommonDir, containerGitCommonDir, consistency), true } -// 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.SplitSeq(content, "\n") { line = strings.TrimRight(line, "\r") @@ -529,49 +404,30 @@ func parseGitdir(content string) (string, bool) { return "", false } -// detectGitRoot finds the git working-tree root for the given path by walking up -// the directory tree looking for a `.git` entry — no `git` binary. -// -// The walk is purely lexical (via filepath.Dir), matching git's `--show-cdup` -// rather than `--show-toplevel`: it does NOT canonicalize symlinks (on macOS -// `/tmp` → `/private/tmp`), which would desync sourceFolder from -// workspace.RootFolderPath and produce a corrupt workspaceFolder (e.g. -// `/workspaces/x/../../..`) and a different mount. Returns "" when dir is not -// inside a repository. func detectGitRoot(dir string) string { return detectGitRootUntil(dir, "") } -// detectGitRootUntil is detectGitRoot with an optional ceiling: the walk stops -// (returning "") once it has checked stopAt, so it never ascends past it. stopAt -// == "" walks all the way to the filesystem root. The ceiling exists so tests can -// bound the walk to an isolated tree instead of depending on no ancestor of the -// system temp dir happening to contain a `.git`. func detectGitRootUntil(dir, stopAt string) string { dir = filepath.Clean(dir) if stopAt != "" { stopAt = filepath.Clean(stopAt) } for { - // `.git` is a directory in a normal clone and a regular file (a gitlink) - // in a worktree or submodule checkout — accept either. if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (info.IsDir() || info.Mode().IsRegular()) { return dir } if dir == stopAt { - return "" // reached the ceiling without finding a repo + return "" } parent := filepath.Dir(dir) if parent == dir { - return "" // reached the filesystem root without finding a repo + return "" } dir = parent } } -// EnvHostSubContext is the host substitution context without a configuration -// file: the platform and this process's environment, which TS substitutes a -// command's empty configuration with ({ platform, env }). func EnvHostSubContext() HostSubContext { return HostSubContext{Platform: currentPlatform(), Env: EnvMap(os.Environ())} } @@ -580,12 +436,9 @@ func currentPlatform() string { if IsWindows() { return "win32" } - return os.Getenv("GOOS_OVERRIDE") // for testing; falls back to runtime + return os.Getenv("GOOS_OVERRIDE") } -// 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 { diff --git a/internal/config/loader_held_test.go b/internal/config/loader_held_test.go index ceaaffd..1ab56dd 100644 --- a/internal/config/loader_held_test.go +++ b/internal/config/loader_held_test.go @@ -7,9 +7,6 @@ import ( "testing" ) -// A workspace that is not in a repository mounts at /workspaces/ with -// no extra mount, even with the worktree common-dir mount enabled: TS -// getWorkspaceConfiguration finds no `.git` and adds nothing. func TestComputeWorkspaceConfigOutsideARepository(t *testing.T) { ws := filepath.Join(t.TempDir(), "plain") if err := os.MkdirAll(ws, 0o755); err != nil { @@ -21,8 +18,6 @@ func TestComputeWorkspaceConfigOutsideARepository(t *testing.T) { } } -// The ceiling is compared as a clean path, so one written with a trailing -// separator still stops the walk before a `.git` above it. func TestDetectGitRootUntilCleansTheCeiling(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { @@ -38,9 +33,6 @@ func TestDetectGitRootUntilCleansTheCeiling(t *testing.T) { } } -// Off Windows a config's ${localEnv:NAME} is case-sensitive (TS -// replaceWithContext → lookupValue lowercases the name only when the platform -// is win32), so a name in another case than the host variable resolves to "". func TestLoadResolvesLocalEnvCaseSensitivelyOffWindows(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the host is Windows") diff --git a/internal/config/loader_nodocker_held_test.go b/internal/config/loader_nodocker_held_test.go index 835ce40..3dd46dc 100644 --- a/internal/config/loader_nodocker_held_test.go +++ b/internal/config/loader_nodocker_held_test.go @@ -20,8 +20,6 @@ func writeFile(t *testing.T, path, content string) { } } -// 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") @@ -31,12 +29,6 @@ func TestLoadReportsAMissingConfigFile(t *testing.T) { } } -// The config file path is the --config file, else the one found in the -// workspace or, with only an override, the workspace's default path, else -// without a workspace the override file (TS resolveWithLocalFolder's -// configPath; readDevContainerConfigFile reads overrideConfigFile ?? -// configFile but keeps 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") @@ -78,8 +70,6 @@ func TestLoadSetsTheConfigFilePath(t *testing.T) { } } -// 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") @@ -100,9 +90,6 @@ func TestLoadResolvesWorkspaceFolderAndMount(t *testing.T) { } } -// 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") @@ -123,8 +110,6 @@ func TestComputeWorkspaceConfigWorksInTheGitRootsSubfolder(t *testing.T) { } } -// 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")} diff --git a/internal/config/loader_object_held_test.go b/internal/config/loader_object_held_test.go index 3f9d0d4..feaedf0 100644 --- a/internal/config/loader_object_held_test.go +++ b/internal/config/loader_object_held_test.go @@ -9,8 +9,6 @@ import ( "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() @@ -43,10 +41,6 @@ func TestLoadRejectsAConfigThatIsNotAnObject(t *testing.T) { } } -// TS readDevContainerConfigFile validates no property type: a falsy legacy -// "extensions", a "devPort" that customizations.vscode already sets (both left -// in place by updateFromOldProperties) and a "customizations" that is no object -// load, and the raw configuration keeps them as written. func TestLoadAcceptsWrongTypedLegacyAndCustomizationsProperties(t *testing.T) { for _, tc := range []struct { content, key string @@ -80,10 +74,6 @@ func TestLoadAcceptsWrongTypedLegacyAndCustomizationsProperties(t *testing.T) { } } -// Deliberate divergence (docs/DIVERGENCES.md): a devcontainer.json that is not -// valid JSONC fails to load, naming the file and where it breaks. TS parses it -// with jsonc-parser's error recovery and silently keeps what it could read -// ({bad loads as {}, and {"image": , "remoteUser": "u"} drops "image"). func TestLoadRejectsAConfigThatIsNotValidJSONC(t *testing.T) { for _, content := range []string{ `{bad`, diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index c32235e..e1b5cc6 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -69,10 +69,8 @@ func TestFindConfigFile(t *testing.T) { func TestLoadDevContainerConfig(t *testing.T) { tests := []struct { - name string - // files maps a path relative to the temp dir to its content. - files map[string]string - // configPath and overridePath are relative to the temp dir; empty means "". + name string + files map[string]string configPath string overridePath string wantErr bool @@ -340,7 +338,6 @@ func TestGetDockerComposeFilePaths_Defaults(t *testing.T) { t.Fatalf("paths len = %d", len(paths)) } - // Add override os.WriteFile(filepath.Join(dir, "docker-compose.override.yml"), []byte("version: '3'"), 0644) paths, err = DockerComposeFilePaths(c, map[string]string{}, dir) if err != nil { @@ -380,7 +377,6 @@ func TestLoadAllFixtures(t *testing.T) { continue } - // Verify variant detection is consistent variants := 0 if result.Config.IsImageConfig() { variants++ @@ -399,9 +395,6 @@ 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") @@ -438,11 +431,6 @@ func TestLoadMovesDeprecatedPropertiesAndKeepsRawConfig(t *testing.T) { } } -// TS names a workspace's default config path (getDefaultDevContainerConfigPath) -// as configFilePath when the workspace has no devcontainer.json and -// --override-config is given, so relative paths in the override (a Dockerfile, -// a local Feature, ${localWorkspaceFolder}-relative files) resolve against -// /.devcontainer, not the override's folder. func TestLoadDevContainerConfigOverrideWithoutWorkspaceConfig(t *testing.T) { ws := t.TempDir() override := filepath.Join(t.TempDir(), "override.json") @@ -464,9 +452,6 @@ func TestLoadDevContainerConfigOverrideWithoutWorkspaceConfig(t *testing.T) { } } -// Without a workspace, TS's configuration is the override itself -// (configPath = overrideConfigFile): a devcontainer.json in the current -// directory is not discovered. func TestLoadDevContainerConfigOverrideWithoutWorkspace(t *testing.T) { cwd := t.TempDir() if err := os.MkdirAll(filepath.Join(cwd, ".devcontainer"), 0o755); err != nil { @@ -492,10 +477,6 @@ func TestLoadDevContainerConfigOverrideWithoutWorkspace(t *testing.T) { } } -// TS reads the override in place of the configuration, so a missing override -// is the configuration not found, named by the configuration's path: --config, -// the one found in the workspace, the workspace's default path, or without a -// workspace the override itself (configContainer.ts resolveWithLocalFolder). func TestLoadDevContainerConfigMissingOverrideIsNotFound(t *testing.T) { ws := t.TempDir() withConfig := t.TempDir() @@ -528,8 +509,6 @@ func TestLoadDevContainerConfigMissingOverrideIsNotFound(t *testing.T) { } } -// Only a missing override is a configuration not found; one that cannot be -// read for another reason keeps its read error. func TestLoadDevContainerConfigUnreadableOverride(t *testing.T) { _, err := LoadDevContainerConfig(t.TempDir(), "", t.TempDir()) if _, ok := errors.AsType[*NotFoundError](err); ok || err == nil || !strings.Contains(err.Error(), "read override config") { @@ -537,9 +516,6 @@ func TestLoadDevContainerConfigUnreadableOverride(t *testing.T) { } } -// A host variable without a name is TS's ContainerError (variableSubstitution.ts), -// its description the whole text: the loader adds no prefix of its own, so -// commands report it as TS does. func TestLoadDevContainerConfigUnresolvableVariableIsAContainerError(t *testing.T) { ws := t.TempDir() cfg := filepath.Join(ws, ".devcontainer", "devcontainer.json") diff --git a/internal/config/override_merge_test.go b/internal/config/override_merge_test.go index dfc8831..0cba3b1 100644 --- a/internal/config/override_merge_test.go +++ b/internal/config/override_merge_test.go @@ -18,7 +18,7 @@ func TestDeepMergeConfig(t *testing.T) { } override := map[string]any{ "remoteUser": "root", - "runArgs": []any{"--b"}, // arrays replace, not append + "runArgs": []any{"--b"}, "customizations": map[string]any{ "vscode": map[string]any{"settings": map[string]any{"k": "v"}}, }, @@ -34,7 +34,6 @@ func TestDeepMergeConfig(t *testing.T) { 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]any)["vscode"].(map[string]any) if !reflect.DeepEqual(vscode["extensions"], []any{"x"}) { t.Errorf("nested base key lost: extensions=%v", vscode["extensions"]) @@ -73,7 +72,6 @@ func TestLoadDevContainerConfigMergesOverride(t *testing.T) { if err != nil { t.Fatalf("load: %v", err) } - // Deep-merge (Go divergence): base image/runArgs survive, override wins remoteUser. if result.Config.Image != "ubuntu:22.04" { t.Errorf("base image lost after override merge: %q", result.Config.Image) } diff --git a/internal/config/reference_oracle_test.go b/internal/config/reference_oracle_test.go index 6f41132..7a04cdc 100644 --- a/internal/config/reference_oracle_test.go +++ b/internal/config/reference_oracle_test.go @@ -1,15 +1,5 @@ package config -// Oracle test ported from the upstream devcontainers CLI: -// reference/src/test/workspaceConfiguration.test.ts -// (describe 'getWorkspaceConfiguration' > 'basic workspace mounting'). -// -// This covers the basic /workspaces/ mount; the git-worktree -// common-dir cases from the same upstream suite are ported in worktree_test.go. -// The platform matrix is reduced to linux, the CI runtime (consistency is empty -// on linux; computeWorkspaceConfig reads runtime.GOOS directly and cannot be -// faked hermetically). - import ( "runtime" "testing" @@ -19,7 +9,6 @@ func TestOracle_WorkspaceMountBasename(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("oracle pins the linux consistency behavior") } - // mountWorkspaceGitRoot=false, no .git → the workspace folder mounts directly. wc := computeWorkspaceConfig(&Workspace{RootFolderPath: "/home/user/project"}, &DevContainer{}, false, false) if wc.WorkspaceFolder != "/workspaces/project" { t.Errorf("workspaceFolder = %q, want /workspaces/project", wc.WorkspaceFolder) diff --git a/internal/config/spec_behavior_test.go b/internal/config/spec_behavior_test.go index fe79ebd..e99957f 100644 --- a/internal/config/spec_behavior_test.go +++ b/internal/config/spec_behavior_test.go @@ -6,10 +6,6 @@ import ( "testing" ) -// TestSpecConfigDiscoveryPrecedence locks the specification's config discovery -// order (containers.dev/implementors/spec, "config location"): a tool searches -// for devcontainer.json as .devcontainer/devcontainer.json first, then the -// top-level .devcontainer.json. func TestSpecConfigDiscoveryPrecedence(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".devcontainer"), 0o755); err != nil { @@ -24,18 +20,15 @@ func TestSpecConfigDiscoveryPrecedence(t *testing.T) { t.Fatal(err) } - // Both present → .devcontainer/devcontainer.json wins. if got := FindConfigFile(dir); got != nested { t.Errorf("both present: FindConfigFile = %q, want %q (.devcontainer/ precedence)", got, nested) } - // Only the top-level .devcontainer.json. if err := os.Remove(nested); err != nil { t.Fatal(err) } if got := FindConfigFile(dir); got != root { t.Errorf("root only: FindConfigFile = %q, want %q", got, root) } - // Neither → not found. if err := os.Remove(root); err != nil { t.Fatal(err) } @@ -44,16 +37,11 @@ func TestSpecConfigDiscoveryPrecedence(t *testing.T) { } } -// TestSpecDevContainerIDDeterminism locks the spec's ${devcontainerId} contract: -// a stable identifier derived from the container's id-labels — the same label -// set always yields the same id (independent of map order), and different label -// sets yield different ids. func TestSpecDevContainerIDDeterminism(t *testing.T) { a := map[string]string{ "devcontainer.local_folder": "/x", "devcontainer.config_file": "/x/.devcontainer/devcontainer.json", } - // Same labels, different insertion order (Go maps iterate randomly). b := map[string]string{ "devcontainer.config_file": "/x/.devcontainer/devcontainer.json", "devcontainer.local_folder": "/x", diff --git a/internal/config/spec_compliance_test.go b/internal/config/spec_compliance_test.go index 1fe23af..f814c2e 100644 --- a/internal/config/spec_compliance_test.go +++ b/internal/config/spec_compliance_test.go @@ -9,45 +9,22 @@ import ( "testing" ) -// TestSpecCompliance gates devcontainer.json schema compliance: every property -// defined in the official devContainer.base.schema.json MUST be modeled by a -// json tag somewhere in the config struct tree, so nothing the spec defines is -// silently dropped on parse / read-configuration / metadata round-trip. -// -// This is the config analog of TestFlagInventoryParity. It excludes our Go-only -// additions by construction: it only fails when a SPEC property is missing from -// our structs, never when we carry extra fields the spec doesn't define. -// -// The schema is vendored (pinned) at testdata/devContainer.base.schema.json from -// github.com/devcontainers/spec (schemas/devContainer.base.schema.json). Refresh -// it with `task spec:schema-update`; a new spec property then fails this test -// until it is added to the struct — that is the "always following the spec" gate. func TestSpecCompliance(t *testing.T) { - // nonModeled lists schema property names we deliberately do not model as - // dedicated struct fields, each with the reason. Everything else MUST map to - // a json tag. Keep this list tiny and justified — a genuinely new config - // property must NOT be added here to silence the test; add the struct field. nonModeled := map[string]string{ "$schema": "JSON-schema meta key, not a devcontainer.json property", "additionalProperties": "JSON-schema keyword surfaced by the walk, not a property", - // Deprecated legacy feature keys nested under `features` (an open map we - // preserve wholesale via Features map[string]interface{}). - "fish": "legacy feature key under `features`", - "maven": "legacy feature key under `features`", - "gradle": "legacy feature key under `features`", - "jupyterlab": "legacy feature key under `features`", - "homebrew": "legacy feature key under `features`", - // Sub-properties of a `secrets` value (open map, preserved wholesale). - "description": "sub-property of a `secrets` value", - "documentationUrl": "sub-property of a `secrets` value", - // Sub-property of hostRequirements.gpu, which we preserve as raw JSON. - "cores": "sub-property of hostRequirements.gpu (kept as raw JSON)", + "fish": "legacy feature key under `features`", + "maven": "legacy feature key under `features`", + "gradle": "legacy feature key under `features`", + "jupyterlab": "legacy feature key under `features`", + "homebrew": "legacy feature key under `features`", + "description": "sub-property of a `secrets` value", + "documentationUrl": "sub-property of a `secrets` value", + "cores": "sub-property of hostRequirements.gpu (kept as raw JSON)", } schemaProps := schemaPropertyNames(t) 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.TypeFor[Mount]()) { structTags[name] = true } @@ -67,8 +44,6 @@ func TestSpecCompliance(t *testing.T) { } } -// schemaPropertyNames returns every property name defined anywhere under a -// "properties" object in the vendored schema. func schemaPropertyNames(t *testing.T) map[string]bool { t.Helper() data, err := os.ReadFile("testdata/devContainer.base.schema.json") @@ -102,8 +77,6 @@ func schemaPropertyNames(t *testing.T) map[string]bool { return out } -// structJSONTags collects every json tag name reachable from t (recursing into -// struct fields, pointers, slices/arrays and map values). func structJSONTags(t reflect.Type) map[string]bool { out := map[string]bool{} seen := map[reflect.Type]bool{} diff --git a/internal/config/types.go b/internal/config/types.go index 6f8decc..94fa26b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -5,22 +5,13 @@ import ( "maps" ) -// DevContainer represents a devcontainer.json. -// It is a flat struct covering all three variants (image, Dockerfile, docker-compose). -// Use IsDockerfileConfig / IsComposeConfig to discriminate. type DevContainer struct { - // Runtime — not serialized from JSON - ConfigFilePath string `json:"configFilePath,omitempty"` - // workspaceMountSet records that the configuration has a workspaceMount - // property, even an empty or null one, which WorkspaceMount cannot tell - // from an absent one (TS getWorkspaceConfiguration's `'workspaceMount' in - // config`). + ConfigFilePath string `json:"configFilePath,omitempty"` workspaceMountSet bool - // --- Common properties (all variants) --- Name string `json:"name,omitempty"` ForwardPorts []PortOrString `json:"forwardPorts,omitempty"` - AppPort json.RawMessage `json:"appPort,omitempty"` // number | string | (number|string)[] + AppPort json.RawMessage `json:"appPort,omitempty"` PortsAttributes map[string]PortAttrs `json:"portsAttributes,omitempty"` OtherPortsAttributes *PortAttrs `json:"otherPortsAttributes,omitempty"` RunArgs []string `json:"runArgs,omitempty"` @@ -50,38 +41,25 @@ type DevContainer struct { OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` HostRequirements *HostRequirements `json:"hostRequirements,omitempty"` Customizations Customizations `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]any `json:"secrets,omitempty"` + Secrets map[string]any `json:"secrets,omitempty"` - // --- Image variant --- Image string `json:"image,omitempty"` - // --- Dockerfile variant --- - DockerFile string `json:"dockerFile,omitempty"` // legacy top-level + DockerFile string `json:"dockerFile,omitempty"` Build *Build `json:"build,omitempty"` - Context string `json:"context,omitempty"` // legacy top-level + Context string `json:"context,omitempty"` - // --- Docker Compose variant --- DockerComposeFile StringOrStrings `json:"dockerComposeFile,omitempty"` Service string `json:"service,omitempty"` RunServices []string `json:"runServices,omitempty"` - // --- Deprecated / VS Code specific (migrated by UpdateFromOldProperties) --- - // Untyped: TS neither validates nor reads what updateFromOldProperties - // leaves behind (a falsy "extensions", a "devPort" vscode already has). Extensions any `json:"extensions,omitempty"` Settings any `json:"settings,omitempty"` DevPort any `json:"devPort,omitempty"` } -// Customizations is the "customizations" object of a configuration. TS -// neither validates it nor fails on another JSON value; such a value is no -// customizations here, and the raw configuration still carries it as written. type Customizations map[string]any -// UnmarshalJSON decodes an object; any other JSON value decodes to nil. func (c *Customizations) UnmarshalJSON(data []byte) error { var v any if err := json.Unmarshal(data, &v); err != nil { @@ -92,7 +70,6 @@ func (c *Customizations) UnmarshalJSON(data []byte) error { return nil } -// Build represents the "build" property in devcontainer.json. type Build struct { Dockerfile string `json:"dockerfile,omitempty"` Context string `json:"context,omitempty"` @@ -102,11 +79,6 @@ type Build struct { Options []string `json:"options,omitempty"` } -// PortAttrs represents port attribute configuration. Fields mirror the spec's -// PortAttributes object (containers.dev json_reference): label, protocol -// (http|https), onAutoForward, requireLocalPort, elevateIfNeeded. The CLI does -// not act on these (port forwarding is the editor's job) but must preserve them -// through read-configuration and the devcontainer.metadata label. type PortAttrs struct { Label string `json:"label,omitempty"` Protocol string `json:"protocol,omitempty"` @@ -115,44 +87,33 @@ type PortAttrs struct { ElevateIfNeeded *bool `json:"elevateIfNeeded,omitempty"` } -// HostRequirements specifies minimum host resources. type HostRequirements struct { CPUs *int `json:"cpus,omitempty"` Memory string `json:"memory,omitempty"` Storage string `json:"storage,omitempty"` - GPU json.RawMessage `json:"gpu,omitempty"` // bool | "optional" | object + GPU json.RawMessage `json:"gpu,omitempty"` } -// Mount represents a structured mount definition. type Mount struct { Type string `json:"type,omitempty"` Source string `json:"source,omitempty"` Target string `json:"target,omitempty"` External *bool `json:"external,omitempty"` - // Readonly maps a mount object to a read-only bind/volume for BOTH the - // docker (`--mount ...,readonly`) and docker-compose (`:ro`) paths, so a - // single Feature/config mount object works in either mode (upstream #881). - Readonly *bool `json:"readonly,omitempty"` + Readonly *bool `json:"readonly,omitempty"` } -// --- Type discrimination --- - -// IsDockerfileConfig returns true if the config specifies a Dockerfile build. func (c *DevContainer) IsDockerfileConfig() bool { return c.DockerFile != "" || (c.Build != nil && c.Build.Dockerfile != "") } -// IsComposeConfig returns true if the config specifies docker-compose. func (c *DevContainer) IsComposeConfig() bool { return len(c.DockerComposeFile) > 0 } -// IsImageConfig returns true if the config specifies a pre-built image (no Dockerfile or Compose). func (c *DevContainer) IsImageConfig() bool { return !c.IsDockerfileConfig() && !c.IsComposeConfig() } -// Dockerfile returns the Dockerfile path from either legacy or build.dockerfile. func (c *DevContainer) Dockerfile() string { if c.DockerFile != "" { return c.DockerFile @@ -163,7 +124,6 @@ func (c *DevContainer) Dockerfile() string { return "" } -// BuildContext returns the build context path. func (c *DevContainer) BuildContext() string { if c.Context != "" { return c.Context @@ -174,12 +134,8 @@ func (c *DevContainer) BuildContext() string { return "" } -// --- Custom union types --- - -// LifecycleCommand can be string, []string, or map[string]interface{}. -// This matches the TS type: string | string[] | Record. type LifecycleCommand struct { - raw any // string, []interface{}, or map[string]interface{} + raw any } func (c *LifecycleCommand) UnmarshalJSON(data []byte) error { @@ -193,18 +149,15 @@ func (c LifecycleCommand) MarshalJSON() ([]byte, error) { return json.Marshal(c.raw) } -// IsEmpty returns true if no command is specified. func (c *LifecycleCommand) IsEmpty() bool { return c.raw == nil } -// AsString returns the command as a string, or empty if not a string. func (c *LifecycleCommand) AsString() (string, bool) { s, ok := c.raw.(string) return s, ok } -// AsStringSlice returns the command as []string, or nil. func (c *LifecycleCommand) AsStringSlice() ([]string, bool) { arr, ok := c.raw.([]any) if !ok { @@ -221,20 +174,17 @@ func (c *LifecycleCommand) AsStringSlice() ([]string, bool) { return strs, true } -// AsMap returns the command as a map (for parallel commands), or nil. 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() any { return c.raw } -// PortOrString represents a port that can be number or string (e.g., "8080:80"). type PortOrString struct { - raw any // float64 (JSON number) or string + raw any } func (p *PortOrString) UnmarshalJSON(data []byte) error { @@ -245,24 +195,20 @@ 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 any // string or map → Mount + raw any } func (m *MountOrString) UnmarshalJSON(data []byte) error { - // Try string first var s string if err := json.Unmarshal(data, &s); err == nil { m.raw = s return nil } - // Try mount object var mount Mount if err := json.Unmarshal(data, &mount); err == nil { m.raw = mount @@ -275,13 +221,11 @@ func (m MountOrString) MarshalJSON() ([]byte, error) { return json.Marshal(m.raw) } -// AsString returns the mount as a string, or empty. func (m *MountOrString) AsString() (string, bool) { s, ok := m.raw.(string) return s, ok } -// AsMount returns the mount as a structured Mount. func (m *MountOrString) AsMount() (*Mount, bool) { mt, ok := m.raw.(Mount) if ok { @@ -290,18 +234,14 @@ func (m *MountOrString) AsMount() (*Mount, bool) { return nil, false } -// StringOrStrings can be a single string or an array of strings. -// Used for dockerComposeFile, cacheFrom. type StringOrStrings []string func (s *StringOrStrings) UnmarshalJSON(data []byte) error { - // Try single string var single string if err := json.Unmarshal(data, &single); err == nil { *s = []string{single} return nil } - // Try array var arr []string if err := json.Unmarshal(data, &arr); err != nil { return err @@ -317,15 +257,6 @@ func (s StringOrStrings) MarshalJSON() ([]byte, error) { return json.Marshal([]string(s)) } -// --- Legacy property migration --- - -// 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 { @@ -373,7 +304,6 @@ func updateFromOldProperties(raw map[string]any) map[string]any { return out } -// truthy is JavaScript truthiness for a JSON value. func truthy(v any) bool { switch x := v.(type) { case nil: diff --git a/internal/config/types_held_test.go b/internal/config/types_held_test.go index 19021e9..6f9121e 100644 --- a/internal/config/types_held_test.go +++ b/internal/config/types_held_test.go @@ -5,9 +5,6 @@ import ( "testing" ) -// The build context is the legacy top-level "context", else build.context, else -// none (TS getDockerContextPath, spec-node/utils.ts: `'dockerFile' in config ? -// config.context : config.build.context`). func TestBuildContextPrefersTheLegacyProperty(t *testing.T) { for in, want := range map[string]string{ `{"dockerFile":"D","context":"legacy","build":{"context":"build"}}`: "legacy", @@ -25,8 +22,6 @@ func TestBuildContextPrefersTheLegacyProperty(t *testing.T) { } } -// A lifecycle command marshals back as it was written - the string, array or -// object TS keeps as-is in the metadata label - and an absent one as null. func TestLifecycleCommandMarshalsAsWritten(t *testing.T) { for _, in := range []string{`"echo hi"`, `["echo","hi"]`, `{"a":"echo a"}`, `null`} { var c LifecycleCommand @@ -46,8 +41,6 @@ func TestLifecycleCommandMarshalsAsWritten(t *testing.T) { } } -// A single dockerComposeFile marshals as the string, more than one as the array -// (the `string | string[]` shape the spec gives the property). func TestStringOrStringsMarshalsOneAsAString(t *testing.T) { for in, want := range map[string]string{ `"a.yml"`: `"a.yml"`, diff --git a/internal/config/types_nodocker_held_test.go b/internal/config/types_nodocker_held_test.go index c9e524a..135d121 100644 --- a/internal/config/types_nodocker_held_test.go +++ b/internal/config/types_nodocker_held_test.go @@ -6,9 +6,6 @@ import ( "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}}}`}, diff --git a/internal/config/types_test.go b/internal/config/types_test.go index c25bef3..f3ac65d 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -205,9 +205,6 @@ 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) { tests := []struct { name string @@ -258,7 +255,6 @@ func TestFeatures(t *testing.T) { } func TestParseFixture_WithJSONC(t *testing.T) { - // Simulates a JSONC fixture with comments input := []byte(`{ // Dev container config "image": "mcr.microsoft.com/devcontainers/base:ubuntu", @@ -284,10 +280,6 @@ func TestParseFixture_WithJSONC(t *testing.T) { } } -// 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 -// dropped, silently losing them from read-configuration and the metadata label. func TestPortAttrsSpecFields(t *testing.T) { const input = `{ "image": "ubuntu", @@ -319,7 +311,6 @@ func TestPortAttrsSpecFields(t *testing.T) { t.Errorf("otherPortsAttributes = %+v", c.OtherPortsAttributes) } - // Re-marshal must preserve protocol and requireLocalPort (the regression). out, err := json.Marshal(c) if err != nil { t.Fatal(err) diff --git a/internal/config/varsub.go b/internal/config/varsub.go index 992bbd8..49faa6c 100644 --- a/internal/config/varsub.go +++ b/internal/config/varsub.go @@ -30,15 +30,11 @@ type SubstitutionContext struct { ContainerEnv map[string]string } -// VariableResolver applies devcontainer substitutions one explicit phase at a -// time. ${...} tags are scanned by substituteTags. type VariableResolver struct{} func NewVariableResolver() *VariableResolver { return &VariableResolver{} } 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) @@ -62,8 +58,6 @@ func (r *VariableResolver) BeforeContainerInto(ctx SubstitutionContext, target a return r.ResolveInto(ctx, PhaseIdentity, 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 any) error { data, err := json.Marshal(target) if err != nil { @@ -122,9 +116,6 @@ func (r *VariableResolver) resolveString(ctx SubstitutionContext, phase Substitu }) } -// substituteTags rewrites every ${...} tag in s using replace. replace returns -// the replacement text and whether it handled the tag; unhandled tags are left -// verbatim so a later phase can resolve them. An unterminated "${" is kept as-is. func substituteTags(s string, replace func(tag string) (string, bool, error)) (string, error) { if !strings.Contains(s, "${") { return s, nil @@ -218,7 +209,6 @@ func resolveEnvTag(isWin bool, env map[string]string, args []string, tag, config if configFilePath != "" { location = " in " + path.Base(configFilePath) } - // TS throws a ContainerError with this description (variableSubstitution.ts). return "", false, &coreerrors.ContainerError{Description: fmt.Sprintf("'${%s}'%s can not be resolved because no environment variable name is given.", tag, location)} } name := args[0] @@ -230,30 +220,22 @@ func resolveEnvTag(isWin bool, env map[string]string, args []string, tag, config return value, true, nil } if len(args) > 1 { - // Rejoin the remaining parts so a default value may itself contain colons - // (e.g. ${localEnv:REG:my.registry.io:5000/img}); only the first colon - // separates the variable name from its default. return strings.Join(args[1:], ":"), true, nil } return "", true, nil } -// HostSubContext holds values available before a container starts. type HostSubContext struct { - Platform string // "linux", "darwin", "win32" + Platform string LocalWorkspaceFolder string ContainerWorkspaceFolder string Env map[string]string - ConfigFilePath string // for error messages + ConfigFilePath string } -// ComputeDevContainerID produces a deterministic ID from id-labels, as TS -// devcontainerIdForLabels does: the labels' JSON.stringify with sorted keys, -// SHA-256, then the hash as a base-32 number padded to 52 digits. func ComputeDevContainerID(idLabels map[string]string) string { hash := sha256.Sum256([]byte(jsonStringifySorted(idLabels))) str := new(big.Int).SetBytes(hash[:]).Text(32) - // A 256-bit hash has at most 52 base-32 digits. return strings.Repeat("0", 52-len(str)) + str } @@ -316,15 +298,11 @@ func normalizeEnvKeys(env map[string]string) map[string]string { return m } -// posixBasename is node's path.posix.basename: trailing slashes are ignored, -// and "/" has none. func posixBasename(p string) string { p = strings.TrimRight(p, "/") return p[strings.LastIndexByte(p, '/')+1:] } -// winBasename is node's path.win32.basename: both / and \ separate, a drive -// letter is no name, trailing separators are ignored. func winBasename(p string) string { if len(p) >= 2 && p[1] == ':' && ('a' <= p[0]|0x20 && p[0]|0x20 <= 'z') { p = p[2:] @@ -333,7 +311,6 @@ func winBasename(p string) string { return p[strings.LastIndexAny(p, `/\`)+1:] } -// IsWindows returns true if the current platform is Windows. func IsWindows() bool { return runtime.GOOS == "windows" } diff --git a/internal/config/varsub_held_test.go b/internal/config/varsub_held_test.go index b5cb8e9..c02b320 100644 --- a/internal/config/varsub_held_test.go +++ b/internal/config/varsub_held_test.go @@ -2,9 +2,6 @@ package config import "testing" -// An empty ${} is left as written and the tags after it are still resolved: TS -// matches /\$\{(.*?)\}/g, so "${}" is a match whose empty variable no case -// handles (replaceWithContext returns the match), and the scan goes on. func TestResolveKeepsAnEmptyTagAndResolvesTheRest(t *testing.T) { ctx := SubstitutionContext{HostSubContext: HostSubContext{LocalWorkspaceFolder: "/home/me/proj"}} got, err := NewVariableResolver().Resolve(ctx, PhaseHost, "${}-${localWorkspaceFolderBasename}") @@ -16,10 +13,6 @@ func TestResolveKeepsAnEmptyTagAndResolvesTheRest(t *testing.T) { } } -// ${containerEnv:NAME} matches the name case-insensitively only for a Windows -// platform (TS containerSubstitute: normalizeEnv lowercases the keys and -// lookupValue the name when isWindows); elsewhere a name in another case is -// missing and resolves to "". func TestResolveContainerEnvIgnoresCaseOnlyOnWindows(t *testing.T) { for platform, want := range map[string]string{"win32": "/bin", "linux": ""} { ctx := SubstitutionContext{ diff --git a/internal/config/varsub_nodocker_held_test.go b/internal/config/varsub_nodocker_held_test.go index cee1dbc..436f84b 100644 --- a/internal/config/varsub_nodocker_held_test.go +++ b/internal/config/varsub_nodocker_held_test.go @@ -2,10 +2,6 @@ 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", @@ -19,7 +15,6 @@ func TestResolveHostPhaseResolvesTheContainerWorkspaceFolderFirst(t *testing.T) 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 be5e985..397e7e6 100644 --- a/internal/config/varsub_test.go +++ b/internal/config/varsub_test.go @@ -78,15 +78,12 @@ func TestVariableResolverIdentityStrings(t *testing.T) { func TestSubstituteTags(t *testing.T) { upper := func(tag string) (string, bool, error) { return strings.ToUpper(tag), true, nil } - // No tags: input returned unchanged (fast path). if got, _ := substituteTags("plain text", upper); got != "plain text" { t.Errorf("no-tag = %q", got) } - // Multiple tags replaced. if got, _ := substituteTags("a ${one} b ${two}", upper); got != "a ONE b TWO" { t.Errorf("multi = %q", got) } - // Unhandled tag kept verbatim so a later phase can resolve it. keepFoo := func(tag string) (string, bool, error) { if tag == "foo" { return "", false, nil @@ -96,19 +93,15 @@ func TestSubstituteTags(t *testing.T) { if got, _ := substituteTags("${foo}:${bar}", keepFoo); got != "${foo}:X" { t.Errorf("verbatim = %q", got) } - // Unterminated "${" is kept as-is. if got, _ := substituteTags("before ${tag", upper); got != "before ${tag" { t.Errorf("unterminated = %q", got) } - // replace errors abort and propagate. boom := errors.New("boom") if _, err := substituteTags("${x}", func(string) (string, bool, error) { return "", false, boom }); !errors.Is(err, boom) { t.Errorf("error prop = %v", err) } } -// The id is the hash in base 32 padded to 52 digits, also when the hash's -// leading digits are zeros. func TestComputeDevContainerIDIsPaddedTo52Digits(t *testing.T) { padded := false for i := 0; i < 200; i++ { @@ -124,8 +117,6 @@ func TestComputeDevContainerIDIsPaddedTo52Digits(t *testing.T) { } } -// Only a letter followed by a colon is a drive (node path.win32.basename): -// '{' lowercases past 'z', so `{:foo` keeps its colon. func TestWinBasenameDrive(t *testing.T) { for in, want := range map[string]string{`C:foo`: "foo", `c:\a\b\`: "b", `1:foo`: "1:foo", `C:`: "", `{:foo`: "{:foo", "@:foo": "@:foo"} { if got := winBasename(in); got != want { diff --git a/internal/config/varsub_win_held_test.go b/internal/config/varsub_win_held_test.go index 27fefb2..7e5a8a9 100644 --- a/internal/config/varsub_win_held_test.go +++ b/internal/config/varsub_win_held_test.go @@ -2,8 +2,6 @@ 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", diff --git a/internal/config/workspacemount_test.go b/internal/config/workspacemount_test.go index 7d580ed..d085aea 100644 --- a/internal/config/workspacemount_test.go +++ b/internal/config/workspacemount_test.go @@ -5,9 +5,6 @@ import ( "testing" ) -// TestWorkspaceMount_QuotesCommaPath covers the fix: a workspace path containing -// a comma must be quoted in the bind spec, or Docker parses the comma as a mount- -// option boundary and the mount breaks (matches the TS srcQuote/tgtQuote guard). func TestWorkspaceMount_QuotesCommaPath(t *testing.T) { ws := &Workspace{RootFolderPath: "/home/user/project,v2"} wc := computeWorkspaceConfig(ws, &DevContainer{}, false, false) @@ -18,7 +15,6 @@ func TestWorkspaceMount_QuotesCommaPath(t *testing.T) { t.Errorf("comma target not quoted: %q", wc.WorkspaceMount) } - // A normal path is NOT quoted. wc2 := computeWorkspaceConfig(&Workspace{RootFolderPath: "/home/user/project"}, &DevContainer{}, false, false) if strings.Contains(wc2.WorkspaceMount, `"`) { t.Errorf("plain path should not be quoted: %q", wc2.WorkspaceMount) diff --git a/internal/config/worktree_test.go b/internal/config/worktree_test.go index 92a2a0c..c4242e9 100644 --- a/internal/config/worktree_test.go +++ b/internal/config/worktree_test.go @@ -1,16 +1,5 @@ package config -// Oracle tests ported from the upstream devcontainers CLI: -// reference/src/test/workspaceConfiguration.test.ts -// (describe 'getWorkspaceConfiguration' > 'git worktree handling'). -// -// The container-side targets (/workspaces/...) are independent of the temp -// prefix — they derive from the path segments below the common ancestor of the -// worktree and its common dir — so the fixtures use a real t.TempDir() and the -// host-side source paths are computed relative to it. Pinned to linux, where the -// bind mount carries no consistency= suffix (computeWorkspaceConfig reads -// runtime.GOOS directly and cannot be faked hermetically). - import ( "os" "path/filepath" @@ -19,8 +8,6 @@ import ( "testing" ) -// writeGitlink creates a worktree at / whose `.git` gitlink -// file holds the given gitdir line, and returns the absolute worktree path. func writeGitlink(t *testing.T, base, worktreeRel, gitdirLine string) string { t.Helper() wt := filepath.Join(base, filepath.FromSlash(worktreeRel)) @@ -114,8 +101,6 @@ func TestWorktreeCommonDir(t *testing.T) { }) } -// TestWorktreeCommonDirNoMount covers the cases that must NOT produce an extra -// mount: a normal clone (.git dir), an absolute gitdir, and the flag disabled. func TestWorktreeCommonDirNoMount(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("oracle pins the linux consistency behavior (empty suffix)") @@ -152,16 +137,12 @@ func TestWorktreeCommonDirNoMount(t *testing.T) { if len(wc.AdditionalMounts) != 0 { t.Errorf("additionalMounts = %q, want none when the flag is off", wc.AdditionalMounts) } - // Without the remap the mount is the plain basename. if want := "type=bind,source=" + wt + ",target=/workspaces/feature"; wc.WorkspaceMount != want { t.Errorf("workspaceMount = %q, want %q", wc.WorkspaceMount, want) } }) } -// TestLoadDevContainerConfigWithMountsWorktree locks the full config-load wiring: -// the MountGitWorktreeCommonDir option reaches computeWorkspaceConfig and the -// resulting LoadResult carries the common-dir mount. func TestLoadDevContainerConfigWithMountsWorktree(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("oracle pins the linux consistency behavior (empty suffix)") @@ -188,7 +169,6 @@ func TestLoadDevContainerConfigWithMountsWorktree(t *testing.T) { t.Errorf("WorkspaceFolder = %q", res.WorkspaceConfig.WorkspaceFolder) } - // Default load (no worktree option) must NOT add the mount. res2, err := LoadDevContainerConfig(wt, "", "") if err != nil { t.Fatalf("default load: %v", err) @@ -198,11 +178,6 @@ func TestLoadDevContainerConfigWithMountsWorktree(t *testing.T) { } } -// TestWorktreeCommonDirCustomWorkspaceMount holds TS getWorkspaceConfiguration -// (utils.ts): the common-dir mount is computed with the workspace mount, so a -// config that sets both workspaceFolder and workspaceMount gets none, and one -// that sets only workspaceMount gets it at the computed container mount folder, -// whatever the custom mount's type or target. func TestWorktreeCommonDirCustomWorkspaceMount(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("oracle pins the linux consistency behavior (empty suffix)") @@ -251,11 +226,6 @@ func TestWorktreeCommonDirCustomWorkspaceMount(t *testing.T) { } } -// TestLoadKeepsAnEmptyWorkspaceMount holds TS getWorkspaceConfiguration and -// readDevContainerConfigFile (utils.ts, configContainer.ts), which test -// `'workspaceMount' in config`: an empty or null workspaceMount is set, so it -// replaces the computed mount and mounts no workspace, and with workspaceFolder -// also set the worktree common dir is not mounted either. func TestLoadKeepsAnEmptyWorkspaceMount(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("oracle pins the linux consistency behavior (empty suffix)") diff --git a/internal/devtools/command.go b/internal/devtools/command.go index 502cdd8..535bd0c 100644 --- a/internal/devtools/command.go +++ b/internal/devtools/command.go @@ -14,11 +14,6 @@ import ( "time" ) -// Main runs the devtool command args names (args[0] is the program, args[1] the -// command) and returns the process's exit code: 2 for a usage error, the code a -// gate or a probe chose, 1 for any other failure. Each command wires stdin, -// stdout, git, `go tool` and docker around the pure logic of this package; the -// children a command starts write to stdout and stderr too. func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) < 2 { fmt.Fprintln(stderr, "usage: devtool [args...]") @@ -61,8 +56,6 @@ func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if err == nil { return 0 } - // exitError carries a specific code (gate failures, probe failures); other - // errors are usage/infra problems. if ee, ok := err.(exitError); ok { if ee.msg != "" { fmt.Fprintln(stderr, ee.msg) @@ -80,14 +73,11 @@ type exitError struct { func (e exitError) Error() string { return e.msg } -// tool is where a command reads its input and writes its output. type tool struct { in io.Reader out, errw io.Writer } -// --- parity selection ------------------------------------------------------ - func (t tool) parityAffected() error { files, err := readLines(t.in) if err != nil { @@ -166,7 +156,6 @@ func (t tool) dailyChanged(args []string) error { return nil } -// nowEpoch honors DAILY_NOW_EPOCH (tests / reproducibility), else wall clock. func nowEpoch() (int, error) { if v := os.Getenv("DAILY_NOW_EPOCH"); v != "" { n, err := strconv.Atoi(v) @@ -178,8 +167,6 @@ func nowEpoch() (int, error) { return int(time.Now().Unix()), nil } -// --- coverage -------------------------------------------------------------- - func (t tool) coverageReport(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: coverage-report [title]") @@ -197,8 +184,6 @@ func (t tool) coverageReport(args []string) error { return nil } -// renderCoverage renders a profile file or a covdata directory (converted with -// `go tool covdata textfmt`) to a Markdown table. func (t tool) renderCoverage(src, title string) (string, error) { profile := src if info, err := os.Stat(src); err == nil && info.IsDir() { @@ -290,8 +275,6 @@ func (t tool) coverageMerge(args []string) error { return nil } -// laneDirs returns the non-empty per-lane covdata subdirs under root (skipping the -// merge output dir itself). func laneDirs(root, out string) ([]string, error) { entries, err := os.ReadDir(root) if err != nil { @@ -314,8 +297,6 @@ func laneDirs(root, out string) ([]string, error) { return dirs, nil } -// --- CI runner side effects ------------------------------------------------ - func (t tool) ciPrepareRunner() error { t.printDisk("disk before:") free, err := FreeDisk("/") @@ -325,12 +306,8 @@ func (t tool) ciPrepareRunner() error { return t.prepareRunner(free) } -// prepareRunner makes a runner with free bytes on / ready for the runtime lanes: -// it deletes the preinstalled SDKs when the runner lacks RunnerDiskRoom, and -// switches docker to the containerd image store. func (t tool) prepareRunner(free uint64) error { if NeedsDiskFreeing(free) { - // Free ~25-30GB of preinstalled SDKs; ignore failures (|| true in the script). bloat := []string{ "/usr/share/dotnet", "/opt/ghc", "/usr/local/lib/android", "/opt/hostedtoolcache/CodeQL", "/usr/share/swift", @@ -343,7 +320,6 @@ func (t tool) prepareRunner(free uint64) error { fmt.Fprintf(t.out, "%d GB free: the runner has room, nothing deleted\n", free>>30) } - // Enable the containerd image store (build-cache export + --platform). tee := exec.Command("sudo", "tee", "/etc/docker/daemon.json") tee.Stdin = strings.NewReader(`{"features":{"containerd-snapshotter":true}}`) tee.Stdout, tee.Stderr = t.out, t.errw @@ -365,8 +341,6 @@ func (t tool) dockerCacheExport() error { if err := os.WriteFile(filepath.Join(d, "Dockerfile"), []byte("FROM busybox\n"), 0o644); err != nil { // mutate-exempt: only the build below reads the file, as its owner, and it is removed on return return err } - // Exit non-zero (silently) iff the builder can't export cache — the caller uses - // this as a fast precondition probe. cmd := exec.Command("docker", "buildx", "build", "--cache-to=type=local,dest="+filepath.Join(d, "c"), "-t", "devcontainer-cache-export-probe", d) @@ -376,10 +350,6 @@ func (t tool) dockerCacheExport() error { return nil } -// --- helpers --------------------------------------------------------------- - -// readLines splits r into lines. bufio.Scanner's default 64 KiB token limit is -// far beyond any path git prints (PATH_MAX is 4096). func readLines(r io.Reader) ([]string, error) { var lines []string sc := bufio.NewScanner(r) @@ -417,11 +387,6 @@ 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 (t tool) releaseResolve() error { in := ReleaseInput{ Event: os.Getenv("GITHUB_EVENT_NAME"), @@ -453,8 +418,6 @@ func (t tool) releaseResolve() error { return t.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 (t tool) appendTo(env, s string) error { path := os.Getenv(env) if path == "" { @@ -472,13 +435,6 @@ func (t tool) appendTo(env, s string) error { 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 (t tool) metrics(args []string) error { if len(args) != 4 { return fmt.Errorf("usage: devtool metrics ") @@ -504,8 +460,6 @@ func (t tool) metrics(args []string) error { } m.StartupMs.GoVersion = new(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 @@ -555,13 +509,6 @@ func (t tool) metrics(args []string) error { return nil } -// tsOracle rewrites every internal/**/testdata/ts-oracle/*.json under root -// with the answers the reference's own functions give to the golden's -// requests; with check it writes nothing and fails on a stale golden. Node.js -// runs in image, the node image versions.yaml pins (the Taskfile hands it in), -// so the answers do not depend on the host's Node.js: URL parsing, IDNA and -// JSON.parse messages change between releases. -// // mutate-lane: TestTSOracleGoldensMatchTheReference func (t tool) tsOracle(root, image string, check bool) error { root, err := filepath.Abs(root) @@ -579,6 +526,8 @@ func (t tool) tsOracle(root, image string, check bool) error { return err } defer os.RemoveAll(dir) + // Node.js runs in image, not on the host: URL parsing, IDNA and JSON.parse + // messages change between Node.js releases. node := func(mounts []string, args ...string) *exec.Cmd { run := []string{"run", "--rm", "-i", "--network=none", "-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())} for _, m := range mounts { @@ -605,9 +554,6 @@ func (t tool) tsOracle(root, image string, check bool) error { }, check, t.errw) } -// refreshTSOracleFiles asks oracle for every golden under root and rewrites -// the ones whose answers changed, naming each on errw; with check it writes -// nothing and fails naming the stale ones. func refreshTSOracleFiles(root string, oracle func(request []byte) ([]byte, error), check bool, errw io.Writer) error { paths, err := TSOracleGoldens(os.DirFS(root)) if err != nil { diff --git a/internal/devtools/command_test.go b/internal/devtools/command_test.go index 7e1459e..e54dfa5 100644 --- a/internal/devtools/command_test.go +++ b/internal/devtools/command_test.go @@ -12,15 +12,11 @@ import ( "time" ) -// TestMain fixes the umask a CI runner has, so the modes of the files the -// commands leave behind are the ones the tests assert. func TestMain(m *testing.M) { syscall.Umask(0o022) os.Exit(m.Run()) } -// runMain runs one devtool command as the binary would and returns its exit code -// and what it wrote. func runMain(t *testing.T, stdin string, args ...string) (code int, stdout, stderr string) { t.Helper() var o, e strings.Builder @@ -28,9 +24,6 @@ func runMain(t *testing.T, stdin string, args ...string) (code int, stdout, stde return code, o.String(), e.String() } -// fakeTools puts an executable per scripts key first on PATH, standing in for a -// host tool the command starts. Each logs its name and argv, one line per call, -// to the returned file, then runs its body. func fakeTools(t *testing.T, scripts map[string]string) (log string) { t.Helper() dir := t.TempDir() @@ -75,13 +68,11 @@ func assertMode(t *testing.T, path string, want os.FileMode) { } } -// gitRepo makes an empty repository in a new directory and enters it, with no -// user or system configuration to sign or hook its commits. The variables git -// exports to hooks and `rebase --exec` are cleared, so the tests never write -// to the repository they run in. func gitRepo(t *testing.T) { t.Helper() t.Chdir(t.TempDir()) + // git exports these to hooks and `rebase --exec`; left set, the tests would + // commit to the repository they run in. for _, k := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_PREFIX"} { t.Setenv(k, "") os.Unsetenv(k) @@ -91,8 +82,6 @@ func gitRepo(t *testing.T) { git(t, "init", "-q") } -// A repository named by GIT_DIR, as `git rebase --exec` names the one it -// runs in, is left alone by gitRepo and the commits made after it. func TestGitRepoLeavesTheOuterRepositoryAlone(t *testing.T) { outer := t.TempDir() t.Chdir(outer) @@ -121,7 +110,6 @@ func git(t *testing.T, args ...string) string { return strings.TrimSpace(string(out)) } -// commit adds file and commits it at epoch, returning the commit's hash. func commit(t *testing.T, file string, epoch int64) string { t.Helper() writeFile(t, file, file) @@ -144,13 +132,11 @@ func TestMainUsageErrors(t *testing.T) { if code != 1 || out != "" || errOut != "devtool coverage-report: usage: coverage-report [title]\n" { t.Errorf("a command's error: code=%d stdout=%q stderr=%q", code, out, errOut) } - // ts-oracle takes the node image, the one argument the Taskfile hands in. for _, args := range [][]string{{"ts-oracle"}, {"ts-oracle", "a", "b"}} { if code, out, errOut := runMain(t, "", args...); code != 2 || out != "" || errOut != "usage: devtool ts-oracle \n" { t.Errorf("%q: code=%d stdout=%q stderr=%q", args, code, out, errOut) } } - // With its image it runs, and here, with no reference/ to bundle, fails. if code, _, errOut := runMain(t, "", "ts-oracle", "node:test"); code != 1 || !strings.Contains(errOut, "reference") { t.Errorf("ts-oracle with an image: code=%d stderr=%q, want the missing reference/", code, errOut) } @@ -245,8 +231,6 @@ const sampleReport = "| Package | Stmts | Coverage |\n|---|---:|---:|\n" + "| internal/docker | 6 | 100.0% |\n" + "| **TOTAL** | **14** | **78.6%** |\n" -// fakeGo stands in for `go tool covdata`: textfmt writes FAKE_PROFILE to its -o -// file, and merge leaves a file in its -o directory. func fakeGo(t *testing.T) (log string) { t.Helper() t.Setenv("FAKE_PROFILE", sampleProfile) @@ -315,7 +299,6 @@ func TestCoverageGateCommand(t *testing.T) { } func TestCoverageMerge(t *testing.T) { - // An edit that took the title for a directory would leave it in the package. t.Chdir(t.TempDir()) t.Run("lanes into the default directory", func(t *testing.T) { root := t.TempDir() @@ -374,8 +357,6 @@ func TestCoverageMerge(t *testing.T) { } } -// runnerTools fakes what preparing a runner starts: sudo (tee echoes its stdin, -// as the real one does), docker and the shell printing the disk. func runnerTools(t *testing.T) (log string) { t.Helper() return fakeTools(t, map[string]string{ @@ -525,8 +506,6 @@ func TestReleaseResolve(t *testing.T) { }) } -// metricsTools fakes the binary measured, in its own directory, and node; each -// logs its calls, and the binary's read-configuration logs the config it reads. func metricsTools(t *testing.T) (bin, log string) { t.Helper() log = fakeTools(t, map[string]string{"node": ""}) @@ -553,7 +532,6 @@ func readMetrics(t *testing.T, path, stdout string) Metrics { } func TestMetrics(t *testing.T) { - // An edit that swapped its arguments would write the version as a file here. t.Chdir(t.TempDir()) t.Run("everything measured", func(t *testing.T) { bin, log := metricsTools(t) diff --git a/internal/devtools/coverage.go b/internal/devtools/coverage.go index 6172995..8725614 100644 --- a/internal/devtools/coverage.go +++ b/internal/devtools/coverage.go @@ -10,34 +10,23 @@ import ( "strings" ) -// modPrefix is this module's import prefix; report rows strip it, and gate keys -// are matched under internal/. const modPrefix = "github.com/devcontainers/cli/" -// PkgCov is statement-weighted coverage for one package (or the synthetic TOTAL). type PkgCov struct { - Pkg string // import path, or "TOTAL" - Covered int // covered statements - Total int // total statements + Pkg string + Covered int + Total int } -// Pct is the statement-weighted percentage, rounded to one decimal to match -// `go tool cover` / the former pkgcov.awk (the gate compares this rounded value). func (p PkgCov) Pct() float64 { if p.Total == 0 { return 0 } - // Round to 1 decimal exactly as fmt "%.1f" would, so String() and numeric - // comparisons never disagree at a boundary. rounded := fmt.Sprintf("%.1f", float64(p.Covered)*100/float64(p.Total)) v, _ := strconv.ParseFloat(rounded, 64) // mutate-exempt: ParseFloat reads any bitSize but 32 as 64 return v } -// ParseProfile aggregates a Go cover profile (text format) into weighted -// per-package coverage plus a TOTAL. Statement-weighted, not a mean of per-file -// ratios — matching `go tool cover`. Ported from pkgcov.awk. Packages are returned -// sorted by import path; total is the module-wide row. func ParseProfile(r io.Reader) (pkgs []PkgCov, total PkgCov, err error) { cov := map[string]int{} tot := map[string]int{} @@ -57,8 +46,6 @@ 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): the - // file and its positions hold no '/'. p := path.Dir(fields[0]) stmts, _ := strconv.Atoi(fields[1]) count, _ := strconv.Atoi(fields[2]) @@ -80,9 +67,6 @@ func ParseProfile(r io.Reader) (pkgs []PkgCov, total PkgCov, err error) { return pkgs, PkgCov{Pkg: "TOTAL", Covered: gCov, Total: gTot}, nil } -// RenderReport renders a per-package coverage table plus the module total as -// GitHub-flavored Markdown. Returns the "no data" form when there are no packages. -// Ported from coverage-report.sh. func RenderReport(pkgs []PkgCov, total PkgCov, title string) string { if len(pkgs) == 0 { return fmt.Sprintf("### Coverage — %s\n\n_(no coverage data)_\n", title) @@ -98,18 +82,12 @@ func RenderReport(pkgs []PkgCov, total PkgCov, title string) string { return b.String() } -// GateResult is one floor check; OK false means the floor was missed (or the -// package had no data). type GateResult struct { Key string Line string OK bool } -// CoverageGate enforces coverage floors: a floor for "TOTAL" checks the module -// total, any other key checks the package at internal/. Returns -// one result per floor (in the given order) and ok=false if any failed. Ported -// from coverage-gate.sh. func CoverageGate(pkgs []PkgCov, total PkgCov, floors []Floor) (results []GateResult, ok bool) { byPkg := map[string]PkgCov{total.Pkg: total} for _, p := range pkgs { @@ -142,14 +120,11 @@ func CoverageGate(pkgs []PkgCov, total PkgCov, floors []Floor) (results []GateRe return results, ok } -// Floor is a "=" coverage threshold. type Floor struct { Key string Min float64 } -// ParseFloors parses "TOTAL=48 cli=28 docker=77" style specs (already split into -// args). A malformed spec is an error so a typo in the Taskfile fails loudly. func ParseFloors(specs []string) ([]Floor, error) { floors := make([]Floor, 0, len(specs)) for _, s := range specs { diff --git a/internal/devtools/coverage_test.go b/internal/devtools/coverage_test.go index 05965b8..778726a 100644 --- a/internal/devtools/coverage_test.go +++ b/internal/devtools/coverage_test.go @@ -21,8 +21,6 @@ func parseSample(t *testing.T) ([]PkgCov, PkgCov) { return pkgs, total } -// TestParseProfile checks statement-weighted aggregation (covered iff count>0), -// matching the former pkgcov.awk. func TestParseProfile(t *testing.T) { pkgs, total := parseSample(t) want := []PkgCov{ @@ -48,7 +46,6 @@ func TestParseProfile(t *testing.T) { } } -// TestRenderReport is a golden check against coverage-report.sh output. func TestRenderReport(t *testing.T) { pkgs, total := parseSample(t) want := "### Coverage — sample (total 78.6%)\n\n" + @@ -68,7 +65,6 @@ func TestRenderReportNoData(t *testing.T) { } } -// TestCoverageGate mirrors coverage-gate.sh: pass/fail lines and exit signal. func TestCoverageGate(t *testing.T) { pkgs, total := parseSample(t) @@ -118,8 +114,6 @@ func TestParseFloorsInvalid(t *testing.T) { } } -// TestPctOfAnEmptyPackage holds the guard a package without statements needs: its -// coverage is 0, not the NaN of 0/0 that the report and the gate would print. func TestPctOfAnEmptyPackage(t *testing.T) { if got := (PkgCov{Pkg: "p"}).Pct(); got != 0 { t.Errorf("Pct of 0 statements = %v, want 0", got) diff --git a/internal/devtools/daily.go b/internal/devtools/daily.go index a21157b..8f2c4da 100644 --- a/internal/devtools/daily.go +++ b/internal/devtools/daily.go @@ -1,10 +1,5 @@ package devtools -// DailyChanged decides whether the daily scheduled parity run should do the heavy -// Docker work: true iff HEAD's commit is within the look-back window (the repo -// changed since the previous daily run). Ages use integer-hour truncation to -// match the former daily-changed.sh ($(( (now-last)/3600 ))). Ported from -// scripts/daily-changed.sh. func DailyChanged(lastCommitEpoch, nowEpoch, windowHours int) bool { ageHours := (nowEpoch - lastCommitEpoch) / 3600 return ageHours < windowHours diff --git a/internal/devtools/daily_test.go b/internal/devtools/daily_test.go index 2d3ae1c..e919810 100644 --- a/internal/devtools/daily_test.go +++ b/internal/devtools/daily_test.go @@ -2,9 +2,6 @@ package devtools import "testing" -// TestDailyChanged covers the run/skip boundary of the daily gate: ages use -// integer-hour truncation, and the window is exclusive (age == window skips), -// matching the former daily-changed.sh. func TestDailyChanged(t *testing.T) { const now = 2_000_000_000 const h = 3600 diff --git a/internal/devtools/metrics.go b/internal/devtools/metrics.go index ca0da4e..d7b3f87 100644 --- a/internal/devtools/metrics.go +++ b/internal/devtools/metrics.go @@ -7,9 +7,6 @@ import ( "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"` @@ -19,14 +16,12 @@ type Metrics struct { 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"` @@ -35,9 +30,6 @@ type SizesBytes struct { 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 @@ -50,7 +42,6 @@ func MeanMillis(runs int, run func()) float64 { 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 { @@ -59,8 +50,6 @@ func FileSize(path string) *int64 { 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 { diff --git a/internal/devtools/metrics_test.go b/internal/devtools/metrics_test.go index 7e7c352..dd78df0 100644 --- a/internal/devtools/metrics_test.go +++ b/internal/devtools/metrics_test.go @@ -10,8 +10,6 @@ import ( "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) @@ -30,7 +28,6 @@ func TestMetricsJSONKeepsEveryKeyAndNulls(t *testing.T) { } } -// 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 { @@ -49,8 +46,6 @@ func TestMeanMillisWarmsUpThenRuns(t *testing.T) { } } -// 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 { @@ -58,7 +53,6 @@ func TestMeanMillisIsMilliseconds(t *testing.T) { } } -// 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 { diff --git a/internal/devtools/parityaffected.go b/internal/devtools/parityaffected.go index c2f543a..573e952 100644 --- a/internal/devtools/parityaffected.go +++ b/internal/devtools/parityaffected.go @@ -1,7 +1,3 @@ -// Package devtools holds the pure logic behind the repo's CI helper commands -// (invoked via cmd/devtool). Keeping the decision logic here — rather than in -// shell scripts — makes it unit-testable; the thin main only wires stdin/stdout, -// git, and `go tool` around these functions. package devtools import ( @@ -10,18 +6,6 @@ import ( "strings" ) -// ParityAffected maps a set of changed files to the parity runtime commands they -// can affect, returning one of: -// -// "all" — run the whole runtime matrix (a shared/core or uncertain file changed) -// "none" — run nothing (only docs/irrelevant files, or no files) -// "" — run only these commands, e.g. "build,up" (a PARITY_COMMAND allowlist) -// -// The mapping is deliberately conservative: only files we are confident are -// command-local narrow the run; everything else falls through to "all". The daily -// full run is the backstop for any cross-command effect this misses. Ported from -// the former scripts/parity-affected.sh; the ordering of checks is significant -// (first match wins, exactly like the shell `case`). func ParityAffected(files []string) string { full := false cmds := map[string]bool{} @@ -39,7 +23,6 @@ func ParityAffected(files []string) string { cmds[c] = true } case fileIgnore: - // contributes nothing } } @@ -60,19 +43,14 @@ const ( fileCommand ) -// classifyParityFile mirrors the ordered shell `case` in the original script: -// the first matching arm wins. Returns the parity commands only for fileCommand. func classifyParityFile(f string) (fileKind, []string) { switch { - // --- Ignorable: never affect the runtime matrix ------------------------ case strings.HasSuffix(f, ".md"), strings.HasPrefix(f, "LICENSE"), f == ".gitignore", f == ".editorconfig", f == ".github/CODEOWNERS", f == ".github/workflows/release.yml", strings.HasPrefix(f, ".goreleaser"): return fileIgnore, nil - // --- Harness / matrix data / build config → run everything ------------- - // (listed BEFORE the generic *_test.go ignore so it wins) case f == "docs/parity/parity-matrix.yaml", f == "internal/cli/parity_matrix_test.go", f == "internal/cli/parity_matrix_helpers_test.go", @@ -80,7 +58,6 @@ func classifyParityFile(f string) (fileKind, []string) { f == ".github/workflows/go-cli.yml": return fileFull, nil - // --- Isolated leaf command files → just that command's cases ----------- case f == "internal/cli/read_configuration.go": return fileCommand, []string{"read-configuration"} case f == "internal/cli/exec.go": @@ -102,15 +79,12 @@ func classifyParityFile(f string) (fileKind, []string) { case f == "internal/cli/collection_commands.go": return fileCommand, []string{"features", "templates", "features-info", "features-package"} - // --- A command's own hermetic/e2e tests don't affect the runtime matrix - case strings.HasPrefix(f, "internal/cli/") && strings.HasSuffix(f, "_test.go"): return fileIgnore, nil - // --- Anything else under the source tree is shared or uncertain → full - case strings.HasPrefix(f, "internal/"), strings.HasPrefix(f, "cmd/"): return fileFull, nil - // --- Unknown top-level path → be safe ---------------------------------- default: return fileFull, nil } diff --git a/internal/devtools/parityaffected_test.go b/internal/devtools/parityaffected_test.go index f7627b4..e636a76 100644 --- a/internal/devtools/parityaffected_test.go +++ b/internal/devtools/parityaffected_test.go @@ -2,11 +2,6 @@ package devtools import "testing" -// TestParityAffected is a golden table: every expected value was captured from the -// former scripts/parity-affected.sh before the port (files fed one-per-line with a -// trailing newline, as `git diff --name-only` emits), so this proves behavior parity -// and guards the conservative mapping (a wrong "none"/narrow result silently skips -// runtime cases the change could break). func TestParityAffected(t *testing.T) { cases := []struct { name string @@ -44,8 +39,6 @@ func TestParityAffected(t *testing.T) { {"unknown path → all", []string{"totally-unknown-file"}, "all"}, {"full wins over leaf", []string{"internal/cli/exec.go", "docs/parity/parity-matrix.yaml"}, "all"}, - // Post-migration: the ported logic lives under internal/ and cmd/, so a - // change to it conservatively triggers the full matrix (internal/*, cmd/*). {"ported logic → all", []string{"internal/devtools/parityaffected.go"}, "all"}, {"devtool main → all", []string{"cmd/devtool/main.go"}, "all"}, diff --git a/internal/devtools/release.go b/internal/devtools/release.go index 08b1001..339f65b 100644 --- a/internal/devtools/release.go +++ b/internal/devtools/release.go @@ -6,35 +6,25 @@ import ( "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.* + Event string + VersionInput string + RefName string + PushedSHA string + HeadSHA string + Today string + TagsToday int 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 diff --git a/internal/devtools/release_test.go b/internal/devtools/release_test.go index faf3235..3f84a31 100644 --- a/internal/devtools/release_test.go +++ b/internal/devtools/release_test.go @@ -6,10 +6,6 @@ import ( "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 { diff --git a/internal/devtools/runnerdisk.go b/internal/devtools/runnerdisk.go index 0729405..926f405 100644 --- a/internal/devtools/runnerdisk.go +++ b/internal/devtools/runnerdisk.go @@ -2,19 +2,12 @@ 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 { diff --git a/internal/devtools/runnerdisk_test.go b/internal/devtools/runnerdisk_test.go index 440c637..cff6f02 100644 --- a/internal/devtools/runnerdisk_test.go +++ b/internal/devtools/runnerdisk_test.go @@ -2,8 +2,6 @@ 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 diff --git a/internal/devtools/tsoracle.go b/internal/devtools/tsoracle.go index 000d8eb..3252126 100644 --- a/internal/devtools/tsoracle.go +++ b/internal/devtools/tsoracle.go @@ -12,8 +12,6 @@ import ( "github.com/devcontainers/cli/internal/tsoracle" ) -// TSOracleGoldens lists every internal/**/testdata/ts-oracle/*.json of fsys -// (the repository root), sorted. func TSOracleGoldens(fsys fs.FS) ([]string, error) { var files []string err := fs.WalkDir(fsys, "internal", func(p string, d fs.DirEntry, err error) error { @@ -30,10 +28,6 @@ func TSOracleGoldens(fsys fs.FS) ([]string, error) { return files, err } -// RefreshTSOracleGoldens sends the cases of every golden (path -> content) to -// oracle in one request and returns each golden rewritten with the results -// oracle recorded. oracle reads and writes the tools/tsoracle protocol: -// {"cases":[{"fn","args"}]} in, the same cases with "result" or "error" out. func RefreshTSOracleGoldens(goldens map[string][]byte, oracle func(request []byte) ([]byte, error)) (map[string][]byte, error) { paths := slices.Sorted(maps.Keys(goldens)) type call struct { diff --git a/internal/devtools/tsoracle_test.go b/internal/devtools/tsoracle_test.go index 50c1f6b..82f9543 100644 --- a/internal/devtools/tsoracle_test.go +++ b/internal/devtools/tsoracle_test.go @@ -31,8 +31,6 @@ func TestTSOracleGoldens(t *testing.T) { } } -// fakeOracle answers parseVersion with its argument's length and throws for -// "boom", recording the request it was sent. func fakeOracle(sent *[]byte) func([]byte) ([]byte, error) { return func(req []byte) ([]byte, error) { *sent = req @@ -71,7 +69,6 @@ func TestRefreshTSOracleGoldens(t *testing.T) { if err != nil { t.Fatal(err) } - // One request, the files' cases in path order, only fn and args. if want := `{"cases":[{"fn":"len","args":["<&>"]},{"fn":"len","args":["ab"]},{"fn":"boom","args":["x"]}]}`; string(sent) != want { t.Errorf("request = %s, want %s", sent, want) } @@ -103,7 +100,6 @@ func TestRefreshTSOracleGoldens(t *testing.T) { if s := string(got["internal/b/testdata/ts-oracle/b.json"]); !strings.Contains(s, `"error": "it threw"`) || strings.Contains(s, `"result"`) { t.Errorf("b.json = %s, want the thrown error in place of the stale result", s) } - // Refreshing a refreshed golden changes nothing. again, err := RefreshTSOracleGoldens(got, fakeOracle(&sent)) if err != nil { t.Fatal(err) @@ -156,8 +152,6 @@ func TestRefreshTSOracleGoldensRejects(t *testing.T) { } } -// With no cases the oracle is still asked with a cases array, which the -// tools/tsoracle protocol requires, not with null. func TestRefreshTSOracleGoldensAsksWithAnEmptyCasesArray(t *testing.T) { var asked string _, err := RefreshTSOracleGoldens(map[string][]byte{"g.json": []byte(`{"cases":[]}`)}, func(request []byte) ([]byte, error) { @@ -172,8 +166,6 @@ func TestRefreshTSOracleGoldensAsksWithAnEmptyCasesArray(t *testing.T) { } } -// refreshTSOracleFiles rewrites only the goldens whose answers changed and -// names them; with check it writes nothing and fails naming the stale ones. func TestRefreshTSOracleFiles(t *testing.T) { root := t.TempDir() write := func(p, content string) { @@ -218,10 +210,6 @@ func TestRefreshTSOracleFiles(t *testing.T) { } } -// The recorded goldens are what the reference's functions return today, run in -// the node image versions.yaml pins, which task parity:contract hands in as -// TS_ORACLE_IMAGE. It runs where `task reference` has installed the reference -// and Docker is reachable (the parity CI job). func TestTSOracleGoldensMatchTheReference(t *testing.T) { const root = "../.." image := os.Getenv("TS_ORACLE_IMAGE") diff --git a/internal/devtools/versions_gate_test.go b/internal/devtools/versions_gate_test.go index fff337a..39ce3c4 100644 --- a/internal/devtools/versions_gate_test.go +++ b/internal/devtools/versions_gate_test.go @@ -7,16 +7,13 @@ import ( "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; -// the testdata/ts-oracle goldens hold digests as inputs of reference functions. func TestVersionsYAMLIsTheOnlyPin(t *testing.T) { + // 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, and its + // BASE_IMAGE is the user's image handed in at run time, not a pin; + // internal/imagemeta/testdata/ holds Dockerfiles the TS CLI generated; the + // testdata/ts-oracle goldens hold digests as inputs of reference functions. g := gate.Gate{Root: "../..", Elsewhere: []string{ "go.sum", "reference/", "src/test/", "internal/docker/updateUID.Dockerfile", "internal/imagemeta/testdata/", }} diff --git a/internal/docker/argbuilders_test.go b/internal/docker/argbuilders_test.go index 49dcfce..b268239 100644 --- a/internal/docker/argbuilders_test.go +++ b/internal/docker/argbuilders_test.go @@ -9,10 +9,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// recordingRunner is an exec.Runner that records every invocation and replies -// with canned output. It supports multi-call functions (e.g. builder detection -// that shells out several times) by consuming a queue of responses; once the -// queue is exhausted it returns a zero-value success. type recordingRunner struct { calls []recordedCall responses []cannedResponse @@ -50,10 +46,6 @@ func (r *recordingRunner) last() recordedCall { return r.calls[len(r.calls)-1] } -// --- docker build / tag argument routing --- - -// TestBuild_RoutesArgsThroughRunner proves Client.Build feeds the exact -// buildArgs slice to the runner seam (name = docker path, args = build args). func TestBuild_RoutesArgsThroughRunner(t *testing.T) { rr := &recordingRunner{} c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} @@ -89,7 +81,6 @@ func TestTag_Args(t *testing.T) { } } -// TestTag_NonZeroExitFails proves Tag surfaces a docker failure exit code. func TestTag_NonZeroExitFails(t *testing.T) { rr := &recordingRunner{responses: []cannedResponse{{stderr: []byte("no such image"), code: 1}}} c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} @@ -98,10 +89,7 @@ func TestTag_NonZeroExitFails(t *testing.T) { } } -// --- buildx detection / builder selection parsing --- - func TestDetectBuildKit(t *testing.T) { - // Available: buildx version returns 0 with a version banner. rr := &recordingRunner{responses: []cannedResponse{{stdout: []byte("github.com/docker/buildx v0.12.0\n"), code: 0}}} c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} info := c.DetectBuildKit(t.Context()) @@ -112,7 +100,6 @@ func TestDetectBuildKit(t *testing.T) { t.Errorf("args = %v, want %v", rr.last().args, want) } - // Unavailable: non-zero exit. rr2 := &recordingRunner{responses: []cannedResponse{{code: 1}}} c2 := &Client{DockerPath: "docker", Log: log.Null, Runner: rr2} if c2.DetectBuildKit(t.Context()).Available { @@ -133,9 +120,6 @@ func TestDetectActiveBuilder(t *testing.T) { } } -// TestFindDockerDriverBuilder exercises the two-call sequence (context inspect, -// then buildx ls) and the parsing that prefers the builder matching the current -// context, falling back to any docker-driver builder. func TestFindDockerDriverBuilder(t *testing.T) { lsOut := "" + "NAME/NODE DRIVER/ENDPOINT STATUS\n" + @@ -143,18 +127,15 @@ func TestFindDockerDriverBuilder(t *testing.T) { "default docker running\n" + "mycontext docker running\n" - // Current context "mycontext" matches a docker-driver builder → returned - // even though "default" (also docker driver) appears first. rr := &recordingRunner{responses: []cannedResponse{ - {stdout: []byte("mycontext\n"), code: 0}, // context inspect - {stdout: []byte(lsOut), code: 0}, // buildx ls + {stdout: []byte("mycontext\n"), code: 0}, + {stdout: []byte(lsOut), code: 0}, }} c := &Client{DockerPath: "docker", Log: log.Null, Runner: rr} if got := c.FindDockerDriverBuilder(t.Context()); got != "mycontext" { t.Errorf("FindDockerDriverBuilder = %q, want mycontext", got) } - // No context match → fall back to first docker-driver builder ("default"). rr2 := &recordingRunner{responses: []cannedResponse{ {stdout: []byte("other\n"), code: 0}, {stdout: []byte(lsOut), code: 0}, @@ -165,10 +146,6 @@ func TestFindDockerDriverBuilder(t *testing.T) { } } -// --- compose argument construction --- - -// newComposeV2 builds a v2 ComposeClient wired to a recording runner. Command -// is ["docker","compose"], so Run prepends "compose" and calls name="docker". func newComposeV2(rr *recordingRunner, version []int) *ComposeClient { return &ComposeClient{ Command: []string{"docker", "compose"}, @@ -217,7 +194,7 @@ func TestComposeBuild_Args(t *testing.T) { ".env", []string{"--progress", "plain"}, []string{"app"}, - true, // noCache + true, ) if err != nil { t.Fatalf("Build error: %v", err) @@ -258,7 +235,7 @@ func TestComposeUp_Args(t *testing.T) { []string{"--profile", "dev"}, "myproject", []string{"app", "db"}, - true, // noRecreate + true, ) if err != nil { t.Fatalf("Up error: %v", err) @@ -311,8 +288,6 @@ func TestComposeConfig_ArgsAndParse(t *testing.T) { } } -// TestComposeBuild_NonZeroExitFails proves a compose failure exit code is -// surfaced as an error rather than swallowed. func TestComposeBuild_NonZeroExitFails(t *testing.T) { rr := &recordingRunner{responses: []cannedResponse{{stderr: []byte("boom"), code: 2}}} c := newComposeV2(rr, []int{2, 24, 0}) diff --git a/internal/docker/builder_detect_held_test.go b/internal/docker/builder_detect_held_test.go index 804b98a..1af47a7 100644 --- a/internal/docker/builder_detect_held_test.go +++ b/internal/docker/builder_detect_held_test.go @@ -6,8 +6,6 @@ import ( "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} diff --git a/internal/docker/client.go b/internal/docker/client.go index b1fc75b..10ddf56 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -1,4 +1,3 @@ -// Package docker wraps the Docker CLI and Engine API the CLI drives. package docker import ( @@ -14,23 +13,14 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// Client wraps the Docker CLI binary. It shells out to `docker` rather -// than using the Docker API/SDK, matching the TS CLI behavior. type Client struct { - DockerPath string - Env []string - Log log.Logger - // Runner is the seam over process execution. When nil, a default OS-backed - // runner is used. Tests inject a fake to avoid shelling out. - Runner exec.Runner - // ProgressWriter, when set, receives `docker build` output live so the user - // sees progress as it happens instead of one dump on completion. Leave nil to - // keep output buffered (e.g. under --log-format json, where a raw byte stream - // would corrupt the structured event stream). + DockerPath string + Env []string + Log log.Logger + Runner exec.Runner ProgressWriter io.Writer } -// NewClient creates a Docker CLI client. func NewClient(dockerPath string, env []string, logger log.Logger) *Client { if dockerPath == "" { dockerPath = "docker" @@ -42,15 +32,12 @@ func NewClient(dockerPath string, env []string, logger log.Logger) *Client { } } -// ExecResult holds output from a docker command. type ExecResult struct { Stdout []byte Stderr []byte ExitCode int } -// runner returns the configured Runner, or a default OS-backed one carrying the -// client's Env plus extraEnv for this invocation. func (c *Client) runner(extraEnv []string) exec.Runner { if c.Runner != nil { return c.Runner @@ -58,17 +45,12 @@ func (c *Client) runner(extraEnv []string) exec.Runner { return exec.OSRunner{Env: append(append([]string{}, c.Env...), extraEnv...)} } -// run executes a docker command and captures output. When stream is true and a -// ProgressWriter is set, the child's output is also forwarded live through the -// runner (a fake runner streams too, so the behavior is exercised in tests). -// extraEnv is appended to the client's Env. ctx cancels the subprocess (e.g. on -// SIGINT), so a long-running `docker build` unwinds on Ctrl-C. func (c *Client) run(ctx context.Context, stream bool, extraEnv []string, args ...string) (*ExecResult, error) { c.Log.Write(fmt.Sprintf("Run: %s %s", c.DockerPath, strings.Join(args, " ")), log.LevelTrace) var live io.Writer if stream { - live = c.ProgressWriter // nil unless the caller opted into live output + live = c.ProgressWriter } stdout, stderr, exitCode, err := c.runner(extraEnv).Run(ctx, live, c.DockerPath, args...) if err != nil { @@ -77,25 +59,16 @@ func (c *Client) run(ctx context.Context, stream bool, extraEnv []string, args . return &ExecResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode}, nil } -// Run executes a docker command and captures output (no live streaming). func (c *Client) Run(ctx context.Context, args ...string) (*ExecResult, error) { return c.run(ctx, false, nil, args...) } -// Build runs `docker build` or `docker buildx build`, streaming progress to -// ProgressWriter when set. When opts.Env is set, those entries are appended to -// the subprocess environment (used to point DOCKER_CONFIG at a temporary -// credentials directory for private base-image pulls / --push / --cache-to, -// without mutating the ambient environment). func (c *Client) Build(ctx context.Context, opts BuildOptions) (*ExecResult, error) { args := c.buildArgs(opts) - // Secret values reach BuildKit through the subprocess environment (referenced - // by `--secret id=KEY,env=KEY`), never on the command line. extraEnv := append(append([]string{}, opts.Env...), opts.Secrets...) return c.run(ctx, true, extraEnv, args...) } -// BuildOptions configures a docker build. type BuildOptions struct { Dockerfile string ContextPath string @@ -106,12 +79,11 @@ type BuildOptions struct { Labels []string NoCache bool Pull bool - ExtraArgs []string // additional --build-context, etc. - Env []string // extra env for the subprocess (e.g. DOCKER_CONFIG=...) - Secrets []string // build secrets as "KEY=VALUE"; passed to buildx as --secret id=KEY,env=KEY with KEY=VALUE in the subprocess env + ExtraArgs []string + Env []string + Secrets []string FileSecrets map[string]string - // Buildx-specific UseBuildx bool Platform string Push bool @@ -119,11 +91,8 @@ type BuildOptions struct { CacheTo string } -// buildxCacheToInlineRe matches a buildx cache spec that is itself an inline -// cache exporter (type=inline), mirroring TS isBuildxCacheToInline. var buildxCacheToInlineRe = regexp.MustCompile(`(?i)type\s*=\s*inline`) -// isBuildxCacheToInline reports whether a --cache-to spec is an inline exporter. func isBuildxCacheToInline(cacheTo string) bool { return cacheTo != "" && buildxCacheToInlineRe.MatchString(cacheTo) } @@ -146,13 +115,9 @@ func (c *Client) buildArgs(opts BuildOptions) []string { if opts.CacheTo != "" { args = append(args, "--cache-to", opts.CacheTo) } - // Inline cache is redundant (and rejected) when --cache-to is itself an - // inline cache exporter; match TS, which skips the build-arg in that case. if !isBuildxCacheToInline(opts.CacheTo) { args = append(args, "--build-arg", "BUILDKIT_INLINE_CACHE=1") } - // Build secrets (buildx-only): the value is read from the subprocess env - // (set in Build), so only the id/env reference appears on the command line. for _, s := range opts.Secrets { if i := strings.IndexByte(s, '='); i > 0 { key := s[:i] @@ -201,7 +166,6 @@ func (c *Client) buildArgs(opts BuildOptions) []string { return args } -// Tag runs `docker tag`. func (c *Client) Tag(ctx context.Context, source, target string) error { res, err := c.Run(ctx, "tag", source, target) if err != nil { @@ -213,13 +177,11 @@ func (c *Client) Tag(ctx context.Context, source, target string) error { return nil } -// BuildKitInfo holds BuildKit version detection results. type BuildKitInfo struct { Available bool Version string } -// DetectBuildKit checks if BuildKit is available via `docker buildx version`. func (c *Client) DetectBuildKit(ctx context.Context) *BuildKitInfo { res, err := c.Run(ctx, "buildx", "version") if err != nil || res.ExitCode != 0 { @@ -232,13 +194,11 @@ func (c *Client) DetectBuildKit(ctx context.Context) *BuildKitInfo { } } -// BuilderInfo holds information about the active buildx builder. type BuilderInfo struct { Name string Driver string } -// DetectActiveBuilder returns information about the currently active buildx builder. func (c *Client) DetectActiveBuilder(ctx context.Context) *BuilderInfo { res, err := c.Run(ctx, "buildx", "inspect") if err != nil || res.ExitCode != 0 { @@ -257,10 +217,7 @@ func (c *Client) DetectActiveBuilder(ctx context.Context) *BuilderInfo { return info } -// FindDockerDriverBuilder finds the name of a buildx builder using the "docker" driver -// that matches the current Docker context. This builder can access local images. func (c *Client) FindDockerDriverBuilder(ctx context.Context) string { - // Get the current Docker context ctxRes, _ := c.Run(ctx, "context", "inspect", "--format", "{{.Name}}") currentContext := strings.TrimSpace(string(ctxRes.Stdout)) @@ -268,19 +225,16 @@ func (c *Client) FindDockerDriverBuilder(ctx context.Context) string { if err != nil || res.ExitCode != 0 { return "" } - // 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.SplitSeq(string(res.Stdout), "\n") { if strings.HasPrefix(line, "NAME") { continue } - fields := strings.Fields(line) // a blank line has none + fields := strings.Fields(line) if len(fields) >= 2 { name := strings.TrimSuffix(fields[0], "*") driver := fields[1] if driver == "docker" { - // Prefer builder matching current context if name == currentContext { return name } diff --git a/internal/docker/client_held_test.go b/internal/docker/client_held_test.go index b4e2d31..ca937c5 100644 --- a/internal/docker/client_held_test.go +++ b/internal/docker/client_held_test.go @@ -5,8 +5,6 @@ import ( "testing" ) -// Every KEY=VALUE build secret becomes `--secret id=KEY,env=KEY` (the contract -// on BuildOptions.Secrets), a one-letter key included. func TestBuildArgsPassesAOneLetterSecret(t *testing.T) { args := (&Client{}).buildArgs(BuildOptions{UseBuildx: true, ContextPath: ".", Secrets: []string{"A=1"}}) i := slices.Index(args, "--secret") diff --git a/internal/docker/client_nodocker_held_test.go b/internal/docker/client_nodocker_held_test.go index 2d40b68..8cafad7 100644 --- a/internal/docker/client_nodocker_held_test.go +++ b/internal/docker/client_nodocker_held_test.go @@ -6,8 +6,6 @@ import ( "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)}}} diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 682ab59..936f5a9 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -42,7 +42,6 @@ func TestBuildArgs(t *testing.T) { Dockerfile: "Dockerfile", ContextPath: ".", }, - // --push present, --load absent want: []string{"buildx", "build", "--push", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-f", "Dockerfile", "."}, }, { @@ -53,7 +52,6 @@ func TestBuildArgs(t *testing.T) { Dockerfile: "Dockerfile", ContextPath: ".", }, - // --output present, --load absent want: []string{"buildx", "build", "--output", "type=docker", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-f", "Dockerfile", "."}, }, { @@ -113,7 +111,6 @@ func TestBuildArgs(t *testing.T) { want: []string{"build", "-f", "Dockerfile", "--build-arg", "NODE_VERSION=18", "."}, }, { - // Legacy build with multiple --cache-from sources, in order. name: "CacheFrom", opts: BuildOptions{ Dockerfile: "Dockerfile", @@ -123,7 +120,6 @@ func TestBuildArgs(t *testing.T) { want: []string{"build", "-f", "Dockerfile", "--cache-from", "img:cache1", "--cache-from", "img:cache2", "."}, }, { - // Empty ContextPath must default to "." as the final positional arg. name: "DefaultContext", opts: BuildOptions{ Dockerfile: "Dockerfile", @@ -131,7 +127,6 @@ func TestBuildArgs(t *testing.T) { want: []string{"build", "-f", "Dockerfile", "."}, }, { - // ExtraArgs are appended verbatim, immediately before the context path. name: "ExtraArgs", opts: BuildOptions{ Dockerfile: "Dockerfile", @@ -141,10 +136,6 @@ func TestBuildArgs(t *testing.T) { want: []string{"build", "-f", "Dockerfile", "--build-context", "shared=/tmp/shared", "--add-host", "foo:127.0.0.1", "/ctx"}, }, { - // buildx: --platform + --push are mutually consistent (no --load), - // and --cache-to / cache-from / tags all coexist in a realistic combo. - // With an inline cache-to, BUILDKIT_INLINE_CACHE=1 is omitted (it is - // redundant/rejected there) — matching TS isBuildxCacheToInline. name: "BuildxPlatformPushCombo", opts: BuildOptions{ UseBuildx: true, diff --git a/internal/docker/compose.go b/internal/docker/compose.go index f565f55..d0c8ba0 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -18,31 +18,19 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// ComposeClient wraps Docker Compose CLI (v1 or v2). type ComposeClient struct { - // Command is ["docker", "compose"] (Compose v2). - Command []string - Version []int // parsed version e.g. [2, 24, 0] - Env []string - Log log.Logger - // Runner is the seam over process execution. When nil, a default OS-backed - // runner is used. - Runner exec.Runner - // ProgressWriter, when set, receives `docker compose build`/`up` output live - // so the user sees progress as it happens. Leave nil to keep output buffered - // (e.g. under --log-format json). `compose config` never streams — its stdout - // is JSON that the CLI parses. + Command []string + Version []int + Env []string + Log log.Logger + Runner exec.Runner ProgressWriter io.Writer } -// NewComposeClient detects Compose v2 (`docker compose`) and returns a client. -// Compose v1 (`docker-compose`) is out of scope for this CLI; --docker-compose-path -// is accepted for flag parity but only surfaces in the not-found error. func NewComposeClient(dockerPath, composePath string, env []string, logger log.Logger) (*ComposeClient, error) { if dockerPath == "" { dockerPath = "docker" } - // TS dockerComposeCLIConfig logs the command and the version it found. runLine := "Run: " + dockerPath + " compose version --short" start := logger.Start(runLine, log.LevelDebug) out, err := osexec.Command(dockerPath, "compose", "version", "--short").Output() @@ -72,8 +60,6 @@ func NewComposeClient(dockerPath, composePath string, env []string, logger log.L // requested"); docs/DIVERGENCES.md records the difference. const bakeFSEntitlement = "BUILDX_BAKE_ENTITLEMENTS_FS" -// withBakeFSEntitlement is env plus BUILDX_BAKE_ENTITLEMENTS_FS=0, unless env or -// the process environment (lookup) already sets it. func withBakeFSEntitlement(env []string, lookup func(string) (string, bool)) []string { if _, ok := lookup(bakeFSEntitlement); ok || slices.ContainsFunc(env, func(kv string) bool { return strings.HasPrefix(kv, bakeFSEntitlement+"=") }) { return env @@ -81,14 +67,10 @@ func withBakeFSEntitlement(env []string, lookup func(string) (string, bool)) []s return append(slices.Clone(env), bakeFSEntitlement+"=0") } -// Run executes a compose command and captures output (no live streaming). func (c *ComposeClient) Run(ctx context.Context, args ...string) (*ExecResult, error) { return c.run(ctx, log.LevelDebug, false, args...) } -// run executes a compose command, its "Run:" lines logged at level. When -// stream is true and ProgressWriter is set, the child's output is also -// forwarded live through the runner. ctx cancels the subprocess. func (c *ComposeClient) run(ctx context.Context, level log.Level, stream bool, args ...string) (*ExecResult, error) { fullArgs := append(c.Command[1:], args...) @@ -120,17 +102,10 @@ func (c *ComposeClient) run(ctx context.Context, level log.Level, stream bool, a }, nil } -// Config runs `docker compose config` with every profile and returns the -// parsed project, as TS readDockerComposeConfig does (it reads the YAML output, -// a missing document being an empty project). A failure is TS's ContainerError: -// "An error occurred retrieving the Docker Compose configuration.", the -// message naming the command that failed. Compose's own output is logged. func (c *ComposeClient) Config(ctx context.Context, composeFiles []string, envFile string) (map[string]any, error) { args := c.buildGlobalArgs(composeFiles, envFile) args = append(args, "--profile", "*", "config") - // TS readDockerComposeConfig runs the command with an info-level log and - // writes its output to the log. res, err := c.run(ctx, log.LevelInfo, false, args...) if err == nil && res.ExitCode == 0 { c.Log.Write(string(res.Stdout)) @@ -149,8 +124,6 @@ func (c *ComposeClient) Config(ctx context.Context, composeFiles []string, envFi return config, nil } -// parseComposeConfig reads `docker compose config`'s YAML into the JSON value -// types (float64 numbers, map[string]any objects) the callers read. func parseComposeConfig(out []byte) (map[string]any, error) { var doc any if err := yaml.Unmarshal(out, &doc); err != nil { @@ -169,8 +142,6 @@ func parseComposeConfig(out []byte) (map[string]any, error) { return config, nil } -// stringKeys turns the map[any]any YAML gives a mapping with a non-string key -// into a map[string]any, which JSON can represent. func stringKeys(v any) any { switch v := v.(type) { case map[string]any: @@ -191,8 +162,6 @@ func stringKeys(v any) any { return v } -// CommandFailedError is a command that exited non-zero; its message is TS -// runCommandNoPty's, "Command failed: ". type CommandFailedError struct { Command string ExitCode int @@ -202,7 +171,6 @@ func (e *CommandFailedError) Error() string { return "Command failed: " + e.Command } -// Build runs `docker compose build`. func (c *ComposeClient) Build(ctx context.Context, composeFiles []string, envFile string, globalArgs, services []string, noCache bool) error { args := c.buildGlobalArgs(composeFiles, envFile) args = append(args, globalArgs...) @@ -222,7 +190,6 @@ func (c *ComposeClient) Build(ctx context.Context, composeFiles []string, envFil return nil } -// Up runs `docker compose up`. func (c *ComposeClient) Up(ctx context.Context, composeFiles []string, envFile string, globalArgs []string, projectName string, services []string, noRecreate bool) error { args := c.buildGlobalArgs(composeFiles, envFile) if projectName != "" { @@ -245,8 +212,6 @@ func (c *ComposeClient) Up(ctx context.Context, composeFiles []string, envFile s return nil } -// Stop stops the project's services without removing them (`docker compose -// stop`), so `up` can restart them. Preserves containers, networks and volumes. func (c *ComposeClient) Stop(ctx context.Context, composeFiles []string, projectName string) error { args := c.buildGlobalArgs(composeFiles, "") if projectName != "" { @@ -264,8 +229,6 @@ func (c *ComposeClient) Stop(ctx context.Context, composeFiles []string, project return nil } -// Down stops and removes the project's containers and networks (`docker compose -// down`). When removeVolumes is set, named volumes are removed too (destructive). func (c *ComposeClient) Down(ctx context.Context, composeFiles []string, projectName string, removeVolumes bool) error { args := c.buildGlobalArgs(composeFiles, "") if projectName != "" { @@ -286,17 +249,10 @@ func (c *ComposeClient) Down(ctx context.Context, composeFiles []string, project return nil } -// SupportsAdditionalContexts returns true if compose version >= 2.17.0, which -// added additional_contexts (TS buildAndExtendDockerCompose: `version && -// !isEarlierVersion(version, [2, 17, 0])`). An unknown (empty) version is -// earlier than 2.17.0, so it reports false like TS. func (c *ComposeClient) SupportsAdditionalContexts() bool { return !isEarlierVersion(c.Version, []int{2, 17, 0}) } -// UsesNewProjectNames returns true if compose version >= 1.21.0 (allowed -// hyphens and underscores in project names), or when the version is unknown -// (TS useNewProjectName: optimistic default). func (c *ComposeClient) UsesNewProjectNames() bool { return len(c.Version) == 0 || !isEarlierVersion(c.Version, []int{1, 21, 0}) } @@ -312,8 +268,6 @@ func (c *ComposeClient) buildGlobalArgs(composeFiles []string, envFile string) [ return args } -// parseComposeVersion extracts the first N[.N]* version found in s (e.g. from -// "Docker Compose version v2.15.1"), every numeric component like TS parseVersion. func parseComposeVersion(s string) []int { i := strings.IndexFunc(s, func(r rune) bool { return r >= '0' && r <= '9' }) if i < 0 { @@ -335,10 +289,7 @@ func parseComposeVersion(s string) []int { return version } -// ToProjectName sanitizes a name for use as a compose project name. -// Matches TS toProjectName() behavior. func ToProjectName(basename string, newProjectName bool) string { - // Compose < 1.21 keeps only [a-z0-9]; >= 1.21 also allows [_-]. return strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': @@ -350,9 +301,6 @@ func ToProjectName(basename string, newProjectName bool) string { }, 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}) { @@ -361,8 +309,6 @@ func (c *ComposeClient) DefaultImageName(projectName, service string) string { 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 diff --git a/internal/docker/compose_held_test.go b/internal/docker/compose_held_test.go index e8a5c3a..304f247 100644 --- a/internal/docker/compose_held_test.go +++ b/internal/docker/compose_held_test.go @@ -8,8 +8,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// Down reports a compose process that could not run and one that exited -// non-zero, and succeeds only on exit 0. func TestDownFailsUnlessComposeExitsZero(t *testing.T) { newClient := func(r *fakeRunner) *ComposeClient { return &ComposeClient{Command: []string{"docker", "compose"}, Log: log.Null, Runner: r} @@ -32,19 +30,12 @@ func TestDownFailsUnlessComposeExitsZero(t *testing.T) { } } -// TS parseVersion (spec-common/commonUtils.ts) keeps every numeric component. func TestParseComposeVersionKeepsEveryComponent(t *testing.T) { if got, want := parseComposeVersion("2.24.5.1"), []int{2, 24, 5, 1}; !slices.Equal(got, want) { t.Errorf("parseComposeVersion = %v, want %v", got, want) } } -// TS compares the whole version with isEarlierVersion, a missing component -// counting as 0: useNewProjectName (dockerCompose.ts) is -// !isEarlierVersion(version, [1, 21, 0]), optimistic when the version is -// unknown, and additional build contexts need !isEarlierVersion(version, -// [2, 17, 0]) (buildAndExtendDockerCompose). A one-component version is -// therefore 1.0.0, 2.0.0 or 3.0.0, not "unknown". func TestComposeVersionGatesCountAMissingComponentAsZero(t *testing.T) { for _, tc := range []struct { version []int diff --git a/internal/docker/compose_log_test.go b/internal/docker/compose_log_test.go index b190a55..7e2cc39 100644 --- a/internal/docker/compose_log_test.go +++ b/internal/docker/compose_log_test.go @@ -12,8 +12,6 @@ import ( "github.com/devcontainers/cli/internal/log" ) -// jsonLog is a --log-format json logger and the events it writes, decoded, -// each as " : ". func jsonLog() (log.Logger, func() []string) { var buf bytes.Buffer lgr := log.New(log.Options{Format: "json", Writer: &buf}) @@ -30,8 +28,6 @@ func jsonLog() (log.Logger, func() []string) { } } -// fakeDocker is an executable docker that answers `compose version --short` -// with version, or fails when version is empty. func fakeDocker(t *testing.T, version string) string { t.Helper() path := filepath.Join(t.TempDir(), "docker") @@ -45,8 +41,6 @@ func fakeDocker(t *testing.T, version string) string { return path } -// Detecting Compose logs the command it runs and the version it found, as TS -// dockerComposeCLIConfig does (spec-node/dockerCompose.ts). func TestNewComposeClientLogsTheComposeVersion(t *testing.T) { docker := fakeDocker(t, "2.24.5") lgr, events := jsonLog() @@ -64,7 +58,6 @@ func TestNewComposeClientLogsTheComposeVersion(t *testing.T) { } } -// When Compose v2 is not there, the command is still logged, but no version. func TestNewComposeClientLogsAFailedVersionCommand(t *testing.T) { docker := fakeDocker(t, "") lgr, events := jsonLog() diff --git a/internal/docker/compose_nodocker_held_test.go b/internal/docker/compose_nodocker_held_test.go index b8004e4..c2b8dd8 100644 --- a/internal/docker/compose_nodocker_held_test.go +++ b/internal/docker/compose_nodocker_held_test.go @@ -2,10 +2,6 @@ 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 diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go index 731abcc..d97668b 100644 --- a/internal/docker/compose_test.go +++ b/internal/docker/compose_test.go @@ -104,7 +104,7 @@ func TestUsesNewProjectNames(t *testing.T) { {[]int{1, 21, 0}, true}, {[]int{1, 20, 0}, false}, {[]int{1, 29, 2}, true}, - {nil, true}, // optimistic default + {nil, true}, } for _, tt := range tests { c := &ComposeClient{Version: tt.version} @@ -137,8 +137,6 @@ func TestBuildGlobalArgs_NoEnvFile(t *testing.T) { } } -// The compose processes get BUILDX_BAKE_ENTITLEMENTS_FS=0 so bake reads the -// Features Dockerfile outside the build context, unless the user set it. func TestWithBakeFSEntitlement(t *testing.T) { unset := func(string) (string, bool) { return "", false } if got := withBakeFSEntitlement([]string{"A=1"}, unset); !slices.Equal(got, []string{"A=1", "BUILDX_BAKE_ENTITLEMENTS_FS=0"}) { diff --git a/internal/docker/composeoverride.go b/internal/docker/composeoverride.go index de16a36..ac2315d 100644 --- a/internal/docker/composeoverride.go +++ b/internal/docker/composeoverride.go @@ -2,10 +2,6 @@ package docker import "strings" -// 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 { 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 6859444..6fbfe4b 100644 --- a/internal/docker/composeoverride_test.go +++ b/internal/docker/composeoverride_test.go @@ -2,8 +2,6 @@ package docker 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) { for _, tt := range []struct { entrypoints []string diff --git a/internal/docker/context.go b/internal/docker/context.go index 11f2d7a..3c26f44 100644 --- a/internal/docker/context.go +++ b/internal/docker/context.go @@ -8,16 +8,6 @@ import ( "path/filepath" ) -// resolveDockerContextHost returns the Docker endpoint host of the active Docker -// CLI context by reading the context store directly — no `docker` binary — matching -// the Docker CLI's own resolution order: -// -// DOCKER_CONTEXT env → ~/.docker/config.json "currentContext" → "default" -// -// The "default" context has no stored endpoint, so "" is returned and the caller -// falls back to the SDK's env-based default socket. It is only consulted when -// DOCKER_HOST is unset (that rung forces "default"), so DOCKER_HOST/-H handling -// lives in the caller. func resolveDockerContextHost() string { configDir := dockerConfigDir() name := activeContextName(configDir) @@ -45,8 +35,6 @@ func resolveDockerContextHost() string { return meta.Endpoints.Docker.Host } -// dockerConfigDir returns the Docker CLI config directory: $DOCKER_CONFIG, else -// ~/.docker. func dockerConfigDir() string { if d := os.Getenv("DOCKER_CONFIG"); d != "" { return d @@ -58,8 +46,6 @@ func dockerConfigDir() string { return filepath.Join(home, ".docker") } -// activeContextName resolves the active context name: $DOCKER_CONTEXT, else the -// "currentContext" field of config.json, else "default". func activeContextName(configDir string) string { if c := os.Getenv("DOCKER_CONTEXT"); c != "" { return c diff --git a/internal/docker/context_held_test.go b/internal/docker/context_held_test.go index 5961371..7146985 100644 --- a/internal/docker/context_held_test.go +++ b/internal/docker/context_held_test.go @@ -18,7 +18,6 @@ func TestResolveDockerContextHostIgnoresAStoredDefault(t *testing.T) { } } -// A config.json without currentContext selects "default". func TestActiveContextNameDefaultsWithoutCurrentContext(t *testing.T) { dir := t.TempDir() t.Setenv("DOCKER_CONTEXT", "") @@ -30,7 +29,6 @@ func TestActiveContextNameDefaultsWithoutCurrentContext(t *testing.T) { } } -// Without DOCKER_CONFIG the store is ~/.docker, as for the Docker CLI. func TestResolveDockerContextHostReadsTheHomeStore(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/docker/context_test.go b/internal/docker/context_test.go index c65d7e3..9a9b2c4 100644 --- a/internal/docker/context_test.go +++ b/internal/docker/context_test.go @@ -8,8 +8,6 @@ import ( "testing" ) -// writeContext seeds a fake ~/.docker context store with one named context whose -// docker endpoint host is `host`. func writeContext(t *testing.T, configDir, name, host string) { t.Helper() sum := sha256.Sum256([]byte(name)) @@ -29,7 +27,6 @@ func TestResolveDockerContextHost(t *testing.T) { t.Setenv("DOCKER_CONTEXT", "") writeContext(t, dir, "remote", "tcp://10.0.0.5:2376") - // currentContext in config.json selects the context. if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"currentContext":"remote"}`), 0o644); err != nil { t.Fatal(err) } @@ -37,7 +34,6 @@ func TestResolveDockerContextHost(t *testing.T) { t.Fatalf("via currentContext = %q, want tcp://10.0.0.5:2376", got) } - // DOCKER_CONTEXT overrides config.json. writeContext(t, dir, "other", "unix:///run/other.sock") t.Setenv("DOCKER_CONTEXT", "other") if got := resolveDockerContextHost(); got != "unix:///run/other.sock" { @@ -50,18 +46,15 @@ func TestResolveDockerContextHostDefaults(t *testing.T) { t.Setenv("DOCKER_CONFIG", dir) t.Setenv("DOCKER_CONTEXT", "") - // No config.json → "default" → "" (SDK falls back to the env default socket). if got := resolveDockerContextHost(); got != "" { t.Fatalf("no config = %q, want empty", got) } - // Explicit "default" currentContext → "" (no stored endpoint). if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"currentContext":"default"}`), 0o644); err != nil { t.Fatal(err) } if got := resolveDockerContextHost(); got != "" { t.Fatalf("default context = %q, want empty", got) } - // A currentContext that has no meta store → "" (not a crash). if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"currentContext":"ghost"}`), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/docker/dockerfile.go b/internal/docker/dockerfile.go index 8aa1d05..f14170a 100644 --- a/internal/docker/dockerfile.go +++ b/internal/docker/dockerfile.go @@ -9,45 +9,35 @@ import ( "github.com/moby/buildkit/frontend/dockerfile/parser" ) -// Dockerfile is a parsed Dockerfile projected from BuildKit's AST into the small -// shape the CLI needs (base-image resolution, final-stage naming). type Dockerfile struct { Preamble Preamble Stages []Stage StagesByLabel map[string]*Stage } -// Preamble is the content before any FROM statement. type Preamble struct { - Version string // tag from # syntax=docker/dockerfile:X.Y (or "latest") + Version string Directives map[string]string - Instructions []Instruction // the global ARGs + Instructions []Instruction } -// Stage is a single FROM ... block. type Stage struct { From From Instructions []Instruction } -// From is a parsed FROM statement. type From struct { Platform string Image string Label string } -// Instruction is a parsed ARG, ENV, or USER statement. type Instruction struct { - Instruction string // "ARG", "ENV", "USER" + Instruction string Name string Value string } -// ExtractDockerfile parses a Dockerfile with BuildKit's real parser (line -// continuations, comments, heredocs and escapes handled correctly) and projects -// it into the internal structs. Surrounding quotes are trimmed from images and -// ARG/ENV values so downstream comparisons see the unquoted form. func ExtractDockerfile(content string) *Dockerfile { df := &Dockerfile{ Preamble: Preamble{Directives: map[string]string{}}, @@ -118,10 +108,6 @@ func stageLabel(s *instructions.Stage) string { return s.Name } -// StageLabels returns the LABEL key/value pairs declared in the named build -// stage (or the final stage when name is ""), with later labels overriding -// earlier ones. Used to recover a user's `LABEL devcontainer.metadata` from the -// Dockerfile so the CLI-stamped metadata label does not clobber it. func (d *Dockerfile) StageLabels(name string) map[string]string { var st *Stage if name != "" { @@ -142,8 +128,6 @@ func (d *Dockerfile) StageLabels(name string) map[string]string { return labels } -// syntaxVersion extracts the tag of a docker/dockerfile syntax directive -// ("docker/dockerfile:1.4" -> "1.4", no tag -> "latest", other frontend -> ""). func syntaxVersion(syntax string) string { s := strings.TrimPrefix(syntax, "docker.io/") if !strings.HasPrefix(s, "docker/dockerfile") { @@ -167,11 +151,6 @@ func derefTrim(p *string) string { return trimQuotes(*p) } -// FindBaseImage is TS findBaseImage: the image the target stage (or the last -// one) is built from, with the --build-arg values and the automatic BUILD*/ -// TARGET* platform ARGs (platformArgs, TS globalBuildxPlatformArgs), following -// FROM to the first image that is no stage; "" when the target is -// unknown or the stages form a cycle. func FindBaseImage(df *Dockerfile, buildArgs map[string]string, target string, platformArgs map[string]string) string { return NewResolver(df, buildArgs, platformArgs).FindBaseImage(target) } @@ -184,22 +163,9 @@ var ( // findFromLines is TS dockerfileUtils' /^(?\s*FROM.*)/gmi; JS's . stops // at \r, \u2028 and \u2029 as well as \n. findFromLines = regexp.MustCompile(`(?mi)^[` + jsSpace + `]*FROM[^\n\r\x{2028}\x{2029}]*`) - // fromLineLabel is TS dockerfileUtils' parseFromLine: - // /FROM\s+(?--platform=\S+\s+)?(?"?[^\s]+"?)(\s+AS\s+(?