diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index 14ae0d3ec..60db0aa73 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -41,5 +41,34 @@ RUN set -eux \ && mkdir -p /workspaces \ && chown -R "${USERNAME}:${USERNAME}" /workspaces +ARG GIT_VERSION=2.55.0 +ARG GIT_SHA256=457fdb04dc8728e007d4688695e6912e6f680727920f2a40bf11eacc17505357 +ARG UV_VERSION=0.12.3 +ARG CARGO_DENY_VERSION=0.20.2 +ARG CARGO_LLVM_COV_VERSION=0.8.7 + +RUN set -eux \ + && apt-get update \ + && apt-get install --yes --no-install-recommends \ + libcurl4-openssl-dev \ + libexpat1-dev \ + libssl-dev \ + zlib1g-dev \ + && curl --fail --silent --show-error --location \ + "https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.xz" \ + --output /tmp/git.tar.xz \ + && echo "${GIT_SHA256} /tmp/git.tar.xz" | sha256sum --check - \ + && tar --extract --xz --file /tmp/git.tar.xz --directory /tmp \ + && make -C "/tmp/git-${GIT_VERSION}" prefix=/usr/local NO_GETTEXT=YesPlease NO_TCLTK=YesPlease -j"$(nproc)" all \ + && make -C "/tmp/git-${GIT_VERSION}" prefix=/usr/local NO_GETTEXT=YesPlease NO_TCLTK=YesPlease install \ + && rm -rf /tmp/git.tar.xz "/tmp/git-${GIT_VERSION}" \ + && rm -rf /var/lib/apt/lists/* \ + && curl --fail --silent --show-error --location "https://astral.sh/uv/${UV_VERSION}/install.sh" \ + | env UV_INSTALL_DIR=/usr/local/bin sh \ + && rustup component add llvm-tools-preview \ + && cargo install --locked --version "${CARGO_DENY_VERSION}" cargo-deny \ + && cargo install --locked --version "${CARGO_LLVM_COV_VERSION}" cargo-llvm-cov \ + && chown -R "${USERNAME}:${USERNAME}" /usr/local/cargo /usr/local/rustup + USER ${USERNAME} WORKDIR /workspaces/devcontainer-rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9ecf52539..e642018a6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "context": "..", "dockerfile": "Containerfile" }, - "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && git submodule update --init --recursive", + "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && git submodule update --init --recursive && COREPACK_ENABLE_PROJECT_SPEC=0 corepack yarn --cwd upstream install --frozen-lockfile --modules-folder ../node_modules", "remoteUser": "dev", "updateRemoteUserUID": true } diff --git a/Makefile b/Makefile index f0464b099..4db1804be 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ check-compatibility-dashboard \ check-upstream-test-coverage \ check-devcontainer-config \ + devcontainer-provision-smoke \ upstream-compatibility RUST_MANIFEST := cmd/devcontainer/Cargo.toml @@ -43,7 +44,7 @@ ACTIONLINT := uv tool run --from actionlint-py actionlint SHELLCHECK := uv tool run --from shellcheck-py shellcheck SHELLCHECK_FILES := $(shell git ls-files -- '*.sh' '.githooks/pre-commit' ':(exclude)upstream/**' ':(exclude)spec/**' ':(exclude)target/**' ':(exclude)node_modules/**') -tests: rust-fmt rust-tests rust-clippy rust-check rust-doc rust-coverage cargo-deny-check actionlint-check shellcheck build-release standalone-artifact-smoke pypi-wheel-smoke native-only-startup-contract acceptance-fixtures-check check-upstream-submodule command-matrix-drift-check check-cli-reference schema-drift-check parity-harness no-node-runtime npm-wrapper-check npm-publish-script-check npm-package-smoke artifact-smoke-workflow-check tap-check homebrew-distribution-check npm-publish-workflow-check check-parity-inventory check-cli-metadata check-compatibility-dashboard check-upstream-test-coverage check-devcontainer-config upstream-compatibility +tests: rust-fmt rust-tests rust-clippy rust-check rust-doc rust-coverage cargo-deny-check actionlint-check shellcheck build-release standalone-artifact-smoke pypi-wheel-smoke native-only-startup-contract acceptance-fixtures-check check-upstream-submodule command-matrix-drift-check check-cli-reference schema-drift-check parity-harness no-node-runtime npm-wrapper-check npm-publish-script-check npm-package-smoke artifact-smoke-workflow-check tap-check homebrew-distribution-check npm-publish-workflow-check check-parity-inventory check-cli-metadata check-compatibility-dashboard check-upstream-test-coverage check-devcontainer-config devcontainer-provision-smoke upstream-compatibility rust-fmt: cargo fmt --manifest-path $(RUST_MANIFEST) --all -- --check @@ -150,5 +151,8 @@ check-upstream-test-coverage: check-devcontainer-config: node build/check-devcontainer-config.js +devcontainer-provision-smoke: build-release + ./scripts/standalone/devcontainer-provision-smoke.sh $(RELEASE_BINARY) + upstream-compatibility: node build/check-upstream-compatibility.js diff --git a/README.md b/README.md index c7c619072..f765189fb 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ not shell command strings with arguments. Use | `--buildkit` | `DEVCONTAINER_BUILDKIT` | | `--user-data-folder` | `DEVCONTAINER_USER_DATA_FOLDER` | | `--container-data-folder` | `DEVCONTAINER_CONTAINER_DATA_FOLDER` | +| `--dotfiles-repository` | `DEVCONTAINER_DOTFILES_REPOSITORY` | +| `--dotfiles-install-command` | `DEVCONTAINER_DOTFILES_INSTALL_COMMAND` | +| `--dotfiles-target-path` | `DEVCONTAINER_DOTFILES_TARGET_PATH` | | `--gpu-availability` | `DEVCONTAINER_GPU_AVAILABILITY` | | `--update-remote-user-uid-default` | `DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT` | | `--mount-workspace-git-root` | `DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT` | diff --git a/build/check-devcontainer-config.js b/build/check-devcontainer-config.js index 9d76aec5f..5add77363 100644 --- a/build/check-devcontainer-config.js +++ b/build/check-devcontainer-config.js @@ -98,8 +98,8 @@ function main() { assert.equal(config.updateRemoteUserUID, true, 'devcontainer should keep host UID/GID alignment enabled'); assert.equal( config.postCreateCommand, - 'git config --global --add safe.directory ${containerWorkspaceFolder} && git submodule update --init --recursive', - 'postCreateCommand should trust the mounted workspace before initializing pinned submodules', + 'git config --global --add safe.directory ${containerWorkspaceFolder} && git submodule update --init --recursive && COREPACK_ENABLE_PROJECT_SPEC=0 corepack yarn --cwd upstream install --frozen-lockfile --modules-folder ../node_modules', + 'postCreateCommand should initialize submodules and install pinned upstream tooling dependencies', ); assert(!('features' in config), 'devcontainer should not depend on external devcontainer features'); assert(!('customizations' in config), 'devcontainer should not carry editor-specific customizations'); diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index ec1d7a7ef..fe93d78e4 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -19,6 +19,10 @@ pub(crate) const DEVCONTAINER_CONFIG: &str = "DEVCONTAINER_CONFIG"; pub(crate) const DEVCONTAINER_BUILDKIT: &str = "DEVCONTAINER_BUILDKIT"; pub(crate) const DEVCONTAINER_USER_DATA_FOLDER: &str = "DEVCONTAINER_USER_DATA_FOLDER"; pub(crate) const DEVCONTAINER_CONTAINER_DATA_FOLDER: &str = "DEVCONTAINER_CONTAINER_DATA_FOLDER"; +pub(crate) const DEVCONTAINER_DOTFILES_REPOSITORY: &str = "DEVCONTAINER_DOTFILES_REPOSITORY"; +pub(crate) const DEVCONTAINER_DOTFILES_INSTALL_COMMAND: &str = + "DEVCONTAINER_DOTFILES_INSTALL_COMMAND"; +pub(crate) const DEVCONTAINER_DOTFILES_TARGET_PATH: &str = "DEVCONTAINER_DOTFILES_TARGET_PATH"; pub(crate) const DEVCONTAINER_GPU_AVAILABILITY: &str = "DEVCONTAINER_GPU_AVAILABILITY"; pub(crate) const DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT: &str = "DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT"; @@ -279,9 +283,21 @@ pub(crate) fn runtime_options(args: &[String]) -> RuntimeOptions { "--update-remote-user-uid-default", DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, ), - dotfiles_repository: parse_option_value(args, "--dotfiles-repository"), - dotfiles_install_command: parse_option_value(args, "--dotfiles-install-command"), - dotfiles_target_path: parse_option_value(args, "--dotfiles-target-path"), + dotfiles_repository: env_default_option_value( + args, + "--dotfiles-repository", + DEVCONTAINER_DOTFILES_REPOSITORY, + ), + dotfiles_install_command: env_default_option_value( + args, + "--dotfiles-install-command", + DEVCONTAINER_DOTFILES_INSTALL_COMMAND, + ), + dotfiles_target_path: env_default_option_value( + args, + "--dotfiles-target-path", + DEVCONTAINER_DOTFILES_TARGET_PATH, + ), user_data_folder: env_default_option_value( args, "--user-data-folder", @@ -504,10 +520,11 @@ mod tests { runtime_options, runtime_process_request, secrets_env, test_env_defaults, validate_choice_option, validate_number_option, validate_option_values, validate_paired_options, validate_runtime_env_defaults, DEVCONTAINER_BUILDKIT, - DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_GPU_AVAILABILITY, - DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, - DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, DEVCONTAINER_USER_DATA_FOLDER, - DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY, + DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_DOTFILES_INSTALL_COMMAND, + DEVCONTAINER_DOTFILES_REPOSITORY, DEVCONTAINER_DOTFILES_TARGET_PATH, + DEVCONTAINER_GPU_AVAILABILITY, DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, + DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, + DEVCONTAINER_USER_DATA_FOLDER, DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY, }; #[test] @@ -707,6 +724,9 @@ mod tests { (DEVCONTAINER_BUILDKIT, "never"), (DEVCONTAINER_GPU_AVAILABILITY, "all"), (DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, "off"), + (DEVCONTAINER_DOTFILES_REPOSITORY, "owner/dotfiles"), + (DEVCONTAINER_DOTFILES_INSTALL_COMMAND, "bootstrap.sh"), + (DEVCONTAINER_DOTFILES_TARGET_PATH, "/env/dotfiles"), (DEVCONTAINER_USER_DATA_FOLDER, "/env/user-data"), (DEVCONTAINER_CONTAINER_DATA_FOLDER, "/env/container-data"), ]); @@ -719,6 +739,18 @@ mod tests { options.update_remote_user_uid_default.as_deref(), Some("off") ); + assert_eq!( + options.dotfiles_repository.as_deref(), + Some("owner/dotfiles") + ); + assert_eq!( + options.dotfiles_install_command.as_deref(), + Some("bootstrap.sh") + ); + assert_eq!( + options.dotfiles_target_path.as_deref(), + Some("/env/dotfiles") + ); assert_eq!(options.user_data_folder.as_deref(), Some("/env/user-data")); assert_eq!( options.container_data_folder.as_deref(), @@ -726,6 +758,52 @@ mod tests { ); } + #[test] + fn runtime_options_prefer_dotfiles_cli_values_over_env_defaults() { + let _env = test_env_defaults(&[ + (DEVCONTAINER_DOTFILES_REPOSITORY, "env/repository"), + (DEVCONTAINER_DOTFILES_INSTALL_COMMAND, "env-install.sh"), + (DEVCONTAINER_DOTFILES_TARGET_PATH, "/env/dotfiles"), + ]); + + let options = runtime_options(&[ + "--dotfiles-repository".to_string(), + "cli/repository".to_string(), + "--dotfiles-install-command".to_string(), + "cli-install.sh".to_string(), + "--dotfiles-target-path".to_string(), + "/cli/dotfiles".to_string(), + ]); + + assert_eq!( + options.dotfiles_repository.as_deref(), + Some("cli/repository") + ); + assert_eq!( + options.dotfiles_install_command.as_deref(), + Some("cli-install.sh") + ); + assert_eq!( + options.dotfiles_target_path.as_deref(), + Some("/cli/dotfiles") + ); + } + + #[test] + fn runtime_options_ignore_blank_dotfiles_env_defaults() { + let _env = test_env_defaults(&[ + (DEVCONTAINER_DOTFILES_REPOSITORY, " "), + (DEVCONTAINER_DOTFILES_INSTALL_COMMAND, "\t"), + (DEVCONTAINER_DOTFILES_TARGET_PATH, "\n"), + ]); + + let options = runtime_options(&[]); + + assert_eq!(options.dotfiles_repository, None); + assert_eq!(options.dotfiles_install_command, None); + assert_eq!(options.dotfiles_target_path, None); + } + #[test] fn runtime_env_default_validation_checks_choices_and_bools() { let env = test_env_defaults(&[(DEVCONTAINER_GPU_AVAILABILITY, "invalid")]); diff --git a/cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs b/cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs index 099826339..f22717ef8 100644 --- a/cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs +++ b/cmd/devcontainer/tests/runtime_lifecycle_smoke/dotfiles.rs @@ -52,6 +52,54 @@ fn up_installs_dotfiles_between_post_create_and_post_start() { assert!(exec_log.contains("printf 'post-start\\n' >> /workspaces/workspace/order.log")); } +#[test] +fn up_installs_dotfiles_from_environment_defaults() { + let harness = RuntimeHarness::new(); + let workspace = WorkspaceFixture::new(harness.workspace()); + workspace.init_dotfiles_repo( + "dotfiles-env-repo", + "#!/bin/sh\nset -eu\nprintf 'env-dotfiles\\n' >> ../env-order.log\n", + ); + let order_log = workspace.root().join("env-order.log"); + write_devcontainer_config( + workspace.root(), + "{\n \"image\": \"alpine:3.20\",\n \"postCreateCommand\": \"printf 'post-create\\\\n' > /workspaces/workspace/env-order.log\",\n \"postStartCommand\": \"printf 'post-start\\\\n' >> /workspaces/workspace/env-order.log\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.root().to_string_lossy().as_ref(), + "--container-data-folder", + "./.devcontainer-data", + ], + &[ + ("DEVCONTAINER_DOTFILES_REPOSITORY", "./dotfiles-env-repo"), + ("DEVCONTAINER_DOTFILES_INSTALL_COMMAND", "install.sh"), + ( + "DEVCONTAINER_DOTFILES_TARGET_PATH", + "./env-applied-dotfiles", + ), + ("FAKE_PODMAN_PS_DISABLE_DEFAULT", "1"), + ], + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_to_string(order_log).expect("environment order log"), + "post-create\nenv-dotfiles\npost-start\n" + ); + let exec_log = harness.read_exec_log(); + assert!(exec_log.contains("./dotfiles-env-repo"), "{exec_log}"); + assert!(exec_log.contains("./env-applied-dotfiles"), "{exec_log}"); + assert!(exec_log.contains("if [ -f './install.sh' ]"), "{exec_log}"); + assert!(!exec_log.contains("for f in install.sh"), "{exec_log}"); +} + #[test] fn dotfiles_marker_skips_reinstall_on_followup_lifecycle_runs() { let harness = RuntimeHarness::new(); diff --git a/scripts/standalone/devcontainer-provision-smoke.sh b/scripts/standalone/devcontainer-provision-smoke.sh new file mode 100755 index 000000000..7583d6aae --- /dev/null +++ b/scripts/standalone/devcontainer-provision-smoke.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 || ! -x "$1" ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +binary="$1" +repository_root="$(git rev-parse --show-toplevel)" +tmp_dir="$(mktemp -d)" +workspace="$tmp_dir/workspace" +result_file="$tmp_dir/up.json" +container_id="" + +cleanup() { + if [[ -n "$container_id" ]]; then + docker rm --force "$container_id" >/dev/null 2>&1 || true + fi + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required for the devcontainer provisioning smoke test" >&2 + exit 2 +fi + +git clone --quiet --local --no-hardlinks "$repository_root" "$workspace" + +"$binary" up --workspace-folder "$workspace" >"$result_file" +if ! grep -Fq '"outcome":"success"' "$result_file"; then + echo "devcontainer creation did not report success" >&2 + cat "$result_file" >&2 + exit 1 +fi + +container_id="$(sed -n 's/.*"containerId":"\([^"]*\)".*/\1/p' "$result_file")" +if [[ -z "$container_id" ]]; then + echo "devcontainer creation did not return a container id" >&2 + cat "$result_file" >&2 + exit 1 +fi + +"$binary" exec --workspace-folder "$workspace" /bin/bash -lc ' + set -euo pipefail + test -d node_modules + git --version + node --version + corepack --version + COREPACK_ENABLE_PROJECT_SPEC=0 corepack yarn --cwd upstream --version + rustc --version + cargo --version + cargo deny --version + cargo llvm-cov --version + uv --version +' + +echo "[devcontainer-provision] devcontainer built, provisioned, and exposed the required tools."