diff --git a/.anvil.lock b/.anvil.lock index 87055558..fc37cc9f 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,39 +1,11 @@ version = 1 tool = "anvil" tool_version = "0.5.0" -catalog_checksum = "sha256:b9e76e5dab6d2cd8cd2eaea04bd1b4457ea8839a688795981dc4cc9a415f8092" +catalog_checksum = "sha256:06b57c3c034a1e91e9e8bac0411d892496a3aeba35aa5848005312c6b192bbb7" [[file]] -path = ".anvil/container/Containerfile" -checksum = "sha256:4c343282b4e773978ff29b7d39e3a0955975bf64ad3e63afe1aa53e51be42916" - -[[file]] -path = ".anvil/container/Containerfile.dockerignore" -checksum = "sha256:359c2bd70d599ff1e5674eae2af5bb35f344f78e642da7024cfdc6efc5d345da" - -[[file]] -path = ".anvil/container/README.md" -checksum = "sha256:ea255c3659e17dee291ded4633394d080c5aae8bd6e3e87295f27ffab5f428bc" - -[[file]] -path = ".anvil/container/entrypoint.sh" -checksum = "sha256:09576bca317f5a413572f6626fc052214f885589f0547ed0b0c044b92cc402e7" - -[[file]] -path = ".anvil/container/image-id.ps1" -checksum = "sha256:8cdd2dd9cdfb037d768802e4dda7d1156f626dc711663e6bc39fbb86d1ce2e04" - -[[file]] -path = ".anvil/container/image-id.sh" -checksum = "sha256:b526a643e42902dd1e8acff65529fb63741760f72c14b123e9747394158faf53" - -[[file]] -path = ".anvil/container/run-in-container.ps1" -checksum = "sha256:b29d0b7a362d204ce9da0739ae61a480b71239aed52e84c4e015e8ea074e5b06" - -[[file]] -path = ".anvil/container/run-in-container.sh" -checksum = "sha256:0f4fcf0fef43e4736260af771cae15b7eb8afc382e5290fabddc52894b09139d" +path = ".anvil/container/Dockerfile.dockerignore" +checksum = "sha256:9c7906c20415ca3b832afb075c21e79f191ef14ee2fb4b6a5a95394e8b2153c1" [[file]] path = ".github/actions/anvil-impact/action.yml" @@ -73,7 +45,7 @@ checksum = "sha256:d4d3bd645a5586e9a1cc3a5fc27e38c93e59b1e29f83ede0ccd610273eec0 [[file]] path = "justfiles/anvil/checks/aprz.just" -checksum = "sha256:62d8334b55b0ba3b221550037dec987d9749e773f40df6c0cd18f08164c095bf" +checksum = "sha256:0f9f3dd3c8a2f034ebf4f2ac0344cba3db79612ff2fc2ea037bfabbaacb1be12" [[file]] path = "justfiles/anvil/checks/audit.just" @@ -165,7 +137,7 @@ checksum = "sha256:9e9d0cbfef1e1e1586c2af4e9c387719c2f017ea1203d326baacc3ae25df8 [[file]] path = "justfiles/anvil/checks/mutants-diff.just" -checksum = "sha256:cb9721c1cc94161ea8bc20ccd94209daa23d83d1beda9f3c0969e2031a135022" +checksum = "sha256:d13b1c467b8899d54e3567cb8d1a2157c4a54ac7ba9dabc00a09f3105fddb9b4" [[file]] path = "justfiles/anvil/checks/mutants-full.just" @@ -181,7 +153,7 @@ checksum = "sha256:d346399f288570066e53fd123baf05f7d4a57f17da8ee687a6d881204c5ba [[file]] path = "justfiles/anvil/checks/semver-check.just" -checksum = "sha256:1b8b67f8c054c8da03d43b746f28c20f5366996e3dea3c21cf25d04a6496a072" +checksum = "sha256:021ea0f7185b183aeb5a53810e4a227964710593410e4a1942b4b2dea3fe4547" [[file]] path = "justfiles/anvil/checks/spellcheck.just" @@ -193,7 +165,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:cbec6450a64f800963ea5ba9d2eedfa4e90d91d441307f18d109629f849126ad" +checksum = "sha256:c8ad802cf2a7d7630098dacec536284e80dbeb481a4bba813d941c6460c144ac" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -217,23 +189,23 @@ checksum = "sha256:ea96d29e261b454a585c0ba3dc7954a35d0c726e9e94f6bb7c82f15261531 [[file]] path = "justfiles/anvil/groups/scheduled-advisories.just" -checksum = "sha256:55bba93aa828d6c3e2b72b637627511703dfb2d84262a2a8608bd5e247f56d2b" +checksum = "sha256:4f9940bb54fd7cd1d622f3207f7c43f38f95232a370f7537b15546271d88805d" [[file]] path = "justfiles/anvil/groups/scheduled-exhaustive.just" -checksum = "sha256:46699373120faccca1462d68b4184531375b557ea787958b7e63472dab038820" +checksum = "sha256:0b9023c614ae400c30f7131fc939b318bf6ee2ba086b18d45491fa30691694e7" [[file]] path = "justfiles/anvil/groups/scheduled-runtime-analysis.just" -checksum = "sha256:e0bc75b9aa950ac8f3bfd07a0e506260a48aa6e669da3c1feed69a1b29733ce2" +checksum = "sha256:51d43ac7399183467cb137f655fa0f1f0c6cf4980aab019dc5898a40ad18259d" [[file]] path = "justfiles/anvil/groups/scheduled-test.just" -checksum = "sha256:a00153eda6b55d4db33e8019fa74f4a905f9c80b10b0db10955179c0af3e9c05" +checksum = "sha256:04679222579a090769403f5aae7c9ffabbf5bd559ddb1f17b5a0655152172846" [[file]] path = "justfiles/anvil/helpers.just" -checksum = "sha256:6b71379310b986e9a44000ba9d072c3f8934397a0f7fb1bf27a31a7742ca7002" +checksum = "sha256:1208b9a51b94ef9bf60a898d23c35c97175279c661a90e774ec3960989b4c5c9" [[file]] path = "justfiles/anvil/impact.just" @@ -241,15 +213,11 @@ checksum = "sha256:5714a138135154b91b234f6bad06ec3ade0e5780f761d631c1eca92b6010e [[file]] path = "justfiles/anvil/mod.just" -checksum = "sha256:76ded6ee071fc3ba1b2b38ca97c0b2d15ab75db20ceda44c57436e5cd249ae0b" - -[[file]] -path = "justfiles/anvil/runner.just" -checksum = "sha256:a5fb128a307c72cbd31dccba8b7dc044a350522e692583423724f48f51b4580d" +checksum = "sha256:2f7f0187f8c45716a1bed85f0c45ffbc71cdf592ed6414c9bc4cd72cf716a3a5" [[file]] path = "justfiles/anvil/tiers.just" -checksum = "sha256:5b2d91569f3fe87cd5f516623a01b5c7ae855ad587870ef9a2ae6d5619dd9d34" +checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cdd18" [[file]] path = "justfiles/anvil/tools.just" @@ -259,6 +227,31 @@ checksum = "sha256:a1e44ca16f172b487afa3997f102512733d3b65a4418cf894cbd749a3abc1 path = "justfiles/anvil/versions.just" checksum = "sha256:acbea93d5117db747537f4f7b9a5eb90b7d3e0dd3e8684cc0e4dc1dcb15ac93e" +[[region]] +host = ".anvil/container/Dockerfile" +id = "anvil-container-base" +checksum = "sha256:734e21d8ae8ce8c0a00f54a36a3a9f15a02b52eff11c27f3de4f7cd60bb95449" + +[[region]] +host = ".anvil/container/Dockerfile" +id = "anvil-container-base-image" +checksum = "sha256:64fa253bd48baecd440ae02cb13d2626e17be6e2bb0e1b19e3345baeade04abb" + +[[region]] +host = ".anvil/container/Dockerfile" +id = "anvil-container-entry" +checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50365" + +[[region]] +host = ".anvil/container/Dockerfile" +id = "anvil-container-setup" +checksum = "sha256:ecba82c02af5b339dfa073239cfbe396205db6395288d891837f5fba3f7644cd" + +[[region]] +host = ".anvil/container/Dockerfile" +id = "anvil-container-tools" +checksum = "sha256:0b6923e81ec99170ece298fcd9ef875f26a3ff018708ce5ec21b4b1dd7a18900" + [[region]] host = ".delta.toml" id = "anvil-delta" @@ -279,11 +272,6 @@ host = "Justfile" id = "anvil-imports" checksum = "sha256:f8affd59b69c7083c2f3b6f593c63672116dda974c1e661dcb66a4412eb3eada" -[[region]] -host = "Justfile" -id = "anvil-runner" -checksum = "sha256:a31c6dd3fc8402e1e89fcf1948e03103e0dc66fbb988c34510eddeded5a67b0e" - [[region]] host = "clippy.toml" id = "anvil-clippy" @@ -324,6 +312,46 @@ host = "crates/cargo-ensure-no-default-features/Cargo.toml" id = "anvil-lints" checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" +[[region]] +host = "crates/cargo-gamma-attrs-impl/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-attrs/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-engine/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-lib/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-process/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-rt/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma-unsafe/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-gamma/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + [[region]] host = "crates/cargo-heather/Cargo.toml" id = "anvil-lints" diff --git a/.anvil/container/Containerfile b/.anvil/container/Containerfile deleted file mode 100644 index fa68b18f..00000000 --- a/.anvil/container/Containerfile +++ /dev/null @@ -1,69 +0,0 @@ -# syntax=docker/dockerfile:1 -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. - -ARG BASE_IMAGE=docker.io/library/debian:bookworm-slim@sha256:63a496b5d3b99214b39f5ed70eb71a61e590a77979c79cbee4faf991f8c0783e -FROM ${BASE_IMAGE} - -ARG ANVIL_IMAGE_ID -ARG JUST_VERSION=1.56.0 -ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 -ARG POWERSHELL_VERSION=7.6.3 -ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 -ARG RUSTUP_VERSION=1.29.0 -ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 - -ENV DEBIAN_FRONTEND=noninteractive \ - CARGO_HOME=/usr/local/cargo \ - RUSTUP_HOME=/usr/local/rustup \ - RUSTUP_NO_UPDATE_CHECK=1 \ - PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential ca-certificates clang libclang-dev curl git libicu-dev \ - libssl-dev pkg-config tar \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsSLo /tmp/powershell.tar.gz \ - "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ - && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ - && mkdir -p /opt/microsoft/powershell/7 \ - && tar -xzf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ - && chmod 755 /opt/microsoft/powershell/7/pwsh \ - && ln -s /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh \ - && rm /tmp/powershell.tar.gz - -RUN curl -fsSLo /tmp/just.tar.gz \ - "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ - && echo "${JUST_SHA256} /tmp/just.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/just.tar.gz -C /usr/local/bin just \ - && chmod 755 /usr/local/bin/just \ - && rm /tmp/just.tar.gz - -RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ - "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ - && echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \ - && chmod 755 /tmp/rustup-init \ - && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ - && rm /tmp/rustup-init - -WORKDIR /opt/anvil -COPY . . -RUN test -f rust-toolchain.toml || { \ - echo "anvil-container requires rust-toolchain.toml" >&2; \ - exit 1; \ - } -RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry \ - --mount=type=cache,id=anvil-cargo-git,target=/usr/local/cargo/git \ - --mount=type=cache,id=anvil-cargo-target,target=/tmp/anvil-target \ - printf "anvil_runner := \"native\"\nimport 'justfiles/anvil/mod.just'\n" > Justfile \ - && CARGO_TARGET_DIR=/tmp/anvil-target just anvil-setup - -COPY .anvil/container/entrypoint.sh /usr/local/bin/anvil-container-entrypoint -RUN chmod 755 /usr/local/bin/anvil-container-entrypoint - -ENV ANVIL_IN_CONTAINER=1 -LABEL io.github.cargo-anvil.image-id="${ANVIL_IMAGE_ID}" -WORKDIR /workspace -ENTRYPOINT ["anvil-container-entrypoint"] -CMD ["bash"] diff --git a/.anvil/container/Containerfile.dockerignore b/.anvil/container/Containerfile.dockerignore deleted file mode 100644 index 6566e657..00000000 --- a/.anvil/container/Containerfile.dockerignore +++ /dev/null @@ -1,26 +0,0 @@ -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Deny-all allow-list for the image build context. -# -# Docker matches each candidate against every pattern in order and lets the -# last match win, testing the path itself *and each of its parent directories* -# (moby/patternmatcher MatchesOrParentMatches). A bare directory re-inclusion -# such as `!justfiles` therefore re-admits the entire subtree below it, which -# would defeat this allow-list, so list only leaf patterns here. Docker still -# descends into a denied directory when some re-inclusion pattern is prefixed -# by it, so the intermediate directories need no entries of their own. -# -# Parent testing also reaches through a single-segment re-inclusion: a -# subdirectory of `.anvil/container/` matches `!.anvil/container/*` in its own -# right. The image-ID helpers list that directory one level deep, so a nested -# file is not an image input; `.anvil/container/*/*` states that leaf-only -# contract in the allow-list too, at every depth, because a deeper candidate -# always has an ancestor of exactly that shape. -** -!rust-toolchain.toml -!justfiles/anvil/*.just -!justfiles/anvil/checks/*.just -!justfiles/anvil/groups/*.just -!.anvil/container/* -.anvil/container/*/* -.anvil/container/customize.sh -.anvil/container/customize.ps1 diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile new file mode 100644 index 00000000..6fbb71a7 --- /dev/null +++ b/.anvil/container/Dockerfile @@ -0,0 +1,112 @@ +# syntax=docker/dockerfile:1 + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# >>> anvil-managed: anvil-container-base-image +# Prebuilt binaries installed by `anvil-setup binstall` link against this +# image's glibc, so it tracks the Linux runner the generated workflows use. +# Digest-pinned: a floating tag moves content under a reference that claims to +# name fixed content. +# +# Re-declare BASE_IMAGE in the gap below to build on another base; a later ARG +# wins, and the pins anvil maintains stay current. +ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +# <<< anvil-managed: anvil-container-base-image + +# >>> anvil-managed: anvil-container-base +FROM ${BASE_IMAGE} + +ARG JUST_VERSION=1.56.0 +ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 +ARG POWERSHELL_VERSION=7.6.3 +ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 +ARG RUSTUP_VERSION=1.29.0 +ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 +ARG CARGO_BINSTALL_VERSION=1.21.1 +ARG CARGO_BINSTALL_SHA256=630c8f8803a686aa6779497f0f0fb51d49822fb5fc3c514d8ced33b34e338e6e + +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + RUSTUP_NO_UPDATE_CHECK=1 \ + PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# <<< anvil-managed: anvil-container-base + +# >>> anvil-managed: anvil-container-tools +# clang/libclang are required by cargo-spellcheck; the rest is the usual Rust +# link-time set. A bare base has no C runtime development files, so every link +# step fails without build-essential. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential ca-certificates clang libclang-dev curl git libicu-dev \ + libssl-dev pkg-config tar \ + && rm -rf /var/lib/apt/lists/* + +# pwsh is not optional: every generated anvil recipe is a `script("pwsh", +# "-NoProfile")` recipe. +RUN curl -fsSLo /tmp/powershell.tar.gz \ + "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ + && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ + && mkdir -p /opt/microsoft/powershell/7 \ + && tar -xzf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ + && chmod 755 /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh \ + && rm /tmp/powershell.tar.gz + +RUN curl -fsSLo /tmp/just.tar.gz \ + "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + && echo "${JUST_SHA256} /tmp/just.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/just.tar.gz -C /usr/local/bin just \ + && chmod 755 /usr/local/bin/just \ + && rm /tmp/just.tar.gz + +RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + && echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \ + && chmod 755 /tmp/rustup-init \ + && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ + && rm /tmp/rustup-init + +RUN curl -fsSLo /tmp/cargo-binstall.tgz \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" \ + && echo "${CARGO_BINSTALL_SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c - \ + && mkdir -p "${CARGO_HOME}/bin" \ + && tar -xzf /tmp/cargo-binstall.tgz -C "${CARGO_HOME}/bin" cargo-binstall \ + && chmod 755 "${CARGO_HOME}/bin/cargo-binstall" \ + && rm /tmp/cargo-binstall.tgz + +# <<< anvil-managed: anvil-container-tools + +# >>> anvil-managed: anvil-container-setup +# The whole recipe tree is copied because `just` parses it to reach the install +# recipes. +# +# The credential files are removed in the same layer that used them: a build +# secret never lands in a layer, but anything the install *writes* with it is +# ordinary content, and the `chmod` below would publish it world-readable. A +# later `RUN` cannot undo that, because the earlier layer keeps them. +# +# `registry` and `git` must exist before the `chmod`. The run mounts a named +# volume over each, and an engine seeds a new volume from the image path it +# covers; a path that does not exist seeds as root-owned 0755, which the +# `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +WORKDIR /opt/anvil +COPY justfiles ./justfiles +COPY rust-toolchain.toml ./ +RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ + && just anvil-setup binstall \ + && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ + && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME}/.netrc" \ + && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ + && chmod -R a+rwX "${CARGO_HOME}" "${RUSTUP_HOME}" +# <<< anvil-managed: anvil-container-setup + +# >>> anvil-managed: anvil-container-entry +# Consumed by `anvil-container` itself: a nested invocation from inside the +# image runs the recipe natively instead of launching another container. +ENV ANVIL_IN_CONTAINER=1 + +WORKDIR /workspace +CMD ["bash"] +# <<< anvil-managed: anvil-container-entry diff --git a/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore new file mode 100644 index 00000000..577907f0 --- /dev/null +++ b/.anvil/container/Dockerfile.dockerignore @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. +# Update the corresponding template in the cargo-anvil crate. +# +# BuildKit reads `.dockerignore` in preference to a root +# `.dockerignore`, so this scopes the exec-image build context without the +# repository having to own a root ignore file or having one silently overridden. +# +# The build context is the repository root but the image only needs two things. +# Excluding everything else keeps a cold build from streaming the whole +# worktree (and every stale `target/`) to the daemon. +# +# The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` +# so that a cold build does not stream unrelated trees to the daemon. The +# recipes are copied to drive `just anvil-setup`, which needs the whole tree to +# parse, and the whole tree is hashed into the image tag: the tier, group and +# check recipes decide which tools `anvil-setup` reaches, not just the catalog. +# +# `.anvil/container/` is admitted because the Dockerfile is composed: the gaps +# between anvil's regions exist for a repository to add its own instructions, +# and the headline case -- `COPY`ing a corporate root CA in before the first +# download -- needs the file to be in the context. Denying it would leave the +# gap documented but unusable for anything but `RUN`. It is also the directory +# the image tag digests, so what the context admits and what the tag covers stay +# the same set -- including the `.anvil-proposed` siblings both exclude, which +# are anvil's review artifacts rather than build inputs. +* +!justfiles +justfiles/* +!justfiles/anvil +justfiles/anvil/**/*.anvil-proposed +!.anvil +.anvil/* +!.anvil/container +.anvil/container/**/*.anvil-proposed +!rust-toolchain.toml diff --git a/.anvil/container/README.md b/.anvil/container/README.md deleted file mode 100644 index b9cd1642..00000000 --- a/.anvil/container/README.md +++ /dev/null @@ -1,228 +0,0 @@ - - -# Run Anvil checks in a local container - -Use `just anvil-container` to run generated Anvil checks in a reproducible -Linux environment without installing the complete Rust and Cargo tool catalog -on the host. - -Native execution remains the default. The first container run builds an image -matching the repository's generated configuration. Later runs reuse that image, -dependency caches, and compilation output. - -## Quick start - -Ensure Docker Engine is running, then run: - -```text -just anvil-container anvil-clippy -``` - -The first run builds the matching image and can take several minutes. - -## Prerequisites - -- [Docker Engine](https://docs.docker.com/engine/install/) 23.0 or newer, - installed directly in Linux or WSL and usable by the current user. -- `git` and `just` on the host. -- Bash on Linux and WSL; PowerShell Core (`pwsh`) and WSL 2 on Windows. -- `[script]` support enabled in the root `Justfile`. Add `set unstable` when - required by the installed `just` version. -- A `rust-toolchain.toml` in the repository root. -- A Linux or WSL environment capable of running `linux/amd64` images, either - natively on x86-64 or through Docker emulation on ARM64. - -On Windows, the driver invokes Docker from the default WSL distribution rather -than calling Windows `docker.exe`. Regardless of how Docker is installed, this -command must succeed from PowerShell: - -```text -wsl -e docker version -``` - -Start the Docker service inside WSL when it is stopped and add the WSL user to -the `docker` group when non-root access is not already configured. Docker -Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment. Image -builds and checks can therefore be substantially slower than on x86-64 hosts. - -## Security boundary - -> [!WARNING] -> `customize.sh` and `customize.ps1` execute on the host with the developer's -> permissions before container isolation begins. Reviewing and trusting these -> files is equivalent to reviewing and trusting any other host-executed script -> in the checked-out branch. - -## Common workflows - -Run one check: - -```text -just anvil-container anvil-clippy -``` - -Run the complete pull-request tier: - -```text -just anvil-container anvil-pr -``` - -Every argument is treated as a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not supported by this command surface. - -Open an interactive Bash shell in the image: - -```text -just anvil-container -``` - -### Use containers for tier commands - -Native execution remains the default. To route tier commands such as -`just anvil-pr` through the container for the current shell: - -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` - -On Unix: - -```sh -ANVIL_RUNNER=container just anvil-pr -``` - -For one invocation: - -```text -just anvil_runner=container anvil-pr -``` - -To make container execution the repository default, change the default value -in the `anvil-runner` region of the repository-root `Justfile` from `"native"` -to `"container"` and commit that policy. Set `ANVIL_RUNNER=native` to override -the repository default for the current shell. - -Tier routing starts a nested `just` invocation. Output and exit status are -preserved, but outer `--dry-run`, dependency introspection, global options, and -CLI variable assignments are not propagated to the selected private tier. -Values other than `native` and `container` are rejected. - -## Images and caches - -The image name includes a content-based tag derived from the repository's Rust -toolchain, generated Anvil recipes, and container build configuration. A -relevant change selects a new image automatically; older branches can continue -using their matching images. - -The following data is reused between runs: - -- the matching container image; -- repository-scoped Cargo registry and Cargo Git caches; -- compilation output in a repository- and image-specific `target` volume. - -The repository is mounted read/write at `/workspace`. Build output remains in a -named volume instead of the host `target/`, avoiding incompatible artifacts and -slow host-to-virtual-machine I/O. - -## GitHub authentication - -`anvil-aprz` and aggregate tiers that include it require GitHub API -authentication. The driver uses either: - -- the host `GITHUB_TOKEN`; or -- the token from an authenticated host `gh` session. - -Trusted customization can provision a short-lived token by setting -`GITHUB_TOKEN`; the driver reads it after loading and validating customization. - -Authenticate the GitHub CLI with: - -```text -gh auth login --hostname github.com -``` - -For an aggregate tier, the driver first runs `anvil-aprz` in a short-lived -container with the token mounted read-only. After it succeeds, the driver runs -the remaining checks in another container without the token. Temporary token -files are removed afterward. - -An interactive invocation can pause while you authenticate. A non-interactive -invocation fails with instructions when authentication is unavailable. - -## Configuration - -| Variable | Effect | -|---|---| -| `ANVIL_RUNNER` | Selects `native` or `container` execution for tier commands | -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a digest-pinned compatible Linux base image and changes the content-based tag | -| `ANVIL_CONTAINER_IMAGE` | Changes the local image name; the content-based tag is retained | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails instead of building when the matching image is absent | - -The public driver builds images locally and does not pull -`ANVIL_CONTAINER_IMAGE` from a registry. - -The default base is digest-pinned Debian Bookworm. Set -`ANVIL_CONTAINER_BASE_IMAGE` to another image compatible with the generated -Debian-based `Containerfile` when a lower glibc baseline is required. A -different package ecosystem such as Azure Linux requires a derived -`Containerfile`. The value must use `image@sha256:` form so the -selected base remains part of the content-addressed image identity. - -Two simultaneous cold invocations can both build the same missing image. This -is accepted for local development: the content-addressed tag converges on the -same inputs, at the cost of duplicate work. - -## Troubleshooting - -| Problem | Resolution | -|---|---| -| Docker is not found on Linux or WSL | Install Docker Engine 23.0 or newer inside that environment | -| Docker is unavailable from Windows | Run `wsl -e docker version`; install or start Docker Engine in the default WSL distribution | -| Docker requires elevated access | Add the Linux/WSL user to the `docker` group, then start a new shell | -| ARM64 execution is slow | The current image is `linux/amd64` and runs through Docker emulation | -| `linux/amd64` cannot run | Configure Docker to run `linux/amd64` images | -| `[script]` recipes are unavailable | Enable `[script]` support; older `just` versions require `set unstable` | -| `rust-toolchain.toml` is missing | Add the repository-owned toolchain file at the repository root | -| GitHub authentication is unavailable | Run `gh auth login --hostname github.com` or set host `GITHUB_TOKEN` | -| A matching image is missing with `ANVIL_CONTAINER_NO_REBUILD=1` | Unset the variable to allow the local image build | -| The first run is slow | The initial image build installs the pinned tool catalog; later runs reuse it | - -Use `docker images anvil-dev` inside Linux or WSL to list locally cached -default Anvil images. - -## Managed files - -This directory is managed by `cargo-anvil`. Regenerate it with `cargo anvil` -instead of editing its files directly. - -> [!IMPORTANT] -> These assets previously lived in `justfiles/anvil/container/`. `cargo anvil` -> relocates the files it generated, but it does not track a hand-authored -> `customize.sh` or `customize.ps1`. Move any such file to -> `.anvil/container/` yourself; the driver only loads customization from the -> new location and warns on stderr when it finds one left behind. - -## Advanced repository customization - -A repository or derived catalog can add one trusted customization file per -supported host: - -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 -``` - -The driver sources the matching file as trusted host code before authentication, -image construction, and recipe execution. The documented customization -contract provides inputs and validated outputs for APRZ classification, build -secrets, dependency preparation, runtime arguments, and cleanup. - -Customization source is excluded from image identity and the build context. -Non-secret image behavior must be represented by hashed static files such as -the `Containerfile`, entrypoint, or supporting build scripts. - -See the [container customization contract](https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md#8-container-customization) -for the complete interface and security requirements. diff --git a/.anvil/container/entrypoint.sh b/.anvil/container/entrypoint.sh deleted file mode 100644 index fadac5f9..00000000 --- a/.anvil/container/entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -eu - -if [ "$(id -u)" -ne 0 ]; then - if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then - HOME="/tmp/anvil-user" - export HOME - fi - - user_cargo_home="$HOME/.cargo" - mkdir -p "$user_cargo_home" - for file in config.toml .crates.toml .crates2.json; do - if [ -r "$CARGO_HOME/$file" ]; then - cp -f "$CARGO_HOME/$file" "$user_cargo_home/$file" - fi - done - export CARGO_HOME="$user_cargo_home" - ln -sfn /usr/local/cargo/registry "$CARGO_HOME/registry" - ln -sfn /usr/local/cargo/git "$CARGO_HOME/git" -fi - -exec "$@" diff --git a/.anvil/container/image-id.ps1 b/.anvil/container/image-id.ps1 deleted file mode 100644 index dfc6a53f..00000000 --- a/.anvil/container/image-id.ps1 +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$inputs = @( - 'rust-toolchain.toml' -) -$toolchainPath = Join-Path $repoRoot 'rust-toolchain.toml' -if (-not (Test-Path -LiteralPath $toolchainPath -PathType Leaf)) { - throw 'anvil-container requires a repository-owned rust-toolchain.toml.' -} -$containerPath = Join-Path $repoRoot '.anvil/container' -$containerRecipe = 'justfiles/anvil/container.just' -$containerfile = Join-Path $containerPath 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } -# The container entry recipe drives execution on the host; it is not image -# content, so it must not participate in image identity. -$inputs += Get-ChildItem (Join-Path $repoRoot 'justfiles/anvil') -Recurse -File -Filter '*.just' | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } | - Where-Object { -not $_.Equals($containerRecipe, $pathComparison) } -$executionOnly = @( - 'image-id.ps1', - 'image-id.sh', - 'README.md', - 'run-in-container.ps1', - 'run-in-container.sh', - 'customize.sh', - 'customize.ps1' -) -# customize.sh/customize.ps1 are trusted runtime orchestration, not image -# content: their source must never affect the image ID or build context. -# Static, non-secret build customization belongs in a hashed artifact instead. -$inputs += Get-ChildItem $containerPath -File | - Where-Object { $_.Name -notin $executionOnly } | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } -$uniqueInputs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($inputPath in $inputs) { - [void]$uniqueInputs.Add($inputPath) -} -$inputs = [string[]]$uniqueInputs -[Array]::Sort($inputs, [StringComparer]::Ordinal) - -$payload = [Text.StringBuilder]::new() -[void]$payload.Append("ANVIL_CONTAINER_BASE_IMAGE`n").Append($baseImage).Append("`n") -foreach ($relative in $inputs) { - $path = Join-Path $repoRoot $relative - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Container image input is missing: $relative" - } - $content = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n") - [void]$payload.Append($relative).Append("`n").Append($content).Append("`n") -} - -$bytes = [Text.Encoding]::UTF8.GetBytes($payload.ToString()) -$hash = [Security.Cryptography.SHA256]::HashData($bytes) -Write-Output ([Convert]::ToHexString($hash).ToLowerInvariant()) diff --git a/.anvil/container/image-id.sh b/.anvil/container/image-id.sh deleted file mode 100644 index e0ed8105..00000000 --- a/.anvil/container/image-id.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi - -toolchain_path="$repo_root/rust-toolchain.toml" -if [[ ! -f "$toolchain_path" ]]; then - echo 'anvil-container requires a repository-owned rust-toolchain.toml.' >&2 - exit 1 -fi - -container_dir="$repo_root/.anvil/container" -container_recipe="justfiles/anvil/container.just" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$container_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -inputs=(rust-toolchain.toml) -while IFS= read -r path; do - relative="${path#"$repo_root"/}" - # The container entry recipe drives execution on the host; it is not - # image content, so it must not participate in image identity. - if [[ "$relative" != "$container_recipe" ]]; then - inputs+=("$relative") - fi -done < <(find "$repo_root/justfiles/anvil" -type f -name '*.just' -print) - -for path in "$container_dir"/*; do - [[ -f "$path" ]] || continue - case "${path##*/}" in - image-id.ps1 | image-id.sh | README.md \ - | run-in-container.ps1 | run-in-container.sh \ - | customize.sh | customize.ps1) continue ;; - esac - inputs+=("${path#"$repo_root"/}") -done - -if command -v sha256sum >/dev/null 2>&1; then - hash_command=(sha256sum) -elif command -v shasum >/dev/null 2>&1; then - hash_command=(shasum -a 256) -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi - -write_normalized_file() { - local path="$1" - local line status - while true; do - line="" - if IFS= read -r line <&3; then - status=0 - else - status=$? - fi - if ((status != 0)) && [[ -z "$line" ]]; then - break - fi - printf '%s' "${line%$'\r'}" - if ((status == 0)); then - printf '\n' - else - break - fi - done 3<"$path" -} - -{ - printf 'ANVIL_CONTAINER_BASE_IMAGE\n%s\n' "$base_image" - while IFS= read -r relative; do - path="$repo_root/$relative" - if [[ ! -f "$path" ]]; then - echo "Container image input is missing: $relative" >&2 - exit 1 - fi - printf '%s\n' "$relative" - write_normalized_file "$path" - printf '\n' - done < <(printf '%s\n' "${inputs[@]}" | LC_ALL=C sort -u) -} | "${hash_command[@]}" | awk '{print $1}' diff --git a/.anvil/container/run-in-container.ps1 b/.anvil/container/run-in-container.ps1 deleted file mode 100644 index a6239f73..00000000 --- a/.anvil/container/run-in-container.ps1 +++ /dev/null @@ -1,338 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param( - [Parameter(Position = 0, ValueFromRemainingArguments = $true)] - [string[]]$Recipe -) - -$ErrorActionPreference = 'Stop' - -function ConvertTo-AnvilVersion([string]$Value) { - $match = [regex]::Match($Value, '^(\d+)\.(\d+)(?:\.(\d+))?') - if (-not $match.Success) { - throw "anvil-container: could not parse Docker Engine version '$Value'." - } - [version]::new( - [int]$match.Groups[1].Value, - [int]$match.Groups[2].Value, - $(if ($match.Groups[3].Success) { [int]$match.Groups[3].Value } else { 0 }) - ) -} - -function Test-AnvilContainerStringArray([string]$Name, $Value) { - if ($Value -isnot [array]) { - throw "anvil-container: `$$Name must be a string array." - } - foreach ($item in $Value) { - if ($item -isnot [string] -or [string]::IsNullOrEmpty($item)) { - throw "anvil-container: `$$Name entries must be non-empty strings." - } - } -} - -function Test-AnvilContainerBuildArgs($Value) { - for ($index = 0; $index -lt $Value.Count; $index++) { - $item = $Value[$index] - if ($item -eq '--secret') { - $index++ - if ($index -ge $Value.Count) { - throw 'anvil-container: $AnvilContainerBuildArgs requires a value after --secret.' - } - } elseif (-not $item.StartsWith('--secret=', [StringComparison]::Ordinal)) { - throw 'anvil-container: $AnvilContainerBuildArgs accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' - } - } -} - -function Test-AnvilRecipeNeedsGitHubToken([string]$Name) { - $Name -in @( - 'anvil-aprz', - 'anvil-scheduled', - '_anvil-scheduled', - 'anvil-scheduled-advisories', - '_anvil-scheduled-advisories', - 'anvil-full', - '_anvil-full' - ) -} - -function Get-AnvilGitHubToken { - $token = $env:GITHUB_TOKEN - if (-not $token -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { - $token = (& gh auth token --hostname github.com 2>$null) - if ($LASTEXITCODE -ne 0) { $token = $null } - } catch { - $token = $null - } - } - if ($token) { $token = $token.Trim() } - if ($token) { return $token } - return $null -} - -if ($env:ANVIL_IN_CONTAINER) { - if ($Recipe.Count -eq 0) { & bash } else { & just @Recipe } - exit $LASTEXITCODE -} - -foreach ($recipeArg in $Recipe) { - if ($recipeArg -notmatch '^_?anvil-[A-Za-z0-9-]+$') { - throw "anvil-container: expected each argument to be an anvil-* recipe, got '$recipeArg'." - } -} - -if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { - throw 'anvil-container: WSL 2 is required. See .anvil/container/README.md.' -} - -$versionText = (& wsl -e docker version --format '{{.Server.Version}}' 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $versionText) { - throw 'anvil-container: `wsl -e docker version` must succeed. Install or start Docker Engine in the default WSL distribution; this driver does not invoke Windows docker.exe.' -} -$versionText = $versionText.Trim() -if ((ConvertTo-AnvilVersion $versionText) -lt [version]'23.0.0') { - throw "anvil-container: Docker Engine 23.0.0 or newer is required (found $versionText)." -} -$wslArchitecture = (& wsl -e uname -m 2>$null) -if ($LASTEXITCODE -eq 0 -and $wslArchitecture) { - $wslArchitecture = $wslArchitecture.Trim() - if ($wslArchitecture -notin @('x86_64', 'amd64')) { - [Console]::Error.WriteLine( - "anvil-container: warning: $wslArchitecture requires emulation for linux/amd64; builds and checks may be substantially slower." - ) - } -} - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$scriptDir = Join-Path $repoRoot '.anvil/container' -$wslRepoRoot = (& wsl -e wslpath -a $repoRoot).Trim() -if ($LASTEXITCODE -ne 0 -or -not $wslRepoRoot) { - throw 'anvil-container: could not translate the repository path into the default WSL distribution.' -} -$wslScriptDir = "$wslRepoRoot/.anvil/container" -$containerfile = Join-Path $scriptDir 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$imageId = (& (Join-Path $scriptDir 'image-id.ps1')).Trim() -$imageBase = if ($env:ANVIL_CONTAINER_IMAGE) { $env:ANVIL_CONTAINER_IMAGE } else { 'anvil-dev' } -$image = "${imageBase}:$imageId" -$repoBytes = [Text.Encoding]::UTF8.GetBytes($wslRepoRoot) -$repoHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($repoBytes)).ToLowerInvariant() -$targetVolume = "anvil-target-$($repoHash.Substring(0, 12))-$($imageId.Substring(0, 12))" - -$needsGitHubToken = $false -foreach ($recipeArg in $Recipe) { - if (Test-AnvilRecipeNeedsGitHubToken $recipeArg) { - $needsGitHubToken = $true - break - } -} -$runsOnlyGitHubCheck = $Recipe.Count -eq 1 -and $Recipe[0] -eq 'anvil-aprz' - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -$null = & wsl -e docker image inspect $image 2>$null -$imageExists = $LASTEXITCODE -eq 0 - -New-Variable -Name AnvilContainerRepoRoot -Value $repoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDir -Value $scriptDir -Option ReadOnly -New-Variable -Name AnvilContainerRepoRootWsl -Value $wslRepoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDirWsl -Value $wslScriptDir -Option ReadOnly -New-Variable -Name AnvilContainerResolvedImage -Value $image -Option ReadOnly -New-Variable -Name AnvilContainerImageExists -Value $imageExists -Option ReadOnly -New-Variable -Name AnvilContainerRequestedRecipes -Value $Recipe -Option ReadOnly -New-Variable -Name AnvilContainerHostIsWindows -Value ([bool]$IsWindows) -Option ReadOnly - -# Customization outputs, initialized before sourcing so a missing customize.ps1 -# leaves every phase a documented no-op. -$AnvilContainerBuildArgs = @() -$AnvilContainerPrepareArgs = @() -$AnvilContainerPrepareCommand = @() -$AnvilContainerRunArgs = @() -$AnvilContainerNeedsGitHubToken = $needsGitHubToken -$AnvilContainerCleanup = $null -$githubToken = $null -$githubTokenFile = $null -$exitCode = 0 -$customizeScript = Join-Path $scriptDir 'customize.ps1' -$legacyCustomizeScript = Join-Path $repoRoot 'justfiles/anvil/container/customize.ps1' - -try { - if (Test-Path -LiteralPath $customizeScript -PathType Leaf) { - . $customizeScript - } - elseif (Test-Path -LiteralPath $legacyCustomizeScript -PathType Leaf) { - [Console]::Error.WriteLine( - 'anvil-container: warning: ignoring justfiles/anvil/container/customize.ps1; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.ps1 to keep it active.' - ) - } - - Test-AnvilContainerStringArray 'AnvilContainerBuildArgs' $AnvilContainerBuildArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareArgs' $AnvilContainerPrepareArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareCommand' $AnvilContainerPrepareCommand - Test-AnvilContainerStringArray 'AnvilContainerRunArgs' $AnvilContainerRunArgs - Test-AnvilContainerBuildArgs $AnvilContainerBuildArgs - if ($AnvilContainerNeedsGitHubToken -isnot [bool]) { - throw 'anvil-container: $AnvilContainerNeedsGitHubToken must be a Boolean.' - } - $needsGitHubToken = $needsGitHubToken -or $AnvilContainerNeedsGitHubToken - if ($AnvilContainerPrepareArgs.Count -gt 0 -and $AnvilContainerPrepareCommand.Count -eq 0) { - throw 'anvil-container: $AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand.' - } - if ($AnvilContainerCleanup -and $AnvilContainerCleanup -isnot [scriptblock]) { - throw 'anvil-container: $AnvilContainerCleanup must be a script block.' - } - $githubToken = if ($needsGitHubToken) { Get-AnvilGitHubToken } else { $null } - if ($needsGitHubToken -and -not $githubToken) { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' - } - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' - } - Write-Host 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' - [void](Read-Host 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel)') - $githubToken = Get-AnvilGitHubToken - if (-not $githubToken) { - throw 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' - } - } - if (-not $imageExists) { - if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { - throw "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." - } - & wsl -e docker build ` - --platform linux/amd64 ` - --tag $image ` - --file "$wslScriptDir/Containerfile" ` - --build-arg "ANVIL_IMAGE_ID=$imageId" ` - --build-arg "BASE_IMAGE=$baseImage" ` - @AnvilContainerBuildArgs ` - $wslRepoRoot - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker build failed with exit code $LASTEXITCODE." - } - } - - $containerUid = (& wsl -e id -u).Trim() - $containerGid = (& wsl -e id -g).Trim() - if ($containerUid -notmatch '^\d+$' -or $containerGid -notmatch '^\d+$') { - throw 'anvil-container: could not determine the default WSL user identity.' - } - $registryVolume = "anvil-cargo-registry-$($repoHash.Substring(0, 12))" - $gitVolume = "anvil-cargo-git-$($repoHash.Substring(0, 12))" - foreach ($volume in @($registryVolume, $gitVolume, $targetVolume)) { - $null = & wsl -e docker volume create $volume - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume creation failed for '$volume' with exit code $LASTEXITCODE." - } - } - $mountArgs = @( - '--mount', "type=bind,source=$wslRepoRoot,target=/workspace", - '--mount', "type=volume,source=$registryVolume,target=/usr/local/cargo/registry", - '--mount', "type=volume,source=$gitVolume,target=/usr/local/cargo/git", - '--mount', "type=volume,source=$targetVolume,target=/workspace/target" - ) - & wsl -e docker run --rm --pull=never ` - --platform linux/amd64 ` - --user 0:0 ` - @mountArgs ` - $image sh -c "chown ${containerUid}:${containerGid} /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume initialization failed with exit code $LASTEXITCODE." - } - - $runArgs = @( - 'run', '--rm', '--pull=never', - '--platform', 'linux/amd64', - '--user', "${containerUid}:${containerGid}", - '--env', 'ANVIL_IN_CONTAINER=1', - '--env', 'HOME=/tmp/anvil-user', - '--workdir', '/workspace' - ) - $runArgs += $mountArgs - $prepareRunArgs = @($runArgs) - $runArgs += $AnvilContainerRunArgs - foreach ($name in @( - 'PR_TITLE', - 'BASE_REF', - 'ANVIL_IMPACT', - 'GITHUB_BASE_REF', - 'SYSTEM_PULLREQUEST_TARGETBRANCH' - )) { - if (Test-Path "Env:$name") { - $runArgs += @('--env', "$name=$((Get-Item "Env:$name").Value)") - } - } - if ($AnvilContainerPrepareCommand.Count -gt 0) { - & wsl -e docker @prepareRunArgs @AnvilContainerPrepareArgs $image @AnvilContainerPrepareCommand - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: preparation command failed with exit code $LASTEXITCODE." - } - } - - if ($githubToken) { - $githubTokenFile = Join-Path ([IO.Path]::GetTempPath()) "anvil-github-token-$PID-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Create($githubTokenFile).Dispose() - if ($IsWindows) { - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls.exe $githubTokenFile '/inheritance:r' '/grant:r' "*$($userSid):(F)" | Out-Null - } else { - & chmod 600 $githubTokenFile - } - if ($LASTEXITCODE -ne 0) { - throw 'anvil-container: failed to restrict permissions on the temporary GitHub token file.' - } - [IO.File]::WriteAllText($githubTokenFile, $githubToken, [Text.Encoding]::ASCII) - $githubToken = $null - $wslTokenFile = (& wsl -e wslpath -a $githubTokenFile).Trim() - if ($LASTEXITCODE -ne 0 -or -not $wslTokenFile) { - throw 'anvil-container: could not translate the temporary GitHub token path into WSL.' - } - $githubRunArgs = @($runArgs) - $githubRunArgs += @( - '--mount', - "type=bind,source=$wslTokenFile,target=/run/secrets/anvil-github-token,readonly" - ) - if ($runsOnlyGitHubCheck) { - $runArgs = $githubRunArgs - } else { - & wsl -e docker @githubRunArgs $image just anvil-aprz - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: isolated anvil-aprz failed with exit code $LASTEXITCODE." - } - $runArgs += @('--env', 'ANVIL_APRZ_ALREADY_RAN=1') - } - } - - if ($Recipe.Count -eq 0) { - & wsl -e docker @runArgs --interactive --tty $image bash - } else { - & wsl -e docker @runArgs $image just @Recipe - } - $exitCode = $LASTEXITCODE -} finally { - if ($githubTokenFile) { - Remove-Item -LiteralPath $githubTokenFile -Force -ErrorAction SilentlyContinue - } - if ($AnvilContainerCleanup) { & $AnvilContainerCleanup } -} - -exit $exitCode diff --git a/.anvil/container/run-in-container.sh b/.anvil/container/run-in-container.sh deleted file mode 100644 index 3f91cc0a..00000000 --- a/.anvil/container/run-in-container.sh +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - if (($# == 0)); then exec bash; else exec just "$@"; fi -fi - -for recipe_arg in "$@"; do - if [[ ! "$recipe_arg" =~ ^_?anvil-[A-Za-z0-9-]+$ ]]; then - echo "anvil-container: expected each argument to be an anvil-* recipe, got '$recipe_arg'." >&2 - exit 2 - fi -done - -anvil_recipe_needs_github_token() { - case "$1" in - anvil-aprz | anvil-scheduled | _anvil-scheduled | anvil-scheduled-advisories | _anvil-scheduled-advisories \ - | anvil-full | _anvil-full) return 0 ;; - *) return 1 ;; - esac -} - -version_at_least() { - local found="${1%%[-+]*}" - local required="${2%%[-+]*}" - local found_major found_minor found_patch found_extra - local required_major required_minor required_patch required_extra - IFS=. read -r found_major found_minor found_patch found_extra <<<"$found" - IFS=. read -r required_major required_minor required_patch required_extra <<<"$required" - found_patch="${found_patch:-0}" - required_patch="${required_patch:-0}" - for component in \ - "$found_major" "$found_minor" "$found_patch" \ - "$required_major" "$required_minor" "$required_patch" - do - case "$component" in - '' | *[!0-9]*) return 2 ;; - esac - done - if ((found_major != required_major)); then ((found_major > required_major)); return; fi - if ((found_minor != required_minor)); then ((found_minor > required_minor)); return; fi - ((found_patch >= required_patch)) -} - -command -v docker >/dev/null 2>&1 || { - echo "anvil-container: Docker Engine is required. See .anvil/container/README.md." >&2 - exit 1 -} - -version="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" || { - echo "anvil-container: Docker Engine is unavailable. Start the Docker service and ensure the current user can access it." >&2 - exit 1 -} -minimum="23.0.0" -if ! version_at_least "$version" "$minimum"; then - echo "anvil-container: Docker Engine $minimum or newer is required (found $version)." >&2 - exit 1 -fi -host_arch="$(uname -m 2>/dev/null || true)" -case "$host_arch" in - x86_64 | amd64 | '') ;; - *) echo "anvil-container: warning: $host_arch requires emulation for linux/amd64; builds and checks may be substantially slower." >&2 ;; -esac - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi -script_dir="$repo_root/.anvil/container" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$script_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -image_id="$(bash "$script_dir/image-id.sh")" -image_base="${ANVIL_CONTAINER_IMAGE:-anvil-dev}" -image="${image_base}:${image_id}" -if command -v sha256sum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | sha256sum | cut -c1-12)" -elif command -v shasum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | shasum -a 256 | cut -c1-12)" -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi -target_volume="anvil-target-${repo_id}-${image_id:0:12}" - -needs_github_token=false -for recipe_arg in "$@"; do - if anvil_recipe_needs_github_token "$recipe_arg"; then - needs_github_token=true - break - fi -done -runs_only_github_check=false -if (($# == 1)) && [[ "$1" == "anvil-aprz" ]]; then - runs_only_github_check=true -fi -github_token="" - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -if docker image inspect "$image" >/dev/null 2>&1; then - image_exists=true -else - image_exists=false -fi - -readonly ANVIL_CONTAINER_REPO_ROOT="$repo_root" -readonly ANVIL_CONTAINER_DIR="$script_dir" -readonly ANVIL_CONTAINER_RESOLVED_IMAGE="$image" -readonly ANVIL_CONTAINER_IMAGE_EXISTS="$image_exists" -declare -a ANVIL_CONTAINER_REQUESTED_RECIPES=("$@") -readonly ANVIL_CONTAINER_REQUESTED_RECIPES - -# Customization outputs, initialized before sourcing so a missing customize.sh -# leaves every phase a documented no-op. -ANVIL_CONTAINER_BUILD_ARGS=() -ANVIL_CONTAINER_PREPARE_ARGS=() -ANVIL_CONTAINER_PREPARE_COMMAND=() -ANVIL_CONTAINER_RUN_ARGS=() -ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN="$needs_github_token" -ANVIL_CONTAINER_CLEANUP=: -github_token_file="" -cleanup() { - if [[ -n "$github_token_file" ]]; then rm -f -- "$github_token_file"; fi - "$ANVIL_CONTAINER_CLEANUP" -} -trap cleanup EXIT - -customize_script="$script_dir/customize.sh" -legacy_customize_script="$repo_root/justfiles/anvil/container/customize.sh" -if [[ ! -f "$customize_script" && -f "$legacy_customize_script" ]]; then - echo "anvil-container: warning: ignoring justfiles/anvil/container/customize.sh; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.sh to keep it active." >&2 -fi -if [[ -f "$customize_script" ]]; then - # shellcheck source=/dev/null - source "$customize_script" -fi - -# Bash 3.2 has neither namerefs (the nameref flag on `local`/`declare`, Bash -# 4.3+) nor safe `set -u` expansion of empty-but- -# declared arrays (fixed in Bash 4.4). Elements are passed positionally -# instead of by nameref, and every expansion of a possibly-empty array uses -# the `${arr[@]+"${arr[@]}"}` idiom: unset/empty-under-old-Bash arrays vanish -# entirely instead of raising "unbound variable", while non-empty arrays -# still expand element-for-element. -anvil_container_validate_array() { - local name="$1" - shift - local declaration value - declaration="$(declare -p "$name" 2>/dev/null || true)" - if [[ ! "$declaration" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ ]]; then - echo "anvil-container: $name must be a string array." >&2 - exit 1 - fi - for value in "$@"; do - if [[ -z "$value" ]]; then - echo "anvil-container: $name entries must be non-empty strings." >&2 - exit 1 - fi - done -} -anvil_container_validate_build_args() { - local expect_secret_value=false value - for value in "$@"; do - if "$expect_secret_value"; then - expect_secret_value=false - continue - fi - case "$value" in - --secret) expect_secret_value=true ;; - --secret=*) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' >&2 - exit 1 - ;; - esac - done - if "$expect_secret_value"; then - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS requires a value after --secret.' >&2 - exit 1 - fi -} -anvil_container_validate_array ANVIL_CONTAINER_BUILD_ARGS ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_ARGS ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_COMMAND ${ANVIL_CONTAINER_PREPARE_COMMAND[@]+"${ANVIL_CONTAINER_PREPARE_COMMAND[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_RUN_ARGS ${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"} -anvil_container_validate_build_args ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -case "$ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN" in - true) needs_github_token=true ;; - false) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN must be true or false.' >&2 - exit 1 - ;; -esac -if ((${#ANVIL_CONTAINER_PREPARE_ARGS[@]} > 0)) && ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} == 0)); then - echo 'anvil-container: ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND.' >&2 - exit 1 -fi -cleanup_kind="$(type -t "$ANVIL_CONTAINER_CLEANUP" 2>/dev/null || true)" -if [[ "$cleanup_kind" != "function" && "$cleanup_kind" != "builtin" ]]; then - echo "anvil-container: ANVIL_CONTAINER_CLEANUP must name a callable function (got '$ANVIL_CONTAINER_CLEANUP')." >&2 - exit 1 -fi - -if "$needs_github_token"; then - gh_command="" - if command -v gh >/dev/null 2>&1; then - gh_command=gh - elif command -v gh.exe >/dev/null 2>&1; then - gh_command=gh.exe - fi - github_token="${GITHUB_TOKEN:-}" - if [[ -z "$github_token" && -n "$gh_command" ]]; then - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - fi - if [[ -z "$github_token" ]]; then - if [[ -z "$gh_command" ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - if [[ ! -t 0 ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - echo 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' >&2 - read -r -p 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel) ' - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - if [[ -z "$github_token" ]]; then - echo 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' >&2 - exit 1 - fi - fi -fi - -if ! "$image_exists"; then - if [[ "${ANVIL_CONTAINER_NO_REBUILD:-}" == "1" ]]; then - echo "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." >&2 - exit 1 - else - docker build \ - --platform linux/amd64 \ - --tag "$image" \ - --file "$script_dir/Containerfile" \ - --build-arg "ANVIL_IMAGE_ID=$image_id" \ - --build-arg "BASE_IMAGE=$base_image" \ - ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} \ - "$repo_root" - fi -fi - -container_uid="$(id -u)" -container_gid="$(id -g)" -registry_volume="anvil-cargo-registry-${repo_id}" -git_volume="anvil-cargo-git-${repo_id}" -for volume in "$registry_volume" "$git_volume" "$target_volume"; do - docker volume create "$volume" >/dev/null -done -mount_args=( - --mount "type=bind,source=$repo_root,target=/workspace" - --mount "type=volume,source=$registry_volume,target=/usr/local/cargo/registry" - --mount "type=volume,source=$git_volume,target=/usr/local/cargo/git" - --mount "type=volume,source=$target_volume,target=/workspace/target" -) -docker run --rm --pull=never \ - --platform linux/amd64 \ - --user 0:0 \ - "${mount_args[@]}" \ - "$image" sh -c \ - "chown $container_uid:$container_gid /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - -run_args=( - run --rm --pull=never - --platform linux/amd64 - --user "$container_uid:$container_gid" - --env ANVIL_IN_CONTAINER=1 - --env HOME=/tmp/anvil-user - "${mount_args[@]}" - --workdir /workspace -) -prepare_run_args=("${run_args[@]}") -run_args+=(${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"}) -for name in PR_TITLE BASE_REF ANVIL_IMPACT GITHUB_BASE_REF SYSTEM_PULLREQUEST_TARGETBRANCH; do - if value="$(printenv "$name")"; then run_args+=(--env "$name=$value"); fi -done -if ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} > 0)); then - docker "${prepare_run_args[@]}" \ - ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} \ - "$image" \ - "${ANVIL_CONTAINER_PREPARE_COMMAND[@]}" -fi - -if [[ -n "$github_token" ]]; then - github_token_file="$(mktemp "${TMPDIR:-/tmp}/anvil-github-token.XXXXXXXX")" - chmod 600 "$github_token_file" - printf '%s' "$github_token" > "$github_token_file" - unset github_token - github_run_args=( - "${run_args[@]}" - --mount "type=bind,source=$github_token_file,target=/run/secrets/anvil-github-token,readonly" - ) - if "$runs_only_github_check"; then - run_args=("${github_run_args[@]}") - else - docker "${github_run_args[@]}" "$image" just anvil-aprz - run_args+=(--env ANVIL_APRZ_ALREADY_RAN=1) - fi -fi - -if (($# == 0)); then - docker "${run_args[@]}" --interactive --tty "$image" bash - exit $? -fi -docker "${run_args[@]}" "$image" just "$@" diff --git a/.spelling b/.spelling index 146a83b5..73e9491c 100644 --- a/.spelling +++ b/.spelling @@ -167,6 +167,7 @@ getters GFM Git's github +glibc globbing glommio grey @@ -279,6 +280,7 @@ pp pre-approved pre-generate pre-heating +prebuilt prefixed prepend prepended @@ -472,8 +474,16 @@ unscoped backtracker prerelease versioned -Containerfile +BuildKit +Dockerfile +dockerignore +Podman +podman +toolset +natively +ARM64 WSL +variadic CSV CEL Codeberg @@ -530,6 +540,7 @@ deprecations parallelization remediate recency +unbuildable 100% 20% 313 @@ -882,3 +893,4 @@ cryptographic groupable memoization triaging +lexically diff --git a/Justfile b/Justfile index b666391e..d906b5b7 100644 --- a/Justfile +++ b/Justfile @@ -26,7 +26,3 @@ import 'justfiles/spelling.just' # >>> anvil-managed: anvil-imports import 'justfiles/anvil/mod.just' # <<< anvil-managed: anvil-imports - -# >>> anvil-managed: anvil-runner -anvil_runner := env_var_or_default("ANVIL_RUNNER", "native") -# <<< anvil-managed: anvil-runner diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md index e8843c60..e8a04a1e 100644 --- a/crates/cargo-anvil/README.md +++ b/crates/cargo-anvil/README.md @@ -105,118 +105,155 @@ Repositories can disable this behavior by setting the ### Containerized local checks -Anvil can run any generated recipe in a content-addressed Linux container. -The image installs the Rust toolchains and Cargo tools pinned by the -repository’s generated Anvil configuration, providing a repeatable Linux -environment without installing those tools directly on the host. - -#### Prerequisites - -* Docker Engine 23.0 or newer, installed directly in Linux or WSL and - usable by the current user. -* `git` and `just` on the host. -* Bash on Linux and WSL; `PowerShell` Core (`pwsh`) and WSL 2 on Windows. -* `[script]` support enabled in the root `Justfile` (`set unstable` when - required by the installed `just` version). -* A repository-owned `rust-toolchain.toml`. -* On Windows, Docker Engine running in the default WSL distribution: - -```powershell -wsl -e docker version -``` - -The Windows driver invokes Docker in the default WSL distribution and does -not call Windows `docker.exe`. Regardless of the installation, the command -above must succeed. Docker Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment, so -image builds and checks can be substantially slower than on x86-64 hosts. - -#### Run a recipe +Any generated recipe can be executed inside a content-addressed Linux +image. The image installs the Rust toolchain and Cargo tools this +repository pins by running `just anvil-setup`, the same recipe the checks +use, reading the same generated pins, so the image and the host agree on +the toolset by construction, with no second tool list to keep in step. + +Execution is opt-in per invocation: `just anvil-pr` and every other recipe +continue to run natively, and a container is entered only through +`anvil-container`, whose arguments are the argv executed inside the image. +Those arguments are whitespace-delimited tokens: `just` joins a variadic +parameter with spaces before the recipe sees it, so an argument that itself +contains a space cannot be recovered and does not survive the round trip. ```text -just anvil-container anvil-clippy -just anvil-container anvil-pr -just anvil-container +just anvil-container just anvil-clippy # one check +just anvil-container just anvil-pr # the whole PR tier +just anvil-container just anvil-setup binstall # a recipe with an argument +just anvil-container cargo build # any other command +just anvil-container # interactive shell ``` -The no-argument form opens an interactive shell. Anvil builds an image the -first time it encounters a content hash and reuses it on later runs. Changes -to the Rust toolchain, generated Anvil files, Containerfile, or other static -image inputs select a new tag and build a new image. Images for earlier -hashes remain available to older branches. Runtime `customize.*` files do not -affect image identity. -Every argument is a recipe name; recipe parameters are not supported by -this command surface. +The feature is three generated artifacts and one optional hook script, with +no configuration file: `justfiles/anvil/container.just` drives the engine, +`.anvil/container/Dockerfile` and its `Dockerfile.dockerignore` define what +the image contains, and `.anvil/container/hooks.ps1` supplies credentials +when a repository needs them. -Cargo registry and Cargo Git caches use repository-scoped named volumes; -`target/` is additionally scoped by image ID. The -repository is mounted at `/workspace`; keeping build output in a named -volume avoids slow host bind-mount I/O, particularly on Windows. +One container is created per invocation, however many checks the requested +recipe runs. The repository is bind-mounted at `/workspace`, so `target/` +stays visible from the host. Cargo’s download caches are named volumes, +keeping that write-heavy path off the host boundary; `CARGO_HOME` and +`RUSTUP_HOME` themselves are deliberately not mounted, since a volume would +pin the first image’s tools over every later one. -#### Make tiers use the container - -Native execution remains the default. Enable container execution for the -current shell: +#### Prerequisites -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` +* A container engine callable from the shell that runs `just`: Docker, or + Podman via `ANVIL_CONTAINER_ENGINE=podman`. On Windows that means Docker + Desktop, Podman, a Windows `docker` CLI pointed at an engine in WSL, or + Docker Engine installed only inside the default WSL distribution. No + Windows CLI is needed in that last case, since anvil reaches the engine + through `wsl.exe` when it finds none on `PATH` and translates repository + paths with `wslpath`. +* `just` and `PowerShell` Core (`pwsh`) on the host. +* A repository-owned `rust-toolchain.toml`. -On Unix: +Docker is supported; Podman works on a best-effort basis, with two +documented gaps on Windows. The image is pinned to `linux/amd64`, so on +ARM64 hosts it is emulated and is substantially slower. + +#### Image identity + +The tag *is* a SHA-256 digest over the inputs that define the image: +everything under `.anvil/container/`, `rust-toolchain.toml`, and the whole +generated `justfiles/anvil/` tree. The container directory is walked rather +than named file by file, because the Dockerfile is composed and a +repository can `COPY` a certificate or an install script it places there. +The recipe tree is included in +full because the image installs its tools by running `just anvil-setup`, +whose dependency chain runs through the tier, group and check recipes +before it reaches the install recipes – so the routing decides *whether* a +tool is installed just as surely as `tools.just` decides *how*. +A changed tool pin names a tag that cannot already exist, so a build +follows. There is no staleness check because there is no staleness to +detect: a locally built image that is present was built from the inputs +that name it. An image *fetched* by the resolve hook only claims as much, +since the digest is over source files and cannot be re-derived from layers, +so that claim is only as strong as the registry it came from, which should +have immutable tags and restricted push. + +`anvil-container-tag` prints the reference without building it, and is the +single place the digest is computed, so a publisher can tag an image with +exactly the reference a consumer will later look up. -```sh -ANVIL_RUNNER=container just anvil-pr -``` +#### Controls -A one-off override is also supported: +|Variable|Effect| +|--------|------| +|`ANVIL_CONTAINER_ENGINE`|`docker` (default) or `podman`. Read at run time.| +|`ANVIL_CONTAINER_NO_REBUILD=1`|Fail when the image is missing instead of building it, which distinguishes a cache miss from a build failure.| +|`ANVIL_CONTAINER_NO_RESOLVE=1`|Skip the resolve hook, so a query never pulls.| +|`ANVIL_CONTAINER_NO_CACHE=1`|Rebuild a tag that already resolves, ignoring the hook.| +|`ANVIL_IN_CONTAINER=1`|Set inside the image; makes a nested invocation run natively.| +|`GITHUB_TOKEN`|Forwarded when set on the host. When it is not, one is derived from `gh auth token` — but only for a target whose plan reads the variable, or for the interactive shell.| + +Supporting recipes: `anvil-container-tag`, `anvil-container-status` +(reports the engine and image without building or pulling), and +`anvil-container-down` (removes this repository’s cache volumes). To rebuild +a tag that already resolves, scope `ANVIL_CONTAINER_NO_CACHE` to the one +invocation — an exported value is read by *every* later container command, +so a forgotten one rebuilds from scratch each time: ```text -just anvil_runner=container anvil-pr +$env:ANVIL_CONTAINER_NO_CACHE = '1' +try { just anvil-container just anvil-fmt } finally { Remove-Item Env:ANVIL_CONTAINER_NO_CACHE } ``` -To make containers the project default, edit `/Justfile` -and change the default value in the `anvil-runner` region from `"native"` -to `"container"`. Commit `/Justfile` with that policy -change. Set `ANVIL_RUNNER=native` to override it for one shell. +#### The hook -#### Controls +crates.io needs no credentials, so no hook is emitted by default. A +repository or a downstream catalog that needs one adds +`.anvil/container/hooks.ps1`, which the recipe loads by path whenever the +file is present, whoever wrote it: -|Variable|Effect| -|--------|------| -|`ANVIL_CONTAINER_BASE_IMAGE`|Select a compatible digest-pinned Linux base image; the value is included in the content hash.| -|`ANVIL_CONTAINER_IMAGE`|Override the local image name. The content hash remains the tag.| -|`ANVIL_CONTAINER_NO_REBUILD=1`|Fail when the matching image is missing instead of building it.| - -The public driver never pulls `ANVIL_CONTAINER_IMAGE` remotely. Repositories -and derived catalogs can add trusted `customize.sh` and -`customize.ps1` files for image-build secrets, dependency preparation, -APRZ classification, runtime arguments, and cleanup through the documented -customization contract without changing the public command surface. - -Customization files execute on the host with the developer’s permissions -before container isolation. Only run them from a repository or catalog you -trust. - -For GitHub API checks, the driver automatically uses an existing host -`GITHUB_TOKEN` or the token from an authenticated host `gh` CLI session. It -mounts the token read-only for the command and removes the temporary file -afterward. If `gh` is installed but not authenticated, an interactive run -pauses before building the image, explains the unauthenticated API limit, -and continues after the user completes `gh auth login` and presses Enter. - -#### Troubleshooting - -* A first-run image build is expected and may take several minutes. -* `wsl -e docker images anvil-dev` lists locally cached Anvil images from - Windows; use `docker images anvil-dev` inside Linux or WSL. -* `ANVIL_CONTAINER_NO_REBUILD=1` distinguishes a cache miss from a build - failure. -* Non-interactive runs cannot pause for login. Authenticate `gh` or set host - `GITHUB_TOKEN` before starting them. -* Regenerate managed files with `cargo anvil`; do not hand-edit - `.anvil/container/`. +```powershell +function Anvil-BuildSecrets { @{ Secrets = @{ feed = (mint-a-token) } } } +function Anvil-RunEnv { @{ Env = @{ FEED_TOKEN = (mint-a-token) } } } +function Anvil-ResolveImage { param($tag) (fetch-a-published-image $tag) } +``` + +All three are optional. Build secrets are passed to `BuildKit` by +environment variable name, so a value never reaches a process argument and +never reaches an image layer; run-time values are forwarded into the +container by name for the same reason. An empty value is a hard error, +because a build that quietly proceeded without its credential would install +a reduced tool set and then be tagged with the digest a credentialed build +produces. + +`Anvil-ResolveImage` is offered the tag when nothing local matches, and +returns the reference it made available: a registry reference, used as-is +rather than re-tagged locally, so the run stays honest about where the +image came from. Its presence is checked before use – which proves +something carries that reference, not that the contents match the digest – +and every failure falls through to a local build: a publisher that has not +caught up must not block the change it has not caught up with. + +The hook executes on the host, with the invoking user’s permissions, before +any container isolation exists. Only use one from a repository or catalog +you trust. + +#### Customizing the image + +`.anvil/container/Dockerfile` is a **user-composed file with managed +regions**: anvil owns six regions inside it and keeps them current, and the +gaps between them are the repository’s. Add to the gap that matches when the +addition is needed – re-declare `ARG BASE_IMAGE` to build on another base, +a root CA or proxy before the first download, libraries a catalog tool +compiles against before `anvil-setup`, run-time tools after it. Adding in a +gap leaves anvil’s content alone, so base and tool-pin bumps keep landing; +editing inside a region is preserved rather than overwritten, but freezes +those pins at the moment of the edit, which is why the gaps exist. + +A downstream catalog that needs a different base OS for every repository it +manages replaces the base and tool regions instead, inheriting the catalog +install and the entry contract. A replacement that copies more of the tree +must replace the ignore file with it, since the build context admits only +`justfiles/anvil/`, `.anvil/container/` and `rust-toolchain.toml`. See +[`artifacts::container`][__link1] and the design document for the full contract, the +host setup for each engine, and the known limitations. ### Checks and tiers @@ -318,7 +355,7 @@ own crates — without editing the generated `justfiles/anvil/` tree. #### Spelling dictionary (`spellcheck`) -The `spellcheck` check ([`cargo-spellcheck`][__link1]) +The `spellcheck` check ([`cargo-spellcheck`][__link2]) reads a repo-root `.spelling` file — one word per line — as its custom dictionary. Add project-specific terms (crate names, acronyms, identifiers) there to silence false positives; the `anvil-spellcheck` @@ -327,7 +364,7 @@ consumes. Keep the file `LF`-terminated. #### Coverage (`llvm-cov`) -Coverage is gated by [`cargo-coverage-gate`][__link2]; +Coverage is gated by [`cargo-coverage-gate`][__link3]; per-package and per-workspace thresholds, the coverage-exclusion attribute, and opt-out are all configured through its `Cargo.toml` metadata conventions — see its documentation. @@ -394,8 +431,8 @@ fn main() -> ExitCode { } ``` -…plus a [`Catalog`][__link3] value that starts from [`Catalog::anvil`][__link4] and -customizes the CLI identity ([`CliMeta`][__link5]) and artifact set: +…plus a [`Catalog`][__link4] value that starts from [`Catalog::anvil`][__link5] and +customizes the CLI identity ([`CliMeta`][__link6]) and artifact set: ```rust use cargo_anvil::{Artifact, Catalog, artifacts}; @@ -419,8 +456,8 @@ The on-disk vocabulary (`.anvil.lock`, `anvil-managed` sentinels, `justfiles/anvil/`, `anvil-` recipes) is the fixed engine format and is never rebranded. A fork customizes only its CLI identity and which artifacts it emits, via the three uniform builder verbs -([`CatalogBuilder::with_artifact`][__link6], [`CatalogBuilder::replace_artifact`][__link7], -[`CatalogBuilder::without_artifact`][__link8]) over the public [`artifacts`][__link9] +([`CatalogBuilder::with_artifact`][__link7], [`CatalogBuilder::replace_artifact`][__link8], +[`CatalogBuilder::without_artifact`][__link9]) over the public [`artifacts`][__link10] registry. The `tool` field recorded in `.anvil.lock` keeps two anvil-family tools from clobbering one another in a shared repo (see `--force`). See `docs/design/extensibility.md`. @@ -445,14 +482,15 @@ And `docs/verification.md` for the continuous-validation strategy. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQblcBzF-_WZVYbCN9Rt1pYQLsblkUTM0oENsMbNe4wSAldeq9hZIGDa2NhcmdvLWFudmlsZTAuNS4wa2NhcmdvX2Fudmls + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbgKHiUZXoKcwbNzADe3lPpOAbmHjFvxOs3xUb5ecQdSnq-vdhZIGDa2NhcmdvLWFudmlsZTAuNS4wa2NhcmdvX2Fudmls [__link0]: https://crates.io/crates/cargo-delta - [__link1]: https://crates.io/crates/cargo-spellcheck - [__link2]: https://crates.io/crates/cargo-coverage-gate - [__link3]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=Catalog - [__link4]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=Catalog::anvil - [__link5]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CliMeta - [__link6]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::with_artifact - [__link7]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::replace_artifact - [__link8]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::without_artifact - [__link9]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=artifacts + [__link1]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=artifacts::container + [__link10]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=artifacts + [__link2]: https://crates.io/crates/cargo-spellcheck + [__link3]: https://crates.io/crates/cargo-coverage-gate + [__link4]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=Catalog + [__link5]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=Catalog::anvil + [__link6]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CliMeta + [__link7]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::with_artifact + [__link8]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::replace_artifact + [__link9]: https://docs.rs/cargo-anvil/0.5.0/cargo_anvil/?search=CatalogBuilder::without_artifact diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index a5840b54..015aa633 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -13,8 +13,8 @@ user-visible shape of the tool. Detail lives in companion documents: - [extensibility.md](./extensibility.md) — how downstream tools ship their own brand + catalog. - [github.md](./github.md) — GitHub Actions emission, example workflows, impact wiring. - [ado.md](./ado.md) — Azure DevOps Pipelines emission, 1ESPT/msrustup composition. -- [containers.md](./containers.md) — the opt-in, local-only container backend for running any - `anvil-*` recipe in a pinned Linux image (Linux-on-Windows parity, distro pinning). +- [containers.md](./containers.md) — containerized execution: the explicit `anvil-container` + recipe, the content-addressed image, and the credential hook. - [../implementation.md](../implementation.md) — internal implementation guidance. - [../verification.md](../verification.md) — continuous-validation strategy: dogfooding, fixture tests, schema validation. @@ -237,7 +237,7 @@ repo/ ├── .anvil.lock sidecar manifest tracking last-rendered checksums (see updates.md) ├── Justfile managed-region: anvil-imports ├── justfiles/anvil/ owned (see local.md) -├── .anvil/container/ owned, only with the container backend (see local.md, containers.md) +├── .anvil/container/ owned — the container image definition (see containers.md) ├── Cargo.toml managed-region: anvil-workspace-lints (or anvil-lints in single-crate) ├── crates//Cargo.toml managed-region: anvil-lints (one per workspace member) ├── deny.toml managed-regions: anvil-deny-{advisories,licenses,bans,sources} @@ -268,10 +268,10 @@ repo/ Detail on each host: - **`Justfile` and `justfiles/anvil/*.just`** — see [local.md](./local.md). -- **`.anvil/container/`** — the non-recipe container assets (Containerfile, - drivers, image-ID helpers, README) emitted only when the catalog includes the - optional container backend. `justfiles/` holds `.just` recipes and nothing - else, so these live in a tool-owned directory of their own; see +- **`.anvil/container/`** — the container image definition: a `Dockerfile` and + its build-context ignore file, plus an optional `hooks.ps1` supplying + credentials. `justfiles/` holds `.just` recipes and nothing else, so these + live in a tool-owned directory of their own; see [containers.md](./containers.md). - **`Cargo.toml` lints regions** — workspace `Cargo.toml` carries the `anvil-workspace-lints` region containing a single `[workspace.lints]` table whose diff --git a/crates/cargo-anvil/docs/design/ado.md b/crates/cargo-anvil/docs/design/ado.md index 8f4a293c..c985a343 100644 --- a/crates/cargo-anvil/docs/design/ado.md +++ b/crates/cargo-anvil/docs/design/ado.md @@ -603,7 +603,7 @@ elide the Windows job entirely if their root pipeline is shaped to support that. The scheduled stages template is simpler — it omits the `impact` stage and runs each group full-workspace, with the same `linuxPool` / `windowsPool` parameter shape and the same `steps/job.yml` delegation. Scheduled step templates pass no `include*` -parameters; the scheduled group recipes route through `_anvil-run` with +parameters; the scheduled group recipes wrap in `_anvil-unscoped`, which exports `ANVIL_IMPACT=off`, so `anvil-impact` no-ops and every category resolves to its full-workspace default (`--workspace`). diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 7ff5a512..aa8bdea3 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -1,447 +1,632 @@ -# cargo-anvil container execution - -This document describes `cargo-anvil`'s optional support for running generated -Anvil recipes in a reproducible local Linux container. Native execution remains -the default. - -The intended audience is `cargo-anvil` maintainers and downstream catalog -authors. User setup and troubleshooting are documented in the generated -`.anvil/container/README.md`. - -## 1. Problem - -Anvil recipes normally use the developer's host toolchain. That is the fastest -inner loop, but it cannot always reproduce: - -- Linux-specific behavior from a Windows host; -- failures caused by differences between the host distribution and a pinned - build environment; -- Linux binaries that require a newer glibc than their deployment environment; -- the exact Rust toolchain and Cargo tools selected by the generated catalog; -- fast repeated container runs without reinstalling tools or rebuilding - unchanged dependencies. - -Container support provides an explicit way to run the same recipes in a -pinned Linux environment. It is a local development feature, not a replacement -for native execution or the generated GitHub Actions and Azure DevOps -workflows. After the initial build, it reuses the matching image, dependency -caches, and compilation output. - -## 2. Design principles - -- **Generated files remain the product.** `cargo-anvil` emits the container - recipe, image definition, and host drivers. The generator is not involved - when a recipe runs. -- **Recipes are unchanged.** The container invokes the existing generated - `anvil-*` recipes rather than maintaining container-specific copies. -- **Container use is explicit or deliberately selected.** There is no `PATH` - shim, replacement `just` binary, or implicit command rewriting. -- **Runtime policy is not generator state.** Selecting the container runner - does not change `.anvil.lock` or the update algorithm. -- **The generated catalog is the image's source of truth.** The image installs - tools through `just anvil-setup`, using the same generated pins and setup - recipes that checks validate. -- **Environment-specific behavior is replaceable.** Downstream catalogs can - replace the image definition and add authentication hooks without forking the - public drivers or execution model. - -## 3. User experience - -Run any generated Anvil recipe in the container: +# Containerized execution -```text -just anvil-container anvil-clippy -just anvil-container anvil-pr +Any command can be executed inside a Linux image built from the toolchain and tool versions the repository +pins: + +```bash +just anvil-container just anvil-pr # a tier +just anvil-container cargo build # any other command +just anvil-container # interactive shell +``` + +Execution is opt-in per invocation: recipes run natively unless a container is requested by name. The feature is three +generated artifacts and one optional hook script, with no configuration file. + +See [README.md](./README.md) for the overall design principles, [local.md](./local.md) for the recipe surface this +wraps, and [extensibility.md](./extensibility.md) for the catalog seam a downstream fork uses. + +- [1. Purpose](#1-purpose) +- [2. Command surface](#2-command-surface) +- [3. Emitted artifacts](#3-emitted-artifacts) +- [4. Image identity](#4-image-identity) + - [4.1 Inputs](#41-inputs) + - [4.2 Digest](#42-digest) + - [4.3 What the tag guarantees](#43-what-the-tag-guarantees) +- [5. Execution model](#5-execution-model) + - [5.1 Mounts and working directory](#51-mounts-and-working-directory) + - [5.2 Process identity](#52-process-identity) + - [5.3 Environment](#53-environment) + - [5.4 Re-entry](#54-re-entry) +- [6. Engines and host setup](#6-engines-and-host-setup) + - [6.1 Engine resolution](#61-engine-resolution) + - [6.2 Docker](#62-docker) + - [6.3 Podman](#63-podman) +- [7. The hook](#7-the-hook) + - [7.1 Anvil-BuildSecrets](#71-anvil-buildsecrets) + - [7.2 Anvil-RunEnv](#72-anvil-runenv) + - [7.3 Anvil-ResolveImage](#73-anvil-resolveimage) + - [7.4 Trust boundary](#74-trust-boundary) +- [8. Customization](#8-customization) +- [9. Limitations](#9-limitations) + +## 1. Purpose + +The generated recipes assume a usable host toolchain; anvil does not install one ([README.md §3][design]: "the user +owns it locally"). Two conditions invalidate that assumption: + +1. **Platform-specific failures.** A `cfg(unix)` code path, a Linux-only lint, or a test that depends on Linux memory + semantics cannot be reproduced on a Windows or macOS host. +2. **Toolchain divergence.** The installed toolset can differ from the one the checks expect, so a passing local run + stops predicting a cloud result. + +Both are addressed by executing the recipe unchanged inside an image built from the repository's own pins. Recipe +bodies are identical in either mode, and cloud workflows are unaffected: they run the same recipes natively on their +own agents. The image is pinned to resemble that environment, not to reproduce it. + +## 2. Command surface + +`anvil-container` takes the argv to run inside the image; every recipe continues to execute natively unless a +container is requested by name. Anvil recipes are reached by naming `just`, like any other command: + +```bash +just anvil-container just anvil-pr +just anvil-container cargo build +``` + +Arguments are whitespace-delimited tokens. `just` joins a variadic `*command` with spaces before the recipe body sees +it, so the original argv is unrecoverable and an argument containing a space does not round-trip. Pass such a value +through the environment instead. + +| Recipe | Behaviour | +| --- | --- | +| `just anvil-container ` | Execute a command in the image. With no argument, opens an interactive shell. | +| `just anvil-container-tag` | Print the image reference for the current inputs. Builds nothing. | +| `just anvil-container-status` | Print the engine, working directory, image reference, and whether it is present. Never builds or pulls. | +| `just anvil-container-down` | Remove this repository's cache volumes. The image is retained. | + +All four are annotated `[group("anvil-container")]` and appear as one cluster in `just --groups`. Each repeats a +one-line summary immediately above its attributes, because `just --list` takes the last comment line before them as the +description and would otherwise print the tail of a rationale paragraph as a fragment. + +There is deliberately no `anvil-container-rebuild`. Its whole body would be `ANVIL_CONTAINER_NO_CACHE=1` followed by +the ordinary resolve, and that variable is already public below — where it also composes with `NO_REBUILD` and +`NO_RESOLVE`, which a recipe form does not. + +What a recipe form would supply is **scope**: it sets the variable in its own process and exits, so exactly one build +ignores the cache. An exported variable is sticky, and every container command reads it, so a forgotten +`ANVIL_CONTAINER_NO_CACHE` rebuilds from scratch on each later invocation with nothing to indicate why. Scope it to +the one run: + +```powershell +$env:ANVIL_CONTAINER_NO_CACHE = '1' +try { just anvil-container just anvil-fmt } finally { Remove-Item Env:ANVIL_CONTAINER_NO_CACHE } ``` -Every positional argument is a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not part of this command surface. +| Variable | Effect | +| --- | --- | +| `ANVIL_CONTAINER_ENGINE` | `docker` (default) or `podman`. Read at run time (§6.1). | +| `ANVIL_CONTAINER_NO_REBUILD=1` | Fail instead of building when the image is absent, separating a cache miss from a build failure. | +| `ANVIL_CONTAINER_NO_RESOLVE=1` | Skip the resolve hook (§7.3), so a query never pulls. | +| `ANVIL_CONTAINER_NO_CACHE=1` | Rebuild with `--no-cache` even when the tag resolves. Skips the resolve hook too (§7.3). | +| `ANVIL_IN_CONTAINER=1` | Set inside the image. Makes a nested invocation execute natively (§5.4). | +| `GITHUB_TOKEN` | Forwarded into the run. Taken from the host environment, or derived from the gh CLI for a target that reads it (§5.3). | + +`NO_REBUILD` is evaluated independently of `NO_CACHE`, so the two compose: `anvil-container-status` sets `NO_REBUILD` +and `NO_RESOLVE` together and answers from local state alone. When `NO_REBUILD` stops a build the reference is still +printed, since a caller that asked not to build is usually asking which image is missing. -With no recipe, the command opens an interactive shell: +## 3. Emitted artifacts ```text -just anvil-container +repo/ +├── justfiles/anvil/ +│ ├── container.just the anvil-container recipes +│ └── … checks, groups, tiers, executed natively *inside* the image +└── .anvil/container/ + ├── Dockerfile composed: anvil's five regions, your content in the gaps + ├── Dockerfile.dockerignore what the build context admits + └── hooks.ps1 optional; not emitted by default (§7) ``` -Native tier execution remains the default. The three public tiers — and, because -they route through the same `_anvil-run` seam, the four scheduled *group* recipes -(`anvil-scheduled-test`, `-advisories`, `-runtime-analysis`, `-exhaustive`) — can -instead route through the container: - -- for one invocation: `just anvil_runner=container anvil-pr`; -- for the current shell: set `ANVIL_RUNNER=container`; -- for the repository: change the default in the `anvil-runner` region of the - repository-root `Justfile` and commit that policy. - -`ANVIL_RUNNER=native` overrides a repository container default for the current -shell. - -This makes group-level container routing asymmetric: the scheduled groups go -through `_anvil-run` (to force the full-workspace backstop), so -`just anvil_runner=container anvil-scheduled-test` containerizes, whereas the PR -group recipes (`anvil-pr-fast`, …) run natively even under `anvil_runner=container` -because the PR tier invokes them directly rather than through the seam. Run a PR -group in a container via the whole tier (`anvil_runner=container anvil-pr`) or with -`anvil-container` directly. - -The tier recipes and the four scheduled group recipes delegate to a tool-owned `_anvil-run` seam. Inside the image, -`ANVIL_IN_CONTAINER=1` forces that seam to select native execution, so the -existing private tier runs without recursively launching another container. -Ad-hoc checks remain explicit through `anvil-container`. - -`just` does not support conditional dependency lists, so `_anvil-run` starts a -second `just` invocation for the selected private tier. It reuses the exact -parsed Justfile and preserves ordinary native output and exit status. Global -CLI options, variable assignments, dependency introspection, and `--dry-run` -apply to the outer invocation and are not propagated to the selected tier. - -## 4. Architecture - -Container support consists of the generated recipe -`justfiles/anvil/container.just` and a generated artifact group under -`.anvil/container/`. The recipe selects the PowerShell driver on Windows and the -Bash driver on Linux or WSL. The PowerShell driver invokes Docker Engine in the -default WSL distribution; the Bash driver invokes the local Docker Engine -directly. Both implement the same lifecycle: - -```mermaid -flowchart TD - user["just anvil-container <recipe>"] --> dispatch["Select the host driver"] - dispatch --> identity["Compute the content-based image ID"] - identity --> customize["Inspect the local image
and load trusted customization"] - customize --> github{"Does the request need GitHub access?"} - github -- Yes --> auth["Acquire host or customized credentials"] - github -- No --> exists - auth --> exists{"Matching local image exists?"} - exists -- No --> build["Build the image
and run just anvil-setup"] - exists -- Yes --> prepare - build --> prepare["Run optional dependency preparation"] - prepare --> aprz["Run anvil-aprz with a temporary token mount when required"] - aprz --> checks["Run the requested recipes without the token"] - checks --> cleanup["Remove temporary credentials and containers"] +`container.just` and `Dockerfile.dockerignore` are owned files carrying the usual `DO NOT EDIT DIRECTLY` marker. + +The Dockerfile is **composed**, not owned: anvil maintains five managed regions inside it, and the repository owns +everything between them. Regions are updated in place on every run. Gap content is preserved byte-for-byte and is +never read, rewritten or reordered. + +| Region | What anvil puts there | What belongs in the gap after it | +| --- | --- | --- | +| `anvil-container-base-image` | `ARG BASE_IMAGE`, pinned to a digest. | A second `ARG BASE_IMAGE=…` to build on a different base. | +| `anvil-container-base` | `FROM`, the version pins for `pwsh`, `just`, `rustup` and `cargo-binstall`, and the `ENV` block. | Anything the first network access needs: a root CA, `http_proxy`, an internal package mirror. | +| `anvil-container-tools` | System packages and those four tools. | Libraries a catalog tool needs to compile, for tools `binstall` has no prebuilt binary for. | +| `anvil-container-setup` | `COPY` of the recipe tree, then `just anvil-setup`. | Anything the repository's own checks need at run time. | +| `anvil-container-entry` | `ANVIL_IN_CONTAINER`, `WORKDIR`, `CMD`. | — | + +Each gap sits at the only point in the build where its kind of addition works: a certificate has to land before the +first download, a run-time tool after the toolchain exists. That is what makes the image extensible without forking +the catalog — a repository adds to a gap and keeps receiving base-image and tool-pin updates, where a fork or an edit +inside a region freezes them. + +Line 1 is `# syntax=docker/dockerfile:1` and belongs to no region, because BuildKit honors the directive only when +nothing precedes it and a region sentinel is a comment. Anvil writes it, and the copyright notice under it, when it +creates the file and never touches either again; both are the repository's from then on. + +**Why not an owned file.** An edited owned file is preserved and anvil's version is written to `.anvil-proposed` +(`updates.md` §2). There is no three-way merge and no recorded common ancestor, so each upgrade leaves two files to +reconcile by hand. For this file the consequence is silent: it carries the base digest and four tool pins, so a +repository that edits it once builds on a frozen base and frozen versions indefinitely, while `anvil-container-tag` +still resolves, because the tag hashes the file as it stands. Identity stays correct and the image stays stale. + +**Regions are not write protection.** The ownership rules in `updates.md` §2 apply to a region body exactly as they do +to a file: an edit inside a region is preserved and produces a proposal rather than being overwritten. The gaps exist +so that editing a region is never the right way to add something. + +**Overriding the base image.** `ARG BASE_IMAGE` and the `FROM` that consumes it are separate regions, so the override +is a gap edit rather than a region edit: a second `ARG BASE_IMAGE=…` in the gap wins, because a later declaration +replaces the default, and every pin the repository did not touch keeps updating. A base with an older glibc breaks +`binstall`, and moving the catalog to source installs is not a repository-level lever. + +**Three properties a Dockerfile host requires that an order-independent TOML or line-set host does not:** + +- **The parser directive must be line 1.** BuildKit honours `# syntax=…` only when nothing precedes it, not even a + comment, and a region's opening sentinel is a comment. The directive therefore cannot be managed, and is instead the + whole of the scaffold anvil writes when the file is absent. The scaffold is one line because anvil never reconciles + it: anything placed there is uncorrectable on a repository that has already generated the file. +- **Region order is semantic.** `ARG BASE_IMAGE` precedes the `FROM` that consumes it, `FROM` precedes everything, and + the toolchain exists before `anvil-setup` runs. Anvil compares the on-disk sequence with the declared one and refuses + the file, naming the region that is out of place, rather than writing a Dockerfile that cannot build. +- **A missing region is spliced at its declared position, not appended.** Appending suits every other host; here a + region introduced in a later release would land after ones it must precede. Anvil inserts it after the nearest + preceding region present in the file, or directly below the scaffold, leaving gap content untouched. + +Anvil classifies an existing Dockerfile before writing to it. A file the lock records as an owned file and that +carries no regions is a render from a version that owned the whole path; it is replaced. A file carrying every region +is composed and is updated in place. Anything else — a Dockerfile the repository wrote itself, or one whose regions +have been removed — is refused, because there is no position for the regions that would not place existing content +above `FROM`. The refusal names the file and the recovery; nothing is written to it. + +The image installs its tools by running `just anvil-setup`, the same recipe the checks use, reading the same +generated pins. There is no second tool list to keep synchronized, and consequently a tool-pin change renames the +image (§4.1). + +`Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/` and +`rust-toolchain.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run +`anvil-setup`, and it is hashed whole (§4). `.anvil/container/` is admitted so a gap can `COPY` a file placed beside +the Dockerfile; anvil's own `.anvil-proposed` review artifacts are excluded from both the context and the digest. +BuildKit reads `.dockerignore` in preference to a root `.dockerignore`, so the repository neither needs to +own a root ignore file nor can have one silently override this. + +## 4. Image identity + +The image reference is `anvil-:<16 hex characters>`, where the tag is a SHA-256 digest over the inputs that +define the image. The name derives from the repository directory (§5.1). + +### 4.1 Inputs + +| Input | Hashed | +| --- | --- | +| every file under `.anvil/container/` | always | +| `rust-toolchain.toml` | always | +| every file under `justfiles/anvil/` | always | + +`.anvil/container/` is hashed by walking it, not as a fixed list of three known files. The Dockerfile is composed, so a +repository can `COPY` something from one of its gaps — a root CA, an install script, a patch — and a downstream +catalog's replacement region can do the same. Naming only the files anvil happens to know about would let any of those +change the image under a reference that already resolves, which is the hole the digest exists to close. A missing +Dockerfile is still a hard error, checked by name: the walk alone would let it contribute nothing and yield a confident +tag for an image that cannot be built. + +The recipe tree is hashed in full. `just anvil-setup` reaches the install recipes through the tier, group and check +recipes, so the routing decides *whether* a tool is installed just as surely as `tools.just` decides *how*: dropping an +`anvil--setup` dependency from a group changes the installed set while `tools.just` and `versions.just` stay +byte-identical. Hashing only the install definitions would leave that change unnamed, and the tag would claim contents +the image does not have. + +`container.just` is hashed too. It is not circular — the digest is over file text, and no file contains the tag — and +it belongs in the set because it passes the build arguments, the secret mounts and the hook's `Anvil-BuildSecrets` output +into the build. + +The cost is that editing any recipe renames the image and the next run rebuilds it. That is the correct trade: a tag +that can name contents the image does not have makes every guarantee below meaningless. + +The hook file's **content** is an input, since it determines what the build installs. Its **output** is deliberately +excluded: a credential must never influence a tag. + +### 4.2 Digest + +Inputs are sorted by relative path with an ordinal comparison, then serialized into one stream in which each entry +contributes a literal `file`, the UTF-8 byte length of its relative path, the path, the UTF-8 byte length of its +content, and the content. Length-prefixing the two variable-length fields is what makes the stream self-delimiting: +newline framing would let a file whose body happened to contain `file`, a path and a newline serialize identically to +two files splitting at that point, so two different input sets could name one image. The lengths are byte counts of +the same UTF-8 encoding the stream is hashed in, so an independent re-implementation arrives at the same bytes. +Tagging entries this way +prevents a rearrangement of names and contents from colliding. Line endings are normalized to LF for `.just` recipes +and the declared text inputs, so those agree across a CRLF and an LF checkout; every other file the walk admits is +hashed as the bytes the build context copies, because bytes are what `COPY` puts in the layer. The sort is ordinal +because a case-insensitive one would drop one of two inputs differing +only in case on the case-sensitive filesystem where the image is built. + +Anvil's own `.anvil-proposed` review siblings are excluded from both the digest and the build context. They are +written beside a host when a template moves under a customized region, and they cannot reach the image, so digesting +one would rename it for as long as the proposal went undismissed. + +The tag is the first eight bytes of the digest, hex-encoded: 64 bits, far beyond practical collision risk for a local +image set, and short enough to keep `docker images` readable. + +`anvil-container-tag` is the only place the digest is computed; every other recipe calls it. A publisher and a +consumer therefore derive the same reference independently, with no `latest` tag and no digest maintained by hand. + +### 4.3 What the tag guarantees + +Changing an input names a tag that cannot already exist, so a build follows; changing nothing resolves the existing +tag immediately. There is no staleness check because a locally built image that is present was built from the inputs +that name it. + +That holds only for a locally built image. One obtained through `Anvil-ResolveImage` (§7.3) merely *claims* those +inputs, since the digest covers source files and cannot be re-derived from layers, so the claim is only as strong as +its registry. Publish to a registry with immutable tags and restricted push. + +Two properties sit outside the digest. The base image is not resolved during hashing, so `ARG BASE_IMAGE` must remain +digest-pinned; a floating tag could otherwise change beneath a tag that claims to name fixed content. The platform is +pinned to `linux/amd64` on build and run, so hosts of differing architecture cannot compute one tag for two images. + +A third sits outside it by necessity: the `apt-get install` layer names packages without versions, and Ubuntu's +archive moves. Two clean builds of a byte-identical Dockerfile weeks apart can therefore install different package +versions under one tag. Pinning every apt version would trade this for a harder failure, since the archive drops +superseded versions and the build would simply stop working. So the guarantee the tag gives is precise: **the inputs +that define the image are fixed, and everything anvil itself installs is version-pinned** — the toolchain, the tool +catalog, `just`, `pwsh`, `rustup` and `cargo-binstall`, each with a checksum. The system packages beneath them track +the base distribution. `ANVIL_CONTAINER_NO_CACHE=1` forces a from-scratch rebuild when that distinction matters. + +The base tracks the Linux runner the generated workflows use, `ubuntu-latest` (currently 24.04). The catalog is +installed with `binstall`, and those prebuilt binaries require that runner's glibc, which is backward but not forward +compatible. A catalog on an older base installs from source instead. + +## 5. Execution model + +One container is created per `anvil-container` invocation, however many checks the requested recipe runs, and is +removed on exit (`--rm`). + +### 5.1 Mounts and working directory + +| Mount | Target | Purpose | +| --- | --- | --- | +| repository root (bind) | `/workspace` | The worktree under test, including `target/`. | +| common git directory (bind, linked worktrees only) | `/anvil/gitdir` | Git history, when the checkout does not carry it. | +| `anvil--cargo-registry` (volume) | `/usr/local/cargo/registry` | Downloaded crate sources. | +| `anvil--cargo-git` (volume) | `/usr/local/cargo/git` | Git checkouts of git dependencies. | + +A linked worktree (`git worktree add`) keeps its git directory outside the checkout and stores an absolute host path +in `.git`, which does not exist inside the container. The recipe resolves this while assembling the run, before the +container starts: it compares `git rev-parse --git-dir` against `--git-common-dir`, and when they differ it adds a +bind mount for the common directory and a second one placing a generated `.git` file over the checkout's own, naming +that mount. Git then resolves the history by ordinary discovery. This is what lets the checks that read history — the +impact-scoped filters, `anvil-mutants-diff`, `anvil-semver-check` — work from a worktree at all; without it git +resolves nothing inside the container and each of them fails a long way from the cause. + +The redirection is confined to the checkout: a git command run elsewhere in the container, such as `git init` in a +scratch directory, is unaffected. That is why a generated `.git` file is used rather than `GIT_DIR`, which is ambient +and would be inherited by every process in the container. An ordinary clone carries its git directory inside the bind +mount and takes none of this. The generated `.git` file is written to the host temp directory, bind-mounted from +there, and removed when the run ends; nothing is written into the checkout, and no flag or variable selects the +behaviour. + +Only cargo's content-addressed download caches are volumes, so the write-heavy download path never crosses the host +boundary and the host's own toolchain is untouched. `$CARGO_HOME` and `$RUSTUP_HOME` themselves are **not** mounted: +they carry the installed tools and toolchains, and an engine populates a named volume from the image only when that +volume is first created. Mounting them would pin the first image's binaries over every later one, so a tool bump +would change the tag, build a new image, and still run the old tools — defeating the identity guarantee in §4. +Tools and toolchains therefore always come from the image layer the tag names. + +`target/` stays on the bind mount, shared with the host and visible from it. A native run and a containerized run write +incompatible artifacts to the same paths, so switching between them recompiles the workspace. Giving the container its +own build directory through `CARGO_TARGET_DIR` avoids that but breaks `cargo-semver-checks`, which builds a baseline +and the current crate and then cannot find its rustdoc output; the recompilation is the lesser cost. + +The caller's working directory is mapped to its in-container equivalent, so relative paths resolve when +`anvil-container` is invoked from a subdirectory. + +Image and volume names derive from the repository directory name, lowercased with every run of characters outside +`[a-z0-9]` replaced by a single `-` and any trailing `-` removed: a checkout in `ox-tools (copy)` becomes +`anvil-ox-tools-copy` rather than ending in a separator, which the engine rejects. A directory name with no `[a-z0-9]` +character at all degrades to plain `anvil` — still a valid reference, but no longer repository-specific. Two checkouts +with the same directory name therefore share cache volumes. That is harmless, since both volumes hold only +content-addressed downloads, but `anvil-container-down` then removes volumes the other checkout is also using. + +### 5.2 Process identity + +On a Linux host the run passes `--user :`, matching the invoking user, unless that user is root. Without it, +everything written under the bind mount is owned by root on the host, and the next native `cargo build` or `git clean` +fails with `EACCES` far from the cause. Docker Desktop on Windows and macOS maps ownership itself, and `id` is not +available to query, so the flag is not passed there. That uid has no `passwd` entry, so `HOME` is set to `/tmp`; +otherwise the engine leaves it as `/`, and anything falling back to `$HOME` writes to a read-only root. + +### 5.3 Environment + +The run passes `ANVIL_IN_CONTAINER=1` (§5.4) and forwards `GITHUB_TOKEN` by name, resolved the way the recipe resolves +it natively: the environment first, then the gh CLI's stored token. `anvil-aprz` runs in the `scheduled-advisories` group and +queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and then sleeps until the quota +resets, so a tier needs the token to terminate rather than merely to run quickly. + +The two sources are not treated alike. An **exported** `GITHUB_TOKEN` is forwarded whatever the target is — that is +exact parity, since a native run exposes it to every process the shell spawns too. A token **derived** from the gh CLI +is a credential the developer never put in this environment, and PID 1's environment is inherited by every build script +and proc macro in the container, where natively `anvil-aprz` would mint it inside its own process. So it is derived +only when the target's plan (`just --dry-run `) reads `GITHUB_TOKEN`, or when there is no target at all: an +interactive session can run anything, and refusing there would reintroduce the stall the token exists to prevent. The +predicate is the variable rather than the name of a check, so a catalog that adds another GitHub-authenticated check is +covered without touching the driver. + +A plan covers the bodies `just` runs itself, not the body of a recipe that one of them launches as a child process. +The unscoped tier wrapper (§`helpers.just`) launches its tier that way, so planning `anvil-scheduled` shows the wrapper +alone. The driver therefore follows each nested target a plan names, until nothing new appears; without that, a wrapped +tier reads as needing nothing and `anvil-aprz` runs unauthenticated inside an image that has no `gh` of its own. + +It also forwards the recipe contract's own inputs when they are set — `PR_TITLE`, `BASE_REF`, `GITHUB_BASE_REF`, +`SYSTEM_PULLREQUEST_TARGETBRANCH` and `ANVIL_IMPACT` — because a check that reads one natively must read +the same value in a container. `anvil-pr-title` is the sharp case: with `PR_TITLE` unset it exits 0 with a skip notice, +so dropping it at the boundary would let a title a native run rejects pass in a container while the tier still reported +green. `ANVIL_IMPACT` is the other: a CI group job exports `consume`, and a container that did not inherit it would +recompute scoping from a diff instead of trusting the artifact the group downloaded. They are forwarded by name and +only when set, so an unset variable stays unset rather than arriving empty. + +A resolved token is set on the driver process, passed by name, and unset after the run, so it never reaches a host +command line. Inside the container it is readable by everything the run executes, including build scripts and proc +macros. + +Everything else a run needs comes from the hook (§7). + +### 5.4 Re-entry + +`ANVIL_IN_CONTAINER=1` is set in the image and passed on each run. `anvil-container` checks it first and, inside the +image, executes the requested recipe directly instead of launching another container, so a recipe that reaches +`anvil-container` transitively still performs its work once. + +## 6. Engines and host setup + +anvil installs nothing and manages no virtual machine. Beyond the engine, the host needs `just` and PowerShell Core +(`pwsh`), which every generated recipe requires, and the repository must own a `rust-toolchain.toml`. + +| | Docker | Podman | +| --- | --- | --- | +| Selected by | default | `ANVIL_CONTAINER_ENGINE=podman` | +| Builder | BuildKit | buildah | +| Status | supported | best-effort; see §6.3 | + +### 6.1 Engine resolution + +`ANVIL_CONTAINER_ENGINE` defaults to `docker`, and any value other than `docker` or `podman` is rejected before the +engine is invoked. Being a host property rather than a repository one, it is read at run time and never committed. +It is the only control: the recipes resolve the engine through nested `just` invocations, which a +`just anvil_container_engine=...` override would not reach. + +Resolution proceeds in a fixed order: + +1. If the named binary is on `PATH`, it is invoked directly. +2. Otherwise, on Windows, the binary is probed inside the default WSL distribution + (`wsl.exe --exec --version`). If the probe succeeds, every subsequent engine call is prefixed with + `wsl.exe --exec`. This accommodates Docker Engine installed inside WSL without Docker Desktop, which leaves no + Windows CLI behind; Docker Desktop and Podman both install one and resolve at step 1. +3. Otherwise the invocation fails with a message naming the variable and linking to this document. + +anvil never falls back to the other engine: if the selected one is unusable, the invocation fails rather than +substituting. Automatic detection is avoided deliberately, because a binary on `PATH` does not prove a reachable +daemon, and silently choosing between two installed engines would split the image cache across two stores and produce +rebuilds with no visible cause. A missing binary is the only failure anvil reports itself; every other engine +diagnostic is shown unchanged. + +When the engine is reached through WSL it does not share the Windows filesystem view, so anvil translates every path +it hands over (bind-mount source, build context, `--file`) with `wslpath -a -u`. An untranslated path is not +rejected by the engine; it silently resolves to an empty directory. The `--exec` form is required rather than +cosmetic: plain `wsl.exe --` hands the command line to the distribution's login shell, which would expand `$NAME` +and split on `;` in repository paths and forwarded recipe arguments alike. A path holding `$` would be truncated by +that expansion and `wslpath -a` would still exit 0, bind-mounting the wrong directory. + +### 6.2 Docker + +**Linux.** Install Docker Engine from your distribution or `get.docker.com`, and add your user to the `docker` group. + +**Windows, with Docker Desktop.** No configuration required: `docker` is on `PATH`. + +**Windows, Docker Engine in WSL.** No Docker Desktop and no Windows CLI: + +```powershell +wsl --install -d Ubuntu-24.04 +wsl -d Ubuntu-24.04 -- sh -c 'printf "[boot]\nsystemd=true\n" | sudo tee /etc/wsl.conf' +wsl -d Ubuntu-24.04 -- sh -c 'curl -fsSL https://get.docker.com | sh' +wsl -d Ubuntu-24.04 -- sudo usermod -aG docker "$USER" +wsl --shutdown +wsl -d Ubuntu-24.04 -- docker version # verify ``` -The driver: +`just` and `pwsh` remain on Windows; the distribution needs only Docker. Installing a Windows `docker` CLI and +pointing `DOCKER_HOST` at the WSL socket also works and takes precedence, since the WSL path is used only when no CLI +is found. The daemon is Linux-side either way, so the repository must be bind-mountable at a path it can resolve. -1. validates the host prerequisites and locates the Git repository root; -2. computes the image ID from build-relevant generated content; -3. checks image availability and loads and validates trusted customization; -4. prepares any credentials required by the requested recipes; -5. builds the matching image when it is not already available; -6. runs an optional downstream dependency-preparation command; -7. starts a short-lived container with the repository and named caches mounted; -8. invokes the requested recipes with `just`, or starts an interactive shell; -9. removes temporary credential files on success or failure. +### 6.3 Podman -## 5. Image construction and identity +**Linux.** Install podman and set `ANVIL_CONTAINER_ENGINE=podman`. -The public `Containerfile` starts from a pinned public Linux base and installs -`just`, Rustup, and PowerShell. It copies the generated Anvil tree and the -repository-owned `rust-toolchain.toml`, then runs: +**Windows.** `podman machine init` provisions and manages its own WSL2 virtual machine, and `podman.exe` is placed on +`PATH`, so anvil invokes it directly. -```text -just anvil-setup +```powershell +winget install RedHat.Podman-Desktop # or the podman CLI alone +podman machine init +podman machine start +$env:ANVIL_CONTAINER_ENGINE = 'podman' ``` -This makes the generated setup recipes and the container image use one source -of truth for Rust toolchains and Cargo tools. - -The local image tag is a SHA-256 hash of build-relevant repository content: - -- `rust-toolchain.toml`; -- generated `justfiles/anvil/**/*.just` recipes; -- the `Containerfile`, `Containerfile.dockerignore`, entrypoint, and other - static image inputs. -- the selected digest-pinned base image from `ANVIL_CONTAINER_BASE_IMAGE`, or - the `Containerfile` default when the variable is absent. - -Execution-only drivers, image-ID helpers, the entry recipe, user -documentation, and `customize.sh`/`customize.ps1` are excluded. Customization -source is runtime orchestration, not image content: it is excluded from both -image identity and the build context, so it can never silently change what a -tag names. See [8.9](#89-image-identity-and-the-build-context). Paths are -sorted and deduplicated, and line endings are normalized so the Bash and -PowerShell helpers produce the same ID. - -By default, the image is tagged `anvil-dev:`. A changed tool pin, -recipe, toolchain, or other static image artifact selects a new immutable tag. -The next invocation builds that image, while images for older branches remain -available. Runtime execution uses `--pull=never` and never substitutes -`latest`. - -Container execution requires a `rust-toolchain.toml` in the repository root. It -does not choose a default Rust channel when that file is absent. - -The public default is digest-pinned Debian Bookworm. A user or automation can -select another image compatible with the generated Debian-based -`Containerfile` through `ANVIL_CONTAINER_BASE_IMAGE`. This supports a lower -glibc baseline such as Debian Bullseye without replacing the generated file. -A distribution with a different package ecosystem requires a derived -`Containerfile`. Unpinned tags are rejected. - -## 6. Runtime and cache model - -Each invocation uses a short-lived container and persistent named volumes: - -```mermaid -flowchart LR - repo["Host repository"] -->|read/write bind mount| workspace["/workspace"] - token["Temporary token file"] -.->|read-only when required| runtime["Anvil container"] - registry[("Repository-scoped Cargo registry volume")] --> cargo["Per-user Cargo home"] - git[("Repository-scoped Cargo Git volume")] --> cargo - target[("Repository- and image-specific target volume")] --> workspace - workspace --> runtime - cargo --> runtime -``` +Podman differs from Docker in three respects: -- The repository is bind-mounted read/write at `/workspace`. -- Cargo registry and Cargo Git data use repository-specific named volumes - shared across branches and image IDs of that repository. -- `target/` uses a repository- and image-specific named volume mounted over - `/workspace/target`. Container builds therefore do not use the host - `target/`. -- Docker runs the image as `linux/amd64` with the invoking Linux/WSL user's - numeric user and group IDs. -- The image sets `ANVIL_IN_CONTAINER=1` and uses `--pull=never`. +- **Build secrets are not supported on Windows.** A build that mounts one fails before it starts, with an error + naming a temporary file: -The driver creates the named volumes explicitly, initializes their top-level -ownership in a short-lived root container, and runs preparation and recipe -containers as the non-root Linux/WSL user. The root container never runs -repository recipes. + ```text + Error: creating temp file: open /mnt/c/Users/…/repo\podman-build-secret-4085781963 + ``` -The entrypoint creates a writable Cargo home for the invoking non-root user. It -copies Cargo installation metadata so `cargo install --list` can discover tools -installed into the image, then links the shared registry and Git caches into -that Cargo home. + Only a repository whose hook defines `Anvil-BuildSecrets` (§7.1) is affected; building, running, and tag reuse are not. + Use Docker if you need build-time credentials on Windows. -The separate target volume prevents incompatible host and container artifacts -from mixing. Including the image ID in its name also prevents an older branch -from reusing target output produced by a different toolchain or generated -catalog. +- **The ignore file is passed explicitly.** buildah honours only an ignore file at the context root, so anvil passes + `--ignorefile`. Without it the entire worktree, `target/` included, is streamed to the daemon on every build. -After the initial image build, repeated container runs reuse the image, -dependency caches, and compilation output, substantially reducing warm-run -time. +- **Rootless user-namespace mapping is not applied.** The run passes `--user` (§5.2) but not `--userns keep-id`, which + rootless podman requires for bind-mount ownership to map back to the invoking user. -## 7. Authentication and secret isolation +## 7. The hook -Authentication has distinct public and downstream extension paths. +`.anvil/container/hooks.ps1` is a single optional PowerShell script supplying the two things anvil cannot derive: +credentials, and where a published image might be obtained. It is not emitted by default, since crates.io requires no +credentials and an empty script would be one more generated file to review. -### 7.1 GitHub API access +It is loaded by path rather than by provenance: the recipe dot-sources it whenever the file exists, whether a +repository wrote it or a catalog shipped it, so a repository can adopt a credential flow without forking the catalog. -The public `anvil-aprz` recipe requires authenticated GitHub API access. The -drivers recognize `anvil-aprz` and aggregate tiers that invoke it, then obtain a -token from the host `GITHUB_TOKEN` or an authenticated host `gh` session. -Because customization loads first, trusted downstream customization can obtain -a short-lived token and assign it to the process `GITHUB_TOKEN`. +The script may define up to three independent functions. All are optional, and each is called only if defined. -For an aggregate tier, the driver: +| Function | Invoked | Returns | +| --- | --- | --- | +| `Anvil-BuildSecrets` | before a build | `@{ Secrets = @{ = } }` | +| `Anvil-RunEnv` | before a run | `@{ Env = @{ = } }` | +| `Anvil-ResolveImage $tag` | before a build, when no local image matches | an image reference, or nothing | -1. writes the token to a user-only temporary file; -2. runs `anvil-aprz` in a separate container with that file mounted read-only; -3. marks APRZ as complete; -4. runs the remaining checks without the token mount; -5. removes the temporary file during cleanup. +Both value-returning functions **fail closed on an empty value**, which the engine does not: BuildKit accepts +`--secret id=t,env=UNSET`, mounts an empty secret and exits 0, so the build would install a reduced tool set, be +tagged with the digest a credentialed build produces, and be reused by every later run. -An interactive invocation can pause while the user completes `gh auth login`. -A non-interactive invocation fails with an actionable error before building the -image when authentication is unavailable. +Credentials are passed to the engine **by name** in both phases, as a `--secret … env=` reference at build time and +`-e ` at run time, so a value never appears in a process command line, where endpoint telemetry records and +retains it far longer than a short-lived token is intended to live. The variables are removed once the engine call +returns, and when the engine is reached through WSL the names are exported through `WSLENV` so the values cross that +boundary. -## 8. Container customization +### 7.1 Anvil-BuildSecrets -Repositories and derived `cargo-anvil` distributions can customize image -construction, dependency preparation, runtime arguments, and cleanup through: +Each entry becomes a BuildKit `--secret id=,env=ANVIL_SECRET_` mount, which BuildKit keeps out of every image +layer. -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 +```powershell +function Anvil-BuildSecrets { + @{ Secrets = @{ feed_token = (az account get-access-token --resource … --query accessToken -o tsv) } } +} ``` -> [!WARNING] -> These files execute on the host with the developer's permissions before -> container isolation. Checking out a branch that adds or changes one of them -> and then running `just anvil-container` executes that code on the host. - -The public catalog does not generate these files. A repository can commit them -directly, or a derived distribution can add them through the artifact API in -[extensibility.md](./extensibility.md). The driver treats both sources -identically. These files are trusted host code, sourced with the developer's -permissions outside the container sandbox. - -The customization interface provides these read-only inputs: - -| Purpose | Bash | PowerShell | Type | -|---|---|---|---| -| Repository root | `ANVIL_CONTAINER_REPO_ROOT` | `$AnvilContainerRepoRoot` | Absolute path | -| Container directory | `ANVIL_CONTAINER_DIR` | `$AnvilContainerDir` | Absolute path | -| WSL repository root | Not applicable | `$AnvilContainerRepoRootWsl` | Absolute WSL path for Docker arguments | -| WSL container directory | Not applicable | `$AnvilContainerDirWsl` | Absolute WSL path for Docker arguments | -| Resolved image | `ANVIL_CONTAINER_RESOLVED_IMAGE` | `$AnvilContainerResolvedImage` | Image name plus content tag | -| Matching image exists | `ANVIL_CONTAINER_IMAGE_EXISTS` | `$AnvilContainerImageExists` | Boolean | -| Requested recipes | `ANVIL_CONTAINER_REQUESTED_RECIPES` | `$AnvilContainerRequestedRecipes` | String array | -| Host is Windows | Not applicable | `$AnvilContainerHostIsWindows` | Boolean | - -The driver initializes and validates these outputs: - -| Purpose | Bash | PowerShell | Type and default | -|---|---|---|---| -| BuildKit secret arguments | `ANVIL_CONTAINER_BUILD_ARGS` | `$AnvilContainerBuildArgs` | String array, empty | -| Preparation arguments | `ANVIL_CONTAINER_PREPARE_ARGS` | `$AnvilContainerPrepareArgs` | String array, empty | -| Preparation command | `ANVIL_CONTAINER_PREPARE_COMMAND` | `$AnvilContainerPrepareCommand` | String array, empty | -| Main runtime arguments | `ANVIL_CONTAINER_RUN_ARGS` | `$AnvilContainerRunArgs` | String array, empty | -| Requested recipes include APRZ | `ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN` | `$AnvilContainerNeedsGitHubToken` | Boolean, derived from public recipes; customization can elevate to true | -| Cleanup callback | `ANVIL_CONTAINER_CLEANUP` | `$AnvilContainerCleanup` | Function name or script block, no-op | - -The driver checks image availability before sourcing customization, validates -outputs, obtains any required GitHub token, then runs the build, optional -preparation, requested recipes, and cleanup phases in order. Failures stop the -invocation and run registered cleanup. - -- Build arguments apply only when constructing a missing image and accept only - BuildKit `--secret` options. Content-changing options such as `--build-arg` - are rejected because their values are not part of the content-addressed - image ID. Static build behavior belongs in hashed container files. -- Preparation runs in a separate short-lived container with the standard - repository and cache mounts, but without main runtime arguments. -- Runtime arguments apply to the requested recipe and the isolated - `anvil-aprz` invocation. Do not forward credentials needed only during build - or preparation. -- Customization that provisions GitHub authentication can assign a short-lived - token to process `GITHUB_TOKEN`. Register cleanup immediately for any - supporting files or external credentials. -- A downstream catalog whose additional aggregate recipe invokes - `anvil-aprz` can set the APRZ-classification output to true. The driver then - performs the same isolated authenticated APRZ phase used by public tiers. -- Cleanup runs after ordinary success, failure, or interactive-shell exit. It - cannot run after forcible process termination or machine failure. - -Customization authors are responsible for least-privilege credentials, -user-restricted temporary files, read-only secret mounts, immediate cleanup -registration, and equivalent Bash and PowerShell behavior. The driver cannot -prevent trusted customization from exposing or persisting secrets. - -`customize.*` is excluded from both image identity and the build context. -Non-secret behavior that changes image contents belongs in hashed static files -such as the `Containerfile`, entrypoint, or supporting build scripts. - -The documented paths, variables, lifecycle, and image-identity behavior form -the compatibility contract. Customizations must not depend on other driver -internals. - -## 9. Downstream extensibility - -Container support is a normal catalog artifact group. A downstream catalog can: - -- replace the `Containerfile` or entrypoint; -- add an optional `customize.sh`/`customize.ps1` customization file and - supporting files; -- inherit the public recipe, drivers, image-ID helpers, cache layout, and - runtime contract unchanged. - -Container support is coupled to the generated imports, tier runner, and APRZ -guard. Removing only `container::all()` is therefore unsupported; a derived -catalog that does not expose container execution must replace that complete -recipe surface rather than removing the container files in isolation. - -This keeps public behavior generic while allowing a downstream catalog to -provide an internal base image, toolchain installer, registry configuration, -and short-lived authentication. - -See [extensibility.md](./extensibility.md) for the catalog builder API. - -## 10. Requirements, controls, and limitations - -Host requirements: - -- Docker Engine 23.0 or newer, installed directly in Linux or WSL and usable by - the current user; -- `git` and `just`; -- Bash on Linux and WSL; -- PowerShell Core (`pwsh`) and WSL 2 on Windows; -- Docker Engine running in the default WSL distribution when invoked from - Windows; `wsl -e docker version` must succeed and the driver does not invoke - Windows `docker.exe`; -- `linux/amd64` execution support; -- a repository-owned `rust-toolchain.toml`. - -Runtime controls: +Minting the value inside the function is the point: a short-lived token must be acquired when it is used, not read +from a committed file or a declared variable. -| Variable | Effect | -|---|---| -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a compatible digest-pinned Linux base image; included in the image ID | -| `ANVIL_CONTAINER_IMAGE` | Overrides the local image name; the content hash remains the tag | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails when the matching image is absent | -| `ANVIL_RUNNER` | Selects `native` or `container` tier execution | -| `ANVIL_IN_CONTAINER` | Internal recursion guard set by the image | - -The initial image build installs the complete pinned tool catalog and can take -several minutes. Later runs with the same image ID reuse the image and target -volume; Cargo registry and Git caches are reused across image IDs. - -Two concurrent cold invocations can both observe that an image is absent and -build the same content-addressed tag. The local backend accepts this redundant -work instead of introducing cross-platform lock ownership and stale-lock -recovery. Both invocations use the same hashed static inputs and selected base -image. Build secrets are intentionally excluded from identity and must provide -equivalent authenticated access rather than select different image content. - -On ARM64 hosts, Docker emulates `linux/amd64`. The driver warns about this -because image builds and checks can be substantially slower than on x86-64. - -The initial implementation is deliberately limited to: - -- local developer execution; -- Linux containers using `linux/amd64`; -- local image construction. - -CI container jobs, remote image publication, registry consumption, and Windows -containers are separate concerns and are not part of this local container -support. - -## 11. Alternatives considered - -- **Native `just anvil-setup` only.** This remains the default and fastest - inner loop, but it cannot provide a pinned Linux distribution or glibc - baseline from Windows and other hosts. -- **VS Code Dev Containers.** They provide a full editor environment, but - require a specific development workflow and do not provide a lightweight - command surface for terminals, agents, or existing editors. -- **A plain `docker run -v` wrapper.** This is simpler initially, but leaves - image construction, tool installation, cache ownership, content identity, - GitHub-secret isolation, and downstream preparation to every repository. -- **Published prebuilt images.** They improve cold-start time but introduce a - registry lifecycle, access policy, retention, and synchronization problem. - Local content-addressed builds keep the initial public feature independent - of registry infrastructure. -- **One fixed deployment distribution.** A Debian-compatible lower-glibc base - can be selected through the base-image override. Azure Linux or another - package ecosystem uses a derived `Containerfile`. The public default remains - broadly available Debian rather than coupling the open-source catalog to one - internal deployment target. - -## 12. Generated artifact reference - -| Path | Purpose | -|---|---| -| `justfiles/anvil/container.just` | Public `anvil-container` entry recipe | -| `.anvil/container/Containerfile` | Generic Linux image definition | -| `.anvil/container/Containerfile.dockerignore` | Restricted image build context | -| `.anvil/container/entrypoint.sh` | Non-root Cargo initialization | -| `.anvil/container/image-id.ps1` | Windows image-ID helper | -| `.anvil/container/image-id.sh` | Unix image-ID helper | -| `.anvil/container/run-in-container.ps1` | Windows driver for Docker Engine in WSL | -| `.anvil/container/run-in-container.sh` | Linux and WSL Docker Engine driver | -| `.anvil/container/customize.ps1` | Optional, not emitted by default; repository or derived-distribution Windows customization, see §8 | -| `.anvil/container/customize.sh` | Optional, not emitted by default; repository or derived-distribution Unix customization, see §8 | -| `.anvil/container/README.md` | Generated user instructions and troubleshooting | -| `justfiles/anvil/runner.just` | Native/container tier dispatch | - -The catalog also emits the user-owned `anvil-runner` region in the -repository-root `Justfile`. - -## 13. References - -- [Overall cargo-anvil design](./README.md) -- [Local recipe design](./local.md) -- [Catalog extensibility](./extensibility.md) -- [Continuous verification](../verification.md) +Declare the mount as required in the Dockerfile, closing the same gap from the build's side: + +```dockerfile +RUN --mount=type=secret,id=feed_token,required=true \ + TOKEN="$(cat /run/secrets/feed_token)" … +``` + +Anything the build *writes* using a secret is ordinary layer content. The default Dockerfile removes +`credentials.toml` and `.netrc` in the same `RUN` layer as the install; a replacement must do the same, or the +credential is baked into a layer that a later deletion cannot remove. + +### 7.2 Anvil-RunEnv + +Each entry is forwarded with `-e ` and is an ordinary environment variable inside the image. The forwarded +names, never their values, are echoed to stderr, because everything executing in the container can read them. + +```powershell +function Anvil-RunEnv { + @{ Env = @{ CARGO_REGISTRIES_INTERNAL_TOKEN = (mint-a-token) } } +} +``` + +### 7.3 Anvil-ResolveImage + +When no local image matches the computed tag, the reference is offered to `Anvil-ResolveImage` before a build starts, +ahead of the `NO_REBUILD` guard, since fetching a published image is not building one. A catalog that publishes images +implements it; without one, the build proceeds. + +```powershell +function Anvil-ResolveImage($tag) { + $remote = "myregistry.azurecr.io/anvil:$($tag.Split(':')[-1])" + az acr login --name myregistry | Out-Null + docker pull $remote | Out-Null + if ($LASTEXITCODE -eq 0) { $remote } +} +``` + +Three properties are load-bearing: + +- **The returned reference is used as-is, never re-tagged locally.** A local tag asserts "built here from these + inputs"; a fetched image only claims it (§4.3). Keeping the registry reference keeps the run honest about origin. +- **The reference is checked for presence before use.** Runs pass `--pull=never`, so a hook reporting an image it had + not actually fetched would otherwise fail later and further from the cause. This is a presence check, not a + verification: `image inspect` proves something carries that reference, not that its contents match the digest the tag + claims. Trusting the publisher is the contract (§4.3). +- **Every resolve failure falls through to a local build**, with the reason printed — including a hook that cannot be + loaded at all, which is why the dot-source sits inside the same `try`. A publisher that has not caught up with a + change must not block the developer who made it. + + That tolerance is scoped to *resolution*. The build and run phases load the same file again to obtain credentials + (§7.1, §7.2) and are deliberately fail-closed, so a `hooks.ps1` that cannot be parsed stops the run there instead — + after resolution has already forgiven it. The two are not in conflict: a hook that yields no image costs nothing, + while a hook that cannot yield its credentials would otherwise produce an image built without them and tag it as if + it had them. + +### 7.4 Trust boundary + +The hook executes on the host, with the invoking user's permissions, before any container isolation exists. Only use +one from a repository or catalog you trust. + +Inside the container everything executes as one user in one mount namespace, so a forwarded credential is readable by +anything the checks execute, including dependency build scripts and procedural macros. Keep the forwarded set narrow +and the tokens short-lived. + +## 8. Customization + +A **repository** changes what its own image contains; a **downstream catalog** (an anvil fork, see +[extensibility.md](./extensibility.md)) changes what every repository it manages receives. Containerized execution is +an ordinary artifact group and uses the same levers as any other. + +| Goal | Mechanism | Owner | +| --- | --- | --- | +| Extra packages, or another base image, in one repository | Add them in the matching gap in `.anvil/container/Dockerfile` (§3) | repository | +| A different base OS, everywhere | `replace_artifact(artifacts::container::dockerfile_base_image().with_body(…))`, usually with `dockerfile_tools()` | catalog | +| Credentials, or a published image | Add `.anvil/container/hooks.ps1`, or ship `artifacts::container::hooks(…)` | either | +| No containerized execution at all | `without_artifact` for each artifact in the group | catalog | + +A repository adds to the Dockerfile without editing anything anvil owns, so pin bumps keep landing (§3). A change that +belongs everywhere is still better made in a catalog, where every consumer gets it. + +Replacing a *region* rather than the whole file is what makes a downstream catalog cheap to keep current: +`dockerfile_setup()` and `dockerfile_entry()` are the contract with the driver and are inherited, so an Azure Linux or +msrustup catalog rewrites the base and tool layers and nothing else. Replacing `dockerfile_setup()` reintroduces the +second tool list the design exists to avoid, and is almost never right. + +**A replacement must keep the ignore file in step.** A region that `COPY`s anything outside `justfiles/anvil/`, +`.anvil/container/` and `rust-toolchain.toml` must also replace `artifacts::container::dockerignore()` (§3), or the +added paths never reach the build context and the build fails on a missing file. + +**Anything extra it copies is digested, provided it lives under `.anvil/container/`.** The hashed set is that whole +directory (§4.1), so an installer script, a config file or a certificate placed beside the Dockerfile is an input: +editing it renames the tag and the next run rebuilds. Content copied from elsewhere in the repository is not, and the +tag will not move when it changes — keep it under `.anvil/container/` and the identity guarantee holds without a +manual `ANVIL_CONTAINER_NO_CACHE=1`. + +`justfiles/anvil/` must contain `.just` recipes and nothing else, which `CatalogBuilder::build` enforces for +catalog-owned files. The reason is legibility rather than identity: the directory is the recipe tree, `just` parses +every file the image copies, and a catalog that hides an installer script there makes the tool set harder to reason +about than one that keeps it in `.anvil/`. Identity is safe either way, because the digest covers every file the build +context admits (§4.1), not only the recipes — a repository that adds a non-recipe file by hand still renames the tag +when it edits it. + +A fork inherits everything else: the recipes, the identity scheme, the cache volumes, the mounts, and the re-entry +guard. A different base OS with a different toolchain source is two region replacements plus one hook. + +## 9. Limitations + +- On ARM64 hosts the `linux/amd64` image is emulated and is substantially slower. +- The first build takes several minutes, installing a toolchain and the entire pinned tool catalog. Later runs reuse + it until an input changes. +- Any edit under `justfiles/anvil/` renames the image and rebuilds it, including edits to a check body that cannot + change what the image contains. Precision here would mean deriving the install closure rather than hashing the files + that express it; until then the digest errs towards rebuilding, because the alternative error — a tag that names + contents the image does not have — is silent (§4.1). +- **The set of hashed inputs is fixed (§4.1) and a fork cannot extend it.** A replacement Dockerfile is itself + hashed, so changing the build recipe always renames the tag — but any *additional* file it copies is outside the + tag. Such a file can change what a build produces while naming a tag that already resolves, and the existing image + is then reused, so the change is never built. A fork that needs extra content should carry it in the Dockerfile + itself, or accept that edits to it require `ANVIL_CONTAINER_NO_CACHE=1`. +- anvil never pushes or promotes an image. It builds one, and will use one a hook fetched (§7.3); publishing belongs + to whoever owns the registry. + +[design]: ./README.md diff --git a/crates/cargo-anvil/docs/design/extensibility.md b/crates/cargo-anvil/docs/design/extensibility.md index 15cf6b53..b0f1f605 100644 --- a/crates/cargo-anvil/docs/design/extensibility.md +++ b/crates/cargo-anvil/docs/design/extensibility.md @@ -466,55 +466,27 @@ the relevant `OwnedFile` (e.g. `checks.just`) wholesale rather than editing indi This is a modest, low-risk refactor: it data-drives the artifact list (§4) without disturbing the engine internals or the template format. -### 6.1 Optional container runner - -The public base catalog emits an explicit `anvil-container` recipe at -`justfiles/anvil/container.just` and its Containerfile, Docker Engine drivers, -content-address helper, and README under `.anvil/container/`. Native -`just anvil-*` execution remains the default. - -A downstream catalog replaces only environment-specific artifacts such as -`artifacts::container::containerfile()` and can add the standard -`artifacts::container::customize_shell(...)` / -`artifacts::container::customize_powershell(...)` files. The public drivers, -image selection, caches, repository mounts, and recipe forwarding remain -unchanged. Static image behavior stays in hashed artifacts; `customize.*` -provides documented runtime orchestration. - -Two placement rules follow from how the container backend derives image -identity and the build context, and both are enforced or documented rather -than left to discovery: - -- **`justfiles/` holds `.just` recipes only.** The image ID hashes `*.just` - files under `justfiles/anvil/`, and the build-context allow-list admits only - those, so any other owned file placed there would be silently dropped from - both. [`CatalogBuilder::build`](#4-the-shape-of-a-catalog) rejects such an - artifact, so a derived catalog fails loudly at construction instead of - shipping a file the container backend ignores. Non-recipe assets belong in a - tool-owned directory such as `.anvil/`. -- **Recipe locations outside the emitted shape need an ignore-file - override.** `Containerfile.dockerignore` is a deny-all allow-list whose - re-inclusions are per-directory (`justfiles/anvil/*.just`, - `justfiles/anvil/checks/*.just`, `justfiles/anvil/groups/*.just`, - `.anvil/container/*`); Docker only descends into a denied directory when - some re-inclusion pattern is prefixed by it. The image ID, by contrast, - hashes recipes recursively. A catalog that adds a recipe directory beyond - those three must also replace `artifacts::container::ignore_file()`; - otherwise the file is hashed into the image ID but never copied, and the - image build fails on the missing import. `.anvil/container/` is leaf-only on - both sides — the image-ID helpers list it one level deep, and - `.anvil/container/*/*` keeps the allow-list to the same depth — so a nested - asset is neither hashed nor copied, and a catalog that wants one must - replace the ignore file and the image-ID helpers together. - -`customize.sh`/`customize.ps1` are trusted host code: the driver sources them -directly into its process before image construction and recipe execution, so -they run with the invoking developer's permissions and outside the container -sandbox. The runtime contract is file-based and ownership-neutral — a regular -repository can commit the standard paths directly, without a derived catalog, -with identical driver behavior. See the [container customization -contract](./containers.md#8-container-customization) for the full -interface, trust boundary, and security responsibilities. +### 6.1 Placement: `justfiles/` holds recipes only + +One placement rule is enforced rather than left to discovery, because violating +it fails in a confusing place. `justfiles/anvil/` may contain `.just` recipes +and nothing else: [`CatalogBuilder::build`](#4-the-shape-of-a-catalog) rejects +any other owned file under that prefix, so a derived catalog fails loudly at +construction instead of shipping a file whose absence is noticed only later. + +The reason is legibility rather than image identity. The image identity hashes +every file under `justfiles/anvil/` recursively, and the build context admits +the whole directory, so a non-recipe file placed there is copied into the image +*and* covered by its tag — editing it renames the image and a rebuild follows. +What the rule protects is the meaning of the directory: it is the recipe tree, +`just` parses everything in it, and a catalog that hides an installer script +there makes the tool set harder to reason about. Non-recipe assets belong in a +tool-owned directory of their own, such as `.anvil/`. + +Containerized execution is itself an ordinary artifact group, customized with +the same `replace_artifact` / `with_artifact` / `without_artifact` levers as +anything else. The artifacts it exposes and the contract each one carries are +specified in [containers.md](./containers.md#8-customization). The public engine contains no environment-specific image, registry, cloud, or credential-provider details. diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md index aa04d90e..5edf3b8f 100644 --- a/crates/cargo-anvil/docs/design/local.md +++ b/crates/cargo-anvil/docs/design/local.md @@ -46,7 +46,7 @@ repo/ │ │ anvil-pr-runtime-analysis, anvil-pr-mutants, │ │ anvil-scheduled-test, …). `anvil-pr-slow` is a │ │ convenience umbrella over the three pr-slow sub-groups. -│ ├── container.just optional container entry recipe (`anvil-container`). +│ ├── container.just containerized execution (`anvil-container`). See containers.md. │ ├── tiers.just tier aggregators (anvil-pr, anvil-scheduled, anvil-full). │ ├── tools.just tool/component/toolchain install + validate-prereqs recipes, │ │ plus the cargo-spellcheck source-deps check and @@ -55,22 +55,20 @@ repo/ │ as plain just variables (rust_nightly, cargo_nextest_version, …). │ Read by recipes via `{{ var }}` interpolation. See §3. │ -└── .anvil/container/ optional non-recipe container assets - ├── Containerfile - ├── Containerfile.dockerignore - ├── README.md - ├── entrypoint.sh - ├── image-id.ps1 - ├── image-id.sh - ├── run-in-container.ps1 - └── run-in-container.sh +└── .anvil/container/ the container image definition + ├── Dockerfile composed: anvil-managed regions, your content between + ├── Dockerfile.dockerignore + └── hooks.ps1 optional; credentials, not emitted by default ``` -The Justfile region is the only file anvil adds to that the user co-owns, and it's -a single `import` line. Generated recipes live inside `justfiles/anvil/`; optional -non-recipe container assets live inside `.anvil/container/`. Generated files in +The Justfile region is not the only file anvil adds to that the user co-owns: the +container `Dockerfile` is composed the same way, from four managed regions with +the repository's own instructions in the gaps between them (see +[containers.md](./containers.md)). Generated recipes live inside `justfiles/anvil/`; +the container image definition lives inside `.anvil/container/`. Generated files in both directories are tool-owned (tracked by full-file checksum in the sidecar -manifest). If the user wants to add project-specific recipes, they add them to +manifest), except the composed `Dockerfile`, whose regions are tracked +individually. If the user wants to add project-specific recipes, they add them to the top-level `Justfile` outside the managed region, or to their own additional imported `.just` files. The alias `anvil := anvil-pr` lives in `mod.just`, not in the user's `Justfile`, so renaming or retargeting the alias is a template update @@ -82,12 +80,13 @@ in `tools.just` (and the per-check/group/tier setup recipes colocated in the sam files) are annotated with `[group("anvil-setup")]`. `just --groups` therefore shows two clean clusters: one for "run checks", one for "install prereqs". -> **Optional container backend.** When a catalog includes the opt-in container -> backend, `justfiles/anvil/container.just` adds the -> `anvil-container ` command and `.anvil/container/` contains its -> non-recipe assets. It runs any recipe below inside a pinned Linux image -> (Linux-on-Windows parity, distro pinning) instead of against the host -> toolchain. The recipe bodies are unchanged; see [containers.md](./containers.md). +> **Containerized execution.** `justfiles/anvil/container.just` adds the +> `anvil-container ` recipe, which runs the given argv inside a pinned +> Linux image instead of against the host toolchain (Linux-on-Windows parity, +> toolchain pinning). Anvil recipes are reached by naming `just` +> (`just anvil-container just anvil-pr`); with no argument it opens a shell. It +> is explicit: the tiers themselves always run natively. +> The recipe bodies are unchanged; see [containers.md](./containers.md). ## 2. Recipe layers @@ -538,12 +537,12 @@ Set `ANVIL_IMPACT=off` in the environment to disable scoping entirely: `anvil-im affected/required, empty for modified). Because `just` runs a recipe's dependencies in the same environment, the guard is honored even when `anvil-impact` fires as a check dependency. This is exactly how the **scheduled** and **full** tiers stay full-workspace: -`anvil-scheduled` / `anvil-full` route through the `_anvil-run` tier router -(`anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off")`), which exports +`anvil-scheduled` / `anvil-full` wrap their private recipe in `_anvil-unscoped` +(`anvil-scheduled: (_anvil-unscoped "scheduled")`), which exports `ANVIL_IMPACT=off` before invoking the private `_anvil-` recipe, so the whole -dependency tree runs unscoped. The export lives in the router because a +dependency tree runs unscoped. The export lives in the wrapper because a dependency-only tier recipe cannot set env for its own dependencies — they run before its -body. The four scheduled *groups* (`anvil-scheduled-test`, …) route the same way, so a +body. The four scheduled *groups* (`anvil-scheduled-test`, …) wrap the same way, so a scheduled group invoked directly is full-workspace too. `ANVIL_IMPACT` is a strict tri-state — `off`, `consume`, or unset. Any other value makes diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md index 8da95c8f..0b071d1d 100644 --- a/crates/cargo-anvil/docs/design/updates.md +++ b/crates/cargo-anvil/docs/design/updates.md @@ -170,9 +170,15 @@ the source of truth and pointing readers at the update workflow: This warning is informational only. Ownership remains path-based in `.anvil.lock`. If a repository edits an owned file, cargo-anvil preserves the edit and reports a proposal instead of overwriting it; `cargo anvil --dry-run` shows that decision. -The ADO `steps/job.yml` extension wrapper is the deliberate exception to the -"do not edit" wording: its header says it is emitted by cargo-anvil and explicitly -invites repository customization because that file is the supported 1ESPT hook. +Two files are deliberate exceptions to the "do not edit" wording, and both carry +a weaker provenance marker instead. The ADO `steps/job.yml` extension wrapper +says it is emitted by cargo-anvil and explicitly invites repository +customization, because that file is the supported 1ESPT hook. The container +`Dockerfile` and its ignore file (`.anvil/container/Dockerfile*`) are marked +"Managed by cargo-anvil." for the same reason: a repository that needs a +different base or extra packages edits them in place, and drift handling +preserves the edit (see [containers.md](./containers.md#8-customization)). A +"DO NOT EDIT" marker would contradict the customization path both are for. ## 3. Managed regions diff --git a/crates/cargo-anvil/docs/implementation.md b/crates/cargo-anvil/docs/implementation.md index 03bb2bcb..79d9e5c3 100644 --- a/crates/cargo-anvil/docs/implementation.md +++ b/crates/cargo-anvil/docs/implementation.md @@ -93,11 +93,10 @@ only affects local runs. `ANVIL_IMPACT` has three modes — producer (unset/compute), `consume` (read a downloaded cache), and `off` (full workspace). The critical invariant is that the mode must be established in the shell **before** `just` evaluates a recipe's dependencies, because scoped checks take a -`: anvil-impact` dependency. `runner.just`'s `_anvil-run` therefore exports `ANVIL_IMPACT` -before re-invoking the private tier, and it threads the same decision through container dispatch -(`run-in-container.*`) without recursing. `tiers.just` routes scheduled/full tiers through an -`ANVIL_IMPACT=off` wrapper so those tiers are never scoped. `tier_routing.rs` exercises the -off-before-dependencies ordering and the container/native paths. +`: anvil-impact` dependency. `helpers.just`'s `_anvil-unscoped` therefore exports +`ANVIL_IMPACT=off` before re-invoking the private tier or group, so those recipes are +never scoped. `container.just` sets the same variable through the engine's `-e` when a +containerized run needs it. `justfile.rs` asserts the off-before-dependencies ordering. ### Cross-backend CI handoff diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index e68fcdb9..b26e7bb0 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -1,845 +1,1017 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! The optional local container backend. +//! Containerized execution: the `anvil-container` recipe and the image it runs. //! -//! The base catalog emits a public Docker Engine implementation. Downstream catalogs -//! replace only environment-specific artifacts such as the Containerfile and -//! add an optional `customize.sh`/`customize.ps1` runtime customization file. - -use crate::catalog::Artifact; +//! The recipe drives the engine and computes the image identity; the Dockerfile +//! defines what the image contains; its build-context ignore file decides what +//! reaches the build at all, and the two must be replaced together. There is no +//! configuration file: whether the group is emitted at all is a catalog +//! decision, and the only host-specific value — which engine to call — is an +//! environment variable read by the recipe at run time. +//! +//! # Why the Dockerfile is composed, not owned +//! +//! The Dockerfile is a **user-composed file with managed regions**, not a +//! wholly-owned file. An owned file that invites in-place edits fails silently +//! here: anvil preserves the edit and writes its own version to +//! `.anvil-proposed`, with no three-way merge and no recorded ancestor, so a +//! repository that edits it once keeps building on the base digest and the four +//! tool pins frozen at that moment — while `anvil-container-tag` keeps +//! resolving, because the tag hashes *their* file. The identity scheme works +//! perfectly and still names a stale image. +//! +//! Splitting anvil's content into regions does not make it read-only: the same +//! ownership rules apply to a region body as to a file, and anvil never +//! overwrites repository content. What it removes is the *reason* to edit: each +//! gap between the regions is the correct home for one class of addition, +//! defined by what must already be true at that point in the build. +//! +//! | Gap | Runs | Exists for | +//! | --- | --- | --- | +//! | after [`dockerfile_base_image`] | before `FROM` | re-declaring `ARG BASE_IMAGE` to build on your own base | +//! | after [`dockerfile_base`] | before the first download | root CA, proxy, internal apt mirror | +//! | after [`dockerfile_tools`] | after the toolchain, before `anvil-setup` | libraries a catalog tool needs to *compile* | +//! | after [`dockerfile_setup`] | after the catalog is installed | what the repository's own checks need at run time | +//! +//! The base image is the case that most needed a gap of its own: it is the +//! setting a repository is likeliest to want, and leaving it inside a region +//! would have made overriding it mean editing anvil's content — freezing every +//! pin in the file to buy one substitution. +//! +//! A downstream catalog customizes by replacing individual regions, and +//! inherits the rest: +//! +//! - [`dockerfile_base_image`] / [`dockerfile_tools`] plus +//! [`Artifact::with_body`] to build on a different base OS or install the +//! toolchain from a different source. [`dockerfile_setup`] and +//! [`dockerfile_entry`] are the contract with the recipe and are rarely +//! replaced. +//! - [`dockerignore`] alongside them when the replacement copies more of the +//! tree, or the added paths never reach the build context. +//! - [`hooks`] to supply credentials, or to resolve a published image through +//! `Anvil-ResolveImage`. The recipe loads the file when it is present, +//! regardless of who put it there. + +use crate::catalog::{Artifact, HostSelector, RegionId, RegionSpec}; +use crate::region::CommentSyntax; const RECIPE: &str = include_str!("../../../templates/justfiles/anvil/container.just"); -const CONTAINERFILE: &str = include_str!("../../../templates/anvil/container/Containerfile"); -const IGNORE: &str = include_str!("../../../templates/anvil/container/Containerfile.dockerignore"); -const ENTRYPOINT: &str = include_str!("../../../templates/anvil/container/entrypoint.sh"); -const IMAGE_ID: &str = include_str!("../../../templates/anvil/container/image-id.ps1"); -const SHELL_IMAGE_ID: &str = include_str!("../../../templates/anvil/container/image-id.sh"); -const SHELL_DRIVER: &str = include_str!("../../../templates/anvil/container/run-in-container.sh"); -const POWERSHELL_DRIVER: &str = include_str!("../../../templates/anvil/container/run-in-container.ps1"); -const README: &str = include_str!("../../../templates/anvil/container/README.md"); +const DOCKERIGNORE: &str = include_str!("../../../templates/anvil/container/Dockerfile.dockerignore"); + +/// Seeded into the Dockerfile when the file does not exist, and never +/// reconciled afterwards — it is the part of the file anvil cannot own. +/// +/// `# syntax=docker/dockerfile:1` pins the `BuildKit` frontend, and `BuildKit` +/// honors the directive only as the very first line, before any comment. A +/// region's opening sentinel *is* a comment, so the directive cannot live inside +/// one without being silently demoted, dropping the build to the default +/// frontend with nothing failing to say so. The copyright notice follows it as +/// ordinary repository content, editable like anything else in a gap. +pub(crate) const DOCKERFILE_HEADER: &str = include_str!("../../../templates/anvil/container/Dockerfile.header"); + +const DOCKERFILE_BASE_IMAGE: &str = include_str!("../../../templates/anvil/container/Dockerfile.baseimage.region"); +const DOCKERFILE_BASE: &str = include_str!("../../../templates/anvil/container/Dockerfile.base.region"); +const DOCKERFILE_TOOLS: &str = include_str!("../../../templates/anvil/container/Dockerfile.tools.region"); +const DOCKERFILE_SETUP: &str = include_str!("../../../templates/anvil/container/Dockerfile.setup.region"); +const DOCKERFILE_ENTRY: &str = include_str!("../../../templates/anvil/container/Dockerfile.entry.region"); const RECIPE_PATH: &str = "justfiles/anvil/container.just"; -const CONTAINERFILE_PATH: &str = ".anvil/container/Containerfile"; -const IGNORE_PATH: &str = ".anvil/container/Containerfile.dockerignore"; -const ENTRYPOINT_PATH: &str = ".anvil/container/entrypoint.sh"; -const IMAGE_ID_PATH: &str = ".anvil/container/image-id.ps1"; -const SHELL_IMAGE_ID_PATH: &str = ".anvil/container/image-id.sh"; -const SHELL_DRIVER_PATH: &str = ".anvil/container/run-in-container.sh"; -const POWERSHELL_DRIVER_PATH: &str = ".anvil/container/run-in-container.ps1"; -const README_PATH: &str = ".anvil/container/README.md"; -const CUSTOMIZE_SHELL_PATH: &str = ".anvil/container/customize.sh"; -const CUSTOMIZE_POWERSHELL_PATH: &str = ".anvil/container/customize.ps1"; - -/// The full public container artifact group. + +/// The composed Dockerfile the managed regions are spliced into. +pub(crate) const DOCKERFILE_PATH: &str = ".anvil/container/Dockerfile"; + +const DOCKERIGNORE_PATH: &str = ".anvil/container/Dockerfile.dockerignore"; + +/// The region ids anvil owns inside [`DOCKERFILE_PATH`], in the order a valid +/// Dockerfile must carry them. +/// +/// Order is load-bearing in a way no other managed-region host is: the base +/// image argument must precede the `FROM` that consumes it, `FROM` must precede +/// every instruction, the toolchain must exist before `anvil-setup` runs, and +/// `WORKDIR`/`CMD` close the file. The engine checks the on-disk sequence +/// against this list and refuses rather than emitting a Dockerfile that is +/// silently wrong. +pub(crate) const DOCKERFILE_REGION_ORDER: &[&str] = &[ + "anvil-container-base-image", + "anvil-container-base", + "anvil-container-tools", + "anvil-container-setup", + "anvil-container-entry", +]; + +/// The path the recipe loads credentials from, when a file is present there. +pub const HOOKS_PATH: &str = ".anvil/container/hooks.ps1"; + +/// The full container artifact group. #[must_use] pub fn all() -> Vec { vec![ recipe(), - containerfile(), - ignore_file(), - entrypoint(), - image_id(), - shell_image_id(), - shell_driver(), - powershell_driver(), - readme(), + dockerignore(), + dockerfile_base_image(), + dockerfile_base(), + dockerfile_tools(), + dockerfile_setup(), + dockerfile_entry(), ] } -/// The explicit `anvil-container` recipe. +/// The `anvil-container` recipe and its private helpers. #[must_use] pub fn recipe() -> Artifact { Artifact::owned_file(RECIPE_PATH, RECIPE) } -/// The public rustup/crates.io Containerfile. -#[must_use] -pub fn containerfile() -> Artifact { - Artifact::owned_file(CONTAINERFILE_PATH, CONTAINERFILE) +fn dockerfile_region(id: &'static str, body: &'static str) -> Artifact { + Artifact::region(RegionSpec { + host: HostSelector::Path(DOCKERFILE_PATH.to_owned()), + id: RegionId::new(id), + body: body.to_owned(), + syntax: CommentSyntax::Hash, + }) } -/// The restricted Docker build-context ignore file. -#[must_use] -pub fn ignore_file() -> Artifact { - Artifact::owned_file(IGNORE_PATH, IGNORE) -} - -/// The generic non-root Cargo metadata entry point. -#[must_use] -pub fn entrypoint() -> Artifact { - Artifact::owned_file(ENTRYPOINT_PATH, ENTRYPOINT) -} - -/// The cross-platform content-addressed image-id helper. +/// The default base image, digest-pinned, alone in its own region. +/// +/// Separate from [`dockerfile_base`] so the gap between them is a place to +/// override it: a second `ARG BASE_IMAGE=…` there wins over this default, and +/// `FROM` in the next region consumes the repository's value. That is what lets +/// a repository choose its own base **without** editing anvil's content, which +/// would otherwise freeze every pin in the file at the moment of the edit. #[must_use] -pub fn image_id() -> Artifact { - Artifact::owned_file(IMAGE_ID_PATH, IMAGE_ID) +pub fn dockerfile_base_image() -> Artifact { + dockerfile_region("anvil-container-base-image", DOCKERFILE_BASE_IMAGE) } -/// The Bash content-addressed image-id helper. +/// `FROM`, the four download pins, and the environment every later region +/// depends on. #[must_use] -pub fn shell_image_id() -> Artifact { - Artifact::owned_file(SHELL_IMAGE_ID_PATH, SHELL_IMAGE_ID) +pub fn dockerfile_base() -> Artifact { + dockerfile_region("anvil-container-base", DOCKERFILE_BASE) } -/// The Linux/WSL Docker Engine driver. +/// The toolchain layer: the system packages, then `pwsh`, `just`, `rustup` and +/// `cargo-binstall` installed against published checksums. +/// +/// This is the region a catalog on a different package ecosystem replaces — +/// `tdnf` rather than `apt-get`, an internal toolchain source rather than +/// `rustup`. #[must_use] -pub fn shell_driver() -> Artifact { - Artifact::owned_file(SHELL_DRIVER_PATH, SHELL_DRIVER) +pub fn dockerfile_tools() -> Artifact { + dockerfile_region("anvil-container-tools", DOCKERFILE_TOOLS) } -/// The Windows-to-WSL Docker Engine driver. +/// The catalog install: copies the generated recipe tree and runs +/// `just anvil-setup`, so the image installs exactly what the checks pin. +/// +/// Rarely replaced. Installing by running the same recipe the checks use is +/// what makes "the image has the right tools" true by construction; a +/// replacement reintroduces the second tool list this exists to avoid. #[must_use] -pub fn powershell_driver() -> Artifact { - Artifact::owned_file(POWERSHELL_DRIVER_PATH, POWERSHELL_DRIVER) +pub fn dockerfile_setup() -> Artifact { + dockerfile_region("anvil-container-setup", DOCKERFILE_SETUP) } -/// User-facing prerequisites and troubleshooting. +/// The entry contract: `ANVIL_IN_CONTAINER`, the mount point the repository is +/// bind-mounted at, and the default command. +/// +/// Every line here is a contract with the recipe rather than an opinion about +/// the image, so replacing it breaks the driver. #[must_use] -pub fn readme() -> Artifact { - Artifact::owned_file(README_PATH, README) +pub fn dockerfile_entry() -> Artifact { + dockerfile_region("anvil-container-entry", DOCKERFILE_ENTRY) } -/// Add a downstream shell customization file (`customize.sh`). +/// The build-context ignore file for the composed Dockerfile. /// -/// The public catalog does not emit this file. A regular repository can add -/// the standard path directly; a derived distribution can package the same -/// file through this constructor. The driver loads it whenever present, -/// regardless of provenance. See -/// [the container customization contract](../../../docs/design/containers.md) -/// for the runtime interface. +/// `BuildKit` reads `.dockerignore` in preference to a root +/// `.dockerignore`, so the build context is scoped without the repository +/// having to own a root ignore file. A catalog that replaces a region with one +/// that copies more of the tree must replace this too. #[must_use] -pub fn customize_shell(body: impl Into) -> Artifact { - Artifact::owned_file(CUSTOMIZE_SHELL_PATH, body) +pub fn dockerignore() -> Artifact { + Artifact::owned_file(DOCKERIGNORE_PATH, DOCKERIGNORE) } -/// Add a downstream `PowerShell` customization file (`customize.ps1`). +/// Add a credential hook at [`HOOKS_PATH`]. +/// +/// The public catalog emits no hook: crates.io needs no credentials, and an +/// empty script would be one more generated file to review. A downstream +/// catalog adds one with [`crate::CatalogBuilder::with_artifact`]; a single +/// repository can write the same path by hand. The recipe loads it either way. /// -/// See [`customize_shell`] for the shared contract and provenance-neutral -/// loading behavior. +/// The script may define any of three functions, and is dot-sourced before the +/// phase that needs it. Each is named for what it supplies rather than for when +/// it runs, because all three exist to provide credentials or an image, not as +/// general-purpose extension points: +/// +/// - `Anvil-BuildSecrets` returns `@{ Secrets = @{ = } }`. Each entry +/// becomes a `BuildKit` `--secret id=`, passed by environment variable +/// name so the value never reaches a process argument, and never a layer. +/// - `Anvil-RunEnv` returns `@{ Env = @{ = } }`. Each entry is +/// forwarded into the container by name, for the same reason. +/// - `Anvil-ResolveImage` takes the computed reference and returns one to use +/// instead, or nothing. It is how a repository fetches a published image +/// rather than building locally. +/// +/// The two credential phases are fail-closed: an empty value, a return with no +/// entries, a throw, or a script that cannot even be loaded stops the run. A +/// build that silently proceeded without its credential would install a reduced +/// tool set and then be tagged with the same content hash a credentialed build +/// produces, so every later run would reuse the broken image. +/// +/// `Anvil-ResolveImage` is the opposite, and deliberately so: every failure -- +/// including a hook that fails to load -- falls through to a local build, which +/// is slower but always correct. A publisher that has not caught up with a +/// change must not block the developer who made it. +/// +/// The file's *content* is part of the image identity, since it decides what the +/// build installs. Its *output* deliberately is not: a credential must never +/// influence a tag. #[must_use] -pub fn customize_powershell(body: impl Into) -> Artifact { - Artifact::owned_file(CUSTOMIZE_POWERSHELL_PATH, body) +pub fn hooks(body: impl Into) -> Artifact { + Artifact::owned_file(HOOKS_PATH, body) } #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::collections::{BTreeMap, BTreeSet}; - use std::path::Path; - use std::process::Command; + use super::*; - use tempfile::TempDir; + fn paths(artifacts: &[Artifact]) -> Vec<&str> { + artifacts + .iter() + .map(|artifact| match artifact { + Artifact::OwnedFile(spec) => spec.path, + Artifact::Region(_) => panic!("expected an owned file"), + }) + .collect() + } - use super::*; - use crate::anvil::artifacts::justfile::dependency_recipe_sources; + fn region_ids(artifacts: &[Artifact]) -> Vec<&str> { + artifacts + .iter() + .filter_map(|artifact| match artifact { + Artifact::Region(spec) => Some(spec.id.as_str()), + Artifact::OwnedFile(_) => None, + }) + .collect() + } - fn write(path: &Path, body: impl AsRef<[u8]>) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("test path parent must be creatable"); + /// Every region body, in the order the engine enforces. + const REGION_BODIES: [&str; 5] = [ + DOCKERFILE_BASE_IMAGE, + DOCKERFILE_BASE, + DOCKERFILE_TOOLS, + DOCKERFILE_SETUP, + DOCKERFILE_ENTRY, + ]; + + /// The Dockerfile as a fresh repository first receives it: the seeded + /// directive followed by every region body in order. + fn composed_dockerfile() -> String { + let mut out = DOCKERFILE_HEADER.to_owned(); + for body in REGION_BODIES { + out.push_str(body); } - std::fs::write(path, body).expect("test file must be writable"); + out } - fn reaches_aprz(recipe: &str, graph: &BTreeMap>, visiting: &mut BTreeSet) -> bool { - if recipe == "anvil-aprz" { - return true; - } - if !visiting.insert(recipe.to_owned()) { - return false; + #[test] + fn group_is_two_owned_files_and_five_dockerfile_regions() { + let all = all(); + let owned: Vec<_> = all + .iter() + .filter(|artifact| matches!(artifact, Artifact::OwnedFile(_))) + .cloned() + .collect(); + assert_eq!(paths(&owned), [RECIPE_PATH, DOCKERIGNORE_PATH]); + assert_eq!(region_ids(&all), DOCKERFILE_REGION_ORDER); + } + + #[test] + fn every_dockerfile_region_targets_the_one_composed_host() { + for artifact in all() { + if let Artifact::Region(spec) = artifact { + assert_eq!(spec.host, HostSelector::Path(DOCKERFILE_PATH.to_owned())); + assert_eq!(spec.syntax, CommentSyntax::Hash); + } } - let reaches = graph - .get(recipe) - .is_some_and(|dependencies| dependencies.iter().any(|dependency| reaches_aprz(dependency, graph, visiting))); - visiting.remove(recipe); - reaches } - fn run_image_id_command(repo: &Path, command: &str, args: &[&str]) -> String { - run_image_id_command_with_base(repo, command, args, None) + #[test] + fn the_syntax_directive_leads_the_seeded_header() { + // BuildKit honors the parser directive only when nothing precedes it. + // If this ever moves, the frontend pin stops applying and nothing + // fails to say so. + assert_eq!( + DOCKERFILE_HEADER.lines().next(), + Some("# syntax=docker/dockerfile:1"), + "the parser directive must be the first line of the seeded header" + ); } - fn run_image_id_command_with_base(repo: &Path, command: &str, args: &[&str], base_image: Option<&str>) -> String { - let mut command = Command::new(command); - command.args(args).current_dir(repo).env_remove("ANVIL_CONTAINER_BASE_IMAGE"); - if let Some(base_image) = base_image { - command.env("ANVIL_CONTAINER_BASE_IMAGE", base_image); + #[test] + fn no_region_body_carries_the_parser_directive() { + // Inside a region the directive would sit below an opening sentinel -- + // a comment -- and be silently demoted. Mentioning it in prose is fine; + // what must not appear is a line that *is* the directive. + for body in REGION_BODIES { + assert!( + !body.lines().any(|line| line.starts_with("# syntax=")), + "a region body must not carry the parser directive as a line" + ); } - let output = command - .output() - .expect("native shell must be available for the container image-id helper"); - assert!( - output.status.success(), - "image-id helper failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("image ID must be UTF-8").trim().to_owned() } - #[cfg(windows)] - fn run_image_id(repo: &Path) -> String { - run_image_id_command(repo, "pwsh", &["-NoProfile", "-File", ".anvil/container/image-id.ps1"]) + #[test] + fn image_installs_the_generated_toolset() { + // The image must not carry a second tool list: it installs by running + // the same recipe the checks use, from the same generated pins. + let composed = composed_dockerfile(); + assert!(composed.contains("just anvil-setup binstall")); + assert!(composed.contains("COPY justfiles")); + assert!(composed.contains("COPY rust-toolchain.toml")); + // The re-entry guard the recipe relies on to avoid nesting. + assert!(composed.contains("ENV ANVIL_IN_CONTAINER=1")); } - #[cfg(unix)] - fn run_image_id(repo: &Path) -> String { - run_image_id_command(repo, "bash", &[".anvil/container/image-id.sh"]) + #[test] + fn the_composed_order_is_a_buildable_dockerfile() { + // The region order is not cosmetic: everything depends on FROM, and + // the catalog install needs the toolchain that precedes it. + let composed = composed_dockerfile(); + let at = |needle: &str| { + composed + .find(needle) + .unwrap_or_else(|| panic!("missing from composed Dockerfile: {needle}")) + }; + assert!(at("ARG BASE_IMAGE=") < at("FROM ${BASE_IMAGE}")); + assert!(at("FROM ${BASE_IMAGE}") < at("RUN apt-get update")); + assert!(at("RUN apt-get update") < at("just anvil-setup binstall")); + assert!(at("just anvil-setup binstall") < at("WORKDIR /workspace")); } - fn write_image_id_fixture(root: &Path) { - write(&root.join("rust-toolchain.toml"), "channel = \"1.93\"\n"); - write(&root.join("justfiles/anvil/versions.just"), "tool_version := \"1\"\n"); - write( - &root.join(CONTAINERFILE_PATH), - "ARG BASE_IMAGE=example.invalid/base@sha256:0000000000000000000000000000000000000000000000000000000000000000\nFROM ${BASE_IMAGE}\n", + #[test] + fn the_base_image_is_overridable_without_editing_a_region() { + // The whole point of giving the default its own region: a repository + // that wants another base re-declares the argument in the gap that + // follows, where a later declaration wins, instead of editing anvil's + // content and freezing every other pin in the file. + assert!( + DOCKERFILE_BASE_IMAGE.contains("ARG BASE_IMAGE="), + "the default must live in its own region" + ); + assert!( + !DOCKERFILE_BASE_IMAGE.contains("FROM "), + "FROM must not share the region, or there is no gap to override in" + ); + assert!( + DOCKERFILE_BASE.starts_with("FROM ${BASE_IMAGE}"), + "the consuming FROM must open the next region: {}", + DOCKERFILE_BASE.lines().next().unwrap_or_default() ); - write(&root.join(IMAGE_ID_PATH), IMAGE_ID); - write(&root.join(SHELL_IMAGE_ID_PATH), SHELL_IMAGE_ID); } #[test] - fn public_group_has_the_expected_files() { - let paths: Vec<&str> = all() - .iter() - .map(|artifact| match artifact { - Artifact::OwnedFile(spec) => spec.path, - Artifact::Region(_) => panic!("container group must contain owned files only"), - }) - .collect(); + fn base_image_is_digest_pinned() { + // A floating base tag can change underneath an identity hash that + // claims to name fixed content, which would make every cached image a + // potential lie. + let base = DOCKERFILE_BASE_IMAGE + .lines() + .find(|line| line.starts_with("ARG BASE_IMAGE=")) + .expect("the base-image region must declare a default BASE_IMAGE"); + assert!(base.contains("@sha256:"), "BASE_IMAGE must be digest-pinned: {base}"); + } + + #[test] + fn the_scaffold_carries_no_instruction_that_could_go_stale() { + // Anything anvil owns outside a region can never be corrected on a + // repository that has already generated the file, because the scaffold + // is written once and never reconciled. The parser directive has to pay + // that price; nothing that pins or installs anything may join it. + let mut lines = DOCKERFILE_HEADER.lines(); assert_eq!( - paths, - [ - RECIPE_PATH, - CONTAINERFILE_PATH, - IGNORE_PATH, - ENTRYPOINT_PATH, - IMAGE_ID_PATH, - SHELL_IMAGE_ID_PATH, - SHELL_DRIVER_PATH, - POWERSHELL_DRIVER_PATH, - README_PATH - ] + lines.next(), + Some("# syntax=docker/dockerfile:1"), + "the parser directive must lead the scaffold" + ); + assert!( + lines.all(|line| line.is_empty() || line.starts_with('#')), + "the scaffold must carry no build instruction: {DOCKERFILE_HEADER}" ); } #[test] - fn containerfile_installs_the_generated_toolset() { - assert!(CONTAINERFILE.contains("just anvil-setup")); - assert!(CONTAINERFILE.contains("COPY . .")); - assert!(IGNORE.contains("!.anvil/container/*")); - assert!(IGNORE.contains("!justfiles/anvil/checks/*.just")); - assert!(CONTAINERFILE.contains("anvil_runner := \\\"native\\\"")); - assert!(CONTAINERFILE.contains("requires rust-toolchain.toml")); - assert!(CONTAINERFILE.contains("anvil-container-entrypoint")); - } - - /// The Docker build-context ignore evaluation, ported from - /// `MatchesOrParentMatches` in `moby/patternmatcher`: patterns apply in - /// order and the last match wins, an `!` pattern applies only while the - /// candidate is ignored (and a plain pattern only while it is not), and - /// every pattern is tested against the candidate path *and each of its - /// parent directories*. Blank and `#` lines are dropped, as - /// `ignorefile::ReadAll` drops them. - /// - /// Only the pattern vocabulary the template actually uses is modeled; - /// anything else panics rather than silently matching differently from - /// Docker. - struct DockerIgnore { - patterns: Vec<(bool, Vec)>, - } - - impl DockerIgnore { - fn parse(text: &str) -> Self { - let mut patterns = Vec::new(); - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (exclusion, body) = line.strip_prefix('!').map_or((false, line), |rest| (true, rest)); - assert!(!body.is_empty(), "illegal exclusion pattern: \"!\""); - let segments: Vec = body.split('/').map(str::to_owned).collect(); - for segment in &segments { - assert!( - !segment.contains("**") || (segment == "**" && segments.len() == 1), - "only a bare `**` is modeled; `{body}` needs Docker's full regex translation" - ); - assert!( - !segment.contains(['[', ']', '\\']), - "character classes and escapes are not modeled: {body}" - ); - } - patterns.push((exclusion, segments)); - } - Self { patterns } - } - - /// Glob one path segment. `*` and `?` never cross a separator, which - /// is already guaranteed because the caller splits on `/`. - fn segment_matches(pattern: &str, segment: &str) -> bool { - let pattern: Vec = pattern.chars().collect(); - let segment: Vec = segment.chars().collect(); - let (mut p, mut s) = (0, 0); - let (mut star, mut retry) = (None, 0); - while s < segment.len() { - if p < pattern.len() && (pattern[p] == '?' || pattern[p] == segment[s]) { - p += 1; - s += 1; - } else if p < pattern.len() && pattern[p] == '*' { - star = Some(p); - p += 1; - retry = s; - } else if let Some(index) = star { - p = index + 1; - retry += 1; - s = retry; - } else { - return false; - } - } - pattern[p..].iter().all(|character| *character == '*') - } - - fn pattern_matches(pattern: &[String], path: &str) -> bool { - if pattern.len() == 1 && pattern[0] == "**" { - return true; - } - let candidate: Vec<&str> = path.split('/').collect(); - pattern.len() == candidate.len() - && pattern - .iter() - .zip(candidate) - .all(|(pattern, segment)| Self::segment_matches(pattern, segment)) - } - - fn is_ignored(&self, path: &str) -> bool { - let segments: Vec<&str> = path.split('/').collect(); - let parents: Vec = (1..segments.len()).map(|end| segments[..end].join("/")).collect(); - let mut ignored = false; - for (exclusion, pattern) in &self.patterns { - if *exclusion != ignored { - continue; - } - if Self::pattern_matches(pattern, path) || parents.iter().any(|parent| Self::pattern_matches(pattern, parent)) { - ignored = !exclusion; - } - } - ignored - } + fn build_context_admits_only_what_the_image_copies() { + assert!(DOCKERIGNORE.contains("!justfiles")); + assert!(DOCKERIGNORE.contains("!rust-toolchain.toml")); } - /// Every owned file the built-in catalog places in one of the two trees - /// the build context admits: the recipe tree, and the container - /// directory itself. `.anvil/` at large is the general home for - /// tool-owned non-recipe assets, and the allow-list deliberately admits - /// only `.anvil/container/*` from it, so scoping to that directory keeps - /// this guard exhaustive for image inputs without making a future - /// `.anvil/` artifact fail a test it has no bearing on. - fn catalog_context_inputs() -> Vec<&'static str> { - crate::anvil::artifacts::anvil_artifacts() - .into_iter() - .filter_map(|artifact| match artifact { - Artifact::OwnedFile(spec) if spec.path.starts_with("justfiles/") || spec.path.starts_with(".anvil/container/") => { - Some(spec.path) - } - _ => None, - }) - .collect() + #[test] + fn recipe_has_no_generation_time_placeholders() { + // Every value is a literal or resolved at run time; nothing is + // substituted at emit time, so the recipe cannot drift from a + // configuration file. + assert!(!RECIPE.contains("__"), "the recipe must not carry rendering placeholders"); + assert!(!RECIPE.contains("anvil.toml")); } #[test] - fn ignore_file_admits_only_image_inputs_into_the_build_context() { - let ignore = DockerIgnore::parse(IGNORE); - - // Driving the include set from the catalog rather than a literal list - // makes this the regression guard for the second placement rule in - // extensibility.md §6.1: an owned file placed outside an admitted glob - // fails here instead of inside a real `docker build`. - let mut included: Vec<&str> = vec![ - // Repository-owned rather than catalog-owned, but a required - // image input all the same. - "rust-toolchain.toml", - ]; - included.extend(catalog_context_inputs()); - assert!( - included.contains(&CONTAINERFILE_PATH) && included.contains(&"justfiles/anvil/checks/clippy.just"), - "the catalog-derived include set must cover both admitted trees" - ); - // The entry recipe is not image content, but `mod.just` imports it - // unconditionally, so `just anvil-setup` needs it present. - assert!(included.contains(&RECIPE_PATH), "the entry recipe must reach the build context"); - for included in included { - assert!(!ignore.is_ignored(included), "{included} must reach the build context"); + fn recipe_exposes_the_documented_surface() { + for expected in [ + "anvil-container *command:", + "anvil-container-tag:", + "anvil-container-status:", + "anvil-container-down:", + ] { + assert!(RECIPE.contains(expected), "missing recipe: {expected}"); } + // A cache-defeating rebuild is `ANVIL_CONTAINER_NO_CACHE=1`, which is + // already public and composes with the other guards. A recipe wrapping + // one variable assignment would be a second way to say the same thing. + assert!(!RECIPE.contains("anvil-container-rebuild:")); + } - for excluded in [ - // Trusted host orchestration: never image content, even though - // the surrounding directory is admitted. - CUSTOMIZE_SHELL_PATH, - CUSTOMIZE_POWERSHELL_PATH, - // The container directory is admitted one level deep only, which - // is exactly the depth the image-ID helpers list. Under the - // strictest reading of Docker's parent testing a subdirectory - // matches `!.anvil/container/*` in its own right, so the - // allow-list denies deeper paths explicitly. - ".anvil/container/nested/asset.txt", - ".anvil/container/nested/customize.sh", - ".anvil/container/nested/deeper/asset.txt", - // Assets stranded at the pre-move location, including a - // hand-authored customization file, must not re-enter through a - // directory-level re-inclusion. - "justfiles/anvil/container/customize.sh", - "justfiles/anvil/container/customize.ps1", - "justfiles/anvil/container/Containerfile", - "justfiles/anvil/container/run-in-container.sh", - // Everything else stays out: the working tree is bind-mounted at - // run time rather than baked into the image. - "Cargo.toml", - "crates/example/src/lib.rs", - "justfiles/basic.just", - "justfiles/anvil/notes.md", - ".anvil.lock", - ".anvil/other/asset.txt", - ".git/config", + #[test] + fn every_public_recipe_lists_with_a_whole_sentence() { + // `just --list` takes the last comment line before the attributes as + // the description, so a recipe whose rationale paragraph ends mid + // sentence lists as a fragment -- "# toolchain that would otherwise + // mask the image's own." The generated tree is the discovery surface, + // so each public recipe repeats a one-line summary immediately above + // its attributes. + for recipe in [ + "anvil-container *command:", + "anvil-container-tag:", + "anvil-container-status:", + "anvil-container-down:", ] { - assert!(ignore.is_ignored(excluded), "{excluded} must not reach the build context"); + let at = RECIPE.find(recipe).expect("recipe must exist"); + let description = RECIPE[..at] + .lines() + .rev() + .find(|line| line.trim_start().starts_with('#')) + .expect("a public recipe must carry a description") + .trim_start() + .trim_start_matches('#') + .trim(); + assert!( + description.ends_with('.') && description.starts_with(|c: char| c.is_uppercase()), + "{recipe} lists as a fragment: {description:?}" + ); } } #[test] - fn ignore_file_excludes_customize_source_from_the_build_context() { - // Order is load-bearing: the customize and nested re-exclusions only - // win because they come after the directory-wide re-inclusion. - let include_position = IGNORE - .find("!.anvil/container/*") - .expect("the container directory inclusion is asserted above"); - let shell_exclude_position = IGNORE - .find("\n.anvil/container/customize.sh") - .expect("customize.sh must be excluded from the build context"); - let powershell_exclude_position = IGNORE - .find("\n.anvil/container/customize.ps1") - .expect("customize.ps1 must be excluded from the build context"); - let nested_exclude_position = IGNORE - .find("\n.anvil/container/*/*") - .expect("nested container assets must be excluded from the build context"); + fn the_tag_is_computed_in_exactly_one_place() { + // `anvil-container-tag` is public so a publisher can name the image it + // is about to build. That only holds while it is the same computation + // the consumer performs: a second copy of the hash would let the two + // drift and turn a published tag into a claim nobody checks. + assert_eq!( + RECIPE.matches("TransformFinalBlock").count(), + 1, + "the content hash must be computed once, by anvil-container-tag" + ); + // Bytes, not decoded text: ReadAllText replaces every invalid sequence + // with U+FFFD, so two files differing only in invalid bytes would hash + // alike while COPY put their real bytes in the image. + assert!(RECIPE.contains("[System.IO.File]::ReadAllBytes($path)")); assert!( - include_position < shell_exclude_position - && include_position < powershell_exclude_position - && include_position < nested_exclude_position, - "the re-exclusion must come after the broad directory inclusion so it wins" + RECIPE.contains("$image = & '{{ replace(just_executable(), \"'\", \"''\") }}' anvil-container-tag"), + "the resolver must ask anvil-container-tag rather than recompute" ); } #[test] - fn drivers_use_docker_and_content_addressing() { - assert!(RECIPE.contains("replace(recipe, \"'\", \"''\")")); - for (driver, customization_source, build_command) in [ - (SHELL_DRIVER, "source \"$customize_script\"", "docker build \\"), - (POWERSHELL_DRIVER, ". $customizeScript", "& wsl -e docker build"), - ] { - assert!(driver.contains("docker")); - assert!(driver.contains("ANVIL_CONTAINER_NO_REBUILD")); - assert!(driver.contains("ANVIL_CONTAINER_BASE_IMAGE")); - assert!(driver.contains("ANVIL_CONTAINER_IMAGE")); - assert!(driver.contains("ANVIL_IN_CONTAINER")); - assert!(driver.contains("auth token --hostname github.com")); - assert!(driver.contains("gh auth login --hostname github.com")); - assert!(driver.contains("/run/secrets/anvil-github-token")); - assert!(!driver.contains("anvil-pr-fast")); - assert!(driver.contains("anvil-scheduled-advisories")); - assert!(driver.contains("PR_TITLE")); - assert!(driver.contains("--pull=never")); - assert!(driver.contains("linux/amd64")); - assert!(driver.contains("ANVIL_APRZ_ALREADY_RAN")); - assert!(!driver.contains("--env GITHUB_TOKEN")); - let auth_position = driver - .find("gh auth login --hostname github.com") - .expect("GitHub login command is asserted present above"); - let image_position = driver - .find("docker image inspect") - .expect("Docker image check is asserted present above"); - let customization_position = driver - .find(customization_source) - .expect("customization source command must be present"); - let build_position = driver.find(build_command).expect("Docker build command must be present"); - assert!( - image_position < customization_position && customization_position < auth_position && auth_position < build_position, - "customization must load before GitHub authentication, and authentication must finish before image building" - ); - } - assert!(POWERSHELL_DRIVER.contains("image-id.ps1")); - assert!(IMAGE_ID.contains("[StringComparer]::Ordinal")); - assert!(POWERSHELL_DRIVER.contains("AnvilContainerPrepareCommand")); - assert!(POWERSHELL_DRIVER.contains("wsl -e docker")); - assert!(!POWERSHELL_DRIVER.contains("BuildInMachine")); - assert!(POWERSHELL_DRIVER.contains("git rev-parse --show-toplevel 2>$null")); - assert!(IMAGE_ID.contains("git rev-parse --show-toplevel 2>$null")); - assert!(POWERSHELL_DRIVER.contains("Test-AnvilRecipeNeedsGitHubToken $recipeArg")); - assert!(POWERSHELL_DRIVER.contains("foreach ($recipeArg in $Recipe)")); - assert!(POWERSHELL_DRIVER.contains("[Console]::IsInputRedirected")); - assert!(POWERSHELL_DRIVER.contains("Read-Host")); - assert!(POWERSHELL_DRIVER.contains("ConvertTo-AnvilVersion")); - assert!(POWERSHELL_DRIVER.contains("isolated anvil-aprz")); - assert!(POWERSHELL_DRIVER.contains("docker volume create")); - assert!(POWERSHELL_DRIVER.contains("--user', \"${containerUid}:${containerGid}\"")); - let token_file_create_position = POWERSHELL_DRIVER - .find("[IO.File]::Create($githubTokenFile).Dispose()") - .expect("the temporary GitHub token file must be created before permissions are restricted"); - let token_file_windows_restrict_position = POWERSHELL_DRIVER - .find("& icacls.exe $githubTokenFile") - .expect("the temporary GitHub token file must have a restricted Windows ACL"); - let token_file_unix_restrict_position = POWERSHELL_DRIVER - .find("& chmod 600 $githubTokenFile") - .expect("the temporary GitHub token file must have restricted Unix permissions"); - let token_file_write_position = POWERSHELL_DRIVER - .find("[IO.File]::WriteAllText($githubTokenFile") - .expect("the GitHub token must be written to the restricted temporary file"); + fn resolution_is_attempted_before_building_and_never_fatal() { + // Order: local image, then the hook, then a build. Resolving sits + // inside the cache guard (NO_CACHE must defeat a remote cache too) and + // before the NO_REBUILD guard, because fetching is not building. + let inspect = RECIPE.find("image inspect $image").expect("the local check must exist"); + let resolve = RECIPE.find("Anvil-ResolveImage $image").expect("the resolve call must exist"); + let build = RECIPE.find("anvil: building $image").expect("the build must exist"); assert!( - token_file_create_position < token_file_windows_restrict_position - && token_file_create_position < token_file_unix_restrict_position - && token_file_windows_restrict_position < token_file_write_position - && token_file_unix_restrict_position < token_file_write_position, - "the temporary GitHub token file must be restricted before the token is written" + inspect < resolve && resolve < build, + "resolve belongs between the local check and the build" ); - assert!(SHELL_DRIVER.contains("anvil_recipe_needs_github_token \"$recipe_arg\"")); - assert!(SHELL_DRIVER.contains("for recipe_arg in \"$@\"")); - assert!(SHELL_DRIVER.contains("image-id.sh")); - assert!(!SHELL_DRIVER.contains("pwsh")); - assert!(SHELL_DRIVER.contains("anvil-container must run from a Git repository")); - assert!(SHELL_DRIVER.contains("[[ ! -t 0 ]]")); - assert!(SHELL_DRIVER.contains("read -r -p")); - assert!(SHELL_DRIVER.contains("github_run_args")); - assert!(SHELL_DRIVER.contains("just anvil-aprz")); - assert!(SHELL_DRIVER.contains("docker volume create")); - assert!(SHELL_DRIVER.contains("--user \"$container_uid:$container_gid\"")); - } - - #[test] - fn github_token_recipe_lists_match_the_generated_dependency_graph() { - fn anvil_recipe_tokens(text: &str) -> impl Iterator { - text.split(|character: char| !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))) - .filter(|token| token.starts_with("anvil-") || token.starts_with("_anvil-")) - } - let mut graph = BTreeMap::>::new(); - - for source in dependency_recipe_sources() { - let mut current = None::; - for line in source.lines() { - if !line.chars().next().is_some_and(char::is_whitespace) { - current = line - .split_once(':') - .and_then(|(header, _)| header.split_whitespace().next()) - .filter(|name| name.starts_with("anvil-") || name.starts_with("_anvil-")) - .map(str::to_owned); - } - let Some(recipe) = current.as_ref() else { - continue; - }; - let dependency_text = line.split_once(':').map_or(line, |(_, dependencies)| dependencies); - let dependencies = graph.entry(recipe.clone()).or_default(); - dependencies.extend( - anvil_recipe_tokens(dependency_text) - .map(str::to_owned) - .filter(|dependency| dependency != recipe), - ); - if let Some((_, routed)) = dependency_text.split_once("_anvil-run \"") - && let Some(tier) = routed.split('"').next() - { - dependencies.insert(format!("_anvil-{tier}")); - } + let no_rebuild = RECIPE + .find("if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') {") + .expect("the no-rebuild guard must exist"); + assert!(resolve < no_rebuild, "resolving is not building, so NO_REBUILD must not block it"); + + // A publisher that has not caught up must not stop the developer who + // made the change, so every failure falls through to a build -- + // including a hook that cannot even be loaded, which is why the + // dot-source is inside the try rather than ahead of it. + let try_start = RECIPE[..resolve].rfind("try {").expect("the resolve call must sit inside a try"); + let load = RECIPE[..resolve] + .rfind(". $hookPath") + .expect("the hook must be loaded before it is called"); + assert!(try_start < load, "loading a broken hook must not escape the catch"); + assert!(RECIPE.contains("anvil: $hookRel failed:")); + assert!(RECIPE.contains("anvil: nothing resolved; building locally")); + } + + #[test] + fn a_resolved_reference_is_checked_for_presence_before_it_is_used() { + // Presence, not verification: `image inspect` proves something carries + // that reference, not that its contents match the digest the tag + // claims. Trusting the hook is the contract; this only keeps a + // reference the hook never fetched from failing later, under + // `--pull=never`, a long way from the cause. + let resolve = RECIPE.find("Anvil-ResolveImage $image").expect("the resolve call must exist"); + let verify = RECIPE[resolve..] + .find("image inspect $resolved") + .expect("a resolved reference must be inspected before use"); + let accept = RECIPE[resolve..] + .find("Write-Output $resolved") + .expect("a resolved reference must be returned"); + assert!(verify < accept, "check the resolved reference before returning it"); + } + + #[test] + fn a_query_never_pulls() { + // Resolving can mean pulling gigabytes; `anvil-container-status` asks + // about this machine and must not reach a registry to answer. + assert!(RECIPE.contains("$env:ANVIL_CONTAINER_NO_RESOLVE = '1'")); + assert!(RECIPE.contains("$env:ANVIL_CONTAINER_NO_RESOLVE -ne '1'")); + } + + #[test] + fn every_interpolation_into_powershell_is_escaped() { + // A `just` value pasted raw into a '…' literal ends the string on an + // apostrophe: a repository path containing one breaks every recipe + // here, and `target` would let the remainder run as host PowerShell. + // The deleted runner.just escaped every interpolation; this guards + // against losing that again. + // + // Every `{{` is visited, not just those preceded by an apostrophe, + // because the defect found in review was the other case: an + // interpolation escaped with `replace(…, "'", "''")` -- correct for a + // '…' literal -- that landed inside a "…" literal, where the doubling + // renders as a literal '' and `$` stays live. Scanning only for `'{{` + // cannot see that site at all, since it does not begin with a quote. + // + // Two variables are exempt and checked explicitly below: the image name + // is regex-sanitized at definition, and the workdir is a literal. + const EXEMPT: [&str; 2] = ["{{anvil_container_name}}", "{{anvil_container_workdir}}"]; + for (index, _) in RECIPE.match_indices("{{") { + let tail = &RECIPE[index..]; + if EXEMPT.iter().any(|exempt| tail.starts_with(exempt)) { + continue; + } + // The quote this interpolation is being pasted into, if any. + let quote = RECIPE[..index].chars().next_back(); + let escaped = tail.starts_with("{{ replace("); + match quote { + // A single-quoted literal needs just's doubling form. + Some('\'') => assert!( + escaped, + "unescaped interpolation into a PowerShell literal at byte {index}: {}", + &tail[..tail.len().min(60)] + ), + // A double-quoted literal is the reviewed hazard: `''` doubling + // does not escape there and `$` keeps expanding, so only an + // exempt name is safe. Interpolating anything else needs a + // single-quoted literal instead. + Some('"') => panic!( + "interpolation into a double-quoted PowerShell literal at byte {index}: {}", + &tail[..tail.len().min(60)] + ), + _ => {} } } + // And the escaping that is present uses just's own doubling form. + assert!(RECIPE.contains(r#"replace(justfile_directory(), "'", "''")"#)); + assert!(RECIPE.contains(r#"replace(invocation_directory_native(), "'", "''")"#)); + assert!(RECIPE.contains(r#"replace(command, "'", "''")"#)); + } - let mut expected = BTreeSet::from(["anvil-aprz".to_owned()]); - expected.extend( - graph - .keys() - .filter(|recipe| reaches_aprz(recipe, &graph, &mut BTreeSet::new())) - .cloned(), - ); + #[test] + fn the_image_name_cannot_carry_an_apostrophe() { + // What makes the exemption above safe: the character class admits + // only alphanumerics, so no quote can reach a PowerShell literal. + assert!(RECIPE.contains(r#"replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-")"#)); + } - let driver_recipes = |driver: &str, start: &str, end: &str| { - let body = driver - .split_once(start) - .and_then(|(_, remainder)| remainder.split_once(end).map(|(body, _)| body)) - .expect("driver token-classification function must have stable boundaries"); - anvil_recipe_tokens(body).map(str::to_owned).collect::>() - }; + #[test] + fn an_argument_survives_as_its_own_word() { + // `*command` joins with spaces, so passing it through as one string + // would run a single argument containing them all. + assert!(RECIPE.contains(r"-split '\s+'")); + // The argv is the command. Prefixing it would confine the recipe to + // `just` and make every other program unreachable. + assert!(RECIPE.contains("$runArgs += $argv")); + assert!(!RECIPE.contains("@('just') + $argv")); + } - let shell = driver_recipes(SHELL_DRIVER, "anvil_recipe_needs_github_token() {", "}\n\nversion_at_least"); - let powershell = driver_recipes( - POWERSHELL_DRIVER, - "function Test-AnvilRecipeNeedsGitHubToken", - "\n}\n\nfunction Get-AnvilGitHubToken", + #[test] + fn a_nested_just_resolves_to_the_launching_binary() { + // ANVIL_IN_CONTAINER is a documented control a developer can set on a + // host, where a bare name resolves against PATH rather than the `just` + // that is running. + assert!(RECIPE.contains(r#"if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] }"#)); + } + + #[test] + fn podman_is_pointed_at_the_ignore_file() { + // BuildKit finds `.dockerignore` itself; buildah reads only + // a context-root file, so without this the whole worktree is the build + // context. + assert!(RECIPE.contains(r"if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile'")); + } + + #[test] + fn no_rebuild_is_honoured_even_with_no_cache_set() { + // The two controls compose: NO_REBUILD must not be skipped just + // because NO_CACHE is exported, or `anvil-container-status` spends + // minutes building from a query. + let no_cache = RECIPE + .find("if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') {") + .expect("the cache guard must exist"); + let no_rebuild = RECIPE + .find("if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') {") + .expect("the no-rebuild guard must exist"); + let guard_end = RECIPE[no_cache..].find("\n }\n").expect("the cache guard must be closed") + no_cache; + assert!(no_rebuild > guard_end, "the NO_REBUILD check must sit outside the NO_CACHE guard"); + } + + #[test] + fn engine_is_an_environment_variable_with_a_docker_default() { + assert!(RECIPE.contains(r#"env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker")"#)); + assert!(RECIPE.contains(r#"replace(anvil_container_engine, "'", "''")"#)); + } + + #[test] + fn hook_file_is_an_image_input_but_hook_output_is_not() { + // A changed hook must rename the tag; a minted credential must not. The + // hook is picked up by the walk of `.anvil/container/`, which is what + // also catches a file a repository `COPY`s from one of the Dockerfile's + // user gaps. + assert!(RECIPE.contains("$containerRoot = Join-Path $repoRoot '.anvil/container'")); + assert!(RECIPE.contains("id=$id,env=$name")); + } + + #[test] + fn every_file_under_the_container_directory_is_an_image_input() { + // The Dockerfile is composed: a repository adds `COPY` lines in the + // gaps between anvil's regions, naming files anvil never hears about. + // Hashing a fixed list would let those change the image under a + // reference that already resolves. + let walk = RECIPE + .find("$containerRoot = Join-Path $repoRoot '.anvil/container'") + .expect("the tag must walk the container directory"); + assert!( + RECIPE[walk..].contains("Get-ChildItem -LiteralPath $containerRoot -Recurse -Force"), + "the walk must be recursive and include hidden entries" ); - assert_eq!(shell, expected, "Bash token routing must match APRZ reachability"); - assert_eq!(powershell, expected, "PowerShell token routing must match APRZ reachability"); - } - - #[test] - fn drivers_implement_the_customization_contract() { - assert!(SHELL_DRIVER.contains("customize.sh")); - assert!(!SHELL_DRIVER.contains("auth.sh")); - assert!(!SHELL_DRIVER.contains("CUSTOMIZATION_API_VERSION")); - assert!(POWERSHELL_DRIVER.contains("customize.ps1")); - assert!(!POWERSHELL_DRIVER.contains("auth.ps1")); - assert!(!POWERSHELL_DRIVER.contains("CustomizationApiVersion")); - - for (driver, image_exists, requested_recipes, needs_github_token) in [ - ( - SHELL_DRIVER, - "ANVIL_CONTAINER_IMAGE_EXISTS", - "ANVIL_CONTAINER_REQUESTED_RECIPES", - "ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN", - ), - ( - POWERSHELL_DRIVER, - "AnvilContainerImageExists", - "AnvilContainerRequestedRecipes", - "AnvilContainerNeedsGitHubToken", - ), - ] { - assert!(driver.contains("ANVIL_CONTAINER_REPO_ROOT") || driver.contains("AnvilContainerRepoRoot")); - assert!(driver.contains("ANVIL_CONTAINER_DIR") || driver.contains("AnvilContainerDir")); - assert!(driver.contains("ANVIL_CONTAINER_RESOLVED_IMAGE") || driver.contains("AnvilContainerResolvedImage")); - assert!(driver.contains(image_exists)); - assert!(driver.contains(requested_recipes)); - assert!(driver.contains(needs_github_token)); - - // The image-exists check must be resolved before the - // customization file is sourced, so warm-run state is available - // to it. - let image_exists_position = driver - .find(image_exists) - .unwrap_or_else(|| panic!("{image_exists} is asserted present above")); - let source_position = driver - .find("customize.sh") - .or_else(|| driver.find("customize.ps1")) - .expect("customize.* sourcing is asserted present above"); - assert!( - image_exists_position < source_position, - "image existence must be resolved before customization is sourced" - ); - } + // A missing Dockerfile must still be fatal: the walk alone would let + // it contribute nothing and yield a confident tag for an unbuildable + // image. The resolver asserts existence, and the tag resolves the path + // through it before walking. + let resolve = RECIPE[..walk] + .rfind("_anvil-container-dockerfile\n") + .expect("the tag must resolve the Dockerfile before hashing the directory"); + assert!(resolve < walk); + assert!(RECIPE.contains("anvil: container image input is missing: .anvil/container/Dockerfile")); + } - assert!(POWERSHELL_DRIVER.contains("AnvilContainerHostIsWindows")); - assert!(!SHELL_DRIVER.contains("ANVIL_CONTAINER_HOST_IS_WINDOWS")); - - // Preparation arguments without a preparation command must fail - // validation before Docker build/run. - assert!(SHELL_DRIVER.contains("ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND")); - assert!(POWERSHELL_DRIVER.contains("$AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand")); - - // Cleanup callback shape is validated. - assert!(SHELL_DRIVER.contains("must name a callable function")); - assert!(POWERSHELL_DRIVER.contains("must be a script block")); - - // Output arrays are validated before Docker is invoked. - for driver in [SHELL_DRIVER, POWERSHELL_DRIVER] { - let validate_position = driver - .find("must be a string array") - .or_else(|| driver.find("anvil_container_validate_array")) - .expect("output validation is present"); - let build_position = driver.find("docker build").expect("build invocation is present"); - assert!( - validate_position < build_position, - "output validation must occur before Docker build" - ); - } + #[test] + fn hook_values_are_passed_by_name_never_by_value() { + // NAME=VALUE on a command line is recorded by endpoint telemetry and + // retained far longer than a short-lived token is meant to live. + assert!(RECIPE.contains("$runArgs += @('-e', $name)")); + assert!(!RECIPE.contains("-e', \"$name=")); } #[test] - #[cfg_attr(miri, ignore = "uses filesystem and subprocesses; miri isolation forbids them")] - fn image_id_excludes_customize_source_but_hashes_static_container_files() { - let tmp = TempDir::new().expect("temporary repository must be creatable"); - let root = tmp.path(); - let status = Command::new("git") - .args(["init", "--quiet"]) - .current_dir(root) - .status() - .expect("git must be available for the image-id helper"); - assert!(status.success(), "temporary Git repository must initialize"); - write_image_id_fixture(root); + fn empty_hook_values_fail_closed() { + // Both phases, asserted independently: "for" alone is a substring of + // the build-side message, so it would pass with the run-side guard + // deleted. + assert!(RECIPE.contains("Anvil-BuildSecrets returned an empty value for secret")); + assert!(RECIPE.contains("Anvil-RunEnv returned an empty value for")); + } - let base = run_image_id(root); + #[test] + fn cache_volumes_never_mask_the_images_tools() { + // An engine seeds a named volume from the image only on first + // creation, so mounting a directory that holds installed binaries + // pins the first image's tools over every later tag. + assert!(RECIPE.contains("-cargo-registry:/usr/local/cargo/registry")); + assert!(RECIPE.contains("-cargo-git:/usr/local/cargo/git")); + assert!(!RECIPE.contains("-cargo:/usr/local/cargo'")); + assert!(!RECIPE.contains(":/usr/local/rustup")); + } - // Customization source is runtime orchestration, not image content: it - // must never affect the image ID, in either host-shell form. - let customize_sh = root.join(CUSTOMIZE_SHELL_PATH); - write(&customize_sh, "# customization\n"); - assert_eq!(base, run_image_id(root), "customize.sh source must not affect the image ID"); + #[test] + fn wsl_calls_bypass_the_login_shell() { + // `wsl.exe -- ` re-parses the command line through the default + // shell: a path holding `$` is silently truncated (and wslpath still + // exits 0), and a `;` in any forwarded argument runs on the host. + // Matched on the invocation form so the comment explaining this may + // still name the broken spelling. + assert!(!RECIPE.contains("& wsl.exe -- ")); + assert!(!RECIPE.contains("wsl.exe|--|")); + assert!(RECIPE.contains("& wsl.exe --exec ")); + assert!(RECIPE.contains("wsl.exe|--exec|")); + } - let customize_ps1 = root.join(CUSTOMIZE_POWERSHELL_PATH); - write(&customize_ps1, "# customization\n"); - assert_eq!(base, run_image_id(root), "customize.ps1 source must not affect the image ID"); + #[test] + fn container_name_is_always_a_valid_reference() { + // A repository name may not end in a separator or repeat `.`/`_`, so + // a directory like `ox-tools (copy)` must not reach the engine as + // `anvil-ox-tools--copy-`. + assert!(RECIPE.contains(r#"replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-")"#)); + assert!(RECIPE.contains(r#"trim_end_matches("anvil-" + replace_regex"#)); + } - write(&customize_sh, "# different customization\n"); - write(&customize_ps1, "# different customization\n"); - assert_eq!( - base, - run_image_id(root), - "changed customization source must still not affect the image ID" - ); + #[test] + fn teardown_reports_a_removal_that_failed() { + // $ErrorActionPreference does not cover native commands, and this is + // the only way to clear a cache volume. + assert!(RECIPE.contains("if ($LASTEXITCODE -ne 0) { $failed += $vol }")); + assert!(RECIPE.contains("anvil: could not remove: ")); + } - write(&root.join(README_PATH), "runtime documentation change\n"); - assert_eq!( - base, - run_image_id(root), - "execution-only documentation must not affect the image ID" - ); + #[test] + fn a_mapped_user_gets_a_writable_home() { + // A uid with no passwd entry is given HOME=/, which is not writable. + assert!(RECIPE.contains("$runArgs += @('--user', \"${hostUid}:${hostGid}\")")); + assert!(RECIPE.contains("$runArgs += @('-e', 'HOME=/tmp')")); + } - write(&root.join(RECIPE_PATH), "execution-only recipe change\n"); - assert_eq!(base, run_image_id(root), "the container entry recipe must not affect the image ID"); + #[test] + fn a_host_token_is_resolved_as_the_recipe_does_and_forwarded_by_name() { + // anvil-aprz is in scheduled-advisories, and unauthenticated it does not merely + // warn: `cargo aprz deps` sleeps until the hourly quota resets, so a + // containerized tier blocks for up to an hour. The driver therefore + // resolves a token the same way the recipe does natively -- the + // environment first, then the gh CLI -- so both paths authenticate for + // the same developers. + assert!(RECIPE.contains("gh auth token --hostname github.com")); + assert!(RECIPE.contains("$forwardedEnv += 'GITHUB_TOKEN'")); + assert!(RECIPE.contains("$runArgs += @('-e', 'GITHUB_TOKEN')")); + // By name, never by value: `-e NAME=VALUE` would put the credential on + // the host's command line, where endpoint telemetry retains it. + assert!(!RECIPE.contains("'-e', \"GITHUB_TOKEN=")); + // A derived token is set on this process, so it must be registered for + // the same cleanup the hook's variables get. + assert!(RECIPE.contains("$hookEnv += 'GITHUB_TOKEN'")); + // An exported token is left alone rather than re-derived; scoping of + // the derived one is asserted in its own test below. + assert!(RECIPE.contains("if (-not $env:GITHUB_TOKEN -and (Get-Command gh")); + // Forwarding by name only works if the engine can see the name, so a + // WSL engine needs it bridged -- otherwise `-e NAME` forwards nothing. + assert!(RECIPE.contains("$engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0")); + } - let override_image = "example.invalid/bullseye@sha256:1111111111111111111111111111111111111111111111111111111111111111"; - #[cfg(windows)] - let overridden = run_image_id_command_with_base( - root, - "pwsh", - &["-NoProfile", "-File", ".anvil/container/image-id.ps1"], - Some(override_image), - ); - #[cfg(unix)] - let overridden = run_image_id_command_with_base(root, "bash", &[".anvil/container/image-id.sh"], Some(override_image)); - assert_ne!(base, overridden, "the selected base image must affect the image ID"); - - write(&root.join("justfiles/anvil/checks/extra.just"), "anvil-extra:\n @echo extra\n"); - assert_ne!( - base, - run_image_id(root), - "only the container entry recipe itself is execution-only; other recipes are hashed" + #[test] + fn the_whole_recipe_tree_defines_the_image() { + // `just anvil-setup` reaches the install recipes through the tier, + // group and check recipes, so the routing decides *whether* a tool is + // installed as surely as tools.just decides *how*. Hashing only the + // install definitions would let a group drop a `-setup` dependency, + // changing the installed set, without renaming the image. + // Every file, not only `*.just`: the build context admits the whole + // directory, so a non-recipe file an adopter adds by hand is copied + // into the image. Filtering here would let it change the image's + // contents without changing its tag. -Force because a dot-prefixed + // file is copied like any other and would otherwise be skipped. + // Directories are enumerated too, because `-File` hides a link to one + // and a link is refused rather than digested. + assert!(RECIPE.contains("-Recurse -Force")); + assert!(!RECIPE.contains("-Recurse -Force -Filter '*.just'")); + assert!( + !RECIPE.contains("-Recurse -File -Force"), + "a link to a directory is invisible to a file-only walk" ); - std::fs::remove_file(root.join("justfiles/anvil/checks/extra.just")).expect("test file must be removable"); - assert_eq!(base, run_image_id(root), "removing the extra recipe must restore the image ID"); + // ComputeHash, not the static HashData: the latter needs .NET 5, and + // the prerequisite check accepts PowerShell 7.0 on .NET Core 3.1. + assert!(!RECIPE.contains("SHA256]::HashData")); + assert!(RECIPE.contains("SHA256]::Create()")); + // Including this driver, which passes the build arguments, the secret + // mounts and the hook's PreBuild output into the build. + assert!(!RECIPE.contains("-cne 'justfiles/anvil/container.just'")); + } - // Static, hashed image content must still affect the image ID. - write( - &root.join(CONTAINERFILE_PATH), - "ARG BASE_IMAGE=example.invalid/base@sha256:0000000000000000000000000000000000000000000000000000000000000000\nFROM ${BASE_IMAGE}\nRUN echo changed\n", + #[test] + fn the_recipe_contract_inputs_cross_the_boundary() { + // A check that reads one of these natively must read the same value in + // a container, or the same command means two different things. + // anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with + // a skip notice, so a title a native run rejects would pass in a + // container and the tier would still report green. + for name in [ + "PR_TITLE", + "BASE_REF", + "GITHUB_BASE_REF", + "SYSTEM_PULLREQUEST_TARGETBRANCH", + "ANVIL_IMPACT", + ] { + assert!(RECIPE.contains(name), "{name} must be forwarded"); + } + // ANVIL_IMPACT decides whether scoping is computed, consumed from a + // downloaded cache, or skipped. A CI group job exports `consume`, and a + // container that did not inherit it would recompute from a diff instead + // of trusting the artifact the group downloaded. + assert!( + RECIPE.contains("'ANVIL_IMPACT')) {"), + "ANVIL_IMPACT must be in the forwarded set, not merely mentioned" ); - assert_ne!( - base, - run_image_id(root), - "changed static Containerfile content must affect the image ID" + // Nothing reads these; forwarding them only implied a contract that + // does not exist. See justfile.rs, which asserts they stay removed. + assert!( + !RECIPE.contains("ANVIL_INCLUDE_"), + "the ANVIL_INCLUDE_* variables were removed and must not be forwarded" ); } #[test] - #[cfg(unix)] - #[cfg_attr(miri, ignore = "uses filesystem and subprocesses; miri isolation forbids them")] - fn image_id_helpers_match_when_pwsh_is_available() { - if Command::new("pwsh").arg("-Version").output().is_err() { - return; - } + fn a_derived_token_is_scoped_to_a_command_that_reads_it() { + // Forwarding an exported GITHUB_TOKEN is exact parity: natively it is + // visible to every process the shell spawns too. Minting one from `gh` + // is not -- PID 1's environment reaches every build script and proc + // macro, where natively the recipe mints it in its own process -- so it + // happens only for a command whose plan reads the variable. + let derive = RECIPE.find("gh auth token --hostname").expect("the gh fallback must exist"); + let guard = RECIPE[..derive].rfind("if ($needsToken)").expect("the derive must be guarded"); + let plan = RECIPE[..guard] + .rfind("$plan -match 'GITHUB_TOKEN'") + .expect("the plan must decide whether a token is needed"); + let dry_run = RECIPE[..plan].rfind("--dry-run @target").expect("the plan must come from just"); + assert!(dry_run < plan && plan < guard, "compute the plan, match it, then derive"); + // Through the launching binary, like every other nested call: a bare + // `just` here fails silently when the caller invoked it by absolute + // path, and an empty plan reads as "no token needed". + assert!(!RECIPE.contains("(just --dry-run")); + assert!(RECIPE.contains(r"}}' --dry-run @target")); + // The predicate is the variable, not the name of a check, so a catalog + // that adds another GitHub-authenticated check is covered for free. + assert!(!RECIPE.contains("$plan -match 'aprz'")); + // An interactive session has no command to plan, and can run anything. + assert!(RECIPE.contains("$needsToken = $argv.Count -eq 0")); + // Only `just` can be planned, so nothing else earns a minted credential. + assert!(RECIPE.contains("if (-not $needsToken -and $argv[0] -eq 'just')")); + } - let tmp = TempDir::new().expect("temporary repository must be creatable"); - let root = tmp.path(); - let status = Command::new("git") - .args(["init", "--quiet"]) - .current_dir(root) - .status() - .expect("git must be available for the image-id helpers"); - assert!(status.success(), "temporary Git repository must initialize"); - write_image_id_fixture(root); - write( - &root.join("justfiles/anvil/checks/custom.just"), - "nested-custom-recipe:\n @echo custom\n", + #[test] + fn planning_follows_a_recipe_launched_as_a_child_process() { + // `just --dry-run` prints the bodies just runs itself. The unscoped tier + // wrapper runs its tier as a child process instead, so a plan of + // `anvil-scheduled` is the wrapper alone and reveals none of the checks + // under it -- including anvil-aprz, whose GITHUB_TOKEN is what stops it + // sleeping on the advisory API's unauthenticated rate limit. + assert!( + RECIPE.contains(r#"[regex]::Matches($step, "'(_anvil-[^'\s]+)'")"#), + "the plan must follow each nested target the wrapper names" ); - // The entry recipe is skipped by both helpers; seed it so the skip - // itself is compared, not just the recipes they agree to hash. - write(&root.join(RECIPE_PATH), "execution-only:\n @echo entry\n"); - - let shell = run_image_id_command(root, "bash", &[".anvil/container/image-id.sh"]); - let powershell = run_image_id_command(root, "pwsh", &["-NoProfile", "-File", ".anvil/container/image-id.ps1"]); - assert_eq!(shell, powershell); - } - - #[test] - fn shell_driver_supports_legacy_bash() { - assert!(!SHELL_DRIVER.contains("sort -V")); - assert!(!SHELL_DRIVER.contains("[[ -v")); - assert!(SHELL_DRIVER.contains("version_at_least")); - assert!(SHELL_DRIVER.contains("if command -v sha256sum")); - assert!(SHELL_DRIVER.contains("shasum -a 256")); - assert!(SHELL_DRIVER.contains("printenv")); - assert!(SHELL_DRIVER.contains("declare -p")); - assert!(SHELL_IMAGE_ID.contains("shasum -a 256")); - assert!(SHELL_IMAGE_ID.contains("LC_ALL=C sort -u")); - assert!(!SHELL_IMAGE_ID.contains("pwsh")); - - // Namerefs (`local -n`/`declare -n`) require Bash 4.3+. Array-name - // validation must pass elements positionally instead. - assert!(!SHELL_DRIVER.contains("local -n"), "namerefs are unsupported on Bash 3.2"); - assert!(!SHELL_DRIVER.contains("declare -n"), "namerefs are unsupported on Bash 3.2"); - - // Every possibly-empty customization-output array must be expanded - // with the `${arr[@]+"${arr[@]}"}` idiom, not a bare `"${arr[@]}"`: - // under `set -u`, Bash versions before 4.4 raise "unbound variable" - // when a declared-but-empty array is expanded bare. The guarded - // idiom necessarily contains the bare form as a substring, so pin - // safety by asserting every bare occurrence is part of a guarded - // one (equal counts) rather than absent outright. - for array in [ - "ANVIL_CONTAINER_BUILD_ARGS", - "ANVIL_CONTAINER_PREPARE_ARGS", - "ANVIL_CONTAINER_RUN_ARGS", - ] { - let guarded = format!("${{{array}[@]+\"${{{array}[@]}}\"}}"); - let bare = format!("\"${{{array}[@]}}\""); - let guarded_count = SHELL_DRIVER.matches(&guarded).count(); - let bare_count = SHELL_DRIVER.matches(&bare).count(); - assert!(guarded_count > 0, "{array} must use the nounset-safe empty-array idiom: {guarded}"); - assert_eq!( - guarded_count, bare_count, - "{array} must never be expanded bare outside the nounset-safe idiom (unsafe under `set -u` on Bash <4.4)" + // Bounded: a recipe reachable twice is planned once, and a body naming + // itself terminates instead of looping. + assert!(RECIPE.contains("if (-not $planned.Add(($target -join ' '))) { continue }")); + // Every step contributes, so a match anywhere in the tree counts. + assert!(RECIPE.contains("$plan = \"$plan`n$step\"")); + } + + #[test] + fn the_credential_phases_are_fail_closed() { + // Unlike resolution, these must stop the run: a container that starts + // without its credentials fails deep inside, far from the cause. + assert!(RECIPE.contains("anvil: Anvil-BuildSecrets returned no secrets")); + assert!(RECIPE.contains("anvil: Anvil-RunEnv returned no variables")); + assert!(RECIPE.contains("anvil: failed to load ${hookRel}:")); + // Whitespace is not a credential. IsNullOrEmpty would accept " ". + assert!(!RECIPE.contains("[string]::IsNullOrEmpty($hook")); + // Take the last object, not the whole stream: a hook that writes + // progress with Write-Output would otherwise hand back an array whose + // .Secrets is silently $null, and the guard above would not fire. + assert!(RECIPE.contains("@(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1")); + assert!(RECIPE.contains("@(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1")); + } + + #[test] + fn the_working_directory_is_mapped_from_a_native_path() { + // `invocation_directory()` reports a Cygwin-style path when cygpath is + // on PATH, which shares no prefix with the native justfile_directory() + // it is made relative to -- so the run would be placed outside the + // mount, on a path that does not exist in the container. + assert!(RECIPE.contains("invocation_directory_native()")); + assert!(!RECIPE.contains("replace(invocation_directory(), ")); + assert!(RECIPE.contains("$rel.StartsWith('..')")); + } + + #[test] + fn teardown_removes_only_volumes_the_run_creates() { + // Naming a volume the run never mounts is a claim that it exists. + let down = RECIPE.find("anvil-container-down:").expect("the teardown recipe must exist"); + for stale in ["-cargo'", "-rustup'"] { + assert!( + !RECIPE[down..].contains(stale), + "{stale} is never created, so it cannot be torn down" ); } + assert!(RECIPE[down..].contains("-cargo-registry'")); + assert!(RECIPE[down..].contains("-cargo-git'")); } #[test] - fn recipe_uses_native_host_interpreters() { - assert!(RECIPE.contains("[windows]")); - assert!(RECIPE.contains("[script(\"pwsh\", \"-NoProfile\")]")); - assert!(RECIPE.contains("[unix]")); - assert!(RECIPE.contains("[script(\"bash\")]")); - assert!(!RECIPE.contains("$IsWindows")); + fn the_build_context_stays_scoped_to_the_recipe_tree() { + // The whole tree is copied because `just` must parse it, but nothing + // outside it is: an unscoped context streams every stale `target/` to + // the daemon on each build. + assert!(DOCKERIGNORE.contains("justfiles/*\n!justfiles/anvil\n")); + assert!(!DOCKERIGNORE.contains("!justfiles\n!rust-toolchain.toml")); } #[test] - fn entrypoint_initializes_non_root_cargo_metadata() { - for file in ["config.toml", ".crates.toml", ".crates2.json"] { - assert!(ENTRYPOINT.contains(file)); - } - assert!(ENTRYPOINT.contains("export CARGO_HOME")); - assert!(ENTRYPOINT.contains("ln -sfn /usr/local/cargo/registry")); - assert!(ENTRYPOINT.contains("ln -sfn /usr/local/cargo/git")); - assert!(ENTRYPOINT.contains("exec \"$@\"")); + fn a_redirected_checkout_can_reach_its_git_directory() { + // A checkout whose .git is a file names a host path outside the mount, + // so without this the container resolves no refs at all and every check + // that needs history fails. + assert!(RECIPE.contains("git rev-parse --git-common-dir")); + assert!(RECIPE.contains("${engineGitCommon}:/anvil/gitdir")); + // Redirected through the checkout's own .git file, never through + // GIT_DIR/GIT_WORK_TREE: those are ambient, so every process in the + // container would inherit them and any git run outside the workspace + // -- `git init` in a test's scratch directory, most of all -- would + // operate on this repository instead of its own. + assert!(RECIPE.contains("gitdir: $containerGitDir")); + assert!(RECIPE.contains("{{anvil_container_workdir}}/.git:ro")); + assert!(!RECIPE.contains("GIT_DIR=")); + assert!(!RECIPE.contains("GIT_WORK_TREE=")); + // The generated file is temporary and must not outlive the run. + assert!(RECIPE.contains("if ($gitFile) { Remove-Item -LiteralPath $gitFile")); + // An ordinary clone keeps its git directory inside the checkout, where + // the bind mount already carries it, and must not take the extra mount. + assert!(RECIPE.contains("(Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)")); + // The shape of .git, not whether the git directory differs from the + // common one: `--separate-git-dir` redirects without differing, so that + // predicate skipped it and left git pointed at a host path. + assert!( + !RECIPE.contains("if ($gitDirAbs -ne $gitCommonAbs) {"), + "a redirect without a separate worktree entry must still be mounted" + ); + // One mount carries both directories, so the git directory has to sit + // under the common one. Emitting a path that climbs out of the mount + // would fail inside the container, where the cause is invisible. + assert!(RECIPE.contains("if ($rel -eq '.') {")); + assert!(RECIPE.contains("$rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)")); + // A host with a working engine but no git must not start failing here. + assert!(RECIPE.contains("if (Get-Command git -ErrorAction SilentlyContinue) {")); + } + + #[test] + fn the_dockerfile_must_carry_the_canonical_name() { + // The engine reads the ignore file as `.dockerignore` and + // anvil maintains it at `.anvil/container/Dockerfile.dockerignore`, so + // the two names have to agree and only one of them can move. A variant + // is refused, because building from `dockerfile` would find no ignore + // file and stream the whole worktree into the build context. + assert!( + !RECIPE.contains("$dockerfile = '.anvil/container/Dockerfile'"), + "the path must come from the recipe that checks it" + ); + assert!(RECIPE.contains("_anvil-container-dockerfile:")); + // -ceq, because PowerShell's -eq on strings is case-insensitive and + // would make the canonical name indistinguishable from a variant. + assert!(RECIPE.contains("$_.Name -ceq 'Dockerfile'")); + assert!(RECIPE.contains("must be named exactly '.anvil/container/Dockerfile'")); + // The one place that asserts the file exists: the tag's directory walk + // cannot, because a missing Dockerfile contributes nothing to the hash + // and yields a confident tag for an image that can never be built. + assert!(RECIPE.contains("anvil: container image input is missing: .anvil/container/Dockerfile")); + // The ignore file is derived from that name, so it lands on the owned + // artifact and is normalized as the text it is -- otherwise a CRLF and + // an LF checkout of one commit disagree on the tag. + assert!(RECIPE.contains(r#"[void]$declaredText.Add("$dockerfile.dockerignore")"#)); } #[test] - fn drivers_support_interactive_shell_mode() { - assert!(SHELL_DRIVER.contains("--interactive --tty")); - assert!(SHELL_DRIVER.contains("\"$image\" bash")); - assert!(POWERSHELL_DRIVER.contains("wsl -e docker @runArgs --interactive --tty $image bash")); + fn hooks_constructor_uses_the_documented_path() { + assert_eq!(paths(&[hooks("# body\n")]), [HOOKS_PATH]); + assert_eq!(hooks("# body\n").body(), "# body\n"); } #[test] - fn customize_helpers_use_the_standard_paths() { - match customize_shell("# shell customization\n") { - Artifact::OwnedFile(spec) => { - assert_eq!(spec.path, CUSTOMIZE_SHELL_PATH); - assert_eq!(spec.body, "# shell customization\n"); - } - Artifact::Region(_) => panic!("customization file must be an owned file"), - } - match customize_powershell("# PowerShell customization\n") { - Artifact::OwnedFile(spec) => { - assert_eq!(spec.path, CUSTOMIZE_POWERSHELL_PATH); - assert_eq!(spec.body, "# PowerShell customization\n"); + fn a_fork_replaces_one_region_without_touching_the_others() { + // The point of splitting the file: a catalog on another base OS + // rewrites the base and tool layers and inherits the catalog install + // and the entry contract, instead of forking the whole Dockerfile and + // freezing every pin in it. + let replaced = dockerfile_base_image().with_body("ARG BASE_IMAGE=example.invalid/base\n"); + match &replaced { + Artifact::Region(spec) => { + assert_eq!(spec.id.as_str(), "anvil-container-base-image"); + assert_eq!(spec.host, HostSelector::Path(DOCKERFILE_PATH.to_owned())); } - Artifact::Region(_) => panic!("customization file must be an owned file"), + Artifact::OwnedFile(_) => panic!("the Dockerfile regions must stay regions"), } + assert_eq!(replaced.body(), "ARG BASE_IMAGE=example.invalid/base\n"); + assert_eq!(dockerfile_setup().body(), DOCKERFILE_SETUP); } } diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index b870538a..db458762 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -59,12 +59,6 @@ const IMPACT_JUST: &str = include_str!("../../../templates/justfiles/anvil/impac /// Repo-root-relative path of the impact recipe file. const IMPACT_JUST_PATH: &str = "justfiles/anvil/impact.just"; -/// Contents of `justfiles/anvil/runner.just` baked into the binary. -const RUNNER_JUST: &str = include_str!("../../../templates/justfiles/anvil/runner.just"); - -/// Repo-root-relative path of the tier execution router. -const RUNNER_JUST_PATH: &str = "justfiles/anvil/runner.just"; - /// Emits `(path, include_str!)` pairs for a set of split recipe files that /// live under a subdirectory of `justfiles/anvil/`. Each file is one owned /// artifact, so the recipe tree is one file per check / per group rather @@ -81,33 +75,16 @@ macro_rules! split_recipe_files { } #[test] -fn runner_routes_tiers_and_guards_recursion() { - assert!(RUNNER_JUST.contains("[windows]")); - assert!(RUNNER_JUST.contains("[script(\"pwsh\", \"-NoProfile\")]")); - assert!(RUNNER_JUST.contains("[unix]")); - assert!(RUNNER_JUST.contains("[script(\"bash\")]")); - assert_eq!(RUNNER_JUST.matches("[no-exit-message]").count(), 2); - assert!(RUNNER_JUST.contains("if ($env:ANVIL_IN_CONTAINER)")); - assert!(RUNNER_JUST.contains("if [[ -n \"${ANVIL_IN_CONTAINER:-}\" ]]")); - assert!(RUNNER_JUST.contains("replace(just_executable(), \"'\", \"''\")")); - assert!(RUNNER_JUST.contains("replace(justfile(), \"'\", \"''\")")); - assert!(RUNNER_JUST.contains("replace(tier, \"'\", \"''\")")); - assert!(RUNNER_JUST.contains("replace(runner, \"'\", \"''\")")); - assert!(RUNNER_JUST.contains("& $just --justfile $justfile anvil-container $nativeTier")); - assert!(RUNNER_JUST.contains("exec \"$just_path\" --justfile \"$justfile\" anvil-container \"$native_tier\"")); - assert_eq!(RUNNER_JUST.matches("expected 'native' or 'container'").count(), 2); -} - -#[test] -fn aprz_uses_the_container_secret_and_fails_fast_without_it() { +fn aprz_forwards_a_github_token_into_the_container() { let aprz = CHECK_FILES .iter() .find_map(|(path, body)| path.ends_with("/aprz.just").then_some(*body)) .expect("aprz.just is registered in CHECK_FILES below"); - assert!(aprz.contains("if ($env:ANVIL_IN_CONTAINER)")); - assert!(aprz.contains("ANVIL_APRZ_ALREADY_RAN")); - assert!(aprz.contains("/run/secrets/anvil-github-token")); - assert!(aprz.contains("Run `gh auth login` on the host")); + // The container driver forwards GITHUB_TOKEN by name, so the check reads + // the variable and says how to obtain one rather than reaching for a + // mounted secret path. + assert!(aprz.contains("GITHUB_TOKEN")); + assert!(aprz.contains("gh auth")); } /// One `justfiles/anvil/checks/.just` file per catalog check @@ -171,11 +148,6 @@ const TIERS_JUST: &str = include_str!("../../../templates/justfiles/anvil/tiers. /// Repo-root-relative path of the tier aggregator file. const TIERS_JUST_PATH: &str = "justfiles/anvil/tiers.just"; -#[cfg(test)] -pub(crate) fn dependency_recipe_sources() -> impl Iterator { - std::iter::once(TIERS_JUST).chain(GROUP_FILES.iter().map(|(_, body)| *body)) -} - /// Embedded body of the `anvil-imports` region in the user's Justfile. pub(crate) const JUSTFILE_IMPORTS_BODY: &str = include_str!("../../../templates/regions/justfile-imports.just"); @@ -220,12 +192,6 @@ pub fn impact() -> Artifact { Artifact::owned_file(IMPACT_JUST_PATH, IMPACT_JUST) } -/// `justfiles/anvil/runner.just` — native/container tier routing. -#[must_use] -pub fn runner() -> Artifact { - Artifact::owned_file(RUNNER_JUST_PATH, RUNNER_JUST) -} - /// The `justfiles/anvil/checks/.just` files — one owned artifact /// per catalog check. #[must_use] @@ -590,9 +556,9 @@ mod tests { ); } // Scheduled groups are the full-workspace backstop: the public recipe - // routes through `_anvil-run` with impact "off" (forcing - // ANVIL_IMPACT=off before the deps run), and the private `_anvil-` - // fan-out lists its validate-prereqs aggregate first. + // wraps in `_anvil-unscoped` (forcing ANVIL_IMPACT=off before the deps + // run), and the private `_anvil-` fan-out lists its + // validate-prereqs aggregate first. for g in [ "scheduled-test", "scheduled-advisories", @@ -600,8 +566,8 @@ mod tests { "scheduled-exhaustive", ] { assert!( - groups.contains(&format!("anvil-{g}: (_anvil-run \"{g}\" anvil_runner \"off\")")), - "scheduled group {g} must route through _anvil-run with impact off" + groups.contains(&format!("anvil-{g}: (_anvil-unscoped \"{g}\")")), + "scheduled group {g} must wrap in _anvil-unscoped" ); assert!( groups.contains(&format!("_anvil-{g}: anvil-{g}-validate-prereqs")), @@ -661,36 +627,32 @@ mod tests { #[test] fn tiers_just_template_has_three_tiers() { - for needle in [ - "anvil-pr:", - "anvil-scheduled:", - "anvil-full:", - "_anvil-pr:", - "_anvil-scheduled:", - "_anvil-full:", - ] { + for needle in ["anvil-pr:", "anvil-scheduled:", "anvil-full:", "_anvil-scheduled:", "_anvil-full:"] { assert!(TIERS_JUST.contains(needle), "tiers.just missing '{needle}'"); } - // Every public tier entry point routes through the `_anvil-run` - // native/container router. The private `_anvil-` recipe carries - // the validate-prereqs aggregate (run first) so a missing tool fails - // up front rather than mid-run. The scheduled and full tiers pass the - // `"off"` impact argument so `_anvil-run` exports ANVIL_IMPACT=off -- - // they are the full-workspace backstop for PR-tier impact scoping. + // Containerized execution is reached only through the explicit + // `anvil-container` recipe, so no tier routes through a native/container + // seam. The PR tier depends on its work directly. The scheduled and full + // tiers are the full-workspace backstop for PR-tier impact scoping, so + // they wrap a private `_anvil-` recipe that carries the + // validate-prereqs aggregate (run first, so a missing tool fails up + // front) and inherits ANVIL_IMPACT=off from the wrapper. + assert!(!TIERS_JUST.contains("_anvil-run"), "tiers must not route through an execution seam"); for needle in [ - "anvil-pr: (_anvil-run \"pr\" anvil_runner)", - "_anvil-pr: anvil-pr-validate-prereqs", - "anvil-scheduled: (_anvil-run \"scheduled\" anvil_runner \"off\")", + "anvil-pr: anvil-pr-validate-prereqs", + "anvil-scheduled: (_anvil-unscoped \"scheduled\")", "_anvil-scheduled: anvil-scheduled-validate-prereqs", - "anvil-full: (_anvil-run \"full\" anvil_runner \"off\")", + "anvil-full: (_anvil-unscoped \"full\")", "_anvil-full: anvil-full-validate-prereqs", ] { assert!(TIERS_JUST.contains(needle), "tier wrapper missing '{needle}'"); } - // The runner forces impact off for the full-workspace tiers. + // Scoping is disabled by a parent process, because `just` runs each + // dependency as its own process and a dependency-only recipe's body + // executes after its dependencies. assert!( - RUNNER_JUST.contains("ANVIL_IMPACT"), - "runner must be able to force ANVIL_IMPACT=off for the scheduled/full tiers" + HELPERS_JUST.contains("$env:ANVIL_IMPACT = 'off'"), + "the wrapper must force ANVIL_IMPACT=off for the scheduled/full tiers" ); // The scheduled tier must fan out to every scheduled group, including // runtime-analysis (a separate group from exhaustive). @@ -736,10 +698,11 @@ mod tests { "import 'impact.just'", "import 'checks/fmt.just'", "import 'checks/miri.just'", - "import 'container.just'", + // Optional: a fork can drop the container backend with + // `without_artifact` and the tree still parses. + "import? 'container.just'", "import 'groups/pr-fast.just'", "import 'groups/scheduled-exhaustive.just'", - "import 'runner.just'", "import 'tiers.just'", "import 'tools.just'", "import 'versions.just'", diff --git a/crates/cargo-anvil/src/anvil/artifacts/mod.rs b/crates/cargo-anvil/src/anvil/artifacts/mod.rs index 73a800e1..94f6d271 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/mod.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/mod.rs @@ -36,7 +36,7 @@ use crate::catalog::Artifact; /// render inventories, but the *policy* answer lives here once). The match is /// exhaustive by group and panics on an unrecognized group, so adding a group /// forces an explicit classification here rather than silently defaulting into -/// `consume` -- which, since `consume` now hard-errors on a missing cache +/// `consume` -- which, since `consume` hard-errors on a missing cache /// (impact.just), would fail the pipeline for an unclassified group instead of /// scoping it; either way, a missing classification is caught up front, not silent. #[must_use] @@ -62,10 +62,8 @@ pub(crate) fn anvil_artifacts() -> Vec { justfile::versions(), justfile::helpers(), justfile::impact(), - justfile::runner(), justfile::tiers(), region::justfile_imports(), - region::justfile_runner(), region::workspace_lints(), region::single_crate_lints(), region::member_lints(), @@ -123,10 +121,8 @@ mod tests { justfile::tools(), justfile::helpers(), justfile::impact(), - justfile::runner(), justfile::tiers(), region::justfile_imports(), - region::justfile_runner(), region::workspace_lints(), region::single_crate_lints(), region::member_lints(), diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs index 6c2a86db..0deba72c 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/region.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs @@ -68,12 +68,6 @@ const GITATTRIBUTES_PATH: &str = ".gitattributes"; /// Region id for the managed section of `.gitattributes`. const GITATTRIBUTES_REGION_ID: &str = "anvil-gitattributes"; -/// Region id for the user-controlled native/container tier policy. -const JUSTFILE_RUNNER_REGION_ID: &str = "anvil-runner"; - -/// Embedded body of the user-controlled tier execution policy. -const JUSTFILE_RUNNER_BODY: &str = include_str!("../../../templates/regions/justfile-runner.just"); - /// Embedded body of the `deny.toml` `[advisories]` managed region. const DENY_ADVISORIES_BODY: &str = include_str!("../../../templates/regions/deny-advisories.toml"); @@ -141,17 +135,6 @@ pub fn justfile_imports() -> Artifact { ) } -/// `Justfile` / `anvil-runner` — user-controlled tier execution policy. -#[must_use] -pub fn justfile_runner() -> Artifact { - Artifact::region(RegionSpec { - host: HostSelector::Path(justfile::JUSTFILE_PATH.to_owned()), - id: RegionId::new(JUSTFILE_RUNNER_REGION_ID), - body: JUSTFILE_RUNNER_BODY.to_owned(), - syntax: CommentSyntax::Hash, - }) -} - /// Root `Cargo.toml` / `anvil-workspace-lints`. /// /// The workspace-scope lint catalog under `[workspace.lints]`. Emitted only diff --git a/crates/cargo-anvil/src/catalog/builder.rs b/crates/cargo-anvil/src/catalog/builder.rs index 6343dbb8..64a9c091 100644 --- a/crates/cargo-anvil/src/catalog/builder.rs +++ b/crates/cargo-anvil/src/catalog/builder.rs @@ -238,11 +238,13 @@ impl CatalogBuilder { } } -/// `justfiles/` is the recipe tree: the container image identity and the -/// Docker build-context allow-list only ever consider `*.just` files below it, -/// so any other owned file placed there would be silently dropped from both. -/// Reject it at catalog-construction time instead, so a derived catalog fails -/// loudly rather than shipping a file the container backend ignores. +/// `justfiles/` is the recipe tree: `just` parses every file the container +/// build copies from it, so a non-recipe owned file there makes the tool set +/// harder to reason about than one kept in a tool-owned directory. Image +/// identity is not at stake — the digest covers every file the build context +/// admits, not only `*.just` — so this is a legibility rule, enforced at +/// catalog-construction time to keep a derived catalog honest about where its +/// assets live. fn non_recipe_under_justfiles(artifact: &Artifact) -> Option { let Artifact::OwnedFile(spec) = artifact else { return None; @@ -252,7 +254,7 @@ fn non_recipe_under_justfiles(artifact: &Artifact) -> Option { return None; } Some(format!( - "owned file '{}' is not a .just recipe; non-recipe artifacts must live outside justfiles/ (the container image identity and build context only admit *.just there)", + "owned file '{}' is not a .just recipe; non-recipe artifacts must live outside justfiles/ (it is the recipe tree, not an asset directory)", spec.path )) } diff --git a/crates/cargo-anvil/src/emit/owned_file.rs b/crates/cargo-anvil/src/emit/owned_file.rs index eb17434a..eb5f2930 100644 --- a/crates/cargo-anvil/src/emit/owned_file.rs +++ b/crates/cargo-anvil/src/emit/owned_file.rs @@ -30,7 +30,7 @@ pub fn plan_owned_file(repo_root: &Path, manifest: &Manifest, relpath: &str, ren let on_disk = read_file_if_present(&abs)?; let disk_checksum = on_disk.as_deref().map(checksum_str); let template_checksum = checksum_str(rendered); - let last_rendered = manifest.files.get(relpath).map(String::as_str); + let last_rendered = manifest.file_checksum(relpath); let inputs = DecisionInputs { last_rendered, diff --git a/crates/cargo-anvil/src/lib.rs b/crates/cargo-anvil/src/lib.rs index 28e84518..8dbef9b0 100644 --- a/crates/cargo-anvil/src/lib.rs +++ b/crates/cargo-anvil/src/lib.rs @@ -106,118 +106,155 @@ //! //! ## Containerized local checks //! -//! Anvil can run any generated recipe in a content-addressed Linux container. -//! The image installs the Rust toolchains and Cargo tools pinned by the -//! repository's generated Anvil configuration, providing a repeatable Linux -//! environment without installing those tools directly on the host. -//! -//! ### Prerequisites -//! -//! - Docker Engine 23.0 or newer, installed directly in Linux or WSL and -//! usable by the current user. -//! - `git` and `just` on the host. -//! - Bash on Linux and WSL; `PowerShell` Core (`pwsh`) and WSL 2 on Windows. -//! - `[script]` support enabled in the root `Justfile` (`set unstable` when -//! required by the installed `just` version). -//! - A repository-owned `rust-toolchain.toml`. -//! - On Windows, Docker Engine running in the default WSL distribution: -//! -//! ```powershell -//! wsl -e docker version -//! ``` -//! -//! The Windows driver invokes Docker in the default WSL distribution and does -//! not call Windows `docker.exe`. Regardless of the installation, the command -//! above must succeed. Docker Desktop is not required. -//! -//! On ARM64 hosts, Docker emulates the required `linux/amd64` environment, so -//! image builds and checks can be substantially slower than on x86-64 hosts. -//! -//! ### Run a recipe +//! Any generated recipe can be executed inside a content-addressed Linux +//! image. The image installs the Rust toolchain and Cargo tools this +//! repository pins by running `just anvil-setup`, the same recipe the checks +//! use, reading the same generated pins, so the image and the host agree on +//! the toolset by construction, with no second tool list to keep in step. +//! +//! Execution is opt-in per invocation: `just anvil-pr` and every other recipe +//! continue to run natively, and a container is entered only through +//! `anvil-container`, whose arguments are the argv executed inside the image. +//! Those arguments are whitespace-delimited tokens: `just` joins a variadic +//! parameter with spaces before the recipe sees it, so an argument that itself +//! contains a space cannot be recovered and does not survive the round trip. //! //! ```text -//! just anvil-container anvil-clippy -//! just anvil-container anvil-pr -//! just anvil-container +//! just anvil-container just anvil-clippy # one check +//! just anvil-container just anvil-pr # the whole PR tier +//! just anvil-container just anvil-setup binstall # a recipe with an argument +//! just anvil-container cargo build # any other command +//! just anvil-container # interactive shell //! ``` //! -//! The no-argument form opens an interactive shell. Anvil builds an image the -//! first time it encounters a content hash and reuses it on later runs. Changes -//! to the Rust toolchain, generated Anvil files, Containerfile, or other static -//! image inputs select a new tag and build a new image. Images for earlier -//! hashes remain available to older branches. Runtime `customize.*` files do not -//! affect image identity. -//! Every argument is a recipe name; recipe parameters are not supported by -//! this command surface. +//! The feature is three generated artifacts and one optional hook script, with +//! no configuration file: `justfiles/anvil/container.just` drives the engine, +//! `.anvil/container/Dockerfile` and its `Dockerfile.dockerignore` define what +//! the image contains, and `.anvil/container/hooks.ps1` supplies credentials +//! when a repository needs them. //! -//! Cargo registry and Cargo Git caches use repository-scoped named volumes; -//! `target/` is additionally scoped by image ID. The -//! repository is mounted at `/workspace`; keeping build output in a named -//! volume avoids slow host bind-mount I/O, particularly on Windows. +//! One container is created per invocation, however many checks the requested +//! recipe runs. The repository is bind-mounted at `/workspace`, so `target/` +//! stays visible from the host. Cargo's download caches are named volumes, +//! keeping that write-heavy path off the host boundary; `CARGO_HOME` and +//! `RUSTUP_HOME` themselves are deliberately not mounted, since a volume would +//! pin the first image's tools over every later one. //! -//! ### Make tiers use the container -//! -//! Native execution remains the default. Enable container execution for the -//! current shell: +//! ### Prerequisites //! -//! ```powershell -//! $env:ANVIL_RUNNER = "container" -//! just anvil-pr -//! ``` +//! - A container engine callable from the shell that runs `just`: Docker, or +//! Podman via `ANVIL_CONTAINER_ENGINE=podman`. On Windows that means Docker +//! Desktop, Podman, a Windows `docker` CLI pointed at an engine in WSL, or +//! Docker Engine installed only inside the default WSL distribution. No +//! Windows CLI is needed in that last case, since anvil reaches the engine +//! through `wsl.exe` when it finds none on `PATH` and translates repository +//! paths with `wslpath`. +//! - `just` and `PowerShell` Core (`pwsh`) on the host. +//! - A repository-owned `rust-toolchain.toml`. //! -//! On Unix: +//! Docker is supported; Podman works on a best-effort basis, with two +//! documented gaps on Windows. The image is pinned to `linux/amd64`, so on +//! ARM64 hosts it is emulated and is substantially slower. +//! +//! ### Image identity +//! +//! The tag *is* a SHA-256 digest over the inputs that define the image: +//! everything under `.anvil/container/`, `rust-toolchain.toml`, and the whole +//! generated `justfiles/anvil/` tree. The container directory is walked rather +//! than named file by file, because the Dockerfile is composed and a +//! repository can `COPY` a certificate or an install script it places there. +//! The recipe tree is included in +//! full because the image installs its tools by running `just anvil-setup`, +//! whose dependency chain runs through the tier, group and check recipes +//! before it reaches the install recipes -- so the routing decides *whether* a +//! tool is installed just as surely as `tools.just` decides *how*. +//! A changed tool pin names a tag that cannot already exist, so a build +//! follows. There is no staleness check because there is no staleness to +//! detect: a locally built image that is present was built from the inputs +//! that name it. An image *fetched* by the resolve hook only claims as much, +//! since the digest is over source files and cannot be re-derived from layers, +//! so that claim is only as strong as the registry it came from, which should +//! have immutable tags and restricted push. +//! +//! `anvil-container-tag` prints the reference without building it, and is the +//! single place the digest is computed, so a publisher can tag an image with +//! exactly the reference a consumer will later look up. //! -//! ```sh -//! ANVIL_RUNNER=container just anvil-pr -//! ``` +//! ### Controls //! -//! A one-off override is also supported: +//! | Variable | Effect | +//! |---|---| +//! | `ANVIL_CONTAINER_ENGINE` | `docker` (default) or `podman`. Read at run time. | +//! | `ANVIL_CONTAINER_NO_REBUILD=1` | Fail when the image is missing instead of building it, which distinguishes a cache miss from a build failure. | +//! | `ANVIL_CONTAINER_NO_RESOLVE=1` | Skip the resolve hook, so a query never pulls. | +//! | `ANVIL_CONTAINER_NO_CACHE=1` | Rebuild a tag that already resolves, ignoring the hook. | +//! | `ANVIL_IN_CONTAINER=1` | Set inside the image; makes a nested invocation run natively. | +//! | `GITHUB_TOKEN` | Forwarded when set on the host. When it is not, one is derived from `gh auth token` — but only for a target whose plan reads the variable, or for the interactive shell. | +//! +//! Supporting recipes: `anvil-container-tag`, `anvil-container-status` +//! (reports the engine and image without building or pulling), and +//! `anvil-container-down` (removes this repository's cache volumes). To rebuild +//! a tag that already resolves, scope `ANVIL_CONTAINER_NO_CACHE` to the one +//! invocation — an exported value is read by *every* later container command, +//! so a forgotten one rebuilds from scratch each time: //! //! ```text -//! just anvil_runner=container anvil-pr +//! $env:ANVIL_CONTAINER_NO_CACHE = '1' +//! try { just anvil-container just anvil-fmt } finally { Remove-Item Env:ANVIL_CONTAINER_NO_CACHE } //! ``` //! -//! To make containers the project default, edit `/Justfile` -//! and change the default value in the `anvil-runner` region from `"native"` -//! to `"container"`. Commit `/Justfile` with that policy -//! change. Set `ANVIL_RUNNER=native` to override it for one shell. +//! ### The hook //! -//! ### Controls +//! crates.io needs no credentials, so no hook is emitted by default. A +//! repository or a downstream catalog that needs one adds +//! `.anvil/container/hooks.ps1`, which the recipe loads by path whenever the +//! file is present, whoever wrote it: //! -//! | Variable | Effect | -//! |---|---| -//! | `ANVIL_CONTAINER_BASE_IMAGE` | Select a compatible digest-pinned Linux base image; the value is included in the content hash. | -//! | `ANVIL_CONTAINER_IMAGE` | Override the local image name. The content hash remains the tag. | -//! | `ANVIL_CONTAINER_NO_REBUILD=1` | Fail when the matching image is missing instead of building it. | -//! -//! The public driver never pulls `ANVIL_CONTAINER_IMAGE` remotely. Repositories -//! and derived catalogs can add trusted `customize.sh` and -//! `customize.ps1` files for image-build secrets, dependency preparation, -//! APRZ classification, runtime arguments, and cleanup through the documented -//! customization contract without changing the public command surface. -//! -//! Customization files execute on the host with the developer's permissions -//! before container isolation. Only run them from a repository or catalog you -//! trust. -//! -//! For GitHub API checks, the driver automatically uses an existing host -//! `GITHUB_TOKEN` or the token from an authenticated host `gh` CLI session. It -//! mounts the token read-only for the command and removes the temporary file -//! afterward. If `gh` is installed but not authenticated, an interactive run -//! pauses before building the image, explains the unauthenticated API limit, -//! and continues after the user completes `gh auth login` and presses Enter. -//! -//! ### Troubleshooting -//! -//! - A first-run image build is expected and may take several minutes. -//! - `wsl -e docker images anvil-dev` lists locally cached Anvil images from -//! Windows; use `docker images anvil-dev` inside Linux or WSL. -//! - `ANVIL_CONTAINER_NO_REBUILD=1` distinguishes a cache miss from a build -//! failure. -//! - Non-interactive runs cannot pause for login. Authenticate `gh` or set host -//! `GITHUB_TOKEN` before starting them. -//! - Regenerate managed files with `cargo anvil`; do not hand-edit -//! `.anvil/container/`. +//! ```powershell +//! function Anvil-BuildSecrets { @{ Secrets = @{ feed = (mint-a-token) } } } +//! function Anvil-RunEnv { @{ Env = @{ FEED_TOKEN = (mint-a-token) } } } +//! function Anvil-ResolveImage { param($tag) (fetch-a-published-image $tag) } +//! ``` +//! +//! All three are optional. Build secrets are passed to `BuildKit` by +//! environment variable name, so a value never reaches a process argument and +//! never reaches an image layer; run-time values are forwarded into the +//! container by name for the same reason. An empty value is a hard error, +//! because a build that quietly proceeded without its credential would install +//! a reduced tool set and then be tagged with the digest a credentialed build +//! produces. +//! +//! `Anvil-ResolveImage` is offered the tag when nothing local matches, and +//! returns the reference it made available: a registry reference, used as-is +//! rather than re-tagged locally, so the run stays honest about where the +//! image came from. Its presence is checked before use -- which proves +//! something carries that reference, not that the contents match the digest -- +//! and every failure falls through to a local build: a publisher that has not +//! caught up must not block the change it has not caught up with. +//! +//! The hook executes on the host, with the invoking user's permissions, before +//! any container isolation exists. Only use one from a repository or catalog +//! you trust. +//! +//! ### Customizing the image +//! +//! `.anvil/container/Dockerfile` is a **user-composed file with managed +//! regions**: anvil owns six regions inside it and keeps them current, and the +//! gaps between them are the repository's. Add to the gap that matches when the +//! addition is needed -- re-declare `ARG BASE_IMAGE` to build on another base, +//! a root CA or proxy before the first download, libraries a catalog tool +//! compiles against before `anvil-setup`, run-time tools after it. Adding in a +//! gap leaves anvil's content alone, so base and tool-pin bumps keep landing; +//! editing inside a region is preserved rather than overwritten, but freezes +//! those pins at the moment of the edit, which is why the gaps exist. +//! +//! A downstream catalog that needs a different base OS for every repository it +//! manages replaces the base and tool regions instead, inheriting the catalog +//! install and the entry contract. A replacement that copies more of the tree +//! must replace the ignore file with it, since the build context admits only +//! `justfiles/anvil/`, `.anvil/container/` and `rust-toolchain.toml`. See +//! [`artifacts::container`] and the design document for the full contract, the +//! host setup for each engine, and the known limitations. //! //! ## Checks and tiers //! @@ -464,9 +501,10 @@ pub(crate) mod workspace; /// `artifacts`, `run_app`, …) instead. #[doc(hidden)] pub mod test_support { + pub use crate::checksum::checksum_str; pub use crate::cli::Cli; pub use crate::decision::Decision; - pub use crate::manifest::{MANIFEST_FILE_NAME, Manifest}; + pub use crate::manifest::{MANIFEST_FILE_NAME, Manifest, RegionKey}; pub use crate::plan::Target; pub use crate::region::upsert_region; pub use crate::run::{RunOutcome, run_update}; diff --git a/crates/cargo-anvil/src/manifest.rs b/crates/cargo-anvil/src/manifest.rs index b2cff67b..2a2b0d37 100644 --- a/crates/cargo-anvil/src/manifest.rs +++ b/crates/cargo-anvil/src/manifest.rs @@ -15,7 +15,7 @@ //! exist today). use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use ohno::{AppError, IntoAppError as _, app_err, bail}; use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value}; @@ -61,6 +61,63 @@ pub struct RegionKey { pub id: String, } +/// Reject a manifest path that would resolve outside the repository root, or +/// to the root itself. +/// +/// Every path in the manifest is joined to the repository root and then read, +/// written or deleted. `Path::join` replaces the base entirely when given an +/// absolute path or a drive-qualified one, and `..` climbs out of it, so a +/// manifest that has been corrupted or hand-edited could direct those +/// operations at arbitrary locations. An empty or purely relative path +/// (`""`, `"."`) resolves to the repository root, so the same operations would +/// target a directory. Paths are always stored `/`-separated and +/// repository-relative, so anything else is malformed. +/// +/// # Errors +/// +/// Returns an error naming `context` and the offending path when it is +/// absolute, carries a drive or network-share prefix or a backslash, contains +/// a `..` component, or names no file at all. +fn ensure_contained(path: &str, context: &str) -> Result<(), AppError> { + // The format is `/`-separated, and the platforms disagree about `\`: + // Windows treats it as a separator, Unix as an ordinary filename character. + // A path carrying one therefore denotes different things on different + // machines and slips past whichever check is written in terms of the other + // -- `a\` counts a component on Windows yet ends no `/` segment, and + // `..\x` climbs on Windows while reading as one filename on Unix. Rejected + // rather than interpreted. + // + // A drive qualifier divides the platforms the same way: `Path::components` + // reports `C:x.txt` as a `Prefix` on Windows and as one ordinary name on + // Unix, so the check below cannot see it there. Matched lexically instead. + let drive_qualified = { + let mut chars = path.chars(); + matches!((chars.next(), chars.next()), (Some(letter), Some(':')) if letter.is_ascii_alphabetic()) + }; + if path.contains('\\') || drive_qualified { + bail!("{context} '{path}' must be a relative path inside the repository"); + } + let mut names = 0_usize; + for component in Path::new(path).components() { + match component { + Component::Normal(_) => names += 1, + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) | Component::ParentDir => { + bail!("{context} '{path}' must be a relative path inside the repository"); + } + } + } + // `Path::components` folds a trailing `.` away, so `a/.` arrives as a lone + // `Normal("a")` and satisfies the count above while naming a directory. + // Paths are stored `/`-separated, so the raw final segment is what decides + // whether a file is named at all. + let last = path.rsplit('/').next().unwrap_or_default(); + if names == 0 || last.is_empty() || last == "." { + bail!("{context} '{path}' must name a file inside the repository"); + } + Ok(()) +} + impl Manifest { /// Path the manifest should be saved at, given a workspace root. #[must_use] @@ -129,6 +186,7 @@ impl Manifest { if files.insert(path.clone(), checksum).is_some() { bail!("duplicate [[file]] entry for '{path}'"); } + ensure_contained(&path, "[[file]] entry")?; } } @@ -154,6 +212,7 @@ impl Manifest { if regions.insert(key.clone(), checksum).is_some() { bail!("duplicate [[region]] entry for host '{}' id '{}'", key.host, key.id); } + ensure_contained(&key.host, "[[region]] host")?; } } @@ -250,6 +309,36 @@ impl Manifest { checksum.into(), ); } + + /// The checksum recorded for an owned file, tolerating a case-only + /// difference between the lock and the path the caller resolved from disk. + /// + /// Plan items carry the host's real on-disk casing; a lock entry carries + /// whatever casing it had when it was written. Comparing the two exactly + /// makes a case-only rename look like a file anvil has never seen, which + /// costs the file its provenance: it is planned as repository-authored and + /// earns a spurious proposal, and the removal pass retires the very entry + /// that names it. + pub fn file_checksum(&self, path: &str) -> Option<&str> { + self.files.get(path).map(String::as_str).or_else(|| { + self.files + .iter() + .find(|(key, _)| key.as_str().eq_ignore_ascii_case(path)) + .map(|(_, checksum)| checksum.as_str()) + }) + } + + /// Whether the lock records any managed region hosted by `path`, comparing + /// the host without case for the reason [`Self::file_checksum`] gives. + /// + /// This is the provenance that separates "a file anvil composes, whose + /// regions have been removed" from "a file anvil has never owned". The two + /// have opposite recoveries, so the distinction decides which one a refusal + /// tells the reader to reach for. + #[must_use] + pub fn has_region_host(&self, path: &str) -> bool { + self.regions.keys().any(|key| key.host.as_str().eq_ignore_ascii_case(path)) + } } // Suppress an unused-import lint when no callers reference `Array`/`Value` @@ -434,4 +523,94 @@ mod tests { let text = sample_manifest().to_toml(); assert!(text.ends_with('\n')); } + + /// The lock keeps whatever casing a path had when it was written, while a + /// plan item carries the casing resolved from disk. An exact-only lookup + /// loses the provenance of anvil's own file on a case-only rename, which + /// is what makes the removal pass delete it. + #[test] + fn file_checksum_tolerates_a_case_only_difference() { + let mut manifest = Manifest::default(); + manifest.set_file("justfiles/anvil/tools.just", "sha256:body"); + + assert_eq!(manifest.file_checksum("justfiles/anvil/tools.just"), Some("sha256:body")); + assert_eq!(manifest.file_checksum("justfiles/anvil/Tools.just"), Some("sha256:body")); + assert_eq!(manifest.file_checksum("justfiles/anvil/other.just"), None); + } + + /// An exact match must win over a case-insensitive one, so a repository + /// holding two entries differing only in case still reads its own. + #[test] + fn file_checksum_prefers_the_exact_entry() { + let mut manifest = Manifest::default(); + manifest.set_file("a/Thing.just", "sha256:upper"); + manifest.set_file("a/thing.just", "sha256:lower"); + + assert_eq!(manifest.file_checksum("a/Thing.just"), Some("sha256:upper")); + assert_eq!(manifest.file_checksum("a/thing.just"), Some("sha256:lower")); + } + + /// The provenance that separates "a composed file whose regions were + /// removed" from "a file anvil has never owned". The two have opposite + /// recoveries, so a missed match sends the reader to delete a file anvil + /// rendered. + #[test] + fn has_region_host_matches_without_case() { + let mut manifest = Manifest::default(); + manifest.set_region(".anvil/container/Dockerfile", "anvil-container-base", "sha256:body"); + + assert!(manifest.has_region_host(".anvil/container/Dockerfile")); + assert!(manifest.has_region_host(".anvil/container/dockerfile")); + assert!(!manifest.has_region_host(".anvil/container/Other")); + assert!(!Manifest::default().has_region_host(".anvil/container/Dockerfile")); + } + #[test] + fn rejects_a_file_path_that_escapes_the_repository() { + for escape in [ + "../outside.txt", + "/etc/passwd", + "a/../../b.txt", + "a\\", + "..\\outside.txt", + "C:\\x.txt", + "C:x.txt", + "z:dir/file.txt", + ] { + // A TOML basic string treats `\` as an escape, so a path carrying + // one has to arrive doubled or the document itself is malformed and + // the parse fails before the path is ever validated. + let literal = escape.replace('\\', "\\\\"); + let toml = format!("version = 1\ntool = \"anvil\"\n\n[[file]]\npath = \"{literal}\"\nchecksum = \"sha256:x\"\n"); + let err = Manifest::parse(&toml).unwrap_err(); + assert!( + format!("{err}").contains("must be a relative path inside the repository"), + "unexpected error for '{escape}': {err}" + ); + } + } + + #[test] + fn rejects_a_region_host_that_escapes_the_repository() { + let toml = "version = 1\ntool = \"anvil\"\n\n[[region]]\nhost = \"../Justfile\"\nid = \"anvil-imports\"\nchecksum = \"sha256:x\"\n"; + let err = Manifest::parse(toml).unwrap_err(); + assert!(format!("{err}").contains("must be a relative path inside the repository"), "{err}"); + } + + #[test] + fn rejects_a_path_that_names_no_file() { + for empty in ["", ".", "./", "a/.", "a/"] { + let toml = format!("version = 1\ntool = \"anvil\"\n\n[[file]]\npath = \"{empty}\"\nchecksum = \"sha256:x\"\n"); + let err = Manifest::parse(&toml).unwrap_err(); + assert!( + format!("{err}").contains("must name a file inside the repository"), + "unexpected error for '{empty}': {err}" + ); + } + } + + #[test] + fn accepts_an_ordinary_nested_path() { + let toml = "version = 1\ntool = \"anvil\"\n\n[[file]]\npath = \"justfiles/anvil/tools.just\"\nchecksum = \"sha256:x\"\n"; + Manifest::parse(toml).expect("an ordinary nested path must be accepted"); + } } diff --git a/crates/cargo-anvil/src/plan.rs b/crates/cargo-anvil/src/plan.rs index 32b3771a..dbbdea41 100644 --- a/crates/cargo-anvil/src/plan.rs +++ b/crates/cargo-anvil/src/plan.rs @@ -20,9 +20,10 @@ use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use ohno::{AppError, IntoAppError as _}; +use ohno::{AppError, IntoAppError as _, bail}; use crate::decision::Decision; +use crate::io::resolve_existing_case_insensitive; use crate::manifest::{Manifest, RegionKey}; /// What is being changed by a single plan item. @@ -347,7 +348,7 @@ impl Plan { write_section(&mut out, "Will update", &updates); write_section(&mut out, "Will propose", &proposes); write_section(&mut out, "Will remove", &removes); - write_section(&mut out, "Orphaned (customized; transferring ownership)", &orphans_kept); + write_section(&mut out, "No longer managed (left in place; ownership transferred)", &orphans_kept); write_section(&mut out, "Will leave alone (silent)", &leave_alones); if !in_syncs.is_empty() { @@ -414,7 +415,7 @@ impl Plan { match (&item.target, item.decision) { (Target::File { path }, Decision::Write) => { let content = item.rendered.as_ref().expect("Write decision must carry rendered content"); - let abs = repo_root.join(path); + let abs = contained_path(repo_root, path)?; write_file(&abs, content)?; if let Some(checksum) = &item.rendered_checksum { next.files.insert(path.clone(), checksum.clone()); @@ -422,7 +423,7 @@ impl Plan { } (Target::File { path }, Decision::Propose) => { let content = item.rendered.as_ref().expect("Propose decision must carry rendered content"); - let abs = repo_root.join(format!("{path}.anvil-proposed")); + let abs = contained_path(repo_root, &format!("{path}.anvil-proposed"))?; write_file(&abs, content)?; if let Some(checksum) = &item.rendered_checksum { // Bump L to the new T so subsequent runs see the @@ -435,7 +436,7 @@ impl Plan { } (Target::Region { host, id }, Decision::Write) => { let spliced = item.spliced_host.as_ref().expect("region Write must carry spliced host"); - let abs = repo_root.join(host); + let abs = contained_path(repo_root, host)?; write_file(&abs, spliced)?; if let Some(checksum) = &item.rendered_checksum { next.regions.insert( @@ -449,7 +450,7 @@ impl Plan { } (Target::Region { host, id }, Decision::Propose) => { let spliced = item.spliced_host.as_ref().expect("region Propose must carry spliced host"); - let abs = repo_root.join(format!("{host}.anvil-proposed")); + let abs = contained_path(repo_root, &format!("{host}.anvil-proposed"))?; write_file(&abs, spliced)?; if let Some(checksum) = &item.rendered_checksum { // Same rationale as the File/Propose branch: bump @@ -466,10 +467,14 @@ impl Plan { } (Target::File { path }, Decision::Remove) => { // Untouched orphan file: delete and drop the - // manifest entry. If the file is already missing - // (race / external delete), absorb the error so - // the result is idempotent. - let abs = repo_root.join(path); + // manifest entry. The path is resolved to its on-disk + // casing first, because the manifest key is whatever + // casing was recorded and the file may since have been + // renamed in case only; deleting the unresolved path + // would leave the file behind with no lock entry. + // If the file is already missing (race / external + // delete), absorb the error so the result is idempotent. + let abs = contained_path(repo_root, &resolve_existing_case_insensitive(repo_root, path))?; if let Err(e) = std::fs::remove_file(&abs) && e.kind() != std::io::ErrorKind::NotFound { @@ -480,8 +485,11 @@ impl Plan { (Target::Region { host, id }, Decision::Remove) => { // Untouched orphan region: splice the markers + body // out of the host file and drop the manifest entry. + // The host is resolved to its on-disk casing for the + // write, for the reason the File arm above gives; the + // manifest key stays as recorded so the entry is purged. let spliced = item.spliced_host.as_ref().expect("region Remove must carry spliced host"); - let abs = repo_root.join(host); + let abs = contained_path(repo_root, &resolve_existing_case_insensitive(repo_root, host))?; write_file(&abs, spliced)?; next.regions.remove(&RegionKey { host: host.clone(), @@ -547,6 +555,49 @@ fn write_section(out: &mut String, header: &str, items: &[&PlanItem]) { } } +/// Join a repository-relative path to `repo_root` and verify that it resolves +/// inside it. +/// +/// `Manifest::ensure_contained` rejects a path that escapes lexically, but a +/// path built entirely from ordinary components still lands outside the +/// repository when one of those components is a symlink pointing out of it. +/// The manifest is committed content, so a checkout can carry both the link and +/// the entry that names it, and the write, delete or proposal below would +/// follow it. +/// +/// Resolution stops at the deepest ancestor that exists, because the path +/// itself frequently does not: a file anvil is about to create has nothing on +/// disk to resolve. That is sufficient, since a symlink can only be a component +/// that exists. +/// +/// # Errors +/// +/// Returns an error if the repository root cannot be resolved, or if the path +/// resolves outside it. +fn contained_path(repo_root: &Path, relpath: &str) -> Result { + let abs = repo_root.join(relpath); + let root = repo_root + .canonicalize() + .into_app_err_with(|| format!("failed to resolve the repository root {}", repo_root.display()))?; + + let mut probe = abs.as_path(); + loop { + if let Ok(resolved) = probe.canonicalize() { + if !resolved.starts_with(&root) { + bail!( + "manifest path '{relpath}' resolves to {}, outside the repository at {}", + resolved.display(), + root.display() + ); + } + return Ok(abs); + } + probe = probe + .parent() + .expect("the walk reaches a filesystem root, which always resolves, before running out of components"); + } +} + fn write_file(path: &Path, content: &str) -> Result<(), AppError> { let parent = path .parent() @@ -571,6 +622,60 @@ mod tests { use super::*; + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn a_path_inside_the_repository_is_joined_as_given() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("a")).unwrap(); + std::fs::write(tmp.path().join("a/b.txt"), "x").unwrap(); + + assert_eq!(contained_path(tmp.path(), "a/b.txt").unwrap(), tmp.path().join("a/b.txt")); + // A file anvil is about to create has nothing on disk to resolve, so + // the walk has to fall back to the deepest ancestor that does. + assert_eq!( + contained_path(tmp.path(), "a/new/deeper/c.txt").unwrap(), + tmp.path().join("a/new/deeper/c.txt") + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn a_path_that_resolves_outside_the_repository_is_refused() { + // The last line of defence, so it must hold on its own rather than + // assuming the manifest's lexical guard has already run. + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(tmp.path().join("outside")).unwrap(); + + let err = contained_path(&root, "../outside").unwrap_err(); + assert!( + format!("{err}").contains("outside the repository"), + "expected a containment error, got: {err}" + ); + } + + #[cfg(unix)] + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn a_symlinked_component_cannot_carry_a_write_out_of_the_repository() { + // The manifest is committed content, so one commit can add both a link + // that leaves the tree and an entry naming a path through it. Every + // component here is ordinary, so the lexical guard passes it. + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("repo"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap(); + + let err = contained_path(&root, "escape/victim.txt").unwrap_err(); + assert!( + format!("{err}").contains("outside the repository"), + "expected a containment error, got: {err}" + ); + } + #[test] fn empty_plan_is_in_sync() { let plan = Plan::default(); @@ -672,7 +777,7 @@ mod tests { let s = plan.summary(None); assert!(s.contains("Will remove: 1 item(s)")); assert!(s.contains("- dropped.txt")); - assert!(s.contains("Orphaned (customized; transferring ownership): 1 item(s)")); + assert!(s.contains("No longer managed (left in place; ownership transferred): 1 item(s)")); assert!(s.contains("- Justfile [anvil-old]")); } diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index 717c0ac8..7102afab 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -41,6 +41,15 @@ pub enum RegionPlacement { Start, /// Place the region after user content. End, + /// Insert a *new* region at this byte offset. An existing region is still + /// updated where it is found, so this only decides where an absent one + /// lands. + /// + /// Needed by hosts whose region order is semantic: appending a newly added + /// region at end-of-file would put it after regions it must precede, which + /// for a Dockerfile means `FROM` below the layers that depend on it. The + /// caller knows the declared order, so it computes the offset. + At(usize), } impl CommentSyntax { @@ -207,6 +216,36 @@ pub fn upsert_region_with_placement( return Ok(prepend_region(text, &rendered)); } + if let RegionPlacement::At(offset) = placement { + let offset = offset.min(text.len()); + // Snap to a line boundary only when the offset is not already on one. + // Callers point at the start of the line the region should displace + // (typically a preceding region's `end_line.end`); advancing + // unconditionally would skip that line, landing the region after the + // first line of the repository's gap content and splitting it. An + // offset that does fall mid-line is rounded forward, because splitting + // a line around the sentinels turns one valid instruction into two + // invalid halves. + let on_line_boundary = offset == 0 || text[..offset].ends_with('\n'); + let offset = if on_line_boundary { + offset + } else { + text[offset..].find('\n').map_or(text.len(), |index| offset + index + 1) + }; + let (before, after) = text.split_at(offset); + let mut out = String::with_capacity(text.len() + rendered.len() + 2); + out.push_str(before); + if !before.is_empty() && !before.ends_with("\n\n") { + out.push('\n'); + } + out.push_str(&rendered); + if !after.is_empty() && !after.starts_with('\n') { + out.push('\n'); + } + out.push_str(after); + return Ok(out); + } + // No region present — append at the end with one blank line of separation // if the file is non-empty and doesn't end in two newlines. let mut out = String::with_capacity(text.len() + rendered.len() + 1); @@ -447,6 +486,75 @@ mod tests { assert_eq!(new, "# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); } + /// `At` exists for hosts whose region order is semantic: a region added in a + /// later release has to land at its declared position, not at end-of-file. + /// The offset the caller computes points at the end of the preceding + /// region's sentinel, so the split must fall on the following line boundary. + #[test] + fn at_placement_on_a_line_boundary_does_not_skip_the_following_line() { + // The offset callers actually pass is a line start -- a preceding + // region's `end_line.end`. Advancing past the next newline would put + // the region after the first line of the gap and split it in two. + let host = "# >>> anvil-managed: a\nbody\n# <<< anvil-managed: a\n# my gap line\nRUN later\n"; + let offset = host.find("# my gap line").unwrap(); + let new = upsert_region_with_placement(host, "x", "body\n", SYN, RegionPlacement::At(offset)).unwrap(); + assert_eq!( + new, + "# >>> anvil-managed: a\nbody\n# <<< anvil-managed: a\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n\n# my gap line\nRUN later\n", + "the region belongs above the gap content, not inside it" + ); + } + + #[test] + fn at_placement_inserts_after_the_line_containing_the_offset() { + let host = "# syntax=docker/dockerfile:1\nFROM base\n"; + // Offset lands mid-way through line 1; the region must go *after* that + // whole line, never inside it. + let new = upsert_region_with_placement(host, "x", "body\n", SYN, RegionPlacement::At(5)).unwrap(); + assert_eq!( + new, + "# syntax=docker/dockerfile:1\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n\nFROM base\n" + ); + } + + #[test] + fn at_placement_into_empty_text_adds_no_leading_blank() { + let new = upsert_region_with_placement("", "x", "body\n", SYN, RegionPlacement::At(0)).unwrap(); + assert_eq!(new, "# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); + } + + #[test] + fn at_placement_does_not_double_the_separating_blank_line() { + // Preceding content already ends with a blank line, so no second one. + let host = "FROM base\n\nRUN later\n"; + let new = upsert_region_with_placement(host, "x", "body\n", SYN, RegionPlacement::At(10)).unwrap(); + assert_eq!( + new, + "FROM base\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n\nRUN later\n" + ); + } + + #[test] + fn at_placement_separates_the_region_from_the_content_that_follows() { + // The tail does not start with a newline, so one is inserted -- without + // it the closing sentinel and the next instruction would share a line. + let host = "FROM base\nRUN later\n"; + let new = upsert_region_with_placement(host, "x", "body\n", SYN, RegionPlacement::At(0)).unwrap(); + assert_eq!( + new, + "# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n\nFROM base\nRUN later\n" + ); + } + + #[test] + fn at_placement_updates_an_existing_region_where_it_already_is() { + // The offset only decides where an *absent* region lands. One already + // present is replaced in place, so a stale offset cannot move it. + let host = "FROM base\n# >>> anvil-managed: x\nold\n# <<< anvil-managed: x\nRUN later\n"; + let new = upsert_region_with_placement(host, "x", "new\n", SYN, RegionPlacement::At(0)).unwrap(); + assert_eq!(new, "FROM base\n# >>> anvil-managed: x\nnew\n# <<< anvil-managed: x\nRUN later\n"); + } + #[test] fn start_placement_prepends_absent_region() { let new = upsert_region_with_placement( diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs index cb03b78e..4ec11368 100644 --- a/crates/cargo-anvil/src/run.rs +++ b/crates/cargo-anvil/src/run.rs @@ -12,6 +12,7 @@ use std::path::Path; use ohno::{AppError, bail}; use tracing::info; +use crate::anvil::artifacts::container; use crate::anvil::artifacts::region::DELTA_REGION_ID; use crate::backend::{self, Backend}; use crate::catalog::Catalog; @@ -159,6 +160,9 @@ fn build_plan( ) -> Result { let mut plan = Plan::default(); let mut hosts = HostTextCache::default(); + // Hosts already reported as unsafe to compose. Every region targeting one + // hits the same fault, and four copies of one message is noise. + let mut composed = ComposedHosts::default(); for artifact in catalog.artifacts() { match artifact { @@ -170,12 +174,12 @@ fn build_plan( } } Artifact::Region(spec) => { - push_region(repo_root, workspace, manifest, &mut plan, &mut hosts, spec)?; + push_region(repo_root, workspace, manifest, &mut plan, &mut hosts, &mut composed, spec)?; } } } - plan_removals(repo_root, manifest, &mut plan, &mut hosts)?; + plan_removals(repo_root, manifest, &mut plan, &mut hosts, &composed)?; // Region proposals are computed eagerly as each region is visited, so a // `Propose` planned before a sibling `Write`/`Remove` on the same host @@ -303,25 +307,26 @@ fn push_region( manifest: &Manifest, plan: &mut Plan, hosts: &mut HostTextCache, + composed: &mut ComposedHosts, spec: &RegionSpec, ) -> Result<(), AppError> { match &spec.host { HostSelector::Path(path) => { - push_region_at(repo_root, manifest, plan, hosts, path, spec)?; + push_region_at(repo_root, manifest, plan, hosts, composed, path, spec)?; } HostSelector::WorkspaceCargoToml => { if workspace.has_workspace_table { - push_region_at(repo_root, manifest, plan, hosts, "Cargo.toml", spec)?; + push_region_at(repo_root, manifest, plan, hosts, composed, "Cargo.toml", spec)?; } } HostSelector::SingleCrateCargoToml => { if !workspace.has_workspace_table { - push_region_at(repo_root, manifest, plan, hosts, "Cargo.toml", spec)?; + push_region_at(repo_root, manifest, plan, hosts, composed, "Cargo.toml", spec)?; } } HostSelector::EachMemberManifest => { for member in &workspace.members { - push_region_at(repo_root, manifest, plan, hosts, &member.manifest_relpath, spec)?; + push_region_at(repo_root, manifest, plan, hosts, composed, &member.manifest_relpath, spec)?; } } } @@ -342,12 +347,49 @@ fn push_region_at( manifest: &Manifest, plan: &mut Plan, hosts: &mut HostTextCache, + composed: &mut ComposedHosts, host: &str, spec: &RegionSpec, ) -> Result<(), AppError> { let host = resolve_existing_case_insensitive(repo_root, host); + if let Some((scaffold, order)) = composed_host_spec(&host) + && !composed.states.contains_key(&host) + { + let state = match hosts.get_or_read(repo_root, &host)? { + Some(text) => composed_host_state(order, &host, &text, manifest), + // Nothing on disk. The scaffold becomes the base the first region + // splices into, carrying the parts of the file that cannot live + // inside a region -- the `# syntax=` parser directive above all. It + // is written once and never reconciled; everything outside the + // sentinels is the repository's from then on. + None => ComposedHostState::SeedFromScaffold, + }; + if matches!(state, ComposedHostState::SeedFromScaffold) { + hosts.set(&host, scaffold.to_owned()); + } + composed.states.insert(host.clone(), state); + } + if let Some(ComposedHostState::Unsafe(reason)) = composed.states.get(&host) { + if composed.reported.insert(host.clone()) { + plan.refusal(format!( + "Refused to manage {host}: {reason}. Nothing was written to it, and other \ + artifacts were still planned." + )); + } + plan.push(PlanItem::noop( + Target::Region { + host, + id: spec.id.as_str().to_owned(), + }, + Decision::LeaveAlone, + )); + return Ok(()); + } let current = hosts.get_or_read(repo_root, &host)?; - let placement = region_placement(spec.id.as_str()); + let placement = composed_host_spec(&host).map_or_else( + || region_placement(spec.id.as_str()), + |(scaffold, order)| composed_placement(order, scaffold, spec.id.as_str(), current.as_deref()), + ); let body = match delta_region_body(current.as_deref(), spec) { DeltaRegionBody::Managed => spec.body.as_str(), DeltaRegionBody::PreserveRepositoryKey => { @@ -397,6 +439,44 @@ fn push_region_at( Ok(()) } +/// Where a region belongs inside a composed host whose order is semantic. +/// +/// An existing region is updated where it is found, so this only decides where +/// an *absent* one lands — which matters whenever anvil adds a region to a +/// release. Appending it at end-of-file, the default for every other host, +/// would put it after regions it must precede: a newly added base-image +/// argument would land below the `FROM` that consumes it. Without this, adding +/// a region would either corrupt or (with the composition check) refuse every +/// file that already exists. +fn composed_placement(order: &[&str], scaffold: &str, id: &str, text: Option<&str>) -> RegionPlacement { + let Some(text) = text else { + return RegionPlacement::End; + }; + if matches!(find_region(text, id, CommentSyntax::Hash), Ok(Some(_))) { + // Present: `upsert_region` replaces it where it is, and the offset is + // never consulted. + return RegionPlacement::End; + } + let Some(position) = order.iter().position(|candidate| *candidate == id) else { + return RegionPlacement::End; + }; + // The nearest declared predecessor that is actually in the file. Anything + // after it and before the next present region is the gap this region opens. + for earlier in order[..position].iter().rev() { + if let Ok(Some(region)) = find_region(text, earlier, CommentSyntax::Hash) { + return RegionPlacement::At(region.end_line.end); + } + } + // Nothing precedes it, so it goes to the top -- but below the scaffold, + // which for a Dockerfile is the `# syntax=` parser directive that BuildKit + // honors only as the very first line. + RegionPlacement::At(if text.starts_with(scaffold.trim_end_matches('\n')) { + scaffold.trim_end_matches('\n').len() + } else { + 0 + }) +} + fn region_placement(region_id: &str) -> RegionPlacement { if region_id == DELTA_REGION_ID { RegionPlacement::Start @@ -405,6 +485,155 @@ fn region_placement(region_id: &str) -> RegionPlacement { } } +/// The scaffold and region order for a host anvil composes rather than owns. +/// +/// Most region hosts are files the repository already has (`Cargo.toml`, +/// `deny.toml`) or files whose first region can be appended to nothing, and +/// whose regions are order-independent — TOML tables, line sets. The container +/// Dockerfile is neither. +/// +/// The **scaffold** exists because `# syntax=docker/dockerfile:1` is a `BuildKit` +/// parser directive honored only when nothing precedes it, not even a comment — +/// so it cannot live inside a region, whose opening sentinel *is* a comment. It +/// is written when the file is absent and never reconciled afterwards. +/// +/// The **order** is load-bearing: `FROM` must precede every instruction that +/// depends on it, and the toolchain must exist before `anvil-setup` runs. +/// +/// The comparison ignores case because the caller has already replaced the +/// canonical path with the host's real on-disk name. A repository that spelled +/// the file `dockerfile` would otherwise miss this lookup entirely, and with it +/// every guard below: the regions would be appended at end-of-file, under the +/// repository's own `FROM`. +fn composed_host_spec(host_relpath: &str) -> Option<(&'static str, &'static [&'static str])> { + host_relpath + .eq_ignore_ascii_case(container::DOCKERFILE_PATH) + .then_some((container::DOCKERFILE_HEADER, container::DOCKERFILE_REGION_ORDER)) +} + +/// What a composed host's current content allows anvil to do with it. +enum ComposedHostState { + /// Carries every region anvil owns, in the declared order. Update in place. + Composable, + /// Either absent, or a byte-identical render of a version that owned this + /// path as a whole file. Every byte of it is anvil's, so the scaffold + /// replaces it and the regions rebuild the file. + SeedFromScaffold, + /// Anvil cannot reach a valid file from here without either destroying + /// repository content or writing something that will not build. + Unsafe(String), +} + +/// Per-host bookkeeping for composed hosts, for the length of one pass. +#[derive(Default)] +struct ComposedHosts { + /// Classification per host, computed once from its on-disk state. Later + /// regions targeting the same host see text this pass has already spliced, + /// which is partially composed by construction — re-classifying that would + /// refuse the file halfway through writing it. + states: HashMap, + /// Hosts whose refusal has already been reported, so one fault produces one + /// diagnostic rather than one per region. + reported: BTreeSet, +} + +/// Classify a composed host before anything is written to it. +/// +/// A Dockerfile is the first managed-region host where the file is only valid +/// in one arrangement, so the states an ordinary region host can ignore all +/// matter here. Each rejected case is one anvil could "handle" by splicing +/// regions in anyway, and each would produce a file that is silently wrong: +/// `upsert_region` appends a missing region at end-of-file, which is the right +/// answer only when the file is already the composed shape. +fn composed_host_state(order: &[&str], host_relpath: &str, text: &str, manifest: &Manifest) -> ComposedHostState { + // A malformed sentinel is its own diagnosis. Folding it in with "absent" + // would report a broken region as a missing one, sending the reader looking + // for content that is in fact right there with a mismatched marker. + let mut malformed: Option = None; + let present: Vec<(&str, usize)> = order + .iter() + .filter_map(|id| match find_region(text, id, CommentSyntax::Hash) { + Ok(Some(region)) => Some((*id, region.start_line.start)), + Ok(None) => None, + Err(err) => { + malformed.get_or_insert_with(|| format!("its '{id}' region cannot be read: {err}")); + None + } + }) + .collect(); + if let Some(reason) = malformed { + return ComposedHostState::Unsafe(reason); + } + + if present.is_empty() { + // Nothing of anvil's is in the file. Either it is a whole-file render + // anvil produced -- safe to replace, because every byte of it came from + // anvil -- or it is content anvil has never owned. + // + // The checksum comparison is the whole safety property: without it, a + // repository that edited such a file would have that edit silently + // destroyed. Appending the regions instead is not a kinder answer, + // because everything already in the file would then sit above `FROM`. + return match manifest.file_checksum(host_relpath) { + Some(recorded) if recorded == checksum_str(text) => ComposedHostState::SeedFromScaffold, + Some(_) => ComposedHostState::Unsafe( + "it was edited after anvil last wrote it, and anvil composes the file from managed \ + regions rather than owning it whole. Move the edits you want to keep into the \ + gaps of a freshly generated file, or delete it and re-run to have one written" + .to_owned(), + ), + // A composed host is recorded in `regions` and never in `files`, so + // "no file entry" is not the same as "anvil has never seen this". + // It is also every composed file that has lost its regions -- a + // merge resolved the other way, a revert, a checkout of an older + // commit -- and for those the lock still holds the provenance. The + // two cases have opposite recoveries, and telling someone to delete + // a file anvil rendered would throw away the gap content the + // message elsewhere tells them to preserve. + None if manifest.has_region_host(host_relpath) => ComposedHostState::Unsafe( + "anvil composes it from managed regions and the lock still records them, but none \ + of them is in the file: they were dropped by a merge, a revert or an edit. \ + Restore the file from version control to recover the regions and your own content \ + around them together" + .to_owned(), + ), + None => ComposedHostState::Unsafe( + "it exists but anvil has never owned it, so there is nowhere to splice the managed \ + regions that would not put your content above `FROM`. Delete it and re-run to have \ + a composed file written, then move your instructions into the gaps" + .to_owned(), + ), + }; + } + + // A file carrying some regions but not all is what every adopter has the + // first time anvil adds one to the set -- a normal upgrade, not damage. + // `composed_placement` inserts each missing region at its declared position + // rather than at end-of-file, so the result stays ordered and the gap + // content is untouched. Refusing here would make every future region + // addition mean "delete your file". + // + // The same regions, in the order the file carries them. Comparing the two + // sequences rather than adjacent offsets keeps the check free of an + // ordering operator whose boundary cannot be exercised: two distinct + // regions can never share a start offset, so `<` and `<=` over positions + // would be indistinguishable by any test. + let mut by_position = present.clone(); + by_position.sort_by_key(|&(_, start)| start); + match present + .iter() + .zip(by_position.iter()) + .find(|(expected, found)| expected.0 != found.0) + { + None => ComposedHostState::Composable, + Some((expected, found)) => ComposedHostState::Unsafe(format!( + "region '{}' appears before '{}', but must follow it. Restore the documented order and \ + re-run", + found.0, expected.0 + )), + } +} + enum DeltaRegionBody { Managed, PreserveRepositoryKey, @@ -444,7 +673,13 @@ fn delta_region_body(host_text: Option<&str>, spec: &RegionSpec) -> DeltaRegionB /// This is what removes orphaned cloud-workflow artifacts, dropped catalog entries, /// disabled-backend files, and any other previously-tracked item that /// is no longer in scope. -fn plan_removals(repo_root: &Path, previous: &Manifest, plan: &mut Plan, hosts: &mut HostTextCache) -> Result<(), AppError> { +fn plan_removals( + repo_root: &Path, + previous: &Manifest, + plan: &mut Plan, + hosts: &mut HostTextCache, + composed: &ComposedHosts, +) -> Result<(), AppError> { let live_files: BTreeSet = plan .items() .iter() @@ -461,12 +696,42 @@ fn plan_removals(repo_root: &Path, previous: &Manifest, plan: &mut Plan, hosts: Target::File { .. } => None, }) .collect(); + let live_region_hosts: BTreeSet = live_regions.iter().map(|(host, _)| host.clone()).collect(); for (path, last) in &previous.files { - if live_files.contains(path) { + // The lock carries the casing the path had when the entry was written, + // while every live plan item carries the casing resolved from disk, so + // the two are compared through the same resolution. Without it a + // case-only rename makes anvil's own file look retired and the removal + // below deletes the artifact this very pass just wrote. + let resolved = resolve_existing_case_insensitive(repo_root, path); + if live_files.contains(&resolved) { + continue; + } + // A path that is no longer an owned file but *is* the host of a live + // managed region has not been retired -- it has changed ownership + // model. Deleting it here would erase what the region writes planned + // for the same pass just produced, and the user content around them + // with it. Drop the stale manifest entry and leave the file alone; the + // region entries now describe what anvil owns inside it. + // + // Unless the host was *refused*, in which case anvil declined to touch + // it and the lock entry is the provenance the next run reclassifies + // from. Dropping it would make a file anvil rendered look like one it + // has never owned, flip the diagnostic to the wrong wording, and + // destroy the clean recovery -- reverting the edit -- that the refusal + // message tells the reader to use. "Nothing was written to it" has to + // be true of the lock as well as the file. + if live_region_hosts.contains(&resolved) { + if !matches!(composed.states.get(&resolved), Some(ComposedHostState::Unsafe(_))) { + plan.push(PlanItem::orphaned_kept(Target::File { path: path.clone() })); + } continue; } - let disk = read_file_if_present(&repo_root.join(path))?; + // Read under the resolved casing: a lock entry recorded before a + // case-only rename would otherwise find nothing on disk, classify a + // file that is still present as `AlreadyGone`, and delete it. + let disk = read_file_if_present(&repo_root.join(&resolved))?; let disk_checksum = disk.as_deref().map(checksum_str); match decide_removal(last, disk_checksum.as_deref()) { // A file still matching its last render is safe to delete; an @@ -488,7 +753,39 @@ fn plan_removals(repo_root: &Path, previous: &Manifest, plan: &mut Plan, hosts: if live_regions.contains(&(key.host.clone(), key.id.clone())) { continue; } - let Some(host_text) = hosts.get_or_read(repo_root, &key.host)? else { + // A lock key records the casing its host had when the entry was + // written; a live key records the casing the write path resolved from + // disk this pass. A case-only rename of the host therefore makes every + // one of its regions look orphaned at the very moment the pass has + // rewritten all of them under the new name -- and for a composed host + // that is the whole file, so removing them would strip this pass's own + // writes and leave a Dockerfile with no `FROM`. The regions are still + // there under a key the manifest already carries, so the honest answer + // is to transfer ownership to the new key and touch nothing on disk. + // + // No `resolved_host != key.host` guard: the `continue` above has + // already established that the recorded key is not live, so when the + // resolution changes nothing this lookup repeats it and fails. + let resolved_host = resolve_existing_case_insensitive(repo_root, &key.host); + // A refused host was not opened, and "nothing was written to it" has to + // be true of the lock as well as the file -- the same invariant the + // owned-file loop above keeps. A lock entry naming a region the catalog + // does not declare would otherwise reach `remove_region` below, so the + // run would splice a block out of the very file whose refusal says it + // was left alone, and purge the provenance the next run reclassifies + // from. Unreachable while every declared id is live; a region rename or + // retirement is what exposes it. + if matches!(composed.states.get(&resolved_host), Some(ComposedHostState::Unsafe(_))) { + continue; + } + if live_regions.contains(&(resolved_host.clone(), key.id.clone())) { + plan.push(PlanItem::orphaned_kept(Target::Region { + host: key.host.clone(), + id: key.id.clone(), + })); + continue; + } + let Some(host_text) = hosts.get_or_read(repo_root, &resolved_host)? else { // Host file is gone entirely; just drop the manifest // entry. Emit OrphanedKept (no-op apply) so the plan // can record the transfer of ownership consistently. @@ -510,9 +807,12 @@ fn plan_removals(repo_root: &Path, previous: &Manifest, plan: &mut Plan, hosts: // Splice against — and update — the accumulated host text // so a removal composes with the writes already planned // for this host this pass instead of clobbering them - // (their item is applied earlier; this one, later). + // (their item is applied earlier; this one, later). The + // cache is keyed by the resolved spelling, which is what + // the writes used; reading under the recorded spelling + // would miss it and splice into the pre-pass text. let spliced = remove_region(&host_text, &key.id, syntax)?; - hosts.set(&key.host, spliced.clone()); + hosts.set(&resolved_host, spliced.clone()); plan.push(PlanItem::remove_region(key.host.clone(), key.id.clone(), spliced)); } RemovalDecision::OrphanedKept | RemovalDecision::AlreadyGone => { @@ -544,6 +844,91 @@ mod tests { fs::write(path, contents).unwrap(); } + /// `composed_placement` decides where an *absent* region lands in a host + /// whose order is semantic. Every branch matters: getting it wrong puts a + /// newly added region after ones it must precede, which for a Dockerfile + /// means `FROM` below the layers that depend on it. + mod composed_placement { + use super::*; + + const SCAFFOLD: &str = "# syntax=docker/dockerfile:1\n"; + const ORDER: &[&str] = &["a", "b", "c"]; + + fn region(id: &str, body: &str) -> String { + format!("# >>> anvil-managed: {id}\n{body}# <<< anvil-managed: {id}\n") + } + + #[test] + fn a_host_that_does_not_exist_yet_appends() { + // Nothing to order against; the regions are written in catalog + // order onto the scaffold. + assert_eq!(super::super::composed_placement(ORDER, SCAFFOLD, "b", None), RegionPlacement::End); + } + + #[test] + fn a_region_already_present_is_replaced_where_it_is() { + let host = format!("{SCAFFOLD}{}", region("b", "body\n")); + assert_eq!( + super::super::composed_placement(ORDER, SCAFFOLD, "b", Some(&host)), + RegionPlacement::End, + "an existing region is upserted in place, so the offset is never consulted" + ); + } + + #[test] + fn a_region_outside_the_declared_order_appends() { + let host = format!("{SCAFFOLD}{}", region("a", "body\n")); + assert_eq!( + super::super::composed_placement(ORDER, SCAFFOLD, "unknown", Some(&host)), + RegionPlacement::End + ); + } + + #[test] + fn a_missing_region_lands_after_its_nearest_present_predecessor() { + // `c` is absent and both `a` and `b` are present, so it must follow + // `b` -- the nearest, not merely the first. + let host = format!("{SCAFFOLD}{}{}", region("a", "first\n"), region("b", "second\n")); + let RegionPlacement::At(offset) = super::super::composed_placement(ORDER, SCAFFOLD, "c", Some(&host)) else { + panic!("a missing region with a present predecessor must be placed by offset"); + }; + assert_eq!(offset, host.len(), "it belongs after the close of `b`"); + } + + #[test] + fn a_missing_region_skips_predecessors_that_are_absent_too() { + // `c` is missing and so is its nearest predecessor `b`, so the + // search has to walk past `b` and anchor on `a`. A fixture where + // the first candidate matches would never exercise the skip. + let host = format!("{SCAFFOLD}{}", region("a", "first\n")); + let RegionPlacement::At(offset) = super::super::composed_placement(ORDER, SCAFFOLD, "c", Some(&host)) else { + panic!("expected an offset placement"); + }; + assert_eq!(offset, host.len(), "it belongs after the close of `a`, the only one present"); + } + + #[test] + fn the_first_region_lands_below_the_scaffold_never_above_it() { + // The scaffold is the parser directive, which BuildKit honors only + // as line 1. Placing the first region at byte 0 would push it down. + let host = format!("{SCAFFOLD}{}", region("b", "second\n")); + let RegionPlacement::At(offset) = super::super::composed_placement(ORDER, SCAFFOLD, "a", Some(&host)) else { + panic!("expected an offset placement"); + }; + assert_eq!(offset, SCAFFOLD.trim_end_matches('\n').len()); + assert!(offset > 0, "the directive must keep line 1"); + } + + #[test] + fn a_host_without_the_scaffold_places_the_first_region_at_the_top() { + let host = region("b", "second\n"); + assert_eq!( + super::super::composed_placement(ORDER, SCAFFOLD, "a", Some(&host)), + RegionPlacement::At(0) + ); + } + } + fn empty_workspace() -> TempDir { let tmp = TempDir::new().unwrap(); let root = tmp.path(); @@ -1336,7 +1721,14 @@ mod tests { let mut previous = Manifest::default(); previous.set_region("Justfile", "anvil-r", "sha256:body"); let mut plan = Plan::default(); - plan_removals(tmp.path(), &previous, &mut plan, &mut HostTextCache::default()).unwrap(); + plan_removals( + tmp.path(), + &previous, + &mut plan, + &mut HostTextCache::default(), + &ComposedHosts::default(), + ) + .unwrap(); let orphans: Vec<(&str, &str)> = plan .items() .iter() @@ -1367,7 +1759,14 @@ mod tests { // decide_removal classifies the region as a customized orphan. previous.set_region("Justfile", "anvil-r", "sha256:stored-different"); let mut plan = Plan::default(); - plan_removals(tmp.path(), &previous, &mut plan, &mut HostTextCache::default()).unwrap(); + plan_removals( + tmp.path(), + &previous, + &mut plan, + &mut HostTextCache::default(), + &ComposedHosts::default(), + ) + .unwrap(); let orphans: Vec<(&str, &str)> = plan .items() .iter() diff --git a/crates/cargo-anvil/templates/anvil/container/Containerfile b/crates/cargo-anvil/templates/anvil/container/Containerfile deleted file mode 100644 index fa68b18f..00000000 --- a/crates/cargo-anvil/templates/anvil/container/Containerfile +++ /dev/null @@ -1,69 +0,0 @@ -# syntax=docker/dockerfile:1 -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. - -ARG BASE_IMAGE=docker.io/library/debian:bookworm-slim@sha256:63a496b5d3b99214b39f5ed70eb71a61e590a77979c79cbee4faf991f8c0783e -FROM ${BASE_IMAGE} - -ARG ANVIL_IMAGE_ID -ARG JUST_VERSION=1.56.0 -ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 -ARG POWERSHELL_VERSION=7.6.3 -ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 -ARG RUSTUP_VERSION=1.29.0 -ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 - -ENV DEBIAN_FRONTEND=noninteractive \ - CARGO_HOME=/usr/local/cargo \ - RUSTUP_HOME=/usr/local/rustup \ - RUSTUP_NO_UPDATE_CHECK=1 \ - PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential ca-certificates clang libclang-dev curl git libicu-dev \ - libssl-dev pkg-config tar \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsSLo /tmp/powershell.tar.gz \ - "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ - && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ - && mkdir -p /opt/microsoft/powershell/7 \ - && tar -xzf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ - && chmod 755 /opt/microsoft/powershell/7/pwsh \ - && ln -s /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh \ - && rm /tmp/powershell.tar.gz - -RUN curl -fsSLo /tmp/just.tar.gz \ - "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ - && echo "${JUST_SHA256} /tmp/just.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/just.tar.gz -C /usr/local/bin just \ - && chmod 755 /usr/local/bin/just \ - && rm /tmp/just.tar.gz - -RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ - "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ - && echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \ - && chmod 755 /tmp/rustup-init \ - && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ - && rm /tmp/rustup-init - -WORKDIR /opt/anvil -COPY . . -RUN test -f rust-toolchain.toml || { \ - echo "anvil-container requires rust-toolchain.toml" >&2; \ - exit 1; \ - } -RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry \ - --mount=type=cache,id=anvil-cargo-git,target=/usr/local/cargo/git \ - --mount=type=cache,id=anvil-cargo-target,target=/tmp/anvil-target \ - printf "anvil_runner := \"native\"\nimport 'justfiles/anvil/mod.just'\n" > Justfile \ - && CARGO_TARGET_DIR=/tmp/anvil-target just anvil-setup - -COPY .anvil/container/entrypoint.sh /usr/local/bin/anvil-container-entrypoint -RUN chmod 755 /usr/local/bin/anvil-container-entrypoint - -ENV ANVIL_IN_CONTAINER=1 -LABEL io.github.cargo-anvil.image-id="${ANVIL_IMAGE_ID}" -WORKDIR /workspace -ENTRYPOINT ["anvil-container-entrypoint"] -CMD ["bash"] diff --git a/crates/cargo-anvil/templates/anvil/container/Containerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Containerfile.dockerignore deleted file mode 100644 index 6566e657..00000000 --- a/crates/cargo-anvil/templates/anvil/container/Containerfile.dockerignore +++ /dev/null @@ -1,26 +0,0 @@ -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Deny-all allow-list for the image build context. -# -# Docker matches each candidate against every pattern in order and lets the -# last match win, testing the path itself *and each of its parent directories* -# (moby/patternmatcher MatchesOrParentMatches). A bare directory re-inclusion -# such as `!justfiles` therefore re-admits the entire subtree below it, which -# would defeat this allow-list, so list only leaf patterns here. Docker still -# descends into a denied directory when some re-inclusion pattern is prefixed -# by it, so the intermediate directories need no entries of their own. -# -# Parent testing also reaches through a single-segment re-inclusion: a -# subdirectory of `.anvil/container/` matches `!.anvil/container/*` in its own -# right. The image-ID helpers list that directory one level deep, so a nested -# file is not an image input; `.anvil/container/*/*` states that leaf-only -# contract in the allow-list too, at every depth, because a deeper candidate -# always has an ancestor of exactly that shape. -** -!rust-toolchain.toml -!justfiles/anvil/*.just -!justfiles/anvil/checks/*.just -!justfiles/anvil/groups/*.just -!.anvil/container/* -.anvil/container/*/* -.anvil/container/customize.sh -.anvil/container/customize.ps1 diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.base.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.base.region new file mode 100644 index 00000000..2c2f57e1 --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.base.region @@ -0,0 +1,16 @@ +FROM ${BASE_IMAGE} + +ARG JUST_VERSION=1.56.0 +ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 +ARG POWERSHELL_VERSION=7.6.3 +ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 +ARG RUSTUP_VERSION=1.29.0 +ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 +ARG CARGO_BINSTALL_VERSION=1.21.1 +ARG CARGO_BINSTALL_SHA256=630c8f8803a686aa6779497f0f0fb51d49822fb5fc3c514d8ced33b34e338e6e + +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + RUSTUP_NO_UPDATE_CHECK=1 \ + PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.baseimage.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.baseimage.region new file mode 100644 index 00000000..8cc7a8b3 --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.baseimage.region @@ -0,0 +1,8 @@ +# Prebuilt binaries installed by `anvil-setup binstall` link against this +# image's glibc, so it tracks the Linux runner the generated workflows use. +# Digest-pinned: a floating tag moves content under a reference that claims to +# name fixed content. +# +# Re-declare BASE_IMAGE in the gap below to build on another base; a later ARG +# wins, and the pins anvil maintains stay current. +ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore new file mode 100644 index 00000000..577907f0 --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. +# Update the corresponding template in the cargo-anvil crate. +# +# BuildKit reads `.dockerignore` in preference to a root +# `.dockerignore`, so this scopes the exec-image build context without the +# repository having to own a root ignore file or having one silently overridden. +# +# The build context is the repository root but the image only needs two things. +# Excluding everything else keeps a cold build from streaming the whole +# worktree (and every stale `target/`) to the daemon. +# +# The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` +# so that a cold build does not stream unrelated trees to the daemon. The +# recipes are copied to drive `just anvil-setup`, which needs the whole tree to +# parse, and the whole tree is hashed into the image tag: the tier, group and +# check recipes decide which tools `anvil-setup` reaches, not just the catalog. +# +# `.anvil/container/` is admitted because the Dockerfile is composed: the gaps +# between anvil's regions exist for a repository to add its own instructions, +# and the headline case -- `COPY`ing a corporate root CA in before the first +# download -- needs the file to be in the context. Denying it would leave the +# gap documented but unusable for anything but `RUN`. It is also the directory +# the image tag digests, so what the context admits and what the tag covers stay +# the same set -- including the `.anvil-proposed` siblings both exclude, which +# are anvil's review artifacts rather than build inputs. +* +!justfiles +justfiles/* +!justfiles/anvil +justfiles/anvil/**/*.anvil-proposed +!.anvil +.anvil/* +!.anvil/container +.anvil/container/**/*.anvil-proposed +!rust-toolchain.toml diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.entry.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.entry.region new file mode 100644 index 00000000..bbdea1b4 --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.entry.region @@ -0,0 +1,6 @@ +# Consumed by `anvil-container` itself: a nested invocation from inside the +# image runs the recipe natively instead of launching another container. +ENV ANVIL_IN_CONTAINER=1 + +WORKDIR /workspace +CMD ["bash"] diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.header b/crates/cargo-anvil/templates/anvil/container/Dockerfile.header new file mode 100644 index 00000000..d063d715 --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.header @@ -0,0 +1,4 @@ +# syntax=docker/dockerfile:1 + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region new file mode 100644 index 00000000..d3306a1e --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -0,0 +1,21 @@ +# The whole recipe tree is copied because `just` parses it to reach the install +# recipes. +# +# The credential files are removed in the same layer that used them: a build +# secret never lands in a layer, but anything the install *writes* with it is +# ordinary content, and the `chmod` below would publish it world-readable. A +# later `RUN` cannot undo that, because the earlier layer keeps them. +# +# `registry` and `git` must exist before the `chmod`. The run mounts a named +# volume over each, and an engine seeds a new volume from the image path it +# covers; a path that does not exist seeds as root-owned 0755, which the +# `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +WORKDIR /opt/anvil +COPY justfiles ./justfiles +COPY rust-toolchain.toml ./ +RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ + && just anvil-setup binstall \ + && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ + && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME}/.netrc" \ + && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ + && chmod -R a+rwX "${CARGO_HOME}" "${RUSTUP_HOME}" diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.tools.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.tools.region new file mode 100644 index 00000000..dd3e315c --- /dev/null +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.tools.region @@ -0,0 +1,42 @@ +# clang/libclang are required by cargo-spellcheck; the rest is the usual Rust +# link-time set. A bare base has no C runtime development files, so every link +# step fails without build-essential. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential ca-certificates clang libclang-dev curl git libicu-dev \ + libssl-dev pkg-config tar \ + && rm -rf /var/lib/apt/lists/* + +# pwsh is not optional: every generated anvil recipe is a `script("pwsh", +# "-NoProfile")` recipe. +RUN curl -fsSLo /tmp/powershell.tar.gz \ + "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ + && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ + && mkdir -p /opt/microsoft/powershell/7 \ + && tar -xzf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ + && chmod 755 /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh \ + && rm /tmp/powershell.tar.gz + +RUN curl -fsSLo /tmp/just.tar.gz \ + "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + && echo "${JUST_SHA256} /tmp/just.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/just.tar.gz -C /usr/local/bin just \ + && chmod 755 /usr/local/bin/just \ + && rm /tmp/just.tar.gz + +RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + && echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \ + && chmod 755 /tmp/rustup-init \ + && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ + && rm /tmp/rustup-init + +RUN curl -fsSLo /tmp/cargo-binstall.tgz \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" \ + && echo "${CARGO_BINSTALL_SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c - \ + && mkdir -p "${CARGO_HOME}/bin" \ + && tar -xzf /tmp/cargo-binstall.tgz -C "${CARGO_HOME}/bin" cargo-binstall \ + && chmod 755 "${CARGO_HOME}/bin/cargo-binstall" \ + && rm /tmp/cargo-binstall.tgz + diff --git a/crates/cargo-anvil/templates/anvil/container/README.md b/crates/cargo-anvil/templates/anvil/container/README.md deleted file mode 100644 index b9cd1642..00000000 --- a/crates/cargo-anvil/templates/anvil/container/README.md +++ /dev/null @@ -1,228 +0,0 @@ - - -# Run Anvil checks in a local container - -Use `just anvil-container` to run generated Anvil checks in a reproducible -Linux environment without installing the complete Rust and Cargo tool catalog -on the host. - -Native execution remains the default. The first container run builds an image -matching the repository's generated configuration. Later runs reuse that image, -dependency caches, and compilation output. - -## Quick start - -Ensure Docker Engine is running, then run: - -```text -just anvil-container anvil-clippy -``` - -The first run builds the matching image and can take several minutes. - -## Prerequisites - -- [Docker Engine](https://docs.docker.com/engine/install/) 23.0 or newer, - installed directly in Linux or WSL and usable by the current user. -- `git` and `just` on the host. -- Bash on Linux and WSL; PowerShell Core (`pwsh`) and WSL 2 on Windows. -- `[script]` support enabled in the root `Justfile`. Add `set unstable` when - required by the installed `just` version. -- A `rust-toolchain.toml` in the repository root. -- A Linux or WSL environment capable of running `linux/amd64` images, either - natively on x86-64 or through Docker emulation on ARM64. - -On Windows, the driver invokes Docker from the default WSL distribution rather -than calling Windows `docker.exe`. Regardless of how Docker is installed, this -command must succeed from PowerShell: - -```text -wsl -e docker version -``` - -Start the Docker service inside WSL when it is stopped and add the WSL user to -the `docker` group when non-root access is not already configured. Docker -Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment. Image -builds and checks can therefore be substantially slower than on x86-64 hosts. - -## Security boundary - -> [!WARNING] -> `customize.sh` and `customize.ps1` execute on the host with the developer's -> permissions before container isolation begins. Reviewing and trusting these -> files is equivalent to reviewing and trusting any other host-executed script -> in the checked-out branch. - -## Common workflows - -Run one check: - -```text -just anvil-container anvil-clippy -``` - -Run the complete pull-request tier: - -```text -just anvil-container anvil-pr -``` - -Every argument is treated as a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not supported by this command surface. - -Open an interactive Bash shell in the image: - -```text -just anvil-container -``` - -### Use containers for tier commands - -Native execution remains the default. To route tier commands such as -`just anvil-pr` through the container for the current shell: - -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` - -On Unix: - -```sh -ANVIL_RUNNER=container just anvil-pr -``` - -For one invocation: - -```text -just anvil_runner=container anvil-pr -``` - -To make container execution the repository default, change the default value -in the `anvil-runner` region of the repository-root `Justfile` from `"native"` -to `"container"` and commit that policy. Set `ANVIL_RUNNER=native` to override -the repository default for the current shell. - -Tier routing starts a nested `just` invocation. Output and exit status are -preserved, but outer `--dry-run`, dependency introspection, global options, and -CLI variable assignments are not propagated to the selected private tier. -Values other than `native` and `container` are rejected. - -## Images and caches - -The image name includes a content-based tag derived from the repository's Rust -toolchain, generated Anvil recipes, and container build configuration. A -relevant change selects a new image automatically; older branches can continue -using their matching images. - -The following data is reused between runs: - -- the matching container image; -- repository-scoped Cargo registry and Cargo Git caches; -- compilation output in a repository- and image-specific `target` volume. - -The repository is mounted read/write at `/workspace`. Build output remains in a -named volume instead of the host `target/`, avoiding incompatible artifacts and -slow host-to-virtual-machine I/O. - -## GitHub authentication - -`anvil-aprz` and aggregate tiers that include it require GitHub API -authentication. The driver uses either: - -- the host `GITHUB_TOKEN`; or -- the token from an authenticated host `gh` session. - -Trusted customization can provision a short-lived token by setting -`GITHUB_TOKEN`; the driver reads it after loading and validating customization. - -Authenticate the GitHub CLI with: - -```text -gh auth login --hostname github.com -``` - -For an aggregate tier, the driver first runs `anvil-aprz` in a short-lived -container with the token mounted read-only. After it succeeds, the driver runs -the remaining checks in another container without the token. Temporary token -files are removed afterward. - -An interactive invocation can pause while you authenticate. A non-interactive -invocation fails with instructions when authentication is unavailable. - -## Configuration - -| Variable | Effect | -|---|---| -| `ANVIL_RUNNER` | Selects `native` or `container` execution for tier commands | -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a digest-pinned compatible Linux base image and changes the content-based tag | -| `ANVIL_CONTAINER_IMAGE` | Changes the local image name; the content-based tag is retained | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails instead of building when the matching image is absent | - -The public driver builds images locally and does not pull -`ANVIL_CONTAINER_IMAGE` from a registry. - -The default base is digest-pinned Debian Bookworm. Set -`ANVIL_CONTAINER_BASE_IMAGE` to another image compatible with the generated -Debian-based `Containerfile` when a lower glibc baseline is required. A -different package ecosystem such as Azure Linux requires a derived -`Containerfile`. The value must use `image@sha256:` form so the -selected base remains part of the content-addressed image identity. - -Two simultaneous cold invocations can both build the same missing image. This -is accepted for local development: the content-addressed tag converges on the -same inputs, at the cost of duplicate work. - -## Troubleshooting - -| Problem | Resolution | -|---|---| -| Docker is not found on Linux or WSL | Install Docker Engine 23.0 or newer inside that environment | -| Docker is unavailable from Windows | Run `wsl -e docker version`; install or start Docker Engine in the default WSL distribution | -| Docker requires elevated access | Add the Linux/WSL user to the `docker` group, then start a new shell | -| ARM64 execution is slow | The current image is `linux/amd64` and runs through Docker emulation | -| `linux/amd64` cannot run | Configure Docker to run `linux/amd64` images | -| `[script]` recipes are unavailable | Enable `[script]` support; older `just` versions require `set unstable` | -| `rust-toolchain.toml` is missing | Add the repository-owned toolchain file at the repository root | -| GitHub authentication is unavailable | Run `gh auth login --hostname github.com` or set host `GITHUB_TOKEN` | -| A matching image is missing with `ANVIL_CONTAINER_NO_REBUILD=1` | Unset the variable to allow the local image build | -| The first run is slow | The initial image build installs the pinned tool catalog; later runs reuse it | - -Use `docker images anvil-dev` inside Linux or WSL to list locally cached -default Anvil images. - -## Managed files - -This directory is managed by `cargo-anvil`. Regenerate it with `cargo anvil` -instead of editing its files directly. - -> [!IMPORTANT] -> These assets previously lived in `justfiles/anvil/container/`. `cargo anvil` -> relocates the files it generated, but it does not track a hand-authored -> `customize.sh` or `customize.ps1`. Move any such file to -> `.anvil/container/` yourself; the driver only loads customization from the -> new location and warns on stderr when it finds one left behind. - -## Advanced repository customization - -A repository or derived catalog can add one trusted customization file per -supported host: - -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 -``` - -The driver sources the matching file as trusted host code before authentication, -image construction, and recipe execution. The documented customization -contract provides inputs and validated outputs for APRZ classification, build -secrets, dependency preparation, runtime arguments, and cleanup. - -Customization source is excluded from image identity and the build context. -Non-secret image behavior must be represented by hashed static files such as -the `Containerfile`, entrypoint, or supporting build scripts. - -See the [container customization contract](https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md#8-container-customization) -for the complete interface and security requirements. diff --git a/crates/cargo-anvil/templates/anvil/container/entrypoint.sh b/crates/cargo-anvil/templates/anvil/container/entrypoint.sh deleted file mode 100644 index fadac5f9..00000000 --- a/crates/cargo-anvil/templates/anvil/container/entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -eu - -if [ "$(id -u)" -ne 0 ]; then - if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then - HOME="/tmp/anvil-user" - export HOME - fi - - user_cargo_home="$HOME/.cargo" - mkdir -p "$user_cargo_home" - for file in config.toml .crates.toml .crates2.json; do - if [ -r "$CARGO_HOME/$file" ]; then - cp -f "$CARGO_HOME/$file" "$user_cargo_home/$file" - fi - done - export CARGO_HOME="$user_cargo_home" - ln -sfn /usr/local/cargo/registry "$CARGO_HOME/registry" - ln -sfn /usr/local/cargo/git "$CARGO_HOME/git" -fi - -exec "$@" diff --git a/crates/cargo-anvil/templates/anvil/container/image-id.ps1 b/crates/cargo-anvil/templates/anvil/container/image-id.ps1 deleted file mode 100644 index dfc6a53f..00000000 --- a/crates/cargo-anvil/templates/anvil/container/image-id.ps1 +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$inputs = @( - 'rust-toolchain.toml' -) -$toolchainPath = Join-Path $repoRoot 'rust-toolchain.toml' -if (-not (Test-Path -LiteralPath $toolchainPath -PathType Leaf)) { - throw 'anvil-container requires a repository-owned rust-toolchain.toml.' -} -$containerPath = Join-Path $repoRoot '.anvil/container' -$containerRecipe = 'justfiles/anvil/container.just' -$containerfile = Join-Path $containerPath 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } -# The container entry recipe drives execution on the host; it is not image -# content, so it must not participate in image identity. -$inputs += Get-ChildItem (Join-Path $repoRoot 'justfiles/anvil') -Recurse -File -Filter '*.just' | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } | - Where-Object { -not $_.Equals($containerRecipe, $pathComparison) } -$executionOnly = @( - 'image-id.ps1', - 'image-id.sh', - 'README.md', - 'run-in-container.ps1', - 'run-in-container.sh', - 'customize.sh', - 'customize.ps1' -) -# customize.sh/customize.ps1 are trusted runtime orchestration, not image -# content: their source must never affect the image ID or build context. -# Static, non-secret build customization belongs in a hashed artifact instead. -$inputs += Get-ChildItem $containerPath -File | - Where-Object { $_.Name -notin $executionOnly } | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } -$uniqueInputs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($inputPath in $inputs) { - [void]$uniqueInputs.Add($inputPath) -} -$inputs = [string[]]$uniqueInputs -[Array]::Sort($inputs, [StringComparer]::Ordinal) - -$payload = [Text.StringBuilder]::new() -[void]$payload.Append("ANVIL_CONTAINER_BASE_IMAGE`n").Append($baseImage).Append("`n") -foreach ($relative in $inputs) { - $path = Join-Path $repoRoot $relative - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Container image input is missing: $relative" - } - $content = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n") - [void]$payload.Append($relative).Append("`n").Append($content).Append("`n") -} - -$bytes = [Text.Encoding]::UTF8.GetBytes($payload.ToString()) -$hash = [Security.Cryptography.SHA256]::HashData($bytes) -Write-Output ([Convert]::ToHexString($hash).ToLowerInvariant()) diff --git a/crates/cargo-anvil/templates/anvil/container/image-id.sh b/crates/cargo-anvil/templates/anvil/container/image-id.sh deleted file mode 100644 index e0ed8105..00000000 --- a/crates/cargo-anvil/templates/anvil/container/image-id.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi - -toolchain_path="$repo_root/rust-toolchain.toml" -if [[ ! -f "$toolchain_path" ]]; then - echo 'anvil-container requires a repository-owned rust-toolchain.toml.' >&2 - exit 1 -fi - -container_dir="$repo_root/.anvil/container" -container_recipe="justfiles/anvil/container.just" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$container_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -inputs=(rust-toolchain.toml) -while IFS= read -r path; do - relative="${path#"$repo_root"/}" - # The container entry recipe drives execution on the host; it is not - # image content, so it must not participate in image identity. - if [[ "$relative" != "$container_recipe" ]]; then - inputs+=("$relative") - fi -done < <(find "$repo_root/justfiles/anvil" -type f -name '*.just' -print) - -for path in "$container_dir"/*; do - [[ -f "$path" ]] || continue - case "${path##*/}" in - image-id.ps1 | image-id.sh | README.md \ - | run-in-container.ps1 | run-in-container.sh \ - | customize.sh | customize.ps1) continue ;; - esac - inputs+=("${path#"$repo_root"/}") -done - -if command -v sha256sum >/dev/null 2>&1; then - hash_command=(sha256sum) -elif command -v shasum >/dev/null 2>&1; then - hash_command=(shasum -a 256) -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi - -write_normalized_file() { - local path="$1" - local line status - while true; do - line="" - if IFS= read -r line <&3; then - status=0 - else - status=$? - fi - if ((status != 0)) && [[ -z "$line" ]]; then - break - fi - printf '%s' "${line%$'\r'}" - if ((status == 0)); then - printf '\n' - else - break - fi - done 3<"$path" -} - -{ - printf 'ANVIL_CONTAINER_BASE_IMAGE\n%s\n' "$base_image" - while IFS= read -r relative; do - path="$repo_root/$relative" - if [[ ! -f "$path" ]]; then - echo "Container image input is missing: $relative" >&2 - exit 1 - fi - printf '%s\n' "$relative" - write_normalized_file "$path" - printf '\n' - done < <(printf '%s\n' "${inputs[@]}" | LC_ALL=C sort -u) -} | "${hash_command[@]}" | awk '{print $1}' diff --git a/crates/cargo-anvil/templates/anvil/container/run-in-container.ps1 b/crates/cargo-anvil/templates/anvil/container/run-in-container.ps1 deleted file mode 100644 index a6239f73..00000000 --- a/crates/cargo-anvil/templates/anvil/container/run-in-container.ps1 +++ /dev/null @@ -1,338 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param( - [Parameter(Position = 0, ValueFromRemainingArguments = $true)] - [string[]]$Recipe -) - -$ErrorActionPreference = 'Stop' - -function ConvertTo-AnvilVersion([string]$Value) { - $match = [regex]::Match($Value, '^(\d+)\.(\d+)(?:\.(\d+))?') - if (-not $match.Success) { - throw "anvil-container: could not parse Docker Engine version '$Value'." - } - [version]::new( - [int]$match.Groups[1].Value, - [int]$match.Groups[2].Value, - $(if ($match.Groups[3].Success) { [int]$match.Groups[3].Value } else { 0 }) - ) -} - -function Test-AnvilContainerStringArray([string]$Name, $Value) { - if ($Value -isnot [array]) { - throw "anvil-container: `$$Name must be a string array." - } - foreach ($item in $Value) { - if ($item -isnot [string] -or [string]::IsNullOrEmpty($item)) { - throw "anvil-container: `$$Name entries must be non-empty strings." - } - } -} - -function Test-AnvilContainerBuildArgs($Value) { - for ($index = 0; $index -lt $Value.Count; $index++) { - $item = $Value[$index] - if ($item -eq '--secret') { - $index++ - if ($index -ge $Value.Count) { - throw 'anvil-container: $AnvilContainerBuildArgs requires a value after --secret.' - } - } elseif (-not $item.StartsWith('--secret=', [StringComparison]::Ordinal)) { - throw 'anvil-container: $AnvilContainerBuildArgs accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' - } - } -} - -function Test-AnvilRecipeNeedsGitHubToken([string]$Name) { - $Name -in @( - 'anvil-aprz', - 'anvil-scheduled', - '_anvil-scheduled', - 'anvil-scheduled-advisories', - '_anvil-scheduled-advisories', - 'anvil-full', - '_anvil-full' - ) -} - -function Get-AnvilGitHubToken { - $token = $env:GITHUB_TOKEN - if (-not $token -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { - $token = (& gh auth token --hostname github.com 2>$null) - if ($LASTEXITCODE -ne 0) { $token = $null } - } catch { - $token = $null - } - } - if ($token) { $token = $token.Trim() } - if ($token) { return $token } - return $null -} - -if ($env:ANVIL_IN_CONTAINER) { - if ($Recipe.Count -eq 0) { & bash } else { & just @Recipe } - exit $LASTEXITCODE -} - -foreach ($recipeArg in $Recipe) { - if ($recipeArg -notmatch '^_?anvil-[A-Za-z0-9-]+$') { - throw "anvil-container: expected each argument to be an anvil-* recipe, got '$recipeArg'." - } -} - -if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { - throw 'anvil-container: WSL 2 is required. See .anvil/container/README.md.' -} - -$versionText = (& wsl -e docker version --format '{{.Server.Version}}' 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $versionText) { - throw 'anvil-container: `wsl -e docker version` must succeed. Install or start Docker Engine in the default WSL distribution; this driver does not invoke Windows docker.exe.' -} -$versionText = $versionText.Trim() -if ((ConvertTo-AnvilVersion $versionText) -lt [version]'23.0.0') { - throw "anvil-container: Docker Engine 23.0.0 or newer is required (found $versionText)." -} -$wslArchitecture = (& wsl -e uname -m 2>$null) -if ($LASTEXITCODE -eq 0 -and $wslArchitecture) { - $wslArchitecture = $wslArchitecture.Trim() - if ($wslArchitecture -notin @('x86_64', 'amd64')) { - [Console]::Error.WriteLine( - "anvil-container: warning: $wslArchitecture requires emulation for linux/amd64; builds and checks may be substantially slower." - ) - } -} - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$scriptDir = Join-Path $repoRoot '.anvil/container' -$wslRepoRoot = (& wsl -e wslpath -a $repoRoot).Trim() -if ($LASTEXITCODE -ne 0 -or -not $wslRepoRoot) { - throw 'anvil-container: could not translate the repository path into the default WSL distribution.' -} -$wslScriptDir = "$wslRepoRoot/.anvil/container" -$containerfile = Join-Path $scriptDir 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$imageId = (& (Join-Path $scriptDir 'image-id.ps1')).Trim() -$imageBase = if ($env:ANVIL_CONTAINER_IMAGE) { $env:ANVIL_CONTAINER_IMAGE } else { 'anvil-dev' } -$image = "${imageBase}:$imageId" -$repoBytes = [Text.Encoding]::UTF8.GetBytes($wslRepoRoot) -$repoHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($repoBytes)).ToLowerInvariant() -$targetVolume = "anvil-target-$($repoHash.Substring(0, 12))-$($imageId.Substring(0, 12))" - -$needsGitHubToken = $false -foreach ($recipeArg in $Recipe) { - if (Test-AnvilRecipeNeedsGitHubToken $recipeArg) { - $needsGitHubToken = $true - break - } -} -$runsOnlyGitHubCheck = $Recipe.Count -eq 1 -and $Recipe[0] -eq 'anvil-aprz' - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -$null = & wsl -e docker image inspect $image 2>$null -$imageExists = $LASTEXITCODE -eq 0 - -New-Variable -Name AnvilContainerRepoRoot -Value $repoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDir -Value $scriptDir -Option ReadOnly -New-Variable -Name AnvilContainerRepoRootWsl -Value $wslRepoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDirWsl -Value $wslScriptDir -Option ReadOnly -New-Variable -Name AnvilContainerResolvedImage -Value $image -Option ReadOnly -New-Variable -Name AnvilContainerImageExists -Value $imageExists -Option ReadOnly -New-Variable -Name AnvilContainerRequestedRecipes -Value $Recipe -Option ReadOnly -New-Variable -Name AnvilContainerHostIsWindows -Value ([bool]$IsWindows) -Option ReadOnly - -# Customization outputs, initialized before sourcing so a missing customize.ps1 -# leaves every phase a documented no-op. -$AnvilContainerBuildArgs = @() -$AnvilContainerPrepareArgs = @() -$AnvilContainerPrepareCommand = @() -$AnvilContainerRunArgs = @() -$AnvilContainerNeedsGitHubToken = $needsGitHubToken -$AnvilContainerCleanup = $null -$githubToken = $null -$githubTokenFile = $null -$exitCode = 0 -$customizeScript = Join-Path $scriptDir 'customize.ps1' -$legacyCustomizeScript = Join-Path $repoRoot 'justfiles/anvil/container/customize.ps1' - -try { - if (Test-Path -LiteralPath $customizeScript -PathType Leaf) { - . $customizeScript - } - elseif (Test-Path -LiteralPath $legacyCustomizeScript -PathType Leaf) { - [Console]::Error.WriteLine( - 'anvil-container: warning: ignoring justfiles/anvil/container/customize.ps1; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.ps1 to keep it active.' - ) - } - - Test-AnvilContainerStringArray 'AnvilContainerBuildArgs' $AnvilContainerBuildArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareArgs' $AnvilContainerPrepareArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareCommand' $AnvilContainerPrepareCommand - Test-AnvilContainerStringArray 'AnvilContainerRunArgs' $AnvilContainerRunArgs - Test-AnvilContainerBuildArgs $AnvilContainerBuildArgs - if ($AnvilContainerNeedsGitHubToken -isnot [bool]) { - throw 'anvil-container: $AnvilContainerNeedsGitHubToken must be a Boolean.' - } - $needsGitHubToken = $needsGitHubToken -or $AnvilContainerNeedsGitHubToken - if ($AnvilContainerPrepareArgs.Count -gt 0 -and $AnvilContainerPrepareCommand.Count -eq 0) { - throw 'anvil-container: $AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand.' - } - if ($AnvilContainerCleanup -and $AnvilContainerCleanup -isnot [scriptblock]) { - throw 'anvil-container: $AnvilContainerCleanup must be a script block.' - } - $githubToken = if ($needsGitHubToken) { Get-AnvilGitHubToken } else { $null } - if ($needsGitHubToken -and -not $githubToken) { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' - } - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' - } - Write-Host 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' - [void](Read-Host 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel)') - $githubToken = Get-AnvilGitHubToken - if (-not $githubToken) { - throw 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' - } - } - if (-not $imageExists) { - if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { - throw "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." - } - & wsl -e docker build ` - --platform linux/amd64 ` - --tag $image ` - --file "$wslScriptDir/Containerfile" ` - --build-arg "ANVIL_IMAGE_ID=$imageId" ` - --build-arg "BASE_IMAGE=$baseImage" ` - @AnvilContainerBuildArgs ` - $wslRepoRoot - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker build failed with exit code $LASTEXITCODE." - } - } - - $containerUid = (& wsl -e id -u).Trim() - $containerGid = (& wsl -e id -g).Trim() - if ($containerUid -notmatch '^\d+$' -or $containerGid -notmatch '^\d+$') { - throw 'anvil-container: could not determine the default WSL user identity.' - } - $registryVolume = "anvil-cargo-registry-$($repoHash.Substring(0, 12))" - $gitVolume = "anvil-cargo-git-$($repoHash.Substring(0, 12))" - foreach ($volume in @($registryVolume, $gitVolume, $targetVolume)) { - $null = & wsl -e docker volume create $volume - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume creation failed for '$volume' with exit code $LASTEXITCODE." - } - } - $mountArgs = @( - '--mount', "type=bind,source=$wslRepoRoot,target=/workspace", - '--mount', "type=volume,source=$registryVolume,target=/usr/local/cargo/registry", - '--mount', "type=volume,source=$gitVolume,target=/usr/local/cargo/git", - '--mount', "type=volume,source=$targetVolume,target=/workspace/target" - ) - & wsl -e docker run --rm --pull=never ` - --platform linux/amd64 ` - --user 0:0 ` - @mountArgs ` - $image sh -c "chown ${containerUid}:${containerGid} /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume initialization failed with exit code $LASTEXITCODE." - } - - $runArgs = @( - 'run', '--rm', '--pull=never', - '--platform', 'linux/amd64', - '--user', "${containerUid}:${containerGid}", - '--env', 'ANVIL_IN_CONTAINER=1', - '--env', 'HOME=/tmp/anvil-user', - '--workdir', '/workspace' - ) - $runArgs += $mountArgs - $prepareRunArgs = @($runArgs) - $runArgs += $AnvilContainerRunArgs - foreach ($name in @( - 'PR_TITLE', - 'BASE_REF', - 'ANVIL_IMPACT', - 'GITHUB_BASE_REF', - 'SYSTEM_PULLREQUEST_TARGETBRANCH' - )) { - if (Test-Path "Env:$name") { - $runArgs += @('--env', "$name=$((Get-Item "Env:$name").Value)") - } - } - if ($AnvilContainerPrepareCommand.Count -gt 0) { - & wsl -e docker @prepareRunArgs @AnvilContainerPrepareArgs $image @AnvilContainerPrepareCommand - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: preparation command failed with exit code $LASTEXITCODE." - } - } - - if ($githubToken) { - $githubTokenFile = Join-Path ([IO.Path]::GetTempPath()) "anvil-github-token-$PID-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Create($githubTokenFile).Dispose() - if ($IsWindows) { - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls.exe $githubTokenFile '/inheritance:r' '/grant:r' "*$($userSid):(F)" | Out-Null - } else { - & chmod 600 $githubTokenFile - } - if ($LASTEXITCODE -ne 0) { - throw 'anvil-container: failed to restrict permissions on the temporary GitHub token file.' - } - [IO.File]::WriteAllText($githubTokenFile, $githubToken, [Text.Encoding]::ASCII) - $githubToken = $null - $wslTokenFile = (& wsl -e wslpath -a $githubTokenFile).Trim() - if ($LASTEXITCODE -ne 0 -or -not $wslTokenFile) { - throw 'anvil-container: could not translate the temporary GitHub token path into WSL.' - } - $githubRunArgs = @($runArgs) - $githubRunArgs += @( - '--mount', - "type=bind,source=$wslTokenFile,target=/run/secrets/anvil-github-token,readonly" - ) - if ($runsOnlyGitHubCheck) { - $runArgs = $githubRunArgs - } else { - & wsl -e docker @githubRunArgs $image just anvil-aprz - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: isolated anvil-aprz failed with exit code $LASTEXITCODE." - } - $runArgs += @('--env', 'ANVIL_APRZ_ALREADY_RAN=1') - } - } - - if ($Recipe.Count -eq 0) { - & wsl -e docker @runArgs --interactive --tty $image bash - } else { - & wsl -e docker @runArgs $image just @Recipe - } - $exitCode = $LASTEXITCODE -} finally { - if ($githubTokenFile) { - Remove-Item -LiteralPath $githubTokenFile -Force -ErrorAction SilentlyContinue - } - if ($AnvilContainerCleanup) { & $AnvilContainerCleanup } -} - -exit $exitCode diff --git a/crates/cargo-anvil/templates/anvil/container/run-in-container.sh b/crates/cargo-anvil/templates/anvil/container/run-in-container.sh deleted file mode 100644 index 3f91cc0a..00000000 --- a/crates/cargo-anvil/templates/anvil/container/run-in-container.sh +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - if (($# == 0)); then exec bash; else exec just "$@"; fi -fi - -for recipe_arg in "$@"; do - if [[ ! "$recipe_arg" =~ ^_?anvil-[A-Za-z0-9-]+$ ]]; then - echo "anvil-container: expected each argument to be an anvil-* recipe, got '$recipe_arg'." >&2 - exit 2 - fi -done - -anvil_recipe_needs_github_token() { - case "$1" in - anvil-aprz | anvil-scheduled | _anvil-scheduled | anvil-scheduled-advisories | _anvil-scheduled-advisories \ - | anvil-full | _anvil-full) return 0 ;; - *) return 1 ;; - esac -} - -version_at_least() { - local found="${1%%[-+]*}" - local required="${2%%[-+]*}" - local found_major found_minor found_patch found_extra - local required_major required_minor required_patch required_extra - IFS=. read -r found_major found_minor found_patch found_extra <<<"$found" - IFS=. read -r required_major required_minor required_patch required_extra <<<"$required" - found_patch="${found_patch:-0}" - required_patch="${required_patch:-0}" - for component in \ - "$found_major" "$found_minor" "$found_patch" \ - "$required_major" "$required_minor" "$required_patch" - do - case "$component" in - '' | *[!0-9]*) return 2 ;; - esac - done - if ((found_major != required_major)); then ((found_major > required_major)); return; fi - if ((found_minor != required_minor)); then ((found_minor > required_minor)); return; fi - ((found_patch >= required_patch)) -} - -command -v docker >/dev/null 2>&1 || { - echo "anvil-container: Docker Engine is required. See .anvil/container/README.md." >&2 - exit 1 -} - -version="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" || { - echo "anvil-container: Docker Engine is unavailable. Start the Docker service and ensure the current user can access it." >&2 - exit 1 -} -minimum="23.0.0" -if ! version_at_least "$version" "$minimum"; then - echo "anvil-container: Docker Engine $minimum or newer is required (found $version)." >&2 - exit 1 -fi -host_arch="$(uname -m 2>/dev/null || true)" -case "$host_arch" in - x86_64 | amd64 | '') ;; - *) echo "anvil-container: warning: $host_arch requires emulation for linux/amd64; builds and checks may be substantially slower." >&2 ;; -esac - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi -script_dir="$repo_root/.anvil/container" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$script_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -image_id="$(bash "$script_dir/image-id.sh")" -image_base="${ANVIL_CONTAINER_IMAGE:-anvil-dev}" -image="${image_base}:${image_id}" -if command -v sha256sum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | sha256sum | cut -c1-12)" -elif command -v shasum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | shasum -a 256 | cut -c1-12)" -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi -target_volume="anvil-target-${repo_id}-${image_id:0:12}" - -needs_github_token=false -for recipe_arg in "$@"; do - if anvil_recipe_needs_github_token "$recipe_arg"; then - needs_github_token=true - break - fi -done -runs_only_github_check=false -if (($# == 1)) && [[ "$1" == "anvil-aprz" ]]; then - runs_only_github_check=true -fi -github_token="" - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -if docker image inspect "$image" >/dev/null 2>&1; then - image_exists=true -else - image_exists=false -fi - -readonly ANVIL_CONTAINER_REPO_ROOT="$repo_root" -readonly ANVIL_CONTAINER_DIR="$script_dir" -readonly ANVIL_CONTAINER_RESOLVED_IMAGE="$image" -readonly ANVIL_CONTAINER_IMAGE_EXISTS="$image_exists" -declare -a ANVIL_CONTAINER_REQUESTED_RECIPES=("$@") -readonly ANVIL_CONTAINER_REQUESTED_RECIPES - -# Customization outputs, initialized before sourcing so a missing customize.sh -# leaves every phase a documented no-op. -ANVIL_CONTAINER_BUILD_ARGS=() -ANVIL_CONTAINER_PREPARE_ARGS=() -ANVIL_CONTAINER_PREPARE_COMMAND=() -ANVIL_CONTAINER_RUN_ARGS=() -ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN="$needs_github_token" -ANVIL_CONTAINER_CLEANUP=: -github_token_file="" -cleanup() { - if [[ -n "$github_token_file" ]]; then rm -f -- "$github_token_file"; fi - "$ANVIL_CONTAINER_CLEANUP" -} -trap cleanup EXIT - -customize_script="$script_dir/customize.sh" -legacy_customize_script="$repo_root/justfiles/anvil/container/customize.sh" -if [[ ! -f "$customize_script" && -f "$legacy_customize_script" ]]; then - echo "anvil-container: warning: ignoring justfiles/anvil/container/customize.sh; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.sh to keep it active." >&2 -fi -if [[ -f "$customize_script" ]]; then - # shellcheck source=/dev/null - source "$customize_script" -fi - -# Bash 3.2 has neither namerefs (the nameref flag on `local`/`declare`, Bash -# 4.3+) nor safe `set -u` expansion of empty-but- -# declared arrays (fixed in Bash 4.4). Elements are passed positionally -# instead of by nameref, and every expansion of a possibly-empty array uses -# the `${arr[@]+"${arr[@]}"}` idiom: unset/empty-under-old-Bash arrays vanish -# entirely instead of raising "unbound variable", while non-empty arrays -# still expand element-for-element. -anvil_container_validate_array() { - local name="$1" - shift - local declaration value - declaration="$(declare -p "$name" 2>/dev/null || true)" - if [[ ! "$declaration" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ ]]; then - echo "anvil-container: $name must be a string array." >&2 - exit 1 - fi - for value in "$@"; do - if [[ -z "$value" ]]; then - echo "anvil-container: $name entries must be non-empty strings." >&2 - exit 1 - fi - done -} -anvil_container_validate_build_args() { - local expect_secret_value=false value - for value in "$@"; do - if "$expect_secret_value"; then - expect_secret_value=false - continue - fi - case "$value" in - --secret) expect_secret_value=true ;; - --secret=*) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' >&2 - exit 1 - ;; - esac - done - if "$expect_secret_value"; then - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS requires a value after --secret.' >&2 - exit 1 - fi -} -anvil_container_validate_array ANVIL_CONTAINER_BUILD_ARGS ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_ARGS ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_COMMAND ${ANVIL_CONTAINER_PREPARE_COMMAND[@]+"${ANVIL_CONTAINER_PREPARE_COMMAND[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_RUN_ARGS ${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"} -anvil_container_validate_build_args ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -case "$ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN" in - true) needs_github_token=true ;; - false) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN must be true or false.' >&2 - exit 1 - ;; -esac -if ((${#ANVIL_CONTAINER_PREPARE_ARGS[@]} > 0)) && ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} == 0)); then - echo 'anvil-container: ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND.' >&2 - exit 1 -fi -cleanup_kind="$(type -t "$ANVIL_CONTAINER_CLEANUP" 2>/dev/null || true)" -if [[ "$cleanup_kind" != "function" && "$cleanup_kind" != "builtin" ]]; then - echo "anvil-container: ANVIL_CONTAINER_CLEANUP must name a callable function (got '$ANVIL_CONTAINER_CLEANUP')." >&2 - exit 1 -fi - -if "$needs_github_token"; then - gh_command="" - if command -v gh >/dev/null 2>&1; then - gh_command=gh - elif command -v gh.exe >/dev/null 2>&1; then - gh_command=gh.exe - fi - github_token="${GITHUB_TOKEN:-}" - if [[ -z "$github_token" && -n "$gh_command" ]]; then - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - fi - if [[ -z "$github_token" ]]; then - if [[ -z "$gh_command" ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - if [[ ! -t 0 ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - echo 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' >&2 - read -r -p 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel) ' - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - if [[ -z "$github_token" ]]; then - echo 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' >&2 - exit 1 - fi - fi -fi - -if ! "$image_exists"; then - if [[ "${ANVIL_CONTAINER_NO_REBUILD:-}" == "1" ]]; then - echo "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." >&2 - exit 1 - else - docker build \ - --platform linux/amd64 \ - --tag "$image" \ - --file "$script_dir/Containerfile" \ - --build-arg "ANVIL_IMAGE_ID=$image_id" \ - --build-arg "BASE_IMAGE=$base_image" \ - ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} \ - "$repo_root" - fi -fi - -container_uid="$(id -u)" -container_gid="$(id -g)" -registry_volume="anvil-cargo-registry-${repo_id}" -git_volume="anvil-cargo-git-${repo_id}" -for volume in "$registry_volume" "$git_volume" "$target_volume"; do - docker volume create "$volume" >/dev/null -done -mount_args=( - --mount "type=bind,source=$repo_root,target=/workspace" - --mount "type=volume,source=$registry_volume,target=/usr/local/cargo/registry" - --mount "type=volume,source=$git_volume,target=/usr/local/cargo/git" - --mount "type=volume,source=$target_volume,target=/workspace/target" -) -docker run --rm --pull=never \ - --platform linux/amd64 \ - --user 0:0 \ - "${mount_args[@]}" \ - "$image" sh -c \ - "chown $container_uid:$container_gid /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - -run_args=( - run --rm --pull=never - --platform linux/amd64 - --user "$container_uid:$container_gid" - --env ANVIL_IN_CONTAINER=1 - --env HOME=/tmp/anvil-user - "${mount_args[@]}" - --workdir /workspace -) -prepare_run_args=("${run_args[@]}") -run_args+=(${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"}) -for name in PR_TITLE BASE_REF ANVIL_IMPACT GITHUB_BASE_REF SYSTEM_PULLREQUEST_TARGETBRANCH; do - if value="$(printenv "$name")"; then run_args+=(--env "$name=$value"); fi -done -if ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} > 0)); then - docker "${prepare_run_args[@]}" \ - ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} \ - "$image" \ - "${ANVIL_CONTAINER_PREPARE_COMMAND[@]}" -fi - -if [[ -n "$github_token" ]]; then - github_token_file="$(mktemp "${TMPDIR:-/tmp}/anvil-github-token.XXXXXXXX")" - chmod 600 "$github_token_file" - printf '%s' "$github_token" > "$github_token_file" - unset github_token - github_run_args=( - "${run_args[@]}" - --mount "type=bind,source=$github_token_file,target=/run/secrets/anvil-github-token,readonly" - ) - if "$runs_only_github_check"; then - run_args=("${github_run_args[@]}") - else - docker "${github_run_args[@]}" "$image" just anvil-aprz - run_args+=(--env ANVIL_APRZ_ALREADY_RAN=1) - fi -fi - -if (($# == 0)); then - docker "${run_args[@]}" --interactive --tty "$image" bash - exit $? -fi -docker "${run_args[@]}" "$image" just "$@" diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just index 1627c3a5..9b2454d1 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just @@ -7,13 +7,15 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md # cargo-aprz queries the GitHub advisory API. Unauthenticated access is -# capped at 60 requests/hour and fails on a full run; an authenticated -# token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Container drivers mount an existing host GITHUB_TOKEN -# or the host gh CLI's stored token as a temporary read-only secret. -# Native runs borrow the gh CLI token directly. Native runs warn and -# proceed unauthenticated if neither is available; container runs fail -# before cargo-aprz can exhaust the unauthenticated rate limit. +# capped at 60 requests an hour, and on a full workspace it exhausts that +# and then waits for the quota to reset rather than failing; an +# authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -21,26 +23,16 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if ($env:ANVIL_APRZ_ALREADY_RAN -eq '1') { - Write-Host 'anvil-aprz: already completed in an isolated authenticated container' - exit 0 - } if (-not $env:GITHUB_TOKEN) { $tok = $null - $containerTokenFile = '/run/secrets/anvil-github-token' - if ($env:ANVIL_IN_CONTAINER -and (Test-Path -LiteralPath $containerTokenFile -PathType Leaf)) { - try { $tok = Get-Content -LiteralPath $containerTokenFile -Raw } catch { $tok = $null } - } elseif (Get-Command gh -ErrorAction SilentlyContinue) { + if (Get-Command gh -ErrorAction SilentlyContinue) { try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } } if ($tok) { $env:GITHUB_TOKEN = $tok.Trim() } else { - if ($env:ANVIL_IN_CONTAINER) { - throw 'anvil-aprz: GitHub authentication is unavailable. Run `gh auth login` on the host or set host GITHUB_TOKEN, then re-run the container command.' - } Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API (60 requests/hour) and may fail on a full run.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' } } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/mutants-diff.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/mutants-diff.just index 25c98ba9..079a54cb 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/mutants-diff.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/mutants-diff.just @@ -41,7 +41,14 @@ anvil-mutants-diff: anvil-mutants-diff-validate-prereqs anvil-impact if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' - git diff "$base..HEAD" --output=$diff_path + # Diff the base against the WORKING TREE, not against HEAD. + # cargo-mutants validates every line of the diff against the file on + # disk and aborts when they disagree, so a commit-to-commit diff fails + # the moment anything is uncommitted -- which is the normal local state, + # since the point of running a tier locally is to check work in progress. + # CI has a clean tree, so the two forms are identical there and this is + # not a behaviour change for it. + git diff "$base" --output=$diff_path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 86508b25..bdf35146 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -2,24 +2,1064 @@ # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. # Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +# +# Containerized execution. `just anvil-container ` runs the given +# argv inside a pinned Linux image; everything else keeps running natively. +# Anvil recipes are reached by naming `just`, like any other command. +# There is no configuration file and no transparent routing: the container is +# reached through this recipe or not at all. +# +# The image tag *is* a hash of the inputs that define it, so the presence of a +# tag is proof that its contents are current -- a changed tool pin names a tag +# that cannot already exist, and a build follows. There is nothing to keep in +# sync and no staleness to detect. +# +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md -# Run any Anvil recipe in the pinned local Linux container. With no recipe, -# open an interactive shell. -[windows] +# The container engine, `docker` or `podman`. A host property, never +# committed. Set it in your environment: a `just anvil_container_engine=...` +# override would not reach the nested invocations that resolve the engine. +anvil_container_engine := env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker") + +# Where the repository is mounted inside the container. +anvil_container_workdir := "/workspace" + +# Image and cache-volume prefix, derived from the repository directory name. +# Two checkouts with the same directory name share cache volumes; that is +# harmless (the caches are content-addressed by cargo) but worth knowing before +# `anvil-container-down` removes volumes another checkout is also using. +# +# Every run of non-alphanumerics collapses to a single `-`, and a trailing one +# is trimmed, because a repository name may not end in a separator or repeat +# `.`/`_`. Without that, a checkout in `ox-tools (copy)` yields a reference the +# engine rejects as malformed, from a directory name nobody would suspect. +anvil_container_name := trim_end_matches("anvil-" + replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-"), "-") + +# Resolve how to invoke the engine, as a pipe-separated command. +# +# There is deliberately no probe *between* engines: presence is not +# reachability, and a silent choice between two installed engines means two +# image stores and an unexplained rebuild. We check that the requested binary +# exists and let every other failure surface the engine's own diagnostic, which +# is more accurate than anything repeated here. +# +# The one fallback is Windows-specific and unambiguous: when the engine is not +# on the Windows PATH, try it inside the default WSL distribution. Installing +# Docker in WSL without Docker Desktop is a documented, common setup, and it +# leaves no Windows CLI behind, so without this fallback a correctly installed +# engine would be unreachable. Docker Desktop and Podman both ship a Windows +# CLI and are found on PATH, so they never take this path. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-engine: + $ErrorActionPreference = 'Stop' + $engine = '{{ replace(anvil_container_engine, "'", "''") }}' + if ($engine -ne 'docker' -and $engine -ne 'podman') { + Write-Error "anvil: ANVIL_CONTAINER_ENGINE must be 'docker' or 'podman', got '$engine'" + exit 1 + } + if (Get-Command $engine -ErrorAction SilentlyContinue) { + Write-Output $engine + exit 0 + } + # --exec, not --: `wsl.exe -- ` hands the rest of the command line to + # the distribution's default shell, which expands $NAME, splits on ;, and + # eats backslashes. Every argument we forward -- the repository path and + # the recipe's own arguments -- would cross that boundary unquoted. + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $engine --version *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "wsl.exe|--exec|$engine" + exit 0 + } + } + Write-Error "anvil: '$engine' was not found on PATH, and is not usable in the default WSL distribution. Install it, or set ANVIL_CONTAINER_ENGINE to the other engine. Setup: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md" + exit 1 + +# Translate a host path into what the engine sees. +# +# Identical when the engine runs on this host. When it runs in WSL, a Windows +# path has to become its /mnt/... form or the daemon silently bind-mounts an +# empty directory -- a failure that surfaces much later, as a missing file +# inside the container. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-path host_path: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + if (-not $engine.StartsWith('wsl.exe|')) { + Write-Output '{{ replace(host_path, "'", "''") }}' + exit 0 + } + # --exec for the reason given above. It matters most here: through a shell, + # a path holding `$` loses it, and `wslpath -a` then makes the *truncated* + # path absolute and exits 0, so the guard below never fires and the wrong + # directory is bind-mounted. + $hostPath = '{{ replace(host_path, "'", "''") }}' + $translated = & wsl.exe --exec wslpath -a -u $hostPath + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: could not translate '$hostPath' for the engine running in WSL" + exit 1 + } + Write-Output $translated.Trim() + +# Verify the composed Dockerfile is present under the name the build uses. +# +# The engine derives the ignore file's name from the Dockerfile's: BuildKit +# reads `.dockerignore` and there is no flag to point it elsewhere. +# Anvil owns that artifact at the fixed path `.anvil/container/Dockerfile.dockerignore`, +# so the two names have to agree, and only one of them can move. A case variant +# is therefore refused rather than accommodated: building from `dockerfile` +# would silently use no ignore file at all, streaming the whole worktree into +# the build context and admitting inputs the tag does not cover. +# +# Also the one place that asserts the file exists: the tag's directory walk +# cannot, because a missing Dockerfile simply contributes nothing to the hash +# and yields a confident tag for an image that can never be built. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-dockerfile: + $ErrorActionPreference = 'Stop' + $dir = Join-Path '{{ replace(justfile_directory(), "'", "''") }}' '.anvil/container' + $entries = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue) + # -ceq because PowerShell's -eq on strings is case-insensitive, which would + # make the exact name indistinguishable from a variant on a filesystem that + # can hold both. + if (@($entries | Where-Object { $_.Name -ceq 'Dockerfile' }).Count -eq 1) { + Write-Output '.anvil/container/Dockerfile' + exit 0 + } + $variant = @($entries | Where-Object { $_.Name -ieq 'Dockerfile' })[0] + if ($variant) { + Write-Error "anvil: the container image input must be named exactly '.anvil/container/Dockerfile', but this repository has '.anvil/container/$($variant.Name)'. The engine reads the ignore file as '.dockerignore', and anvil maintains '.anvil/container/Dockerfile.dockerignore', so a differently-cased name would build with no ignore file. Rename it." + exit 1 + } + Write-Error 'anvil: container image input is missing: .anvil/container/Dockerfile' + exit 1 + +# Print the exec image reference for the current inputs, without building it. +# +# The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its +# ignore file, the pinned toolchain, the optional hook, and the whole generated +# recipe tree -- because the image installs its tools by running +# `just anvil-setup`, whose dependency chain reaches the tier, group, check and +# tool recipes alike. Editing any of them can change what the image contains, so +# any of them can rename it. +# +# This is the only recipe that computes the reference; everything else asks it. +# It is public because a publisher needs the tag before there is an image to +# inspect: a pipeline that builds the image tags the result with exactly the +# reference a consumer will later compute, which is what lets presence be +# checked without a second source of truth. + +# Print the exec image reference for the current inputs, without building it. [group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-container *recipe: - $requested = @('{{ replace(recipe, "'", "''") }}' -split '\s+' | Where-Object { $_ }) - & '.anvil/container/run-in-container.ps1' @requested - exit $LASTEXITCODE +anvil-container-tag: + $ErrorActionPreference = 'Stop' + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $inputs = @('rust-toolchain.toml') + $links = @() + # The declared input and the two walk roots are checked here, because a walk + # only ever reports descendants: a link that *is* the root is traversed or + # read through and never appears in its own output. Same hazard as a link + # below them -- the engine copies the link while everything here follows it. + foreach ($declared in @('rust-toolchain.toml', '.anvil/container', 'justfiles/anvil')) { + $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $links += $item.FullName + } + } + # The declared inputs are text this tree owns, so their line endings are + # normalized before hashing and a CRLF checkout agrees with an LF one. + # Everything discovered by walking a directory is treated as text only when + # it is a `.just` recipe; anything else is hashed as the bytes the build + # context actually copies. + $declaredText = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + [void]$declaredText.Add($dockerfile) + [void]$declaredText.Add("$dockerfile.dockerignore") + [void]$declaredText.Add($hookRel) + # Everything under `.anvil/container/`, not a fixed list of three files. + # The Dockerfile is composed -- anvil owns regions inside it and the + # repository owns the gaps -- and a repository that adds a `COPY` in one of + # those gaps names a file that shapes the image: a corporate root CA, an + # install script, a patch. A replacement region from a downstream catalog + # does the same. Hashing only the three files anvil happens to know about + # would let any of them change the image under a reference that already + # resolves, which is precisely the hole this digest exists to close. + # + # The hook is picked up by the same walk. Its *output* is deliberately + # never hashed: a credential must not influence a tag. + # + # `.anvil-proposed` siblings are excluded. A region proposal is anvil's own + # review artifact, written beside its host when a template moves under a + # customized region; the build cannot see it and it cannot change what the + # image contains. Digesting it would rename the image for as long as a + # proposal sat undismissed, so two checkouts of one commit would disagree + # on the tag and a published image would stop resolving. + $containerRoot = Join-Path $repoRoot '.anvil/container' + if (Test-Path -LiteralPath $containerRoot) { + foreach ($file in Get-ChildItem -LiteralPath $containerRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + # Every generated recipe file. The image installs its tools by running + # `just anvil-setup`, and that dependency chain runs through the tier, + # group and check recipes before it reaches the install recipes in + # tools.just -- so the routing decides *whether* a tool is installed just + # as surely as tools.just decides *how*. Hashing only the install + # definitions would let a group drop a `-setup` dependency, changing the + # installed set, without renaming the image. + # + # This driver is included too. It is not circular -- the tag is derived + # from file text, and no file contains the tag -- and it belongs in the set + # because it passes the build arguments, the secret mounts and the hook's + # `Anvil-BuildSecrets` output into the build, all of which shape the result. + # Every file in the generated recipe tree, not only `*.just`. The build + # context admits the whole `justfiles/anvil/` directory (see the ignore + # file), so anything an adopter drops there is copied into the image. The + # catalog refuses to *own* a non-recipe file there, but a repository can + # still add one by hand, and a file that reaches the image without reaching + # the tag is precisely the hole this digest exists to close. Hashing what + # the context copies keeps the two sets identical by construction. + # + # -Force because Get-ChildItem omits hidden entries otherwise: a + # dot-prefixed file is copied like any other, and skipping it would let its + # edits ride under an unchanged tag -- and make Windows and Unix disagree. + # + # `.anvil-proposed` siblings are excluded here for the same reason as under + # `.anvil/container/`: this driver is itself an owned artifact, so a + # repository that customizes it gets the proposal written right here. + $recipeRoot = Join-Path $repoRoot 'justfiles/anvil' + if (Test-Path -LiteralPath $recipeRoot) { + foreach ($file in Get-ChildItem -LiteralPath $recipeRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + + # A symlink is refused rather than digested. The engine copies the link + # itself while any reading of it here follows it, so a retarget changes the + # image without changing a single byte the walk can see, and a link to a + # directory is not enumerated by the walk at all. Framing link text instead + # would have to work on Windows, where git materializes a symlink as an + # ordinary file unless the checkout was privileged, so the same commit would + # digest differently per platform. Anvil never creates one under these + # trees, so refusing costs nothing and closes the whole class. + if ($links.Count -gt 0) { + $named = ($links | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' }) -join ', ' + Write-Error "anvil: the container image inputs must be regular files, but these are links: $named. The engine copies a link as a link while the image tag is computed from what it points at, so the image would not match its own reference. Replace them with regular files." + exit 1 + } + + # Hash a tagged stream rather than raw concatenation, so no rearrangement of + # names and contents can collide. Line endings are normalized once, here, so + # a CRLF checkout and an LF checkout agree on the tag. Ordinal sort and dedup: + # `Sort-Object -Unique` compares case-insensitively, which would silently drop + # one of two inputs differing only in case on the case-sensitive filesystem + # where the image is actually built. + # Length-prefix the path and the content rather than relying on newlines as + # separators. A bare `file\n\n\n` stream is not + # self-delimiting: content is arbitrary, so a file whose body contains + # "file\n\n" serializes identically to two files whose bodies + # split at that point. That makes distinct input sets nameable by one tag -- + # a hook that exists versus an ignore file whose body ends in the hook's + # path and body, for instance -- and the second state would silently reuse + # the first state's image. Byte counts cannot be forged by content. + # + # Content is hashed as BYTES, not as decoded text. `ReadAllText` decodes + # UTF-8 with a replacing fallback, so every invalid sequence becomes U+FFFD + # before it is hashed: a one-byte file of 0xFF and one of 0xFE both collapse + # to the same replacement character and produce the same tag, while `COPY` + # puts their real, different bytes in the image. That was unreachable while + # only `*.just` was hashed and became reachable the moment the input set + # widened to everything the build context copies -- which is precisely the + # class of file (a stray `.png`, a `.DS_Store`, a UTF-16 fragment) that the + # widening admitted. + # + # Line endings are still normalized, but only for the text this tree owns: + # a `.just` recipe and the declared inputs, which a CRLF checkout and an LF + # checkout must agree on. Normalizing bytes generally would reintroduce the + # same collision from the other direction. + $ordered = [System.Collections.Generic.SortedSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + # A file's git mode is part of what `COPY` puts in the image -- the + # executable bit, and whether the entry is a regular file or a symlink -- so + # a change that leaves the bytes alone still changes the image and must + # rename the tag. Git's index is the only source of that mode which answers + # identically on every platform: Windows has no executable bit, so reading + # it from the filesystem would make two checkouts of one commit disagree on + # the tag, and a published image would stop resolving for half the people + # who use it. + # + # The index is authoritative only while the working tree agrees with it. A + # mode change that has not been staged would be copied by the build and + # missed by the tag, so it is refused below rather than absorbed. + # + # An untracked file's mode is not an input: it has no committed identity, so + # no other checkout can reproduce it and there is nothing for a shared tag + # to encode. + # + # quotePath=false so a non-ASCII path arrives verbatim rather than + # backslash-escaped, which would key the map on a spelling the walk never + # produces. + # Ordinal, like the sort and the dedup below: PowerShell's `@{}` folds case, + # so two paths differing only in case -- which git permits and the + # case-sensitive filesystem the image is built on can hold -- would collapse + # to one entry and both would be framed with whichever mode was stored last. + $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $tracked = @('.anvil/container', 'justfiles', 'rust-toolchain.toml') + if (Get-Command git -ErrorAction SilentlyContinue) { + $staged = & git -c core.quotePath=false -C $repoRoot ls-files --stage -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $staged) { + if ($entry -match '^(\d{6}) [0-9a-f]+ \d+\t(.+)$') { + $indexMode[$Matches[2]] = $Matches[1] + } + } + } + # `--raw` reports the working-tree mode as its second field, so a + # mode-only change is visible even though the content is identical. On + # Windows core.fileMode is normally false and git reports no drift, + # which is correct: the filesystem has no bit to disagree with. + # + # A zero working-tree mode is a deletion: the path is in neither the + # build context nor the digest, so there is nothing to disagree about. + # Every other entry is present in the context and is compared against + # the mode the digest actually framed, which comes from `ls-files + # --stage` above. The raw index-side mode is not that mode -- an + # intent-to-add entry reports zero there while `ls-files` reports a real + # placeholder -- so comparing the two raw fields would both miss an + # executable `git add -N` file and reject an ordinary one. + $drift = & git -c core.quotePath=false -C $repoRoot diff --no-ext-diff --raw -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $drift) { + if ($entry -match '^:\d{6} (\d{6}) [0-9a-f]+ [0-9a-f]+ \S+\t(.+)$') { + $worktreeMode, $driftPath = $Matches[1], $Matches[2] + if ($worktreeMode -ne '000000' -and $indexMode.ContainsKey($driftPath) -and $indexMode[$driftPath] -ne $worktreeMode) { + Write-Error "anvil: '$driftPath' has mode $worktreeMode in the working tree, but the image tag was computed from mode $($indexMode[$driftPath]). The build copies the working tree, so the image would not match its own reference. Stage the change (git add) and re-run." + exit 1 + } + } + } + } + } + # ComputeHash rather than the static HashData: the latter arrived in .NET 5, + # and the prerequisite check accepts any PowerShell 7, including 7.0 on + # .NET Core 3.1 where the static overload does not exist. Failing there + # would be a MethodNotFound at tag time, before anything useful happened. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($rel in $ordered) { + $path = Join-Path $repoRoot $rel + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "anvil: container image input is missing: $rel" + exit 1 + } + if ([System.IO.Path]::GetExtension($rel) -eq '.just' -or $declaredText.Contains($rel)) { + $content = [System.Text.Encoding]::UTF8.GetBytes( + ([System.IO.File]::ReadAllText($path) -replace "`r`n", "`n")) + } else { + $content = [System.IO.File]::ReadAllBytes($path) + } + # The whole git mode, not just the executable bit: `COPY` preserves + # a symlink as a symlink, while the walk above reads through it, so + # replacing a regular file with a link to identical bytes would + # otherwise keep the tag. An untracked path has no framed mode. + $mode = if ($indexMode.ContainsKey($rel)) { $indexMode[$rel] } else { '-' } + $header = [System.Text.Encoding]::UTF8.GetBytes( + 'file ' + [System.Text.Encoding]::UTF8.GetByteCount($rel) + ' ' + $rel + ' ' + $mode + ' ' + $content.Length + ' ') + [void]$sha.TransformBlock($header, 0, $header.Length, $null, 0) + if ($content.Length -gt 0) { + [void]$sha.TransformBlock($content, 0, $content.Length, $null, 0) + } + } + [void]$sha.TransformFinalBlock([byte[]]::new(0), 0, 0) + $digest = $sha.Hash + } finally { + $sha.Dispose() + } + # 16 hex characters (64 bits) is far past any practical collision risk for a + # local image set, and keeps `docker images` readable. + $imageId = -join ($digest[0..7] | ForEach-Object { $_.ToString('x2') }) + Write-Output ('{{anvil_container_name}}:' + $imageId) -[unix] +# Resolve the exec image, building it if it is neither present nor resolvable. +# +# Three steps, in order: a local image under the computed tag, then the +# optional `Anvil-ResolveImage` hook (a registry, typically), then a build. +# Resolution comes before the NO_REBUILD guard because fetching a published +# image is not building one. +# +# ANVIL_CONTAINER_NO_REBUILD=1 fails instead of building, which is how a cache +# miss is told apart from a build failure. ANVIL_CONTAINER_NO_RESOLVE=1 skips +# the hook, so a query stays a query -- resolving can mean pulling gigabytes. +# ANVIL_CONTAINER_NO_CACHE=1 rebuilds a tag that already resolves, for the cases +# a content hash cannot see: a moved upstream package, or a base layer that +# changed behind its digest. It skips the hook too -- "ignore what is cached" +# has to mean the remote cache as well, or a rebuild would be undone by the +# next pull. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-image: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $image = & '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $image = "$image".Trim() + + if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') { + & $engineExe @enginePrefix image inspect $image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output $image + exit 0 + } + + # Nothing local. Give the optional hook a chance to fetch a published + # image built from these same inputs -- a registry, typically. + # + # The hook returns the reference it made available, and we run that + # reference rather than re-tagging it to the local name: a local tag + # asserts "built here from these inputs", and a fetched image only + # *claims* that, since the hash is over source files and cannot be + # re-derived from layers. Whether that claim holds is a property of the + # registry (immutable tags, restricted push), not of anything this + # recipe can check, so the reference stays honest about where it came + # from. + # + # Every failure here is non-fatal: a missing image, an expired + # credential and a broken hook all fall through to a build, which is + # slower but always correct. A publisher that has not yet caught up + # with a change must not stop the developer who made it. + $hookPath = Join-Path $repoRoot $hookRel + if ($env:ANVIL_CONTAINER_NO_RESOLVE -ne '1' -and (Test-Path -LiteralPath $hookPath -PathType Leaf)) { + # Dot-sourcing is inside the try as well: a hook with a syntax error, + # or one that throws while being loaded, must cost no more than a + # hook that resolves nothing. The recipe runs under + # `$ErrorActionPreference = 'Stop'`, so leaving the load outside + # would abort the run instead of falling through to a build. + $resolved = $null + try { + . $hookPath + if (Get-Command Anvil-ResolveImage -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-ResolveImage from $hookRel") + $resolved = @(Anvil-ResolveImage $image | Where-Object { $_ }) | Select-Object -Last 1 + } + } catch { + [Console]::Error.WriteLine("anvil: $hookRel failed: $($_.Exception.Message)") + $resolved = $null + } + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $resolved = ([string]$resolved).Trim() + # A presence check, not a verification: `image inspect` proves + # something is tagged with that reference, not that its contents + # match the digest the tag claims. Trusting the hook is the + # contract -- this only keeps a reference the hook reported but + # never fetched from failing later, under `--pull=never`, a long + # way from the cause. + & $engineExe @enginePrefix image inspect $resolved *> $null + if ($LASTEXITCODE -eq 0) { + [Console]::Error.WriteLine("anvil: resolved $resolved") + Write-Output $resolved + exit 0 + } + [Console]::Error.WriteLine( + "anvil: Anvil-ResolveImage reported '$resolved' but no such image is present locally") + } + [Console]::Error.WriteLine("anvil: nothing resolved; building locally") + } + } + + # Checked outside the cache guard, so the two variables compose: a caller + # that has NO_CACHE exported would otherwise fall straight through to a + # from-scratch build, which is exactly what NO_REBUILD exists to prevent -- + # and `anvil-container-status`, which sets it, would spend minutes building + # from a query. + if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { + # Still report the reference: a caller that asked not to build is + # usually asking *which* image is missing. + Write-Output $image + [Console]::Error.WriteLine("anvil: $image is not present or not current, and ANVIL_CONTAINER_NO_REBUILD=1") + exit 1 + } + + # Build-time credentials come from the optional hook, never from a committed + # file. Values are handed to BuildKit by environment variable name, so they + # stay out of the host's process command line, and BuildKit keeps them out of + # every image layer. An empty value is fatal: BuildKit would mount an empty + # secret, the build would install a reduced tool set and exit 0, and the + # result would be tagged with the same hash a credentialed build produces -- + # so every later run would reuse the broken image. + $secretArgs = @() + $secretEnv = @() + $hookPath = Join-Path $repoRoot $hookRel + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed, unlike the resolve hook: a build that cannot mint its + # credentials must stop, not proceed to produce a reduced image. The + # try exists only so the cause is named -- loading a hook with a syntax + # error would otherwise surface as a bare parser error with no hint + # that a hook was involved. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load ${hookRel}: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-BuildSecrets -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-BuildSecrets from $hookRel") + try { + # Take the last emitted object, not the whole stream: a hook + # that writes progress with `Write-Output` would otherwise hand + # back an array whose `.Secrets` is silently $null. + $hook = @(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-BuildSecrets failed: $($_.Exception.Message)" + exit 1 + } + $secrets = if ($null -ne $hook) { $hook.Secrets } else { $null } + # A defined `Anvil-BuildSecrets` that yields nothing is the hazard this + # guard exists for, not a hook opting out: secrets are the only + # thing the phase can contribute, so an empty return means the mint + # failed quietly. A hook with no build-time credentials simply does + # not define the function. + if ($null -eq $secrets -or $secrets.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-BuildSecrets returned no secrets; omit the function if the build needs none" + exit 1 + } + foreach ($id in $secrets.Keys) { + if ([string]::IsNullOrWhiteSpace($secrets[$id])) { + Write-Error "anvil: Anvil-BuildSecrets returned an empty value for secret '$id'" + exit 1 + } + $name = "ANVIL_SECRET_$id" + Set-Item -LiteralPath "Env:$name" -Value $secrets[$id] + $secretEnv += $name + $secretArgs += "id=$id,env=$name" + } + [Console]::Error.WriteLine("anvil: build secrets: $($secrets.Keys -join ', ')") + } + } + + try { + # Progress goes to stderr: callers capture this recipe's stdout to learn + # the image reference, so anything else written there becomes part of it. + [Console]::Error.WriteLine("anvil: building $image (inputs changed or first run)") + # The engine may not share this host's filesystem view, so the context + # and the Dockerfile are given in its terms rather than ours. + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + # Pinned, not inferred from the host. The Dockerfile installs amd64 + # toolchains and verifies amd64 checksums, so an arm host would resolve + # the multi-arch base to arm64 and fail late with an exec-format error. + # It also keeps the identity scheme honest: without this, two hosts of + # different architecture compute the same tag for different images. + $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # BuildKit reads `.dockerignore` on its own; buildah reads + # only a context-root ignore file and needs to be pointed at ours. Named + # rather than probed, because the flag is rejected outright by the engine + # that does not take it, and an unscoped context streams the whole + # worktree -- `target/` included -- on every build. + if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile', "$engineRoot/$dockerfile.dockerignore") } + if ($env:ANVIL_CONTAINER_NO_CACHE -eq '1') { $buildCmd += '--no-cache' } + foreach ($secret in $secretArgs) { $buildCmd += @('--secret', $secret) } + $buildCmd += $engineRoot + # BuildKit is required for --secret; docker enables it by default from + # 23.0 but an older daemon silently ignores the flag, so ask explicitly. + $env:DOCKER_BUILDKIT = '1' + # WSLENV exports the named variables into the WSL environment, which is + # where the engine reads a secret's value from when it runs there. + if ($engineExe -eq 'wsl.exe') { + $bridged = @('DOCKER_BUILDKIT/u') + ($secretEnv | ForEach-Object { "$_/u" }) + $env:WSLENV = (@($env:WSLENV) + $bridged | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @buildCmd | ForEach-Object { [Console]::Error.WriteLine($_) } + if ($LASTEXITCODE -ne 0) { + # Build secrets are the part of this path engines implement least + # consistently -- podman on Windows cannot mount one at all, and + # fails with a path error that names neither the secret nor the + # engine. Say so once, rather than leaving that to be rediscovered. + if ($secretArgs.Count -gt 0) { + [Console]::Error.WriteLine( + "anvil: the build passed $($secretArgs.Count) secret(s) from $hookRel. " + + "If the failure above is about a temp file or a path, the engine may not support " + + "build secrets on this host; see docs/design/containers.md.") + } + exit $LASTEXITCODE + } + } finally { + foreach ($name in $secretEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + } + + Write-Output $image + +# Run a command inside the pinned Linux image. +# +# The tokens after the recipe name are the argv, executed verbatim in the +# image. Anvil recipes are reached by naming `just` like any other command. +# +# just anvil-container just anvil-pr # a tier +# just anvil-container cargo build # any other command +# just anvil-container # interactive shell +# +# ANVIL_IN_CONTAINER is set inside the image, so a nested invocation runs the +# command on the spot and the work happens exactly once. + +# Run a command inside the pinned Linux image (no argument: a shell). [group("anvil-container")] -[script("bash")] -anvil-container *recipe: - requested={{ quote(recipe) }} - if [[ -z "$requested" ]]; then - exec bash '.anvil/container/run-in-container.sh' - fi - read -r -a requested_args <<<"$requested" - exec bash '.anvil/container/run-in-container.sh' "${requested_args[@]}" +[script("pwsh", "-NoProfile")] +anvil-container *command: + $ErrorActionPreference = 'Stop' + # `*command` joins its parts with spaces, so the string is split back into + # argv here. Whitespace is the only separator, so an argument containing a + # space does not survive; pass such a value through the environment. + $argv = @('{{ replace(command, "'", "''") }}' -split '\s+' | Where-Object { $_ }) + if ($env:ANVIL_IN_CONTAINER -eq '1') { + if ($argv.Count -eq 0) { + # The no-argument form asks for a shell in the image, and this is + # that shell. Nothing runs, so exiting 0 would report success for a + # request that was not carried out. + [Console]::Error.WriteLine("anvil: already inside the container; run the command directly") + exit 1 + } + # `just` resolves to the binary running this tree rather than to PATH: + # ANVIL_IN_CONTAINER is a documented control a developer can set on a + # host, and a caller who invoked `just` by absolute path with its + # directory off PATH would otherwise fail here. + $exe = if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] } + & $exe @($argv | Select-Object -Skip 1) + exit $LASTEXITCODE + } + + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + $image = (& '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + + # Map the caller's working directory to its in-container equivalent so + # relative paths keep working from a subdirectory. `invocation_directory()` + # would be wrong here: with `cygpath` on PATH it reports a Cygwin-style + # path, which shares no prefix with the native `justfile_directory()` above. + # `GetRelativePath` then walks out with `..` segments and the run is placed + # outside the mount, so it fails on a path that does not exist in the + # container. The `_native` form is the one that agrees with the root. + $rel = [System.IO.Path]::GetRelativePath($repoRoot, '{{ replace(invocation_directory_native(), "'", "''") }}') -replace '\\', '/' + if ($rel.StartsWith('..')) { + Write-Error "anvil: run this from inside the repository; $rel is outside $repoRoot" + exit 1 + } + $containerCwd = if ($rel -eq '.' -or [string]::IsNullOrEmpty($rel)) { + '{{anvil_container_workdir}}' + } else { + '{{anvil_container_workdir}}/' + $rel + } + + $interactive = $argv.Count -eq 0 + $runArgs = @('run', '--rm', '--platform', 'linux/amd64') + $runArgs += $interactive ? '-it' : '-i' + $runArgs += @('-v', "${engineRoot}:{{anvil_container_workdir}}") + + # A checkout whose `.git` is a file keeps its real git directory elsewhere: + # a linked worktree points into the main clone, `--separate-git-dir` and a + # submodule point somewhere else again. The path recorded there is a host + # path that does not exist inside the container, so git resolves neither + # HEAD nor origin/main. Mount the common git directory and replace the + # checkout's `.git` with one naming that mount; the `commondir` file in a + # worktree's entry is relative, so it resolves under it. + # + # The predicate is the shape of `.git`, not whether the git directory + # differs from the common one: `--separate-git-dir` redirects without + # differing, and testing for a difference skips it and leaves git pointed at + # a path the container cannot see. + # + # The redirection lives in the checkout rather than in GIT_DIR/GIT_WORK_TREE + # so that it stays scoped to it. Those variables are ambient: every process + # in the container inherits them, and a git command run elsewhere -- `git + # init` in a test's scratch directory -- would operate on this repository + # instead of its own. + # + # An ordinary clone keeps its git directory inside the checkout, where the + # bind mount already carries it, and takes none of this. + # + # Guarded on git being present: the run path needs it only to answer this + # question, and a host with a working engine but no git on PATH keeps + # working rather than failing on a call it does not need. + $gitFile = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $gitDir = & git rev-parse --git-dir 2>$null + $gitCommon = & git rev-parse --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $gitDir -and $gitCommon -and + (Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)) { + $gitDirAbs = (Resolve-Path -LiteralPath $gitDir).Path + $gitCommonAbs = (Resolve-Path -LiteralPath $gitCommon).Path + $rel = [System.IO.Path]::GetRelativePath($gitCommonAbs, $gitDirAbs) -replace '\\', '/' + # One mount has to carry both directories, so the git directory must + # sit under the common one. `git worktree` always places it there and + # a redirect without a separate worktree entry makes the two equal; + # anything else cannot be expressed as a single mount, and emitting a + # path that climbs out of it would fail inside the container instead. + if ($rel -eq '.') { + $containerGitDir = '/anvil/gitdir' + } elseif ($rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)) { + Write-Error "anvil: this checkout's git directory ($gitDirAbs) is not inside its common git directory ($gitCommonAbs), so the two cannot be mounted as one tree. Run the container from an ordinary clone or a git worktree checkout." + exit 1 + } else { + $containerGitDir = "/anvil/gitdir/$rel" + } + $engineGitCommon = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitCommonAbs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitCommon = "$engineGitCommon".Trim() + # LF and no trailing newline: git parses this file strictly. + $gitFile = Join-Path ([System.IO.Path]::GetTempPath()) "anvil-gitfile-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($gitFile, "gitdir: $containerGitDir`n") + $engineGitFile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitFile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitFile = "$engineGitFile".Trim() + $runArgs += @('-v', "${engineGitCommon}:/anvil/gitdir") + $runArgs += @('-v', "${engineGitFile}:{{anvil_container_workdir}}/.git:ro") + } + } + + # Cache only what is content-addressed: the downloaded registry and the git + # checkouts. Deliberately NOT $CARGO_HOME or $RUSTUP_HOME themselves -- + # those hold the installed tools and toolchains, and a named volume is + # populated from the image only when it is first created. Mounting them + # would pin the first image's binaries over every later one, so a tool bump + # would change the tag, build a new image, and still run the old tools. + $runArgs += @('-v', '{{anvil_container_name}}-cargo-registry:/usr/local/cargo/registry') + $runArgs += @('-v', '{{anvil_container_name}}-cargo-git:/usr/local/cargo/git') + # Match the caller's uid/gid on Linux. Without this everything the run + # writes under the bind mount -- target/, generated files -- lands as root + # on the host, and the next native cargo build or git clean fails with + # EACCES a long way from the cause. Docker Desktop on Windows and macOS + # already maps ownership, and `id` is not there to ask. + if (-not $IsWindows -and -not $IsMacOS) { + $hostUid = (id -u); $hostGid = (id -g) + if ($LASTEXITCODE -eq 0 -and $hostUid -ne '0') { + $runArgs += @('--user', "${hostUid}:${hostGid}") + # That uid has no passwd entry, so the engine leaves HOME as `/`. + # Anything falling back to $HOME for a cache then writes to a + # read-only root and fails a long way from the cause. + $runArgs += @('-e', 'HOME=/tmp') + } + } + $runArgs += @('-e', 'ANVIL_IN_CONTAINER=1') + + # Run-time credentials come from the optional hook. Forwarded by NAME, never + # as NAME=VALUE: the engine copies the value out of the environment it + # already inherits, so a credential never appears in the host's process + # command line, where endpoint telemetry records and retains it for far + # longer than a short-lived token is meant to live. + # $forwardedEnv is every name passed with -e; $hookEnv is the subset this + # process set, and so the subset it must unset again. + $forwardedEnv = @() + $hookEnv = @() + + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # + # An already-exported GITHUB_TOKEN is forwarded whatever the command is: + # that is exact parity, since a native run exposes it to every process the + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. + # + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { + # A dry run has no side effects, and a target that cannot be planned + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. + # + # A plan covers the bodies just runs itself, not the body of a + # recipe that one of them launches as a child process. The unscoped + # tier wrapper launches its tier that way, so planning + # `anvil-scheduled` shows the wrapper and none of the checks + # underneath it. Follow each nested target a plan names, or a + # wrapped tier reads as needing nothing and runs unauthenticated. + $plan = '' + $targets = [System.Collections.Generic.List[object]]::new() + $targets.Add([string[]]@($argv | Select-Object -Skip 1)) + $planned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($i = 0; $i -lt $targets.Count; $i++) { + $target = [string[]]$targets[$i] + # A recipe reachable twice is planned once, and a body naming + # itself terminates. + if (-not $planned.Add(($target -join ' '))) { continue } + # The same executable that launched this tree, for the reason + # every other nested call uses it: a caller invoking `just` by + # absolute path with its directory off PATH would otherwise fail + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. + $step = '' + try { + $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | + ForEach-Object { $_.ToString() }) -join "`n" + } catch { + $step = '' + } + $plan = "$plan`n$step" + # A launched recipe appears as a quoted argument to `just`. + foreach ($nested in [regex]::Matches($step, "'(_anvil-[^'\s]+)'")) { + $targets.Add([string[]]@($nested.Groups[1].Value)) + } + } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } + } + } + if ($env:GITHUB_TOKEN) { + $forwardedEnv += 'GITHUB_TOKEN' + $runArgs += @('-e', 'GITHUB_TOKEN') + } + + # The recipe contract's own inputs. These are read by generated checks -- + # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its + # CI equivalents, and `anvil-impact` reads ANVIL_IMPACT to decide whether to + # compute scoping, consume a downloaded cache, or skip -- so dropping them at + # the boundary makes the same command mean different things inside and out. + # anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with a + # skip notice, so a title a native run rejects passes in a container and the + # tier still reports green. ANVIL_IMPACT is the other: a CI group job exports + # `consume`, and a container that did not inherit it would recompute scoping + # from a diff instead of trusting the artifact the group downloaded. + # + # Forwarded by name and only when set, so an unset variable stays unset + # rather than arriving as an empty string, which several of these treat as + # a value. + foreach ($name in @( + 'PR_TITLE', + 'BASE_REF', 'GITHUB_BASE_REF', 'SYSTEM_PULLREQUEST_TARGETBRANCH', + 'ANVIL_IMPACT')) { + if ((Test-Path -LiteralPath "Env:$name") -and -not [string]::IsNullOrEmpty((Get-Item -LiteralPath "Env:$name").Value)) { + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + } + + $hookPath = Join-Path $repoRoot '.anvil/container/hooks.ps1' + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed like Anvil-BuildSecrets: a run that cannot obtain its + # credentials fails inside the container in a far less obvious way. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load .anvil/container/hooks.ps1: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-RunEnv -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-RunEnv from .anvil/container/hooks.ps1") + try { + $hook = @(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-RunEnv failed: $($_.Exception.Message)" + exit 1 + } + $hookVars = if ($null -ne $hook) { $hook.Env } else { $null } + if ($null -eq $hookVars -or $hookVars.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-RunEnv returned no variables; omit the function if the run needs none" + exit 1 + } + foreach ($name in $hookVars.Keys) { + if ([string]::IsNullOrWhiteSpace($hookVars[$name])) { + Write-Error "anvil: Anvil-RunEnv returned an empty value for '$name'" + exit 1 + } + Set-Item -LiteralPath "Env:$name" -Value $hookVars[$name] + $hookEnv += $name + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + # Names only, never values: a hook with a broad idea of what to + # forward should be visible, since everything inside the + # container can read it -- including third-party build scripts. + [Console]::Error.WriteLine("anvil: forwarding env: $($hookVars.Keys -join ', ')") + } + } + + try { + # --pull=never: the reference names content that is already here, either + # built locally or fetched by the resolve hook, so a miss is a bug to + # surface rather than an invitation to fetch something unrelated. + $runArgs += @('--pull=never', '-w', $containerCwd, $image) + if (-not $interactive) { $runArgs += $argv } + # WSLENV exports the forwarded names into the WSL environment, which is + # where the engine reads their values from when it runs there. Without + # it, `-e NAME` reaches an engine that cannot see NAME and forwards + # nothing, leaving the variable unset inside the container. + # + # It is not restored afterwards because there is nothing to restore to: + # `just` runs a [script(...)] recipe as its own pwsh process, so this + # assignment dies with that process and never reaches the caller's + # shell. The `finally` below unsets the credential names for hygiene + # within this process, not to protect the parent. + if ($engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0) { + $env:WSLENV = (@($env:WSLENV) + ($forwardedEnv | ForEach-Object { "$_/u" }) | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @runArgs + exit $LASTEXITCODE + } finally { + foreach ($name in $hookEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + if ($gitFile) { Remove-Item -LiteralPath $gitFile -Force -ErrorAction SilentlyContinue } + } + +# Report the engine, the exec image, and whether it is present and current. +# +# The tag embeds the hash of the image's inputs, so "absent" and "out of date" +# are the same condition and are reported as one. + +# Report the engine, the exec image, and whether it is present and current. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-status: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + Write-Output ("engine: " + ($engine -replace '\|', ' ')) + Write-Output "workdir: {{anvil_container_workdir}}" + + # NO_REBUILD turns the resolve into a pure query: report the state instead + # of silently spending several minutes building from a status command. + # NO_RESOLVE is the same argument applied to the hook, which would otherwise + # pull gigabytes to answer a question about the local machine. + # + # Compute the tag first and let it fail loudly. It is fatal for a reason a + # query cannot paper over -- a declared input is missing -- and reporting + # that as "not present locally" would be a lie: the next run cannot build + # it either. + $image = (& '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output "image: $image" + + $env:ANVIL_CONTAINER_NO_REBUILD = '1' + $env:ANVIL_CONTAINER_NO_RESOLVE = '1' + # And explicitly *not* NO_CACHE. A caller who exported it is asking the next + # build to ignore the layer cache, which is a statement about building -- + # but it also makes the resolver skip the local `image inspect` + # short-circuit, so a present image would be reported absent by a command + # that is only ever asking what is on this machine. + $env:ANVIL_CONTAINER_NO_CACHE = $null + & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "status: present and current" + exit 0 + } + + # A cache miss and an unreachable daemon both make `image inspect` fail, and + # reporting the second as the first tells a developer to expect a build that + # will not start either. Ask the engine whether it is answering at all: only + # then is absence the honest reading. + $engineCmd = $engine -split '\|' + & $engineCmd[0] @($engineCmd | Select-Object -Skip 1) version *> $null + if ($LASTEXITCODE -ne 0) { + Write-Output "status: unknown -- the engine is not responding (is the daemon running?)" + exit 1 + } + Write-Output "status: not present locally (the next run resolves or builds it)" + exit 0 + +# Only the download caches are volumes, so this discards fetched crates and git +# checkouts and nothing else: the next run re-fetches them, and the image's own +# tools are untouched. To discard the image instead, set +# ANVIL_CONTAINER_NO_CACHE=1 for a single run. + +# Remove this repository's cache volumes. The image is left in place. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-down: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + # Report a teardown that did not happen. $ErrorActionPreference does not + # cover native commands, so a non-serving engine would otherwise print a + # connection error per volume and still exit 0 -- and this recipe is the + # only way to clear a cache volume, so a caller that scripts teardown must + # be able to tell that it failed. `-f` already exits 0 for a volume that + # does not exist, so this cannot fire spuriously. + $failed = @() + foreach ($vol in @('{{anvil_container_name}}-cargo-registry', '{{anvil_container_name}}-cargo-git')) { + & $engineExe @enginePrefix volume rm -f $vol + if ($LASTEXITCODE -ne 0) { $failed += $vol } + } + if ($failed.Count -gt 0) { + Write-Error ("anvil: could not remove: " + ($failed -join ', ')) + exit 1 + } + exit 0 diff --git a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-advisories.just b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-advisories.just index 728c82af..d4787562 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-advisories.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-advisories.just @@ -6,14 +6,14 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled advisory checks. [group("anvil")] -anvil-scheduled-advisories: (_anvil-run "scheduled-advisories" anvil_runner "off") +anvil-scheduled-advisories: (_anvil-unscoped "scheduled-advisories") [private] _anvil-scheduled-advisories: anvil-scheduled-advisories-validate-prereqs \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-exhaustive.just b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-exhaustive.just index 2e46be87..9cfd91c1 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-exhaustive.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-exhaustive.just @@ -6,14 +6,14 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled exhaustive checks. [group("anvil")] -anvil-scheduled-exhaustive: (_anvil-run "scheduled-exhaustive" anvil_runner "off") +anvil-scheduled-exhaustive: (_anvil-unscoped "scheduled-exhaustive") [private] _anvil-scheduled-exhaustive: anvil-scheduled-exhaustive-validate-prereqs \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-runtime-analysis.just b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-runtime-analysis.just index 19228447..f443eb5c 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-runtime-analysis.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-runtime-analysis.just @@ -12,14 +12,14 @@ # and adds the three stricter miri profiles (tree-borrows, strict- # provenance, race-coverage) which are too expensive for PR. -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled runtime analysis. [group("anvil")] -anvil-scheduled-runtime-analysis: (_anvil-run "scheduled-runtime-analysis" anvil_runner "off") +anvil-scheduled-runtime-analysis: (_anvil-unscoped "scheduled-runtime-analysis") [private] _anvil-scheduled-runtime-analysis: anvil-scheduled-runtime-analysis-validate-prereqs \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-test.just b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-test.just index c94b6690..d60f03f5 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-test.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/groups/scheduled-test.just @@ -9,7 +9,7 @@ # Scheduled groups # Scheduled groups are the full-workspace backstop for PR-tier impact scoping, -# so route through _anvil-run with impact "off": it exports ANVIL_IMPACT=off +# so route through _anvil-unscoped: it exports ANVIL_IMPACT=off # before the check dependencies run, so the group is full-workspace regardless # of how it is invoked (CI, `just anvil-scheduled`, or # `just anvil-scheduled-test` directly). Because these groups never recompute @@ -17,7 +17,7 @@ # Run the scheduled tests. [group("anvil")] -anvil-scheduled-test: (_anvil-run "scheduled-test" anvil_runner "off") +anvil-scheduled-test: (_anvil-unscoped "scheduled-test") [private] _anvil-scheduled-test: anvil-scheduled-test-validate-prereqs \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/helpers.just b/crates/cargo-anvil/templates/justfiles/anvil/helpers.just index 971e607b..5be4b411 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/helpers.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/helpers.just @@ -120,3 +120,22 @@ _anvil-base-ref: } Write-Error 'anvil-base-ref: cannot resolve a base ref. Set BASE_REF, or ensure origin/main or origin/master exists.' exit 1 + +# Run a private recipe with impact scoping disabled. +# +# The only way to reach a whole dependency tree with an environment variable: +# `just` runs each dependency as its own process, and a dependency-only +# recipe's body executes after its dependencies, so exporting from there is +# too late. Invoking `_anvil-` as a child process makes every check +# below it inherit the setting. +# +# The justfile is named explicitly so the child resolves the same file the +# wrapper was defined in, rather than whatever an upward search from the +# working directory happens to find. +[private] +[script("pwsh", "-NoProfile")] +_anvil-unscoped name: + $ErrorActionPreference = 'Stop' + $env:ANVIL_IMPACT = 'off' + & '{{ replace(just_executable(), "'", "''") }}' --justfile '{{ replace(justfile(), "'", "''") }}' '_anvil-{{ replace(name, "'", "''") }}' + exit $LASTEXITCODE \ No newline at end of file diff --git a/crates/cargo-anvil/templates/justfiles/anvil/mod.just b/crates/cargo-anvil/templates/justfiles/anvil/mod.just index ba229814..ee29054c 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/mod.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/mod.just @@ -67,7 +67,11 @@ import 'checks/readme-check.just' import 'checks/semver-check.just' import 'checks/spellcheck.just' import 'checks/udeps.just' -import 'container.just' +# Optional: the container artifacts can be removed through `without_artifact`, +# which deletes this file. A hard import would then fail parsing for every +# recipe in the tree, not merely the container ones, so the documented opt-out +# would break the whole Justfile. +import? 'container.just' import 'groups/pr-fast.just' import 'groups/pr-slow.just' import 'groups/pr-test.just' @@ -77,7 +81,6 @@ import 'groups/scheduled-test.just' import 'groups/scheduled-advisories.just' import 'groups/scheduled-runtime-analysis.just' import 'groups/scheduled-exhaustive.just' -import 'runner.just' import 'tiers.just' import 'tools.just' import 'versions.just' diff --git a/crates/cargo-anvil/templates/justfiles/anvil/runner.just b/crates/cargo-anvil/templates/justfiles/anvil/runner.just deleted file mode 100644 index 0aecbb7c..00000000 --- a/crates/cargo-anvil/templates/justfiles/anvil/runner.just +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -# Route public tier entry points through the configured execution environment. -# ANVIL_IN_CONTAINER always wins to prevent recursive container launches. -# -# `impact` selects the tier's impact-scoping mode: the default "on" leaves -# scoping enabled (PR tier), while "off" exports ANVIL_IMPACT=off before -# invoking the native tier so every check runs full-workspace -- the -# scheduled/full backstop for PR-tier impact scoping. Setting it here (rather -# than in a dep-only tier recipe) ensures the private `_anvil-` recipe's -# own dependencies, which run before any recipe body, inherit the mode. -[private] -[no-exit-message] -[windows] -[script("pwsh", "-NoProfile")] -_anvil-run tier runner impact="on": - if ('{{ replace(impact, "'", "''") }}' -ceq 'off') { $env:ANVIL_IMPACT = 'off' } - $just = '{{ replace(just_executable(), "'", "''") }}' - $justfile = '{{ replace(justfile(), "'", "''") }}' - $nativeTier = '_anvil-{{ replace(tier, "'", "''") }}' - if ($env:ANVIL_IN_CONTAINER) { - & $just --justfile $justfile $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'container') { - & $just --justfile $justfile anvil-container $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'native') { - & $just --justfile $justfile $nativeTier - } else { - [Console]::Error.WriteLine("anvil-runner: expected 'native' or 'container', got '{{ replace(runner, "'", "''") }}'.") - exit 2 - } - exit $LASTEXITCODE - -[private] -[no-exit-message] -[unix] -[script("bash")] -_anvil-run tier runner impact="on": - just_path={{ quote(just_executable()) }} - justfile={{ quote(justfile()) }} - tier={{ quote(tier) }} - runner={{ quote(runner) }} - impact={{ quote(impact) }} - if [[ "$impact" == "off" ]]; then - export ANVIL_IMPACT=off - fi - native_tier="_anvil-$tier" - if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - elif [[ "$runner" == "container" ]]; then - exec "$just_path" --justfile "$justfile" anvil-container "$native_tier" - elif [[ "$runner" == "native" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - else - echo "anvil-runner: expected 'native' or 'container', got '$runner'." >&2 - exit 2 - fi diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tiers.just b/crates/cargo-anvil/templates/justfiles/anvil/tiers.just index 4cc1a0dc..a06e87b4 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tiers.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tiers.just @@ -12,10 +12,7 @@ # Run all pull request checks. [group("anvil")] -anvil-pr: (_anvil-run "pr" anvil_runner) - -[private] -_anvil-pr: anvil-pr-validate-prereqs \ +anvil-pr: anvil-pr-validate-prereqs \ anvil-pr-fast \ anvil-pr-slow @@ -24,20 +21,18 @@ _anvil-pr: anvil-pr-validate-prereqs \ # exhaustive checks that don't fit in a PR budget. Runs on a schedule # against `main`, not on PRs. # -# The scheduled tier is deliberately NOT impact-scoped: it is the -# catch-all that backstops PR-tier scoping. Because every impact-scoped -# check depends on `anvil-impact` and self-populates its scope from the -# cache, the tier must run with ANVIL_IMPACT=off so `_anvil-impact-include` -# returns each tier's full-workspace default and the `anvil-impact` -# dependency no-ops. A dependency-only recipe can't set env for its own -# deps (deps run before the body), so the public tier routes through -# `_anvil-run` with the `"off"` impact argument: `_anvil-run` exports -# ANVIL_IMPACT=off before invoking the private `_anvil-scheduled` recipe, -# whose deps then inherit it. +# The scheduled tier is deliberately NOT impact-scoped: it is the catch-all +# that backstops PR-tier scoping. Every impact-scoped check depends on +# `anvil-impact` and populates its own scope from the cache, so the tier runs +# with ANVIL_IMPACT=off, which makes `_anvil-impact-include` return each +# category's full-workspace default and the `anvil-impact` dependency no-op. +# A dependency-only recipe cannot set an environment variable for its own +# dependencies, so the public tier wraps the private one through +# `_anvil-unscoped`. # Run all scheduled checks. [group("anvil")] -anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off") +anvil-scheduled: (_anvil-unscoped "scheduled") [private] _anvil-scheduled: anvil-scheduled-validate-prereqs \ @@ -46,16 +41,15 @@ _anvil-scheduled: anvil-scheduled-validate-prereqs \ anvil-scheduled-runtime-analysis \ anvil-scheduled-exhaustive -# Runs everything full-workspace (ANVIL_IMPACT=off, via the `"off"` impact -# argument to _anvil-run), same wrapper shape as anvil-scheduled. +# Full-workspace for the same reason as the scheduled tier. # Full tier: PR + scheduled, end-to-end. Useful before tagging a release. [group("anvil")] -anvil-full: (_anvil-run "full" anvil_runner "off") +anvil-full: (_anvil-unscoped "full") [private] _anvil-full: anvil-full-validate-prereqs \ - _anvil-pr \ + anvil-pr \ _anvil-scheduled # Tier-level + global setup + validate-prereqs diff --git a/crates/cargo-anvil/templates/regions/justfile-runner.just b/crates/cargo-anvil/templates/regions/justfile-runner.just deleted file mode 100644 index 64a4fe40..00000000 --- a/crates/cargo-anvil/templates/regions/justfile-runner.just +++ /dev/null @@ -1 +0,0 @@ -anvil_runner := env_var_or_default("ANVIL_RUNNER", "native") diff --git a/crates/cargo-anvil/tests/container_customization.rs b/crates/cargo-anvil/tests/container_customization.rs deleted file mode 100644 index 2aef9804..00000000 --- a/crates/cargo-anvil/tests/container_customization.rs +++ /dev/null @@ -1,798 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#![cfg(all(windows, not(miri)))] // exercises the real pwsh driver against a fake `wsl`; miri can't sandbox this. -#![allow( - clippy::expect_used, - clippy::unwrap_used, - reason = "panic-on-failure idioms are appropriate in tests" -)] - -//! Driver-level verification of the `customize.ps1` runtime contract from -//! [`containers.md`](../docs/design/containers.md#8-container-customization). -//! -//! Generates the real `.anvil/container/` tree with -//! [`cargo_anvil::test_support::run_update`], then runs the generated -//! `run-in-container.ps1` against a fake `wsl` on `PATH` so the driver's -//! own process, argument construction, and validation execute for real. -//! `anvil-clippy` is used throughout so the GitHub-token path (which would -//! also require a fake `gh`) is never exercised. -//! -//! The Bash mirror lives in `container_customization_bash.rs` and runs on -//! Unix. It cannot run in this Windows test process because `bash` resolves -//! to the WSL launcher, which uses a different filesystem namespace from the -//! generated temporary repository. - -use std::collections::BTreeSet; -use std::path::Path; -use std::process::Command; - -use cargo_anvil::Catalog; -use cargo_anvil::test_support::{Cli, run_update}; -use tempfile::TempDir; - -fn write(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, contents).unwrap(); -} - -fn local() -> Cli { - Cli { - backends: vec![], - no_backends: true, - dry_run: false, - force: false, - } -} - -/// A repository with the public container tree generated and no derived -/// catalog involved, proving the driver loads `customize.ps1` purely by -/// standard path discovery. -fn repo_with_container() -> TempDir { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n", - ); - write( - &root.join("crates/alpha/Cargo.toml"), - "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", - ); - write(&root.join("crates/alpha/src/lib.rs"), ""); - write(&root.join("rust-toolchain.toml"), "channel = \"1.93\"\n"); - run_update(&Catalog::anvil(), &local(), root).unwrap(); - assert!( - !root.join(".anvil/container/customize.ps1").exists(), - "the public catalog must not emit customize.ps1 by default" - ); - let status = Command::new("git") - .args(["init", "--quiet"]) - .current_dir(root) - .status() - .expect("git must be available"); - assert!(status.success(), "temporary Git repository must initialize"); - tmp -} - -/// Installs a fake `wsl` on `PATH` so the real Windows driver runs against -/// controllable Docker Engine behavior without depending on the host's WSL -/// configuration. -fn install_fake_wsl(bin_dir: &Path) { - std::fs::create_dir_all(bin_dir).unwrap(); - write( - &bin_dir.join("wsl.cmd"), - "@echo off\r\npwsh -NoProfile -File \"%~dp0wsl.ps1\" %*\r\nexit /b %ERRORLEVEL%\r\n", - ); - write( - &bin_dir.join("wsl.ps1"), - r" -$command = $args[1] -$commandArgs = @($args | Select-Object -Skip 2) -switch ($command) { - 'wslpath' { - if ($env:FAKE_TOKEN_PATH_LOG -and $commandArgs[-1] -like '*anvil-github-token-*') { - Add-Content -LiteralPath $env:FAKE_TOKEN_PATH_LOG -Value $commandArgs[-1] - } - Write-Output $env:FAKE_WSL_REPO_PATH - exit 0 - } - 'id' { - if ($commandArgs[0] -eq '-u') { Write-Output '1000' } else { Write-Output '1000' } - exit 0 - } - 'docker' { - $logPath = $env:FAKE_DOCKER_LOG - if ($logPath) { Add-Content -LiteralPath $logPath -Value ($commandArgs -join ' ') } - $sub = $commandArgs[0] - switch ($sub) { - 'version' { Write-Output '26.1.5'; exit 0 } - 'image' { - if ($env:FAKE_DOCKER_IMAGE_EXISTS -eq '1') { exit 0 } else { exit 1 } - } - 'build' { - exit [int]($(if ($env:FAKE_DOCKER_BUILD_EXIT) { $env:FAKE_DOCKER_BUILD_EXIT } else { '0' })) - } - 'volume' { exit 0 } - 'run' { - $joined = $commandArgs -join ' ' - if ($env:FAKE_DOCKER_FAIL_MARKER -and $joined.Contains($env:FAKE_DOCKER_FAIL_MARKER)) { - exit 1 - } - exit 0 - } - default { exit 0 } - } - } - default { exit 0 } -} -", - ); -} - -struct DriverRun { - status: std::process::ExitStatus, - stderr: String, - docker_log: String, - test_log: String, - token_paths: Vec, -} - -fn assert_token_files_removed(run: &DriverRun) { - assert!(!run.token_paths.is_empty(), "expected a GitHub token path"); - for path in &run.token_paths { - assert!(!path.exists(), "temporary GitHub token file was not removed: {}", path.display()); - } -} - -fn created_volumes(docker_log: &str) -> BTreeSet { - docker_log - .lines() - .filter_map(|line| line.strip_prefix("volume create ")) - .map(str::to_owned) - .collect() -} - -/// Runs the real generated `run-in-container.ps1` against the fake `wsl`, -/// with `customize.ps1` written from `customize_ps1_body` beforehand. -fn run_driver(root: &Path, customize_ps1_body: &str, recipe: &str, env: &[(&str, &str)]) -> DriverRun { - run_driver_args(root, customize_ps1_body, &[recipe], env) -} - -fn run_driver_args(root: &Path, customize_ps1_body: &str, recipe_args: &[&str], env: &[(&str, &str)]) -> DriverRun { - run_driver_maybe_customized(root, Some(customize_ps1_body), recipe_args, env) -} - -/// Runs the driver with no `customize.ps1` at the current location, so the -/// stranded-legacy-file detection is observable. -fn run_driver_without_customization(root: &Path, recipe: &str, env: &[(&str, &str)]) -> DriverRun { - run_driver_maybe_customized(root, None, &[recipe], env) -} - -fn run_driver_maybe_customized(root: &Path, customize_ps1_body: Option<&str>, recipe_args: &[&str], env: &[(&str, &str)]) -> DriverRun { - let customize = root.join(".anvil/container/customize.ps1"); - match customize_ps1_body { - Some(body) => write(&customize, body), - None => drop(std::fs::remove_file(&customize)), - } - - let bin_dir = root.join("fake-bin"); - install_fake_wsl(&bin_dir); - - let docker_log = root.join("docker.log"); - let test_log = root.join("test.log"); - let token_path_log = root.join("token-path.log"); - let _ = std::fs::remove_file(&docker_log); - let _ = std::fs::remove_file(&test_log); - let _ = std::fs::remove_file(&token_path_log); - let fake_wsl_repo = format!( - "/mnt/c/fake/{}", - root.file_name() - .expect("temporary repository must have a directory name") - .to_string_lossy() - ); - let path = format!("{};{}", bin_dir.display(), std::env::var("PATH").unwrap_or_default()); - - let mut command = Command::new("pwsh"); - command - .args(["-NoProfile", "-File", ".anvil/container/run-in-container.ps1"]) - .args(recipe_args) - .current_dir(root) - .env("PATH", path) - .env("FAKE_DOCKER_LOG", &docker_log) - .env("FAKE_TEST_LOG", &test_log) - .env("FAKE_TOKEN_PATH_LOG", &token_path_log) - .env("FAKE_WSL_REPO_PATH", &fake_wsl_repo) - .env_remove("GITHUB_TOKEN") - .env_remove("ANVIL_CONTAINER_BASE_IMAGE") - .env_remove("ANVIL_CONTAINER_IMAGE") - .env_remove("ANVIL_CONTAINER_NO_REBUILD"); - for (key, value) in env { - command.env(key, value); - } - let output = command.output().expect("pwsh must be available to run the driver"); - - DriverRun { - status: output.status, - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - docker_log: std::fs::read_to_string(&docker_log).unwrap_or_default(), - test_log: std::fs::read_to_string(&test_log).unwrap_or_default(), - token_paths: std::fs::read_to_string(&token_path_log) - .unwrap_or_default() - .lines() - .map(Into::into) - .collect(), - } -} - -#[test] -fn a_customization_file_stranded_at_the_pre_move_path_is_reported_and_not_sourced() { - // Container assets moved from justfiles/anvil/container/ to - // .anvil/container/. A hand-authored customization file is not - // catalog-tracked, so `cargo anvil` cannot relocate it; the driver must - // say so instead of silently running without it. - let tmp = repo_with_container(); - write( - &tmp.path().join("justfiles/anvil/container/customize.ps1"), - "$AnvilContainerRunArgs = @('--label', 'stranded=1')\n", - ); - - let run = run_driver_without_customization(tmp.path(), "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - assert!(run.status.success(), "the run must still proceed: stderr={}", run.stderr); - assert!( - run.stderr.contains("justfiles/anvil/container/customize.ps1") && run.stderr.contains(".anvil/container/customize.ps1"), - "the stranded file and its new home must both be named: stderr={}", - run.stderr - ); - assert!( - !run.docker_log.contains("stranded=1"), - "the stranded file must not be sourced: docker.log={}", - run.docker_log - ); -} - -#[test] -fn a_customization_file_at_the_current_path_wins_without_a_migration_warning() { - let tmp = repo_with_container(); - // A stale copy at the old path must be inert, not a second source. - write( - &tmp.path().join("justfiles/anvil/container/customize.ps1"), - "throw 'the pre-move path must never be sourced'\n", - ); - - let run = run_driver( - tmp.path(), - "$AnvilContainerRunArgs = @('--label', 'current=1')\n", - "anvil-clippy", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1")], - ); - - assert!(run.status.success(), "the run must succeed: stderr={}", run.stderr); - assert!( - !run.stderr.contains("justfiles/anvil/container/customize.ps1"), - "no migration warning is due when the current path is populated: stderr={}", - run.stderr - ); - assert!( - run.docker_log.contains("current=1"), - "the current customization must take effect: docker.log={}", - run.docker_log - ); -} - -#[test] -fn every_requested_recipe_is_checked_for_github_authentication() { - let tmp = repo_with_container(); - let run = run_driver_args( - tmp.path(), - "", - &["anvil-clippy", "anvil-aprz"], - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!( - run.status.success(), - "a later requested recipe must receive GitHub authentication: {}", - run.stderr - ); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-clippy anvil-aprz")), - "all arguments must still be forwarded to the requested recipe: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 1, - "a later token-requiring recipe must cause one isolated anvil-aprz invocation" - ); - assert!( - run.docker_log - .lines() - .any(|line| line.contains("--env ANVIL_APRZ_ALREADY_RAN=1") && line.contains("just anvil-clippy anvil-aprz")), - "the requested recipes must run with APRZ marked complete: {}", - run.docker_log - ); -} - -#[test] -fn pr_recipes_run_without_github_token_handling() { - // main #76 moved the token-requiring GitHub work into `anvil-aprz`, so the - // PR recipes (`anvil-pr`, `_anvil-pr`, `anvil-pr-fast`) are deliberately NOT - // token-required. Run one with GITHUB_TOKEN set and assert the container - // receives no `/run/secrets/anvil-github-token` mount and triggers no - // isolated APRZ invocation -- the negative half of - // `Test-AnvilRecipeNeedsGitHubToken`, otherwise pinned only by - // generated-text snapshots. Accidentally re-adding any of the three recipes - // to that classifier would flip these assertions. - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "anvil-pr-fast", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!( - run.status.success(), - "a PR recipe must run without GitHub token handling: {}", - run.stderr - ); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-pr-fast")), - "the requested PR recipe must still be forwarded to a container: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 0, - "a PR recipe must not trigger an isolated anvil-aprz invocation: {}", - run.docker_log - ); - assert!( - !run.docker_log.contains("/run/secrets/anvil-github-token"), - "no token secret may be mounted for a PR recipe: {}", - run.docker_log - ); -} - -#[test] -fn customization_can_provide_github_authentication() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "$env:GITHUB_TOKEN = 'custom-token'\n", - "anvil-aprz", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1")], - ); - - assert!(run.status.success(), "custom authentication must be accepted: {}", run.stderr); - assert!( - run.docker_log.contains("/run/secrets/anvil-github-token"), - "the customization-provided token must be mounted for APRZ: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 1, - "direct anvil-aprz must run exactly one recipe container: {}", - run.docker_log - ); - assert_token_files_removed(&run); -} - -#[test] -fn customization_can_extend_aprz_classification() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "$AnvilContainerNeedsGitHubToken = $true\n", - "anvil-clippy", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!(run.status.success(), "custom APRZ classification failed: {}", run.stderr); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-aprz")), - "custom classification must trigger isolated APRZ: {}", - run.docker_log - ); - assert!( - run.docker_log - .lines() - .any(|line| line.contains("ANVIL_APRZ_ALREADY_RAN=1") && line.contains("just anvil-clippy")), - "the requested recipe must run after APRZ completion: {}", - run.docker_log - ); -} - -#[test] -fn every_requested_argument_must_be_an_anvil_recipe() { - let tmp = repo_with_container(); - let run = run_driver_args(tmp.path(), "", &["anvil-clippy", "not-anvil"], &[]); - - assert!(!run.status.success(), "invalid later recipe must fail"); - assert!( - run.stderr.contains("expected each argument to be an anvil-* recipe"), - "stderr must explain the command contract: {}", - run.stderr - ); - assert!( - run.docker_log.is_empty(), - "validation must happen before Docker: {}", - run.docker_log - ); -} - -#[test] -fn base_image_override_is_digest_pinned_and_passed_to_build() { - let tmp = repo_with_container(); - let base_image = "example.invalid/bullseye@sha256:1111111111111111111111111111111111111111111111111111111111111111"; - let run = run_driver(tmp.path(), "", "anvil-clippy", &[("ANVIL_CONTAINER_BASE_IMAGE", base_image)]); - - assert!(run.status.success(), "digest-pinned override failed: {}", run.stderr); - assert!( - run.docker_log - .lines() - .any(|line| line.starts_with("build ") && line.contains(&format!("BASE_IMAGE={base_image}"))), - "Docker build must receive the selected base image: {}", - run.docker_log - ); - - let invalid = run_driver( - tmp.path(), - "", - "anvil-clippy", - &[("ANVIL_CONTAINER_BASE_IMAGE", "debian:bullseye-slim")], - ); - assert!(!invalid.status.success(), "an unpinned base image must fail"); - assert!(invalid.stderr.contains("must be pinned by sha256 digest")); -} - -#[test] -fn aggregate_recipe_isolates_the_token_from_the_main_container() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "_anvil-scheduled", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!(run.status.success(), "aggregate recipe failed: {}", run.stderr); - let aprz = run - .docker_log - .lines() - .find(|line| line.contains("just anvil-aprz")) - .expect("aggregate recipe must run isolated APRZ"); - let main = run - .docker_log - .lines() - .find(|line| line.contains("just _anvil-scheduled")) - .expect("aggregate recipe must run its main container"); - assert!( - aprz.contains("/run/secrets/anvil-github-token"), - "APRZ must receive the token mount: {aprz}" - ); - assert!( - !main.contains("/run/secrets/anvil-github-token"), - "main container must not receive the token mount: {main}" - ); - assert!( - main.contains("ANVIL_APRZ_ALREADY_RAN=1"), - "main container must skip the completed APRZ check: {main}" - ); - assert!( - !run.docker_log.contains("--env GITHUB_TOKEN"), - "the token must never be passed through the environment" - ); - assert_token_files_removed(&run); -} - -#[test] -fn token_file_is_removed_after_aprz_or_main_failure() { - for marker in ["just anvil-aprz", "just _anvil-scheduled"] { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "_anvil-scheduled", - &[ - ("FAKE_DOCKER_IMAGE_EXISTS", "1"), - ("GITHUB_TOKEN", "test-token"), - ("FAKE_DOCKER_FAIL_MARKER", marker), - ], - ); - - assert!(!run.status.success(), "failure marker must fail the driver: {marker}"); - assert_token_files_removed(&run); - } -} - -#[test] -fn powershell_just_dispatch_treats_interpolated_values_as_data() { - if Command::new("just").arg("--version").output().is_err() { - return; - } - - let tmp = repo_with_container(); - let root = tmp.path(); - - let runner_output = Command::new("just") - .args(["_anvil-run", "missing", "x') { Write-Output RUNNER_INJECTED } elseif ('a"]) - .current_dir(root) - .output() - .expect("just must be available"); - assert!(!runner_output.status.success(), "the missing native tier must fail"); - assert!( - !String::from_utf8_lossy(&runner_output.stdout).contains("RUNNER_INJECTED"), - "the runner parameter must not execute as PowerShell source" - ); - - write( - &root.join(".anvil/container/run-in-container.ps1"), - "param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Recipe)\nWrite-Output 'DRIVER_OK'\n", - ); - let recipe_output = Command::new("just") - .args(["anvil-container", "x'); Write-Output RECIPE_INJECTED; @('a"]) - .current_dir(root) - .output() - .expect("just must be available"); - assert!( - recipe_output.status.success(), - "the escaped container recipe must reach the driver: {}", - String::from_utf8_lossy(&recipe_output.stderr) - ); - let stdout = String::from_utf8_lossy(&recipe_output.stdout); - assert!(stdout.contains("DRIVER_OK"), "the container driver must run"); - assert!( - !stdout.contains("RECIPE_INJECTED"), - "the recipe parameter must not execute as PowerShell source" - ); -} - -#[test] -fn cold_run_exposes_contract_inputs_scopes_phases_and_runs_cleanup() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r#" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "exists=$AnvilContainerImageExists" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "recipes=$($AnvilContainerRequestedRecipes -join ',')" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "windows=$AnvilContainerHostIsWindows" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "repo-is-dir=$(Test-Path -LiteralPath $AnvilContainerRepoRoot -PathType Container)" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "dir-is-container-dir=$($AnvilContainerDir -eq (Join-Path $AnvilContainerRepoRoot '.anvil/container'))" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "repo-wsl=$AnvilContainerRepoRootWsl" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "dir-wsl=$AnvilContainerDirWsl" -$AnvilContainerBuildArgs = @('--secret', 'id=build-marker,src=fake') -$AnvilContainerRunArgs = @('--label', 'run-marker=1') -$AnvilContainerCleanup = { Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value 'cleanup-ran' } -"#; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!( - run.status.success(), - "cold run must succeed: stderr={}\ndocker.log={}", - run.stderr, - run.docker_log - ); - assert!(run.test_log.contains("exists=False"), "log: {}", run.test_log); - assert!(run.test_log.contains("recipes=anvil-clippy"), "log: {}", run.test_log); - assert!(run.test_log.contains("windows=True"), "log: {}", run.test_log); - assert!(run.test_log.contains("repo-is-dir=True"), "log: {}", run.test_log); - assert!(run.test_log.contains("dir-is-container-dir=True"), "log: {}", run.test_log); - let fake_wsl_repo = format!( - "/mnt/c/fake/{}", - root.file_name() - .expect("temporary repository must have a directory name") - .to_string_lossy() - ); - assert!(run.test_log.contains(&format!("repo-wsl={fake_wsl_repo}")), "log: {}", run.test_log); - assert!( - run.test_log.contains(&format!("dir-wsl={fake_wsl_repo}/.anvil/container")), - "log: {}", - run.test_log - ); - // Build-phase arguments must only appear on the `build` invocation, and - // run-phase arguments only on the `run` invocation: phases stay isolated. - let build_line = run - .docker_log - .lines() - .find(|line| line.starts_with("build ")) - .unwrap_or_else(|| panic!("expected a docker build invocation, got: {}", run.docker_log)); - assert!(build_line.contains("id=build-marker,src=fake"), "line: {build_line}"); - assert!(!build_line.contains("run-marker=1"), "line: {build_line}"); - let run_line = run - .docker_log - .lines() - .find(|line| line.starts_with("run ") && line.contains("just anvil-clippy")) - .unwrap_or_else(|| panic!("expected a docker run invocation, got: {}", run.docker_log)); - assert!(run_line.contains("run-marker=1"), "line: {run_line}"); - assert!(!run_line.contains("id=build-marker,src=fake"), "line: {run_line}"); - assert!( - run_line.contains("--user 1000:1000"), - "recipe execution must use the WSL user: {run_line}" - ); - assert_eq!( - run.docker_log.lines().filter(|line| line.starts_with("volume create ")).count(), - 3, - "the driver must create all named cache volumes: {}", - run.docker_log - ); - assert!( - run.docker_log.contains("volume create anvil-cargo-registry-") && run.docker_log.contains("volume create anvil-cargo-git-"), - "Cargo caches must be repository-scoped: {}", - run.docker_log - ); - assert!( - run.test_log.contains("cleanup-ran"), - "cleanup must run after an ordinary successful invocation: {}", - run.test_log - ); -} - -#[test] -fn cargo_caches_are_repository_scoped_but_stable_across_image_ids() { - let first = repo_with_container(); - let second = repo_with_container(); - let first_run = run_driver(first.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - let second_run = run_driver(second.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - let first_volumes = created_volumes(&first_run.docker_log); - let second_volumes = created_volumes(&second_run.docker_log); - let first_registry = first_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-registry-")) - .expect("first repository must create a registry cache"); - let second_registry = second_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-registry-")) - .expect("second repository must create a registry cache"); - let first_git = first_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-git-")) - .expect("first repository must create a Git cache"); - let second_git = second_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-git-")) - .expect("second repository must create a Git cache"); - assert_ne!(first_registry, second_registry); - assert_ne!(first_git, second_git); - - write(&first.path().join("justfiles/anvil/versions.just"), "changed := \"1\"\n"); - let changed_run = run_driver(first.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - let changed_volumes = created_volumes(&changed_run.docker_log); - assert!(changed_volumes.contains(first_registry)); - assert!(changed_volumes.contains(first_git)); - assert_ne!( - first_volumes.iter().find(|name| name.starts_with("anvil-target-")), - changed_volumes.iter().find(|name| name.starts_with("anvil-target-")) - ); -} - -#[test] -fn warm_run_skips_the_build_and_still_reports_image_exists() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r#" -Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value "exists=$AnvilContainerImageExists" -"#; - let run = run_driver(root, customize, "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - assert!( - run.status.success(), - "warm run must succeed: stderr={}\ndocker.log={}", - run.stderr, - run.docker_log - ); - assert!(run.test_log.contains("exists=True"), "log: {}", run.test_log); - assert!( - !run.docker_log.lines().any(|line| line.starts_with("build ")), - "a warm run (matching image already present) must not invoke docker build: {}", - run.docker_log - ); -} - -#[test] -fn prepare_args_without_a_prepare_command_are_rejected_before_docker_runs() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r" -$AnvilContainerPrepareArgs = @('--label', 'prepare-marker=1') -"; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!(!run.status.success(), "prepare args without a prepare command must fail validation"); - assert!( - run.stderr.contains("AnvilContainerPrepareArgs requires") && run.stderr.contains("AnvilContainerPrepareCommand"), - "stderr must name the invalid output: {}", - run.stderr - ); - assert!( - !run.docker_log - .lines() - .any(|line| line.starts_with("build ") || line.starts_with("run ")), - "validation must fail before any Docker build or run invocation \ - (version/image-exists checks happen earlier and are expected): {}", - run.docker_log - ); -} - -#[test] -fn null_array_output_is_rejected_before_docker_runs() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r" -$AnvilContainerRunArgs = $null -"; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!(!run.status.success(), "a null array output must fail validation"); - assert!( - run.stderr.contains("AnvilContainerRunArgs must be a string array"), - "stderr must name the invalid output: {}", - run.stderr - ); - assert!( - !run.docker_log - .lines() - .any(|line| line.starts_with("build ") || line.starts_with("run ")), - "validation must fail before any Docker build or run invocation \ - (version/image-exists checks happen earlier and are expected): {}", - run.docker_log - ); -} - -#[test] -fn content_changing_build_arguments_are_rejected() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "$AnvilContainerBuildArgs = @('--build-arg', 'BASE_IMAGE=example.invalid/base')\n", - "anvil-clippy", - &[], - ); - - assert!(!run.status.success(), "content-changing build arguments must fail validation"); - assert!( - run.stderr - .contains("AnvilContainerBuildArgs accepts only BuildKit --secret arguments"), - "stderr must explain the image-identity restriction: {}", - run.stderr - ); -} -#[test] -fn cleanup_still_runs_after_the_main_recipe_container_fails() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r" -$AnvilContainerRunArgs = @('--label', 'run-marker=1') -$AnvilContainerCleanup = { Add-Content -LiteralPath $env:FAKE_TEST_LOG -Value 'cleanup-ran' } -"; - let run = run_driver( - root, - customize, - "anvil-clippy", - &[ - ("FAKE_DOCKER_IMAGE_EXISTS", "1"), // warm run: only the main recipe container executes. - ("FAKE_DOCKER_FAIL_MARKER", "run-marker=1"), - ], - ); - - assert!(!run.status.success(), "the driver must surface the recipe failure"); - assert!( - run.test_log.contains("cleanup-ran"), - "cleanup must still run after an ordinary recipe failure: {}", - run.test_log - ); -} diff --git a/crates/cargo-anvil/tests/container_customization_bash.rs b/crates/cargo-anvil/tests/container_customization_bash.rs deleted file mode 100644 index 799963d3..00000000 --- a/crates/cargo-anvil/tests/container_customization_bash.rs +++ /dev/null @@ -1,771 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#![cfg(all(unix, not(miri)))] // exercises the real Bash driver against a fake `docker`; miri can't sandbox this. -#![allow( - clippy::expect_used, - clippy::unwrap_used, - reason = "panic-on-failure idioms are appropriate in tests" -)] -#![expect( - clippy::literal_string_with_formatting_args, - reason = "the Bash fixture intentionally contains shell parameter expansions" -)] - -//! Driver-level verification of the `customize.sh` runtime contract from -//! [`containers.md`](../docs/design/containers.md#8-container-customization). -//! -//! This is the Bash mirror of `container_customization.rs`'s `PowerShell` -//! driver tests. It generates the real `.anvil/container/` tree -//! with [`cargo_anvil::test_support::run_update`], then runs the generated -//! `run-in-container.sh` against a fake `docker` on `PATH` so the driver's -//! own process, argument construction, and validation execute for real — -//! including with the default (customize.sh-empty) arrays, which is the -//! condition that regressed under Bash 3.2 / Bash <4.4 `set -u` semantics. -//! `anvil-clippy` is used throughout so the GitHub-token path (which would -//! also require a fake `gh`) is never exercised. - -use std::collections::BTreeSet; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::process::Command; - -use cargo_anvil::Catalog; -use cargo_anvil::test_support::{Cli, run_update}; -use tempfile::TempDir; - -fn write(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, contents).unwrap(); -} - -fn write_executable(path: &Path, contents: &str) { - write(path, contents); - let mut permissions = std::fs::metadata(path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).unwrap(); -} - -fn local() -> Cli { - Cli { - backends: vec![], - no_backends: true, - dry_run: false, - force: false, - } -} - -/// A repository with the public container tree generated and no derived -/// catalog involved, proving the driver loads `customize.sh` purely by -/// standard path discovery. -fn repo_with_container() -> TempDir { - let tmp = TempDir::new().unwrap(); - let root = tmp.path(); - write( - &root.join("Cargo.toml"), - "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n", - ); - write( - &root.join("crates/alpha/Cargo.toml"), - "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", - ); - write(&root.join("crates/alpha/src/lib.rs"), ""); - write(&root.join("rust-toolchain.toml"), "channel = \"1.93\"\n"); - run_update(&Catalog::anvil(), &local(), root).unwrap(); - assert!( - !root.join(".anvil/container/customize.sh").exists(), - "the public catalog must not emit customize.sh by default" - ); - let status = Command::new("git") - .args(["init", "--quiet"]) - .current_dir(root) - .status() - .expect("git must be available"); - assert!(status.success(), "temporary Git repository must initialize"); - tmp -} - -/// Installs a fake `docker` on `PATH` so the real driver runs against -/// controllable, observable behavior instead of a real container engine. -fn install_fake_docker(bin_dir: &Path) { - write_executable( - &bin_dir.join("docker"), - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ -n "${FAKE_DOCKER_LOG:-}" ]]; then - printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG" -fi -case "${1:-}" in - version) - echo '26.1.5' - exit 0 - ;; - image) - if [[ "${FAKE_DOCKER_IMAGE_EXISTS:-}" == "1" ]]; then exit 0; else exit 1; fi - ;; - build) - exit "${FAKE_DOCKER_BUILD_EXIT:-0}" - ;; - volume) - exit 0 - ;; - run) - joined="$*" - if [[ -n "${FAKE_DOCKER_FAIL_MARKER:-}" && "$joined" == *"$FAKE_DOCKER_FAIL_MARKER"* ]]; then - exit 1 - fi - exit 0 - ;; - *) - exit 0 - ;; -esac -"#, - ); -} - -struct DriverRun { - status: std::process::ExitStatus, - stderr: String, - docker_log: String, - test_log: String, -} - -fn token_source_paths(docker_log: &str) -> Vec { - docker_log - .lines() - .flat_map(str::split_whitespace) - .filter_map(|argument| { - argument.strip_prefix("type=bind,source=").and_then(|mount| { - mount - .split_once(",target=/run/secrets/anvil-github-token") - .map(|(source, _)| source) - }) - }) - .map(Into::into) - .collect() -} - -fn assert_token_files_removed(run: &DriverRun) { - let paths = token_source_paths(&run.docker_log); - assert!(!paths.is_empty(), "expected a GitHub token mount: {}", run.docker_log); - for path in paths { - assert!(!path.exists(), "temporary GitHub token file was not removed: {}", path.display()); - } -} - -fn created_volumes(docker_log: &str) -> BTreeSet { - docker_log - .lines() - .filter_map(|line| line.strip_prefix("volume create ")) - .map(str::to_owned) - .collect() -} - -/// Runs the real generated `run-in-container.sh` against the fake `docker`, -/// with `customize.sh` written from `customize_sh_body` beforehand. -fn run_driver(root: &Path, customize_sh_body: &str, recipe: &str, env: &[(&str, &str)]) -> DriverRun { - run_driver_args(root, customize_sh_body, &[recipe], env) -} - -fn run_driver_args(root: &Path, customize_sh_body: &str, recipe_args: &[&str], env: &[(&str, &str)]) -> DriverRun { - run_driver_maybe_customized(root, Some(customize_sh_body), recipe_args, env) -} - -/// Runs the driver with no `customize.sh` at the current location, so the -/// stranded-legacy-file detection is observable. -fn run_driver_without_customization(root: &Path, recipe: &str, env: &[(&str, &str)]) -> DriverRun { - run_driver_maybe_customized(root, None, &[recipe], env) -} - -fn run_driver_maybe_customized(root: &Path, customize_sh_body: Option<&str>, recipe_args: &[&str], env: &[(&str, &str)]) -> DriverRun { - let customize = root.join(".anvil/container/customize.sh"); - match customize_sh_body { - Some(body) => write(&customize, body), - None => drop(std::fs::remove_file(&customize)), - } - - let bin_dir = root.join("fake-bin"); - install_fake_docker(&bin_dir); - - let docker_log = root.join("docker.log"); - let test_log = root.join("test.log"); - let _ = std::fs::remove_file(&docker_log); - let _ = std::fs::remove_file(&test_log); - let path = format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap_or_default()); - - let mut command = Command::new("bash"); - command - .arg(".anvil/container/run-in-container.sh") - .args(recipe_args) - .current_dir(root) - .env("PATH", path) - .env("FAKE_DOCKER_LOG", &docker_log) - .env("FAKE_TEST_LOG", &test_log) - .env_remove("ANVIL_IN_CONTAINER") - .env_remove("GITHUB_TOKEN") - .env_remove("ANVIL_CONTAINER_BASE_IMAGE") - .env_remove("ANVIL_CONTAINER_IMAGE") - .env_remove("ANVIL_CONTAINER_NO_REBUILD"); - for (key, value) in env { - command.env(key, value); - } - let output = command.output().expect("bash must be available to run the driver"); - - DriverRun { - status: output.status, - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - docker_log: std::fs::read_to_string(&docker_log).unwrap_or_default(), - test_log: std::fs::read_to_string(&test_log).unwrap_or_default(), - } -} - -#[test] -fn a_customization_file_stranded_at_the_pre_move_path_is_reported_and_not_sourced() { - // Container assets moved from justfiles/anvil/container/ to - // .anvil/container/. A hand-authored customization file is not - // catalog-tracked, so `cargo anvil` cannot relocate it; the driver must - // say so instead of silently running without it. - let tmp = repo_with_container(); - write( - &tmp.path().join("justfiles/anvil/container/customize.sh"), - "ANVIL_CONTAINER_RUN_ARGS=(--label stranded=1)\n", - ); - - let run = run_driver_without_customization(tmp.path(), "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - assert!(run.status.success(), "the run must still proceed: stderr={}", run.stderr); - assert!( - run.stderr.contains("justfiles/anvil/container/customize.sh") && run.stderr.contains(".anvil/container/customize.sh"), - "the stranded file and its new home must both be named: stderr={}", - run.stderr - ); - assert!( - !run.docker_log.contains("stranded=1"), - "the stranded file must not be sourced: docker.log={}", - run.docker_log - ); -} - -#[test] -fn a_customization_file_at_the_current_path_wins_without_a_migration_warning() { - let tmp = repo_with_container(); - // A stale copy at the old path must be inert, not a second source. - write( - &tmp.path().join("justfiles/anvil/container/customize.sh"), - "echo 'the pre-move path must never be sourced' >&2\nexit 1\n", - ); - - let run = run_driver( - tmp.path(), - "ANVIL_CONTAINER_RUN_ARGS=(--label current=1)\n", - "anvil-clippy", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1")], - ); - - assert!(run.status.success(), "the run must succeed: stderr={}", run.stderr); - assert!( - !run.stderr.contains("justfiles/anvil/container/customize.sh"), - "no migration warning is due when the current path is populated: stderr={}", - run.stderr - ); - assert!( - run.docker_log.contains("current=1"), - "the current customization must take effect: docker.log={}", - run.docker_log - ); -} - -#[test] -fn every_requested_recipe_is_checked_for_github_authentication() { - let tmp = repo_with_container(); - let run = run_driver_args( - tmp.path(), - "", - &["anvil-clippy", "anvil-aprz"], - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!( - run.status.success(), - "a later requested recipe must receive GitHub authentication: {}", - run.stderr - ); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-clippy anvil-aprz")), - "all arguments must still be forwarded to the requested recipe: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 1, - "a later token-requiring recipe must cause one isolated anvil-aprz invocation" - ); - assert!( - run.docker_log - .lines() - .any(|line| line.contains("--env ANVIL_APRZ_ALREADY_RAN=1") && line.contains("just anvil-clippy anvil-aprz")), - "the requested recipes must run with APRZ marked complete: {}", - run.docker_log - ); -} - -#[test] -fn customization_can_provide_github_authentication() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "GITHUB_TOKEN=custom-token\n", - "anvil-aprz", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1")], - ); - - assert!(run.status.success(), "custom authentication must be accepted: {}", run.stderr); - assert!( - run.docker_log.contains("/run/secrets/anvil-github-token"), - "the customization-provided token must be mounted for APRZ: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 1, - "direct anvil-aprz must run exactly one recipe container: {}", - run.docker_log - ); - assert_token_files_removed(&run); -} - -#[test] -fn customization_can_extend_aprz_classification() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN=true\n", - "anvil-clippy", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!(run.status.success(), "custom APRZ classification failed: {}", run.stderr); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-aprz")), - "custom classification must trigger isolated APRZ: {}", - run.docker_log - ); - assert!( - run.docker_log - .lines() - .any(|line| line.contains("ANVIL_APRZ_ALREADY_RAN=1") && line.contains("just anvil-clippy")), - "the requested recipe must run after APRZ completion: {}", - run.docker_log - ); -} - -#[test] -fn every_requested_argument_must_be_an_anvil_recipe() { - let tmp = repo_with_container(); - let run = run_driver_args(tmp.path(), "", &["anvil-clippy", "not-anvil"], &[]); - - assert!(!run.status.success(), "invalid later recipe must fail"); - assert!( - run.stderr.contains("expected each argument to be an anvil-* recipe"), - "stderr must explain the command contract: {}", - run.stderr - ); - assert!( - run.docker_log.is_empty(), - "validation must happen before Docker: {}", - run.docker_log - ); -} - -#[test] -fn base_image_override_is_digest_pinned_and_passed_to_build() { - let tmp = repo_with_container(); - let base_image = "example.invalid/bullseye@sha256:1111111111111111111111111111111111111111111111111111111111111111"; - let run = run_driver(tmp.path(), "", "anvil-clippy", &[("ANVIL_CONTAINER_BASE_IMAGE", base_image)]); - - assert!(run.status.success(), "digest-pinned override failed: {}", run.stderr); - assert!( - run.docker_log - .lines() - .any(|line| line.starts_with("build ") && line.contains(&format!("BASE_IMAGE={base_image}"))), - "Docker build must receive the selected base image: {}", - run.docker_log - ); - - let invalid = run_driver( - tmp.path(), - "", - "anvil-clippy", - &[("ANVIL_CONTAINER_BASE_IMAGE", "debian:bullseye-slim")], - ); - assert!(!invalid.status.success(), "an unpinned base image must fail"); - assert!(invalid.stderr.contains("must be pinned by sha256 digest")); -} - -#[test] -fn aggregate_recipe_isolates_the_token_from_the_main_container() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "_anvil-scheduled", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!(run.status.success(), "aggregate recipe failed: {}", run.stderr); - let aprz = run - .docker_log - .lines() - .find(|line| line.contains("just anvil-aprz")) - .expect("aggregate recipe must run isolated APRZ"); - let main = run - .docker_log - .lines() - .find(|line| line.contains("just _anvil-scheduled")) - .expect("aggregate recipe must run its main container"); - assert!( - aprz.contains("/run/secrets/anvil-github-token"), - "APRZ must receive the token mount: {aprz}" - ); - assert!( - !main.contains("/run/secrets/anvil-github-token"), - "main container must not receive the token mount: {main}" - ); - assert!( - main.contains("ANVIL_APRZ_ALREADY_RAN=1"), - "main container must skip the completed APRZ check: {main}" - ); - assert!( - !run.docker_log.contains("--env GITHUB_TOKEN"), - "the token must never be passed through the environment" - ); - assert_token_files_removed(&run); -} - -#[test] -fn pr_recipes_run_without_github_token_handling() { - // main #76 moved the token-requiring GitHub work into `anvil-aprz`, so the - // PR recipes (`anvil-pr`, `_anvil-pr`, `anvil-pr-fast`) are deliberately NOT - // token-required. Run one with GITHUB_TOKEN set and assert the container - // receives no `/run/secrets/anvil-github-token` mount and triggers no - // isolated APRZ invocation -- the negative half of the - // `anvil_recipe_needs_github_token` case arm, otherwise pinned only by - // generated-text snapshots. Accidentally re-adding any of the three recipes - // to that arm would flip these assertions. - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "anvil-pr-fast", - &[("FAKE_DOCKER_IMAGE_EXISTS", "1"), ("GITHUB_TOKEN", "test-token")], - ); - - assert!( - run.status.success(), - "a PR recipe must run without GitHub token handling: {}", - run.stderr - ); - assert!( - run.docker_log.lines().any(|line| line.contains("just anvil-pr-fast")), - "the requested PR recipe must still be forwarded to a container: {}", - run.docker_log - ); - assert!( - token_source_paths(&run.docker_log).is_empty(), - "a PR recipe must not mount a GitHub token: {}", - run.docker_log - ); - assert_eq!( - run.docker_log - .lines() - .filter(|line| line.starts_with("run ") && line.contains("just anvil-aprz")) - .count(), - 0, - "a PR recipe must not trigger an isolated anvil-aprz invocation: {}", - run.docker_log - ); - assert!( - !run.docker_log.contains("/run/secrets/anvil-github-token"), - "no token secret may be mounted for a PR recipe: {}", - run.docker_log - ); -} - -#[test] -fn token_file_is_removed_after_aprz_or_main_failure() { - for marker in ["just anvil-aprz", "just _anvil-scheduled"] { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "", - "_anvil-scheduled", - &[ - ("FAKE_DOCKER_IMAGE_EXISTS", "1"), - ("GITHUB_TOKEN", "test-token"), - ("FAKE_DOCKER_FAIL_MARKER", marker), - ], - ); - - assert!(!run.status.success(), "failure marker must fail the driver: {marker}"); - assert_token_files_removed(&run); - } -} - -#[test] -fn cold_run_with_empty_default_arrays_exposes_contract_inputs_scopes_phases_and_runs_cleanup() { - let tmp = repo_with_container(); - let root = tmp.path(); - // Deliberately leaves ANVIL_CONTAINER_BUILD_ARGS/PREPARE_ARGS/PREPARE_COMMAND/RUN_ARGS - // at their script-provided empty defaults, which is exactly the state - // that broke under Bash 3.2 / Bash <4.4 `set -u` semantics. - let customize = r#" -printf 'exists=%s\n' "$ANVIL_CONTAINER_IMAGE_EXISTS" >> "$FAKE_TEST_LOG" -printf 'recipes=%s\n' "${ANVIL_CONTAINER_REQUESTED_RECIPES[*]}" >> "$FAKE_TEST_LOG" -printf 'repo-is-dir=%s\n' "$([[ -d "$ANVIL_CONTAINER_REPO_ROOT" ]] && echo true || echo false)" >> "$FAKE_TEST_LOG" -printf 'dir-is-container-dir=%s\n' "$([[ "$ANVIL_CONTAINER_DIR" == "$ANVIL_CONTAINER_REPO_ROOT/.anvil/container" ]] && echo true || echo false)" >> "$FAKE_TEST_LOG" -anvil_test_cleanup() { printf 'cleanup-ran\n' >> "$FAKE_TEST_LOG"; } -ANVIL_CONTAINER_CLEANUP=anvil_test_cleanup -"#; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!( - run.status.success(), - "cold run with empty default arrays must succeed: stderr={}\ndocker.log={}", - run.stderr, - run.docker_log - ); - assert!(run.test_log.contains("exists=false"), "log: {}", run.test_log); - assert!(run.test_log.contains("recipes=anvil-clippy"), "log: {}", run.test_log); - assert!(run.test_log.contains("repo-is-dir=true"), "log: {}", run.test_log); - assert!(run.test_log.contains("dir-is-container-dir=true"), "log: {}", run.test_log); - assert!( - run.docker_log.lines().any(|line| line.starts_with("build ")), - "a cold run must invoke docker build: {}", - run.docker_log - ); - assert!( - run.docker_log - .lines() - .any(|line| line.starts_with("run ") && line.contains("just anvil-clippy")), - "expected a docker run invocation, got: {}", - run.docker_log - ); - assert_eq!( - run.docker_log.lines().filter(|line| line.starts_with("volume create ")).count(), - 3, - "the driver must create all named cache volumes: {}", - run.docker_log - ); - assert!( - run.docker_log.contains("volume create anvil-cargo-registry-") && run.docker_log.contains("volume create anvil-cargo-git-"), - "Cargo caches must be repository-scoped: {}", - run.docker_log - ); - let recipe_line = run - .docker_log - .lines() - .find(|line| line.starts_with("run ") && line.contains("just anvil-clippy")) - .expect("the recipe run is asserted present above"); - assert!( - recipe_line.contains("--user ") && !recipe_line.contains("--user 0:0"), - "recipe execution must use the WSL/Linux user identity: {recipe_line}" - ); - assert!( - run.test_log.contains("cleanup-ran"), - "cleanup must run after an ordinary successful invocation: {}", - run.test_log - ); -} - -#[test] -fn cargo_caches_are_repository_scoped_but_stable_across_image_ids() { - let first = repo_with_container(); - let second = repo_with_container(); - let first_run = run_driver(first.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - let second_run = run_driver(second.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - let first_volumes = created_volumes(&first_run.docker_log); - let second_volumes = created_volumes(&second_run.docker_log); - let first_registry = first_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-registry-")) - .expect("first repository must create a registry cache"); - let second_registry = second_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-registry-")) - .expect("second repository must create a registry cache"); - let first_git = first_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-git-")) - .expect("first repository must create a Git cache"); - let second_git = second_volumes - .iter() - .find(|name| name.starts_with("anvil-cargo-git-")) - .expect("second repository must create a Git cache"); - assert_ne!(first_registry, second_registry); - assert_ne!(first_git, second_git); - - write(&first.path().join("justfiles/anvil/versions.just"), "changed := \"1\"\n"); - let changed_run = run_driver(first.path(), "", "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - let changed_volumes = created_volumes(&changed_run.docker_log); - assert!(changed_volumes.contains(first_registry)); - assert!(changed_volumes.contains(first_git)); - assert_ne!( - first_volumes.iter().find(|name| name.starts_with("anvil-target-")), - changed_volumes.iter().find(|name| name.starts_with("anvil-target-")) - ); -} - -#[test] -fn warm_run_skips_the_build_and_still_reports_image_exists() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r#" -printf 'exists=%s\n' "$ANVIL_CONTAINER_IMAGE_EXISTS" >> "$FAKE_TEST_LOG" -"#; - let run = run_driver(root, customize, "anvil-clippy", &[("FAKE_DOCKER_IMAGE_EXISTS", "1")]); - - assert!( - run.status.success(), - "warm run must succeed: stderr={}\ndocker.log={}", - run.stderr, - run.docker_log - ); - assert!(run.test_log.contains("exists=true"), "log: {}", run.test_log); - assert!( - !run.docker_log.lines().any(|line| line.starts_with("build ")), - "a warm run (matching image already present) must not invoke docker build: {}", - run.docker_log - ); -} - -#[test] -fn prepare_args_without_a_prepare_command_are_rejected_before_docker_runs() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r" -ANVIL_CONTAINER_PREPARE_ARGS=(--label 'prepare-marker=1') -"; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!(!run.status.success(), "prepare args without a prepare command must fail validation"); - assert!( - run.stderr - .contains("ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND"), - "stderr must name the invalid output: {}", - run.stderr - ); - assert!( - !run.docker_log - .lines() - .any(|line| line.starts_with("build ") || line.starts_with("run ")), - "validation must fail before any Docker build or run invocation \ - (version/image-exists checks happen earlier and are expected): {}", - run.docker_log - ); -} - -#[test] -fn scalar_output_redeclaration_is_rejected_before_docker_runs() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "unset ANVIL_CONTAINER_RUN_ARGS\nANVIL_CONTAINER_RUN_ARGS=--label\n", - "anvil-clippy", - &[], - ); - - assert!(!run.status.success(), "a scalar output must fail validation"); - assert!( - run.stderr.contains("ANVIL_CONTAINER_RUN_ARGS must be a string array"), - "stderr must name the invalid output: {}", - run.stderr - ); -} - -#[test] -fn content_changing_build_arguments_are_rejected() { - let tmp = repo_with_container(); - let run = run_driver( - tmp.path(), - "ANVIL_CONTAINER_BUILD_ARGS=(--build-arg BASE_IMAGE=example.invalid/base)\n", - "anvil-clippy", - &[], - ); - - assert!(!run.status.success(), "content-changing build arguments must fail validation"); - assert!( - run.stderr - .contains("ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments"), - "stderr must explain the image-identity restriction: {}", - run.stderr - ); -} - -#[test] -fn cleanup_still_runs_after_the_main_recipe_container_fails() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r#" -ANVIL_CONTAINER_RUN_ARGS=(--label 'run-marker=1') -anvil_test_cleanup() { printf 'cleanup-ran\n' >> "$FAKE_TEST_LOG"; } -ANVIL_CONTAINER_CLEANUP=anvil_test_cleanup -"#; - let run = run_driver( - root, - customize, - "anvil-clippy", - &[ - ("FAKE_DOCKER_IMAGE_EXISTS", "1"), // warm run: only the main recipe container executes. - ("FAKE_DOCKER_FAIL_MARKER", "run-marker=1"), - ], - ); - - assert!(!run.status.success(), "the driver must surface the recipe failure"); - assert!( - run.test_log.contains("cleanup-ran"), - "cleanup must still run after an ordinary recipe failure: {}", - run.test_log - ); -} - -#[test] -fn build_and_run_phase_arguments_stay_isolated() { - let tmp = repo_with_container(); - let root = tmp.path(); - let customize = r" -ANVIL_CONTAINER_BUILD_ARGS=(--secret 'id=build-marker,src=fake') -ANVIL_CONTAINER_RUN_ARGS=(--label 'run-marker=1') -"; - let run = run_driver(root, customize, "anvil-clippy", &[]); - - assert!( - run.status.success(), - "cold run must succeed: stderr={}\ndocker.log={}", - run.stderr, - run.docker_log - ); - let build_line = run - .docker_log - .lines() - .find(|line| line.starts_with("build ")) - .unwrap_or_else(|| panic!("expected a docker build invocation, got: {}", run.docker_log)); - assert!(build_line.contains("id=build-marker,src=fake"), "line: {build_line}"); - assert!(!build_line.contains("run-marker=1"), "line: {build_line}"); - let run_line = run - .docker_log - .lines() - .find(|line| line.starts_with("run ") && line.contains("just anvil-clippy")) - .unwrap_or_else(|| panic!("expected a docker run invocation, got: {}", run.docker_log)); - assert!(run_line.contains("run-marker=1"), "line: {run_line}"); - assert!(!run_line.contains("id=build-marker,src=fake"), "line: {run_line}"); -} diff --git a/crates/cargo-anvil/tests/container_upgrade.rs b/crates/cargo-anvil/tests/container_upgrade.rs index 35d40084..c098c326 100644 --- a/crates/cargo-anvil/tests/container_upgrade.rs +++ b/crates/cargo-anvil/tests/container_upgrade.rs @@ -13,29 +13,46 @@ reason = "integration tests panic on unmet preconditions for readable failure output" )] -//! Consumer-upgrade coverage for the container asset relocation. +//! Consumer-upgrade coverage for the container backend. //! -//! The snapshot tests describe a fresh tree. This exercises the path an -//! existing adopter actually takes: a repository generated before the move, -//! with its `.anvil.lock` and its assets under `justfiles/anvil/container/`, -//! updated by a binary that emits `.anvil/container/`. +//! The snapshot tests describe a fresh tree: the end state of a generation that +//! starts from nothing. This exercises the path an existing adopter takes -- +//! a repository generated by 0.4.0, holding a `.anvil.lock` that tracks the +//! runner seam and its assets, updated by a binary that emits neither. +//! +//! Across that upgrade `Containerfile` and its ignore file, `README.md`, +//! `entrypoint.sh`, `image-id.{sh,ps1}` and `run-in-container.{sh,ps1}` all +//! disappear from `.anvil/container/`, `justfiles/anvil/runner.just` +//! disappears with them, and the root `Justfile` loses its `anvil-runner` +//! region. What matters is that an untouched asset is removed cleanly and an +//! *edited* one is handed back to the repository rather than deleted. use std::path::Path; -use cargo_anvil::test_support::{Cli, Decision, Manifest, RunOutcome, Target, run_update}; -use cargo_anvil::{Artifact, Catalog, artifacts}; +use cargo_anvil::test_support::{Cli, Decision, Manifest, RegionKey, RunOutcome, Target, checksum_str, run_update}; +use cargo_anvil::{Catalog, artifacts}; use tempfile::TempDir; -/// A hand-authored customization file: never catalog-tracked, so `cargo anvil` -/// can neither move it nor report it. The drivers warn about one left here. -const LEGACY_CUSTOMIZE: &str = "justfiles/anvil/container/customize.sh"; +/// Generated container assets that 0.4.0 tracked and the current catalog does +/// not. Paths are as 0.4.0 wrote them. +const RETIRED_ASSETS: [&str; 8] = [ + ".anvil/container/Containerfile", + ".anvil/container/Containerfile.dockerignore", + ".anvil/container/README.md", + ".anvil/container/entrypoint.sh", + ".anvil/container/image-id.ps1", + ".anvil/container/image-id.sh", + ".anvil/container/run-in-container.ps1", + ".anvil/container/run-in-container.sh", +]; -/// Where a generated container asset lived before the move. Every asset, -/// including the entry recipe, sat directly under `justfiles/anvil/container/`. -fn pre_move_path(current: &str) -> String { - let name = current.rsplit('/').next().expect("split always yields one element"); - format!("justfiles/anvil/container/{name}") -} +/// The routing seam's own recipe file, retired with the assets above. +const RETIRED_RECIPE: &str = "justfiles/anvil/runner.just"; + +/// The managed region 0.4.0 spliced into the root `Justfile` to carry the +/// runner selection, and the id it was keyed by. +const RETIRED_REGION_ID: &str = "anvil-runner"; +const RETIRED_REGION_BODY: &str = "anvil_runner := \"native\"\n"; fn write(path: &Path, contents: &str) { if let Some(parent) = path.parent() { @@ -68,38 +85,76 @@ fn local() -> Cli { } } -/// The current container assets, paired with their pre-move locations. -fn container_assets() -> Vec<(String, String)> { - artifacts::container::all() - .into_iter() - .map(|artifact| match artifact { - Artifact::OwnedFile(spec) => (spec.path.to_owned(), pre_move_path(spec.path)), - Artifact::Region(_) => panic!("container artifacts are owned files"), - }) - .collect() +/// A workspace that has already been generated once, so the lock and the +/// composed tree exist and a test can rewind one part of them. +fn generated_tree() -> TempDir { + let tmp = workspace(); + run_update(&Catalog::anvil(), &local(), tmp.path()).unwrap(); + tmp } -/// Rewrite a freshly generated tree into the shape the previous release -/// produced: every generated container asset under `justfiles/anvil/container/`, -/// tracked at that path by `.anvil.lock`. -fn rewind_to_pre_move_layout(root: &Path) { +/// Rewrite a freshly generated tree into the shape 0.4.0 produced: the retired +/// assets present on disk and tracked in the lock, and none of the current +/// container artifacts present at all. +/// +/// The checksum recorded for each retired asset is the checksum of the body +/// written here, which is what makes the file "untouched since last render" and +/// so eligible for `Remove`. A test that wants the customized path overwrites +/// the body afterwards, leaving the recorded checksum stale on purpose. +fn rewind_to_runner_layout(root: &Path) -> Manifest { let mut manifest = Manifest::load(root).unwrap(); - for (current, previous) in container_assets() { - let to = root.join(&previous); - std::fs::create_dir_all(to.parent().unwrap()).unwrap(); - std::fs::rename(root.join(¤t), &to).unwrap(); - let checksum = manifest - .files - .remove(¤t) - .unwrap_or_else(|| panic!("{current} must be tracked by the fresh lock")); - manifest.files.insert(previous, checksum); + + for artifact in artifacts::container::all() { + // The Dockerfile is composed now, so the group is a mix: the recipe and + // the ignore file are owned, the image definition is four regions in a + // host this rewind deletes outright. + let path = match artifact { + cargo_anvil::Artifact::OwnedFile(spec) => spec.path, + cargo_anvil::Artifact::Region(_) => continue, + }; + let full = root.join(path); + if full.exists() { + std::fs::remove_file(&full).unwrap(); + } + manifest.files.remove(path); + } + + let dockerfile = root.join(".anvil/container/Dockerfile"); + if dockerfile.exists() { + std::fs::remove_file(&dockerfile).unwrap(); + } + manifest + .regions + .retain(|key, _| !key.host.starts_with(".anvil/container/Dockerfile")); + + for path in RETIRED_ASSETS.iter().copied().chain(std::iter::once(RETIRED_RECIPE)) { + let body = format!("# 0.4.0 generated {path}\n"); + write(&root.join(path), &body); + manifest.files.insert(path.to_owned(), checksum_str(&body)); } - // Provenance of the older build. It is recorded, never a gate. + + // The managed region 0.4.0 spliced into the root Justfile. Seeding it is + // what makes the removal assertion mean anything: without it the Justfile + // never contained `anvil-runner`, so asserting its absence afterwards would + // hold before the upgrade ran and would keep holding if region removal + // broke entirely. + let justfile_path = root.join("Justfile"); + let justfile = std::fs::read_to_string(&justfile_path).unwrap(); + let region = format!("# >>> anvil-managed: {RETIRED_REGION_ID}\n{RETIRED_REGION_BODY}# <<< anvil-managed: {RETIRED_REGION_ID}\n"); + write(&justfile_path, &format!("{justfile}\n{region}")); + manifest.regions.insert( + RegionKey { + host: "Justfile".to_owned(), + id: RETIRED_REGION_ID.to_owned(), + }, + checksum_str(RETIRED_REGION_BODY), + ); + + // Provenance of the older build. Recorded, never a gate. manifest.catalog_checksum = Some("sha256:0000000000000000000000000000000000000000000000000000000000000000".to_owned()); - manifest.tool_version = Some("0.2.0".to_owned()); + manifest.tool_version = Some("0.4.0".to_owned()); manifest.save(root).unwrap(); - std::fs::remove_dir(root.join(".anvil/container")).unwrap(); - std::fs::remove_dir(root.join(".anvil")).unwrap(); + manifest } fn decision_for(outcome: &RunOutcome, path: &str) -> Decision { @@ -113,92 +168,839 @@ fn decision_for(outcome: &RunOutcome, path: &str) -> Decision { } #[test] -fn upgrading_from_the_pre_move_layout_relocates_generated_container_assets() { +fn upgrading_from_the_runner_layout_retires_the_seam_and_emits_the_new_backend() { let tmp = workspace(); let root = tmp.path(); run_update(&Catalog::anvil(), &local(), root).unwrap(); - rewind_to_pre_move_layout(root); + rewind_to_runner_layout(root); - let assets = container_assets(); - let (customized_current, customized_previous) = assets + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + assert!(outcome.applied); + let manifest = Manifest::load(root).unwrap(); + + // Untouched retired assets are deleted and untracked. Leaving them behind + // would ship a repository two container backends, one of which no recipe + // can reach. + for path in RETIRED_ASSETS.iter().copied().chain(std::iter::once(RETIRED_RECIPE)) { + assert_eq!(decision_for(&outcome, path), Decision::Remove, "{path} must be removed"); + assert!(!root.join(path).exists(), "{path} must be gone from disk"); + assert!(!manifest.files.contains_key(path), "{path} must be dropped from the lock"); + } + + // The current artifacts take their place and are tracked. + for artifact in artifacts::container::all() { + match artifact { + cargo_anvil::Artifact::OwnedFile(spec) => { + assert!(root.join(spec.path).is_file(), "{} must be written", spec.path); + assert!(manifest.files.contains_key(spec.path), "{} must be tracked", spec.path); + } + cargo_anvil::Artifact::Region(spec) => { + let cargo_anvil::HostSelector::Path(host) = &spec.host else { + panic!("container regions target a literal path"); + }; + let composed = std::fs::read_to_string(root.join(host)).unwrap(); + assert!( + composed.contains(&format!("# >>> anvil-managed: {}", spec.id)), + "{} must be spliced into {host}", + spec.id + ); + assert!( + manifest.regions.contains_key(&RegionKey { + host: host.clone(), + id: spec.id.as_str().to_owned(), + }), + "{} must be tracked", + spec.id + ); + } + } + } + + // The composed Dockerfile keeps its seeded parser directive on line 1: + // BuildKit honors it nowhere else, and a region sentinel above it would + // demote it silently. + let dockerfile = std::fs::read_to_string(root.join(".anvil/container/Dockerfile")).unwrap(); + assert!( + dockerfile.starts_with("# syntax=docker/dockerfile:1\n"), + "the composed Dockerfile must lead with the parser directive" + ); + + // The runner region is spliced out of the root Justfile, and nothing else + // is: the surrounding content the repository owns must survive intact. + // Seeded in the rewind above, so this assertion can actually fail. + let region_decision = outcome + .plan + .items() .iter() - .find(|(current, _)| current.ends_with("entrypoint.sh")) - .cloned() - .expect("the entry point is part of the container group"); + .find(|item| matches!(&item.target, Target::Region { host, id } if host == "Justfile" && id == RETIRED_REGION_ID)) + .map(|item| item.decision); + assert_eq!( + region_decision, + Some(Decision::Remove), + "the obsolete runner region must be planned for removal" + ); - // One adopter-edited generated asset, and one hand-authored customization - // file the catalog has never tracked. - let customized_body = "#!/bin/sh\n# locally patched entry point\n"; - write(&root.join(&customized_previous), customized_body); - write(&root.join(LEGACY_CUSTOMIZE), "# hand-authored customization\n"); + let justfile = std::fs::read_to_string(root.join("Justfile")).unwrap(); + assert!(!justfile.contains(RETIRED_REGION_ID), "the runner region must be spliced out"); + assert!(!justfile.contains(RETIRED_REGION_BODY.trim()), "the region body must go with it"); + assert!(justfile.contains("anvil"), "the Justfile must still import the anvil tree"); + assert!( + !Manifest::load(root).unwrap().regions.keys().any(|key| key.id == RETIRED_REGION_ID), + "the region must be dropped from the lock" + ); +} + +#[test] +fn an_edited_retired_asset_is_handed_back_rather_than_deleted() { + let tmp = workspace(); + let root = tmp.path(); + run_update(&Catalog::anvil(), &local(), root).unwrap(); + rewind_to_runner_layout(root); + + // The adopter patched their Containerfile. The lock still holds the + // checksum of the generated body, so anvil can see the divergence. + let edited = ".anvil/container/Containerfile"; + let body = "FROM ubuntu:24.04\n# locally patched base\n"; + write(&root.join(edited), body); let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); assert!(outcome.applied); - let manifest = Manifest::load(root).unwrap(); - for (current, previous) in &assets { - // Every asset is re-emitted at its new location and tracked there. - assert!(root.join(current).is_file(), "{current} must be written at the new location"); - assert_eq!( - decision_for(&outcome, current), - Decision::Write, - "{current} must be freshly written" - ); - assert!(manifest.files.contains_key(current), "{current} must be tracked at the new path"); - assert!(!manifest.files.contains_key(previous), "{previous} must be dropped from the lock"); + // Removing a file the repository has edited would destroy work anvil did + // not author. Ownership transfers instead: the file stays, the lock entry + // goes. + assert_eq!( + decision_for(&outcome, edited), + Decision::OrphanedKept, + "an edited retired asset must be kept" + ); + assert_eq!( + std::fs::read_to_string(root.join(edited)).unwrap(), + body, + "the adopter's content must be preserved byte for byte" + ); + assert!( + !Manifest::load(root).unwrap().files.contains_key(edited), + "the lock entry must be dropped so the file becomes the repository's" + ); +} - if previous == &customized_previous { - continue; - } - // Untouched old assets are removed outright. - assert!(!root.join(previous).exists(), "untouched orphan {previous} must be removed"); - assert_eq!( - decision_for(&outcome, previous), - Decision::Remove, - "{previous} must be removed as an untouched orphan" +/// The release before this one owned `.anvil/container/Dockerfile` outright: a +/// single generated file a repository was invited to edit in place. This +/// release composes the same path from four managed regions. +/// +/// The upgrade must replace that file, not append to it. Appending would leave +/// the whole previous definition above the regions -- a second `FROM`, a second +/// set of pins, and an image built from whichever the frontend saw first. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn upgrading_from_the_owned_dockerfile_reseeds_the_composed_host() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // Rewind to the owned-file shape: one generated file, tracked in the lock + // by the checksum of exactly what is on disk, and no regions recorded. + let previous_render = + "# syntax=docker/dockerfile:1\n# Managed by cargo-anvil.\nFROM docker.io/library/ubuntu:24.04\nRUN apt-get update\n"; + write(&dockerfile, previous_render); + let mut manifest = Manifest::load(root).unwrap(); + manifest + .files + .insert(".anvil/container/Dockerfile".to_owned(), checksum_str(previous_render)); + manifest.regions.retain(|key, _| key.host != ".anvil/container/Dockerfile"); + manifest.save(root).unwrap(); + + run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let composed = std::fs::read_to_string(&dockerfile).unwrap(); + assert_eq!( + composed.matches("\nFROM ").count() + usize::from(composed.starts_with("FROM ")), + 1, + "the superseded definition must be replaced, not appended to:\n{composed}" + ); + assert!( + !composed.contains("RUN apt-get update\n# >>>"), + "no line of the previous render may survive above the regions" + ); + assert!(composed.starts_with("# syntax=docker/dockerfile:1\n")); + for id in [ + "anvil-container-base", + "anvil-container-tools", + "anvil-container-setup", + "anvil-container-entry", + ] { + assert!(composed.contains(&format!("# >>> anvil-managed: {id}")), "{id} must be spliced in"); + } + + // The stale owned-file entry is gone; the regions are tracked in its place. + let after = Manifest::load(root).unwrap(); + assert!( + !after.files.contains_key(".anvil/container/Dockerfile"), + "the superseded owned-file entry must be dropped from the lock" + ); + assert!( + after.regions.contains_key(&RegionKey { + host: ".anvil/container/Dockerfile".to_owned(), + id: "anvil-container-base".to_owned(), + }), + "the composed regions must be tracked instead" + ); +} + +/// A Dockerfile the repository wrote itself -- never tracked as an owned file -- +/// cannot be composed by appending regions to it: everything already in the file +/// would end up above `FROM`. Anvil must refuse and say so, leaving the file +/// untouched, rather than writing something that cannot build. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_repository_authored_dockerfile_is_refused_not_appended_to() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + let hand_written = "# hand written by the repository\nRUN echo mine\n"; + write(&dockerfile, hand_written); + let mut manifest = Manifest::load(root).unwrap(); + manifest.files.remove(".anvil/container/Dockerfile"); + manifest.regions.retain(|key, _| key.host != ".anvil/container/Dockerfile"); + manifest.save(root).unwrap(); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + assert_eq!( + std::fs::read_to_string(&dockerfile).unwrap(), + hand_written, + "content anvil never owned must be left exactly as found" + ); + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.contains(".anvil/container/Dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host: {refusals:?}"); + assert!( + refusals[0].contains("anvil has never owned it"), + "the diagnostic must explain why it cannot be composed: {}", + refusals[0] + ); +} + +/// The upgrade re-seed replaces a whole-file render anvil produced. It +/// must not do that when the repository edited that file: the file is not +/// tracked region-by-region, so there is no proposal to fall back on and +/// nothing to recover the edit from. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn an_edited_superseded_dockerfile_is_never_overwritten() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // Tracked as an owned file, but the recorded checksum is of a *different* + // body: the repository edited it after anvil last wrote it. + let previous_render = "# syntax=docker/dockerfile:1\nFROM docker.io/library/ubuntu:24.04\n"; + let edited = format!("{previous_render}RUN apt-get install -y our-internal-tool\n"); + write(&dockerfile, &edited); + let mut manifest = Manifest::load(root).unwrap(); + manifest + .files + .insert(".anvil/container/Dockerfile".to_owned(), checksum_str(previous_render)); + manifest.regions.retain(|key, _| key.host != ".anvil/container/Dockerfile"); + manifest.save(root).unwrap(); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + assert_eq!( + std::fs::read_to_string(&dockerfile).unwrap(), + edited, + "an edit made after the last render must survive the upgrade untouched" + ); + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.contains(".anvil/container/Dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host: {refusals:?}"); + assert!( + refusals[0].contains("edited after anvil last wrote it"), + "the diagnostic must name the reason: {}", + refusals[0] + ); +} + +/// A half-composed file — some of anvil's regions present, others missing — is +/// what an adopter has the first time anvil adds a region to the set. That is a +/// normal upgrade: each missing region is inserted at its declared position, so +/// the file stays ordered and the repository's own content between the regions +/// survives untouched. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_newly_added_region_is_inserted_in_order_not_appended() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // Drop the base region and put repository content in the gaps around it, + // exactly as an adopter would have. Appending the region back would land + // `FROM` after the layers that depend on it. + let text = std::fs::read_to_string(&dockerfile).unwrap(); + let open = text.find("# >>> anvil-managed: anvil-container-base\n").unwrap(); + let close_marker = "# <<< anvil-managed: anvil-container-base\n"; + let close = text.find(close_marker).unwrap() + close_marker.len(); + let without_base = format!("{}# MINE-BEFORE\n{}# MINE-AFTER\n", &text[..open], &text[close..]); + write(&dockerfile, &without_base); + let mut manifest = Manifest::load(root).unwrap(); + manifest + .regions + .retain(|key, _| !(key.host == ".anvil/container/Dockerfile" && key.id == "anvil-container-base")); + manifest.save(root).unwrap(); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + assert!( + outcome.plan.refusals().is_empty(), + "adding a region is an upgrade, not a refusal: {:?}", + outcome.plan.refusals() + ); + + let composed = std::fs::read_to_string(&dockerfile).unwrap(); + let at = |needle: &str| { + composed + .find(needle) + .unwrap_or_else(|| panic!("missing from recomposed Dockerfile: {needle}\n{composed}")) + }; + // Restored in order, not at the end. + assert!(at("ARG BASE_IMAGE=") < at("FROM ${BASE_IMAGE}")); + assert!(at("FROM ${BASE_IMAGE}") < at("RUN apt-get update")); + assert!(at("RUN apt-get update") < at("WORKDIR /workspace")); + // And the repository's own lines are still there. + assert!(composed.contains("# MINE-BEFORE"), "gap content before the region must survive"); + assert!(composed.contains("# MINE-AFTER"), "gap content after the region must survive"); + // `# MINE-AFTER` sits in the gap that follows the restored region's + // predecessor. The region has to land above it: inserting one line lower + // would split the repository's gap content around anvil's sentinels. + assert!( + at("# >>> anvil-managed: anvil-container-base\n") < at("# MINE-AFTER"), + "the restored region must land above the gap content, not inside it:\n{composed}" + ); +} + +/// The Dockerfile is the first managed-region host whose region *order* is +/// semantic: `FROM` must precede everything, and the toolchain must exist +/// before `anvil-setup` runs. `upsert_region` replaces a region wherever it +/// finds it, so a reordered file would otherwise be updated in place into a +/// Dockerfile that is silently wrong or cannot build at all. +/// +/// Anvil must refuse the host and say which region moved, exactly once, rather +/// than once per region. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_reordered_composed_dockerfile_is_refused_with_one_diagnostic() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // Move the base region below the others, which is exactly the mistake a + // repository makes by dropping its own instructions above `FROM`. + let text = std::fs::read_to_string(&dockerfile).unwrap(); + let open = text.find("# >>> anvil-managed: anvil-container-base\n").unwrap(); + let close_marker = "# <<< anvil-managed: anvil-container-base\n"; + let close = text.find(close_marker).unwrap() + close_marker.len(); + let base = &text[open..close]; + let reordered = format!("{}{}\n{}", &text[..open], &text[close..], base); + write(&dockerfile, &reordered); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.contains(".anvil/container/Dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host, not one per region: {refusals:?}"); + assert!( + refusals[0].contains("'anvil-container-tools' appears before 'anvil-container-base'"), + "the diagnostic must name the region that moved: {}", + refusals[0] + ); + + // Refusing means leaving the file exactly as it was found. Rewriting it + // would destroy whatever the repository put between the regions. + assert_eq!( + std::fs::read_to_string(&dockerfile).unwrap(), + reordered, + "a refused host must not be modified" + ); +} + +/// A refusal says "nothing was written to it". That has to be true of the lock +/// as well as the file: the recorded checksum is the provenance the next run +/// reclassifies from, and the clean recovery -- revert the edit, get a valid +/// re-seed -- only exists while it survives. Dropping it would make a file +/// anvil rendered look like one it has never owned, and the second run would +/// give the wrong diagnostic. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn refusing_a_composed_host_leaves_its_lock_entry_intact() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + let previous_render = "# syntax=docker/dockerfile:1\nFROM docker.io/library/ubuntu:24.04\n"; + let edited = format!("{previous_render}RUN apt-get install -y our-internal-tool\n"); + write(&dockerfile, &edited); + let mut manifest = Manifest::load(root).unwrap(); + manifest + .files + .insert(".anvil/container/Dockerfile".to_owned(), checksum_str(previous_render)); + manifest.regions.retain(|key, _| key.host != ".anvil/container/Dockerfile"); + manifest.save(root).unwrap(); + let lock_before = std::fs::read_to_string(root.join(".anvil.lock")).unwrap(); + + run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let after = Manifest::load(root).unwrap(); + assert_eq!( + after.files.get(".anvil/container/Dockerfile"), + Some(&checksum_str(previous_render)), + "a refused host keeps its recorded checksum, or its provenance is gone" + ); + assert_eq!( + std::fs::read_to_string(root.join(".anvil.lock")).unwrap(), + lock_before, + "refusing must not rewrite the lock at all" + ); + + // And the recovery the message advertises still works: revert the edit and + // the next run composes the file cleanly. + write(&dockerfile, previous_render); + run_update(&Catalog::anvil(), &local(), root).unwrap(); + let composed = std::fs::read_to_string(&dockerfile).unwrap(); + assert!(composed.starts_with("# syntax=docker/dockerfile:1\n")); + for id in [ + "anvil-container-base", + "anvil-container-tools", + "anvil-container-setup", + "anvil-container-entry", + ] { + assert!( + composed.contains(&format!("# >>> anvil-managed: {id}")), + "{id} must be spliced in after recovery" ); } +} + +/// A malformed sentinel must be reported as such, not folded in with "this +/// region is missing" -- the content is right there, with a broken marker. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_malformed_sentinel_is_named_in_the_refusal() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // Duplicate the opening sentinel, leaving the close unmatched. + let text = std::fs::read_to_string(&dockerfile).unwrap(); + let opener = "# >>> anvil-managed: anvil-container-base"; + let broken = text.replacen(opener, &format!("{opener}\n{opener}"), 1); + write(&dockerfile, &broken); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); - // The adopter's edit survives at the old path, with ownership transferred. assert_eq!( - std::fs::read_to_string(root.join(&customized_previous)).unwrap(), - customized_body, - "a customized orphan must keep the adopter's content" + std::fs::read_to_string(&dockerfile).unwrap(), + broken, + "a refused host must not be modified" ); + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.contains(".anvil/container/Dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host: {refusals:?}"); + assert!( + refusals[0].contains("cannot be read"), + "a broken sentinel must not be reported as a missing region: {}", + refusals[0] + ); +} + +/// The guard that refuses a repository-authored Dockerfile is reached through +/// a host name resolved from disk, so it must not be keyed off the canonical +/// spelling. A repository that wrote a lower-cased `dockerfile` -- the commoner +/// spelling in the wild, and indistinguishable from the canonical one on +/// Windows and macOS -- would otherwise miss the composed-host lookup entirely, +/// get no refusal, and have anvil's six regions appended below its own `FROM`. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_case_variant_repository_dockerfile_is_refused_too() { + let tmp = generated_tree(); + let root = tmp.path(); + let canonical = root.join(".anvil/container/Dockerfile"); + let lowercased = root.join(".anvil/container/dockerfile"); + + std::fs::remove_file(&canonical).unwrap(); + let hand_written = "# hand written by the repository\nFROM mine:1\nRUN echo mine\n"; + write(&lowercased, hand_written); + let mut manifest = Manifest::load(root).unwrap(); + manifest.files.remove(".anvil/container/Dockerfile"); + manifest.regions.retain(|key, _| key.host != ".anvil/container/Dockerfile"); + manifest.save(root).unwrap(); + + // Without this the test passes vacuously on a case-insensitive filesystem + // that kept the old directory entry. assert_eq!( - decision_for(&outcome, &customized_previous), - Decision::OrphanedKept, - "a customized orphan must transfer ownership rather than be deleted" + on_disk_dockerfile_name(root), + "dockerfile", + "precondition: the host is lower-cased on disk" + ); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + assert_eq!( + std::fs::read_to_string(&lowercased).unwrap(), + hand_written, + "content anvil never owned must be left exactly as found" + ); + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.to_lowercase().contains(".anvil/container/dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host: {refusals:?}"); + assert!( + refusals[0].contains("anvil has never owned it"), + "the diagnostic must explain why it cannot be composed: {}", + refusals[0] + ); +} + +/// A case-only rename of an already composed host leaves the lock keyed by the +/// old spelling and the pass keyed by the new one. Nothing is missing -- every +/// region is still in the file, rewritten this pass -- so the stale entries +/// must transfer ownership rather than be treated as orphans: removing them +/// would strip the pass's own writes and leave a Dockerfile with no `FROM`. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_case_only_rename_of_a_composed_host_keeps_its_regions() { + let tmp = generated_tree(); + let root = tmp.path(); + let canonical = root.join(".anvil/container/Dockerfile"); + let lowercased = root.join(".anvil/container/dockerfile"); + + let composed = std::fs::read_to_string(&canonical).unwrap(); + let regions_before = composed.matches("# >>> anvil-managed:").count(); + assert!(regions_before >= 2, "precondition: the host is composed from several regions"); + std::fs::remove_file(&canonical).unwrap(); + write(&lowercased, &composed); + assert_eq!( + on_disk_dockerfile_name(root), + "dockerfile", + "precondition: the host is lower-cased on disk" + ); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let after = std::fs::read_to_string(&lowercased).unwrap(); + assert_eq!( + after.matches("# >>> anvil-managed:").count(), + regions_before, + "a case-only rename must not cost the host any of its regions:\n{after}" + ); + assert!( + after.contains("\nFROM "), + "the composed file must still declare a base image:\n{after}" + ); + let removed: Vec<&Target> = outcome + .plan + .items() + .iter() + .filter(|i| i.decision == Decision::Remove) + .map(|i| &i.target) + .collect(); + assert!(removed.is_empty(), "nothing may be removed for a case-only rename: {removed:?}"); +} + +/// The real on-disk spelling of the composed host, so the case tests cannot +/// pass without the rename they claim to make having taken effect. +fn on_disk_dockerfile_name(root: &Path) -> String { + std::fs::read_dir(root.join(".anvil/container")) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .find(|n| n.eq_ignore_ascii_case("Dockerfile")) + .expect("the composed host must exist") +} + +/// A composed host lives in `manifest.regions` and never in `manifest.files`, +/// so "no file entry" is not the same as "anvil has never seen this path". It +/// is also every composed file that has lost its regions to a merge, a revert +/// or an edit -- and the lock still records them. Reporting that as a +/// never-owned file tells the reader to delete a file anvil rendered, throwing +/// away the gap content the refusal exists to protect, when the cheap recovery +/// is restoring the file. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_composed_host_that_lost_its_regions_is_told_to_restore_it() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // The lock is left exactly as generated: it still records every region. + let recorded = Manifest::load(root).unwrap(); + assert!( + recorded.regions.keys().any(|key| key.host == ".anvil/container/Dockerfile"), + "precondition: the lock records the composed host's regions" + ); + assert!( + !recorded.files.contains_key(".anvil/container/Dockerfile"), + "precondition: a composed host is never tracked as an owned file" + ); + write(&dockerfile, "# my own content\nFROM mine:1\n"); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let refusals: Vec<&String> = outcome + .plan + .refusals() + .iter() + .filter(|r| r.contains(".anvil/container/Dockerfile")) + .collect(); + assert_eq!(refusals.len(), 1, "one diagnostic per host: {refusals:?}"); + assert!( + !refusals[0].contains("anvil has never owned it"), + "the lock records the regions, so this is not a never-owned file: {}", + refusals[0] + ); + assert!( + refusals[0].contains("Restore the file"), + "the recovery must be restoring the file, not deleting it: {}", + refusals[0] ); - assert_ne!( - std::fs::read_to_string(root.join(&customized_current)).unwrap(), - customized_body, - "the new location must carry the current template, not the adopter's old edit" +} + +/// The removal pass compares the lock's casing against the casing every plan +/// item resolved from disk. Without resolving first, a case-only rename makes +/// anvil's own artifact look retired -- and on a case-insensitive filesystem +/// the removal opens the very file the pass just wrote and deletes it. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_case_only_rename_of_an_owned_file_is_not_deleted() { + let tmp = generated_tree(); + let root = tmp.path(); + let canonical = root.join("justfiles/anvil/tools.just"); + let renamed = root.join("justfiles/anvil/Tools.just"); + assert!(canonical.is_file(), "precondition: the owned recipe exists as generated"); + + let body = std::fs::read_to_string(&canonical).unwrap(); + std::fs::remove_file(&canonical).unwrap(); + write(&renamed, &body); + // The lock still carries the original casing, which is the whole point. + assert_eq!( + Manifest::load(root).unwrap().file_checksum("justfiles/anvil/tools.just"), + Some(checksum_str(&body).as_str()), + "precondition: the lock records the pre-rename casing" ); - // The recipe keeps its identity across the move: it is emitted at the new - // path and imported from there. - let recipe = std::fs::read_to_string(root.join("justfiles/anvil/container.just")).unwrap(); - assert!(recipe.contains("anvil-container"), "the entry recipe must survive the move"); - let module = std::fs::read_to_string(root.join("justfiles/anvil/mod.just")).unwrap(); + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let removed: Vec = outcome + .plan + .items() + .iter() + .filter(|i| i.decision == Decision::Remove) + .filter_map(|i| match &i.target { + Target::File { path } => Some(path.clone()), + Target::Region { .. } => None, + }) + .collect(); assert!( - module.contains("import 'container.just'"), - "mod.just must import the flattened recipe:\n{module}" + !removed.iter().any(|p| p.eq_ignore_ascii_case("justfiles/anvil/tools.just")), + "anvil must not retire the artifact it just wrote: {removed:?}" ); + assert!( + renamed.is_file() || canonical.is_file(), + "the generated recipe must still be on disk after the run" + ); + assert!( + !root.join("justfiles/anvil/Tools.just.anvil-proposed").exists() + && !root.join("justfiles/anvil/tools.just.anvil-proposed").exists(), + "a file anvil still owns must not be proposed against as though it were repository-authored" + ); +} + +/// The refusal is atomic across *both* removal loops: a refused host keeps its +/// file and its lock entries whichever loop reaches them. A lock entry naming a +/// region the catalog does not declare would otherwise reach `remove_region`, +/// splicing a block out of the very file whose refusal says it was untouched, +/// and purging the provenance the next run reclassifies from. +/// +/// Reaching that path needs a retired region id, which the catalog has none of, +/// so the stale key is planted here. It is one region rename away from being +/// an ordinary upgrade. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn refusing_a_composed_host_spares_a_retired_region_entry_too() { + let tmp = generated_tree(); + let root = tmp.path(); + let dockerfile = root.join(".anvil/container/Dockerfile"); + + // A region the catalog no longer declares, present in the file and tracked + // in the lock -- the state left by a rename or a retirement. + let legacy_id = "anvil-container-legacy"; + let legacy_body = "RUN echo legacy\n"; + let text = std::fs::read_to_string(&dockerfile).unwrap(); + let with_legacy = format!("{text}\n# >>> anvil-managed: {legacy_id}\n{legacy_body}# <<< anvil-managed: {legacy_id}\n"); + // Duplicate an opening sentinel so the host classifies `Unsafe` and the run + // refuses it. The legacy region itself stays well formed, so the removal + // path can still reach it. + let opener = "# >>> anvil-managed: anvil-container-base\n"; + let broken = with_legacy.replacen(opener, &format!("{opener}{opener}"), 1); + write(&dockerfile, &broken); + + let mut manifest = Manifest::load(root).unwrap(); + manifest.set_region(".anvil/container/Dockerfile", legacy_id, checksum_str(legacy_body)); + manifest.save(root).unwrap(); + let lock_before = std::fs::read_to_string(root.join(".anvil.lock")).unwrap(); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); - // A hand-authored customization file is invisible to the catalog, so the - // update neither moves nor deletes it. The drivers warn at run time. assert_eq!( - std::fs::read_to_string(root.join(LEGACY_CUSTOMIZE)).unwrap(), - "# hand-authored customization\n", - "an untracked customization file must be left exactly as the adopter wrote it" + std::fs::read_to_string(&dockerfile).unwrap(), + broken, + "a refused host must not have a block spliced out of it" + ); + assert_eq!( + std::fs::read_to_string(root.join(".anvil.lock")).unwrap(), + lock_before, + "\"nothing was written to it\" has to be true of the lock as well as the file" + ); + let key = RegionKey { + host: ".anvil/container/Dockerfile".to_owned(), + id: legacy_id.to_owned(), + }; + assert!( + Manifest::load(root).unwrap().regions.contains_key(&key), + "the retired region's provenance must survive a refusal" ); + assert!( + outcome.plan.refusals().iter().any(|r| r.contains(".anvil/container/Dockerfile")), + "precondition: the host must actually have been refused" + ); +} + +/// A retired owned file is deleted under its real on-disk name. The lock +/// records whatever casing was written; on a case-sensitive filesystem a +/// case-only rename makes the recorded path absent, which reads as +/// `AlreadyGone` and would delete nothing while the lock entry is purged -- +/// leaving the file on disk owned by no one. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_retired_owned_file_is_removed_under_its_on_disk_casing() { + let tmp = generated_tree(); + let root = tmp.path(); + if !filesystem_is_case_sensitive(root) { + // The two spellings name one file here, so nothing distinguishes the + // fix from the defect. The property only exists where they differ. + return; + } + + // A file anvil no longer declares, tracked in the lock under one casing + // and present on disk under another. + let recorded = "justfiles/anvil/Retired.just"; + let on_disk = root.join("justfiles/anvil/retired.just"); + let body = "# retired\n"; + write(&on_disk, body); + let mut manifest = Manifest::load(root).unwrap(); + manifest.set_file(recorded, checksum_str(body)); + manifest.save(root).unwrap(); + + let outcome = run_update(&Catalog::anvil(), &local(), root).unwrap(); + + assert!( + outcome + .plan + .items() + .iter() + .any(|i| i.decision == Decision::Remove && matches!(&i.target, Target::File { path } if path == recorded)), + "the retired file must be planned for removal" + ); + assert!(!on_disk.exists(), "the file must be gone from disk, not merely untracked"); + assert!( + !Manifest::load(root).unwrap().files.contains_key(recorded), + "the lock entry must be purged" + ); +} + +/// Whether two spellings of one name are distinct paths on the filesystem +/// backing `root`. The casing behaviour under test is only observable when +/// they are. +fn filesystem_is_case_sensitive(root: &Path) -> bool { + let probe = root.join("anvil-case-probe"); + std::fs::write(&probe, b"").unwrap(); + let sensitive = !root.join("ANVIL-CASE-PROBE").exists(); + std::fs::remove_file(&probe).unwrap(); + sensitive +} + +/// A region removal must splice into the text this pass wrote, not the text +/// that was on disk before it. The host text cache is keyed by the spelling +/// the writes used, so a removal reading under the lock's spelling misses the +/// cache, re-reads the pre-pass file, and writes that back over the regions +/// the same run produced. Removals apply after writes, so the stale text wins. +/// +/// The two spellings diverge after a case-only rename of the host, which is +/// what this sets up: the lock keeps `Justfile`, the file on disk is +/// `justfile`, and both a write and a removal target it in one pass. +#[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] +#[test] +fn a_region_removal_composes_with_the_writes_of_the_same_pass() { + const IMPORTS_REGION_ID: &str = "anvil-imports"; - // The migration is complete: a second update is a no-op. - let settled = run_update(&Catalog::anvil(), &local(), root).unwrap(); + let tmp = generated_tree(); + let root = tmp.path(); + let recorded = root.join("Justfile"); + let renamed = root.join("justfile"); + + // Drop the region anvil maintains so this pass has to write it back, and + // plant a retired one so the removal path runs against the same host. + let text = std::fs::read_to_string(&recorded).unwrap(); + let with_retired = + format!("{text}\n# >>> anvil-managed: {RETIRED_REGION_ID}\n{RETIRED_REGION_BODY}# <<< anvil-managed: {RETIRED_REGION_ID}\n"); + let staged = remove_region_block(&with_retired, IMPORTS_REGION_ID); + std::fs::remove_file(&recorded).unwrap(); + write(&renamed, &staged); + + let mut manifest = Manifest::load(root).unwrap(); + manifest.set_region("Justfile", RETIRED_REGION_ID, checksum_str(RETIRED_REGION_BODY)); + manifest.save(root).unwrap(); + + run_update(&Catalog::anvil(), &local(), root).unwrap(); + + let after = std::fs::read_to_string(&renamed).unwrap(); assert!( - !settled.plan.has_changes(), - "the upgraded tree must be steady; plan: {:#?}", - settled.plan.items() + after.contains(&format!("# >>> anvil-managed: {IMPORTS_REGION_ID}")), + "the region written this pass must survive the removal of another region in the same host:\n{after}" ); + assert!( + !after.contains(RETIRED_REGION_ID), + "the retired region must still be removed:\n{after}" + ); +} + +/// Strip a whole managed region, sentinels included, from `text`. +fn remove_region_block(text: &str, id: &str) -> String { + let open = format!("# >>> anvil-managed: {id}"); + let close = format!("# <<< anvil-managed: {id}"); + let start = text.find(&open).expect("region must be present to remove"); + let end = text[start..].find(&close).expect("region must be closed") + start + close.len(); + let mut out = String::with_capacity(text.len()); + out.push_str(&text[..start]); + out.push_str(text[end..].trim_start_matches('\n')); + out } diff --git a/crates/cargo-anvil/tests/extensibility.rs b/crates/cargo-anvil/tests/extensibility.rs index aec499aa..fd230206 100644 --- a/crates/cargo-anvil/tests/extensibility.rs +++ b/crates/cargo-anvil/tests/extensibility.rs @@ -26,9 +26,9 @@ use tempfile::TempDir; const EXTRA_FILE: &str = "justfiles/anvil/demoforge.just"; const METADATA_REGION: &str = "demoforge-metadata"; const CONTAINER_JUST: &str = "justfiles/anvil/container.just"; -const CONTAINERFILE: &str = ".anvil/container/Containerfile"; -const CONTAINER_RUNNER: &str = ".anvil/container/run-in-container.ps1"; -const CONTAINER_CUSTOMIZE: &str = ".anvil/container/customize.ps1"; +const DOCKERFILE: &str = ".anvil/container/Dockerfile"; +const DOCKERIGNORE: &str = ".anvil/container/Dockerfile.dockerignore"; +const CONTAINER_HOOKS: &str = ".anvil/container/hooks.ps1"; /// The example downstream catalog: anvil's, customized four ways. fn demoforge() -> Catalog { @@ -55,8 +55,8 @@ fn containerforge() -> Catalog { .subcommand("containerforge") .about("ContainerForge: an anvil container catalog for tests") .version("9.9.9") - .replace_artifact(artifacts::container::containerfile().with_body("FROM example.invalid/base\n")) - .with_artifact(artifacts::container::customize_powershell("# test customization\n")) + .replace_artifact(artifacts::container::dockerfile_base().with_body("FROM example.invalid/base\n")) + .with_artifact(artifacts::container::hooks("# test credential hook\n")) .build() .unwrap() } @@ -159,14 +159,10 @@ fn public_container_artifacts_can_be_specialized_by_downstream_catalogs() { base_container.contains("anvil-container"), "base catalog must expose the public container command" ); + let base_ignore = std::fs::read_to_string(base.path().join(DOCKERIGNORE)).unwrap(); assert!( - base.path().join(CONTAINER_RUNNER).is_file(), - "base catalog must emit its public runner" - ); - let base_runner = std::fs::read_to_string(base.path().join(CONTAINER_RUNNER)).unwrap(); - assert!( - !base.path().join(CONTAINER_CUSTOMIZE).exists(), - "base catalog must not emit a customization file by default" + !base.path().join(CONTAINER_HOOKS).exists(), + "base catalog must not emit a credential hook by default" ); let configured = workspace(); @@ -176,50 +172,48 @@ fn public_container_artifacts_can_be_specialized_by_downstream_catalogs() { configured_container, base_container, "downstream catalog must inherit the public container command unchanged" ); - let configured_containerfile = std::fs::read_to_string(configured.path().join(CONTAINERFILE)).unwrap(); - assert_eq!( - configured_containerfile, "FROM example.invalid/base\n", - "downstream catalog must replace the public Containerfile" + let configured_dockerfile = std::fs::read_to_string(configured.path().join(DOCKERFILE)).unwrap(); + assert!( + configured_dockerfile.contains("# >>> anvil-managed: anvil-container-base\nFROM example.invalid/base\n"), + "downstream catalog must replace the public Dockerfile base region" + ); + assert!( + configured_dockerfile.contains("just anvil-setup binstall"), + "downstream catalog must inherit the catalog-install region it did not replace" ); - let configured_runner = std::fs::read_to_string(configured.path().join(CONTAINER_RUNNER)).unwrap(); + assert!( + configured_dockerfile.starts_with("# syntax=docker/dockerfile:1\n"), + "the seeded parser directive must lead the composed Dockerfile" + ); + let configured_ignore = std::fs::read_to_string(configured.path().join(DOCKERIGNORE)).unwrap(); assert_eq!( - configured_runner, base_runner, - "downstream catalog must inherit the public runner unchanged" + configured_ignore, base_ignore, + "downstream catalog must inherit the public build-context ignore unchanged" ); - let configured_customize = std::fs::read_to_string(configured.path().join(CONTAINER_CUSTOMIZE)).unwrap(); + let configured_hooks = std::fs::read_to_string(configured.path().join(CONTAINER_HOOKS)).unwrap(); assert_eq!( - configured_customize, "# test customization\n", - "downstream catalog must be able to add its own customization file" + configured_hooks, "# test credential hook\n", + "downstream catalog must be able to add its own credential hook" ); } #[test] -fn a_regular_repository_can_add_customization_files_without_a_derived_catalog() { - // A repository maintainer can commit customize.sh/customize.ps1 directly - // beside the generated files, without forking anvil into a derived - // catalog. The public generator neither creates nor manages them, and - // must not disturb them on a later re-run. +fn a_regular_repository_can_add_a_credential_hook_without_a_derived_catalog() { + // A repository maintainer can commit hooks.ps1 directly beside the + // generated files, without forking anvil into a derived catalog. The + // public generator neither creates nor manages it, and must not disturb it + // on a later re-run. let tmp = workspace(); run_update(&Catalog::anvil(), &local(false), tmp.path()).unwrap(); - let shell_customize = tmp.path().join(".anvil/container/customize.sh"); - let powershell_customize = tmp.path().join(CONTAINER_CUSTOMIZE); - assert!(!shell_customize.exists(), "the public catalog must not emit customize.sh"); - assert!(!powershell_customize.exists(), "the public catalog must not emit customize.ps1"); + let hooks = tmp.path().join(CONTAINER_HOOKS); + assert!(!hooks.exists(), "the public catalog must not emit hooks.ps1"); - write(&shell_customize, "# repository-owned shell customization\n"); - write(&powershell_customize, "# repository-owned PowerShell customization\n"); + write(&hooks, "# repository-owned credential hook\n"); - // Re-running the public generator must leave repository-owned - // customization files untouched. + // Re-running the public generator must leave a repository-owned hook + // untouched. run_update(&Catalog::anvil(), &local(false), tmp.path()).unwrap(); - assert_eq!( - std::fs::read_to_string(&shell_customize).unwrap(), - "# repository-owned shell customization\n" - ); - assert_eq!( - std::fs::read_to_string(&powershell_customize).unwrap(), - "# repository-owned PowerShell customization\n" - ); + assert_eq!(std::fs::read_to_string(&hooks).unwrap(), "# repository-owned credential hook\n"); } #[test] diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 408f45c6..22986a4a 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -24,7 +24,10 @@ const LLVM_COV: &str = include_str!("../templates/justfiles/anvil/checks/llvm-co const SEMVER: &str = include_str!("../templates/justfiles/anvil/checks/semver-check.just"); const EXTERNAL_TYPES: &str = include_str!("../templates/justfiles/anvil/checks/external-types.just"); const TOOLS: &str = include_str!("../templates/justfiles/anvil/tools.just"); +const APRZ: &str = include_str!("../templates/justfiles/anvil/checks/aprz.just"); +const MUTANTS_DIFF: &str = include_str!("../templates/justfiles/anvil/checks/mutants-diff.just"); const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just"); +const CONTAINER: &str = include_str!("../templates/justfiles/anvil/container.just"); const FAKE_CARGO_PS1: &str = r#" $joined = $args -join ' ' if ($env:FAKE_CARGO_LOG) { @@ -176,6 +179,20 @@ fn run_just(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> command.args(["--justfile", "Justfile"]).args(arguments).current_dir(root); command.env("PATH", path_with_fake_bin(root)); command.env("FAKE_WORKSPACE_ROOT", root); + // A fixture is a scratch workspace, so it must not inherit the impact + // scoping of whatever invoked the test suite. A CI group job exports + // `ANVIL_IMPACT=consume` and downloads a cache into the real repository; + // inherited into a temp directory that has no cache, `anvil-impact` fails + // hard and takes the recipe under test with it. `ANVIL_INCLUDE_*` is the + // same hazard one level down: a leg whose scope resolved to `--skip` would + // silently short-circuit the recipe before it did anything. A test that + // cares about either value passes it explicitly below. + command.env_remove("ANVIL_IMPACT"); + for key in std::env::vars_os().map(|(key, _)| key) { + if key.to_string_lossy().starts_with("ANVIL_INCLUDE_") { + command.env_remove(key); + } + } for &(key, value) in environment { command.env(key, value); } @@ -790,3 +807,614 @@ fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { assert_eq!(calls.matches("--no-tests=pass").count(), 2, "calls:\n{calls}"); assert!(!calls.contains("llvm-cov"), "coverage commands must not run:\n{calls}"); } + +// --- container-specific behaviour ------------------------------------------ + +/// `anvil-aprz` warns and proceeds when it cannot obtain a token, rather than +/// throwing. That change exists so a containerized tier is not aborted by a +/// missing credential, and nothing else covers it: the dogfood run normally has +/// a host token, and the tokenless container E2E case runs a custom echo recipe. +#[test] +fn aprz_without_a_token_warns_and_still_runs() { + if !tools_available() { + return; + } + let tmp = fixture( + &[("aprz.just", APRZ)], + &[ + "anvil-tool-cargo-aprz-validate-prereqs", + "anvil-tool-cargo-aprz-install installer=\"install\"", + ], + ); + // A gh that yields no token: the recipe must fall through to the warnings + // rather than treating a failed lookup as fatal. + // + // Three stubs because command lookup differs by platform and the fallback + // is the developer's real, signed-in `gh`: on Windows only `.cmd` is in + // PATHEXT, so a `.ps1` stub is skipped; on Unix a bare `gh` must exist and + // be executable. Getting this wrong does not fail the test -- it makes it + // pass while exercising the authenticated path, which is the opposite of + // what the name claims. + write(&tmp.path().join("fake-bin/gh.cmd"), "@exit /b 1\r\n"); + write(&tmp.path().join("fake-bin/gh.ps1"), "exit 1\n"); + let unix_stub = tmp.path().join("fake-bin/gh"); + write(&unix_stub, "#!/bin/sh\nexit 1\n"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&unix_stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let log = tmp.path().join("cargo.log"); + + let output = run_just( + tmp.path(), + &["anvil-aprz"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("GITHUB_TOKEN", OsStr::new("")), + ("ANVIL_IN_CONTAINER", OsStr::new("1")), + ], + ); + + assert!( + output.status.success(), + "a missing token must not fail the check\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + // PowerShell's warning stream surfaces on stdout once `just` has run the + // script, so assert on what the developer actually sees rather than on a + // particular stream. + let seen = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + seen.contains("GITHUB_TOKEN is not set"), + "the warning must name the variable:\n{seen}" + ); + assert!(seen.contains("gh auth login"), "the warning must say how to fix it:\n{seen}"); + + // The point of warning rather than throwing: the check still runs. + let calls = std::fs::read_to_string(&log).unwrap_or_default(); + assert!(calls.contains("aprz deps"), "cargo aprz must still be invoked:\n{calls}"); +} + +/// `anvil-mutants-diff` diffs the base against the WORKING TREE, not against +/// HEAD. cargo-mutants validates every diff line against the file on disk and +/// aborts when they disagree, so a commit-to-commit diff fails as soon as +/// anything is uncommitted -- the normal local state, and the one CI never +/// exercises because its tree is clean. +#[test] +fn mutants_diff_covers_uncommitted_work() { + if !tools_available() || Command::new("git").arg("--version").output().is_err() { + return; + } + // On aarch64-pc-windows-msvc the recipe bails out before doing any of this, + // because cargo-mutants does not build there -- so there is no `--in-diff` + // behavior to assert. The architecture cannot be faked past: Windows + // re-derives PROCESSOR_ARCHITECTURE for every new process from its real + // architecture, so an override does not survive the spawn. The skip itself + // is covered by `mutants_diff_skips_on_arm64_windows`, and this contract is + // exercised on the other three legs. + if cfg!(windows) && cfg!(target_arch = "aarch64") { + return; + } + let tmp = fixture( + &[ + ("helpers.just", HELPERS), + ("impact.just", IMPACT), + ("mutants-diff.just", MUTANTS_DIFF), + ], + &[ + "anvil-tool-cargo-mutants-validate-prereqs", + "anvil-tool-cargo-mutants-install installer=\"install\"", + ], + ); + let root = tmp.path(); + // Real git: the stub the fixture installs would make `git diff` a no-op. + std::fs::remove_file(root.join("fake-bin/git.ps1")).unwrap(); + + let git = |args: &[&str]| { + let status = Command::new("git").args(args).current_dir(root).output().unwrap(); + assert!( + status.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&status.stderr) + ); + }; + git(&["init", "-q"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "test"]); + // The host's global config decides line-ending rewriting and commit + // signing, and either will stop this fixture: a machine set to autocrlf + // rejects the add outright ("LF would be replaced by CRLF"), and one with + // commit.gpgsign and no usable key or TTY fails the commit before the + // behaviour under test runs. Pin both so the test means the same thing on + // every developer's box. + git(&["config", "core.autocrlf", "false"]); + git(&["config", "core.safecrlf", "false"]); + git(&["config", "commit.gpgsign", "false"]); + git(&["config", "tag.gpgsign", "false"]); + write(&root.join("src/lib.rs"), "pub fn base() {}\n"); + git(&["add", "-A"]); + git(&["commit", "-qm", "base"]); + let base = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .unwrap() + .stdout, + ) + .unwrap() + .trim() + .to_owned(); + + // One change committed after the base, and one left uncommitted. A + // `base..HEAD` diff sees only the first. + write(&root.join("src/lib.rs"), "pub fn base() {}\npub fn committed() {}\n"); + git(&["add", "-A"]); + git(&["commit", "-qm", "committed change"]); + write( + &root.join("src/lib.rs"), + "pub fn base() {}\npub fn committed() {}\npub fn uncommitted() {}\n", + ); + + let log = root.join("cargo.log"); + let output = run_just( + root, + &["anvil-mutants-diff"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("BASE_REF", OsStr::new(&base)), + ("RUNNER_TEMP", root.as_os_str()), + // The other early exit. Impact scoping sets this to `--skip` when a + // job has no affected packages, and the value is inherited from + // whatever environment the test runs in -- so on a CI leg that + // skipped, this test would assert against a recipe that returned + // before doing anything. Pin it to a scope that runs. + // + // The architecture guard is deliberately *not* pinned: Windows + // re-derives PROCESSOR_ARCHITECTURE for each new process from the + // process's real architecture, so it cannot be overridden across a + // spawn. That is why this test returns early on ARM64 above rather + // than faking its way past the branch. + ("ANVIL_INCLUDE_AFFECTED", OsStr::new("--package fixture@0.1.0")), + // The recipe depends on `anvil-impact`, which would otherwise + // invoke cargo-delta against this fixture. The scope this test + // asserts on is pinned above, so computing an impact set would only + // add a tool dependency to a contract that does not exercise it. + ("ANVIL_IMPACT", OsStr::new("off")), + ], + ); + assert!( + output.status.success(), + "the recipe must succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let calls = std::fs::read_to_string(&log).unwrap_or_default(); + assert!( + calls.contains("--in-diff"), + "cargo mutants must be given a diff file.\ncargo log:\n{calls}\nrecipe stdout:\n{}\nrecipe stderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let diff = std::fs::read_to_string(root.join("anvil-mutants-diff.diff")).unwrap(); + assert!(diff.contains("committed"), "the committed change must be in the diff:\n{diff}"); + assert!( + diff.contains("uncommitted"), + "the uncommitted change must be in the diff -- a base..HEAD diff would omit it:\n{diff}" + ); +} + +/// The ARM64 Windows bail-out is a documented behavior, not an accident: +/// cargo-mutants does not build for `aarch64-pc-windows-msvc`, so the recipe +/// exits cleanly rather than failing the merged `pr-slow` group on that leg. +/// +/// This runs only on a real ARM64 Windows host, which CI has. Faking the +/// architecture is not an option: `PROCESSOR_ARCHITECTURE` is load-bearing for +/// the Windows loader, and setting it to ARM64 on an x64 host makes spawning +/// `just` fail outright rather than exercise the branch. +/// +/// Asserting it here is what keeps the sibling test above honest. That one pins +/// the architecture to AMD64 so it exercises the real path; without this test +/// the skip branch would be exercised by nothing. +#[test] +fn mutants_diff_skips_on_arm64_windows() { + if !tools_available() { + return; + } + if !(cfg!(windows) && cfg!(target_arch = "aarch64")) { + return; + } + let tmp = fixture( + &[ + ("helpers.just", HELPERS), + ("impact.just", IMPACT), + ("mutants-diff.just", MUTANTS_DIFF), + ], + &[ + "anvil-tool-cargo-mutants-validate-prereqs", + "anvil-tool-cargo-mutants-install installer=\"install\"", + ], + ); + let root = tmp.path(); + + let log = root.join("cargo.log"); + let output = run_just( + root, + &["anvil-mutants-diff"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("RUNNER_TEMP", root.as_os_str()), + // Not the architecture -- that is the host's, and real here. This + // is the *other* early exit, pinned so a skipped impact scope + // cannot be mistaken for the architecture bail-out. + ("ANVIL_INCLUDE_AFFECTED", OsStr::new("--package fixture@0.1.0")), + // Same reason as the sibling contract: `anvil-mutants-diff` depends + // on `anvil-impact`, and this test is about the architecture + // bail-out, not about computing an impact set. + ("ANVIL_IMPACT", OsStr::new("off")), + ], + ); + + assert!( + output.status.success(), + "the recipe must skip cleanly, not fail, on aarch64-pc-windows-msvc\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("cargo-mutants does not build here"), + "the skip must say why, or a silent no-op looks like a passing run:\n{stdout}" + ); + assert!( + std::fs::read_to_string(&log).unwrap_or_default().is_empty(), + "cargo must not be invoked at all on the skipped leg" + ); +} + +/// The wrapper that disables impact scoping for the full-workspace tiers is +/// only correct if the export happens *before* the wrapped recipe's +/// dependencies run: `just` evaluates dependencies in their own processes, and +/// every impact-scoped check reads `ANVIL_IMPACT` as a dependency of the tier, +/// not in the tier's own body. A fixture dependency that fails unless the +/// variable is already set pins that ordering; invoking the wrapped recipe +/// directly is the negative control that proves the fixture can fail. +#[test] +fn unscoped_wrapper_exports_impact_off_before_dependencies_run() { + const PROBE: &str = "[private]\n_anvil-probe: probe-dep\n\n\ + [private]\n[script(\"pwsh\", \"-NoProfile\")]\nprobe-dep:\n \ + if ($env:ANVIL_IMPACT -ne 'off') { exit 9 }\n exit 0\n"; + + if !tools_available() { + return; + } + let tmp = fixture(&[("helpers.just", HELPERS), ("probe.just", PROBE)], &[]); + let root = tmp.path(); + + let wrapped = run_just(root, &["_anvil-unscoped", "probe"], &[]); + assert!( + wrapped.status.success(), + "the dependency must observe ANVIL_IMPACT=off\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&wrapped.stdout), + String::from_utf8_lossy(&wrapped.stderr) + ); + + let direct = run_just(root, &["_anvil-probe"], &[]); + assert_eq!( + direct.status.code(), + Some(9), + "without the wrapper the dependency must see no setting, or this test proves nothing\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&direct.stdout), + String::from_utf8_lossy(&direct.stderr) + ); +} + +/// `just --dry-run` reports the bodies just runs itself, not the body of a +/// recipe that one of them launches as a child process. The unscoped wrapper +/// launches its tier that way, so a plan of the public tier name reveals the +/// wrapper alone. +/// +/// The container driver decides whether to mint a GitHub token by matching the +/// plan for `GITHUB_TOKEN`, so this is why it has to follow each nested target +/// rather than reading one plan. If this test ever fails because a plan now +/// reaches through the child process, that expansion can be deleted. +#[test] +fn a_wrapped_tier_hides_its_checks_from_a_plan() { + const PROBE: &str = "[private]\n[script(\"pwsh\", \"-NoProfile\")]\n_anvil-probe:\n \ + if (-not $env:GITHUB_TOKEN) { exit 1 }\n\n\ + probe: (_anvil-unscoped \"probe\")\n"; + + if !tools_available() { + return; + } + let tmp = fixture(&[("helpers.just", HELPERS), ("probe.just", PROBE)], &[]); + let root = tmp.path(); + + let wrapped = run_just(root, &["--dry-run", "probe"], &[]); + let wrapped_plan = format!( + "{}{}", + String::from_utf8_lossy(&wrapped.stdout), + String::from_utf8_lossy(&wrapped.stderr) + ); + assert!( + !wrapped_plan.contains("GITHUB_TOKEN"), + "a wrapped tier's plan must not reach the recipe it launches, or the driver's expansion is dead code\n{wrapped_plan}" + ); + assert!( + wrapped_plan.contains("_anvil-probe"), + "the wrapper must still name the recipe it launches, which is what the driver follows\n{wrapped_plan}" + ); + + let direct = run_just(root, &["--dry-run", "_anvil-probe"], &[]); + let direct_plan = format!( + "{}{}", + String::from_utf8_lossy(&direct.stdout), + String::from_utf8_lossy(&direct.stderr) + ); + assert!( + direct_plan.contains("GITHUB_TOKEN"), + "planning the launched recipe directly must reveal the variable, or this test proves nothing\n{direct_plan}" + ); +} + +/// The engine derives the ignore file's name from the Dockerfile's, and anvil +/// maintains that artifact at a fixed canonical path, so the two names have to +/// agree. A case variant is refused rather than accommodated: building from +/// `dockerfile` would find no `dockerfile.dockerignore`, silently stream the +/// whole worktree into the build context, and admit inputs the tag does not +/// cover. +#[test] +fn a_case_variant_dockerfile_is_refused_rather_than_built_from() { + if !tools_available() { + return; + } + + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); + let canonical = run_just(root, &["_anvil-container-dockerfile"], &[]); + assert!( + canonical.status.success(), + "the canonical name must resolve\nstderr:\n{}", + String::from_utf8_lossy(&canonical.stderr) + ); + assert_eq!(String::from_utf8_lossy(&canonical.stdout).trim(), ".anvil/container/Dockerfile"); + + // Only a case-sensitive filesystem can hold a variant that is a different + // file, which is exactly where the ignore-file lookup breaks. + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join(".anvil/container/dockerfile"), "FROM scratch\n"); + let holds_variant = !root.join(".anvil/container/Dockerfile").exists(); + if holds_variant { + let variant = run_just(root, &["_anvil-container-dockerfile"], &[]); + assert_failed(&variant, "resolving a case-variant Dockerfile"); + let stderr = String::from_utf8_lossy(&variant.stderr); + assert!( + stderr.contains("must be named exactly") && stderr.contains("dockerignore"), + "the refusal must name the rule and the reason\nstderr:\n{stderr}" + ); + } + + // Absent, the tag would hash a directory that contributes nothing for it + // and hand back a confident reference to an image that cannot be built. + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let missing = run_just(tmp.path(), &["_anvil-container-dockerfile"], &[]); + assert_failed(&missing, "resolving an absent Dockerfile"); + assert!( + String::from_utf8_lossy(&missing.stderr).contains("container image input is missing"), + "the failure must name the missing input\nstderr:\n{}", + String::from_utf8_lossy(&missing.stderr) + ); +} +/// `COPY` carries a file's executable bit into the image, so a `chmod +x` with +/// no content change still changes what the image contains. The tag has to +/// follow it, or the changed image keeps a reference that already resolves and +/// the stale one is reused. +/// +/// The bit is read from git's index rather than the filesystem, because Windows +/// has no such bit and two checkouts of one commit must agree on the tag. The +/// fixture's stub git is what makes that observable from either platform. +#[test] +fn the_image_tag_follows_the_executable_bit() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"stable\"\n"); + write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); + write(&root.join("justfiles/anvil/setup.sh"), "echo hello\n"); + write( + &root.join("fake-bin/git.ps1"), + "if ($args -contains 'ls-files') {\n \ + $mode = if ($env:FAKE_EXECUTABLE -eq '1') { '100755' } else { '100644' }\n \ + Write-Output \"$mode 0000000000000000000000000000000000000000 0`tjustfiles/anvil/setup.sh\"\n}\nexit 0\n", + ); + + let tag = |executable: &str| { + let output = run_just(root, &["anvil-container-tag"], &[("FAKE_EXECUTABLE", OsStr::new(executable))]); + assert!( + output.status.success(), + "computing the tag failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + }; + + let plain = tag("0"); + let executable = tag("1"); + assert_ne!( + plain, executable, + "the executable bit must reach the digest, or a chmod leaves the image unnamed" + ); + assert_eq!(plain, tag("0"), "the tag must depend on the inputs alone"); +} + +/// The tag is computed from the index while the build copies the working tree, +/// so the two have to agree about the executable bit. Where they do not, the +/// reference names an image the build does not produce, and the run stops +/// rather than absorbing it. +/// +/// `git diff --raw` has three shapes here and only one of them is drift, so +/// each is pinned: an ordinary modification, a deletion (absent from both the +/// context and the digest), and an intent-to-add entry, whose raw index mode is +/// zero even though `ls-files --stage` reports a real placeholder mode. +#[test] +fn a_working_tree_mode_the_tag_did_not_frame_stops_the_run() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"stable\"\n"); + write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); + write(&root.join("justfiles/anvil/setup.sh"), "echo hello\n"); + // The digest frames this path from `ls-files --stage`, which reports + // 100644 in every case below -- including the intent-to-add ones, where + // the raw index mode is zero but the placeholder is a real mode. + write( + &root.join("fake-bin/git.ps1"), + "if ($args -contains 'ls-files') {\n \ + Write-Output \"100644 0000000000000000000000000000000000000000 0`tjustfiles/anvil/setup.sh\"\n}\n\ + if ($args -contains 'diff' -and $env:FAKE_RAW) {\n Write-Output $env:FAKE_RAW\n}\nexit 0\n", + ); + + let tag = |raw: &str| run_just(root, &["anvil-container-tag"], &[("FAKE_RAW", OsStr::new(raw))]); + + for (kind, raw) in [ + ("no working-tree change at all", ""), + ("an unstaged deletion", ":100644 000000 0000000 0000000 D\tjustfiles/anvil/setup.sh"), + ( + "an intent-to-add entry that is not executable", + ":000000 100644 0000000 0000000 A\tjustfiles/anvil/setup.sh", + ), + ( + "an edit that leaves the mode alone", + ":100644 100644 0000000 0000000 M\tjustfiles/anvil/setup.sh", + ), + ] { + let output = tag(raw); + assert!( + output.status.success(), + "{kind} must not be reported as drift\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + for (kind, raw) in [ + ("an unstaged chmod +x", ":100644 100755 0000000 0000000 M\tjustfiles/anvil/setup.sh"), + ( + "an intent-to-add entry that is executable", + ":000000 100755 0000000 0000000 A\tjustfiles/anvil/setup.sh", + ), + ( + "a regular file replaced by a symlink", + ":100644 120000 0000000 0000000 T\tjustfiles/anvil/setup.sh", + ), + ] { + let output = tag(raw); + assert_failed(&output, kind); + let stderr = String::from_utf8_lossy(&output.stderr); + // PowerShell wraps an error record and decorates each continuation, so + // a multi-word phrase is not a substring of what reaches stderr. It + // wraps at spaces though, so individual words survive intact. + assert!( + stderr.contains("working") && stderr.contains("Stage"), + "{kind} must name the drift and the recovery\nstderr:\n{stderr}" + ); + } +} + +/// The engine copies a link as a link, while any read of one here follows it, +/// so a retarget changes the image without changing a byte the walk can see -- +/// and a link to a directory is not enumerated by the walk at all. Framing the +/// link text instead would have to work on Windows, where git materializes a +/// symlink as an ordinary file unless the checkout was privileged, so the same +/// commit would digest differently per platform. Anvil creates no link under +/// these trees, so the whole class is refused. +#[test] +fn a_link_among_the_image_inputs_is_refused() { + if !tools_available() { + return; + } + + // A walk only ever reports descendants, so a link that *is* a declared + // input or a walk root is followed and never appears in its own output. + // Both positions are covered. + for (kind, name, links_a_directory) in [ + ("a file link below a walk root", "justfiles/anvil/linked.just", false), + ("a directory link below a walk root", "justfiles/anvil/linked", true), + ("a linked declared input", "rust-toolchain.toml", false), + ("a linked recipe walk root", "justfiles/anvil", true), + ("a linked container walk root", ".anvil/container", true), + ] { + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join("elsewhere/target.just"), "# shared\n"); + write(&root.join("elsewhere/Dockerfile"), "FROM scratch\n"); + // Everything the tag needs, except whatever this case replaces with a + // link. The link stands in for it, so writing it first would defeat the + // case for a walk root and leave nothing to link at all. + for (path, body) in [ + ("rust-toolchain.toml", "[toolchain]\nchannel = \"stable\"\n"), + (".anvil/container/Dockerfile", "FROM scratch\n"), + ("justfiles/anvil/mod.just", "# recipes\n"), + ] { + if !path.starts_with(name) { + write(&root.join(path), body); + } + } + + let link = root.join(name); + if let Some(parent) = link.parent() { + fs::create_dir_all(parent).unwrap(); + } + let created = if links_a_directory { + symlink_dir(&root.join("elsewhere"), &link) + } else { + symlink_file(&root.join("elsewhere/target.just"), &link) + }; + // Creating a link needs a privilege that not every environment grants. + // Where it is refused there is nothing to assert about. + if created.is_err() { + continue; + } + + let output = run_just(root, &["anvil-container-tag"], &[]); + assert_failed(&output, &format!("computing a tag with {kind} among the inputs")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("regular") && stderr.contains(name.rsplit('/').next().unwrap()), + "{kind} must be named in the refusal\nstderr:\n{stderr}" + ); + } +} + +#[cfg(windows)] +fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(target, link) +} + +#[cfg(windows)] +fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, link) +} + +#[cfg(unix)] +fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index c0ab6168..c4575148 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -2,33 +2,54 @@ source: crates/cargo-anvil/tests/snapshots.rs expression: render_tree(tmp.path()) --- -=== .anvil/container/Containerfile === +=== .anvil/container/Dockerfile === # syntax=docker/dockerfile:1 -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -ARG BASE_IMAGE=docker.io/library/debian:bookworm-slim@sha256:63a496b5d3b99214b39f5ed70eb71a61e590a77979c79cbee4faf991f8c0783e +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# >>> anvil-managed: anvil-container-base-image +# Prebuilt binaries installed by `anvil-setup binstall` link against this +# image's glibc, so it tracks the Linux runner the generated workflows use. +# Digest-pinned: a floating tag moves content under a reference that claims to +# name fixed content. +# +# Re-declare BASE_IMAGE in the gap below to build on another base; a later ARG +# wins, and the pins anvil maintains stay current. +ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +# <<< anvil-managed: anvil-container-base-image + +# >>> anvil-managed: anvil-container-base FROM ${BASE_IMAGE} -ARG ANVIL_IMAGE_ID ARG JUST_VERSION=1.56.0 ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 ARG POWERSHELL_VERSION=7.6.3 ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 ARG RUSTUP_VERSION=1.29.0 ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 +ARG CARGO_BINSTALL_VERSION=1.21.1 +ARG CARGO_BINSTALL_SHA256=630c8f8803a686aa6779497f0f0fb51d49822fb5fc3c514d8ced33b34e338e6e ENV DEBIAN_FRONTEND=noninteractive \ CARGO_HOME=/usr/local/cargo \ RUSTUP_HOME=/usr/local/rustup \ RUSTUP_NO_UPDATE_CHECK=1 \ PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# <<< anvil-managed: anvil-container-base +# >>> anvil-managed: anvil-container-tools +# clang/libclang are required by cargo-spellcheck; the rest is the usual Rust +# link-time set. A bare base has no C runtime development files, so every link +# step fails without build-essential. RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential ca-certificates clang libclang-dev curl git libicu-dev \ libssl-dev pkg-config tar \ && rm -rf /var/lib/apt/lists/* +# pwsh is not optional: every generated anvil recipe is a `script("pwsh", +# "-NoProfile")` recipe. RUN curl -fsSLo /tmp/powershell.tar.gz \ "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ @@ -52,1145 +73,87 @@ RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ && rm /tmp/rustup-init -WORKDIR /opt/anvil -COPY . . -RUN test -f rust-toolchain.toml || { \ - echo "anvil-container requires rust-toolchain.toml" >&2; \ - exit 1; \ - } -RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry \ - --mount=type=cache,id=anvil-cargo-git,target=/usr/local/cargo/git \ - --mount=type=cache,id=anvil-cargo-target,target=/tmp/anvil-target \ - printf "anvil_runner := \"native\"\nimport 'justfiles/anvil/mod.just'\n" > Justfile \ - && CARGO_TARGET_DIR=/tmp/anvil-target just anvil-setup +RUN curl -fsSLo /tmp/cargo-binstall.tgz \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" \ + && echo "${CARGO_BINSTALL_SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c - \ + && mkdir -p "${CARGO_HOME}/bin" \ + && tar -xzf /tmp/cargo-binstall.tgz -C "${CARGO_HOME}/bin" cargo-binstall \ + && chmod 755 "${CARGO_HOME}/bin/cargo-binstall" \ + && rm /tmp/cargo-binstall.tgz -COPY .anvil/container/entrypoint.sh /usr/local/bin/anvil-container-entrypoint -RUN chmod 755 /usr/local/bin/anvil-container-entrypoint +# <<< anvil-managed: anvil-container-tools +# >>> anvil-managed: anvil-container-setup +# The whole recipe tree is copied because `just` parses it to reach the install +# recipes. +# +# The credential files are removed in the same layer that used them: a build +# secret never lands in a layer, but anything the install *writes* with it is +# ordinary content, and the `chmod` below would publish it world-readable. A +# later `RUN` cannot undo that, because the earlier layer keeps them. +# +# `registry` and `git` must exist before the `chmod`. The run mounts a named +# volume over each, and an engine seeds a new volume from the image path it +# covers; a path that does not exist seeds as root-owned 0755, which the +# `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +WORKDIR /opt/anvil +COPY justfiles ./justfiles +COPY rust-toolchain.toml ./ +RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ + && just anvil-setup binstall \ + && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ + && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME}/.netrc" \ + && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ + && chmod -R a+rwX "${CARGO_HOME}" "${RUSTUP_HOME}" +# <<< anvil-managed: anvil-container-setup + +# >>> anvil-managed: anvil-container-entry +# Consumed by `anvil-container` itself: a nested invocation from inside the +# image runs the recipe natively instead of launching another container. ENV ANVIL_IN_CONTAINER=1 -LABEL io.github.cargo-anvil.image-id="${ANVIL_IMAGE_ID}" + WORKDIR /workspace -ENTRYPOINT ["anvil-container-entrypoint"] CMD ["bash"] +# <<< anvil-managed: anvil-container-entry -=== .anvil/container/Containerfile.dockerignore === +=== .anvil/container/Dockerfile.dockerignore === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Deny-all allow-list for the image build context. +# Update the corresponding template in the cargo-anvil crate. +# +# BuildKit reads `.dockerignore` in preference to a root +# `.dockerignore`, so this scopes the exec-image build context without the +# repository having to own a root ignore file or having one silently overridden. # -# Docker matches each candidate against every pattern in order and lets the -# last match win, testing the path itself *and each of its parent directories* -# (moby/patternmatcher MatchesOrParentMatches). A bare directory re-inclusion -# such as `!justfiles` therefore re-admits the entire subtree below it, which -# would defeat this allow-list, so list only leaf patterns here. Docker still -# descends into a denied directory when some re-inclusion pattern is prefixed -# by it, so the intermediate directories need no entries of their own. +# The build context is the repository root but the image only needs two things. +# Excluding everything else keeps a cold build from streaming the whole +# worktree (and every stale `target/`) to the daemon. # -# Parent testing also reaches through a single-segment re-inclusion: a -# subdirectory of `.anvil/container/` matches `!.anvil/container/*` in its own -# right. The image-ID helpers list that directory one level deep, so a nested -# file is not an image input; `.anvil/container/*/*` states that leaf-only -# contract in the allow-list too, at every depth, because a deeper candidate -# always has an ancestor of exactly that shape. -** +# The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` +# so that a cold build does not stream unrelated trees to the daemon. The +# recipes are copied to drive `just anvil-setup`, which needs the whole tree to +# parse, and the whole tree is hashed into the image tag: the tier, group and +# check recipes decide which tools `anvil-setup` reaches, not just the catalog. +# +# `.anvil/container/` is admitted because the Dockerfile is composed: the gaps +# between anvil's regions exist for a repository to add its own instructions, +# and the headline case -- `COPY`ing a corporate root CA in before the first +# download -- needs the file to be in the context. Denying it would leave the +# gap documented but unusable for anything but `RUN`. It is also the directory +# the image tag digests, so what the context admits and what the tag covers stay +# the same set -- including the `.anvil-proposed` siblings both exclude, which +# are anvil's review artifacts rather than build inputs. +* +!justfiles +justfiles/* +!justfiles/anvil +justfiles/anvil/**/*.anvil-proposed +!.anvil +.anvil/* +!.anvil/container +.anvil/container/**/*.anvil-proposed !rust-toolchain.toml -!justfiles/anvil/*.just -!justfiles/anvil/checks/*.just -!justfiles/anvil/groups/*.just -!.anvil/container/* -.anvil/container/*/* -.anvil/container/customize.sh -.anvil/container/customize.ps1 - -=== .anvil/container/README.md === - - -# Run Anvil checks in a local container - -Use `just anvil-container` to run generated Anvil checks in a reproducible -Linux environment without installing the complete Rust and Cargo tool catalog -on the host. - -Native execution remains the default. The first container run builds an image -matching the repository's generated configuration. Later runs reuse that image, -dependency caches, and compilation output. - -## Quick start - -Ensure Docker Engine is running, then run: - -```text -just anvil-container anvil-clippy -``` - -The first run builds the matching image and can take several minutes. - -## Prerequisites - -- [Docker Engine](https://docs.docker.com/engine/install/) 23.0 or newer, - installed directly in Linux or WSL and usable by the current user. -- `git` and `just` on the host. -- Bash on Linux and WSL; PowerShell Core (`pwsh`) and WSL 2 on Windows. -- `[script]` support enabled in the root `Justfile`. Add `set unstable` when - required by the installed `just` version. -- A `rust-toolchain.toml` in the repository root. -- A Linux or WSL environment capable of running `linux/amd64` images, either - natively on x86-64 or through Docker emulation on ARM64. - -On Windows, the driver invokes Docker from the default WSL distribution rather -than calling Windows `docker.exe`. Regardless of how Docker is installed, this -command must succeed from PowerShell: - -```text -wsl -e docker version -``` - -Start the Docker service inside WSL when it is stopped and add the WSL user to -the `docker` group when non-root access is not already configured. Docker -Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment. Image -builds and checks can therefore be substantially slower than on x86-64 hosts. - -## Security boundary - -> [!WARNING] -> `customize.sh` and `customize.ps1` execute on the host with the developer's -> permissions before container isolation begins. Reviewing and trusting these -> files is equivalent to reviewing and trusting any other host-executed script -> in the checked-out branch. - -## Common workflows - -Run one check: - -```text -just anvil-container anvil-clippy -``` - -Run the complete pull-request tier: - -```text -just anvil-container anvil-pr -``` - -Every argument is treated as a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not supported by this command surface. - -Open an interactive Bash shell in the image: - -```text -just anvil-container -``` - -### Use containers for tier commands - -Native execution remains the default. To route tier commands such as -`just anvil-pr` through the container for the current shell: - -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` - -On Unix: - -```sh -ANVIL_RUNNER=container just anvil-pr -``` - -For one invocation: - -```text -just anvil_runner=container anvil-pr -``` - -To make container execution the repository default, change the default value -in the `anvil-runner` region of the repository-root `Justfile` from `"native"` -to `"container"` and commit that policy. Set `ANVIL_RUNNER=native` to override -the repository default for the current shell. - -Tier routing starts a nested `just` invocation. Output and exit status are -preserved, but outer `--dry-run`, dependency introspection, global options, and -CLI variable assignments are not propagated to the selected private tier. -Values other than `native` and `container` are rejected. - -## Images and caches - -The image name includes a content-based tag derived from the repository's Rust -toolchain, generated Anvil recipes, and container build configuration. A -relevant change selects a new image automatically; older branches can continue -using their matching images. - -The following data is reused between runs: - -- the matching container image; -- repository-scoped Cargo registry and Cargo Git caches; -- compilation output in a repository- and image-specific `target` volume. - -The repository is mounted read/write at `/workspace`. Build output remains in a -named volume instead of the host `target/`, avoiding incompatible artifacts and -slow host-to-virtual-machine I/O. - -## GitHub authentication - -`anvil-aprz` and aggregate tiers that include it require GitHub API -authentication. The driver uses either: - -- the host `GITHUB_TOKEN`; or -- the token from an authenticated host `gh` session. - -Trusted customization can provision a short-lived token by setting -`GITHUB_TOKEN`; the driver reads it after loading and validating customization. - -Authenticate the GitHub CLI with: - -```text -gh auth login --hostname github.com -``` - -For an aggregate tier, the driver first runs `anvil-aprz` in a short-lived -container with the token mounted read-only. After it succeeds, the driver runs -the remaining checks in another container without the token. Temporary token -files are removed afterward. - -An interactive invocation can pause while you authenticate. A non-interactive -invocation fails with instructions when authentication is unavailable. - -## Configuration - -| Variable | Effect | -|---|---| -| `ANVIL_RUNNER` | Selects `native` or `container` execution for tier commands | -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a digest-pinned compatible Linux base image and changes the content-based tag | -| `ANVIL_CONTAINER_IMAGE` | Changes the local image name; the content-based tag is retained | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails instead of building when the matching image is absent | - -The public driver builds images locally and does not pull -`ANVIL_CONTAINER_IMAGE` from a registry. - -The default base is digest-pinned Debian Bookworm. Set -`ANVIL_CONTAINER_BASE_IMAGE` to another image compatible with the generated -Debian-based `Containerfile` when a lower glibc baseline is required. A -different package ecosystem such as Azure Linux requires a derived -`Containerfile`. The value must use `image@sha256:` form so the -selected base remains part of the content-addressed image identity. - -Two simultaneous cold invocations can both build the same missing image. This -is accepted for local development: the content-addressed tag converges on the -same inputs, at the cost of duplicate work. - -## Troubleshooting - -| Problem | Resolution | -|---|---| -| Docker is not found on Linux or WSL | Install Docker Engine 23.0 or newer inside that environment | -| Docker is unavailable from Windows | Run `wsl -e docker version`; install or start Docker Engine in the default WSL distribution | -| Docker requires elevated access | Add the Linux/WSL user to the `docker` group, then start a new shell | -| ARM64 execution is slow | The current image is `linux/amd64` and runs through Docker emulation | -| `linux/amd64` cannot run | Configure Docker to run `linux/amd64` images | -| `[script]` recipes are unavailable | Enable `[script]` support; older `just` versions require `set unstable` | -| `rust-toolchain.toml` is missing | Add the repository-owned toolchain file at the repository root | -| GitHub authentication is unavailable | Run `gh auth login --hostname github.com` or set host `GITHUB_TOKEN` | -| A matching image is missing with `ANVIL_CONTAINER_NO_REBUILD=1` | Unset the variable to allow the local image build | -| The first run is slow | The initial image build installs the pinned tool catalog; later runs reuse it | - -Use `docker images anvil-dev` inside Linux or WSL to list locally cached -default Anvil images. - -## Managed files - -This directory is managed by `cargo-anvil`. Regenerate it with `cargo anvil` -instead of editing its files directly. - -> [!IMPORTANT] -> These assets previously lived in `justfiles/anvil/container/`. `cargo anvil` -> relocates the files it generated, but it does not track a hand-authored -> `customize.sh` or `customize.ps1`. Move any such file to -> `.anvil/container/` yourself; the driver only loads customization from the -> new location and warns on stderr when it finds one left behind. - -## Advanced repository customization - -A repository or derived catalog can add one trusted customization file per -supported host: - -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 -``` - -The driver sources the matching file as trusted host code before authentication, -image construction, and recipe execution. The documented customization -contract provides inputs and validated outputs for APRZ classification, build -secrets, dependency preparation, runtime arguments, and cleanup. - -Customization source is excluded from image identity and the build context. -Non-secret image behavior must be represented by hashed static files such as -the `Containerfile`, entrypoint, or supporting build scripts. - -See the [container customization contract](https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md#8-container-customization) -for the complete interface and security requirements. - -=== .anvil/container/entrypoint.sh === -#!/bin/sh -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -eu - -if [ "$(id -u)" -ne 0 ]; then - if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then - HOME="/tmp/anvil-user" - export HOME - fi - - user_cargo_home="$HOME/.cargo" - mkdir -p "$user_cargo_home" - for file in config.toml .crates.toml .crates2.json; do - if [ -r "$CARGO_HOME/$file" ]; then - cp -f "$CARGO_HOME/$file" "$user_cargo_home/$file" - fi - done - export CARGO_HOME="$user_cargo_home" - ln -sfn /usr/local/cargo/registry "$CARGO_HOME/registry" - ln -sfn /usr/local/cargo/git "$CARGO_HOME/git" -fi - -exec "$@" - -=== .anvil/container/image-id.ps1 === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$inputs = @( - 'rust-toolchain.toml' -) -$toolchainPath = Join-Path $repoRoot 'rust-toolchain.toml' -if (-not (Test-Path -LiteralPath $toolchainPath -PathType Leaf)) { - throw 'anvil-container requires a repository-owned rust-toolchain.toml.' -} -$containerPath = Join-Path $repoRoot '.anvil/container' -$containerRecipe = 'justfiles/anvil/container.just' -$containerfile = Join-Path $containerPath 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } -# The container entry recipe drives execution on the host; it is not image -# content, so it must not participate in image identity. -$inputs += Get-ChildItem (Join-Path $repoRoot 'justfiles/anvil') -Recurse -File -Filter '*.just' | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } | - Where-Object { -not $_.Equals($containerRecipe, $pathComparison) } -$executionOnly = @( - 'image-id.ps1', - 'image-id.sh', - 'README.md', - 'run-in-container.ps1', - 'run-in-container.sh', - 'customize.sh', - 'customize.ps1' -) -# customize.sh/customize.ps1 are trusted runtime orchestration, not image -# content: their source must never affect the image ID or build context. -# Static, non-secret build customization belongs in a hashed artifact instead. -$inputs += Get-ChildItem $containerPath -File | - Where-Object { $_.Name -notin $executionOnly } | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } -$uniqueInputs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($inputPath in $inputs) { - [void]$uniqueInputs.Add($inputPath) -} -$inputs = [string[]]$uniqueInputs -[Array]::Sort($inputs, [StringComparer]::Ordinal) - -$payload = [Text.StringBuilder]::new() -[void]$payload.Append("ANVIL_CONTAINER_BASE_IMAGE`n").Append($baseImage).Append("`n") -foreach ($relative in $inputs) { - $path = Join-Path $repoRoot $relative - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Container image input is missing: $relative" - } - $content = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n") - [void]$payload.Append($relative).Append("`n").Append($content).Append("`n") -} - -$bytes = [Text.Encoding]::UTF8.GetBytes($payload.ToString()) -$hash = [Security.Cryptography.SHA256]::HashData($bytes) -Write-Output ([Convert]::ToHexString($hash).ToLowerInvariant()) - -=== .anvil/container/image-id.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi - -toolchain_path="$repo_root/rust-toolchain.toml" -if [[ ! -f "$toolchain_path" ]]; then - echo 'anvil-container requires a repository-owned rust-toolchain.toml.' >&2 - exit 1 -fi - -container_dir="$repo_root/.anvil/container" -container_recipe="justfiles/anvil/container.just" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$container_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -inputs=(rust-toolchain.toml) -while IFS= read -r path; do - relative="${path#"$repo_root"/}" - # The container entry recipe drives execution on the host; it is not - # image content, so it must not participate in image identity. - if [[ "$relative" != "$container_recipe" ]]; then - inputs+=("$relative") - fi -done < <(find "$repo_root/justfiles/anvil" -type f -name '*.just' -print) - -for path in "$container_dir"/*; do - [[ -f "$path" ]] || continue - case "${path##*/}" in - image-id.ps1 | image-id.sh | README.md \ - | run-in-container.ps1 | run-in-container.sh \ - | customize.sh | customize.ps1) continue ;; - esac - inputs+=("${path#"$repo_root"/}") -done - -if command -v sha256sum >/dev/null 2>&1; then - hash_command=(sha256sum) -elif command -v shasum >/dev/null 2>&1; then - hash_command=(shasum -a 256) -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi - -write_normalized_file() { - local path="$1" - local line status - while true; do - line="" - if IFS= read -r line <&3; then - status=0 - else - status=$? - fi - if ((status != 0)) && [[ -z "$line" ]]; then - break - fi - printf '%s' "${line%$'\r'}" - if ((status == 0)); then - printf '\n' - else - break - fi - done 3<"$path" -} - -{ - printf 'ANVIL_CONTAINER_BASE_IMAGE\n%s\n' "$base_image" - while IFS= read -r relative; do - path="$repo_root/$relative" - if [[ ! -f "$path" ]]; then - echo "Container image input is missing: $relative" >&2 - exit 1 - fi - printf '%s\n' "$relative" - write_normalized_file "$path" - printf '\n' - done < <(printf '%s\n' "${inputs[@]}" | LC_ALL=C sort -u) -} | "${hash_command[@]}" | awk '{print $1}' - -=== .anvil/container/run-in-container.ps1 === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param( - [Parameter(Position = 0, ValueFromRemainingArguments = $true)] - [string[]]$Recipe -) - -$ErrorActionPreference = 'Stop' - -function ConvertTo-AnvilVersion([string]$Value) { - $match = [regex]::Match($Value, '^(\d+)\.(\d+)(?:\.(\d+))?') - if (-not $match.Success) { - throw "anvil-container: could not parse Docker Engine version '$Value'." - } - [version]::new( - [int]$match.Groups[1].Value, - [int]$match.Groups[2].Value, - $(if ($match.Groups[3].Success) { [int]$match.Groups[3].Value } else { 0 }) - ) -} - -function Test-AnvilContainerStringArray([string]$Name, $Value) { - if ($Value -isnot [array]) { - throw "anvil-container: `$$Name must be a string array." - } - foreach ($item in $Value) { - if ($item -isnot [string] -or [string]::IsNullOrEmpty($item)) { - throw "anvil-container: `$$Name entries must be non-empty strings." - } - } -} - -function Test-AnvilContainerBuildArgs($Value) { - for ($index = 0; $index -lt $Value.Count; $index++) { - $item = $Value[$index] - if ($item -eq '--secret') { - $index++ - if ($index -ge $Value.Count) { - throw 'anvil-container: $AnvilContainerBuildArgs requires a value after --secret.' - } - } elseif (-not $item.StartsWith('--secret=', [StringComparison]::Ordinal)) { - throw 'anvil-container: $AnvilContainerBuildArgs accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' - } - } -} - -function Test-AnvilRecipeNeedsGitHubToken([string]$Name) { - $Name -in @( - 'anvil-aprz', - 'anvil-scheduled', - '_anvil-scheduled', - 'anvil-scheduled-advisories', - '_anvil-scheduled-advisories', - 'anvil-full', - '_anvil-full' - ) -} - -function Get-AnvilGitHubToken { - $token = $env:GITHUB_TOKEN - if (-not $token -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { - $token = (& gh auth token --hostname github.com 2>$null) - if ($LASTEXITCODE -ne 0) { $token = $null } - } catch { - $token = $null - } - } - if ($token) { $token = $token.Trim() } - if ($token) { return $token } - return $null -} - -if ($env:ANVIL_IN_CONTAINER) { - if ($Recipe.Count -eq 0) { & bash } else { & just @Recipe } - exit $LASTEXITCODE -} - -foreach ($recipeArg in $Recipe) { - if ($recipeArg -notmatch '^_?anvil-[A-Za-z0-9-]+$') { - throw "anvil-container: expected each argument to be an anvil-* recipe, got '$recipeArg'." - } -} - -if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { - throw 'anvil-container: WSL 2 is required. See .anvil/container/README.md.' -} - -$versionText = (& wsl -e docker version --format '{{.Server.Version}}' 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $versionText) { - throw 'anvil-container: `wsl -e docker version` must succeed. Install or start Docker Engine in the default WSL distribution; this driver does not invoke Windows docker.exe.' -} -$versionText = $versionText.Trim() -if ((ConvertTo-AnvilVersion $versionText) -lt [version]'23.0.0') { - throw "anvil-container: Docker Engine 23.0.0 or newer is required (found $versionText)." -} -$wslArchitecture = (& wsl -e uname -m 2>$null) -if ($LASTEXITCODE -eq 0 -and $wslArchitecture) { - $wslArchitecture = $wslArchitecture.Trim() - if ($wslArchitecture -notin @('x86_64', 'amd64')) { - [Console]::Error.WriteLine( - "anvil-container: warning: $wslArchitecture requires emulation for linux/amd64; builds and checks may be substantially slower." - ) - } -} - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$scriptDir = Join-Path $repoRoot '.anvil/container' -$wslRepoRoot = (& wsl -e wslpath -a $repoRoot).Trim() -if ($LASTEXITCODE -ne 0 -or -not $wslRepoRoot) { - throw 'anvil-container: could not translate the repository path into the default WSL distribution.' -} -$wslScriptDir = "$wslRepoRoot/.anvil/container" -$containerfile = Join-Path $scriptDir 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$imageId = (& (Join-Path $scriptDir 'image-id.ps1')).Trim() -$imageBase = if ($env:ANVIL_CONTAINER_IMAGE) { $env:ANVIL_CONTAINER_IMAGE } else { 'anvil-dev' } -$image = "${imageBase}:$imageId" -$repoBytes = [Text.Encoding]::UTF8.GetBytes($wslRepoRoot) -$repoHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($repoBytes)).ToLowerInvariant() -$targetVolume = "anvil-target-$($repoHash.Substring(0, 12))-$($imageId.Substring(0, 12))" - -$needsGitHubToken = $false -foreach ($recipeArg in $Recipe) { - if (Test-AnvilRecipeNeedsGitHubToken $recipeArg) { - $needsGitHubToken = $true - break - } -} -$runsOnlyGitHubCheck = $Recipe.Count -eq 1 -and $Recipe[0] -eq 'anvil-aprz' - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -$null = & wsl -e docker image inspect $image 2>$null -$imageExists = $LASTEXITCODE -eq 0 - -New-Variable -Name AnvilContainerRepoRoot -Value $repoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDir -Value $scriptDir -Option ReadOnly -New-Variable -Name AnvilContainerRepoRootWsl -Value $wslRepoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDirWsl -Value $wslScriptDir -Option ReadOnly -New-Variable -Name AnvilContainerResolvedImage -Value $image -Option ReadOnly -New-Variable -Name AnvilContainerImageExists -Value $imageExists -Option ReadOnly -New-Variable -Name AnvilContainerRequestedRecipes -Value $Recipe -Option ReadOnly -New-Variable -Name AnvilContainerHostIsWindows -Value ([bool]$IsWindows) -Option ReadOnly - -# Customization outputs, initialized before sourcing so a missing customize.ps1 -# leaves every phase a documented no-op. -$AnvilContainerBuildArgs = @() -$AnvilContainerPrepareArgs = @() -$AnvilContainerPrepareCommand = @() -$AnvilContainerRunArgs = @() -$AnvilContainerNeedsGitHubToken = $needsGitHubToken -$AnvilContainerCleanup = $null -$githubToken = $null -$githubTokenFile = $null -$exitCode = 0 -$customizeScript = Join-Path $scriptDir 'customize.ps1' -$legacyCustomizeScript = Join-Path $repoRoot 'justfiles/anvil/container/customize.ps1' - -try { - if (Test-Path -LiteralPath $customizeScript -PathType Leaf) { - . $customizeScript - } - elseif (Test-Path -LiteralPath $legacyCustomizeScript -PathType Leaf) { - [Console]::Error.WriteLine( - 'anvil-container: warning: ignoring justfiles/anvil/container/customize.ps1; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.ps1 to keep it active.' - ) - } - - Test-AnvilContainerStringArray 'AnvilContainerBuildArgs' $AnvilContainerBuildArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareArgs' $AnvilContainerPrepareArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareCommand' $AnvilContainerPrepareCommand - Test-AnvilContainerStringArray 'AnvilContainerRunArgs' $AnvilContainerRunArgs - Test-AnvilContainerBuildArgs $AnvilContainerBuildArgs - if ($AnvilContainerNeedsGitHubToken -isnot [bool]) { - throw 'anvil-container: $AnvilContainerNeedsGitHubToken must be a Boolean.' - } - $needsGitHubToken = $needsGitHubToken -or $AnvilContainerNeedsGitHubToken - if ($AnvilContainerPrepareArgs.Count -gt 0 -and $AnvilContainerPrepareCommand.Count -eq 0) { - throw 'anvil-container: $AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand.' - } - if ($AnvilContainerCleanup -and $AnvilContainerCleanup -isnot [scriptblock]) { - throw 'anvil-container: $AnvilContainerCleanup must be a script block.' - } - $githubToken = if ($needsGitHubToken) { Get-AnvilGitHubToken } else { $null } - if ($needsGitHubToken -and -not $githubToken) { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' - } - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' - } - Write-Host 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' - [void](Read-Host 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel)') - $githubToken = Get-AnvilGitHubToken - if (-not $githubToken) { - throw 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' - } - } - if (-not $imageExists) { - if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { - throw "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." - } - & wsl -e docker build ` - --platform linux/amd64 ` - --tag $image ` - --file "$wslScriptDir/Containerfile" ` - --build-arg "ANVIL_IMAGE_ID=$imageId" ` - --build-arg "BASE_IMAGE=$baseImage" ` - @AnvilContainerBuildArgs ` - $wslRepoRoot - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker build failed with exit code $LASTEXITCODE." - } - } - - $containerUid = (& wsl -e id -u).Trim() - $containerGid = (& wsl -e id -g).Trim() - if ($containerUid -notmatch '^\d+$' -or $containerGid -notmatch '^\d+$') { - throw 'anvil-container: could not determine the default WSL user identity.' - } - $registryVolume = "anvil-cargo-registry-$($repoHash.Substring(0, 12))" - $gitVolume = "anvil-cargo-git-$($repoHash.Substring(0, 12))" - foreach ($volume in @($registryVolume, $gitVolume, $targetVolume)) { - $null = & wsl -e docker volume create $volume - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume creation failed for '$volume' with exit code $LASTEXITCODE." - } - } - $mountArgs = @( - '--mount', "type=bind,source=$wslRepoRoot,target=/workspace", - '--mount', "type=volume,source=$registryVolume,target=/usr/local/cargo/registry", - '--mount', "type=volume,source=$gitVolume,target=/usr/local/cargo/git", - '--mount', "type=volume,source=$targetVolume,target=/workspace/target" - ) - & wsl -e docker run --rm --pull=never ` - --platform linux/amd64 ` - --user 0:0 ` - @mountArgs ` - $image sh -c "chown ${containerUid}:${containerGid} /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume initialization failed with exit code $LASTEXITCODE." - } - - $runArgs = @( - 'run', '--rm', '--pull=never', - '--platform', 'linux/amd64', - '--user', "${containerUid}:${containerGid}", - '--env', 'ANVIL_IN_CONTAINER=1', - '--env', 'HOME=/tmp/anvil-user', - '--workdir', '/workspace' - ) - $runArgs += $mountArgs - $prepareRunArgs = @($runArgs) - $runArgs += $AnvilContainerRunArgs - foreach ($name in @( - 'PR_TITLE', - 'BASE_REF', - 'ANVIL_IMPACT', - 'GITHUB_BASE_REF', - 'SYSTEM_PULLREQUEST_TARGETBRANCH' - )) { - if (Test-Path "Env:$name") { - $runArgs += @('--env', "$name=$((Get-Item "Env:$name").Value)") - } - } - if ($AnvilContainerPrepareCommand.Count -gt 0) { - & wsl -e docker @prepareRunArgs @AnvilContainerPrepareArgs $image @AnvilContainerPrepareCommand - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: preparation command failed with exit code $LASTEXITCODE." - } - } - - if ($githubToken) { - $githubTokenFile = Join-Path ([IO.Path]::GetTempPath()) "anvil-github-token-$PID-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Create($githubTokenFile).Dispose() - if ($IsWindows) { - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls.exe $githubTokenFile '/inheritance:r' '/grant:r' "*$($userSid):(F)" | Out-Null - } else { - & chmod 600 $githubTokenFile - } - if ($LASTEXITCODE -ne 0) { - throw 'anvil-container: failed to restrict permissions on the temporary GitHub token file.' - } - [IO.File]::WriteAllText($githubTokenFile, $githubToken, [Text.Encoding]::ASCII) - $githubToken = $null - $wslTokenFile = (& wsl -e wslpath -a $githubTokenFile).Trim() - if ($LASTEXITCODE -ne 0 -or -not $wslTokenFile) { - throw 'anvil-container: could not translate the temporary GitHub token path into WSL.' - } - $githubRunArgs = @($runArgs) - $githubRunArgs += @( - '--mount', - "type=bind,source=$wslTokenFile,target=/run/secrets/anvil-github-token,readonly" - ) - if ($runsOnlyGitHubCheck) { - $runArgs = $githubRunArgs - } else { - & wsl -e docker @githubRunArgs $image just anvil-aprz - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: isolated anvil-aprz failed with exit code $LASTEXITCODE." - } - $runArgs += @('--env', 'ANVIL_APRZ_ALREADY_RAN=1') - } - } - - if ($Recipe.Count -eq 0) { - & wsl -e docker @runArgs --interactive --tty $image bash - } else { - & wsl -e docker @runArgs $image just @Recipe - } - $exitCode = $LASTEXITCODE -} finally { - if ($githubTokenFile) { - Remove-Item -LiteralPath $githubTokenFile -Force -ErrorAction SilentlyContinue - } - if ($AnvilContainerCleanup) { & $AnvilContainerCleanup } -} - -exit $exitCode - -=== .anvil/container/run-in-container.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - if (($# == 0)); then exec bash; else exec just "$@"; fi -fi - -for recipe_arg in "$@"; do - if [[ ! "$recipe_arg" =~ ^_?anvil-[A-Za-z0-9-]+$ ]]; then - echo "anvil-container: expected each argument to be an anvil-* recipe, got '$recipe_arg'." >&2 - exit 2 - fi -done - -anvil_recipe_needs_github_token() { - case "$1" in - anvil-aprz | anvil-scheduled | _anvil-scheduled | anvil-scheduled-advisories | _anvil-scheduled-advisories \ - | anvil-full | _anvil-full) return 0 ;; - *) return 1 ;; - esac -} - -version_at_least() { - local found="${1%%[-+]*}" - local required="${2%%[-+]*}" - local found_major found_minor found_patch found_extra - local required_major required_minor required_patch required_extra - IFS=. read -r found_major found_minor found_patch found_extra <<<"$found" - IFS=. read -r required_major required_minor required_patch required_extra <<<"$required" - found_patch="${found_patch:-0}" - required_patch="${required_patch:-0}" - for component in \ - "$found_major" "$found_minor" "$found_patch" \ - "$required_major" "$required_minor" "$required_patch" - do - case "$component" in - '' | *[!0-9]*) return 2 ;; - esac - done - if ((found_major != required_major)); then ((found_major > required_major)); return; fi - if ((found_minor != required_minor)); then ((found_minor > required_minor)); return; fi - ((found_patch >= required_patch)) -} - -command -v docker >/dev/null 2>&1 || { - echo "anvil-container: Docker Engine is required. See .anvil/container/README.md." >&2 - exit 1 -} - -version="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" || { - echo "anvil-container: Docker Engine is unavailable. Start the Docker service and ensure the current user can access it." >&2 - exit 1 -} -minimum="23.0.0" -if ! version_at_least "$version" "$minimum"; then - echo "anvil-container: Docker Engine $minimum or newer is required (found $version)." >&2 - exit 1 -fi -host_arch="$(uname -m 2>/dev/null || true)" -case "$host_arch" in - x86_64 | amd64 | '') ;; - *) echo "anvil-container: warning: $host_arch requires emulation for linux/amd64; builds and checks may be substantially slower." >&2 ;; -esac - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi -script_dir="$repo_root/.anvil/container" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$script_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -image_id="$(bash "$script_dir/image-id.sh")" -image_base="${ANVIL_CONTAINER_IMAGE:-anvil-dev}" -image="${image_base}:${image_id}" -if command -v sha256sum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | sha256sum | cut -c1-12)" -elif command -v shasum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | shasum -a 256 | cut -c1-12)" -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi -target_volume="anvil-target-${repo_id}-${image_id:0:12}" - -needs_github_token=false -for recipe_arg in "$@"; do - if anvil_recipe_needs_github_token "$recipe_arg"; then - needs_github_token=true - break - fi -done -runs_only_github_check=false -if (($# == 1)) && [[ "$1" == "anvil-aprz" ]]; then - runs_only_github_check=true -fi -github_token="" - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -if docker image inspect "$image" >/dev/null 2>&1; then - image_exists=true -else - image_exists=false -fi - -readonly ANVIL_CONTAINER_REPO_ROOT="$repo_root" -readonly ANVIL_CONTAINER_DIR="$script_dir" -readonly ANVIL_CONTAINER_RESOLVED_IMAGE="$image" -readonly ANVIL_CONTAINER_IMAGE_EXISTS="$image_exists" -declare -a ANVIL_CONTAINER_REQUESTED_RECIPES=("$@") -readonly ANVIL_CONTAINER_REQUESTED_RECIPES - -# Customization outputs, initialized before sourcing so a missing customize.sh -# leaves every phase a documented no-op. -ANVIL_CONTAINER_BUILD_ARGS=() -ANVIL_CONTAINER_PREPARE_ARGS=() -ANVIL_CONTAINER_PREPARE_COMMAND=() -ANVIL_CONTAINER_RUN_ARGS=() -ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN="$needs_github_token" -ANVIL_CONTAINER_CLEANUP=: -github_token_file="" -cleanup() { - if [[ -n "$github_token_file" ]]; then rm -f -- "$github_token_file"; fi - "$ANVIL_CONTAINER_CLEANUP" -} -trap cleanup EXIT - -customize_script="$script_dir/customize.sh" -legacy_customize_script="$repo_root/justfiles/anvil/container/customize.sh" -if [[ ! -f "$customize_script" && -f "$legacy_customize_script" ]]; then - echo "anvil-container: warning: ignoring justfiles/anvil/container/customize.sh; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.sh to keep it active." >&2 -fi -if [[ -f "$customize_script" ]]; then - # shellcheck source=/dev/null - source "$customize_script" -fi - -# Bash 3.2 has neither namerefs (the nameref flag on `local`/`declare`, Bash -# 4.3+) nor safe `set -u` expansion of empty-but- -# declared arrays (fixed in Bash 4.4). Elements are passed positionally -# instead of by nameref, and every expansion of a possibly-empty array uses -# the `${arr[@]+"${arr[@]}"}` idiom: unset/empty-under-old-Bash arrays vanish -# entirely instead of raising "unbound variable", while non-empty arrays -# still expand element-for-element. -anvil_container_validate_array() { - local name="$1" - shift - local declaration value - declaration="$(declare -p "$name" 2>/dev/null || true)" - if [[ ! "$declaration" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ ]]; then - echo "anvil-container: $name must be a string array." >&2 - exit 1 - fi - for value in "$@"; do - if [[ -z "$value" ]]; then - echo "anvil-container: $name entries must be non-empty strings." >&2 - exit 1 - fi - done -} -anvil_container_validate_build_args() { - local expect_secret_value=false value - for value in "$@"; do - if "$expect_secret_value"; then - expect_secret_value=false - continue - fi - case "$value" in - --secret) expect_secret_value=true ;; - --secret=*) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' >&2 - exit 1 - ;; - esac - done - if "$expect_secret_value"; then - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS requires a value after --secret.' >&2 - exit 1 - fi -} -anvil_container_validate_array ANVIL_CONTAINER_BUILD_ARGS ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_ARGS ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_COMMAND ${ANVIL_CONTAINER_PREPARE_COMMAND[@]+"${ANVIL_CONTAINER_PREPARE_COMMAND[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_RUN_ARGS ${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"} -anvil_container_validate_build_args ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -case "$ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN" in - true) needs_github_token=true ;; - false) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN must be true or false.' >&2 - exit 1 - ;; -esac -if ((${#ANVIL_CONTAINER_PREPARE_ARGS[@]} > 0)) && ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} == 0)); then - echo 'anvil-container: ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND.' >&2 - exit 1 -fi -cleanup_kind="$(type -t "$ANVIL_CONTAINER_CLEANUP" 2>/dev/null || true)" -if [[ "$cleanup_kind" != "function" && "$cleanup_kind" != "builtin" ]]; then - echo "anvil-container: ANVIL_CONTAINER_CLEANUP must name a callable function (got '$ANVIL_CONTAINER_CLEANUP')." >&2 - exit 1 -fi - -if "$needs_github_token"; then - gh_command="" - if command -v gh >/dev/null 2>&1; then - gh_command=gh - elif command -v gh.exe >/dev/null 2>&1; then - gh_command=gh.exe - fi - github_token="${GITHUB_TOKEN:-}" - if [[ -z "$github_token" && -n "$gh_command" ]]; then - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - fi - if [[ -z "$github_token" ]]; then - if [[ -z "$gh_command" ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - if [[ ! -t 0 ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - echo 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' >&2 - read -r -p 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel) ' - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - if [[ -z "$github_token" ]]; then - echo 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' >&2 - exit 1 - fi - fi -fi - -if ! "$image_exists"; then - if [[ "${ANVIL_CONTAINER_NO_REBUILD:-}" == "1" ]]; then - echo "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." >&2 - exit 1 - else - docker build \ - --platform linux/amd64 \ - --tag "$image" \ - --file "$script_dir/Containerfile" \ - --build-arg "ANVIL_IMAGE_ID=$image_id" \ - --build-arg "BASE_IMAGE=$base_image" \ - ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} \ - "$repo_root" - fi -fi - -container_uid="$(id -u)" -container_gid="$(id -g)" -registry_volume="anvil-cargo-registry-${repo_id}" -git_volume="anvil-cargo-git-${repo_id}" -for volume in "$registry_volume" "$git_volume" "$target_volume"; do - docker volume create "$volume" >/dev/null -done -mount_args=( - --mount "type=bind,source=$repo_root,target=/workspace" - --mount "type=volume,source=$registry_volume,target=/usr/local/cargo/registry" - --mount "type=volume,source=$git_volume,target=/usr/local/cargo/git" - --mount "type=volume,source=$target_volume,target=/workspace/target" -) -docker run --rm --pull=never \ - --platform linux/amd64 \ - --user 0:0 \ - "${mount_args[@]}" \ - "$image" sh -c \ - "chown $container_uid:$container_gid /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - -run_args=( - run --rm --pull=never - --platform linux/amd64 - --user "$container_uid:$container_gid" - --env ANVIL_IN_CONTAINER=1 - --env HOME=/tmp/anvil-user - "${mount_args[@]}" - --workdir /workspace -) -prepare_run_args=("${run_args[@]}") -run_args+=(${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"}) -for name in PR_TITLE BASE_REF ANVIL_IMPACT GITHUB_BASE_REF SYSTEM_PULLREQUEST_TARGETBRANCH; do - if value="$(printenv "$name")"; then run_args+=(--env "$name=$value"); fi -done -if ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} > 0)); then - docker "${prepare_run_args[@]}" \ - ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} \ - "$image" \ - "${ANVIL_CONTAINER_PREPARE_COMMAND[@]}" -fi - -if [[ -n "$github_token" ]]; then - github_token_file="$(mktemp "${TMPDIR:-/tmp}/anvil-github-token.XXXXXXXX")" - chmod 600 "$github_token_file" - printf '%s' "$github_token" > "$github_token_file" - unset github_token - github_run_args=( - "${run_args[@]}" - --mount "type=bind,source=$github_token_file,target=/run/secrets/anvil-github-token,readonly" - ) - if "$runs_only_github_check"; then - run_args=("${github_run_args[@]}") - else - docker "${github_run_args[@]}" "$image" just anvil-aprz - run_args+=(--env ANVIL_APRZ_ALREADY_RAN=1) - fi -fi - -if (($# == 0)); then - docker "${run_args[@]}" --interactive --tty "$image" bash - exit $? -fi -docker "${run_args[@]}" "$image" just "$@" === .delta.toml === # >>> anvil-managed: anvil-delta @@ -2575,10 +1538,6 @@ clippy.wildcard_imports = "allow" import 'justfiles/anvil/mod.just' # <<< anvil-managed: anvil-imports -# >>> anvil-managed: anvil-runner -anvil_runner := env_var_or_default("ANVIL_RUNNER", "native") -# <<< anvil-managed: anvil-runner - === clippy.toml === # >>> anvil-managed: anvil-clippy # Fine-tuning settings for clippy lints. These cannot be expressed in @@ -2676,13 +1635,15 @@ unknown-git = "deny" # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md # cargo-aprz queries the GitHub advisory API. Unauthenticated access is -# capped at 60 requests/hour and fails on a full run; an authenticated -# token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Container drivers mount an existing host GITHUB_TOKEN -# or the host gh CLI's stored token as a temporary read-only secret. -# Native runs borrow the gh CLI token directly. Native runs warn and -# proceed unauthenticated if neither is available; container runs fail -# before cargo-aprz can exhaust the unauthenticated rate limit. +# capped at 60 requests an hour, and on a full workspace it exhausts that +# and then waits for the quota to reset rather than failing; an +# authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -2690,26 +1651,16 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if ($env:ANVIL_APRZ_ALREADY_RAN -eq '1') { - Write-Host 'anvil-aprz: already completed in an isolated authenticated container' - exit 0 - } if (-not $env:GITHUB_TOKEN) { $tok = $null - $containerTokenFile = '/run/secrets/anvil-github-token' - if ($env:ANVIL_IN_CONTAINER -and (Test-Path -LiteralPath $containerTokenFile -PathType Leaf)) { - try { $tok = Get-Content -LiteralPath $containerTokenFile -Raw } catch { $tok = $null } - } elseif (Get-Command gh -ErrorAction SilentlyContinue) { + if (Get-Command gh -ErrorAction SilentlyContinue) { try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } } if ($tok) { $env:GITHUB_TOKEN = $tok.Trim() } else { - if ($env:ANVIL_IN_CONTAINER) { - throw 'anvil-aprz: GitHub authentication is unavailable. Run `gh auth login` on the host or set host GITHUB_TOKEN, then re-run the container command.' - } Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API (60 requests/hour) and may fail on a full run.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' } } @@ -3972,7 +2923,14 @@ anvil-mutants-diff: anvil-mutants-diff-validate-prereqs anvil-impact if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' - git diff "$base..HEAD" --output=$diff_path + # Diff the base against the WORKING TREE, not against HEAD. + # cargo-mutants validates every line of the diff against the file on + # disk and aborts when they disagree, so a commit-to-commit diff fails + # the moment anything is uncommitted -- which is the normal local state, + # since the point of running a tier locally is to check work in progress. + # CI has a clean tree, so the two forms are identical there and this is + # not a behaviour change for it. + git diff "$base" --output=$diff_path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -4573,27 +3531,1067 @@ anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-too # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. # Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +# +# Containerized execution. `just anvil-container ` runs the given +# argv inside a pinned Linux image; everything else keeps running natively. +# Anvil recipes are reached by naming `just`, like any other command. +# There is no configuration file and no transparent routing: the container is +# reached through this recipe or not at all. +# +# The image tag *is* a hash of the inputs that define it, so the presence of a +# tag is proof that its contents are current -- a changed tool pin names a tag +# that cannot already exist, and a build follows. There is nothing to keep in +# sync and no staleness to detect. +# +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md + +# The container engine, `docker` or `podman`. A host property, never +# committed. Set it in your environment: a `just anvil_container_engine=...` +# override would not reach the nested invocations that resolve the engine. +anvil_container_engine := env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker") + +# Where the repository is mounted inside the container. +anvil_container_workdir := "/workspace" + +# Image and cache-volume prefix, derived from the repository directory name. +# Two checkouts with the same directory name share cache volumes; that is +# harmless (the caches are content-addressed by cargo) but worth knowing before +# `anvil-container-down` removes volumes another checkout is also using. +# +# Every run of non-alphanumerics collapses to a single `-`, and a trailing one +# is trimmed, because a repository name may not end in a separator or repeat +# `.`/`_`. Without that, a checkout in `ox-tools (copy)` yields a reference the +# engine rejects as malformed, from a directory name nobody would suspect. +anvil_container_name := trim_end_matches("anvil-" + replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-"), "-") + +# Resolve how to invoke the engine, as a pipe-separated command. +# +# There is deliberately no probe *between* engines: presence is not +# reachability, and a silent choice between two installed engines means two +# image stores and an unexplained rebuild. We check that the requested binary +# exists and let every other failure surface the engine's own diagnostic, which +# is more accurate than anything repeated here. +# +# The one fallback is Windows-specific and unambiguous: when the engine is not +# on the Windows PATH, try it inside the default WSL distribution. Installing +# Docker in WSL without Docker Desktop is a documented, common setup, and it +# leaves no Windows CLI behind, so without this fallback a correctly installed +# engine would be unreachable. Docker Desktop and Podman both ship a Windows +# CLI and are found on PATH, so they never take this path. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-engine: + $ErrorActionPreference = 'Stop' + $engine = '{{ replace(anvil_container_engine, "'", "''") }}' + if ($engine -ne 'docker' -and $engine -ne 'podman') { + Write-Error "anvil: ANVIL_CONTAINER_ENGINE must be 'docker' or 'podman', got '$engine'" + exit 1 + } + if (Get-Command $engine -ErrorAction SilentlyContinue) { + Write-Output $engine + exit 0 + } + # --exec, not --: `wsl.exe -- ` hands the rest of the command line to + # the distribution's default shell, which expands $NAME, splits on ;, and + # eats backslashes. Every argument we forward -- the repository path and + # the recipe's own arguments -- would cross that boundary unquoted. + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $engine --version *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "wsl.exe|--exec|$engine" + exit 0 + } + } + Write-Error "anvil: '$engine' was not found on PATH, and is not usable in the default WSL distribution. Install it, or set ANVIL_CONTAINER_ENGINE to the other engine. Setup: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md" + exit 1 + +# Translate a host path into what the engine sees. +# +# Identical when the engine runs on this host. When it runs in WSL, a Windows +# path has to become its /mnt/... form or the daemon silently bind-mounts an +# empty directory -- a failure that surfaces much later, as a missing file +# inside the container. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-path host_path: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + if (-not $engine.StartsWith('wsl.exe|')) { + Write-Output '{{ replace(host_path, "'", "''") }}' + exit 0 + } + # --exec for the reason given above. It matters most here: through a shell, + # a path holding `$` loses it, and `wslpath -a` then makes the *truncated* + # path absolute and exits 0, so the guard below never fires and the wrong + # directory is bind-mounted. + $hostPath = '{{ replace(host_path, "'", "''") }}' + $translated = & wsl.exe --exec wslpath -a -u $hostPath + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: could not translate '$hostPath' for the engine running in WSL" + exit 1 + } + Write-Output $translated.Trim() + +# Verify the composed Dockerfile is present under the name the build uses. +# +# The engine derives the ignore file's name from the Dockerfile's: BuildKit +# reads `.dockerignore` and there is no flag to point it elsewhere. +# Anvil owns that artifact at the fixed path `.anvil/container/Dockerfile.dockerignore`, +# so the two names have to agree, and only one of them can move. A case variant +# is therefore refused rather than accommodated: building from `dockerfile` +# would silently use no ignore file at all, streaming the whole worktree into +# the build context and admitting inputs the tag does not cover. +# +# Also the one place that asserts the file exists: the tag's directory walk +# cannot, because a missing Dockerfile simply contributes nothing to the hash +# and yields a confident tag for an image that can never be built. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-dockerfile: + $ErrorActionPreference = 'Stop' + $dir = Join-Path '{{ replace(justfile_directory(), "'", "''") }}' '.anvil/container' + $entries = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue) + # -ceq because PowerShell's -eq on strings is case-insensitive, which would + # make the exact name indistinguishable from a variant on a filesystem that + # can hold both. + if (@($entries | Where-Object { $_.Name -ceq 'Dockerfile' }).Count -eq 1) { + Write-Output '.anvil/container/Dockerfile' + exit 0 + } + $variant = @($entries | Where-Object { $_.Name -ieq 'Dockerfile' })[0] + if ($variant) { + Write-Error "anvil: the container image input must be named exactly '.anvil/container/Dockerfile', but this repository has '.anvil/container/$($variant.Name)'. The engine reads the ignore file as '.dockerignore', and anvil maintains '.anvil/container/Dockerfile.dockerignore', so a differently-cased name would build with no ignore file. Rename it." + exit 1 + } + Write-Error 'anvil: container image input is missing: .anvil/container/Dockerfile' + exit 1 -# Run any Anvil recipe in the pinned local Linux container. With no recipe, -# open an interactive shell. -[windows] +# Print the exec image reference for the current inputs, without building it. +# +# The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its +# ignore file, the pinned toolchain, the optional hook, and the whole generated +# recipe tree -- because the image installs its tools by running +# `just anvil-setup`, whose dependency chain reaches the tier, group, check and +# tool recipes alike. Editing any of them can change what the image contains, so +# any of them can rename it. +# +# This is the only recipe that computes the reference; everything else asks it. +# It is public because a publisher needs the tag before there is an image to +# inspect: a pipeline that builds the image tags the result with exactly the +# reference a consumer will later compute, which is what lets presence be +# checked without a second source of truth. + +# Print the exec image reference for the current inputs, without building it. [group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-container *recipe: - $requested = @('{{ replace(recipe, "'", "''") }}' -split '\s+' | Where-Object { $_ }) - & '.anvil/container/run-in-container.ps1' @requested - exit $LASTEXITCODE +anvil-container-tag: + $ErrorActionPreference = 'Stop' + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $inputs = @('rust-toolchain.toml') + $links = @() + # The declared input and the two walk roots are checked here, because a walk + # only ever reports descendants: a link that *is* the root is traversed or + # read through and never appears in its own output. Same hazard as a link + # below them -- the engine copies the link while everything here follows it. + foreach ($declared in @('rust-toolchain.toml', '.anvil/container', 'justfiles/anvil')) { + $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $links += $item.FullName + } + } + # The declared inputs are text this tree owns, so their line endings are + # normalized before hashing and a CRLF checkout agrees with an LF one. + # Everything discovered by walking a directory is treated as text only when + # it is a `.just` recipe; anything else is hashed as the bytes the build + # context actually copies. + $declaredText = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + [void]$declaredText.Add($dockerfile) + [void]$declaredText.Add("$dockerfile.dockerignore") + [void]$declaredText.Add($hookRel) + # Everything under `.anvil/container/`, not a fixed list of three files. + # The Dockerfile is composed -- anvil owns regions inside it and the + # repository owns the gaps -- and a repository that adds a `COPY` in one of + # those gaps names a file that shapes the image: a corporate root CA, an + # install script, a patch. A replacement region from a downstream catalog + # does the same. Hashing only the three files anvil happens to know about + # would let any of them change the image under a reference that already + # resolves, which is precisely the hole this digest exists to close. + # + # The hook is picked up by the same walk. Its *output* is deliberately + # never hashed: a credential must not influence a tag. + # + # `.anvil-proposed` siblings are excluded. A region proposal is anvil's own + # review artifact, written beside its host when a template moves under a + # customized region; the build cannot see it and it cannot change what the + # image contains. Digesting it would rename the image for as long as a + # proposal sat undismissed, so two checkouts of one commit would disagree + # on the tag and a published image would stop resolving. + $containerRoot = Join-Path $repoRoot '.anvil/container' + if (Test-Path -LiteralPath $containerRoot) { + foreach ($file in Get-ChildItem -LiteralPath $containerRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + # Every generated recipe file. The image installs its tools by running + # `just anvil-setup`, and that dependency chain runs through the tier, + # group and check recipes before it reaches the install recipes in + # tools.just -- so the routing decides *whether* a tool is installed just + # as surely as tools.just decides *how*. Hashing only the install + # definitions would let a group drop a `-setup` dependency, changing the + # installed set, without renaming the image. + # + # This driver is included too. It is not circular -- the tag is derived + # from file text, and no file contains the tag -- and it belongs in the set + # because it passes the build arguments, the secret mounts and the hook's + # `Anvil-BuildSecrets` output into the build, all of which shape the result. + # Every file in the generated recipe tree, not only `*.just`. The build + # context admits the whole `justfiles/anvil/` directory (see the ignore + # file), so anything an adopter drops there is copied into the image. The + # catalog refuses to *own* a non-recipe file there, but a repository can + # still add one by hand, and a file that reaches the image without reaching + # the tag is precisely the hole this digest exists to close. Hashing what + # the context copies keeps the two sets identical by construction. + # + # -Force because Get-ChildItem omits hidden entries otherwise: a + # dot-prefixed file is copied like any other, and skipping it would let its + # edits ride under an unchanged tag -- and make Windows and Unix disagree. + # + # `.anvil-proposed` siblings are excluded here for the same reason as under + # `.anvil/container/`: this driver is itself an owned artifact, so a + # repository that customizes it gets the proposal written right here. + $recipeRoot = Join-Path $repoRoot 'justfiles/anvil' + if (Test-Path -LiteralPath $recipeRoot) { + foreach ($file in Get-ChildItem -LiteralPath $recipeRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + + # A symlink is refused rather than digested. The engine copies the link + # itself while any reading of it here follows it, so a retarget changes the + # image without changing a single byte the walk can see, and a link to a + # directory is not enumerated by the walk at all. Framing link text instead + # would have to work on Windows, where git materializes a symlink as an + # ordinary file unless the checkout was privileged, so the same commit would + # digest differently per platform. Anvil never creates one under these + # trees, so refusing costs nothing and closes the whole class. + if ($links.Count -gt 0) { + $named = ($links | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' }) -join ', ' + Write-Error "anvil: the container image inputs must be regular files, but these are links: $named. The engine copies a link as a link while the image tag is computed from what it points at, so the image would not match its own reference. Replace them with regular files." + exit 1 + } + + # Hash a tagged stream rather than raw concatenation, so no rearrangement of + # names and contents can collide. Line endings are normalized once, here, so + # a CRLF checkout and an LF checkout agree on the tag. Ordinal sort and dedup: + # `Sort-Object -Unique` compares case-insensitively, which would silently drop + # one of two inputs differing only in case on the case-sensitive filesystem + # where the image is actually built. + # Length-prefix the path and the content rather than relying on newlines as + # separators. A bare `file\n\n\n` stream is not + # self-delimiting: content is arbitrary, so a file whose body contains + # "file\n\n" serializes identically to two files whose bodies + # split at that point. That makes distinct input sets nameable by one tag -- + # a hook that exists versus an ignore file whose body ends in the hook's + # path and body, for instance -- and the second state would silently reuse + # the first state's image. Byte counts cannot be forged by content. + # + # Content is hashed as BYTES, not as decoded text. `ReadAllText` decodes + # UTF-8 with a replacing fallback, so every invalid sequence becomes U+FFFD + # before it is hashed: a one-byte file of 0xFF and one of 0xFE both collapse + # to the same replacement character and produce the same tag, while `COPY` + # puts their real, different bytes in the image. That was unreachable while + # only `*.just` was hashed and became reachable the moment the input set + # widened to everything the build context copies -- which is precisely the + # class of file (a stray `.png`, a `.DS_Store`, a UTF-16 fragment) that the + # widening admitted. + # + # Line endings are still normalized, but only for the text this tree owns: + # a `.just` recipe and the declared inputs, which a CRLF checkout and an LF + # checkout must agree on. Normalizing bytes generally would reintroduce the + # same collision from the other direction. + $ordered = [System.Collections.Generic.SortedSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + # A file's git mode is part of what `COPY` puts in the image -- the + # executable bit, and whether the entry is a regular file or a symlink -- so + # a change that leaves the bytes alone still changes the image and must + # rename the tag. Git's index is the only source of that mode which answers + # identically on every platform: Windows has no executable bit, so reading + # it from the filesystem would make two checkouts of one commit disagree on + # the tag, and a published image would stop resolving for half the people + # who use it. + # + # The index is authoritative only while the working tree agrees with it. A + # mode change that has not been staged would be copied by the build and + # missed by the tag, so it is refused below rather than absorbed. + # + # An untracked file's mode is not an input: it has no committed identity, so + # no other checkout can reproduce it and there is nothing for a shared tag + # to encode. + # + # quotePath=false so a non-ASCII path arrives verbatim rather than + # backslash-escaped, which would key the map on a spelling the walk never + # produces. + # Ordinal, like the sort and the dedup below: PowerShell's `@{}` folds case, + # so two paths differing only in case -- which git permits and the + # case-sensitive filesystem the image is built on can hold -- would collapse + # to one entry and both would be framed with whichever mode was stored last. + $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $tracked = @('.anvil/container', 'justfiles', 'rust-toolchain.toml') + if (Get-Command git -ErrorAction SilentlyContinue) { + $staged = & git -c core.quotePath=false -C $repoRoot ls-files --stage -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $staged) { + if ($entry -match '^(\d{6}) [0-9a-f]+ \d+\t(.+)$') { + $indexMode[$Matches[2]] = $Matches[1] + } + } + } + # `--raw` reports the working-tree mode as its second field, so a + # mode-only change is visible even though the content is identical. On + # Windows core.fileMode is normally false and git reports no drift, + # which is correct: the filesystem has no bit to disagree with. + # + # A zero working-tree mode is a deletion: the path is in neither the + # build context nor the digest, so there is nothing to disagree about. + # Every other entry is present in the context and is compared against + # the mode the digest actually framed, which comes from `ls-files + # --stage` above. The raw index-side mode is not that mode -- an + # intent-to-add entry reports zero there while `ls-files` reports a real + # placeholder -- so comparing the two raw fields would both miss an + # executable `git add -N` file and reject an ordinary one. + $drift = & git -c core.quotePath=false -C $repoRoot diff --no-ext-diff --raw -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $drift) { + if ($entry -match '^:\d{6} (\d{6}) [0-9a-f]+ [0-9a-f]+ \S+\t(.+)$') { + $worktreeMode, $driftPath = $Matches[1], $Matches[2] + if ($worktreeMode -ne '000000' -and $indexMode.ContainsKey($driftPath) -and $indexMode[$driftPath] -ne $worktreeMode) { + Write-Error "anvil: '$driftPath' has mode $worktreeMode in the working tree, but the image tag was computed from mode $($indexMode[$driftPath]). The build copies the working tree, so the image would not match its own reference. Stage the change (git add) and re-run." + exit 1 + } + } + } + } + } + # ComputeHash rather than the static HashData: the latter arrived in .NET 5, + # and the prerequisite check accepts any PowerShell 7, including 7.0 on + # .NET Core 3.1 where the static overload does not exist. Failing there + # would be a MethodNotFound at tag time, before anything useful happened. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($rel in $ordered) { + $path = Join-Path $repoRoot $rel + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "anvil: container image input is missing: $rel" + exit 1 + } + if ([System.IO.Path]::GetExtension($rel) -eq '.just' -or $declaredText.Contains($rel)) { + $content = [System.Text.Encoding]::UTF8.GetBytes( + ([System.IO.File]::ReadAllText($path) -replace "`r`n", "`n")) + } else { + $content = [System.IO.File]::ReadAllBytes($path) + } + # The whole git mode, not just the executable bit: `COPY` preserves + # a symlink as a symlink, while the walk above reads through it, so + # replacing a regular file with a link to identical bytes would + # otherwise keep the tag. An untracked path has no framed mode. + $mode = if ($indexMode.ContainsKey($rel)) { $indexMode[$rel] } else { '-' } + $header = [System.Text.Encoding]::UTF8.GetBytes( + 'file ' + [System.Text.Encoding]::UTF8.GetByteCount($rel) + ' ' + $rel + ' ' + $mode + ' ' + $content.Length + ' ') + [void]$sha.TransformBlock($header, 0, $header.Length, $null, 0) + if ($content.Length -gt 0) { + [void]$sha.TransformBlock($content, 0, $content.Length, $null, 0) + } + } + [void]$sha.TransformFinalBlock([byte[]]::new(0), 0, 0) + $digest = $sha.Hash + } finally { + $sha.Dispose() + } + # 16 hex characters (64 bits) is far past any practical collision risk for a + # local image set, and keeps `docker images` readable. + $imageId = -join ($digest[0..7] | ForEach-Object { $_.ToString('x2') }) + Write-Output ('{{anvil_container_name}}:' + $imageId) + +# Resolve the exec image, building it if it is neither present nor resolvable. +# +# Three steps, in order: a local image under the computed tag, then the +# optional `Anvil-ResolveImage` hook (a registry, typically), then a build. +# Resolution comes before the NO_REBUILD guard because fetching a published +# image is not building one. +# +# ANVIL_CONTAINER_NO_REBUILD=1 fails instead of building, which is how a cache +# miss is told apart from a build failure. ANVIL_CONTAINER_NO_RESOLVE=1 skips +# the hook, so a query stays a query -- resolving can mean pulling gigabytes. +# ANVIL_CONTAINER_NO_CACHE=1 rebuilds a tag that already resolves, for the cases +# a content hash cannot see: a moved upstream package, or a base layer that +# changed behind its digest. It skips the hook too -- "ignore what is cached" +# has to mean the remote cache as well, or a rebuild would be undone by the +# next pull. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-image: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $image = & '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $image = "$image".Trim() + + if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') { + & $engineExe @enginePrefix image inspect $image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output $image + exit 0 + } + + # Nothing local. Give the optional hook a chance to fetch a published + # image built from these same inputs -- a registry, typically. + # + # The hook returns the reference it made available, and we run that + # reference rather than re-tagging it to the local name: a local tag + # asserts "built here from these inputs", and a fetched image only + # *claims* that, since the hash is over source files and cannot be + # re-derived from layers. Whether that claim holds is a property of the + # registry (immutable tags, restricted push), not of anything this + # recipe can check, so the reference stays honest about where it came + # from. + # + # Every failure here is non-fatal: a missing image, an expired + # credential and a broken hook all fall through to a build, which is + # slower but always correct. A publisher that has not yet caught up + # with a change must not stop the developer who made it. + $hookPath = Join-Path $repoRoot $hookRel + if ($env:ANVIL_CONTAINER_NO_RESOLVE -ne '1' -and (Test-Path -LiteralPath $hookPath -PathType Leaf)) { + # Dot-sourcing is inside the try as well: a hook with a syntax error, + # or one that throws while being loaded, must cost no more than a + # hook that resolves nothing. The recipe runs under + # `$ErrorActionPreference = 'Stop'`, so leaving the load outside + # would abort the run instead of falling through to a build. + $resolved = $null + try { + . $hookPath + if (Get-Command Anvil-ResolveImage -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-ResolveImage from $hookRel") + $resolved = @(Anvil-ResolveImage $image | Where-Object { $_ }) | Select-Object -Last 1 + } + } catch { + [Console]::Error.WriteLine("anvil: $hookRel failed: $($_.Exception.Message)") + $resolved = $null + } + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $resolved = ([string]$resolved).Trim() + # A presence check, not a verification: `image inspect` proves + # something is tagged with that reference, not that its contents + # match the digest the tag claims. Trusting the hook is the + # contract -- this only keeps a reference the hook reported but + # never fetched from failing later, under `--pull=never`, a long + # way from the cause. + & $engineExe @enginePrefix image inspect $resolved *> $null + if ($LASTEXITCODE -eq 0) { + [Console]::Error.WriteLine("anvil: resolved $resolved") + Write-Output $resolved + exit 0 + } + [Console]::Error.WriteLine( + "anvil: Anvil-ResolveImage reported '$resolved' but no such image is present locally") + } + [Console]::Error.WriteLine("anvil: nothing resolved; building locally") + } + } + + # Checked outside the cache guard, so the two variables compose: a caller + # that has NO_CACHE exported would otherwise fall straight through to a + # from-scratch build, which is exactly what NO_REBUILD exists to prevent -- + # and `anvil-container-status`, which sets it, would spend minutes building + # from a query. + if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { + # Still report the reference: a caller that asked not to build is + # usually asking *which* image is missing. + Write-Output $image + [Console]::Error.WriteLine("anvil: $image is not present or not current, and ANVIL_CONTAINER_NO_REBUILD=1") + exit 1 + } + + # Build-time credentials come from the optional hook, never from a committed + # file. Values are handed to BuildKit by environment variable name, so they + # stay out of the host's process command line, and BuildKit keeps them out of + # every image layer. An empty value is fatal: BuildKit would mount an empty + # secret, the build would install a reduced tool set and exit 0, and the + # result would be tagged with the same hash a credentialed build produces -- + # so every later run would reuse the broken image. + $secretArgs = @() + $secretEnv = @() + $hookPath = Join-Path $repoRoot $hookRel + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed, unlike the resolve hook: a build that cannot mint its + # credentials must stop, not proceed to produce a reduced image. The + # try exists only so the cause is named -- loading a hook with a syntax + # error would otherwise surface as a bare parser error with no hint + # that a hook was involved. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load ${hookRel}: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-BuildSecrets -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-BuildSecrets from $hookRel") + try { + # Take the last emitted object, not the whole stream: a hook + # that writes progress with `Write-Output` would otherwise hand + # back an array whose `.Secrets` is silently $null. + $hook = @(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-BuildSecrets failed: $($_.Exception.Message)" + exit 1 + } + $secrets = if ($null -ne $hook) { $hook.Secrets } else { $null } + # A defined `Anvil-BuildSecrets` that yields nothing is the hazard this + # guard exists for, not a hook opting out: secrets are the only + # thing the phase can contribute, so an empty return means the mint + # failed quietly. A hook with no build-time credentials simply does + # not define the function. + if ($null -eq $secrets -or $secrets.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-BuildSecrets returned no secrets; omit the function if the build needs none" + exit 1 + } + foreach ($id in $secrets.Keys) { + if ([string]::IsNullOrWhiteSpace($secrets[$id])) { + Write-Error "anvil: Anvil-BuildSecrets returned an empty value for secret '$id'" + exit 1 + } + $name = "ANVIL_SECRET_$id" + Set-Item -LiteralPath "Env:$name" -Value $secrets[$id] + $secretEnv += $name + $secretArgs += "id=$id,env=$name" + } + [Console]::Error.WriteLine("anvil: build secrets: $($secrets.Keys -join ', ')") + } + } + + try { + # Progress goes to stderr: callers capture this recipe's stdout to learn + # the image reference, so anything else written there becomes part of it. + [Console]::Error.WriteLine("anvil: building $image (inputs changed or first run)") + # The engine may not share this host's filesystem view, so the context + # and the Dockerfile are given in its terms rather than ours. + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + # Pinned, not inferred from the host. The Dockerfile installs amd64 + # toolchains and verifies amd64 checksums, so an arm host would resolve + # the multi-arch base to arm64 and fail late with an exec-format error. + # It also keeps the identity scheme honest: without this, two hosts of + # different architecture compute the same tag for different images. + $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # BuildKit reads `.dockerignore` on its own; buildah reads + # only a context-root ignore file and needs to be pointed at ours. Named + # rather than probed, because the flag is rejected outright by the engine + # that does not take it, and an unscoped context streams the whole + # worktree -- `target/` included -- on every build. + if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile', "$engineRoot/$dockerfile.dockerignore") } + if ($env:ANVIL_CONTAINER_NO_CACHE -eq '1') { $buildCmd += '--no-cache' } + foreach ($secret in $secretArgs) { $buildCmd += @('--secret', $secret) } + $buildCmd += $engineRoot + # BuildKit is required for --secret; docker enables it by default from + # 23.0 but an older daemon silently ignores the flag, so ask explicitly. + $env:DOCKER_BUILDKIT = '1' + # WSLENV exports the named variables into the WSL environment, which is + # where the engine reads a secret's value from when it runs there. + if ($engineExe -eq 'wsl.exe') { + $bridged = @('DOCKER_BUILDKIT/u') + ($secretEnv | ForEach-Object { "$_/u" }) + $env:WSLENV = (@($env:WSLENV) + $bridged | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @buildCmd | ForEach-Object { [Console]::Error.WriteLine($_) } + if ($LASTEXITCODE -ne 0) { + # Build secrets are the part of this path engines implement least + # consistently -- podman on Windows cannot mount one at all, and + # fails with a path error that names neither the secret nor the + # engine. Say so once, rather than leaving that to be rediscovered. + if ($secretArgs.Count -gt 0) { + [Console]::Error.WriteLine( + "anvil: the build passed $($secretArgs.Count) secret(s) from $hookRel. " + + "If the failure above is about a temp file or a path, the engine may not support " + + "build secrets on this host; see docs/design/containers.md.") + } + exit $LASTEXITCODE + } + } finally { + foreach ($name in $secretEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + } + + Write-Output $image + +# Run a command inside the pinned Linux image. +# +# The tokens after the recipe name are the argv, executed verbatim in the +# image. Anvil recipes are reached by naming `just` like any other command. +# +# just anvil-container just anvil-pr # a tier +# just anvil-container cargo build # any other command +# just anvil-container # interactive shell +# +# ANVIL_IN_CONTAINER is set inside the image, so a nested invocation runs the +# command on the spot and the work happens exactly once. + +# Run a command inside the pinned Linux image (no argument: a shell). +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container *command: + $ErrorActionPreference = 'Stop' + # `*command` joins its parts with spaces, so the string is split back into + # argv here. Whitespace is the only separator, so an argument containing a + # space does not survive; pass such a value through the environment. + $argv = @('{{ replace(command, "'", "''") }}' -split '\s+' | Where-Object { $_ }) + if ($env:ANVIL_IN_CONTAINER -eq '1') { + if ($argv.Count -eq 0) { + # The no-argument form asks for a shell in the image, and this is + # that shell. Nothing runs, so exiting 0 would report success for a + # request that was not carried out. + [Console]::Error.WriteLine("anvil: already inside the container; run the command directly") + exit 1 + } + # `just` resolves to the binary running this tree rather than to PATH: + # ANVIL_IN_CONTAINER is a documented control a developer can set on a + # host, and a caller who invoked `just` by absolute path with its + # directory off PATH would otherwise fail here. + $exe = if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] } + & $exe @($argv | Select-Object -Skip 1) + exit $LASTEXITCODE + } + + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + $image = (& '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + + # Map the caller's working directory to its in-container equivalent so + # relative paths keep working from a subdirectory. `invocation_directory()` + # would be wrong here: with `cygpath` on PATH it reports a Cygwin-style + # path, which shares no prefix with the native `justfile_directory()` above. + # `GetRelativePath` then walks out with `..` segments and the run is placed + # outside the mount, so it fails on a path that does not exist in the + # container. The `_native` form is the one that agrees with the root. + $rel = [System.IO.Path]::GetRelativePath($repoRoot, '{{ replace(invocation_directory_native(), "'", "''") }}') -replace '\\', '/' + if ($rel.StartsWith('..')) { + Write-Error "anvil: run this from inside the repository; $rel is outside $repoRoot" + exit 1 + } + $containerCwd = if ($rel -eq '.' -or [string]::IsNullOrEmpty($rel)) { + '{{anvil_container_workdir}}' + } else { + '{{anvil_container_workdir}}/' + $rel + } + + $interactive = $argv.Count -eq 0 + $runArgs = @('run', '--rm', '--platform', 'linux/amd64') + $runArgs += $interactive ? '-it' : '-i' + $runArgs += @('-v', "${engineRoot}:{{anvil_container_workdir}}") + + # A checkout whose `.git` is a file keeps its real git directory elsewhere: + # a linked worktree points into the main clone, `--separate-git-dir` and a + # submodule point somewhere else again. The path recorded there is a host + # path that does not exist inside the container, so git resolves neither + # HEAD nor origin/main. Mount the common git directory and replace the + # checkout's `.git` with one naming that mount; the `commondir` file in a + # worktree's entry is relative, so it resolves under it. + # + # The predicate is the shape of `.git`, not whether the git directory + # differs from the common one: `--separate-git-dir` redirects without + # differing, and testing for a difference skips it and leaves git pointed at + # a path the container cannot see. + # + # The redirection lives in the checkout rather than in GIT_DIR/GIT_WORK_TREE + # so that it stays scoped to it. Those variables are ambient: every process + # in the container inherits them, and a git command run elsewhere -- `git + # init` in a test's scratch directory -- would operate on this repository + # instead of its own. + # + # An ordinary clone keeps its git directory inside the checkout, where the + # bind mount already carries it, and takes none of this. + # + # Guarded on git being present: the run path needs it only to answer this + # question, and a host with a working engine but no git on PATH keeps + # working rather than failing on a call it does not need. + $gitFile = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $gitDir = & git rev-parse --git-dir 2>$null + $gitCommon = & git rev-parse --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $gitDir -and $gitCommon -and + (Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)) { + $gitDirAbs = (Resolve-Path -LiteralPath $gitDir).Path + $gitCommonAbs = (Resolve-Path -LiteralPath $gitCommon).Path + $rel = [System.IO.Path]::GetRelativePath($gitCommonAbs, $gitDirAbs) -replace '\\', '/' + # One mount has to carry both directories, so the git directory must + # sit under the common one. `git worktree` always places it there and + # a redirect without a separate worktree entry makes the two equal; + # anything else cannot be expressed as a single mount, and emitting a + # path that climbs out of it would fail inside the container instead. + if ($rel -eq '.') { + $containerGitDir = '/anvil/gitdir' + } elseif ($rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)) { + Write-Error "anvil: this checkout's git directory ($gitDirAbs) is not inside its common git directory ($gitCommonAbs), so the two cannot be mounted as one tree. Run the container from an ordinary clone or a git worktree checkout." + exit 1 + } else { + $containerGitDir = "/anvil/gitdir/$rel" + } + $engineGitCommon = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitCommonAbs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitCommon = "$engineGitCommon".Trim() + # LF and no trailing newline: git parses this file strictly. + $gitFile = Join-Path ([System.IO.Path]::GetTempPath()) "anvil-gitfile-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($gitFile, "gitdir: $containerGitDir`n") + $engineGitFile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitFile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitFile = "$engineGitFile".Trim() + $runArgs += @('-v', "${engineGitCommon}:/anvil/gitdir") + $runArgs += @('-v', "${engineGitFile}:{{anvil_container_workdir}}/.git:ro") + } + } + + # Cache only what is content-addressed: the downloaded registry and the git + # checkouts. Deliberately NOT $CARGO_HOME or $RUSTUP_HOME themselves -- + # those hold the installed tools and toolchains, and a named volume is + # populated from the image only when it is first created. Mounting them + # would pin the first image's binaries over every later one, so a tool bump + # would change the tag, build a new image, and still run the old tools. + $runArgs += @('-v', '{{anvil_container_name}}-cargo-registry:/usr/local/cargo/registry') + $runArgs += @('-v', '{{anvil_container_name}}-cargo-git:/usr/local/cargo/git') + # Match the caller's uid/gid on Linux. Without this everything the run + # writes under the bind mount -- target/, generated files -- lands as root + # on the host, and the next native cargo build or git clean fails with + # EACCES a long way from the cause. Docker Desktop on Windows and macOS + # already maps ownership, and `id` is not there to ask. + if (-not $IsWindows -and -not $IsMacOS) { + $hostUid = (id -u); $hostGid = (id -g) + if ($LASTEXITCODE -eq 0 -and $hostUid -ne '0') { + $runArgs += @('--user', "${hostUid}:${hostGid}") + # That uid has no passwd entry, so the engine leaves HOME as `/`. + # Anything falling back to $HOME for a cache then writes to a + # read-only root and fails a long way from the cause. + $runArgs += @('-e', 'HOME=/tmp') + } + } + $runArgs += @('-e', 'ANVIL_IN_CONTAINER=1') + + # Run-time credentials come from the optional hook. Forwarded by NAME, never + # as NAME=VALUE: the engine copies the value out of the environment it + # already inherits, so a credential never appears in the host's process + # command line, where endpoint telemetry records and retains it for far + # longer than a short-lived token is meant to live. + # $forwardedEnv is every name passed with -e; $hookEnv is the subset this + # process set, and so the subset it must unset again. + $forwardedEnv = @() + $hookEnv = @() + + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # + # An already-exported GITHUB_TOKEN is forwarded whatever the command is: + # that is exact parity, since a native run exposes it to every process the + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. + # + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { + # A dry run has no side effects, and a target that cannot be planned + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. + # + # A plan covers the bodies just runs itself, not the body of a + # recipe that one of them launches as a child process. The unscoped + # tier wrapper launches its tier that way, so planning + # `anvil-scheduled` shows the wrapper and none of the checks + # underneath it. Follow each nested target a plan names, or a + # wrapped tier reads as needing nothing and runs unauthenticated. + $plan = '' + $targets = [System.Collections.Generic.List[object]]::new() + $targets.Add([string[]]@($argv | Select-Object -Skip 1)) + $planned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($i = 0; $i -lt $targets.Count; $i++) { + $target = [string[]]$targets[$i] + # A recipe reachable twice is planned once, and a body naming + # itself terminates. + if (-not $planned.Add(($target -join ' '))) { continue } + # The same executable that launched this tree, for the reason + # every other nested call uses it: a caller invoking `just` by + # absolute path with its directory off PATH would otherwise fail + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. + $step = '' + try { + $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | + ForEach-Object { $_.ToString() }) -join "`n" + } catch { + $step = '' + } + $plan = "$plan`n$step" + # A launched recipe appears as a quoted argument to `just`. + foreach ($nested in [regex]::Matches($step, "'(_anvil-[^'\s]+)'")) { + $targets.Add([string[]]@($nested.Groups[1].Value)) + } + } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } + } + } + if ($env:GITHUB_TOKEN) { + $forwardedEnv += 'GITHUB_TOKEN' + $runArgs += @('-e', 'GITHUB_TOKEN') + } + + # The recipe contract's own inputs. These are read by generated checks -- + # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its + # CI equivalents, and `anvil-impact` reads ANVIL_IMPACT to decide whether to + # compute scoping, consume a downloaded cache, or skip -- so dropping them at + # the boundary makes the same command mean different things inside and out. + # anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with a + # skip notice, so a title a native run rejects passes in a container and the + # tier still reports green. ANVIL_IMPACT is the other: a CI group job exports + # `consume`, and a container that did not inherit it would recompute scoping + # from a diff instead of trusting the artifact the group downloaded. + # + # Forwarded by name and only when set, so an unset variable stays unset + # rather than arriving as an empty string, which several of these treat as + # a value. + foreach ($name in @( + 'PR_TITLE', + 'BASE_REF', 'GITHUB_BASE_REF', 'SYSTEM_PULLREQUEST_TARGETBRANCH', + 'ANVIL_IMPACT')) { + if ((Test-Path -LiteralPath "Env:$name") -and -not [string]::IsNullOrEmpty((Get-Item -LiteralPath "Env:$name").Value)) { + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + } + + $hookPath = Join-Path $repoRoot '.anvil/container/hooks.ps1' + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed like Anvil-BuildSecrets: a run that cannot obtain its + # credentials fails inside the container in a far less obvious way. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load .anvil/container/hooks.ps1: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-RunEnv -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-RunEnv from .anvil/container/hooks.ps1") + try { + $hook = @(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-RunEnv failed: $($_.Exception.Message)" + exit 1 + } + $hookVars = if ($null -ne $hook) { $hook.Env } else { $null } + if ($null -eq $hookVars -or $hookVars.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-RunEnv returned no variables; omit the function if the run needs none" + exit 1 + } + foreach ($name in $hookVars.Keys) { + if ([string]::IsNullOrWhiteSpace($hookVars[$name])) { + Write-Error "anvil: Anvil-RunEnv returned an empty value for '$name'" + exit 1 + } + Set-Item -LiteralPath "Env:$name" -Value $hookVars[$name] + $hookEnv += $name + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + # Names only, never values: a hook with a broad idea of what to + # forward should be visible, since everything inside the + # container can read it -- including third-party build scripts. + [Console]::Error.WriteLine("anvil: forwarding env: $($hookVars.Keys -join ', ')") + } + } -[unix] + try { + # --pull=never: the reference names content that is already here, either + # built locally or fetched by the resolve hook, so a miss is a bug to + # surface rather than an invitation to fetch something unrelated. + $runArgs += @('--pull=never', '-w', $containerCwd, $image) + if (-not $interactive) { $runArgs += $argv } + # WSLENV exports the forwarded names into the WSL environment, which is + # where the engine reads their values from when it runs there. Without + # it, `-e NAME` reaches an engine that cannot see NAME and forwards + # nothing, leaving the variable unset inside the container. + # + # It is not restored afterwards because there is nothing to restore to: + # `just` runs a [script(...)] recipe as its own pwsh process, so this + # assignment dies with that process and never reaches the caller's + # shell. The `finally` below unsets the credential names for hygiene + # within this process, not to protect the parent. + if ($engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0) { + $env:WSLENV = (@($env:WSLENV) + ($forwardedEnv | ForEach-Object { "$_/u" }) | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @runArgs + exit $LASTEXITCODE + } finally { + foreach ($name in $hookEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + if ($gitFile) { Remove-Item -LiteralPath $gitFile -Force -ErrorAction SilentlyContinue } + } + +# Report the engine, the exec image, and whether it is present and current. +# +# The tag embeds the hash of the image's inputs, so "absent" and "out of date" +# are the same condition and are reported as one. + +# Report the engine, the exec image, and whether it is present and current. [group("anvil-container")] -[script("bash")] -anvil-container *recipe: - requested={{ quote(recipe) }} - if [[ -z "$requested" ]]; then - exec bash '.anvil/container/run-in-container.sh' - fi - read -r -a requested_args <<<"$requested" - exec bash '.anvil/container/run-in-container.sh' "${requested_args[@]}" +[script("pwsh", "-NoProfile")] +anvil-container-status: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + Write-Output ("engine: " + ($engine -replace '\|', ' ')) + Write-Output "workdir: {{anvil_container_workdir}}" + + # NO_REBUILD turns the resolve into a pure query: report the state instead + # of silently spending several minutes building from a status command. + # NO_RESOLVE is the same argument applied to the hook, which would otherwise + # pull gigabytes to answer a question about the local machine. + # + # Compute the tag first and let it fail loudly. It is fatal for a reason a + # query cannot paper over -- a declared input is missing -- and reporting + # that as "not present locally" would be a lie: the next run cannot build + # it either. + $image = (& '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output "image: $image" + + $env:ANVIL_CONTAINER_NO_REBUILD = '1' + $env:ANVIL_CONTAINER_NO_RESOLVE = '1' + # And explicitly *not* NO_CACHE. A caller who exported it is asking the next + # build to ignore the layer cache, which is a statement about building -- + # but it also makes the resolver skip the local `image inspect` + # short-circuit, so a present image would be reported absent by a command + # that is only ever asking what is on this machine. + $env:ANVIL_CONTAINER_NO_CACHE = $null + & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "status: present and current" + exit 0 + } + + # A cache miss and an unreachable daemon both make `image inspect` fail, and + # reporting the second as the first tells a developer to expect a build that + # will not start either. Ask the engine whether it is answering at all: only + # then is absence the honest reading. + $engineCmd = $engine -split '\|' + & $engineCmd[0] @($engineCmd | Select-Object -Skip 1) version *> $null + if ($LASTEXITCODE -ne 0) { + Write-Output "status: unknown -- the engine is not responding (is the daemon running?)" + exit 1 + } + Write-Output "status: not present locally (the next run resolves or builds it)" + exit 0 + +# Only the download caches are volumes, so this discards fetched crates and git +# checkouts and nothing else: the next run re-fetches them, and the image's own +# tools are untouched. To discard the image instead, set +# ANVIL_CONTAINER_NO_CACHE=1 for a single run. + +# Remove this repository's cache volumes. The image is left in place. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-down: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + # Report a teardown that did not happen. $ErrorActionPreference does not + # cover native commands, so a non-serving engine would otherwise print a + # connection error per volume and still exit 0 -- and this recipe is the + # only way to clear a cache volume, so a caller that scripts teardown must + # be able to tell that it failed. `-f` already exits 0 for a volume that + # does not exist, so this cannot fire spuriously. + $failed = @() + foreach ($vol in @('{{anvil_container_name}}-cargo-registry', '{{anvil_container_name}}-cargo-git')) { + & $engineExe @enginePrefix volume rm -f $vol + if ($LASTEXITCODE -ne 0) { $failed += $vol } + } + if ($failed.Count -gt 0) { + Write-Error ("anvil: could not remove: " + ($failed -join ', ')) + exit 1 + } + exit 0 === justfiles/anvil/groups/pr-fast.just === # Copyright (c) Microsoft Corporation. @@ -4815,14 +4813,14 @@ anvil-pr-test-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled advisory checks. [group("anvil")] -anvil-scheduled-advisories: (_anvil-run "scheduled-advisories" anvil_runner "off") +anvil-scheduled-advisories: (_anvil-unscoped "scheduled-advisories") [private] _anvil-scheduled-advisories: anvil-scheduled-advisories-validate-prereqs \ @@ -4856,14 +4854,14 @@ anvil-scheduled-advisories-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled exhaustive checks. [group("anvil")] -anvil-scheduled-exhaustive: (_anvil-run "scheduled-exhaustive" anvil_runner "off") +anvil-scheduled-exhaustive: (_anvil-unscoped "scheduled-exhaustive") [private] _anvil-scheduled-exhaustive: anvil-scheduled-exhaustive-validate-prereqs \ @@ -4900,14 +4898,14 @@ anvil-scheduled-exhaustive-validate-prereqs: \ # and adds the three stricter miri profiles (tree-borrows, strict- # provenance, race-coverage) which are too expensive for PR. -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled runtime analysis. [group("anvil")] -anvil-scheduled-runtime-analysis: (_anvil-run "scheduled-runtime-analysis" anvil_runner "off") +anvil-scheduled-runtime-analysis: (_anvil-unscoped "scheduled-runtime-analysis") [private] _anvil-scheduled-runtime-analysis: anvil-scheduled-runtime-analysis-validate-prereqs \ @@ -4944,7 +4942,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Scheduled groups # Scheduled groups are the full-workspace backstop for PR-tier impact scoping, -# so route through _anvil-run with impact "off": it exports ANVIL_IMPACT=off +# so route through _anvil-unscoped: it exports ANVIL_IMPACT=off # before the check dependencies run, so the group is full-workspace regardless # of how it is invoked (CI, `just anvil-scheduled`, or # `just anvil-scheduled-test` directly). Because these groups never recompute @@ -4952,7 +4950,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Run the scheduled tests. [group("anvil")] -anvil-scheduled-test: (_anvil-run "scheduled-test" anvil_runner "off") +anvil-scheduled-test: (_anvil-unscoped "scheduled-test") [private] _anvil-scheduled-test: anvil-scheduled-test-validate-prereqs \ @@ -5098,6 +5096,25 @@ _anvil-base-ref: Write-Error 'anvil-base-ref: cannot resolve a base ref. Set BASE_REF, or ensure origin/main or origin/master exists.' exit 1 +# Run a private recipe with impact scoping disabled. +# +# The only way to reach a whole dependency tree with an environment variable: +# `just` runs each dependency as its own process, and a dependency-only +# recipe's body executes after its dependencies, so exporting from there is +# too late. Invoking `_anvil-` as a child process makes every check +# below it inherit the setting. +# +# The justfile is named explicitly so the child resolves the same file the +# wrapper was defined in, rather than whatever an upward search from the +# working directory happens to find. +[private] +[script("pwsh", "-NoProfile")] +_anvil-unscoped name: + $ErrorActionPreference = 'Stop' + $env:ANVIL_IMPACT = 'off' + & '{{ replace(just_executable(), "'", "''") }}' --justfile '{{ replace(justfile(), "'", "''") }}' '_anvil-{{ replace(name, "'", "''") }}' + exit $LASTEXITCODE + === justfiles/anvil/impact.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -5796,7 +5813,11 @@ import 'checks/readme-check.just' import 'checks/semver-check.just' import 'checks/spellcheck.just' import 'checks/udeps.just' -import 'container.just' +# Optional: the container artifacts can be removed through `without_artifact`, +# which deletes this file. A hard import would then fail parsing for every +# recipe in the tree, not merely the container ones, so the documented opt-out +# would break the whole Justfile. +import? 'container.just' import 'groups/pr-fast.just' import 'groups/pr-slow.just' import 'groups/pr-test.just' @@ -5806,7 +5827,6 @@ import 'groups/scheduled-test.just' import 'groups/scheduled-advisories.just' import 'groups/scheduled-runtime-analysis.just' import 'groups/scheduled-exhaustive.just' -import 'runner.just' import 'tiers.just' import 'tools.just' import 'versions.just' @@ -5814,67 +5834,6 @@ import 'versions.just' # Friendly default: `just anvil` runs the PR tier. alias anvil := anvil-pr -=== justfiles/anvil/runner.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -# Route public tier entry points through the configured execution environment. -# ANVIL_IN_CONTAINER always wins to prevent recursive container launches. -# -# `impact` selects the tier's impact-scoping mode: the default "on" leaves -# scoping enabled (PR tier), while "off" exports ANVIL_IMPACT=off before -# invoking the native tier so every check runs full-workspace -- the -# scheduled/full backstop for PR-tier impact scoping. Setting it here (rather -# than in a dep-only tier recipe) ensures the private `_anvil-` recipe's -# own dependencies, which run before any recipe body, inherit the mode. -[private] -[no-exit-message] -[windows] -[script("pwsh", "-NoProfile")] -_anvil-run tier runner impact="on": - if ('{{ replace(impact, "'", "''") }}' -ceq 'off') { $env:ANVIL_IMPACT = 'off' } - $just = '{{ replace(just_executable(), "'", "''") }}' - $justfile = '{{ replace(justfile(), "'", "''") }}' - $nativeTier = '_anvil-{{ replace(tier, "'", "''") }}' - if ($env:ANVIL_IN_CONTAINER) { - & $just --justfile $justfile $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'container') { - & $just --justfile $justfile anvil-container $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'native') { - & $just --justfile $justfile $nativeTier - } else { - [Console]::Error.WriteLine("anvil-runner: expected 'native' or 'container', got '{{ replace(runner, "'", "''") }}'.") - exit 2 - } - exit $LASTEXITCODE - -[private] -[no-exit-message] -[unix] -[script("bash")] -_anvil-run tier runner impact="on": - just_path={{ quote(just_executable()) }} - justfile={{ quote(justfile()) }} - tier={{ quote(tier) }} - runner={{ quote(runner) }} - impact={{ quote(impact) }} - if [[ "$impact" == "off" ]]; then - export ANVIL_IMPACT=off - fi - native_tier="_anvil-$tier" - if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - elif [[ "$runner" == "container" ]]; then - exec "$just_path" --justfile "$justfile" anvil-container "$native_tier" - elif [[ "$runner" == "native" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - else - echo "anvil-runner: expected 'native' or 'container', got '$runner'." >&2 - exit 2 - fi - === justfiles/anvil/tiers.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -5890,10 +5849,7 @@ _anvil-run tier runner impact="on": # Run all pull request checks. [group("anvil")] -anvil-pr: (_anvil-run "pr" anvil_runner) - -[private] -_anvil-pr: anvil-pr-validate-prereqs \ +anvil-pr: anvil-pr-validate-prereqs \ anvil-pr-fast \ anvil-pr-slow @@ -5902,20 +5858,18 @@ _anvil-pr: anvil-pr-validate-prereqs \ # exhaustive checks that don't fit in a PR budget. Runs on a schedule # against `main`, not on PRs. # -# The scheduled tier is deliberately NOT impact-scoped: it is the -# catch-all that backstops PR-tier scoping. Because every impact-scoped -# check depends on `anvil-impact` and self-populates its scope from the -# cache, the tier must run with ANVIL_IMPACT=off so `_anvil-impact-include` -# returns each tier's full-workspace default and the `anvil-impact` -# dependency no-ops. A dependency-only recipe can't set env for its own -# deps (deps run before the body), so the public tier routes through -# `_anvil-run` with the `"off"` impact argument: `_anvil-run` exports -# ANVIL_IMPACT=off before invoking the private `_anvil-scheduled` recipe, -# whose deps then inherit it. +# The scheduled tier is deliberately NOT impact-scoped: it is the catch-all +# that backstops PR-tier scoping. Every impact-scoped check depends on +# `anvil-impact` and populates its own scope from the cache, so the tier runs +# with ANVIL_IMPACT=off, which makes `_anvil-impact-include` return each +# category's full-workspace default and the `anvil-impact` dependency no-op. +# A dependency-only recipe cannot set an environment variable for its own +# dependencies, so the public tier wraps the private one through +# `_anvil-unscoped`. # Run all scheduled checks. [group("anvil")] -anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off") +anvil-scheduled: (_anvil-unscoped "scheduled") [private] _anvil-scheduled: anvil-scheduled-validate-prereqs \ @@ -5924,16 +5878,15 @@ _anvil-scheduled: anvil-scheduled-validate-prereqs \ anvil-scheduled-runtime-analysis \ anvil-scheduled-exhaustive -# Runs everything full-workspace (ANVIL_IMPACT=off, via the `"off"` impact -# argument to _anvil-run), same wrapper shape as anvil-scheduled. +# Full-workspace for the same reason as the scheduled tier. # Full tier: PR + scheduled, end-to-end. Useful before tagging a release. [group("anvil")] -anvil-full: (_anvil-run "full" anvil_runner "off") +anvil-full: (_anvil-unscoped "full") [private] _anvil-full: anvil-full-validate-prereqs \ - _anvil-pr \ + anvil-pr \ _anvil-scheduled # Tier-level + global setup + validate-prereqs diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index b05e44c0..6d3b5b9f 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -2,33 +2,54 @@ source: crates/cargo-anvil/tests/snapshots.rs expression: render_tree(tmp.path()) --- -=== .anvil/container/Containerfile === +=== .anvil/container/Dockerfile === # syntax=docker/dockerfile:1 -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -ARG BASE_IMAGE=docker.io/library/debian:bookworm-slim@sha256:63a496b5d3b99214b39f5ed70eb71a61e590a77979c79cbee4faf991f8c0783e +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# >>> anvil-managed: anvil-container-base-image +# Prebuilt binaries installed by `anvil-setup binstall` link against this +# image's glibc, so it tracks the Linux runner the generated workflows use. +# Digest-pinned: a floating tag moves content under a reference that claims to +# name fixed content. +# +# Re-declare BASE_IMAGE in the gap below to build on another base; a later ARG +# wins, and the pins anvil maintains stay current. +ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +# <<< anvil-managed: anvil-container-base-image + +# >>> anvil-managed: anvil-container-base FROM ${BASE_IMAGE} -ARG ANVIL_IMAGE_ID ARG JUST_VERSION=1.56.0 ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 ARG POWERSHELL_VERSION=7.6.3 ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 ARG RUSTUP_VERSION=1.29.0 ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 +ARG CARGO_BINSTALL_VERSION=1.21.1 +ARG CARGO_BINSTALL_SHA256=630c8f8803a686aa6779497f0f0fb51d49822fb5fc3c514d8ced33b34e338e6e ENV DEBIAN_FRONTEND=noninteractive \ CARGO_HOME=/usr/local/cargo \ RUSTUP_HOME=/usr/local/rustup \ RUSTUP_NO_UPDATE_CHECK=1 \ PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# <<< anvil-managed: anvil-container-base +# >>> anvil-managed: anvil-container-tools +# clang/libclang are required by cargo-spellcheck; the rest is the usual Rust +# link-time set. A bare base has no C runtime development files, so every link +# step fails without build-essential. RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential ca-certificates clang libclang-dev curl git libicu-dev \ libssl-dev pkg-config tar \ && rm -rf /var/lib/apt/lists/* +# pwsh is not optional: every generated anvil recipe is a `script("pwsh", +# "-NoProfile")` recipe. RUN curl -fsSLo /tmp/powershell.tar.gz \ "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ @@ -52,1249 +73,191 @@ RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ && rm /tmp/rustup-init -WORKDIR /opt/anvil -COPY . . -RUN test -f rust-toolchain.toml || { \ - echo "anvil-container requires rust-toolchain.toml" >&2; \ - exit 1; \ - } -RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry \ - --mount=type=cache,id=anvil-cargo-git,target=/usr/local/cargo/git \ - --mount=type=cache,id=anvil-cargo-target,target=/tmp/anvil-target \ - printf "anvil_runner := \"native\"\nimport 'justfiles/anvil/mod.just'\n" > Justfile \ - && CARGO_TARGET_DIR=/tmp/anvil-target just anvil-setup +RUN curl -fsSLo /tmp/cargo-binstall.tgz \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" \ + && echo "${CARGO_BINSTALL_SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c - \ + && mkdir -p "${CARGO_HOME}/bin" \ + && tar -xzf /tmp/cargo-binstall.tgz -C "${CARGO_HOME}/bin" cargo-binstall \ + && chmod 755 "${CARGO_HOME}/bin/cargo-binstall" \ + && rm /tmp/cargo-binstall.tgz -COPY .anvil/container/entrypoint.sh /usr/local/bin/anvil-container-entrypoint -RUN chmod 755 /usr/local/bin/anvil-container-entrypoint +# <<< anvil-managed: anvil-container-tools +# >>> anvil-managed: anvil-container-setup +# The whole recipe tree is copied because `just` parses it to reach the install +# recipes. +# +# The credential files are removed in the same layer that used them: a build +# secret never lands in a layer, but anything the install *writes* with it is +# ordinary content, and the `chmod` below would publish it world-readable. A +# later `RUN` cannot undo that, because the earlier layer keeps them. +# +# `registry` and `git` must exist before the `chmod`. The run mounts a named +# volume over each, and an engine seeds a new volume from the image path it +# covers; a path that does not exist seeds as root-owned 0755, which the +# `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +WORKDIR /opt/anvil +COPY justfiles ./justfiles +COPY rust-toolchain.toml ./ +RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ + && just anvil-setup binstall \ + && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ + && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME}/.netrc" \ + && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ + && chmod -R a+rwX "${CARGO_HOME}" "${RUSTUP_HOME}" +# <<< anvil-managed: anvil-container-setup + +# >>> anvil-managed: anvil-container-entry +# Consumed by `anvil-container` itself: a nested invocation from inside the +# image runs the recipe natively instead of launching another container. ENV ANVIL_IN_CONTAINER=1 -LABEL io.github.cargo-anvil.image-id="${ANVIL_IMAGE_ID}" + WORKDIR /workspace -ENTRYPOINT ["anvil-container-entrypoint"] CMD ["bash"] +# <<< anvil-managed: anvil-container-entry -=== .anvil/container/Containerfile.dockerignore === +=== .anvil/container/Dockerfile.dockerignore === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Deny-all allow-list for the image build context. +# Update the corresponding template in the cargo-anvil crate. +# +# BuildKit reads `.dockerignore` in preference to a root +# `.dockerignore`, so this scopes the exec-image build context without the +# repository having to own a root ignore file or having one silently overridden. +# +# The build context is the repository root but the image only needs two things. +# Excluding everything else keeps a cold build from streaming the whole +# worktree (and every stale `target/`) to the daemon. # -# Docker matches each candidate against every pattern in order and lets the -# last match win, testing the path itself *and each of its parent directories* -# (moby/patternmatcher MatchesOrParentMatches). A bare directory re-inclusion -# such as `!justfiles` therefore re-admits the entire subtree below it, which -# would defeat this allow-list, so list only leaf patterns here. Docker still -# descends into a denied directory when some re-inclusion pattern is prefixed -# by it, so the intermediate directories need no entries of their own. +# The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` +# so that a cold build does not stream unrelated trees to the daemon. The +# recipes are copied to drive `just anvil-setup`, which needs the whole tree to +# parse, and the whole tree is hashed into the image tag: the tier, group and +# check recipes decide which tools `anvil-setup` reaches, not just the catalog. # -# Parent testing also reaches through a single-segment re-inclusion: a -# subdirectory of `.anvil/container/` matches `!.anvil/container/*` in its own -# right. The image-ID helpers list that directory one level deep, so a nested -# file is not an image input; `.anvil/container/*/*` states that leaf-only -# contract in the allow-list too, at every depth, because a deeper candidate -# always has an ancestor of exactly that shape. -** +# `.anvil/container/` is admitted because the Dockerfile is composed: the gaps +# between anvil's regions exist for a repository to add its own instructions, +# and the headline case -- `COPY`ing a corporate root CA in before the first +# download -- needs the file to be in the context. Denying it would leave the +# gap documented but unusable for anything but `RUN`. It is also the directory +# the image tag digests, so what the context admits and what the tag covers stay +# the same set -- including the `.anvil-proposed` siblings both exclude, which +# are anvil's review artifacts rather than build inputs. +* +!justfiles +justfiles/* +!justfiles/anvil +justfiles/anvil/**/*.anvil-proposed +!.anvil +.anvil/* +!.anvil/container +.anvil/container/**/*.anvil-proposed !rust-toolchain.toml -!justfiles/anvil/*.just -!justfiles/anvil/checks/*.just -!justfiles/anvil/groups/*.just -!.anvil/container/* -.anvil/container/*/* -.anvil/container/customize.sh -.anvil/container/customize.ps1 - -=== .anvil/container/README.md === - - -# Run Anvil checks in a local container - -Use `just anvil-container` to run generated Anvil checks in a reproducible -Linux environment without installing the complete Rust and Cargo tool catalog -on the host. - -Native execution remains the default. The first container run builds an image -matching the repository's generated configuration. Later runs reuse that image, -dependency caches, and compilation output. - -## Quick start - -Ensure Docker Engine is running, then run: - -```text -just anvil-container anvil-clippy -``` - -The first run builds the matching image and can take several minutes. - -## Prerequisites - -- [Docker Engine](https://docs.docker.com/engine/install/) 23.0 or newer, - installed directly in Linux or WSL and usable by the current user. -- `git` and `just` on the host. -- Bash on Linux and WSL; PowerShell Core (`pwsh`) and WSL 2 on Windows. -- `[script]` support enabled in the root `Justfile`. Add `set unstable` when - required by the installed `just` version. -- A `rust-toolchain.toml` in the repository root. -- A Linux or WSL environment capable of running `linux/amd64` images, either - natively on x86-64 or through Docker emulation on ARM64. - -On Windows, the driver invokes Docker from the default WSL distribution rather -than calling Windows `docker.exe`. Regardless of how Docker is installed, this -command must succeed from PowerShell: - -```text -wsl -e docker version -``` - -Start the Docker service inside WSL when it is stopped and add the WSL user to -the `docker` group when non-root access is not already configured. Docker -Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment. Image -builds and checks can therefore be substantially slower than on x86-64 hosts. - -## Security boundary - -> [!WARNING] -> `customize.sh` and `customize.ps1` execute on the host with the developer's -> permissions before container isolation begins. Reviewing and trusting these -> files is equivalent to reviewing and trusting any other host-executed script -> in the checked-out branch. - -## Common workflows - -Run one check: - -```text -just anvil-container anvil-clippy -``` - -Run the complete pull-request tier: - -```text -just anvil-container anvil-pr -``` - -Every argument is treated as a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not supported by this command surface. - -Open an interactive Bash shell in the image: - -```text -just anvil-container -``` - -### Use containers for tier commands - -Native execution remains the default. To route tier commands such as -`just anvil-pr` through the container for the current shell: - -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` - -On Unix: - -```sh -ANVIL_RUNNER=container just anvil-pr -``` - -For one invocation: - -```text -just anvil_runner=container anvil-pr -``` - -To make container execution the repository default, change the default value -in the `anvil-runner` region of the repository-root `Justfile` from `"native"` -to `"container"` and commit that policy. Set `ANVIL_RUNNER=native` to override -the repository default for the current shell. - -Tier routing starts a nested `just` invocation. Output and exit status are -preserved, but outer `--dry-run`, dependency introspection, global options, and -CLI variable assignments are not propagated to the selected private tier. -Values other than `native` and `container` are rejected. - -## Images and caches - -The image name includes a content-based tag derived from the repository's Rust -toolchain, generated Anvil recipes, and container build configuration. A -relevant change selects a new image automatically; older branches can continue -using their matching images. - -The following data is reused between runs: - -- the matching container image; -- repository-scoped Cargo registry and Cargo Git caches; -- compilation output in a repository- and image-specific `target` volume. - -The repository is mounted read/write at `/workspace`. Build output remains in a -named volume instead of the host `target/`, avoiding incompatible artifacts and -slow host-to-virtual-machine I/O. - -## GitHub authentication - -`anvil-aprz` and aggregate tiers that include it require GitHub API -authentication. The driver uses either: - -- the host `GITHUB_TOKEN`; or -- the token from an authenticated host `gh` session. - -Trusted customization can provision a short-lived token by setting -`GITHUB_TOKEN`; the driver reads it after loading and validating customization. - -Authenticate the GitHub CLI with: - -```text -gh auth login --hostname github.com -``` - -For an aggregate tier, the driver first runs `anvil-aprz` in a short-lived -container with the token mounted read-only. After it succeeds, the driver runs -the remaining checks in another container without the token. Temporary token -files are removed afterward. - -An interactive invocation can pause while you authenticate. A non-interactive -invocation fails with instructions when authentication is unavailable. - -## Configuration - -| Variable | Effect | -|---|---| -| `ANVIL_RUNNER` | Selects `native` or `container` execution for tier commands | -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a digest-pinned compatible Linux base image and changes the content-based tag | -| `ANVIL_CONTAINER_IMAGE` | Changes the local image name; the content-based tag is retained | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails instead of building when the matching image is absent | -The public driver builds images locally and does not pull -`ANVIL_CONTAINER_IMAGE` from a registry. - -The default base is digest-pinned Debian Bookworm. Set -`ANVIL_CONTAINER_BASE_IMAGE` to another image compatible with the generated -Debian-based `Containerfile` when a lower glibc baseline is required. A -different package ecosystem such as Azure Linux requires a derived -`Containerfile`. The value must use `image@sha256:` form so the -selected base remains part of the content-addressed image identity. - -Two simultaneous cold invocations can both build the same missing image. This -is accepted for local development: the content-addressed tag converges on the -same inputs, at the cost of duplicate work. - -## Troubleshooting - -| Problem | Resolution | -|---|---| -| Docker is not found on Linux or WSL | Install Docker Engine 23.0 or newer inside that environment | -| Docker is unavailable from Windows | Run `wsl -e docker version`; install or start Docker Engine in the default WSL distribution | -| Docker requires elevated access | Add the Linux/WSL user to the `docker` group, then start a new shell | -| ARM64 execution is slow | The current image is `linux/amd64` and runs through Docker emulation | -| `linux/amd64` cannot run | Configure Docker to run `linux/amd64` images | -| `[script]` recipes are unavailable | Enable `[script]` support; older `just` versions require `set unstable` | -| `rust-toolchain.toml` is missing | Add the repository-owned toolchain file at the repository root | -| GitHub authentication is unavailable | Run `gh auth login --hostname github.com` or set host `GITHUB_TOKEN` | -| A matching image is missing with `ANVIL_CONTAINER_NO_REBUILD=1` | Unset the variable to allow the local image build | -| The first run is slow | The initial image build installs the pinned tool catalog; later runs reuse it | - -Use `docker images anvil-dev` inside Linux or WSL to list locally cached -default Anvil images. - -## Managed files - -This directory is managed by `cargo-anvil`. Regenerate it with `cargo anvil` -instead of editing its files directly. - -> [!IMPORTANT] -> These assets previously lived in `justfiles/anvil/container/`. `cargo anvil` -> relocates the files it generated, but it does not track a hand-authored -> `customize.sh` or `customize.ps1`. Move any such file to -> `.anvil/container/` yourself; the driver only loads customization from the -> new location and warns on stderr when it finds one left behind. - -## Advanced repository customization - -A repository or derived catalog can add one trusted customization file per -supported host: - -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 -``` - -The driver sources the matching file as trusted host code before authentication, -image construction, and recipe execution. The documented customization -contract provides inputs and validated outputs for APRZ classification, build -secrets, dependency preparation, runtime arguments, and cleanup. - -Customization source is excluded from image identity and the build context. -Non-secret image behavior must be represented by hashed static files such as -the `Containerfile`, entrypoint, or supporting build scripts. - -See the [container customization contract](https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md#8-container-customization) -for the complete interface and security requirements. - -=== .anvil/container/entrypoint.sh === -#!/bin/sh -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -eu - -if [ "$(id -u)" -ne 0 ]; then - if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then - HOME="/tmp/anvil-user" - export HOME - fi - - user_cargo_home="$HOME/.cargo" - mkdir -p "$user_cargo_home" - for file in config.toml .crates.toml .crates2.json; do - if [ -r "$CARGO_HOME/$file" ]; then - cp -f "$CARGO_HOME/$file" "$user_cargo_home/$file" - fi - done - export CARGO_HOME="$user_cargo_home" - ln -sfn /usr/local/cargo/registry "$CARGO_HOME/registry" - ln -sfn /usr/local/cargo/git "$CARGO_HOME/git" -fi +=== .delta.toml === +# >>> anvil-managed: anvil-delta +# Changes to these workspace-level inputs conservatively affect every package. +trip_wire_patterns = [ + ".ado/**", + ".cargo/**", + ".delta.toml", + ".editorconfig", + ".github/**", + ".pipelines/**", + "*.just", + "Cargo.lock", + "Cargo.toml", + "Justfile", + "clippy.toml", + "codecov.yml", + "constants.env", + "deny.toml", + "justfile", + "justfiles/**", + "rust-toolchain.toml", + "rustfmt.toml", + "scripts/**", + "spellcheck.toml", + "unstable-rustfmt.toml", +] +# <<< anvil-managed: anvil-delta -exec "$@" +=== .gitattributes === +# >>> anvil-managed: anvil-gitattributes +# Force LF line endings for Rust sources regardless of the checkout +# platform, so rustfmt and other tooling see consistent newlines. +*.rs text eol=lf +*.sh text eol=lf +# <<< anvil-managed: anvil-gitattributes -=== .anvil/container/image-id.ps1 === +=== .github/actions/anvil-impact/action.yml === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. # Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +name: anvil-impact +description: | + Compute the cargo-delta impact set for this PR and publish it as the + `anvil-impact-` workflow artifact. -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$inputs = @( - 'rust-toolchain.toml' -) -$toolchainPath = Join-Path $repoRoot 'rust-toolchain.toml' -if (-not (Test-Path -LiteralPath $toolchainPath -PathType Leaf)) { - throw 'anvil-container requires a repository-owned rust-toolchain.toml.' -} -$containerPath = Join-Path $repoRoot '.anvil/container' -$containerRecipe = 'justfiles/anvil/container.just' -$containerfile = Join-Path $containerPath 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } -# The container entry recipe drives execution on the host; it is not image -# content, so it must not participate in image identity. -$inputs += Get-ChildItem (Join-Path $repoRoot 'justfiles/anvil') -Recurse -File -Filter '*.just' | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } | - Where-Object { -not $_.Equals($containerRecipe, $pathComparison) } -$executionOnly = @( - 'image-id.ps1', - 'image-id.sh', - 'README.md', - 'run-in-container.ps1', - 'run-in-container.sh', - 'customize.sh', - 'customize.ps1' -) -# customize.sh/customize.ps1 are trusted runtime orchestration, not image -# content: their source must never affect the image ID or build context. -# Static, non-secret build customization belongs in a hashed artifact instead. -$inputs += Get-ChildItem $containerPath -File | - Where-Object { $_.Name -notin $executionOnly } | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } -$uniqueInputs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($inputPath in $inputs) { - [void]$uniqueInputs.Add($inputPath) -} -$inputs = [string[]]$uniqueInputs -[Array]::Sort($inputs, [StringComparer]::Ordinal) - -$payload = [Text.StringBuilder]::new() -[void]$payload.Append("ANVIL_CONTAINER_BASE_IMAGE`n").Append($baseImage).Append("`n") -foreach ($relative in $inputs) { - $path = Join-Path $repoRoot $relative - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Container image input is missing: $relative" - } - $content = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n") - [void]$payload.Append($relative).Append("`n").Append($content).Append("`n") -} - -$bytes = [Text.Encoding]::UTF8.GetBytes($payload.ToString()) -$hash = [Security.Cryptography.SHA256]::HashData($bytes) -Write-Output ([Convert]::ToHexString($hash).ToLowerInvariant()) - -=== .anvil/container/image-id.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi - -toolchain_path="$repo_root/rust-toolchain.toml" -if [[ ! -f "$toolchain_path" ]]; then - echo 'anvil-container requires a repository-owned rust-toolchain.toml.' >&2 - exit 1 -fi - -container_dir="$repo_root/.anvil/container" -container_recipe="justfiles/anvil/container.just" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$container_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -inputs=(rust-toolchain.toml) -while IFS= read -r path; do - relative="${path#"$repo_root"/}" - # The container entry recipe drives execution on the host; it is not - # image content, so it must not participate in image identity. - if [[ "$relative" != "$container_recipe" ]]; then - inputs+=("$relative") - fi -done < <(find "$repo_root/justfiles/anvil" -type f -name '*.just' -print) - -for path in "$container_dir"/*; do - [[ -f "$path" ]] || continue - case "${path##*/}" in - image-id.ps1 | image-id.sh | README.md \ - | run-in-container.ps1 | run-in-container.sh \ - | customize.sh | customize.ps1) continue ;; - esac - inputs+=("${path#"$repo_root"/}") -done - -if command -v sha256sum >/dev/null 2>&1; then - hash_command=(sha256sum) -elif command -v shasum >/dev/null 2>&1; then - hash_command=(shasum -a 256) -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi - -write_normalized_file() { - local path="$1" - local line status - while true; do - line="" - if IFS= read -r line <&3; then - status=0 - else - status=$? - fi - if ((status != 0)) && [[ -z "$line" ]]; then - break - fi - printf '%s' "${line%$'\r'}" - if ((status == 0)); then - printf '\n' - else - break - fi - done 3<"$path" -} + This runs the same `anvil-impact` recipe adopters run locally: it snapshots + the base ref and the working tree, runs `cargo delta impact`, and + writes the durable cache under `target/anvil/impact/` (the per-tier + `include_.txt` lists, `impact.json`, and the `snapshots/`). The whole + directory is uploaded so each downstream group job can download it and read + the cache exactly as a local run does -- rather than threading the include + lists through job outputs / environment variables. This keeps CI and local + execution identical by construction. -{ - printf 'ANVIL_CONTAINER_BASE_IMAGE\n%s\n' "$base_image" - while IFS= read -r relative; do - path="$repo_root/$relative" - if [[ ! -f "$path" ]]; then - echo "Container image input is missing: $relative" >&2 - exit 1 - fi - printf '%s\n' "$relative" - write_normalized_file "$path" - printf '\n' - done < <(printf '%s\n' "${inputs[@]}" | LC_ALL=C sort -u) -} | "${hash_command[@]}" | awk '{print $1}' + Computed once per OS family (see anvil-pr-impl.yml) because an + OS-conditional dependency changes the reverse-dep set only in that host's + cargo-metadata graph. +runs: + using: composite + steps: + # anvil-setup with group=none bootstraps the rust toolchain + + # just + binstall + cache, but skips the full catalog install. + # We follow it with just the cargo-delta install (the only tool + # this composite needs). This keeps the impact stage lean -- it's + # the critical-path gating dep for every PR-tier group job. + - uses: ./.github/actions/anvil-setup + with: + group: none + - name: Install cargo-delta + shell: bash + run: just anvil-tool-cargo-delta-install binstall + - name: Compute impact + shell: bash + # Run the shared anvil-impact recipe -- the same impact building block + # adopters run locally. It resolves the base ref (_anvil-base-ref), + # snapshots the base ref in a throwaway worktree and the working + # tree, runs `cargo delta impact`, and writes the cache under + # target/anvil/impact/. This is the only job that runs cargo-delta to + # compute the impact set; group jobs install it as a setup prereq (for + # local recompute-capable runs) but consume the downloaded artifact + # instead of recomputing. + run: just anvil-impact + - name: Upload impact artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + # Per-OS name so a downstream leg downloads the impact set computed on + # its own host (Linux / Windows). The two arm legs reuse their OS + # family's artifact. + name: anvil-impact-${{ runner.os }} + path: target/anvil/impact + # Match GitHub's default 30-day workflow-rerun window. Consumer group + # jobs (pr-fast, pr-slow, ...) unconditionally download this artifact + # and run under ANVIL_IMPACT=consume, so a rerun triggered within that + # window must still find the impact set. A shorter lifetime (e.g. 1 day) + # would silently cap the effective rerun window at that lifetime -- the + # download step would fail once the artifact expired even though GitHub + # still offers the rerun. The set is small (a few cache files), so the + # storage cost of the full window is negligible. + retention-days: 30 -=== .anvil/container/run-in-container.ps1 === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param( - [Parameter(Position = 0, ValueFromRemainingArguments = $true)] - [string[]]$Recipe -) - -$ErrorActionPreference = 'Stop' - -function ConvertTo-AnvilVersion([string]$Value) { - $match = [regex]::Match($Value, '^(\d+)\.(\d+)(?:\.(\d+))?') - if (-not $match.Success) { - throw "anvil-container: could not parse Docker Engine version '$Value'." - } - [version]::new( - [int]$match.Groups[1].Value, - [int]$match.Groups[2].Value, - $(if ($match.Groups[3].Success) { [int]$match.Groups[3].Value } else { 0 }) - ) -} - -function Test-AnvilContainerStringArray([string]$Name, $Value) { - if ($Value -isnot [array]) { - throw "anvil-container: `$$Name must be a string array." - } - foreach ($item in $Value) { - if ($item -isnot [string] -or [string]::IsNullOrEmpty($item)) { - throw "anvil-container: `$$Name entries must be non-empty strings." - } - } -} - -function Test-AnvilContainerBuildArgs($Value) { - for ($index = 0; $index -lt $Value.Count; $index++) { - $item = $Value[$index] - if ($item -eq '--secret') { - $index++ - if ($index -ge $Value.Count) { - throw 'anvil-container: $AnvilContainerBuildArgs requires a value after --secret.' - } - } elseif (-not $item.StartsWith('--secret=', [StringComparison]::Ordinal)) { - throw 'anvil-container: $AnvilContainerBuildArgs accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' - } - } -} - -function Test-AnvilRecipeNeedsGitHubToken([string]$Name) { - $Name -in @( - 'anvil-aprz', - 'anvil-scheduled', - '_anvil-scheduled', - 'anvil-scheduled-advisories', - '_anvil-scheduled-advisories', - 'anvil-full', - '_anvil-full' - ) -} - -function Get-AnvilGitHubToken { - $token = $env:GITHUB_TOKEN - if (-not $token -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { - $token = (& gh auth token --hostname github.com 2>$null) - if ($LASTEXITCODE -ne 0) { $token = $null } - } catch { - $token = $null - } - } - if ($token) { $token = $token.Trim() } - if ($token) { return $token } - return $null -} - -if ($env:ANVIL_IN_CONTAINER) { - if ($Recipe.Count -eq 0) { & bash } else { & just @Recipe } - exit $LASTEXITCODE -} - -foreach ($recipeArg in $Recipe) { - if ($recipeArg -notmatch '^_?anvil-[A-Za-z0-9-]+$') { - throw "anvil-container: expected each argument to be an anvil-* recipe, got '$recipeArg'." - } -} - -if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { - throw 'anvil-container: WSL 2 is required. See .anvil/container/README.md.' -} - -$versionText = (& wsl -e docker version --format '{{.Server.Version}}' 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $versionText) { - throw 'anvil-container: `wsl -e docker version` must succeed. Install or start Docker Engine in the default WSL distribution; this driver does not invoke Windows docker.exe.' -} -$versionText = $versionText.Trim() -if ((ConvertTo-AnvilVersion $versionText) -lt [version]'23.0.0') { - throw "anvil-container: Docker Engine 23.0.0 or newer is required (found $versionText)." -} -$wslArchitecture = (& wsl -e uname -m 2>$null) -if ($LASTEXITCODE -eq 0 -and $wslArchitecture) { - $wslArchitecture = $wslArchitecture.Trim() - if ($wslArchitecture -notin @('x86_64', 'amd64')) { - [Console]::Error.WriteLine( - "anvil-container: warning: $wslArchitecture requires emulation for linux/amd64; builds and checks may be substantially slower." - ) - } -} - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$scriptDir = Join-Path $repoRoot '.anvil/container' -$wslRepoRoot = (& wsl -e wslpath -a $repoRoot).Trim() -if ($LASTEXITCODE -ne 0 -or -not $wslRepoRoot) { - throw 'anvil-container: could not translate the repository path into the default WSL distribution.' -} -$wslScriptDir = "$wslRepoRoot/.anvil/container" -$containerfile = Join-Path $scriptDir 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$imageId = (& (Join-Path $scriptDir 'image-id.ps1')).Trim() -$imageBase = if ($env:ANVIL_CONTAINER_IMAGE) { $env:ANVIL_CONTAINER_IMAGE } else { 'anvil-dev' } -$image = "${imageBase}:$imageId" -$repoBytes = [Text.Encoding]::UTF8.GetBytes($wslRepoRoot) -$repoHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($repoBytes)).ToLowerInvariant() -$targetVolume = "anvil-target-$($repoHash.Substring(0, 12))-$($imageId.Substring(0, 12))" - -$needsGitHubToken = $false -foreach ($recipeArg in $Recipe) { - if (Test-AnvilRecipeNeedsGitHubToken $recipeArg) { - $needsGitHubToken = $true - break - } -} -$runsOnlyGitHubCheck = $Recipe.Count -eq 1 -and $Recipe[0] -eq 'anvil-aprz' - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -$null = & wsl -e docker image inspect $image 2>$null -$imageExists = $LASTEXITCODE -eq 0 - -New-Variable -Name AnvilContainerRepoRoot -Value $repoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDir -Value $scriptDir -Option ReadOnly -New-Variable -Name AnvilContainerRepoRootWsl -Value $wslRepoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDirWsl -Value $wslScriptDir -Option ReadOnly -New-Variable -Name AnvilContainerResolvedImage -Value $image -Option ReadOnly -New-Variable -Name AnvilContainerImageExists -Value $imageExists -Option ReadOnly -New-Variable -Name AnvilContainerRequestedRecipes -Value $Recipe -Option ReadOnly -New-Variable -Name AnvilContainerHostIsWindows -Value ([bool]$IsWindows) -Option ReadOnly - -# Customization outputs, initialized before sourcing so a missing customize.ps1 -# leaves every phase a documented no-op. -$AnvilContainerBuildArgs = @() -$AnvilContainerPrepareArgs = @() -$AnvilContainerPrepareCommand = @() -$AnvilContainerRunArgs = @() -$AnvilContainerNeedsGitHubToken = $needsGitHubToken -$AnvilContainerCleanup = $null -$githubToken = $null -$githubTokenFile = $null -$exitCode = 0 -$customizeScript = Join-Path $scriptDir 'customize.ps1' -$legacyCustomizeScript = Join-Path $repoRoot 'justfiles/anvil/container/customize.ps1' - -try { - if (Test-Path -LiteralPath $customizeScript -PathType Leaf) { - . $customizeScript - } - elseif (Test-Path -LiteralPath $legacyCustomizeScript -PathType Leaf) { - [Console]::Error.WriteLine( - 'anvil-container: warning: ignoring justfiles/anvil/container/customize.ps1; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.ps1 to keep it active.' - ) - } - - Test-AnvilContainerStringArray 'AnvilContainerBuildArgs' $AnvilContainerBuildArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareArgs' $AnvilContainerPrepareArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareCommand' $AnvilContainerPrepareCommand - Test-AnvilContainerStringArray 'AnvilContainerRunArgs' $AnvilContainerRunArgs - Test-AnvilContainerBuildArgs $AnvilContainerBuildArgs - if ($AnvilContainerNeedsGitHubToken -isnot [bool]) { - throw 'anvil-container: $AnvilContainerNeedsGitHubToken must be a Boolean.' - } - $needsGitHubToken = $needsGitHubToken -or $AnvilContainerNeedsGitHubToken - if ($AnvilContainerPrepareArgs.Count -gt 0 -and $AnvilContainerPrepareCommand.Count -eq 0) { - throw 'anvil-container: $AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand.' - } - if ($AnvilContainerCleanup -and $AnvilContainerCleanup -isnot [scriptblock]) { - throw 'anvil-container: $AnvilContainerCleanup must be a script block.' - } - $githubToken = if ($needsGitHubToken) { Get-AnvilGitHubToken } else { $null } - if ($needsGitHubToken -and -not $githubToken) { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' - } - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' - } - Write-Host 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' - [void](Read-Host 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel)') - $githubToken = Get-AnvilGitHubToken - if (-not $githubToken) { - throw 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' - } - } - if (-not $imageExists) { - if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { - throw "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." - } - & wsl -e docker build ` - --platform linux/amd64 ` - --tag $image ` - --file "$wslScriptDir/Containerfile" ` - --build-arg "ANVIL_IMAGE_ID=$imageId" ` - --build-arg "BASE_IMAGE=$baseImage" ` - @AnvilContainerBuildArgs ` - $wslRepoRoot - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker build failed with exit code $LASTEXITCODE." - } - } - - $containerUid = (& wsl -e id -u).Trim() - $containerGid = (& wsl -e id -g).Trim() - if ($containerUid -notmatch '^\d+$' -or $containerGid -notmatch '^\d+$') { - throw 'anvil-container: could not determine the default WSL user identity.' - } - $registryVolume = "anvil-cargo-registry-$($repoHash.Substring(0, 12))" - $gitVolume = "anvil-cargo-git-$($repoHash.Substring(0, 12))" - foreach ($volume in @($registryVolume, $gitVolume, $targetVolume)) { - $null = & wsl -e docker volume create $volume - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume creation failed for '$volume' with exit code $LASTEXITCODE." - } - } - $mountArgs = @( - '--mount', "type=bind,source=$wslRepoRoot,target=/workspace", - '--mount', "type=volume,source=$registryVolume,target=/usr/local/cargo/registry", - '--mount', "type=volume,source=$gitVolume,target=/usr/local/cargo/git", - '--mount', "type=volume,source=$targetVolume,target=/workspace/target" - ) - & wsl -e docker run --rm --pull=never ` - --platform linux/amd64 ` - --user 0:0 ` - @mountArgs ` - $image sh -c "chown ${containerUid}:${containerGid} /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume initialization failed with exit code $LASTEXITCODE." - } - - $runArgs = @( - 'run', '--rm', '--pull=never', - '--platform', 'linux/amd64', - '--user', "${containerUid}:${containerGid}", - '--env', 'ANVIL_IN_CONTAINER=1', - '--env', 'HOME=/tmp/anvil-user', - '--workdir', '/workspace' - ) - $runArgs += $mountArgs - $prepareRunArgs = @($runArgs) - $runArgs += $AnvilContainerRunArgs - foreach ($name in @( - 'PR_TITLE', - 'BASE_REF', - 'ANVIL_IMPACT', - 'GITHUB_BASE_REF', - 'SYSTEM_PULLREQUEST_TARGETBRANCH' - )) { - if (Test-Path "Env:$name") { - $runArgs += @('--env', "$name=$((Get-Item "Env:$name").Value)") - } - } - if ($AnvilContainerPrepareCommand.Count -gt 0) { - & wsl -e docker @prepareRunArgs @AnvilContainerPrepareArgs $image @AnvilContainerPrepareCommand - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: preparation command failed with exit code $LASTEXITCODE." - } - } - - if ($githubToken) { - $githubTokenFile = Join-Path ([IO.Path]::GetTempPath()) "anvil-github-token-$PID-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Create($githubTokenFile).Dispose() - if ($IsWindows) { - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls.exe $githubTokenFile '/inheritance:r' '/grant:r' "*$($userSid):(F)" | Out-Null - } else { - & chmod 600 $githubTokenFile - } - if ($LASTEXITCODE -ne 0) { - throw 'anvil-container: failed to restrict permissions on the temporary GitHub token file.' - } - [IO.File]::WriteAllText($githubTokenFile, $githubToken, [Text.Encoding]::ASCII) - $githubToken = $null - $wslTokenFile = (& wsl -e wslpath -a $githubTokenFile).Trim() - if ($LASTEXITCODE -ne 0 -or -not $wslTokenFile) { - throw 'anvil-container: could not translate the temporary GitHub token path into WSL.' - } - $githubRunArgs = @($runArgs) - $githubRunArgs += @( - '--mount', - "type=bind,source=$wslTokenFile,target=/run/secrets/anvil-github-token,readonly" - ) - if ($runsOnlyGitHubCheck) { - $runArgs = $githubRunArgs - } else { - & wsl -e docker @githubRunArgs $image just anvil-aprz - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: isolated anvil-aprz failed with exit code $LASTEXITCODE." - } - $runArgs += @('--env', 'ANVIL_APRZ_ALREADY_RAN=1') - } - } - - if ($Recipe.Count -eq 0) { - & wsl -e docker @runArgs --interactive --tty $image bash - } else { - & wsl -e docker @runArgs $image just @Recipe - } - $exitCode = $LASTEXITCODE -} finally { - if ($githubTokenFile) { - Remove-Item -LiteralPath $githubTokenFile -Force -ErrorAction SilentlyContinue - } - if ($AnvilContainerCleanup) { & $AnvilContainerCleanup } -} - -exit $exitCode - -=== .anvil/container/run-in-container.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - if (($# == 0)); then exec bash; else exec just "$@"; fi -fi - -for recipe_arg in "$@"; do - if [[ ! "$recipe_arg" =~ ^_?anvil-[A-Za-z0-9-]+$ ]]; then - echo "anvil-container: expected each argument to be an anvil-* recipe, got '$recipe_arg'." >&2 - exit 2 - fi -done - -anvil_recipe_needs_github_token() { - case "$1" in - anvil-aprz | anvil-scheduled | _anvil-scheduled | anvil-scheduled-advisories | _anvil-scheduled-advisories \ - | anvil-full | _anvil-full) return 0 ;; - *) return 1 ;; - esac -} - -version_at_least() { - local found="${1%%[-+]*}" - local required="${2%%[-+]*}" - local found_major found_minor found_patch found_extra - local required_major required_minor required_patch required_extra - IFS=. read -r found_major found_minor found_patch found_extra <<<"$found" - IFS=. read -r required_major required_minor required_patch required_extra <<<"$required" - found_patch="${found_patch:-0}" - required_patch="${required_patch:-0}" - for component in \ - "$found_major" "$found_minor" "$found_patch" \ - "$required_major" "$required_minor" "$required_patch" - do - case "$component" in - '' | *[!0-9]*) return 2 ;; - esac - done - if ((found_major != required_major)); then ((found_major > required_major)); return; fi - if ((found_minor != required_minor)); then ((found_minor > required_minor)); return; fi - ((found_patch >= required_patch)) -} - -command -v docker >/dev/null 2>&1 || { - echo "anvil-container: Docker Engine is required. See .anvil/container/README.md." >&2 - exit 1 -} - -version="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" || { - echo "anvil-container: Docker Engine is unavailable. Start the Docker service and ensure the current user can access it." >&2 - exit 1 -} -minimum="23.0.0" -if ! version_at_least "$version" "$minimum"; then - echo "anvil-container: Docker Engine $minimum or newer is required (found $version)." >&2 - exit 1 -fi -host_arch="$(uname -m 2>/dev/null || true)" -case "$host_arch" in - x86_64 | amd64 | '') ;; - *) echo "anvil-container: warning: $host_arch requires emulation for linux/amd64; builds and checks may be substantially slower." >&2 ;; -esac - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi -script_dir="$repo_root/.anvil/container" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$script_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -image_id="$(bash "$script_dir/image-id.sh")" -image_base="${ANVIL_CONTAINER_IMAGE:-anvil-dev}" -image="${image_base}:${image_id}" -if command -v sha256sum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | sha256sum | cut -c1-12)" -elif command -v shasum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | shasum -a 256 | cut -c1-12)" -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi -target_volume="anvil-target-${repo_id}-${image_id:0:12}" - -needs_github_token=false -for recipe_arg in "$@"; do - if anvil_recipe_needs_github_token "$recipe_arg"; then - needs_github_token=true - break - fi -done -runs_only_github_check=false -if (($# == 1)) && [[ "$1" == "anvil-aprz" ]]; then - runs_only_github_check=true -fi -github_token="" - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -if docker image inspect "$image" >/dev/null 2>&1; then - image_exists=true -else - image_exists=false -fi - -readonly ANVIL_CONTAINER_REPO_ROOT="$repo_root" -readonly ANVIL_CONTAINER_DIR="$script_dir" -readonly ANVIL_CONTAINER_RESOLVED_IMAGE="$image" -readonly ANVIL_CONTAINER_IMAGE_EXISTS="$image_exists" -declare -a ANVIL_CONTAINER_REQUESTED_RECIPES=("$@") -readonly ANVIL_CONTAINER_REQUESTED_RECIPES - -# Customization outputs, initialized before sourcing so a missing customize.sh -# leaves every phase a documented no-op. -ANVIL_CONTAINER_BUILD_ARGS=() -ANVIL_CONTAINER_PREPARE_ARGS=() -ANVIL_CONTAINER_PREPARE_COMMAND=() -ANVIL_CONTAINER_RUN_ARGS=() -ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN="$needs_github_token" -ANVIL_CONTAINER_CLEANUP=: -github_token_file="" -cleanup() { - if [[ -n "$github_token_file" ]]; then rm -f -- "$github_token_file"; fi - "$ANVIL_CONTAINER_CLEANUP" -} -trap cleanup EXIT - -customize_script="$script_dir/customize.sh" -legacy_customize_script="$repo_root/justfiles/anvil/container/customize.sh" -if [[ ! -f "$customize_script" && -f "$legacy_customize_script" ]]; then - echo "anvil-container: warning: ignoring justfiles/anvil/container/customize.sh; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.sh to keep it active." >&2 -fi -if [[ -f "$customize_script" ]]; then - # shellcheck source=/dev/null - source "$customize_script" -fi - -# Bash 3.2 has neither namerefs (the nameref flag on `local`/`declare`, Bash -# 4.3+) nor safe `set -u` expansion of empty-but- -# declared arrays (fixed in Bash 4.4). Elements are passed positionally -# instead of by nameref, and every expansion of a possibly-empty array uses -# the `${arr[@]+"${arr[@]}"}` idiom: unset/empty-under-old-Bash arrays vanish -# entirely instead of raising "unbound variable", while non-empty arrays -# still expand element-for-element. -anvil_container_validate_array() { - local name="$1" - shift - local declaration value - declaration="$(declare -p "$name" 2>/dev/null || true)" - if [[ ! "$declaration" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ ]]; then - echo "anvil-container: $name must be a string array." >&2 - exit 1 - fi - for value in "$@"; do - if [[ -z "$value" ]]; then - echo "anvil-container: $name entries must be non-empty strings." >&2 - exit 1 - fi - done -} -anvil_container_validate_build_args() { - local expect_secret_value=false value - for value in "$@"; do - if "$expect_secret_value"; then - expect_secret_value=false - continue - fi - case "$value" in - --secret) expect_secret_value=true ;; - --secret=*) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' >&2 - exit 1 - ;; - esac - done - if "$expect_secret_value"; then - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS requires a value after --secret.' >&2 - exit 1 - fi -} -anvil_container_validate_array ANVIL_CONTAINER_BUILD_ARGS ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_ARGS ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_COMMAND ${ANVIL_CONTAINER_PREPARE_COMMAND[@]+"${ANVIL_CONTAINER_PREPARE_COMMAND[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_RUN_ARGS ${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"} -anvil_container_validate_build_args ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -case "$ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN" in - true) needs_github_token=true ;; - false) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN must be true or false.' >&2 - exit 1 - ;; -esac -if ((${#ANVIL_CONTAINER_PREPARE_ARGS[@]} > 0)) && ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} == 0)); then - echo 'anvil-container: ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND.' >&2 - exit 1 -fi -cleanup_kind="$(type -t "$ANVIL_CONTAINER_CLEANUP" 2>/dev/null || true)" -if [[ "$cleanup_kind" != "function" && "$cleanup_kind" != "builtin" ]]; then - echo "anvil-container: ANVIL_CONTAINER_CLEANUP must name a callable function (got '$ANVIL_CONTAINER_CLEANUP')." >&2 - exit 1 -fi - -if "$needs_github_token"; then - gh_command="" - if command -v gh >/dev/null 2>&1; then - gh_command=gh - elif command -v gh.exe >/dev/null 2>&1; then - gh_command=gh.exe - fi - github_token="${GITHUB_TOKEN:-}" - if [[ -z "$github_token" && -n "$gh_command" ]]; then - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - fi - if [[ -z "$github_token" ]]; then - if [[ -z "$gh_command" ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - if [[ ! -t 0 ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - echo 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' >&2 - read -r -p 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel) ' - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - if [[ -z "$github_token" ]]; then - echo 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' >&2 - exit 1 - fi - fi -fi - -if ! "$image_exists"; then - if [[ "${ANVIL_CONTAINER_NO_REBUILD:-}" == "1" ]]; then - echo "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." >&2 - exit 1 - else - docker build \ - --platform linux/amd64 \ - --tag "$image" \ - --file "$script_dir/Containerfile" \ - --build-arg "ANVIL_IMAGE_ID=$image_id" \ - --build-arg "BASE_IMAGE=$base_image" \ - ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} \ - "$repo_root" - fi -fi - -container_uid="$(id -u)" -container_gid="$(id -g)" -registry_volume="anvil-cargo-registry-${repo_id}" -git_volume="anvil-cargo-git-${repo_id}" -for volume in "$registry_volume" "$git_volume" "$target_volume"; do - docker volume create "$volume" >/dev/null -done -mount_args=( - --mount "type=bind,source=$repo_root,target=/workspace" - --mount "type=volume,source=$registry_volume,target=/usr/local/cargo/registry" - --mount "type=volume,source=$git_volume,target=/usr/local/cargo/git" - --mount "type=volume,source=$target_volume,target=/workspace/target" -) -docker run --rm --pull=never \ - --platform linux/amd64 \ - --user 0:0 \ - "${mount_args[@]}" \ - "$image" sh -c \ - "chown $container_uid:$container_gid /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - -run_args=( - run --rm --pull=never - --platform linux/amd64 - --user "$container_uid:$container_gid" - --env ANVIL_IN_CONTAINER=1 - --env HOME=/tmp/anvil-user - "${mount_args[@]}" - --workdir /workspace -) -prepare_run_args=("${run_args[@]}") -run_args+=(${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"}) -for name in PR_TITLE BASE_REF ANVIL_IMPACT GITHUB_BASE_REF SYSTEM_PULLREQUEST_TARGETBRANCH; do - if value="$(printenv "$name")"; then run_args+=(--env "$name=$value"); fi -done -if ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} > 0)); then - docker "${prepare_run_args[@]}" \ - ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} \ - "$image" \ - "${ANVIL_CONTAINER_PREPARE_COMMAND[@]}" -fi - -if [[ -n "$github_token" ]]; then - github_token_file="$(mktemp "${TMPDIR:-/tmp}/anvil-github-token.XXXXXXXX")" - chmod 600 "$github_token_file" - printf '%s' "$github_token" > "$github_token_file" - unset github_token - github_run_args=( - "${run_args[@]}" - --mount "type=bind,source=$github_token_file,target=/run/secrets/anvil-github-token,readonly" - ) - if "$runs_only_github_check"; then - run_args=("${github_run_args[@]}") - else - docker "${github_run_args[@]}" "$image" just anvil-aprz - run_args+=(--env ANVIL_APRZ_ALREADY_RAN=1) - fi -fi - -if (($# == 0)); then - docker "${run_args[@]}" --interactive --tty "$image" bash - exit $? -fi -docker "${run_args[@]}" "$image" just "$@" - -=== .delta.toml === -# >>> anvil-managed: anvil-delta -# Changes to these workspace-level inputs conservatively affect every package. -trip_wire_patterns = [ - ".ado/**", - ".cargo/**", - ".delta.toml", - ".editorconfig", - ".github/**", - ".pipelines/**", - "*.just", - "Cargo.lock", - "Cargo.toml", - "Justfile", - "clippy.toml", - "codecov.yml", - "constants.env", - "deny.toml", - "justfile", - "justfiles/**", - "rust-toolchain.toml", - "rustfmt.toml", - "scripts/**", - "spellcheck.toml", - "unstable-rustfmt.toml", -] -# <<< anvil-managed: anvil-delta - -=== .gitattributes === -# >>> anvil-managed: anvil-gitattributes -# Force LF line endings for Rust sources regardless of the checkout -# platform, so rustfmt and other tooling see consistent newlines. -*.rs text eol=lf -*.sh text eol=lf -# <<< anvil-managed: anvil-gitattributes - -=== .github/actions/anvil-impact/action.yml === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. -# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md -name: anvil-impact -description: | - Compute the cargo-delta impact set for this PR and publish it as the - `anvil-impact-` workflow artifact. - - This runs the same `anvil-impact` recipe adopters run locally: it snapshots - the base ref and the working tree, runs `cargo delta impact`, and - writes the durable cache under `target/anvil/impact/` (the per-tier - `include_.txt` lists, `impact.json`, and the `snapshots/`). The whole - directory is uploaded so each downstream group job can download it and read - the cache exactly as a local run does -- rather than threading the include - lists through job outputs / environment variables. This keeps CI and local - execution identical by construction. - - Computed once per OS family (see anvil-pr-impl.yml) because an - OS-conditional dependency changes the reverse-dep set only in that host's - cargo-metadata graph. -runs: - using: composite - steps: - # anvil-setup with group=none bootstraps the rust toolchain + - # just + binstall + cache, but skips the full catalog install. - # We follow it with just the cargo-delta install (the only tool - # this composite needs). This keeps the impact stage lean -- it's - # the critical-path gating dep for every PR-tier group job. - - uses: ./.github/actions/anvil-setup - with: - group: none - - name: Install cargo-delta - shell: bash - run: just anvil-tool-cargo-delta-install binstall - - name: Compute impact - shell: bash - # Run the shared anvil-impact recipe -- the same impact building block - # adopters run locally. It resolves the base ref (_anvil-base-ref), - # snapshots the base ref in a throwaway worktree and the working - # tree, runs `cargo delta impact`, and writes the cache under - # target/anvil/impact/. This is the only job that runs cargo-delta to - # compute the impact set; group jobs install it as a setup prereq (for - # local recompute-capable runs) but consume the downloaded artifact - # instead of recomputing. - run: just anvil-impact - - name: Upload impact artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - # Per-OS name so a downstream leg downloads the impact set computed on - # its own host (Linux / Windows). The two arm legs reuse their OS - # family's artifact. - name: anvil-impact-${{ runner.os }} - path: target/anvil/impact - # Match GitHub's default 30-day workflow-rerun window. Consumer group - # jobs (pr-fast, pr-slow, ...) unconditionally download this artifact - # and run under ANVIL_IMPACT=consume, so a rerun triggered within that - # window must still find the impact set. A shorter lifetime (e.g. 1 day) - # would silently cap the effective rerun window at that lifetime -- the - # download step would fail once the artifact expired even though GitHub - # still offers the rerun. The set is small (a few cache files), so the - # storage cost of the full window is negligible. - retention-days: 30 - -=== .github/actions/anvil-report-status/action.yml === +=== .github/actions/anvil-report-status/action.yml === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. @@ -2454,10 +1417,6 @@ clippy.wildcard_imports = "allow" import 'justfiles/anvil/mod.just' # <<< anvil-managed: anvil-imports -# >>> anvil-managed: anvil-runner -anvil_runner := env_var_or_default("ANVIL_RUNNER", "native") -# <<< anvil-managed: anvil-runner - === clippy.toml === # >>> anvil-managed: anvil-clippy # Fine-tuning settings for clippy lints. These cannot be expressed in @@ -2555,13 +1514,15 @@ unknown-git = "deny" # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md # cargo-aprz queries the GitHub advisory API. Unauthenticated access is -# capped at 60 requests/hour and fails on a full run; an authenticated -# token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Container drivers mount an existing host GITHUB_TOKEN -# or the host gh CLI's stored token as a temporary read-only secret. -# Native runs borrow the gh CLI token directly. Native runs warn and -# proceed unauthenticated if neither is available; container runs fail -# before cargo-aprz can exhaust the unauthenticated rate limit. +# capped at 60 requests an hour, and on a full workspace it exhausts that +# and then waits for the quota to reset rather than failing; an +# authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -2569,26 +1530,16 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if ($env:ANVIL_APRZ_ALREADY_RAN -eq '1') { - Write-Host 'anvil-aprz: already completed in an isolated authenticated container' - exit 0 - } if (-not $env:GITHUB_TOKEN) { $tok = $null - $containerTokenFile = '/run/secrets/anvil-github-token' - if ($env:ANVIL_IN_CONTAINER -and (Test-Path -LiteralPath $containerTokenFile -PathType Leaf)) { - try { $tok = Get-Content -LiteralPath $containerTokenFile -Raw } catch { $tok = $null } - } elseif (Get-Command gh -ErrorAction SilentlyContinue) { + if (Get-Command gh -ErrorAction SilentlyContinue) { try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } } if ($tok) { $env:GITHUB_TOKEN = $tok.Trim() } else { - if ($env:ANVIL_IN_CONTAINER) { - throw 'anvil-aprz: GitHub authentication is unavailable. Run `gh auth login` on the host or set host GITHUB_TOKEN, then re-run the container command.' - } Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API (60 requests/hour) and may fail on a full run.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' } } @@ -3851,7 +2802,14 @@ anvil-mutants-diff: anvil-mutants-diff-validate-prereqs anvil-impact if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' - git diff "$base..HEAD" --output=$diff_path + # Diff the base against the WORKING TREE, not against HEAD. + # cargo-mutants validates every line of the diff against the file on + # disk and aborts when they disagree, so a commit-to-commit diff fails + # the moment anything is uncommitted -- which is the normal local state, + # since the point of running a tier locally is to check work in progress. + # CI has a clean tree, so the two forms are identical there and this is + # not a behaviour change for it. + git diff "$base" --output=$diff_path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -4277,202 +3235,1242 @@ anvil-semver-check: anvil-semver-check-validate-prereqs anvil-impact $inconclusive.Add('```') | Out-Null $inconclusive.Add('') | Out-Null } - } elseif ($semverExit -ne 0) { - Write-Warning "anvil-semver-check: $p failed with unexpected cargo-semver-checks exit code ${semverExit}:`n$output" - $inconclusive.Add('#### `' + $p + '` (exit ' + $semverExit + ')') | Out-Null - $inconclusive.Add('') | Out-Null - $inconclusive.Add('```') | Out-Null - foreach ($line in ($output.TrimEnd() -split "`r?`n")) { - $inconclusive.Add($line.TrimEnd()) | Out-Null + } elseif ($semverExit -ne 0) { + Write-Warning "anvil-semver-check: $p failed with unexpected cargo-semver-checks exit code ${semverExit}:`n$output" + $inconclusive.Add('#### `' + $p + '` (exit ' + $semverExit + ')') | Out-Null + $inconclusive.Add('') | Out-Null + $inconclusive.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $inconclusive.Add($line.TrimEnd()) | Out-Null + } + $inconclusive.Add('```') | Out-Null + $inconclusive.Add('') | Out-Null + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0 -or $inconclusive.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: SemVer check advisory') | Out-Null + if ($findings.Count -gt 0) { + $lines.Add('') | Out-Null + $lines.Add('### Potential breaking changes') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + } + if ($inconclusive.Count -gt 0) { + $lines.Add('') | Out-Null + $lines.Add('### Inconclusive comparisons') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` could not complete the following comparisons. These failures are **informational** because an unbuildable baseline is not evidence of a breaking API change.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $inconclusive) { $lines.Add($f) | Out-Null } + } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # cargo-semver-checks findings and operational failures are advisory. + exit 0 + +# Install prerequisites for the `anvil-semver-check` recipe. +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +# Validate prerequisites for the `anvil-semver-check` recipe. +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +=== justfiles/anvil/checks/spellcheck.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. +# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md + +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md + +# Unscoped. +# +# cargo-spellcheck checks the whole workspace and reads a repo-root `.spelling` +# dictionary -- repo-level inputs that cargo-delta does not map to any cargo +# package -- so this check cannot be safely impact-scoped and always runs. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# We always (re)generate `target/spelling.dic` so that a spellcheck.toml +# pointing at it never fails on a missing file -- if `.spelling` is +# absent we emit an empty dictionary (count `0`, no words) rather than +# leaving the path dangling. + +# Check spelling in source comments and documentation. +[script("pwsh", "-NoProfile")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + $output_file = 'target/spelling.dic' + $filtered_lines = @() + if (Test-Path '.spelling') { + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = @($lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' }) + } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# cargo-spellcheck has a source-build-time libclang dependency. The +# catalog installer checks it only immediately before a source install; +# successful prebuilt installation does not require libclang. + +# Install prerequisites for the `anvil-spellcheck` recipe. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": (anvil-tool-cargo-spellcheck-install installer) + +# Validate prerequisites for the `anvil-spellcheck` recipe. +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +=== justfiles/anvil/checks/udeps.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. +# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md + +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Run twice, because cargo-udeps only analyzes the targets it is told +# to, and each invocation catches a variant the other masks: +# 1. default targets (lib + bins): catches a dep in `[dependencies]` +# that is referenced only by tests/benches/examples -- it belongs +# in `[dev-dependencies]`. Under `--all-targets` such a dep looks +# "used" (the test/bench/example target satisfies the lookup), so +# this run is the only one that surfaces it. +# 2. `--all-targets`: catches unused `[dev-dependencies]` -- which the +# default-targets run never compiles, so it is the only one that +# surfaces those. +# A genuinely-unused dep is caught by both; the two together cover all +# of {unused dep, unused dev-dep, dep that should be a dev-dep}. + +# Check required workspace packages for unused dependencies. +[script("pwsh", "-NoProfile")] +anvil-udeps: anvil-udeps-validate-prereqs anvil-impact + $ErrorActionPreference = 'Stop' + $include = (& "{{ just_executable() }}" _anvil-impact-include required) + if ($include -eq '--skip') { Write-Host 'anvil-udeps: no affected packages; skipping'; exit 0 } + $pkg = @(if ($include) { -split $include } else { '--workspace' }) + # Pass 1: default targets (lib + bins) -- surfaces [dependencies] that + # are only used by tests/benches/examples (should be dev-deps). + & cargo '+{{ rust_nightly }}' udeps @pkg --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Pass 2: --all-targets -- surfaces unused [dev-dependencies]. + & cargo '+{{ rust_nightly }}' udeps @pkg --all-features --all-targets + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Install prerequisites for the `anvil-udeps` recipe. +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +# Validate prerequisites for the `anvil-udeps` recipe. +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +=== justfiles/anvil/container.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. +# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +# +# Containerized execution. `just anvil-container ` runs the given +# argv inside a pinned Linux image; everything else keeps running natively. +# Anvil recipes are reached by naming `just`, like any other command. +# There is no configuration file and no transparent routing: the container is +# reached through this recipe or not at all. +# +# The image tag *is* a hash of the inputs that define it, so the presence of a +# tag is proof that its contents are current -- a changed tool pin names a tag +# that cannot already exist, and a build follows. There is nothing to keep in +# sync and no staleness to detect. +# +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md + +# The container engine, `docker` or `podman`. A host property, never +# committed. Set it in your environment: a `just anvil_container_engine=...` +# override would not reach the nested invocations that resolve the engine. +anvil_container_engine := env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker") + +# Where the repository is mounted inside the container. +anvil_container_workdir := "/workspace" + +# Image and cache-volume prefix, derived from the repository directory name. +# Two checkouts with the same directory name share cache volumes; that is +# harmless (the caches are content-addressed by cargo) but worth knowing before +# `anvil-container-down` removes volumes another checkout is also using. +# +# Every run of non-alphanumerics collapses to a single `-`, and a trailing one +# is trimmed, because a repository name may not end in a separator or repeat +# `.`/`_`. Without that, a checkout in `ox-tools (copy)` yields a reference the +# engine rejects as malformed, from a directory name nobody would suspect. +anvil_container_name := trim_end_matches("anvil-" + replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-"), "-") + +# Resolve how to invoke the engine, as a pipe-separated command. +# +# There is deliberately no probe *between* engines: presence is not +# reachability, and a silent choice between two installed engines means two +# image stores and an unexplained rebuild. We check that the requested binary +# exists and let every other failure surface the engine's own diagnostic, which +# is more accurate than anything repeated here. +# +# The one fallback is Windows-specific and unambiguous: when the engine is not +# on the Windows PATH, try it inside the default WSL distribution. Installing +# Docker in WSL without Docker Desktop is a documented, common setup, and it +# leaves no Windows CLI behind, so without this fallback a correctly installed +# engine would be unreachable. Docker Desktop and Podman both ship a Windows +# CLI and are found on PATH, so they never take this path. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-engine: + $ErrorActionPreference = 'Stop' + $engine = '{{ replace(anvil_container_engine, "'", "''") }}' + if ($engine -ne 'docker' -and $engine -ne 'podman') { + Write-Error "anvil: ANVIL_CONTAINER_ENGINE must be 'docker' or 'podman', got '$engine'" + exit 1 + } + if (Get-Command $engine -ErrorAction SilentlyContinue) { + Write-Output $engine + exit 0 + } + # --exec, not --: `wsl.exe -- ` hands the rest of the command line to + # the distribution's default shell, which expands $NAME, splits on ;, and + # eats backslashes. Every argument we forward -- the repository path and + # the recipe's own arguments -- would cross that boundary unquoted. + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $engine --version *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "wsl.exe|--exec|$engine" + exit 0 + } + } + Write-Error "anvil: '$engine' was not found on PATH, and is not usable in the default WSL distribution. Install it, or set ANVIL_CONTAINER_ENGINE to the other engine. Setup: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md" + exit 1 + +# Translate a host path into what the engine sees. +# +# Identical when the engine runs on this host. When it runs in WSL, a Windows +# path has to become its /mnt/... form or the daemon silently bind-mounts an +# empty directory -- a failure that surfaces much later, as a missing file +# inside the container. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-path host_path: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + if (-not $engine.StartsWith('wsl.exe|')) { + Write-Output '{{ replace(host_path, "'", "''") }}' + exit 0 + } + # --exec for the reason given above. It matters most here: through a shell, + # a path holding `$` loses it, and `wslpath -a` then makes the *truncated* + # path absolute and exits 0, so the guard below never fires and the wrong + # directory is bind-mounted. + $hostPath = '{{ replace(host_path, "'", "''") }}' + $translated = & wsl.exe --exec wslpath -a -u $hostPath + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: could not translate '$hostPath' for the engine running in WSL" + exit 1 + } + Write-Output $translated.Trim() + +# Verify the composed Dockerfile is present under the name the build uses. +# +# The engine derives the ignore file's name from the Dockerfile's: BuildKit +# reads `.dockerignore` and there is no flag to point it elsewhere. +# Anvil owns that artifact at the fixed path `.anvil/container/Dockerfile.dockerignore`, +# so the two names have to agree, and only one of them can move. A case variant +# is therefore refused rather than accommodated: building from `dockerfile` +# would silently use no ignore file at all, streaming the whole worktree into +# the build context and admitting inputs the tag does not cover. +# +# Also the one place that asserts the file exists: the tag's directory walk +# cannot, because a missing Dockerfile simply contributes nothing to the hash +# and yields a confident tag for an image that can never be built. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-dockerfile: + $ErrorActionPreference = 'Stop' + $dir = Join-Path '{{ replace(justfile_directory(), "'", "''") }}' '.anvil/container' + $entries = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue) + # -ceq because PowerShell's -eq on strings is case-insensitive, which would + # make the exact name indistinguishable from a variant on a filesystem that + # can hold both. + if (@($entries | Where-Object { $_.Name -ceq 'Dockerfile' }).Count -eq 1) { + Write-Output '.anvil/container/Dockerfile' + exit 0 + } + $variant = @($entries | Where-Object { $_.Name -ieq 'Dockerfile' })[0] + if ($variant) { + Write-Error "anvil: the container image input must be named exactly '.anvil/container/Dockerfile', but this repository has '.anvil/container/$($variant.Name)'. The engine reads the ignore file as '.dockerignore', and anvil maintains '.anvil/container/Dockerfile.dockerignore', so a differently-cased name would build with no ignore file. Rename it." + exit 1 + } + Write-Error 'anvil: container image input is missing: .anvil/container/Dockerfile' + exit 1 + +# Print the exec image reference for the current inputs, without building it. +# +# The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its +# ignore file, the pinned toolchain, the optional hook, and the whole generated +# recipe tree -- because the image installs its tools by running +# `just anvil-setup`, whose dependency chain reaches the tier, group, check and +# tool recipes alike. Editing any of them can change what the image contains, so +# any of them can rename it. +# +# This is the only recipe that computes the reference; everything else asks it. +# It is public because a publisher needs the tag before there is an image to +# inspect: a pipeline that builds the image tags the result with exactly the +# reference a consumer will later compute, which is what lets presence be +# checked without a second source of truth. + +# Print the exec image reference for the current inputs, without building it. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-tag: + $ErrorActionPreference = 'Stop' + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $inputs = @('rust-toolchain.toml') + $links = @() + # The declared input and the two walk roots are checked here, because a walk + # only ever reports descendants: a link that *is* the root is traversed or + # read through and never appears in its own output. Same hazard as a link + # below them -- the engine copies the link while everything here follows it. + foreach ($declared in @('rust-toolchain.toml', '.anvil/container', 'justfiles/anvil')) { + $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $links += $item.FullName + } + } + # The declared inputs are text this tree owns, so their line endings are + # normalized before hashing and a CRLF checkout agrees with an LF one. + # Everything discovered by walking a directory is treated as text only when + # it is a `.just` recipe; anything else is hashed as the bytes the build + # context actually copies. + $declaredText = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + [void]$declaredText.Add($dockerfile) + [void]$declaredText.Add("$dockerfile.dockerignore") + [void]$declaredText.Add($hookRel) + # Everything under `.anvil/container/`, not a fixed list of three files. + # The Dockerfile is composed -- anvil owns regions inside it and the + # repository owns the gaps -- and a repository that adds a `COPY` in one of + # those gaps names a file that shapes the image: a corporate root CA, an + # install script, a patch. A replacement region from a downstream catalog + # does the same. Hashing only the three files anvil happens to know about + # would let any of them change the image under a reference that already + # resolves, which is precisely the hole this digest exists to close. + # + # The hook is picked up by the same walk. Its *output* is deliberately + # never hashed: a credential must not influence a tag. + # + # `.anvil-proposed` siblings are excluded. A region proposal is anvil's own + # review artifact, written beside its host when a template moves under a + # customized region; the build cannot see it and it cannot change what the + # image contains. Digesting it would rename the image for as long as a + # proposal sat undismissed, so two checkouts of one commit would disagree + # on the tag and a published image would stop resolving. + $containerRoot = Join-Path $repoRoot '.anvil/container' + if (Test-Path -LiteralPath $containerRoot) { + foreach ($file in Get-ChildItem -LiteralPath $containerRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + # Every generated recipe file. The image installs its tools by running + # `just anvil-setup`, and that dependency chain runs through the tier, + # group and check recipes before it reaches the install recipes in + # tools.just -- so the routing decides *whether* a tool is installed just + # as surely as tools.just decides *how*. Hashing only the install + # definitions would let a group drop a `-setup` dependency, changing the + # installed set, without renaming the image. + # + # This driver is included too. It is not circular -- the tag is derived + # from file text, and no file contains the tag -- and it belongs in the set + # because it passes the build arguments, the secret mounts and the hook's + # `Anvil-BuildSecrets` output into the build, all of which shape the result. + # Every file in the generated recipe tree, not only `*.just`. The build + # context admits the whole `justfiles/anvil/` directory (see the ignore + # file), so anything an adopter drops there is copied into the image. The + # catalog refuses to *own* a non-recipe file there, but a repository can + # still add one by hand, and a file that reaches the image without reaching + # the tag is precisely the hole this digest exists to close. Hashing what + # the context copies keeps the two sets identical by construction. + # + # -Force because Get-ChildItem omits hidden entries otherwise: a + # dot-prefixed file is copied like any other, and skipping it would let its + # edits ride under an unchanged tag -- and make Windows and Unix disagree. + # + # `.anvil-proposed` siblings are excluded here for the same reason as under + # `.anvil/container/`: this driver is itself an owned artifact, so a + # repository that customizes it gets the proposal written right here. + $recipeRoot = Join-Path $repoRoot 'justfiles/anvil' + if (Test-Path -LiteralPath $recipeRoot) { + foreach ($file in Get-ChildItem -LiteralPath $recipeRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + + # A symlink is refused rather than digested. The engine copies the link + # itself while any reading of it here follows it, so a retarget changes the + # image without changing a single byte the walk can see, and a link to a + # directory is not enumerated by the walk at all. Framing link text instead + # would have to work on Windows, where git materializes a symlink as an + # ordinary file unless the checkout was privileged, so the same commit would + # digest differently per platform. Anvil never creates one under these + # trees, so refusing costs nothing and closes the whole class. + if ($links.Count -gt 0) { + $named = ($links | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' }) -join ', ' + Write-Error "anvil: the container image inputs must be regular files, but these are links: $named. The engine copies a link as a link while the image tag is computed from what it points at, so the image would not match its own reference. Replace them with regular files." + exit 1 + } + + # Hash a tagged stream rather than raw concatenation, so no rearrangement of + # names and contents can collide. Line endings are normalized once, here, so + # a CRLF checkout and an LF checkout agree on the tag. Ordinal sort and dedup: + # `Sort-Object -Unique` compares case-insensitively, which would silently drop + # one of two inputs differing only in case on the case-sensitive filesystem + # where the image is actually built. + # Length-prefix the path and the content rather than relying on newlines as + # separators. A bare `file\n\n\n` stream is not + # self-delimiting: content is arbitrary, so a file whose body contains + # "file\n\n" serializes identically to two files whose bodies + # split at that point. That makes distinct input sets nameable by one tag -- + # a hook that exists versus an ignore file whose body ends in the hook's + # path and body, for instance -- and the second state would silently reuse + # the first state's image. Byte counts cannot be forged by content. + # + # Content is hashed as BYTES, not as decoded text. `ReadAllText` decodes + # UTF-8 with a replacing fallback, so every invalid sequence becomes U+FFFD + # before it is hashed: a one-byte file of 0xFF and one of 0xFE both collapse + # to the same replacement character and produce the same tag, while `COPY` + # puts their real, different bytes in the image. That was unreachable while + # only `*.just` was hashed and became reachable the moment the input set + # widened to everything the build context copies -- which is precisely the + # class of file (a stray `.png`, a `.DS_Store`, a UTF-16 fragment) that the + # widening admitted. + # + # Line endings are still normalized, but only for the text this tree owns: + # a `.just` recipe and the declared inputs, which a CRLF checkout and an LF + # checkout must agree on. Normalizing bytes generally would reintroduce the + # same collision from the other direction. + $ordered = [System.Collections.Generic.SortedSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + # A file's git mode is part of what `COPY` puts in the image -- the + # executable bit, and whether the entry is a regular file or a symlink -- so + # a change that leaves the bytes alone still changes the image and must + # rename the tag. Git's index is the only source of that mode which answers + # identically on every platform: Windows has no executable bit, so reading + # it from the filesystem would make two checkouts of one commit disagree on + # the tag, and a published image would stop resolving for half the people + # who use it. + # + # The index is authoritative only while the working tree agrees with it. A + # mode change that has not been staged would be copied by the build and + # missed by the tag, so it is refused below rather than absorbed. + # + # An untracked file's mode is not an input: it has no committed identity, so + # no other checkout can reproduce it and there is nothing for a shared tag + # to encode. + # + # quotePath=false so a non-ASCII path arrives verbatim rather than + # backslash-escaped, which would key the map on a spelling the walk never + # produces. + # Ordinal, like the sort and the dedup below: PowerShell's `@{}` folds case, + # so two paths differing only in case -- which git permits and the + # case-sensitive filesystem the image is built on can hold -- would collapse + # to one entry and both would be framed with whichever mode was stored last. + $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $tracked = @('.anvil/container', 'justfiles', 'rust-toolchain.toml') + if (Get-Command git -ErrorAction SilentlyContinue) { + $staged = & git -c core.quotePath=false -C $repoRoot ls-files --stage -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $staged) { + if ($entry -match '^(\d{6}) [0-9a-f]+ \d+\t(.+)$') { + $indexMode[$Matches[2]] = $Matches[1] + } + } + } + # `--raw` reports the working-tree mode as its second field, so a + # mode-only change is visible even though the content is identical. On + # Windows core.fileMode is normally false and git reports no drift, + # which is correct: the filesystem has no bit to disagree with. + # + # A zero working-tree mode is a deletion: the path is in neither the + # build context nor the digest, so there is nothing to disagree about. + # Every other entry is present in the context and is compared against + # the mode the digest actually framed, which comes from `ls-files + # --stage` above. The raw index-side mode is not that mode -- an + # intent-to-add entry reports zero there while `ls-files` reports a real + # placeholder -- so comparing the two raw fields would both miss an + # executable `git add -N` file and reject an ordinary one. + $drift = & git -c core.quotePath=false -C $repoRoot diff --no-ext-diff --raw -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $drift) { + if ($entry -match '^:\d{6} (\d{6}) [0-9a-f]+ [0-9a-f]+ \S+\t(.+)$') { + $worktreeMode, $driftPath = $Matches[1], $Matches[2] + if ($worktreeMode -ne '000000' -and $indexMode.ContainsKey($driftPath) -and $indexMode[$driftPath] -ne $worktreeMode) { + Write-Error "anvil: '$driftPath' has mode $worktreeMode in the working tree, but the image tag was computed from mode $($indexMode[$driftPath]). The build copies the working tree, so the image would not match its own reference. Stage the change (git add) and re-run." + exit 1 + } + } + } + } + } + # ComputeHash rather than the static HashData: the latter arrived in .NET 5, + # and the prerequisite check accepts any PowerShell 7, including 7.0 on + # .NET Core 3.1 where the static overload does not exist. Failing there + # would be a MethodNotFound at tag time, before anything useful happened. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($rel in $ordered) { + $path = Join-Path $repoRoot $rel + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "anvil: container image input is missing: $rel" + exit 1 + } + if ([System.IO.Path]::GetExtension($rel) -eq '.just' -or $declaredText.Contains($rel)) { + $content = [System.Text.Encoding]::UTF8.GetBytes( + ([System.IO.File]::ReadAllText($path) -replace "`r`n", "`n")) + } else { + $content = [System.IO.File]::ReadAllBytes($path) + } + # The whole git mode, not just the executable bit: `COPY` preserves + # a symlink as a symlink, while the walk above reads through it, so + # replacing a regular file with a link to identical bytes would + # otherwise keep the tag. An untracked path has no framed mode. + $mode = if ($indexMode.ContainsKey($rel)) { $indexMode[$rel] } else { '-' } + $header = [System.Text.Encoding]::UTF8.GetBytes( + 'file ' + [System.Text.Encoding]::UTF8.GetByteCount($rel) + ' ' + $rel + ' ' + $mode + ' ' + $content.Length + ' ') + [void]$sha.TransformBlock($header, 0, $header.Length, $null, 0) + if ($content.Length -gt 0) { + [void]$sha.TransformBlock($content, 0, $content.Length, $null, 0) + } + } + [void]$sha.TransformFinalBlock([byte[]]::new(0), 0, 0) + $digest = $sha.Hash + } finally { + $sha.Dispose() + } + # 16 hex characters (64 bits) is far past any practical collision risk for a + # local image set, and keeps `docker images` readable. + $imageId = -join ($digest[0..7] | ForEach-Object { $_.ToString('x2') }) + Write-Output ('{{anvil_container_name}}:' + $imageId) + +# Resolve the exec image, building it if it is neither present nor resolvable. +# +# Three steps, in order: a local image under the computed tag, then the +# optional `Anvil-ResolveImage` hook (a registry, typically), then a build. +# Resolution comes before the NO_REBUILD guard because fetching a published +# image is not building one. +# +# ANVIL_CONTAINER_NO_REBUILD=1 fails instead of building, which is how a cache +# miss is told apart from a build failure. ANVIL_CONTAINER_NO_RESOLVE=1 skips +# the hook, so a query stays a query -- resolving can mean pulling gigabytes. +# ANVIL_CONTAINER_NO_CACHE=1 rebuilds a tag that already resolves, for the cases +# a content hash cannot see: a moved upstream package, or a base layer that +# changed behind its digest. It skips the hook too -- "ignore what is cached" +# has to mean the remote cache as well, or a rebuild would be undone by the +# next pull. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-image: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $image = & '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $image = "$image".Trim() + + if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') { + & $engineExe @enginePrefix image inspect $image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output $image + exit 0 + } + + # Nothing local. Give the optional hook a chance to fetch a published + # image built from these same inputs -- a registry, typically. + # + # The hook returns the reference it made available, and we run that + # reference rather than re-tagging it to the local name: a local tag + # asserts "built here from these inputs", and a fetched image only + # *claims* that, since the hash is over source files and cannot be + # re-derived from layers. Whether that claim holds is a property of the + # registry (immutable tags, restricted push), not of anything this + # recipe can check, so the reference stays honest about where it came + # from. + # + # Every failure here is non-fatal: a missing image, an expired + # credential and a broken hook all fall through to a build, which is + # slower but always correct. A publisher that has not yet caught up + # with a change must not stop the developer who made it. + $hookPath = Join-Path $repoRoot $hookRel + if ($env:ANVIL_CONTAINER_NO_RESOLVE -ne '1' -and (Test-Path -LiteralPath $hookPath -PathType Leaf)) { + # Dot-sourcing is inside the try as well: a hook with a syntax error, + # or one that throws while being loaded, must cost no more than a + # hook that resolves nothing. The recipe runs under + # `$ErrorActionPreference = 'Stop'`, so leaving the load outside + # would abort the run instead of falling through to a build. + $resolved = $null + try { + . $hookPath + if (Get-Command Anvil-ResolveImage -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-ResolveImage from $hookRel") + $resolved = @(Anvil-ResolveImage $image | Where-Object { $_ }) | Select-Object -Last 1 + } + } catch { + [Console]::Error.WriteLine("anvil: $hookRel failed: $($_.Exception.Message)") + $resolved = $null + } + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $resolved = ([string]$resolved).Trim() + # A presence check, not a verification: `image inspect` proves + # something is tagged with that reference, not that its contents + # match the digest the tag claims. Trusting the hook is the + # contract -- this only keeps a reference the hook reported but + # never fetched from failing later, under `--pull=never`, a long + # way from the cause. + & $engineExe @enginePrefix image inspect $resolved *> $null + if ($LASTEXITCODE -eq 0) { + [Console]::Error.WriteLine("anvil: resolved $resolved") + Write-Output $resolved + exit 0 + } + [Console]::Error.WriteLine( + "anvil: Anvil-ResolveImage reported '$resolved' but no such image is present locally") + } + [Console]::Error.WriteLine("anvil: nothing resolved; building locally") + } + } + + # Checked outside the cache guard, so the two variables compose: a caller + # that has NO_CACHE exported would otherwise fall straight through to a + # from-scratch build, which is exactly what NO_REBUILD exists to prevent -- + # and `anvil-container-status`, which sets it, would spend minutes building + # from a query. + if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { + # Still report the reference: a caller that asked not to build is + # usually asking *which* image is missing. + Write-Output $image + [Console]::Error.WriteLine("anvil: $image is not present or not current, and ANVIL_CONTAINER_NO_REBUILD=1") + exit 1 + } + + # Build-time credentials come from the optional hook, never from a committed + # file. Values are handed to BuildKit by environment variable name, so they + # stay out of the host's process command line, and BuildKit keeps them out of + # every image layer. An empty value is fatal: BuildKit would mount an empty + # secret, the build would install a reduced tool set and exit 0, and the + # result would be tagged with the same hash a credentialed build produces -- + # so every later run would reuse the broken image. + $secretArgs = @() + $secretEnv = @() + $hookPath = Join-Path $repoRoot $hookRel + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed, unlike the resolve hook: a build that cannot mint its + # credentials must stop, not proceed to produce a reduced image. The + # try exists only so the cause is named -- loading a hook with a syntax + # error would otherwise surface as a bare parser error with no hint + # that a hook was involved. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load ${hookRel}: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-BuildSecrets -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-BuildSecrets from $hookRel") + try { + # Take the last emitted object, not the whole stream: a hook + # that writes progress with `Write-Output` would otherwise hand + # back an array whose `.Secrets` is silently $null. + $hook = @(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-BuildSecrets failed: $($_.Exception.Message)" + exit 1 + } + $secrets = if ($null -ne $hook) { $hook.Secrets } else { $null } + # A defined `Anvil-BuildSecrets` that yields nothing is the hazard this + # guard exists for, not a hook opting out: secrets are the only + # thing the phase can contribute, so an empty return means the mint + # failed quietly. A hook with no build-time credentials simply does + # not define the function. + if ($null -eq $secrets -or $secrets.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-BuildSecrets returned no secrets; omit the function if the build needs none" + exit 1 } - $inconclusive.Add('```') | Out-Null - $inconclusive.Add('') | Out-Null + foreach ($id in $secrets.Keys) { + if ([string]::IsNullOrWhiteSpace($secrets[$id])) { + Write-Error "anvil: Anvil-BuildSecrets returned an empty value for secret '$id'" + exit 1 + } + $name = "ANVIL_SECRET_$id" + Set-Item -LiteralPath "Env:$name" -Value $secrets[$id] + $secretEnv += $name + $secretArgs += "id=$id,env=$name" + } + [Console]::Error.WriteLine("anvil: build secrets: $($secrets.Keys -join ', ')") } } - [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null - if ($findings.Count -gt 0 -or $inconclusive.Count -gt 0) { - # Body starts with an HTML-comment marker so the ADO wiring can - # locate the existing thread on subsequent runs (ADO has no - # native "sticky comment header"; the marker is invisible to - # human readers). Marocchino on GH uses its own `header:` input - # and ignores the marker, but having it in the body keeps a - # single source of truth across backends. - $lines = New-Object System.Collections.Generic.List[string] - $lines.Add('') | Out-Null - $lines.Add('## :warning: SemVer check advisory') | Out-Null - if ($findings.Count -gt 0) { - $lines.Add('') | Out-Null - $lines.Add('### Potential breaking changes') | Out-Null - $lines.Add('') | Out-Null - $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null - $lines.Add('') | Out-Null - foreach ($f in $findings) { $lines.Add($f) | Out-Null } + + try { + # Progress goes to stderr: callers capture this recipe's stdout to learn + # the image reference, so anything else written there becomes part of it. + [Console]::Error.WriteLine("anvil: building $image (inputs changed or first run)") + # The engine may not share this host's filesystem view, so the context + # and the Dockerfile are given in its terms rather than ours. + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + # Pinned, not inferred from the host. The Dockerfile installs amd64 + # toolchains and verifies amd64 checksums, so an arm host would resolve + # the multi-arch base to arm64 and fail late with an exec-format error. + # It also keeps the identity scheme honest: without this, two hosts of + # different architecture compute the same tag for different images. + $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # BuildKit reads `.dockerignore` on its own; buildah reads + # only a context-root ignore file and needs to be pointed at ours. Named + # rather than probed, because the flag is rejected outright by the engine + # that does not take it, and an unscoped context streams the whole + # worktree -- `target/` included -- on every build. + if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile', "$engineRoot/$dockerfile.dockerignore") } + if ($env:ANVIL_CONTAINER_NO_CACHE -eq '1') { $buildCmd += '--no-cache' } + foreach ($secret in $secretArgs) { $buildCmd += @('--secret', $secret) } + $buildCmd += $engineRoot + # BuildKit is required for --secret; docker enables it by default from + # 23.0 but an older daemon silently ignores the flag, so ask explicitly. + $env:DOCKER_BUILDKIT = '1' + # WSLENV exports the named variables into the WSL environment, which is + # where the engine reads a secret's value from when it runs there. + if ($engineExe -eq 'wsl.exe') { + $bridged = @('DOCKER_BUILDKIT/u') + ($secretEnv | ForEach-Object { "$_/u" }) + $env:WSLENV = (@($env:WSLENV) + $bridged | Where-Object { $_ }) -join ':' } - if ($inconclusive.Count -gt 0) { - $lines.Add('') | Out-Null - $lines.Add('### Inconclusive comparisons') | Out-Null - $lines.Add('') | Out-Null - $lines.Add('`cargo semver-checks` could not complete the following comparisons. These failures are **informational** because an unbuildable baseline is not evidence of a breaking API change.') | Out-Null - $lines.Add('') | Out-Null - foreach ($f in $inconclusive) { $lines.Add($f) | Out-Null } + & $engineExe @enginePrefix @buildCmd | ForEach-Object { [Console]::Error.WriteLine($_) } + if ($LASTEXITCODE -ne 0) { + # Build secrets are the part of this path engines implement least + # consistently -- podman on Windows cannot mount one at all, and + # fails with a path error that names neither the secret nor the + # engine. Say so once, rather than leaving that to be rediscovered. + if ($secretArgs.Count -gt 0) { + [Console]::Error.WriteLine( + "anvil: the build passed $($secretArgs.Count) secret(s) from $hookRel. " + + "If the failure above is about a temp file or a path, the engine may not support " + + "build secrets on this host; see docs/design/containers.md.") + } + exit $LASTEXITCODE } - $body = ($lines -join "`n") + "`n" - Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline - Write-Host '' - Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow - } else { - Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } finally { + foreach ($name in $secretEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } } - # cargo-semver-checks findings and operational failures are advisory. - exit 0 - -# Install prerequisites for the `anvil-semver-check` recipe. -[group("anvil-setup")] -anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) - -# Validate prerequisites for the `anvil-semver-check` recipe. -[group("anvil-setup")] -anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs - -=== justfiles/anvil/checks/spellcheck.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. -# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md -# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md + Write-Output $image -# Unscoped. +# Run a command inside the pinned Linux image. # -# cargo-spellcheck checks the whole workspace and reads a repo-root `.spelling` -# dictionary -- repo-level inputs that cargo-delta does not map to any cargo -# package -- so this check cannot be safely impact-scoped and always runs. +# The tokens after the recipe name are the argv, executed verbatim in the +# image. Anvil recipes are reached by naming `just` like any other command. # -# cargo-spellcheck reads a Hunspell-compatible dictionary file at the -# path configured in spellcheck.toml (typically `extra_dictionaries = -# ["target/spelling.dic"]`). The convention used by the surveyed -# Microsoft Rust repos is to keep the *source* word list in a -# human-edited `.spelling` file at the repo root and preprocess it -# into the .dic format at check time (Hunspell .dic requires: -# alphabetical sort, blank/numeric lines removed, line-count header). -# We always (re)generate `target/spelling.dic` so that a spellcheck.toml -# pointing at it never fails on a missing file -- if `.spelling` is -# absent we emit an empty dictionary (count `0`, no words) rather than -# leaving the path dangling. +# just anvil-container just anvil-pr # a tier +# just anvil-container cargo build # any other command +# just anvil-container # interactive shell +# +# ANVIL_IN_CONTAINER is set inside the image, so a nested invocation runs the +# command on the spot and the work happens exactly once. -# Check spelling in source comments and documentation. +# Run a command inside the pinned Linux image (no argument: a shell). +[group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-spellcheck: anvil-spellcheck-validate-prereqs +anvil-container *command: $ErrorActionPreference = 'Stop' - $output_file = 'target/spelling.dic' - $filtered_lines = @() - if (Test-Path '.spelling') { - $lines = Get-Content '.spelling' | Sort-Object - $filtered_lines = @($lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' }) + # `*command` joins its parts with spaces, so the string is split back into + # argv here. Whitespace is the only separator, so an argument containing a + # space does not survive; pass such a value through the environment. + $argv = @('{{ replace(command, "'", "''") }}' -split '\s+' | Where-Object { $_ }) + if ($env:ANVIL_IN_CONTAINER -eq '1') { + if ($argv.Count -eq 0) { + # The no-argument form asks for a shell in the image, and this is + # that shell. Nothing runs, so exiting 0 would report success for a + # request that was not carried out. + [Console]::Error.WriteLine("anvil: already inside the container; run the command directly") + exit 1 + } + # `just` resolves to the binary running this tree rather than to PATH: + # ANVIL_IN_CONTAINER is a documented control a developer can set on a + # host, and a caller who invoked `just` by absolute path with its + # directory off PATH would otherwise fail here. + $exe = if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] } + & $exe @($argv | Select-Object -Skip 1) + exit $LASTEXITCODE } - $line_count = $filtered_lines.Count - [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null - @($line_count) + $filtered_lines | Set-Content $output_file - # Pass --cfg explicitly when a spellcheck.toml exists at repo root, - # otherwise cargo-spellcheck falls back to its built-in defaults and - # ignores user-curated dictionaries (`extra_dictionaries`, custom - # hunspell langs, etc.). - if (Test-Path 'spellcheck.toml') { - cargo spellcheck --cfg spellcheck.toml check --code 1 + + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + $image = (& '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + + # Map the caller's working directory to its in-container equivalent so + # relative paths keep working from a subdirectory. `invocation_directory()` + # would be wrong here: with `cygpath` on PATH it reports a Cygwin-style + # path, which shares no prefix with the native `justfile_directory()` above. + # `GetRelativePath` then walks out with `..` segments and the run is placed + # outside the mount, so it fails on a path that does not exist in the + # container. The `_native` form is the one that agrees with the root. + $rel = [System.IO.Path]::GetRelativePath($repoRoot, '{{ replace(invocation_directory_native(), "'", "''") }}') -replace '\\', '/' + if ($rel.StartsWith('..')) { + Write-Error "anvil: run this from inside the repository; $rel is outside $repoRoot" + exit 1 + } + $containerCwd = if ($rel -eq '.' -or [string]::IsNullOrEmpty($rel)) { + '{{anvil_container_workdir}}' } else { - cargo spellcheck check --code 1 + '{{anvil_container_workdir}}/' + $rel } - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -# cargo-spellcheck has a source-build-time libclang dependency. The -# catalog installer checks it only immediately before a source install; -# successful prebuilt installation does not require libclang. + $interactive = $argv.Count -eq 0 + $runArgs = @('run', '--rm', '--platform', 'linux/amd64') + $runArgs += $interactive ? '-it' : '-i' + $runArgs += @('-v', "${engineRoot}:{{anvil_container_workdir}}") -# Install prerequisites for the `anvil-spellcheck` recipe. -[group("anvil-setup")] -anvil-spellcheck-setup installer="install": (anvil-tool-cargo-spellcheck-install installer) + # A checkout whose `.git` is a file keeps its real git directory elsewhere: + # a linked worktree points into the main clone, `--separate-git-dir` and a + # submodule point somewhere else again. The path recorded there is a host + # path that does not exist inside the container, so git resolves neither + # HEAD nor origin/main. Mount the common git directory and replace the + # checkout's `.git` with one naming that mount; the `commondir` file in a + # worktree's entry is relative, so it resolves under it. + # + # The predicate is the shape of `.git`, not whether the git directory + # differs from the common one: `--separate-git-dir` redirects without + # differing, and testing for a difference skips it and leaves git pointed at + # a path the container cannot see. + # + # The redirection lives in the checkout rather than in GIT_DIR/GIT_WORK_TREE + # so that it stays scoped to it. Those variables are ambient: every process + # in the container inherits them, and a git command run elsewhere -- `git + # init` in a test's scratch directory -- would operate on this repository + # instead of its own. + # + # An ordinary clone keeps its git directory inside the checkout, where the + # bind mount already carries it, and takes none of this. + # + # Guarded on git being present: the run path needs it only to answer this + # question, and a host with a working engine but no git on PATH keeps + # working rather than failing on a call it does not need. + $gitFile = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $gitDir = & git rev-parse --git-dir 2>$null + $gitCommon = & git rev-parse --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $gitDir -and $gitCommon -and + (Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)) { + $gitDirAbs = (Resolve-Path -LiteralPath $gitDir).Path + $gitCommonAbs = (Resolve-Path -LiteralPath $gitCommon).Path + $rel = [System.IO.Path]::GetRelativePath($gitCommonAbs, $gitDirAbs) -replace '\\', '/' + # One mount has to carry both directories, so the git directory must + # sit under the common one. `git worktree` always places it there and + # a redirect without a separate worktree entry makes the two equal; + # anything else cannot be expressed as a single mount, and emitting a + # path that climbs out of it would fail inside the container instead. + if ($rel -eq '.') { + $containerGitDir = '/anvil/gitdir' + } elseif ($rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)) { + Write-Error "anvil: this checkout's git directory ($gitDirAbs) is not inside its common git directory ($gitCommonAbs), so the two cannot be mounted as one tree. Run the container from an ordinary clone or a git worktree checkout." + exit 1 + } else { + $containerGitDir = "/anvil/gitdir/$rel" + } + $engineGitCommon = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitCommonAbs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitCommon = "$engineGitCommon".Trim() + # LF and no trailing newline: git parses this file strictly. + $gitFile = Join-Path ([System.IO.Path]::GetTempPath()) "anvil-gitfile-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($gitFile, "gitdir: $containerGitDir`n") + $engineGitFile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitFile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitFile = "$engineGitFile".Trim() + $runArgs += @('-v', "${engineGitCommon}:/anvil/gitdir") + $runArgs += @('-v', "${engineGitFile}:{{anvil_container_workdir}}/.git:ro") + } + } -# Validate prerequisites for the `anvil-spellcheck` recipe. -[group("anvil-setup")] -anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + # Cache only what is content-addressed: the downloaded registry and the git + # checkouts. Deliberately NOT $CARGO_HOME or $RUSTUP_HOME themselves -- + # those hold the installed tools and toolchains, and a named volume is + # populated from the image only when it is first created. Mounting them + # would pin the first image's binaries over every later one, so a tool bump + # would change the tag, build a new image, and still run the old tools. + $runArgs += @('-v', '{{anvil_container_name}}-cargo-registry:/usr/local/cargo/registry') + $runArgs += @('-v', '{{anvil_container_name}}-cargo-git:/usr/local/cargo/git') + # Match the caller's uid/gid on Linux. Without this everything the run + # writes under the bind mount -- target/, generated files -- lands as root + # on the host, and the next native cargo build or git clean fails with + # EACCES a long way from the cause. Docker Desktop on Windows and macOS + # already maps ownership, and `id` is not there to ask. + if (-not $IsWindows -and -not $IsMacOS) { + $hostUid = (id -u); $hostGid = (id -g) + if ($LASTEXITCODE -eq 0 -and $hostUid -ne '0') { + $runArgs += @('--user', "${hostUid}:${hostGid}") + # That uid has no passwd entry, so the engine leaves HOME as `/`. + # Anything falling back to $HOME for a cache then writes to a + # read-only root and fails a long way from the cause. + $runArgs += @('-e', 'HOME=/tmp') + } + } + $runArgs += @('-e', 'ANVIL_IN_CONTAINER=1') + + # Run-time credentials come from the optional hook. Forwarded by NAME, never + # as NAME=VALUE: the engine copies the value out of the environment it + # already inherits, so a credential never appears in the host's process + # command line, where endpoint telemetry records and retains it for far + # longer than a short-lived token is meant to live. + # $forwardedEnv is every name passed with -e; $hookEnv is the subset this + # process set, and so the subset it must unset again. + $forwardedEnv = @() + $hookEnv = @() + + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # + # An already-exported GITHUB_TOKEN is forwarded whatever the command is: + # that is exact parity, since a native run exposes it to every process the + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. + # + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { + # A dry run has no side effects, and a target that cannot be planned + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. + # + # A plan covers the bodies just runs itself, not the body of a + # recipe that one of them launches as a child process. The unscoped + # tier wrapper launches its tier that way, so planning + # `anvil-scheduled` shows the wrapper and none of the checks + # underneath it. Follow each nested target a plan names, or a + # wrapped tier reads as needing nothing and runs unauthenticated. + $plan = '' + $targets = [System.Collections.Generic.List[object]]::new() + $targets.Add([string[]]@($argv | Select-Object -Skip 1)) + $planned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($i = 0; $i -lt $targets.Count; $i++) { + $target = [string[]]$targets[$i] + # A recipe reachable twice is planned once, and a body naming + # itself terminates. + if (-not $planned.Add(($target -join ' '))) { continue } + # The same executable that launched this tree, for the reason + # every other nested call uses it: a caller invoking `just` by + # absolute path with its directory off PATH would otherwise fail + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. + $step = '' + try { + $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | + ForEach-Object { $_.ToString() }) -join "`n" + } catch { + $step = '' + } + $plan = "$plan`n$step" + # A launched recipe appears as a quoted argument to `just`. + foreach ($nested in [regex]::Matches($step, "'(_anvil-[^'\s]+)'")) { + $targets.Add([string[]]@($nested.Groups[1].Value)) + } + } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } + } + } + if ($env:GITHUB_TOKEN) { + $forwardedEnv += 'GITHUB_TOKEN' + $runArgs += @('-e', 'GITHUB_TOKEN') + } + + # The recipe contract's own inputs. These are read by generated checks -- + # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its + # CI equivalents, and `anvil-impact` reads ANVIL_IMPACT to decide whether to + # compute scoping, consume a downloaded cache, or skip -- so dropping them at + # the boundary makes the same command mean different things inside and out. + # anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with a + # skip notice, so a title a native run rejects passes in a container and the + # tier still reports green. ANVIL_IMPACT is the other: a CI group job exports + # `consume`, and a container that did not inherit it would recompute scoping + # from a diff instead of trusting the artifact the group downloaded. + # + # Forwarded by name and only when set, so an unset variable stays unset + # rather than arriving as an empty string, which several of these treat as + # a value. + foreach ($name in @( + 'PR_TITLE', + 'BASE_REF', 'GITHUB_BASE_REF', 'SYSTEM_PULLREQUEST_TARGETBRANCH', + 'ANVIL_IMPACT')) { + if ((Test-Path -LiteralPath "Env:$name") -and -not [string]::IsNullOrEmpty((Get-Item -LiteralPath "Env:$name").Value)) { + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + } -=== justfiles/anvil/checks/udeps.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. -# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md + $hookPath = Join-Path $repoRoot '.anvil/container/hooks.ps1' + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed like Anvil-BuildSecrets: a run that cannot obtain its + # credentials fails inside the container in a far less obvious way. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load .anvil/container/hooks.ps1: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-RunEnv -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-RunEnv from .anvil/container/hooks.ps1") + try { + $hook = @(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-RunEnv failed: $($_.Exception.Message)" + exit 1 + } + $hookVars = if ($null -ne $hook) { $hook.Env } else { $null } + if ($null -eq $hookVars -or $hookVars.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-RunEnv returned no variables; omit the function if the run needs none" + exit 1 + } + foreach ($name in $hookVars.Keys) { + if ([string]::IsNullOrWhiteSpace($hookVars[$name])) { + Write-Error "anvil: Anvil-RunEnv returned an empty value for '$name'" + exit 1 + } + Set-Item -LiteralPath "Env:$name" -Value $hookVars[$name] + $hookEnv += $name + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + # Names only, never values: a hook with a broad idea of what to + # forward should be visible, since everything inside the + # container can read it -- including third-party build scripts. + [Console]::Error.WriteLine("anvil: forwarding env: $($hookVars.Keys -join ', ')") + } + } -# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md + try { + # --pull=never: the reference names content that is already here, either + # built locally or fetched by the resolve hook, so a miss is a bug to + # surface rather than an invitation to fetch something unrelated. + $runArgs += @('--pull=never', '-w', $containerCwd, $image) + if (-not $interactive) { $runArgs += $argv } + # WSLENV exports the forwarded names into the WSL environment, which is + # where the engine reads their values from when it runs there. Without + # it, `-e NAME` reaches an engine that cannot see NAME and forwards + # nothing, leaving the variable unset inside the container. + # + # It is not restored afterwards because there is nothing to restore to: + # `just` runs a [script(...)] recipe as its own pwsh process, so this + # assignment dies with that process and never reaches the caller's + # shell. The `finally` below unsets the credential names for hygiene + # within this process, not to protect the parent. + if ($engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0) { + $env:WSLENV = (@($env:WSLENV) + ($forwardedEnv | ForEach-Object { "$_/u" }) | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @runArgs + exit $LASTEXITCODE + } finally { + foreach ($name in $hookEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + if ($gitFile) { Remove-Item -LiteralPath $gitFile -Force -ErrorAction SilentlyContinue } + } -# Required tier. cargo-udeps detects unused dependencies by resolving -# the full crate graph and seeing which deps are referenced; that's -# precisely what the required tier is for. Pinned to the general -# nightly defined in versions.just. +# Report the engine, the exec image, and whether it is present and current. # -# Run twice, because cargo-udeps only analyzes the targets it is told -# to, and each invocation catches a variant the other masks: -# 1. default targets (lib + bins): catches a dep in `[dependencies]` -# that is referenced only by tests/benches/examples -- it belongs -# in `[dev-dependencies]`. Under `--all-targets` such a dep looks -# "used" (the test/bench/example target satisfies the lookup), so -# this run is the only one that surfaces it. -# 2. `--all-targets`: catches unused `[dev-dependencies]` -- which the -# default-targets run never compiles, so it is the only one that -# surfaces those. -# A genuinely-unused dep is caught by both; the two together cover all -# of {unused dep, unused dev-dep, dep that should be a dev-dep}. +# The tag embeds the hash of the image's inputs, so "absent" and "out of date" +# are the same condition and are reported as one. -# Check required workspace packages for unused dependencies. +# Report the engine, the exec image, and whether it is present and current. +[group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-udeps: anvil-udeps-validate-prereqs anvil-impact +anvil-container-status: $ErrorActionPreference = 'Stop' - $include = (& "{{ just_executable() }}" _anvil-impact-include required) - if ($include -eq '--skip') { Write-Host 'anvil-udeps: no affected packages; skipping'; exit 0 } - $pkg = @(if ($include) { -split $include } else { '--workspace' }) - # Pass 1: default targets (lib + bins) -- surfaces [dependencies] that - # are only used by tests/benches/examples (should be dev-deps). - & cargo '+{{ rust_nightly }}' udeps @pkg --all-features + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - # Pass 2: --all-targets -- surfaces unused [dev-dependencies]. - & cargo '+{{ rust_nightly }}' udeps @pkg --all-features --all-targets + $engine = "$engine".Trim() + Write-Output ("engine: " + ($engine -replace '\|', ' ')) + Write-Output "workdir: {{anvil_container_workdir}}" + + # NO_REBUILD turns the resolve into a pure query: report the state instead + # of silently spending several minutes building from a status command. + # NO_RESOLVE is the same argument applied to the hook, which would otherwise + # pull gigabytes to answer a question about the local machine. + # + # Compute the tag first and let it fail loudly. It is fatal for a reason a + # query cannot paper over -- a declared input is missing -- and reporting + # that as "not present locally" would be a lie: the next run cannot build + # it either. + $image = (& '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag) | Select-Object -Last 1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output "image: $image" + + $env:ANVIL_CONTAINER_NO_REBUILD = '1' + $env:ANVIL_CONTAINER_NO_RESOLVE = '1' + # And explicitly *not* NO_CACHE. A caller who exported it is asking the next + # build to ignore the layer cache, which is a statement about building -- + # but it also makes the resolver skip the local `image inspect` + # short-circuit, so a present image would be reported absent by a command + # that is only ever asking what is on this machine. + $env:ANVIL_CONTAINER_NO_CACHE = $null + & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "status: present and current" + exit 0 + } -# Install prerequisites for the `anvil-udeps` recipe. -[group("anvil-setup")] -anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) - -# Validate prerequisites for the `anvil-udeps` recipe. -[group("anvil-setup")] -anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + # A cache miss and an unreachable daemon both make `image inspect` fail, and + # reporting the second as the first tells a developer to expect a build that + # will not start either. Ask the engine whether it is answering at all: only + # then is absence the honest reading. + $engineCmd = $engine -split '\|' + & $engineCmd[0] @($engineCmd | Select-Object -Skip 1) version *> $null + if ($LASTEXITCODE -ne 0) { + Write-Output "status: unknown -- the engine is not responding (is the daemon running?)" + exit 1 + } + Write-Output "status: not present locally (the next run resolves or builds it)" + exit 0 -=== justfiles/anvil/container.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Only the download caches are volumes, so this discards fetched crates and git +# checkouts and nothing else: the next run re-fetches them, and the image's own +# tools are untouched. To discard the image instead, set +# ANVIL_CONTAINER_NO_CACHE=1 for a single run. -# Run any Anvil recipe in the pinned local Linux container. With no recipe, -# open an interactive shell. -[windows] +# Remove this repository's cache volumes. The image is left in place. [group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-container *recipe: - $requested = @('{{ replace(recipe, "'", "''") }}' -split '\s+' | Where-Object { $_ }) - & '.anvil/container/run-in-container.ps1' @requested - exit $LASTEXITCODE - -[unix] -[group("anvil-container")] -[script("bash")] -anvil-container *recipe: - requested={{ quote(recipe) }} - if [[ -z "$requested" ]]; then - exec bash '.anvil/container/run-in-container.sh' - fi - read -r -a requested_args <<<"$requested" - exec bash '.anvil/container/run-in-container.sh' "${requested_args[@]}" +anvil-container-down: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + # Report a teardown that did not happen. $ErrorActionPreference does not + # cover native commands, so a non-serving engine would otherwise print a + # connection error per volume and still exit 0 -- and this recipe is the + # only way to clear a cache volume, so a caller that scripts teardown must + # be able to tell that it failed. `-f` already exits 0 for a volume that + # does not exist, so this cannot fire spuriously. + $failed = @() + foreach ($vol in @('{{anvil_container_name}}-cargo-registry', '{{anvil_container_name}}-cargo-git')) { + & $engineExe @enginePrefix volume rm -f $vol + if ($LASTEXITCODE -ne 0) { $failed += $vol } + } + if ($failed.Count -gt 0) { + Write-Error ("anvil: could not remove: " + ($failed -join ', ')) + exit 1 + } + exit 0 === justfiles/anvil/groups/pr-fast.just === # Copyright (c) Microsoft Corporation. @@ -4694,14 +4692,14 @@ anvil-pr-test-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled advisory checks. [group("anvil")] -anvil-scheduled-advisories: (_anvil-run "scheduled-advisories" anvil_runner "off") +anvil-scheduled-advisories: (_anvil-unscoped "scheduled-advisories") [private] _anvil-scheduled-advisories: anvil-scheduled-advisories-validate-prereqs \ @@ -4735,14 +4733,14 @@ anvil-scheduled-advisories-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled exhaustive checks. [group("anvil")] -anvil-scheduled-exhaustive: (_anvil-run "scheduled-exhaustive" anvil_runner "off") +anvil-scheduled-exhaustive: (_anvil-unscoped "scheduled-exhaustive") [private] _anvil-scheduled-exhaustive: anvil-scheduled-exhaustive-validate-prereqs \ @@ -4779,14 +4777,14 @@ anvil-scheduled-exhaustive-validate-prereqs: \ # and adds the three stricter miri profiles (tree-borrows, strict- # provenance, race-coverage) which are too expensive for PR. -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled runtime analysis. [group("anvil")] -anvil-scheduled-runtime-analysis: (_anvil-run "scheduled-runtime-analysis" anvil_runner "off") +anvil-scheduled-runtime-analysis: (_anvil-unscoped "scheduled-runtime-analysis") [private] _anvil-scheduled-runtime-analysis: anvil-scheduled-runtime-analysis-validate-prereqs \ @@ -4823,7 +4821,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Scheduled groups # Scheduled groups are the full-workspace backstop for PR-tier impact scoping, -# so route through _anvil-run with impact "off": it exports ANVIL_IMPACT=off +# so route through _anvil-unscoped: it exports ANVIL_IMPACT=off # before the check dependencies run, so the group is full-workspace regardless # of how it is invoked (CI, `just anvil-scheduled`, or # `just anvil-scheduled-test` directly). Because these groups never recompute @@ -4831,7 +4829,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Run the scheduled tests. [group("anvil")] -anvil-scheduled-test: (_anvil-run "scheduled-test" anvil_runner "off") +anvil-scheduled-test: (_anvil-unscoped "scheduled-test") [private] _anvil-scheduled-test: anvil-scheduled-test-validate-prereqs \ @@ -4977,6 +4975,25 @@ _anvil-base-ref: Write-Error 'anvil-base-ref: cannot resolve a base ref. Set BASE_REF, or ensure origin/main or origin/master exists.' exit 1 +# Run a private recipe with impact scoping disabled. +# +# The only way to reach a whole dependency tree with an environment variable: +# `just` runs each dependency as its own process, and a dependency-only +# recipe's body executes after its dependencies, so exporting from there is +# too late. Invoking `_anvil-` as a child process makes every check +# below it inherit the setting. +# +# The justfile is named explicitly so the child resolves the same file the +# wrapper was defined in, rather than whatever an upward search from the +# working directory happens to find. +[private] +[script("pwsh", "-NoProfile")] +_anvil-unscoped name: + $ErrorActionPreference = 'Stop' + $env:ANVIL_IMPACT = 'off' + & '{{ replace(just_executable(), "'", "''") }}' --justfile '{{ replace(justfile(), "'", "''") }}' '_anvil-{{ replace(name, "'", "''") }}' + exit $LASTEXITCODE + === justfiles/anvil/impact.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -5675,7 +5692,11 @@ import 'checks/readme-check.just' import 'checks/semver-check.just' import 'checks/spellcheck.just' import 'checks/udeps.just' -import 'container.just' +# Optional: the container artifacts can be removed through `without_artifact`, +# which deletes this file. A hard import would then fail parsing for every +# recipe in the tree, not merely the container ones, so the documented opt-out +# would break the whole Justfile. +import? 'container.just' import 'groups/pr-fast.just' import 'groups/pr-slow.just' import 'groups/pr-test.just' @@ -5685,7 +5706,6 @@ import 'groups/scheduled-test.just' import 'groups/scheduled-advisories.just' import 'groups/scheduled-runtime-analysis.just' import 'groups/scheduled-exhaustive.just' -import 'runner.just' import 'tiers.just' import 'tools.just' import 'versions.just' @@ -5693,67 +5713,6 @@ import 'versions.just' # Friendly default: `just anvil` runs the PR tier. alias anvil := anvil-pr -=== justfiles/anvil/runner.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -# Route public tier entry points through the configured execution environment. -# ANVIL_IN_CONTAINER always wins to prevent recursive container launches. -# -# `impact` selects the tier's impact-scoping mode: the default "on" leaves -# scoping enabled (PR tier), while "off" exports ANVIL_IMPACT=off before -# invoking the native tier so every check runs full-workspace -- the -# scheduled/full backstop for PR-tier impact scoping. Setting it here (rather -# than in a dep-only tier recipe) ensures the private `_anvil-` recipe's -# own dependencies, which run before any recipe body, inherit the mode. -[private] -[no-exit-message] -[windows] -[script("pwsh", "-NoProfile")] -_anvil-run tier runner impact="on": - if ('{{ replace(impact, "'", "''") }}' -ceq 'off') { $env:ANVIL_IMPACT = 'off' } - $just = '{{ replace(just_executable(), "'", "''") }}' - $justfile = '{{ replace(justfile(), "'", "''") }}' - $nativeTier = '_anvil-{{ replace(tier, "'", "''") }}' - if ($env:ANVIL_IN_CONTAINER) { - & $just --justfile $justfile $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'container') { - & $just --justfile $justfile anvil-container $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'native') { - & $just --justfile $justfile $nativeTier - } else { - [Console]::Error.WriteLine("anvil-runner: expected 'native' or 'container', got '{{ replace(runner, "'", "''") }}'.") - exit 2 - } - exit $LASTEXITCODE - -[private] -[no-exit-message] -[unix] -[script("bash")] -_anvil-run tier runner impact="on": - just_path={{ quote(just_executable()) }} - justfile={{ quote(justfile()) }} - tier={{ quote(tier) }} - runner={{ quote(runner) }} - impact={{ quote(impact) }} - if [[ "$impact" == "off" ]]; then - export ANVIL_IMPACT=off - fi - native_tier="_anvil-$tier" - if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - elif [[ "$runner" == "container" ]]; then - exec "$just_path" --justfile "$justfile" anvil-container "$native_tier" - elif [[ "$runner" == "native" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - else - echo "anvil-runner: expected 'native' or 'container', got '$runner'." >&2 - exit 2 - fi - === justfiles/anvil/tiers.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -5769,10 +5728,7 @@ _anvil-run tier runner impact="on": # Run all pull request checks. [group("anvil")] -anvil-pr: (_anvil-run "pr" anvil_runner) - -[private] -_anvil-pr: anvil-pr-validate-prereqs \ +anvil-pr: anvil-pr-validate-prereqs \ anvil-pr-fast \ anvil-pr-slow @@ -5781,20 +5737,18 @@ _anvil-pr: anvil-pr-validate-prereqs \ # exhaustive checks that don't fit in a PR budget. Runs on a schedule # against `main`, not on PRs. # -# The scheduled tier is deliberately NOT impact-scoped: it is the -# catch-all that backstops PR-tier scoping. Because every impact-scoped -# check depends on `anvil-impact` and self-populates its scope from the -# cache, the tier must run with ANVIL_IMPACT=off so `_anvil-impact-include` -# returns each tier's full-workspace default and the `anvil-impact` -# dependency no-ops. A dependency-only recipe can't set env for its own -# deps (deps run before the body), so the public tier routes through -# `_anvil-run` with the `"off"` impact argument: `_anvil-run` exports -# ANVIL_IMPACT=off before invoking the private `_anvil-scheduled` recipe, -# whose deps then inherit it. +# The scheduled tier is deliberately NOT impact-scoped: it is the catch-all +# that backstops PR-tier scoping. Every impact-scoped check depends on +# `anvil-impact` and populates its own scope from the cache, so the tier runs +# with ANVIL_IMPACT=off, which makes `_anvil-impact-include` return each +# category's full-workspace default and the `anvil-impact` dependency no-op. +# A dependency-only recipe cannot set an environment variable for its own +# dependencies, so the public tier wraps the private one through +# `_anvil-unscoped`. # Run all scheduled checks. [group("anvil")] -anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off") +anvil-scheduled: (_anvil-unscoped "scheduled") [private] _anvil-scheduled: anvil-scheduled-validate-prereqs \ @@ -5803,16 +5757,15 @@ _anvil-scheduled: anvil-scheduled-validate-prereqs \ anvil-scheduled-runtime-analysis \ anvil-scheduled-exhaustive -# Runs everything full-workspace (ANVIL_IMPACT=off, via the `"off"` impact -# argument to _anvil-run), same wrapper shape as anvil-scheduled. +# Full-workspace for the same reason as the scheduled tier. # Full tier: PR + scheduled, end-to-end. Useful before tagging a release. [group("anvil")] -anvil-full: (_anvil-run "full" anvil_runner "off") +anvil-full: (_anvil-unscoped "full") [private] _anvil-full: anvil-full-validate-prereqs \ - _anvil-pr \ + anvil-pr \ _anvil-scheduled # Tier-level + global setup + validate-prereqs diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index dc0bc298..9b098f5f 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -2,33 +2,54 @@ source: crates/cargo-anvil/tests/snapshots.rs expression: render_tree(tmp.path()) --- -=== .anvil/container/Containerfile === +=== .anvil/container/Dockerfile === # syntax=docker/dockerfile:1 -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -ARG BASE_IMAGE=docker.io/library/debian:bookworm-slim@sha256:63a496b5d3b99214b39f5ed70eb71a61e590a77979c79cbee4faf991f8c0783e +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# >>> anvil-managed: anvil-container-base-image +# Prebuilt binaries installed by `anvil-setup binstall` link against this +# image's glibc, so it tracks the Linux runner the generated workflows use. +# Digest-pinned: a floating tag moves content under a reference that claims to +# name fixed content. +# +# Re-declare BASE_IMAGE in the gap below to build on another base; a later ARG +# wins, and the pins anvil maintains stay current. +ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +# <<< anvil-managed: anvil-container-base-image + +# >>> anvil-managed: anvil-container-base FROM ${BASE_IMAGE} -ARG ANVIL_IMAGE_ID ARG JUST_VERSION=1.56.0 ARG JUST_SHA256=fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 ARG POWERSHELL_VERSION=7.6.3 ARG POWERSHELL_SHA256=856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7 ARG RUSTUP_VERSION=1.29.0 ARG RUSTUP_SHA256=4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10 +ARG CARGO_BINSTALL_VERSION=1.21.1 +ARG CARGO_BINSTALL_SHA256=630c8f8803a686aa6779497f0f0fb51d49822fb5fc3c514d8ced33b34e338e6e ENV DEBIAN_FRONTEND=noninteractive \ CARGO_HOME=/usr/local/cargo \ RUSTUP_HOME=/usr/local/rustup \ RUSTUP_NO_UPDATE_CHECK=1 \ PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# <<< anvil-managed: anvil-container-base +# >>> anvil-managed: anvil-container-tools +# clang/libclang are required by cargo-spellcheck; the rest is the usual Rust +# link-time set. A bare base has no C runtime development files, so every link +# step fails without build-essential. RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential ca-certificates clang libclang-dev curl git libicu-dev \ libssl-dev pkg-config tar \ && rm -rf /var/lib/apt/lists/* +# pwsh is not optional: every generated anvil recipe is a `script("pwsh", +# "-NoProfile")` recipe. RUN curl -fsSLo /tmp/powershell.tar.gz \ "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-x64.tar.gz" \ && echo "${POWERSHELL_SHA256} /tmp/powershell.tar.gz" | sha256sum -c - \ @@ -52,1145 +73,87 @@ RUN curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ && /tmp/rustup-init -y --profile minimal --default-toolchain none --no-modify-path \ && rm /tmp/rustup-init -WORKDIR /opt/anvil -COPY . . -RUN test -f rust-toolchain.toml || { \ - echo "anvil-container requires rust-toolchain.toml" >&2; \ - exit 1; \ - } -RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry \ - --mount=type=cache,id=anvil-cargo-git,target=/usr/local/cargo/git \ - --mount=type=cache,id=anvil-cargo-target,target=/tmp/anvil-target \ - printf "anvil_runner := \"native\"\nimport 'justfiles/anvil/mod.just'\n" > Justfile \ - && CARGO_TARGET_DIR=/tmp/anvil-target just anvil-setup +RUN curl -fsSLo /tmp/cargo-binstall.tgz \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" \ + && echo "${CARGO_BINSTALL_SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c - \ + && mkdir -p "${CARGO_HOME}/bin" \ + && tar -xzf /tmp/cargo-binstall.tgz -C "${CARGO_HOME}/bin" cargo-binstall \ + && chmod 755 "${CARGO_HOME}/bin/cargo-binstall" \ + && rm /tmp/cargo-binstall.tgz -COPY .anvil/container/entrypoint.sh /usr/local/bin/anvil-container-entrypoint -RUN chmod 755 /usr/local/bin/anvil-container-entrypoint +# <<< anvil-managed: anvil-container-tools +# >>> anvil-managed: anvil-container-setup +# The whole recipe tree is copied because `just` parses it to reach the install +# recipes. +# +# The credential files are removed in the same layer that used them: a build +# secret never lands in a layer, but anything the install *writes* with it is +# ordinary content, and the `chmod` below would publish it world-readable. A +# later `RUN` cannot undo that, because the earlier layer keeps them. +# +# `registry` and `git` must exist before the `chmod`. The run mounts a named +# volume over each, and an engine seeds a new volume from the image path it +# covers; a path that does not exist seeds as root-owned 0755, which the +# `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +WORKDIR /opt/anvil +COPY justfiles ./justfiles +COPY rust-toolchain.toml ./ +RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ + && just anvil-setup binstall \ + && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ + && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME}/.netrc" \ + && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ + && chmod -R a+rwX "${CARGO_HOME}" "${RUSTUP_HOME}" +# <<< anvil-managed: anvil-container-setup + +# >>> anvil-managed: anvil-container-entry +# Consumed by `anvil-container` itself: a nested invocation from inside the +# image runs the recipe natively instead of launching another container. ENV ANVIL_IN_CONTAINER=1 -LABEL io.github.cargo-anvil.image-id="${ANVIL_IMAGE_ID}" + WORKDIR /workspace -ENTRYPOINT ["anvil-container-entrypoint"] CMD ["bash"] +# <<< anvil-managed: anvil-container-entry -=== .anvil/container/Containerfile.dockerignore === +=== .anvil/container/Dockerfile.dockerignore === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Deny-all allow-list for the image build context. +# Update the corresponding template in the cargo-anvil crate. +# +# BuildKit reads `.dockerignore` in preference to a root +# `.dockerignore`, so this scopes the exec-image build context without the +# repository having to own a root ignore file or having one silently overridden. # -# Docker matches each candidate against every pattern in order and lets the -# last match win, testing the path itself *and each of its parent directories* -# (moby/patternmatcher MatchesOrParentMatches). A bare directory re-inclusion -# such as `!justfiles` therefore re-admits the entire subtree below it, which -# would defeat this allow-list, so list only leaf patterns here. Docker still -# descends into a denied directory when some re-inclusion pattern is prefixed -# by it, so the intermediate directories need no entries of their own. +# The build context is the repository root but the image only needs two things. +# Excluding everything else keeps a cold build from streaming the whole +# worktree (and every stale `target/`) to the daemon. # -# Parent testing also reaches through a single-segment re-inclusion: a -# subdirectory of `.anvil/container/` matches `!.anvil/container/*` in its own -# right. The image-ID helpers list that directory one level deep, so a nested -# file is not an image input; `.anvil/container/*/*` states that leaf-only -# contract in the allow-list too, at every depth, because a deeper candidate -# always has an ancestor of exactly that shape. -** +# The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` +# so that a cold build does not stream unrelated trees to the daemon. The +# recipes are copied to drive `just anvil-setup`, which needs the whole tree to +# parse, and the whole tree is hashed into the image tag: the tier, group and +# check recipes decide which tools `anvil-setup` reaches, not just the catalog. +# +# `.anvil/container/` is admitted because the Dockerfile is composed: the gaps +# between anvil's regions exist for a repository to add its own instructions, +# and the headline case -- `COPY`ing a corporate root CA in before the first +# download -- needs the file to be in the context. Denying it would leave the +# gap documented but unusable for anything but `RUN`. It is also the directory +# the image tag digests, so what the context admits and what the tag covers stay +# the same set -- including the `.anvil-proposed` siblings both exclude, which +# are anvil's review artifacts rather than build inputs. +* +!justfiles +justfiles/* +!justfiles/anvil +justfiles/anvil/**/*.anvil-proposed +!.anvil +.anvil/* +!.anvil/container +.anvil/container/**/*.anvil-proposed !rust-toolchain.toml -!justfiles/anvil/*.just -!justfiles/anvil/checks/*.just -!justfiles/anvil/groups/*.just -!.anvil/container/* -.anvil/container/*/* -.anvil/container/customize.sh -.anvil/container/customize.ps1 - -=== .anvil/container/README.md === - - -# Run Anvil checks in a local container - -Use `just anvil-container` to run generated Anvil checks in a reproducible -Linux environment without installing the complete Rust and Cargo tool catalog -on the host. - -Native execution remains the default. The first container run builds an image -matching the repository's generated configuration. Later runs reuse that image, -dependency caches, and compilation output. - -## Quick start - -Ensure Docker Engine is running, then run: - -```text -just anvil-container anvil-clippy -``` - -The first run builds the matching image and can take several minutes. - -## Prerequisites - -- [Docker Engine](https://docs.docker.com/engine/install/) 23.0 or newer, - installed directly in Linux or WSL and usable by the current user. -- `git` and `just` on the host. -- Bash on Linux and WSL; PowerShell Core (`pwsh`) and WSL 2 on Windows. -- `[script]` support enabled in the root `Justfile`. Add `set unstable` when - required by the installed `just` version. -- A `rust-toolchain.toml` in the repository root. -- A Linux or WSL environment capable of running `linux/amd64` images, either - natively on x86-64 or through Docker emulation on ARM64. - -On Windows, the driver invokes Docker from the default WSL distribution rather -than calling Windows `docker.exe`. Regardless of how Docker is installed, this -command must succeed from PowerShell: - -```text -wsl -e docker version -``` - -Start the Docker service inside WSL when it is stopped and add the WSL user to -the `docker` group when non-root access is not already configured. Docker -Desktop is not required. - -On ARM64 hosts, Docker emulates the required `linux/amd64` environment. Image -builds and checks can therefore be substantially slower than on x86-64 hosts. - -## Security boundary - -> [!WARNING] -> `customize.sh` and `customize.ps1` execute on the host with the developer's -> permissions before container isolation begins. Reviewing and trusting these -> files is equivalent to reviewing and trusting any other host-executed script -> in the checked-out branch. - -## Common workflows - -Run one check: - -```text -just anvil-container anvil-clippy -``` - -Run the complete pull-request tier: - -```text -just anvil-container anvil-pr -``` - -Every argument is treated as a recipe name and must match `anvil-*` or -`_anvil-*`. Recipe parameters are not supported by this command surface. - -Open an interactive Bash shell in the image: - -```text -just anvil-container -``` - -### Use containers for tier commands - -Native execution remains the default. To route tier commands such as -`just anvil-pr` through the container for the current shell: - -```powershell -$env:ANVIL_RUNNER = "container" -just anvil-pr -``` - -On Unix: - -```sh -ANVIL_RUNNER=container just anvil-pr -``` - -For one invocation: - -```text -just anvil_runner=container anvil-pr -``` - -To make container execution the repository default, change the default value -in the `anvil-runner` region of the repository-root `Justfile` from `"native"` -to `"container"` and commit that policy. Set `ANVIL_RUNNER=native` to override -the repository default for the current shell. - -Tier routing starts a nested `just` invocation. Output and exit status are -preserved, but outer `--dry-run`, dependency introspection, global options, and -CLI variable assignments are not propagated to the selected private tier. -Values other than `native` and `container` are rejected. - -## Images and caches - -The image name includes a content-based tag derived from the repository's Rust -toolchain, generated Anvil recipes, and container build configuration. A -relevant change selects a new image automatically; older branches can continue -using their matching images. - -The following data is reused between runs: - -- the matching container image; -- repository-scoped Cargo registry and Cargo Git caches; -- compilation output in a repository- and image-specific `target` volume. - -The repository is mounted read/write at `/workspace`. Build output remains in a -named volume instead of the host `target/`, avoiding incompatible artifacts and -slow host-to-virtual-machine I/O. - -## GitHub authentication - -`anvil-aprz` and aggregate tiers that include it require GitHub API -authentication. The driver uses either: - -- the host `GITHUB_TOKEN`; or -- the token from an authenticated host `gh` session. - -Trusted customization can provision a short-lived token by setting -`GITHUB_TOKEN`; the driver reads it after loading and validating customization. - -Authenticate the GitHub CLI with: - -```text -gh auth login --hostname github.com -``` - -For an aggregate tier, the driver first runs `anvil-aprz` in a short-lived -container with the token mounted read-only. After it succeeds, the driver runs -the remaining checks in another container without the token. Temporary token -files are removed afterward. - -An interactive invocation can pause while you authenticate. A non-interactive -invocation fails with instructions when authentication is unavailable. - -## Configuration - -| Variable | Effect | -|---|---| -| `ANVIL_RUNNER` | Selects `native` or `container` execution for tier commands | -| `ANVIL_CONTAINER_BASE_IMAGE` | Selects a digest-pinned compatible Linux base image and changes the content-based tag | -| `ANVIL_CONTAINER_IMAGE` | Changes the local image name; the content-based tag is retained | -| `ANVIL_CONTAINER_NO_REBUILD=1` | Fails instead of building when the matching image is absent | - -The public driver builds images locally and does not pull -`ANVIL_CONTAINER_IMAGE` from a registry. - -The default base is digest-pinned Debian Bookworm. Set -`ANVIL_CONTAINER_BASE_IMAGE` to another image compatible with the generated -Debian-based `Containerfile` when a lower glibc baseline is required. A -different package ecosystem such as Azure Linux requires a derived -`Containerfile`. The value must use `image@sha256:` form so the -selected base remains part of the content-addressed image identity. - -Two simultaneous cold invocations can both build the same missing image. This -is accepted for local development: the content-addressed tag converges on the -same inputs, at the cost of duplicate work. - -## Troubleshooting - -| Problem | Resolution | -|---|---| -| Docker is not found on Linux or WSL | Install Docker Engine 23.0 or newer inside that environment | -| Docker is unavailable from Windows | Run `wsl -e docker version`; install or start Docker Engine in the default WSL distribution | -| Docker requires elevated access | Add the Linux/WSL user to the `docker` group, then start a new shell | -| ARM64 execution is slow | The current image is `linux/amd64` and runs through Docker emulation | -| `linux/amd64` cannot run | Configure Docker to run `linux/amd64` images | -| `[script]` recipes are unavailable | Enable `[script]` support; older `just` versions require `set unstable` | -| `rust-toolchain.toml` is missing | Add the repository-owned toolchain file at the repository root | -| GitHub authentication is unavailable | Run `gh auth login --hostname github.com` or set host `GITHUB_TOKEN` | -| A matching image is missing with `ANVIL_CONTAINER_NO_REBUILD=1` | Unset the variable to allow the local image build | -| The first run is slow | The initial image build installs the pinned tool catalog; later runs reuse it | - -Use `docker images anvil-dev` inside Linux or WSL to list locally cached -default Anvil images. - -## Managed files - -This directory is managed by `cargo-anvil`. Regenerate it with `cargo anvil` -instead of editing its files directly. - -> [!IMPORTANT] -> These assets previously lived in `justfiles/anvil/container/`. `cargo anvil` -> relocates the files it generated, but it does not track a hand-authored -> `customize.sh` or `customize.ps1`. Move any such file to -> `.anvil/container/` yourself; the driver only loads customization from the -> new location and warns on stderr when it finds one left behind. - -## Advanced repository customization - -A repository or derived catalog can add one trusted customization file per -supported host: - -```text -.anvil/container/customize.sh -.anvil/container/customize.ps1 -``` - -The driver sources the matching file as trusted host code before authentication, -image construction, and recipe execution. The documented customization -contract provides inputs and validated outputs for APRZ classification, build -secrets, dependency preparation, runtime arguments, and cleanup. - -Customization source is excluded from image identity and the build context. -Non-secret image behavior must be represented by hashed static files such as -the `Containerfile`, entrypoint, or supporting build scripts. - -See the [container customization contract](https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md#8-container-customization) -for the complete interface and security requirements. - -=== .anvil/container/entrypoint.sh === -#!/bin/sh -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -eu - -if [ "$(id -u)" -ne 0 ]; then - if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then - HOME="/tmp/anvil-user" - export HOME - fi - - user_cargo_home="$HOME/.cargo" - mkdir -p "$user_cargo_home" - for file in config.toml .crates.toml .crates2.json; do - if [ -r "$CARGO_HOME/$file" ]; then - cp -f "$CARGO_HOME/$file" "$user_cargo_home/$file" - fi - done - export CARGO_HOME="$user_cargo_home" - ln -sfn /usr/local/cargo/registry "$CARGO_HOME/registry" - ln -sfn /usr/local/cargo/git "$CARGO_HOME/git" -fi - -exec "$@" - -=== .anvil/container/image-id.ps1 === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$inputs = @( - 'rust-toolchain.toml' -) -$toolchainPath = Join-Path $repoRoot 'rust-toolchain.toml' -if (-not (Test-Path -LiteralPath $toolchainPath -PathType Leaf)) { - throw 'anvil-container requires a repository-owned rust-toolchain.toml.' -} -$containerPath = Join-Path $repoRoot '.anvil/container' -$containerRecipe = 'justfiles/anvil/container.just' -$containerfile = Join-Path $containerPath 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } -# The container entry recipe drives execution on the host; it is not image -# content, so it must not participate in image identity. -$inputs += Get-ChildItem (Join-Path $repoRoot 'justfiles/anvil') -Recurse -File -Filter '*.just' | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } | - Where-Object { -not $_.Equals($containerRecipe, $pathComparison) } -$executionOnly = @( - 'image-id.ps1', - 'image-id.sh', - 'README.md', - 'run-in-container.ps1', - 'run-in-container.sh', - 'customize.sh', - 'customize.ps1' -) -# customize.sh/customize.ps1 are trusted runtime orchestration, not image -# content: their source must never affect the image ID or build context. -# Static, non-secret build customization belongs in a hashed artifact instead. -$inputs += Get-ChildItem $containerPath -File | - Where-Object { $_.Name -notin $executionOnly } | - ForEach-Object { [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } -$uniqueInputs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($inputPath in $inputs) { - [void]$uniqueInputs.Add($inputPath) -} -$inputs = [string[]]$uniqueInputs -[Array]::Sort($inputs, [StringComparer]::Ordinal) - -$payload = [Text.StringBuilder]::new() -[void]$payload.Append("ANVIL_CONTAINER_BASE_IMAGE`n").Append($baseImage).Append("`n") -foreach ($relative in $inputs) { - $path = Join-Path $repoRoot $relative - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Container image input is missing: $relative" - } - $content = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n") - [void]$payload.Append($relative).Append("`n").Append($content).Append("`n") -} - -$bytes = [Text.Encoding]::UTF8.GetBytes($payload.ToString()) -$hash = [Security.Cryptography.SHA256]::HashData($bytes) -Write-Output ([Convert]::ToHexString($hash).ToLowerInvariant()) - -=== .anvil/container/image-id.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi - -toolchain_path="$repo_root/rust-toolchain.toml" -if [[ ! -f "$toolchain_path" ]]; then - echo 'anvil-container requires a repository-owned rust-toolchain.toml.' >&2 - exit 1 -fi - -container_dir="$repo_root/.anvil/container" -container_recipe="justfiles/anvil/container.just" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$container_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -inputs=(rust-toolchain.toml) -while IFS= read -r path; do - relative="${path#"$repo_root"/}" - # The container entry recipe drives execution on the host; it is not - # image content, so it must not participate in image identity. - if [[ "$relative" != "$container_recipe" ]]; then - inputs+=("$relative") - fi -done < <(find "$repo_root/justfiles/anvil" -type f -name '*.just' -print) - -for path in "$container_dir"/*; do - [[ -f "$path" ]] || continue - case "${path##*/}" in - image-id.ps1 | image-id.sh | README.md \ - | run-in-container.ps1 | run-in-container.sh \ - | customize.sh | customize.ps1) continue ;; - esac - inputs+=("${path#"$repo_root"/}") -done - -if command -v sha256sum >/dev/null 2>&1; then - hash_command=(sha256sum) -elif command -v shasum >/dev/null 2>&1; then - hash_command=(shasum -a 256) -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi - -write_normalized_file() { - local path="$1" - local line status - while true; do - line="" - if IFS= read -r line <&3; then - status=0 - else - status=$? - fi - if ((status != 0)) && [[ -z "$line" ]]; then - break - fi - printf '%s' "${line%$'\r'}" - if ((status == 0)); then - printf '\n' - else - break - fi - done 3<"$path" -} - -{ - printf 'ANVIL_CONTAINER_BASE_IMAGE\n%s\n' "$base_image" - while IFS= read -r relative; do - path="$repo_root/$relative" - if [[ ! -f "$path" ]]; then - echo "Container image input is missing: $relative" >&2 - exit 1 - fi - printf '%s\n' "$relative" - write_normalized_file "$path" - printf '\n' - done < <(printf '%s\n' "${inputs[@]}" | LC_ALL=C sort -u) -} | "${hash_command[@]}" | awk '{print $1}' - -=== .anvil/container/run-in-container.ps1 === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -[CmdletBinding()] -param( - [Parameter(Position = 0, ValueFromRemainingArguments = $true)] - [string[]]$Recipe -) - -$ErrorActionPreference = 'Stop' - -function ConvertTo-AnvilVersion([string]$Value) { - $match = [regex]::Match($Value, '^(\d+)\.(\d+)(?:\.(\d+))?') - if (-not $match.Success) { - throw "anvil-container: could not parse Docker Engine version '$Value'." - } - [version]::new( - [int]$match.Groups[1].Value, - [int]$match.Groups[2].Value, - $(if ($match.Groups[3].Success) { [int]$match.Groups[3].Value } else { 0 }) - ) -} - -function Test-AnvilContainerStringArray([string]$Name, $Value) { - if ($Value -isnot [array]) { - throw "anvil-container: `$$Name must be a string array." - } - foreach ($item in $Value) { - if ($item -isnot [string] -or [string]::IsNullOrEmpty($item)) { - throw "anvil-container: `$$Name entries must be non-empty strings." - } - } -} - -function Test-AnvilContainerBuildArgs($Value) { - for ($index = 0; $index -lt $Value.Count; $index++) { - $item = $Value[$index] - if ($item -eq '--secret') { - $index++ - if ($index -ge $Value.Count) { - throw 'anvil-container: $AnvilContainerBuildArgs requires a value after --secret.' - } - } elseif (-not $item.StartsWith('--secret=', [StringComparison]::Ordinal)) { - throw 'anvil-container: $AnvilContainerBuildArgs accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' - } - } -} - -function Test-AnvilRecipeNeedsGitHubToken([string]$Name) { - $Name -in @( - 'anvil-aprz', - 'anvil-scheduled', - '_anvil-scheduled', - 'anvil-scheduled-advisories', - '_anvil-scheduled-advisories', - 'anvil-full', - '_anvil-full' - ) -} - -function Get-AnvilGitHubToken { - $token = $env:GITHUB_TOKEN - if (-not $token -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { - $token = (& gh auth token --hostname github.com 2>$null) - if ($LASTEXITCODE -ne 0) { $token = $null } - } catch { - $token = $null - } - } - if ($token) { $token = $token.Trim() } - if ($token) { return $token } - return $null -} - -if ($env:ANVIL_IN_CONTAINER) { - if ($Recipe.Count -eq 0) { & bash } else { & just @Recipe } - exit $LASTEXITCODE -} - -foreach ($recipeArg in $Recipe) { - if ($recipeArg -notmatch '^_?anvil-[A-Za-z0-9-]+$') { - throw "anvil-container: expected each argument to be an anvil-* recipe, got '$recipeArg'." - } -} - -if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { - throw 'anvil-container: WSL 2 is required. See .anvil/container/README.md.' -} - -$versionText = (& wsl -e docker version --format '{{.Server.Version}}' 2>$null) -if ($LASTEXITCODE -ne 0 -or -not $versionText) { - throw 'anvil-container: `wsl -e docker version` must succeed. Install or start Docker Engine in the default WSL distribution; this driver does not invoke Windows docker.exe.' -} -$versionText = $versionText.Trim() -if ((ConvertTo-AnvilVersion $versionText) -lt [version]'23.0.0') { - throw "anvil-container: Docker Engine 23.0.0 or newer is required (found $versionText)." -} -$wslArchitecture = (& wsl -e uname -m 2>$null) -if ($LASTEXITCODE -eq 0 -and $wslArchitecture) { - $wslArchitecture = $wslArchitecture.Trim() - if ($wslArchitecture -notin @('x86_64', 'amd64')) { - [Console]::Error.WriteLine( - "anvil-container: warning: $wslArchitecture requires emulation for linux/amd64; builds and checks may be substantially slower." - ) - } -} - -$repoRoot = (git rev-parse --show-toplevel 2>$null).Trim() -if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { - throw 'anvil-container must run from a Git repository.' -} - -$scriptDir = Join-Path $repoRoot '.anvil/container' -$wslRepoRoot = (& wsl -e wslpath -a $repoRoot).Trim() -if ($LASTEXITCODE -ne 0 -or -not $wslRepoRoot) { - throw 'anvil-container: could not translate the repository path into the default WSL distribution.' -} -$wslScriptDir = "$wslRepoRoot/.anvil/container" -$containerfile = Join-Path $scriptDir 'Containerfile' -$baseImageMatch = [regex]::Match([IO.File]::ReadAllText($containerfile), '(?m)^ARG BASE_IMAGE=([^\r\n]+)') -if (-not $baseImageMatch.Success) { - throw 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' -} -$defaultBaseImage = $baseImageMatch.Groups[1].Value -$baseImage = if ($env:ANVIL_CONTAINER_BASE_IMAGE) { $env:ANVIL_CONTAINER_BASE_IMAGE } else { $defaultBaseImage } -if ($baseImage -notmatch '@sha256:[0-9a-fA-F]{64}$') { - throw 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' -} -$imageId = (& (Join-Path $scriptDir 'image-id.ps1')).Trim() -$imageBase = if ($env:ANVIL_CONTAINER_IMAGE) { $env:ANVIL_CONTAINER_IMAGE } else { 'anvil-dev' } -$image = "${imageBase}:$imageId" -$repoBytes = [Text.Encoding]::UTF8.GetBytes($wslRepoRoot) -$repoHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($repoBytes)).ToLowerInvariant() -$targetVolume = "anvil-target-$($repoHash.Substring(0, 12))-$($imageId.Substring(0, 12))" - -$needsGitHubToken = $false -foreach ($recipeArg in $Recipe) { - if (Test-AnvilRecipeNeedsGitHubToken $recipeArg) { - $needsGitHubToken = $true - break - } -} -$runsOnlyGitHubCheck = $Recipe.Count -eq 1 -and $Recipe[0] -eq 'anvil-aprz' - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -$null = & wsl -e docker image inspect $image 2>$null -$imageExists = $LASTEXITCODE -eq 0 - -New-Variable -Name AnvilContainerRepoRoot -Value $repoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDir -Value $scriptDir -Option ReadOnly -New-Variable -Name AnvilContainerRepoRootWsl -Value $wslRepoRoot -Option ReadOnly -New-Variable -Name AnvilContainerDirWsl -Value $wslScriptDir -Option ReadOnly -New-Variable -Name AnvilContainerResolvedImage -Value $image -Option ReadOnly -New-Variable -Name AnvilContainerImageExists -Value $imageExists -Option ReadOnly -New-Variable -Name AnvilContainerRequestedRecipes -Value $Recipe -Option ReadOnly -New-Variable -Name AnvilContainerHostIsWindows -Value ([bool]$IsWindows) -Option ReadOnly - -# Customization outputs, initialized before sourcing so a missing customize.ps1 -# leaves every phase a documented no-op. -$AnvilContainerBuildArgs = @() -$AnvilContainerPrepareArgs = @() -$AnvilContainerPrepareCommand = @() -$AnvilContainerRunArgs = @() -$AnvilContainerNeedsGitHubToken = $needsGitHubToken -$AnvilContainerCleanup = $null -$githubToken = $null -$githubTokenFile = $null -$exitCode = 0 -$customizeScript = Join-Path $scriptDir 'customize.ps1' -$legacyCustomizeScript = Join-Path $repoRoot 'justfiles/anvil/container/customize.ps1' - -try { - if (Test-Path -LiteralPath $customizeScript -PathType Leaf) { - . $customizeScript - } - elseif (Test-Path -LiteralPath $legacyCustomizeScript -PathType Leaf) { - [Console]::Error.WriteLine( - 'anvil-container: warning: ignoring justfiles/anvil/container/customize.ps1; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.ps1 to keep it active.' - ) - } - - Test-AnvilContainerStringArray 'AnvilContainerBuildArgs' $AnvilContainerBuildArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareArgs' $AnvilContainerPrepareArgs - Test-AnvilContainerStringArray 'AnvilContainerPrepareCommand' $AnvilContainerPrepareCommand - Test-AnvilContainerStringArray 'AnvilContainerRunArgs' $AnvilContainerRunArgs - Test-AnvilContainerBuildArgs $AnvilContainerBuildArgs - if ($AnvilContainerNeedsGitHubToken -isnot [bool]) { - throw 'anvil-container: $AnvilContainerNeedsGitHubToken must be a Boolean.' - } - $needsGitHubToken = $needsGitHubToken -or $AnvilContainerNeedsGitHubToken - if ($AnvilContainerPrepareArgs.Count -gt 0 -and $AnvilContainerPrepareCommand.Count -eq 0) { - throw 'anvil-container: $AnvilContainerPrepareArgs requires $AnvilContainerPrepareCommand.' - } - if ($AnvilContainerCleanup -and $AnvilContainerCleanup -isnot [scriptblock]) { - throw 'anvil-container: $AnvilContainerCleanup must be a script block.' - } - $githubToken = if ($needsGitHubToken) { Get-AnvilGitHubToken } else { $null } - if ($needsGitHubToken -and -not $githubToken) { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' - } - if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { - throw 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' - } - Write-Host 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' - [void](Read-Host 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel)') - $githubToken = Get-AnvilGitHubToken - if (-not $githubToken) { - throw 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' - } - } - if (-not $imageExists) { - if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { - throw "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." - } - & wsl -e docker build ` - --platform linux/amd64 ` - --tag $image ` - --file "$wslScriptDir/Containerfile" ` - --build-arg "ANVIL_IMAGE_ID=$imageId" ` - --build-arg "BASE_IMAGE=$baseImage" ` - @AnvilContainerBuildArgs ` - $wslRepoRoot - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker build failed with exit code $LASTEXITCODE." - } - } - - $containerUid = (& wsl -e id -u).Trim() - $containerGid = (& wsl -e id -g).Trim() - if ($containerUid -notmatch '^\d+$' -or $containerGid -notmatch '^\d+$') { - throw 'anvil-container: could not determine the default WSL user identity.' - } - $registryVolume = "anvil-cargo-registry-$($repoHash.Substring(0, 12))" - $gitVolume = "anvil-cargo-git-$($repoHash.Substring(0, 12))" - foreach ($volume in @($registryVolume, $gitVolume, $targetVolume)) { - $null = & wsl -e docker volume create $volume - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume creation failed for '$volume' with exit code $LASTEXITCODE." - } - } - $mountArgs = @( - '--mount', "type=bind,source=$wslRepoRoot,target=/workspace", - '--mount', "type=volume,source=$registryVolume,target=/usr/local/cargo/registry", - '--mount', "type=volume,source=$gitVolume,target=/usr/local/cargo/git", - '--mount', "type=volume,source=$targetVolume,target=/workspace/target" - ) - & wsl -e docker run --rm --pull=never ` - --platform linux/amd64 ` - --user 0:0 ` - @mountArgs ` - $image sh -c "chown ${containerUid}:${containerGid} /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: Docker volume initialization failed with exit code $LASTEXITCODE." - } - - $runArgs = @( - 'run', '--rm', '--pull=never', - '--platform', 'linux/amd64', - '--user', "${containerUid}:${containerGid}", - '--env', 'ANVIL_IN_CONTAINER=1', - '--env', 'HOME=/tmp/anvil-user', - '--workdir', '/workspace' - ) - $runArgs += $mountArgs - $prepareRunArgs = @($runArgs) - $runArgs += $AnvilContainerRunArgs - foreach ($name in @( - 'PR_TITLE', - 'BASE_REF', - 'ANVIL_IMPACT', - 'GITHUB_BASE_REF', - 'SYSTEM_PULLREQUEST_TARGETBRANCH' - )) { - if (Test-Path "Env:$name") { - $runArgs += @('--env', "$name=$((Get-Item "Env:$name").Value)") - } - } - if ($AnvilContainerPrepareCommand.Count -gt 0) { - & wsl -e docker @prepareRunArgs @AnvilContainerPrepareArgs $image @AnvilContainerPrepareCommand - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: preparation command failed with exit code $LASTEXITCODE." - } - } - - if ($githubToken) { - $githubTokenFile = Join-Path ([IO.Path]::GetTempPath()) "anvil-github-token-$PID-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Create($githubTokenFile).Dispose() - if ($IsWindows) { - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls.exe $githubTokenFile '/inheritance:r' '/grant:r' "*$($userSid):(F)" | Out-Null - } else { - & chmod 600 $githubTokenFile - } - if ($LASTEXITCODE -ne 0) { - throw 'anvil-container: failed to restrict permissions on the temporary GitHub token file.' - } - [IO.File]::WriteAllText($githubTokenFile, $githubToken, [Text.Encoding]::ASCII) - $githubToken = $null - $wslTokenFile = (& wsl -e wslpath -a $githubTokenFile).Trim() - if ($LASTEXITCODE -ne 0 -or -not $wslTokenFile) { - throw 'anvil-container: could not translate the temporary GitHub token path into WSL.' - } - $githubRunArgs = @($runArgs) - $githubRunArgs += @( - '--mount', - "type=bind,source=$wslTokenFile,target=/run/secrets/anvil-github-token,readonly" - ) - if ($runsOnlyGitHubCheck) { - $runArgs = $githubRunArgs - } else { - & wsl -e docker @githubRunArgs $image just anvil-aprz - if ($LASTEXITCODE -ne 0) { - throw "anvil-container: isolated anvil-aprz failed with exit code $LASTEXITCODE." - } - $runArgs += @('--env', 'ANVIL_APRZ_ALREADY_RAN=1') - } - } - - if ($Recipe.Count -eq 0) { - & wsl -e docker @runArgs --interactive --tty $image bash - } else { - & wsl -e docker @runArgs $image just @Recipe - } - $exitCode = $LASTEXITCODE -} finally { - if ($githubTokenFile) { - Remove-Item -LiteralPath $githubTokenFile -Force -ErrorAction SilentlyContinue - } - if ($AnvilContainerCleanup) { & $AnvilContainerCleanup } -} - -exit $exitCode - -=== .anvil/container/run-in-container.sh === -#!/usr/bin/env bash -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -set -euo pipefail - -if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - if (($# == 0)); then exec bash; else exec just "$@"; fi -fi - -for recipe_arg in "$@"; do - if [[ ! "$recipe_arg" =~ ^_?anvil-[A-Za-z0-9-]+$ ]]; then - echo "anvil-container: expected each argument to be an anvil-* recipe, got '$recipe_arg'." >&2 - exit 2 - fi -done - -anvil_recipe_needs_github_token() { - case "$1" in - anvil-aprz | anvil-scheduled | _anvil-scheduled | anvil-scheduled-advisories | _anvil-scheduled-advisories \ - | anvil-full | _anvil-full) return 0 ;; - *) return 1 ;; - esac -} - -version_at_least() { - local found="${1%%[-+]*}" - local required="${2%%[-+]*}" - local found_major found_minor found_patch found_extra - local required_major required_minor required_patch required_extra - IFS=. read -r found_major found_minor found_patch found_extra <<<"$found" - IFS=. read -r required_major required_minor required_patch required_extra <<<"$required" - found_patch="${found_patch:-0}" - required_patch="${required_patch:-0}" - for component in \ - "$found_major" "$found_minor" "$found_patch" \ - "$required_major" "$required_minor" "$required_patch" - do - case "$component" in - '' | *[!0-9]*) return 2 ;; - esac - done - if ((found_major != required_major)); then ((found_major > required_major)); return; fi - if ((found_minor != required_minor)); then ((found_minor > required_minor)); return; fi - ((found_patch >= required_patch)) -} - -command -v docker >/dev/null 2>&1 || { - echo "anvil-container: Docker Engine is required. See .anvil/container/README.md." >&2 - exit 1 -} - -version="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" || { - echo "anvil-container: Docker Engine is unavailable. Start the Docker service and ensure the current user can access it." >&2 - exit 1 -} -minimum="23.0.0" -if ! version_at_least "$version" "$minimum"; then - echo "anvil-container: Docker Engine $minimum or newer is required (found $version)." >&2 - exit 1 -fi -host_arch="$(uname -m 2>/dev/null || true)" -case "$host_arch" in - x86_64 | amd64 | '') ;; - *) echo "anvil-container: warning: $host_arch requires emulation for linux/amd64; builds and checks may be substantially slower." >&2 ;; -esac - -if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - echo 'anvil-container must run from a Git repository.' >&2 - exit 1 -fi -script_dir="$repo_root/.anvil/container" -default_base_image="$(sed -n 's/^ARG BASE_IMAGE=//p' "$script_dir/Containerfile" | head -n 1)" -if [[ -z "$default_base_image" ]]; then - echo 'anvil-container: Containerfile must define ARG BASE_IMAGE=.' >&2 - exit 1 -fi -base_image="${ANVIL_CONTAINER_BASE_IMAGE:-$default_base_image}" -if [[ ! "$base_image" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then - echo 'anvil-container: ANVIL_CONTAINER_BASE_IMAGE must be pinned by sha256 digest (image@sha256:<64 hex characters>).' >&2 - exit 1 -fi -image_id="$(bash "$script_dir/image-id.sh")" -image_base="${ANVIL_CONTAINER_IMAGE:-anvil-dev}" -image="${image_base}:${image_id}" -if command -v sha256sum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | sha256sum | cut -c1-12)" -elif command -v shasum >/dev/null 2>&1; then - repo_id="$(printf '%s' "$repo_root" | shasum -a 256 | cut -c1-12)" -else - echo 'anvil-container: sha256sum or shasum is required.' >&2 - exit 1 -fi -target_volume="anvil-target-${repo_id}-${image_id:0:12}" - -needs_github_token=false -for recipe_arg in "$@"; do - if anvil_recipe_needs_github_token "$recipe_arg"; then - needs_github_token=true - break - fi -done -runs_only_github_check=false -if (($# == 1)) && [[ "$1" == "anvil-aprz" ]]; then - runs_only_github_check=true -fi -github_token="" - -# Customization contract: check warm/cold state before sourcing so -# customization needed only for image construction can be skipped on a warm -# run, then expose read-only inputs. See docs/design/containers.md. -if docker image inspect "$image" >/dev/null 2>&1; then - image_exists=true -else - image_exists=false -fi - -readonly ANVIL_CONTAINER_REPO_ROOT="$repo_root" -readonly ANVIL_CONTAINER_DIR="$script_dir" -readonly ANVIL_CONTAINER_RESOLVED_IMAGE="$image" -readonly ANVIL_CONTAINER_IMAGE_EXISTS="$image_exists" -declare -a ANVIL_CONTAINER_REQUESTED_RECIPES=("$@") -readonly ANVIL_CONTAINER_REQUESTED_RECIPES - -# Customization outputs, initialized before sourcing so a missing customize.sh -# leaves every phase a documented no-op. -ANVIL_CONTAINER_BUILD_ARGS=() -ANVIL_CONTAINER_PREPARE_ARGS=() -ANVIL_CONTAINER_PREPARE_COMMAND=() -ANVIL_CONTAINER_RUN_ARGS=() -ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN="$needs_github_token" -ANVIL_CONTAINER_CLEANUP=: -github_token_file="" -cleanup() { - if [[ -n "$github_token_file" ]]; then rm -f -- "$github_token_file"; fi - "$ANVIL_CONTAINER_CLEANUP" -} -trap cleanup EXIT - -customize_script="$script_dir/customize.sh" -legacy_customize_script="$repo_root/justfiles/anvil/container/customize.sh" -if [[ ! -f "$customize_script" && -f "$legacy_customize_script" ]]; then - echo "anvil-container: warning: ignoring justfiles/anvil/container/customize.sh; container assets moved to .anvil/container/. Move the file to .anvil/container/customize.sh to keep it active." >&2 -fi -if [[ -f "$customize_script" ]]; then - # shellcheck source=/dev/null - source "$customize_script" -fi - -# Bash 3.2 has neither namerefs (the nameref flag on `local`/`declare`, Bash -# 4.3+) nor safe `set -u` expansion of empty-but- -# declared arrays (fixed in Bash 4.4). Elements are passed positionally -# instead of by nameref, and every expansion of a possibly-empty array uses -# the `${arr[@]+"${arr[@]}"}` idiom: unset/empty-under-old-Bash arrays vanish -# entirely instead of raising "unbound variable", while non-empty arrays -# still expand element-for-element. -anvil_container_validate_array() { - local name="$1" - shift - local declaration value - declaration="$(declare -p "$name" 2>/dev/null || true)" - if [[ ! "$declaration" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ ]]; then - echo "anvil-container: $name must be a string array." >&2 - exit 1 - fi - for value in "$@"; do - if [[ -z "$value" ]]; then - echo "anvil-container: $name entries must be non-empty strings." >&2 - exit 1 - fi - done -} -anvil_container_validate_build_args() { - local expect_secret_value=false value - for value in "$@"; do - if "$expect_secret_value"; then - expect_secret_value=false - continue - fi - case "$value" in - --secret) expect_secret_value=true ;; - --secret=*) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS accepts only BuildKit --secret arguments; static build behavior must use hashed container files.' >&2 - exit 1 - ;; - esac - done - if "$expect_secret_value"; then - echo 'anvil-container: ANVIL_CONTAINER_BUILD_ARGS requires a value after --secret.' >&2 - exit 1 - fi -} -anvil_container_validate_array ANVIL_CONTAINER_BUILD_ARGS ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_ARGS ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_PREPARE_COMMAND ${ANVIL_CONTAINER_PREPARE_COMMAND[@]+"${ANVIL_CONTAINER_PREPARE_COMMAND[@]}"} -anvil_container_validate_array ANVIL_CONTAINER_RUN_ARGS ${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"} -anvil_container_validate_build_args ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} -case "$ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN" in - true) needs_github_token=true ;; - false) ;; - *) - echo 'anvil-container: ANVIL_CONTAINER_NEEDS_GITHUB_TOKEN must be true or false.' >&2 - exit 1 - ;; -esac -if ((${#ANVIL_CONTAINER_PREPARE_ARGS[@]} > 0)) && ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} == 0)); then - echo 'anvil-container: ANVIL_CONTAINER_PREPARE_ARGS requires ANVIL_CONTAINER_PREPARE_COMMAND.' >&2 - exit 1 -fi -cleanup_kind="$(type -t "$ANVIL_CONTAINER_CLEANUP" 2>/dev/null || true)" -if [[ "$cleanup_kind" != "function" && "$cleanup_kind" != "builtin" ]]; then - echo "anvil-container: ANVIL_CONTAINER_CLEANUP must name a callable function (got '$ANVIL_CONTAINER_CLEANUP')." >&2 - exit 1 -fi - -if "$needs_github_token"; then - gh_command="" - if command -v gh >/dev/null 2>&1; then - gh_command=gh - elif command -v gh.exe >/dev/null 2>&1; then - gh_command=gh.exe - fi - github_token="${GITHUB_TOKEN:-}" - if [[ -z "$github_token" && -n "$gh_command" ]]; then - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - fi - if [[ -z "$github_token" ]]; then - if [[ -z "$gh_command" ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Install the GitHub CLI and run `gh auth login --hostname github.com`, or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - if [[ ! -t 0 ]]; then - echo 'anvil-container: GitHub authentication is required for anvil-aprz. Run `gh auth login --hostname github.com` or set GITHUB_TOKEN before rerunning.' >&2 - exit 1 - fi - echo 'anvil-container: anvil-aprz requires GitHub authentication to avoid the 60 requests/hour unauthenticated API limit.' >&2 - read -r -p 'Run `gh auth login --hostname github.com` in another terminal, then press Enter to continue (Ctrl+C to cancel) ' - github_token="$("$gh_command" auth token --hostname github.com 2>/dev/null | tr -d '\r' || true)" - if [[ -z "$github_token" ]]; then - echo 'anvil-container: GitHub authentication is still unavailable. Complete `gh auth login --hostname github.com`, then rerun.' >&2 - exit 1 - fi - fi -fi - -if ! "$image_exists"; then - if [[ "${ANVIL_CONTAINER_NO_REBUILD:-}" == "1" ]]; then - echo "anvil-container: image $image is missing and ANVIL_CONTAINER_NO_REBUILD=1." >&2 - exit 1 - else - docker build \ - --platform linux/amd64 \ - --tag "$image" \ - --file "$script_dir/Containerfile" \ - --build-arg "ANVIL_IMAGE_ID=$image_id" \ - --build-arg "BASE_IMAGE=$base_image" \ - ${ANVIL_CONTAINER_BUILD_ARGS[@]+"${ANVIL_CONTAINER_BUILD_ARGS[@]}"} \ - "$repo_root" - fi -fi - -container_uid="$(id -u)" -container_gid="$(id -g)" -registry_volume="anvil-cargo-registry-${repo_id}" -git_volume="anvil-cargo-git-${repo_id}" -for volume in "$registry_volume" "$git_volume" "$target_volume"; do - docker volume create "$volume" >/dev/null -done -mount_args=( - --mount "type=bind,source=$repo_root,target=/workspace" - --mount "type=volume,source=$registry_volume,target=/usr/local/cargo/registry" - --mount "type=volume,source=$git_volume,target=/usr/local/cargo/git" - --mount "type=volume,source=$target_volume,target=/workspace/target" -) -docker run --rm --pull=never \ - --platform linux/amd64 \ - --user 0:0 \ - "${mount_args[@]}" \ - "$image" sh -c \ - "chown $container_uid:$container_gid /usr/local/cargo/registry /usr/local/cargo/git /workspace/target" - -run_args=( - run --rm --pull=never - --platform linux/amd64 - --user "$container_uid:$container_gid" - --env ANVIL_IN_CONTAINER=1 - --env HOME=/tmp/anvil-user - "${mount_args[@]}" - --workdir /workspace -) -prepare_run_args=("${run_args[@]}") -run_args+=(${ANVIL_CONTAINER_RUN_ARGS[@]+"${ANVIL_CONTAINER_RUN_ARGS[@]}"}) -for name in PR_TITLE BASE_REF ANVIL_IMPACT GITHUB_BASE_REF SYSTEM_PULLREQUEST_TARGETBRANCH; do - if value="$(printenv "$name")"; then run_args+=(--env "$name=$value"); fi -done -if ((${#ANVIL_CONTAINER_PREPARE_COMMAND[@]} > 0)); then - docker "${prepare_run_args[@]}" \ - ${ANVIL_CONTAINER_PREPARE_ARGS[@]+"${ANVIL_CONTAINER_PREPARE_ARGS[@]}"} \ - "$image" \ - "${ANVIL_CONTAINER_PREPARE_COMMAND[@]}" -fi - -if [[ -n "$github_token" ]]; then - github_token_file="$(mktemp "${TMPDIR:-/tmp}/anvil-github-token.XXXXXXXX")" - chmod 600 "$github_token_file" - printf '%s' "$github_token" > "$github_token_file" - unset github_token - github_run_args=( - "${run_args[@]}" - --mount "type=bind,source=$github_token_file,target=/run/secrets/anvil-github-token,readonly" - ) - if "$runs_only_github_check"; then - run_args=("${github_run_args[@]}") - else - docker "${github_run_args[@]}" "$image" just anvil-aprz - run_args+=(--env ANVIL_APRZ_ALREADY_RAN=1) - fi -fi - -if (($# == 0)); then - docker "${run_args[@]}" --interactive --tty "$image" bash - exit $? -fi -docker "${run_args[@]}" "$image" just "$@" === .delta.toml === # >>> anvil-managed: anvil-delta @@ -1327,10 +290,6 @@ clippy.wildcard_imports = "allow" import 'justfiles/anvil/mod.just' # <<< anvil-managed: anvil-imports -# >>> anvil-managed: anvil-runner -anvil_runner := env_var_or_default("ANVIL_RUNNER", "native") -# <<< anvil-managed: anvil-runner - === clippy.toml === # >>> anvil-managed: anvil-clippy # Fine-tuning settings for clippy lints. These cannot be expressed in @@ -1428,13 +387,15 @@ unknown-git = "deny" # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md # cargo-aprz queries the GitHub advisory API. Unauthenticated access is -# capped at 60 requests/hour and fails on a full run; an authenticated -# token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Container drivers mount an existing host GITHUB_TOKEN -# or the host gh CLI's stored token as a temporary read-only secret. -# Native runs borrow the gh CLI token directly. Native runs warn and -# proceed unauthenticated if neither is available; container runs fail -# before cargo-aprz can exhaust the unauthenticated rate limit. +# capped at 60 requests an hour, and on a full workspace it exhausts that +# and then waits for the quota to reset rather than failing; an +# authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -1442,26 +403,16 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if ($env:ANVIL_APRZ_ALREADY_RAN -eq '1') { - Write-Host 'anvil-aprz: already completed in an isolated authenticated container' - exit 0 - } if (-not $env:GITHUB_TOKEN) { $tok = $null - $containerTokenFile = '/run/secrets/anvil-github-token' - if ($env:ANVIL_IN_CONTAINER -and (Test-Path -LiteralPath $containerTokenFile -PathType Leaf)) { - try { $tok = Get-Content -LiteralPath $containerTokenFile -Raw } catch { $tok = $null } - } elseif (Get-Command gh -ErrorAction SilentlyContinue) { + if (Get-Command gh -ErrorAction SilentlyContinue) { try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } } if ($tok) { $env:GITHUB_TOKEN = $tok.Trim() } else { - if ($env:ANVIL_IN_CONTAINER) { - throw 'anvil-aprz: GitHub authentication is unavailable. Run `gh auth login` on the host or set host GITHUB_TOKEN, then re-run the container command.' - } Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API (60 requests/hour) and may fail on a full run.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' } } @@ -2724,7 +1675,14 @@ anvil-mutants-diff: anvil-mutants-diff-validate-prereqs anvil-impact if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' - git diff "$base..HEAD" --output=$diff_path + # Diff the base against the WORKING TREE, not against HEAD. + # cargo-mutants validates every line of the diff against the file on + # disk and aborts when they disagree, so a commit-to-commit diff fails + # the moment anything is uncommitted -- which is the normal local state, + # since the point of running a tier locally is to check work in progress. + # CI has a clean tree, so the two forms are identical there and this is + # not a behaviour change for it. + git diff "$base" --output=$diff_path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -3325,27 +2283,1067 @@ anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-too # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. # Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +# +# Containerized execution. `just anvil-container ` runs the given +# argv inside a pinned Linux image; everything else keeps running natively. +# Anvil recipes are reached by naming `just`, like any other command. +# There is no configuration file and no transparent routing: the container is +# reached through this recipe or not at all. +# +# The image tag *is* a hash of the inputs that define it, so the presence of a +# tag is proof that its contents are current -- a changed tool pin names a tag +# that cannot already exist, and a build follows. There is nothing to keep in +# sync and no staleness to detect. +# +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md + +# The container engine, `docker` or `podman`. A host property, never +# committed. Set it in your environment: a `just anvil_container_engine=...` +# override would not reach the nested invocations that resolve the engine. +anvil_container_engine := env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker") + +# Where the repository is mounted inside the container. +anvil_container_workdir := "/workspace" + +# Image and cache-volume prefix, derived from the repository directory name. +# Two checkouts with the same directory name share cache volumes; that is +# harmless (the caches are content-addressed by cargo) but worth knowing before +# `anvil-container-down` removes volumes another checkout is also using. +# +# Every run of non-alphanumerics collapses to a single `-`, and a trailing one +# is trimmed, because a repository name may not end in a separator or repeat +# `.`/`_`. Without that, a checkout in `ox-tools (copy)` yields a reference the +# engine rejects as malformed, from a directory name nobody would suspect. +anvil_container_name := trim_end_matches("anvil-" + replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-"), "-") + +# Resolve how to invoke the engine, as a pipe-separated command. +# +# There is deliberately no probe *between* engines: presence is not +# reachability, and a silent choice between two installed engines means two +# image stores and an unexplained rebuild. We check that the requested binary +# exists and let every other failure surface the engine's own diagnostic, which +# is more accurate than anything repeated here. +# +# The one fallback is Windows-specific and unambiguous: when the engine is not +# on the Windows PATH, try it inside the default WSL distribution. Installing +# Docker in WSL without Docker Desktop is a documented, common setup, and it +# leaves no Windows CLI behind, so without this fallback a correctly installed +# engine would be unreachable. Docker Desktop and Podman both ship a Windows +# CLI and are found on PATH, so they never take this path. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-engine: + $ErrorActionPreference = 'Stop' + $engine = '{{ replace(anvil_container_engine, "'", "''") }}' + if ($engine -ne 'docker' -and $engine -ne 'podman') { + Write-Error "anvil: ANVIL_CONTAINER_ENGINE must be 'docker' or 'podman', got '$engine'" + exit 1 + } + if (Get-Command $engine -ErrorAction SilentlyContinue) { + Write-Output $engine + exit 0 + } + # --exec, not --: `wsl.exe -- ` hands the rest of the command line to + # the distribution's default shell, which expands $NAME, splits on ;, and + # eats backslashes. Every argument we forward -- the repository path and + # the recipe's own arguments -- would cross that boundary unquoted. + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $engine --version *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "wsl.exe|--exec|$engine" + exit 0 + } + } + Write-Error "anvil: '$engine' was not found on PATH, and is not usable in the default WSL distribution. Install it, or set ANVIL_CONTAINER_ENGINE to the other engine. Setup: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md" + exit 1 + +# Translate a host path into what the engine sees. +# +# Identical when the engine runs on this host. When it runs in WSL, a Windows +# path has to become its /mnt/... form or the daemon silently bind-mounts an +# empty directory -- a failure that surfaces much later, as a missing file +# inside the container. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-path host_path: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + if (-not $engine.StartsWith('wsl.exe|')) { + Write-Output '{{ replace(host_path, "'", "''") }}' + exit 0 + } + # --exec for the reason given above. It matters most here: through a shell, + # a path holding `$` loses it, and `wslpath -a` then makes the *truncated* + # path absolute and exits 0, so the guard below never fires and the wrong + # directory is bind-mounted. + $hostPath = '{{ replace(host_path, "'", "''") }}' + $translated = & wsl.exe --exec wslpath -a -u $hostPath + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: could not translate '$hostPath' for the engine running in WSL" + exit 1 + } + Write-Output $translated.Trim() + +# Verify the composed Dockerfile is present under the name the build uses. +# +# The engine derives the ignore file's name from the Dockerfile's: BuildKit +# reads `.dockerignore` and there is no flag to point it elsewhere. +# Anvil owns that artifact at the fixed path `.anvil/container/Dockerfile.dockerignore`, +# so the two names have to agree, and only one of them can move. A case variant +# is therefore refused rather than accommodated: building from `dockerfile` +# would silently use no ignore file at all, streaming the whole worktree into +# the build context and admitting inputs the tag does not cover. +# +# Also the one place that asserts the file exists: the tag's directory walk +# cannot, because a missing Dockerfile simply contributes nothing to the hash +# and yields a confident tag for an image that can never be built. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-dockerfile: + $ErrorActionPreference = 'Stop' + $dir = Join-Path '{{ replace(justfile_directory(), "'", "''") }}' '.anvil/container' + $entries = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue) + # -ceq because PowerShell's -eq on strings is case-insensitive, which would + # make the exact name indistinguishable from a variant on a filesystem that + # can hold both. + if (@($entries | Where-Object { $_.Name -ceq 'Dockerfile' }).Count -eq 1) { + Write-Output '.anvil/container/Dockerfile' + exit 0 + } + $variant = @($entries | Where-Object { $_.Name -ieq 'Dockerfile' })[0] + if ($variant) { + Write-Error "anvil: the container image input must be named exactly '.anvil/container/Dockerfile', but this repository has '.anvil/container/$($variant.Name)'. The engine reads the ignore file as '.dockerignore', and anvil maintains '.anvil/container/Dockerfile.dockerignore', so a differently-cased name would build with no ignore file. Rename it." + exit 1 + } + Write-Error 'anvil: container image input is missing: .anvil/container/Dockerfile' + exit 1 -# Run any Anvil recipe in the pinned local Linux container. With no recipe, -# open an interactive shell. -[windows] +# Print the exec image reference for the current inputs, without building it. +# +# The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its +# ignore file, the pinned toolchain, the optional hook, and the whole generated +# recipe tree -- because the image installs its tools by running +# `just anvil-setup`, whose dependency chain reaches the tier, group, check and +# tool recipes alike. Editing any of them can change what the image contains, so +# any of them can rename it. +# +# This is the only recipe that computes the reference; everything else asks it. +# It is public because a publisher needs the tag before there is an image to +# inspect: a pipeline that builds the image tags the result with exactly the +# reference a consumer will later compute, which is what lets presence be +# checked without a second source of truth. + +# Print the exec image reference for the current inputs, without building it. [group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-container *recipe: - $requested = @('{{ replace(recipe, "'", "''") }}' -split '\s+' | Where-Object { $_ }) - & '.anvil/container/run-in-container.ps1' @requested - exit $LASTEXITCODE +anvil-container-tag: + $ErrorActionPreference = 'Stop' + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $inputs = @('rust-toolchain.toml') + $links = @() + # The declared input and the two walk roots are checked here, because a walk + # only ever reports descendants: a link that *is* the root is traversed or + # read through and never appears in its own output. Same hazard as a link + # below them -- the engine copies the link while everything here follows it. + foreach ($declared in @('rust-toolchain.toml', '.anvil/container', 'justfiles/anvil')) { + $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $links += $item.FullName + } + } + # The declared inputs are text this tree owns, so their line endings are + # normalized before hashing and a CRLF checkout agrees with an LF one. + # Everything discovered by walking a directory is treated as text only when + # it is a `.just` recipe; anything else is hashed as the bytes the build + # context actually copies. + $declaredText = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + [void]$declaredText.Add($dockerfile) + [void]$declaredText.Add("$dockerfile.dockerignore") + [void]$declaredText.Add($hookRel) + # Everything under `.anvil/container/`, not a fixed list of three files. + # The Dockerfile is composed -- anvil owns regions inside it and the + # repository owns the gaps -- and a repository that adds a `COPY` in one of + # those gaps names a file that shapes the image: a corporate root CA, an + # install script, a patch. A replacement region from a downstream catalog + # does the same. Hashing only the three files anvil happens to know about + # would let any of them change the image under a reference that already + # resolves, which is precisely the hole this digest exists to close. + # + # The hook is picked up by the same walk. Its *output* is deliberately + # never hashed: a credential must not influence a tag. + # + # `.anvil-proposed` siblings are excluded. A region proposal is anvil's own + # review artifact, written beside its host when a template moves under a + # customized region; the build cannot see it and it cannot change what the + # image contains. Digesting it would rename the image for as long as a + # proposal sat undismissed, so two checkouts of one commit would disagree + # on the tag and a published image would stop resolving. + $containerRoot = Join-Path $repoRoot '.anvil/container' + if (Test-Path -LiteralPath $containerRoot) { + foreach ($file in Get-ChildItem -LiteralPath $containerRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + # Every generated recipe file. The image installs its tools by running + # `just anvil-setup`, and that dependency chain runs through the tier, + # group and check recipes before it reaches the install recipes in + # tools.just -- so the routing decides *whether* a tool is installed just + # as surely as tools.just decides *how*. Hashing only the install + # definitions would let a group drop a `-setup` dependency, changing the + # installed set, without renaming the image. + # + # This driver is included too. It is not circular -- the tag is derived + # from file text, and no file contains the tag -- and it belongs in the set + # because it passes the build arguments, the secret mounts and the hook's + # `Anvil-BuildSecrets` output into the build, all of which shape the result. + # Every file in the generated recipe tree, not only `*.just`. The build + # context admits the whole `justfiles/anvil/` directory (see the ignore + # file), so anything an adopter drops there is copied into the image. The + # catalog refuses to *own* a non-recipe file there, but a repository can + # still add one by hand, and a file that reaches the image without reaching + # the tag is precisely the hole this digest exists to close. Hashing what + # the context copies keeps the two sets identical by construction. + # + # -Force because Get-ChildItem omits hidden entries otherwise: a + # dot-prefixed file is copied like any other, and skipping it would let its + # edits ride under an unchanged tag -- and make Windows and Unix disagree. + # + # `.anvil-proposed` siblings are excluded here for the same reason as under + # `.anvil/container/`: this driver is itself an owned artifact, so a + # repository that customizes it gets the proposal written right here. + $recipeRoot = Join-Path $repoRoot 'justfiles/anvil' + if (Test-Path -LiteralPath $recipeRoot) { + foreach ($file in Get-ChildItem -LiteralPath $recipeRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + + # A symlink is refused rather than digested. The engine copies the link + # itself while any reading of it here follows it, so a retarget changes the + # image without changing a single byte the walk can see, and a link to a + # directory is not enumerated by the walk at all. Framing link text instead + # would have to work on Windows, where git materializes a symlink as an + # ordinary file unless the checkout was privileged, so the same commit would + # digest differently per platform. Anvil never creates one under these + # trees, so refusing costs nothing and closes the whole class. + if ($links.Count -gt 0) { + $named = ($links | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' }) -join ', ' + Write-Error "anvil: the container image inputs must be regular files, but these are links: $named. The engine copies a link as a link while the image tag is computed from what it points at, so the image would not match its own reference. Replace them with regular files." + exit 1 + } + + # Hash a tagged stream rather than raw concatenation, so no rearrangement of + # names and contents can collide. Line endings are normalized once, here, so + # a CRLF checkout and an LF checkout agree on the tag. Ordinal sort and dedup: + # `Sort-Object -Unique` compares case-insensitively, which would silently drop + # one of two inputs differing only in case on the case-sensitive filesystem + # where the image is actually built. + # Length-prefix the path and the content rather than relying on newlines as + # separators. A bare `file\n\n\n` stream is not + # self-delimiting: content is arbitrary, so a file whose body contains + # "file\n\n" serializes identically to two files whose bodies + # split at that point. That makes distinct input sets nameable by one tag -- + # a hook that exists versus an ignore file whose body ends in the hook's + # path and body, for instance -- and the second state would silently reuse + # the first state's image. Byte counts cannot be forged by content. + # + # Content is hashed as BYTES, not as decoded text. `ReadAllText` decodes + # UTF-8 with a replacing fallback, so every invalid sequence becomes U+FFFD + # before it is hashed: a one-byte file of 0xFF and one of 0xFE both collapse + # to the same replacement character and produce the same tag, while `COPY` + # puts their real, different bytes in the image. That was unreachable while + # only `*.just` was hashed and became reachable the moment the input set + # widened to everything the build context copies -- which is precisely the + # class of file (a stray `.png`, a `.DS_Store`, a UTF-16 fragment) that the + # widening admitted. + # + # Line endings are still normalized, but only for the text this tree owns: + # a `.just` recipe and the declared inputs, which a CRLF checkout and an LF + # checkout must agree on. Normalizing bytes generally would reintroduce the + # same collision from the other direction. + $ordered = [System.Collections.Generic.SortedSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + # A file's git mode is part of what `COPY` puts in the image -- the + # executable bit, and whether the entry is a regular file or a symlink -- so + # a change that leaves the bytes alone still changes the image and must + # rename the tag. Git's index is the only source of that mode which answers + # identically on every platform: Windows has no executable bit, so reading + # it from the filesystem would make two checkouts of one commit disagree on + # the tag, and a published image would stop resolving for half the people + # who use it. + # + # The index is authoritative only while the working tree agrees with it. A + # mode change that has not been staged would be copied by the build and + # missed by the tag, so it is refused below rather than absorbed. + # + # An untracked file's mode is not an input: it has no committed identity, so + # no other checkout can reproduce it and there is nothing for a shared tag + # to encode. + # + # quotePath=false so a non-ASCII path arrives verbatim rather than + # backslash-escaped, which would key the map on a spelling the walk never + # produces. + # Ordinal, like the sort and the dedup below: PowerShell's `@{}` folds case, + # so two paths differing only in case -- which git permits and the + # case-sensitive filesystem the image is built on can hold -- would collapse + # to one entry and both would be framed with whichever mode was stored last. + $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $tracked = @('.anvil/container', 'justfiles', 'rust-toolchain.toml') + if (Get-Command git -ErrorAction SilentlyContinue) { + $staged = & git -c core.quotePath=false -C $repoRoot ls-files --stage -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $staged) { + if ($entry -match '^(\d{6}) [0-9a-f]+ \d+\t(.+)$') { + $indexMode[$Matches[2]] = $Matches[1] + } + } + } + # `--raw` reports the working-tree mode as its second field, so a + # mode-only change is visible even though the content is identical. On + # Windows core.fileMode is normally false and git reports no drift, + # which is correct: the filesystem has no bit to disagree with. + # + # A zero working-tree mode is a deletion: the path is in neither the + # build context nor the digest, so there is nothing to disagree about. + # Every other entry is present in the context and is compared against + # the mode the digest actually framed, which comes from `ls-files + # --stage` above. The raw index-side mode is not that mode -- an + # intent-to-add entry reports zero there while `ls-files` reports a real + # placeholder -- so comparing the two raw fields would both miss an + # executable `git add -N` file and reject an ordinary one. + $drift = & git -c core.quotePath=false -C $repoRoot diff --no-ext-diff --raw -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $drift) { + if ($entry -match '^:\d{6} (\d{6}) [0-9a-f]+ [0-9a-f]+ \S+\t(.+)$') { + $worktreeMode, $driftPath = $Matches[1], $Matches[2] + if ($worktreeMode -ne '000000' -and $indexMode.ContainsKey($driftPath) -and $indexMode[$driftPath] -ne $worktreeMode) { + Write-Error "anvil: '$driftPath' has mode $worktreeMode in the working tree, but the image tag was computed from mode $($indexMode[$driftPath]). The build copies the working tree, so the image would not match its own reference. Stage the change (git add) and re-run." + exit 1 + } + } + } + } + } + # ComputeHash rather than the static HashData: the latter arrived in .NET 5, + # and the prerequisite check accepts any PowerShell 7, including 7.0 on + # .NET Core 3.1 where the static overload does not exist. Failing there + # would be a MethodNotFound at tag time, before anything useful happened. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($rel in $ordered) { + $path = Join-Path $repoRoot $rel + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "anvil: container image input is missing: $rel" + exit 1 + } + if ([System.IO.Path]::GetExtension($rel) -eq '.just' -or $declaredText.Contains($rel)) { + $content = [System.Text.Encoding]::UTF8.GetBytes( + ([System.IO.File]::ReadAllText($path) -replace "`r`n", "`n")) + } else { + $content = [System.IO.File]::ReadAllBytes($path) + } + # The whole git mode, not just the executable bit: `COPY` preserves + # a symlink as a symlink, while the walk above reads through it, so + # replacing a regular file with a link to identical bytes would + # otherwise keep the tag. An untracked path has no framed mode. + $mode = if ($indexMode.ContainsKey($rel)) { $indexMode[$rel] } else { '-' } + $header = [System.Text.Encoding]::UTF8.GetBytes( + 'file ' + [System.Text.Encoding]::UTF8.GetByteCount($rel) + ' ' + $rel + ' ' + $mode + ' ' + $content.Length + ' ') + [void]$sha.TransformBlock($header, 0, $header.Length, $null, 0) + if ($content.Length -gt 0) { + [void]$sha.TransformBlock($content, 0, $content.Length, $null, 0) + } + } + [void]$sha.TransformFinalBlock([byte[]]::new(0), 0, 0) + $digest = $sha.Hash + } finally { + $sha.Dispose() + } + # 16 hex characters (64 bits) is far past any practical collision risk for a + # local image set, and keeps `docker images` readable. + $imageId = -join ($digest[0..7] | ForEach-Object { $_.ToString('x2') }) + Write-Output ('{{anvil_container_name}}:' + $imageId) + +# Resolve the exec image, building it if it is neither present nor resolvable. +# +# Three steps, in order: a local image under the computed tag, then the +# optional `Anvil-ResolveImage` hook (a registry, typically), then a build. +# Resolution comes before the NO_REBUILD guard because fetching a published +# image is not building one. +# +# ANVIL_CONTAINER_NO_REBUILD=1 fails instead of building, which is how a cache +# miss is told apart from a build failure. ANVIL_CONTAINER_NO_RESOLVE=1 skips +# the hook, so a query stays a query -- resolving can mean pulling gigabytes. +# ANVIL_CONTAINER_NO_CACHE=1 rebuilds a tag that already resolves, for the cases +# a content hash cannot see: a moved upstream package, or a base layer that +# changed behind its digest. It skips the hook too -- "ignore what is cached" +# has to mean the remote cache as well, or a rebuild would be undone by the +# next pull. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-image: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $image = & '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $image = "$image".Trim() + + if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') { + & $engineExe @enginePrefix image inspect $image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output $image + exit 0 + } + + # Nothing local. Give the optional hook a chance to fetch a published + # image built from these same inputs -- a registry, typically. + # + # The hook returns the reference it made available, and we run that + # reference rather than re-tagging it to the local name: a local tag + # asserts "built here from these inputs", and a fetched image only + # *claims* that, since the hash is over source files and cannot be + # re-derived from layers. Whether that claim holds is a property of the + # registry (immutable tags, restricted push), not of anything this + # recipe can check, so the reference stays honest about where it came + # from. + # + # Every failure here is non-fatal: a missing image, an expired + # credential and a broken hook all fall through to a build, which is + # slower but always correct. A publisher that has not yet caught up + # with a change must not stop the developer who made it. + $hookPath = Join-Path $repoRoot $hookRel + if ($env:ANVIL_CONTAINER_NO_RESOLVE -ne '1' -and (Test-Path -LiteralPath $hookPath -PathType Leaf)) { + # Dot-sourcing is inside the try as well: a hook with a syntax error, + # or one that throws while being loaded, must cost no more than a + # hook that resolves nothing. The recipe runs under + # `$ErrorActionPreference = 'Stop'`, so leaving the load outside + # would abort the run instead of falling through to a build. + $resolved = $null + try { + . $hookPath + if (Get-Command Anvil-ResolveImage -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-ResolveImage from $hookRel") + $resolved = @(Anvil-ResolveImage $image | Where-Object { $_ }) | Select-Object -Last 1 + } + } catch { + [Console]::Error.WriteLine("anvil: $hookRel failed: $($_.Exception.Message)") + $resolved = $null + } + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $resolved = ([string]$resolved).Trim() + # A presence check, not a verification: `image inspect` proves + # something is tagged with that reference, not that its contents + # match the digest the tag claims. Trusting the hook is the + # contract -- this only keeps a reference the hook reported but + # never fetched from failing later, under `--pull=never`, a long + # way from the cause. + & $engineExe @enginePrefix image inspect $resolved *> $null + if ($LASTEXITCODE -eq 0) { + [Console]::Error.WriteLine("anvil: resolved $resolved") + Write-Output $resolved + exit 0 + } + [Console]::Error.WriteLine( + "anvil: Anvil-ResolveImage reported '$resolved' but no such image is present locally") + } + [Console]::Error.WriteLine("anvil: nothing resolved; building locally") + } + } + + # Checked outside the cache guard, so the two variables compose: a caller + # that has NO_CACHE exported would otherwise fall straight through to a + # from-scratch build, which is exactly what NO_REBUILD exists to prevent -- + # and `anvil-container-status`, which sets it, would spend minutes building + # from a query. + if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { + # Still report the reference: a caller that asked not to build is + # usually asking *which* image is missing. + Write-Output $image + [Console]::Error.WriteLine("anvil: $image is not present or not current, and ANVIL_CONTAINER_NO_REBUILD=1") + exit 1 + } + + # Build-time credentials come from the optional hook, never from a committed + # file. Values are handed to BuildKit by environment variable name, so they + # stay out of the host's process command line, and BuildKit keeps them out of + # every image layer. An empty value is fatal: BuildKit would mount an empty + # secret, the build would install a reduced tool set and exit 0, and the + # result would be tagged with the same hash a credentialed build produces -- + # so every later run would reuse the broken image. + $secretArgs = @() + $secretEnv = @() + $hookPath = Join-Path $repoRoot $hookRel + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed, unlike the resolve hook: a build that cannot mint its + # credentials must stop, not proceed to produce a reduced image. The + # try exists only so the cause is named -- loading a hook with a syntax + # error would otherwise surface as a bare parser error with no hint + # that a hook was involved. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load ${hookRel}: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-BuildSecrets -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-BuildSecrets from $hookRel") + try { + # Take the last emitted object, not the whole stream: a hook + # that writes progress with `Write-Output` would otherwise hand + # back an array whose `.Secrets` is silently $null. + $hook = @(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-BuildSecrets failed: $($_.Exception.Message)" + exit 1 + } + $secrets = if ($null -ne $hook) { $hook.Secrets } else { $null } + # A defined `Anvil-BuildSecrets` that yields nothing is the hazard this + # guard exists for, not a hook opting out: secrets are the only + # thing the phase can contribute, so an empty return means the mint + # failed quietly. A hook with no build-time credentials simply does + # not define the function. + if ($null -eq $secrets -or $secrets.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-BuildSecrets returned no secrets; omit the function if the build needs none" + exit 1 + } + foreach ($id in $secrets.Keys) { + if ([string]::IsNullOrWhiteSpace($secrets[$id])) { + Write-Error "anvil: Anvil-BuildSecrets returned an empty value for secret '$id'" + exit 1 + } + $name = "ANVIL_SECRET_$id" + Set-Item -LiteralPath "Env:$name" -Value $secrets[$id] + $secretEnv += $name + $secretArgs += "id=$id,env=$name" + } + [Console]::Error.WriteLine("anvil: build secrets: $($secrets.Keys -join ', ')") + } + } + + try { + # Progress goes to stderr: callers capture this recipe's stdout to learn + # the image reference, so anything else written there becomes part of it. + [Console]::Error.WriteLine("anvil: building $image (inputs changed or first run)") + # The engine may not share this host's filesystem view, so the context + # and the Dockerfile are given in its terms rather than ours. + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + # Pinned, not inferred from the host. The Dockerfile installs amd64 + # toolchains and verifies amd64 checksums, so an arm host would resolve + # the multi-arch base to arm64 and fail late with an exec-format error. + # It also keeps the identity scheme honest: without this, two hosts of + # different architecture compute the same tag for different images. + $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # BuildKit reads `.dockerignore` on its own; buildah reads + # only a context-root ignore file and needs to be pointed at ours. Named + # rather than probed, because the flag is rejected outright by the engine + # that does not take it, and an unscoped context streams the whole + # worktree -- `target/` included -- on every build. + if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile', "$engineRoot/$dockerfile.dockerignore") } + if ($env:ANVIL_CONTAINER_NO_CACHE -eq '1') { $buildCmd += '--no-cache' } + foreach ($secret in $secretArgs) { $buildCmd += @('--secret', $secret) } + $buildCmd += $engineRoot + # BuildKit is required for --secret; docker enables it by default from + # 23.0 but an older daemon silently ignores the flag, so ask explicitly. + $env:DOCKER_BUILDKIT = '1' + # WSLENV exports the named variables into the WSL environment, which is + # where the engine reads a secret's value from when it runs there. + if ($engineExe -eq 'wsl.exe') { + $bridged = @('DOCKER_BUILDKIT/u') + ($secretEnv | ForEach-Object { "$_/u" }) + $env:WSLENV = (@($env:WSLENV) + $bridged | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @buildCmd | ForEach-Object { [Console]::Error.WriteLine($_) } + if ($LASTEXITCODE -ne 0) { + # Build secrets are the part of this path engines implement least + # consistently -- podman on Windows cannot mount one at all, and + # fails with a path error that names neither the secret nor the + # engine. Say so once, rather than leaving that to be rediscovered. + if ($secretArgs.Count -gt 0) { + [Console]::Error.WriteLine( + "anvil: the build passed $($secretArgs.Count) secret(s) from $hookRel. " + + "If the failure above is about a temp file or a path, the engine may not support " + + "build secrets on this host; see docs/design/containers.md.") + } + exit $LASTEXITCODE + } + } finally { + foreach ($name in $secretEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + } + + Write-Output $image + +# Run a command inside the pinned Linux image. +# +# The tokens after the recipe name are the argv, executed verbatim in the +# image. Anvil recipes are reached by naming `just` like any other command. +# +# just anvil-container just anvil-pr # a tier +# just anvil-container cargo build # any other command +# just anvil-container # interactive shell +# +# ANVIL_IN_CONTAINER is set inside the image, so a nested invocation runs the +# command on the spot and the work happens exactly once. + +# Run a command inside the pinned Linux image (no argument: a shell). +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container *command: + $ErrorActionPreference = 'Stop' + # `*command` joins its parts with spaces, so the string is split back into + # argv here. Whitespace is the only separator, so an argument containing a + # space does not survive; pass such a value through the environment. + $argv = @('{{ replace(command, "'", "''") }}' -split '\s+' | Where-Object { $_ }) + if ($env:ANVIL_IN_CONTAINER -eq '1') { + if ($argv.Count -eq 0) { + # The no-argument form asks for a shell in the image, and this is + # that shell. Nothing runs, so exiting 0 would report success for a + # request that was not carried out. + [Console]::Error.WriteLine("anvil: already inside the container; run the command directly") + exit 1 + } + # `just` resolves to the binary running this tree rather than to PATH: + # ANVIL_IN_CONTAINER is a documented control a developer can set on a + # host, and a caller who invoked `just` by absolute path with its + # directory off PATH would otherwise fail here. + $exe = if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] } + & $exe @($argv | Select-Object -Skip 1) + exit $LASTEXITCODE + } + + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + $image = (& '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + + # Map the caller's working directory to its in-container equivalent so + # relative paths keep working from a subdirectory. `invocation_directory()` + # would be wrong here: with `cygpath` on PATH it reports a Cygwin-style + # path, which shares no prefix with the native `justfile_directory()` above. + # `GetRelativePath` then walks out with `..` segments and the run is placed + # outside the mount, so it fails on a path that does not exist in the + # container. The `_native` form is the one that agrees with the root. + $rel = [System.IO.Path]::GetRelativePath($repoRoot, '{{ replace(invocation_directory_native(), "'", "''") }}') -replace '\\', '/' + if ($rel.StartsWith('..')) { + Write-Error "anvil: run this from inside the repository; $rel is outside $repoRoot" + exit 1 + } + $containerCwd = if ($rel -eq '.' -or [string]::IsNullOrEmpty($rel)) { + '{{anvil_container_workdir}}' + } else { + '{{anvil_container_workdir}}/' + $rel + } + + $interactive = $argv.Count -eq 0 + $runArgs = @('run', '--rm', '--platform', 'linux/amd64') + $runArgs += $interactive ? '-it' : '-i' + $runArgs += @('-v', "${engineRoot}:{{anvil_container_workdir}}") + + # A checkout whose `.git` is a file keeps its real git directory elsewhere: + # a linked worktree points into the main clone, `--separate-git-dir` and a + # submodule point somewhere else again. The path recorded there is a host + # path that does not exist inside the container, so git resolves neither + # HEAD nor origin/main. Mount the common git directory and replace the + # checkout's `.git` with one naming that mount; the `commondir` file in a + # worktree's entry is relative, so it resolves under it. + # + # The predicate is the shape of `.git`, not whether the git directory + # differs from the common one: `--separate-git-dir` redirects without + # differing, and testing for a difference skips it and leaves git pointed at + # a path the container cannot see. + # + # The redirection lives in the checkout rather than in GIT_DIR/GIT_WORK_TREE + # so that it stays scoped to it. Those variables are ambient: every process + # in the container inherits them, and a git command run elsewhere -- `git + # init` in a test's scratch directory -- would operate on this repository + # instead of its own. + # + # An ordinary clone keeps its git directory inside the checkout, where the + # bind mount already carries it, and takes none of this. + # + # Guarded on git being present: the run path needs it only to answer this + # question, and a host with a working engine but no git on PATH keeps + # working rather than failing on a call it does not need. + $gitFile = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $gitDir = & git rev-parse --git-dir 2>$null + $gitCommon = & git rev-parse --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $gitDir -and $gitCommon -and + (Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)) { + $gitDirAbs = (Resolve-Path -LiteralPath $gitDir).Path + $gitCommonAbs = (Resolve-Path -LiteralPath $gitCommon).Path + $rel = [System.IO.Path]::GetRelativePath($gitCommonAbs, $gitDirAbs) -replace '\\', '/' + # One mount has to carry both directories, so the git directory must + # sit under the common one. `git worktree` always places it there and + # a redirect without a separate worktree entry makes the two equal; + # anything else cannot be expressed as a single mount, and emitting a + # path that climbs out of it would fail inside the container instead. + if ($rel -eq '.') { + $containerGitDir = '/anvil/gitdir' + } elseif ($rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)) { + Write-Error "anvil: this checkout's git directory ($gitDirAbs) is not inside its common git directory ($gitCommonAbs), so the two cannot be mounted as one tree. Run the container from an ordinary clone or a git worktree checkout." + exit 1 + } else { + $containerGitDir = "/anvil/gitdir/$rel" + } + $engineGitCommon = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitCommonAbs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitCommon = "$engineGitCommon".Trim() + # LF and no trailing newline: git parses this file strictly. + $gitFile = Join-Path ([System.IO.Path]::GetTempPath()) "anvil-gitfile-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($gitFile, "gitdir: $containerGitDir`n") + $engineGitFile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitFile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitFile = "$engineGitFile".Trim() + $runArgs += @('-v', "${engineGitCommon}:/anvil/gitdir") + $runArgs += @('-v', "${engineGitFile}:{{anvil_container_workdir}}/.git:ro") + } + } + + # Cache only what is content-addressed: the downloaded registry and the git + # checkouts. Deliberately NOT $CARGO_HOME or $RUSTUP_HOME themselves -- + # those hold the installed tools and toolchains, and a named volume is + # populated from the image only when it is first created. Mounting them + # would pin the first image's binaries over every later one, so a tool bump + # would change the tag, build a new image, and still run the old tools. + $runArgs += @('-v', '{{anvil_container_name}}-cargo-registry:/usr/local/cargo/registry') + $runArgs += @('-v', '{{anvil_container_name}}-cargo-git:/usr/local/cargo/git') + # Match the caller's uid/gid on Linux. Without this everything the run + # writes under the bind mount -- target/, generated files -- lands as root + # on the host, and the next native cargo build or git clean fails with + # EACCES a long way from the cause. Docker Desktop on Windows and macOS + # already maps ownership, and `id` is not there to ask. + if (-not $IsWindows -and -not $IsMacOS) { + $hostUid = (id -u); $hostGid = (id -g) + if ($LASTEXITCODE -eq 0 -and $hostUid -ne '0') { + $runArgs += @('--user', "${hostUid}:${hostGid}") + # That uid has no passwd entry, so the engine leaves HOME as `/`. + # Anything falling back to $HOME for a cache then writes to a + # read-only root and fails a long way from the cause. + $runArgs += @('-e', 'HOME=/tmp') + } + } + $runArgs += @('-e', 'ANVIL_IN_CONTAINER=1') + + # Run-time credentials come from the optional hook. Forwarded by NAME, never + # as NAME=VALUE: the engine copies the value out of the environment it + # already inherits, so a credential never appears in the host's process + # command line, where endpoint telemetry records and retains it for far + # longer than a short-lived token is meant to live. + # $forwardedEnv is every name passed with -e; $hookEnv is the subset this + # process set, and so the subset it must unset again. + $forwardedEnv = @() + $hookEnv = @() + + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # + # An already-exported GITHUB_TOKEN is forwarded whatever the command is: + # that is exact parity, since a native run exposes it to every process the + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. + # + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { + # A dry run has no side effects, and a target that cannot be planned + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. + # + # A plan covers the bodies just runs itself, not the body of a + # recipe that one of them launches as a child process. The unscoped + # tier wrapper launches its tier that way, so planning + # `anvil-scheduled` shows the wrapper and none of the checks + # underneath it. Follow each nested target a plan names, or a + # wrapped tier reads as needing nothing and runs unauthenticated. + $plan = '' + $targets = [System.Collections.Generic.List[object]]::new() + $targets.Add([string[]]@($argv | Select-Object -Skip 1)) + $planned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($i = 0; $i -lt $targets.Count; $i++) { + $target = [string[]]$targets[$i] + # A recipe reachable twice is planned once, and a body naming + # itself terminates. + if (-not $planned.Add(($target -join ' '))) { continue } + # The same executable that launched this tree, for the reason + # every other nested call uses it: a caller invoking `just` by + # absolute path with its directory off PATH would otherwise fail + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. + $step = '' + try { + $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | + ForEach-Object { $_.ToString() }) -join "`n" + } catch { + $step = '' + } + $plan = "$plan`n$step" + # A launched recipe appears as a quoted argument to `just`. + foreach ($nested in [regex]::Matches($step, "'(_anvil-[^'\s]+)'")) { + $targets.Add([string[]]@($nested.Groups[1].Value)) + } + } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } + } + } + if ($env:GITHUB_TOKEN) { + $forwardedEnv += 'GITHUB_TOKEN' + $runArgs += @('-e', 'GITHUB_TOKEN') + } + + # The recipe contract's own inputs. These are read by generated checks -- + # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its + # CI equivalents, and `anvil-impact` reads ANVIL_IMPACT to decide whether to + # compute scoping, consume a downloaded cache, or skip -- so dropping them at + # the boundary makes the same command mean different things inside and out. + # anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with a + # skip notice, so a title a native run rejects passes in a container and the + # tier still reports green. ANVIL_IMPACT is the other: a CI group job exports + # `consume`, and a container that did not inherit it would recompute scoping + # from a diff instead of trusting the artifact the group downloaded. + # + # Forwarded by name and only when set, so an unset variable stays unset + # rather than arriving as an empty string, which several of these treat as + # a value. + foreach ($name in @( + 'PR_TITLE', + 'BASE_REF', 'GITHUB_BASE_REF', 'SYSTEM_PULLREQUEST_TARGETBRANCH', + 'ANVIL_IMPACT')) { + if ((Test-Path -LiteralPath "Env:$name") -and -not [string]::IsNullOrEmpty((Get-Item -LiteralPath "Env:$name").Value)) { + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + } + + $hookPath = Join-Path $repoRoot '.anvil/container/hooks.ps1' + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed like Anvil-BuildSecrets: a run that cannot obtain its + # credentials fails inside the container in a far less obvious way. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load .anvil/container/hooks.ps1: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-RunEnv -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-RunEnv from .anvil/container/hooks.ps1") + try { + $hook = @(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-RunEnv failed: $($_.Exception.Message)" + exit 1 + } + $hookVars = if ($null -ne $hook) { $hook.Env } else { $null } + if ($null -eq $hookVars -or $hookVars.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-RunEnv returned no variables; omit the function if the run needs none" + exit 1 + } + foreach ($name in $hookVars.Keys) { + if ([string]::IsNullOrWhiteSpace($hookVars[$name])) { + Write-Error "anvil: Anvil-RunEnv returned an empty value for '$name'" + exit 1 + } + Set-Item -LiteralPath "Env:$name" -Value $hookVars[$name] + $hookEnv += $name + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + # Names only, never values: a hook with a broad idea of what to + # forward should be visible, since everything inside the + # container can read it -- including third-party build scripts. + [Console]::Error.WriteLine("anvil: forwarding env: $($hookVars.Keys -join ', ')") + } + } -[unix] + try { + # --pull=never: the reference names content that is already here, either + # built locally or fetched by the resolve hook, so a miss is a bug to + # surface rather than an invitation to fetch something unrelated. + $runArgs += @('--pull=never', '-w', $containerCwd, $image) + if (-not $interactive) { $runArgs += $argv } + # WSLENV exports the forwarded names into the WSL environment, which is + # where the engine reads their values from when it runs there. Without + # it, `-e NAME` reaches an engine that cannot see NAME and forwards + # nothing, leaving the variable unset inside the container. + # + # It is not restored afterwards because there is nothing to restore to: + # `just` runs a [script(...)] recipe as its own pwsh process, so this + # assignment dies with that process and never reaches the caller's + # shell. The `finally` below unsets the credential names for hygiene + # within this process, not to protect the parent. + if ($engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0) { + $env:WSLENV = (@($env:WSLENV) + ($forwardedEnv | ForEach-Object { "$_/u" }) | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @runArgs + exit $LASTEXITCODE + } finally { + foreach ($name in $hookEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + if ($gitFile) { Remove-Item -LiteralPath $gitFile -Force -ErrorAction SilentlyContinue } + } + +# Report the engine, the exec image, and whether it is present and current. +# +# The tag embeds the hash of the image's inputs, so "absent" and "out of date" +# are the same condition and are reported as one. + +# Report the engine, the exec image, and whether it is present and current. [group("anvil-container")] -[script("bash")] -anvil-container *recipe: - requested={{ quote(recipe) }} - if [[ -z "$requested" ]]; then - exec bash '.anvil/container/run-in-container.sh' - fi - read -r -a requested_args <<<"$requested" - exec bash '.anvil/container/run-in-container.sh' "${requested_args[@]}" +[script("pwsh", "-NoProfile")] +anvil-container-status: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + Write-Output ("engine: " + ($engine -replace '\|', ' ')) + Write-Output "workdir: {{anvil_container_workdir}}" + + # NO_REBUILD turns the resolve into a pure query: report the state instead + # of silently spending several minutes building from a status command. + # NO_RESOLVE is the same argument applied to the hook, which would otherwise + # pull gigabytes to answer a question about the local machine. + # + # Compute the tag first and let it fail loudly. It is fatal for a reason a + # query cannot paper over -- a declared input is missing -- and reporting + # that as "not present locally" would be a lie: the next run cannot build + # it either. + $image = (& '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output "image: $image" + + $env:ANVIL_CONTAINER_NO_REBUILD = '1' + $env:ANVIL_CONTAINER_NO_RESOLVE = '1' + # And explicitly *not* NO_CACHE. A caller who exported it is asking the next + # build to ignore the layer cache, which is a statement about building -- + # but it also makes the resolver skip the local `image inspect` + # short-circuit, so a present image would be reported absent by a command + # that is only ever asking what is on this machine. + $env:ANVIL_CONTAINER_NO_CACHE = $null + & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "status: present and current" + exit 0 + } + + # A cache miss and an unreachable daemon both make `image inspect` fail, and + # reporting the second as the first tells a developer to expect a build that + # will not start either. Ask the engine whether it is answering at all: only + # then is absence the honest reading. + $engineCmd = $engine -split '\|' + & $engineCmd[0] @($engineCmd | Select-Object -Skip 1) version *> $null + if ($LASTEXITCODE -ne 0) { + Write-Output "status: unknown -- the engine is not responding (is the daemon running?)" + exit 1 + } + Write-Output "status: not present locally (the next run resolves or builds it)" + exit 0 + +# Only the download caches are volumes, so this discards fetched crates and git +# checkouts and nothing else: the next run re-fetches them, and the image's own +# tools are untouched. To discard the image instead, set +# ANVIL_CONTAINER_NO_CACHE=1 for a single run. + +# Remove this repository's cache volumes. The image is left in place. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-down: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + # Report a teardown that did not happen. $ErrorActionPreference does not + # cover native commands, so a non-serving engine would otherwise print a + # connection error per volume and still exit 0 -- and this recipe is the + # only way to clear a cache volume, so a caller that scripts teardown must + # be able to tell that it failed. `-f` already exits 0 for a volume that + # does not exist, so this cannot fire spuriously. + $failed = @() + foreach ($vol in @('{{anvil_container_name}}-cargo-registry', '{{anvil_container_name}}-cargo-git')) { + & $engineExe @enginePrefix volume rm -f $vol + if ($LASTEXITCODE -ne 0) { $failed += $vol } + } + if ($failed.Count -gt 0) { + Write-Error ("anvil: could not remove: " + ($failed -join ', ')) + exit 1 + } + exit 0 === justfiles/anvil/groups/pr-fast.just === # Copyright (c) Microsoft Corporation. @@ -3567,14 +3565,14 @@ anvil-pr-test-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled advisory checks. [group("anvil")] -anvil-scheduled-advisories: (_anvil-run "scheduled-advisories" anvil_runner "off") +anvil-scheduled-advisories: (_anvil-unscoped "scheduled-advisories") [private] _anvil-scheduled-advisories: anvil-scheduled-advisories-validate-prereqs \ @@ -3608,14 +3606,14 @@ anvil-scheduled-advisories-validate-prereqs: \ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled exhaustive checks. [group("anvil")] -anvil-scheduled-exhaustive: (_anvil-run "scheduled-exhaustive" anvil_runner "off") +anvil-scheduled-exhaustive: (_anvil-unscoped "scheduled-exhaustive") [private] _anvil-scheduled-exhaustive: anvil-scheduled-exhaustive-validate-prereqs \ @@ -3652,14 +3650,14 @@ anvil-scheduled-exhaustive-validate-prereqs: \ # and adds the three stricter miri profiles (tree-borrows, strict- # provenance, race-coverage) which are too expensive for PR. -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled runtime analysis. [group("anvil")] -anvil-scheduled-runtime-analysis: (_anvil-run "scheduled-runtime-analysis" anvil_runner "off") +anvil-scheduled-runtime-analysis: (_anvil-unscoped "scheduled-runtime-analysis") [private] _anvil-scheduled-runtime-analysis: anvil-scheduled-runtime-analysis-validate-prereqs \ @@ -3696,7 +3694,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Scheduled groups # Scheduled groups are the full-workspace backstop for PR-tier impact scoping, -# so route through _anvil-run with impact "off": it exports ANVIL_IMPACT=off +# so route through _anvil-unscoped: it exports ANVIL_IMPACT=off # before the check dependencies run, so the group is full-workspace regardless # of how it is invoked (CI, `just anvil-scheduled`, or # `just anvil-scheduled-test` directly). Because these groups never recompute @@ -3704,7 +3702,7 @@ anvil-scheduled-runtime-analysis-validate-prereqs: \ # Run the scheduled tests. [group("anvil")] -anvil-scheduled-test: (_anvil-run "scheduled-test" anvil_runner "off") +anvil-scheduled-test: (_anvil-unscoped "scheduled-test") [private] _anvil-scheduled-test: anvil-scheduled-test-validate-prereqs \ @@ -3850,6 +3848,25 @@ _anvil-base-ref: Write-Error 'anvil-base-ref: cannot resolve a base ref. Set BASE_REF, or ensure origin/main or origin/master exists.' exit 1 +# Run a private recipe with impact scoping disabled. +# +# The only way to reach a whole dependency tree with an environment variable: +# `just` runs each dependency as its own process, and a dependency-only +# recipe's body executes after its dependencies, so exporting from there is +# too late. Invoking `_anvil-` as a child process makes every check +# below it inherit the setting. +# +# The justfile is named explicitly so the child resolves the same file the +# wrapper was defined in, rather than whatever an upward search from the +# working directory happens to find. +[private] +[script("pwsh", "-NoProfile")] +_anvil-unscoped name: + $ErrorActionPreference = 'Stop' + $env:ANVIL_IMPACT = 'off' + & '{{ replace(just_executable(), "'", "''") }}' --justfile '{{ replace(justfile(), "'", "''") }}' '_anvil-{{ replace(name, "'", "''") }}' + exit $LASTEXITCODE + === justfiles/anvil/impact.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -4548,7 +4565,11 @@ import 'checks/readme-check.just' import 'checks/semver-check.just' import 'checks/spellcheck.just' import 'checks/udeps.just' -import 'container.just' +# Optional: the container artifacts can be removed through `without_artifact`, +# which deletes this file. A hard import would then fail parsing for every +# recipe in the tree, not merely the container ones, so the documented opt-out +# would break the whole Justfile. +import? 'container.just' import 'groups/pr-fast.just' import 'groups/pr-slow.just' import 'groups/pr-test.just' @@ -4558,7 +4579,6 @@ import 'groups/scheduled-test.just' import 'groups/scheduled-advisories.just' import 'groups/scheduled-runtime-analysis.just' import 'groups/scheduled-exhaustive.just' -import 'runner.just' import 'tiers.just' import 'tools.just' import 'versions.just' @@ -4566,67 +4586,6 @@ import 'versions.just' # Friendly default: `just anvil` runs the PR tier. alias anvil := anvil-pr -=== justfiles/anvil/runner.just === -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -# Route public tier entry points through the configured execution environment. -# ANVIL_IN_CONTAINER always wins to prevent recursive container launches. -# -# `impact` selects the tier's impact-scoping mode: the default "on" leaves -# scoping enabled (PR tier), while "off" exports ANVIL_IMPACT=off before -# invoking the native tier so every check runs full-workspace -- the -# scheduled/full backstop for PR-tier impact scoping. Setting it here (rather -# than in a dep-only tier recipe) ensures the private `_anvil-` recipe's -# own dependencies, which run before any recipe body, inherit the mode. -[private] -[no-exit-message] -[windows] -[script("pwsh", "-NoProfile")] -_anvil-run tier runner impact="on": - if ('{{ replace(impact, "'", "''") }}' -ceq 'off') { $env:ANVIL_IMPACT = 'off' } - $just = '{{ replace(just_executable(), "'", "''") }}' - $justfile = '{{ replace(justfile(), "'", "''") }}' - $nativeTier = '_anvil-{{ replace(tier, "'", "''") }}' - if ($env:ANVIL_IN_CONTAINER) { - & $just --justfile $justfile $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'container') { - & $just --justfile $justfile anvil-container $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'native') { - & $just --justfile $justfile $nativeTier - } else { - [Console]::Error.WriteLine("anvil-runner: expected 'native' or 'container', got '{{ replace(runner, "'", "''") }}'.") - exit 2 - } - exit $LASTEXITCODE - -[private] -[no-exit-message] -[unix] -[script("bash")] -_anvil-run tier runner impact="on": - just_path={{ quote(just_executable()) }} - justfile={{ quote(justfile()) }} - tier={{ quote(tier) }} - runner={{ quote(runner) }} - impact={{ quote(impact) }} - if [[ "$impact" == "off" ]]; then - export ANVIL_IMPACT=off - fi - native_tier="_anvil-$tier" - if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - elif [[ "$runner" == "container" ]]; then - exec "$just_path" --justfile "$justfile" anvil-container "$native_tier" - elif [[ "$runner" == "native" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - else - echo "anvil-runner: expected 'native' or 'container', got '$runner'." >&2 - exit 2 - fi - === justfiles/anvil/tiers.just === # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. @@ -4642,10 +4601,7 @@ _anvil-run tier runner impact="on": # Run all pull request checks. [group("anvil")] -anvil-pr: (_anvil-run "pr" anvil_runner) - -[private] -_anvil-pr: anvil-pr-validate-prereqs \ +anvil-pr: anvil-pr-validate-prereqs \ anvil-pr-fast \ anvil-pr-slow @@ -4654,20 +4610,18 @@ _anvil-pr: anvil-pr-validate-prereqs \ # exhaustive checks that don't fit in a PR budget. Runs on a schedule # against `main`, not on PRs. # -# The scheduled tier is deliberately NOT impact-scoped: it is the -# catch-all that backstops PR-tier scoping. Because every impact-scoped -# check depends on `anvil-impact` and self-populates its scope from the -# cache, the tier must run with ANVIL_IMPACT=off so `_anvil-impact-include` -# returns each tier's full-workspace default and the `anvil-impact` -# dependency no-ops. A dependency-only recipe can't set env for its own -# deps (deps run before the body), so the public tier routes through -# `_anvil-run` with the `"off"` impact argument: `_anvil-run` exports -# ANVIL_IMPACT=off before invoking the private `_anvil-scheduled` recipe, -# whose deps then inherit it. +# The scheduled tier is deliberately NOT impact-scoped: it is the catch-all +# that backstops PR-tier scoping. Every impact-scoped check depends on +# `anvil-impact` and populates its own scope from the cache, so the tier runs +# with ANVIL_IMPACT=off, which makes `_anvil-impact-include` return each +# category's full-workspace default and the `anvil-impact` dependency no-op. +# A dependency-only recipe cannot set an environment variable for its own +# dependencies, so the public tier wraps the private one through +# `_anvil-unscoped`. # Run all scheduled checks. [group("anvil")] -anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off") +anvil-scheduled: (_anvil-unscoped "scheduled") [private] _anvil-scheduled: anvil-scheduled-validate-prereqs \ @@ -4676,16 +4630,15 @@ _anvil-scheduled: anvil-scheduled-validate-prereqs \ anvil-scheduled-runtime-analysis \ anvil-scheduled-exhaustive -# Runs everything full-workspace (ANVIL_IMPACT=off, via the `"off"` impact -# argument to _anvil-run), same wrapper shape as anvil-scheduled. +# Full-workspace for the same reason as the scheduled tier. # Full tier: PR + scheduled, end-to-end. Useful before tagging a release. [group("anvil")] -anvil-full: (_anvil-run "full" anvil_runner "off") +anvil-full: (_anvil-unscoped "full") [private] _anvil-full: anvil-full-validate-prereqs \ - _anvil-pr \ + anvil-pr \ _anvil-scheduled # Tier-level + global setup + validate-prereqs diff --git a/crates/cargo-anvil/tests/tier_routing.rs b/crates/cargo-anvil/tests/tier_routing.rs deleted file mode 100644 index 86a84d3c..00000000 --- a/crates/cargo-anvil/tests/tier_routing.rs +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#![cfg(not(miri))] -#![allow( - clippy::expect_used, - clippy::unwrap_used, - reason = "panic-on-failure idioms are appropriate in tests" -)] - -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; - -use tempfile::TempDir; - -const RUNNER: &str = include_str!("../templates/justfiles/anvil/runner.just"); -const JUSTFILE: &str = "routing.just"; - -fn write(path: &Path, contents: &str) { - std::fs::write(path, contents).unwrap(); -} - -fn fixture() -> TempDir { - let tmp = TempDir::new().unwrap(); - write(&tmp.path().join("runner.just"), RUNNER); - let justfile = r#" -import 'runner.just' - -runner := env_var_or_default("ANVIL_RUNNER", "native") - -default: (_anvil-run "pr" runner) -failure: (_anvil-run "fail" runner) -offtier: (_anvil-run "offtier" runner "off") - -[private] -_anvil-pr: first second - -[private] -_anvil-fail: first failing - -[private] -_anvil-offtier: require-impact-off second - -# require-impact-off exits 9 when ANVIL_IMPACT is not "off". 9 is an arbitrary -# nonzero sentinel chosen to be distinct from the generic failure status 7 used -# by _anvil-fail's `failing`, so a routed-off assertion that trips this is -# unambiguously the impact-precondition, not some other recipe failure. The -# Windows, Unix, and the Rust assertion below must stay tied to this value. -[windows] -[script("pwsh", "-NoProfile")] -require-impact-off: - if ($env:ANVIL_IMPACT -ne 'off') { Write-Output "impact=$($env:ANVIL_IMPACT)"; exit 9 } - Write-Output 'impact-off' - -[unix] -require-impact-off: - @if [ "${ANVIL_IMPACT:-}" = off ]; then printf 'impact-off\n'; else printf 'impact=%s\n' "${ANVIL_IMPACT:-}"; exit 9; fi - -[windows] -[script("pwsh", "-NoProfile")] -first: - Write-Output first - -[windows] -[script("pwsh", "-NoProfile")] -second: - Write-Output second - -[windows] -[script("pwsh", "-NoProfile")] -failing: - Write-Output failing - exit 7 - -[windows] -[script("pwsh", "-NoProfile")] -anvil-container *recipe: - Write-Output 'container:{{ recipe }}' - -[script("pwsh", "-NoProfile")] -profile-independent: - Write-Output profile-safe - -[script("pwsh")] -profile-dependent: - Write-Output profile-noisy - -[unix] -first: - @printf 'first\n' - -[unix] -second: - @printf 'second\n' - -[unix] -failing: - @printf 'failing\n' - @exit 7 - -[unix] -anvil-container *recipe: - @printf 'container:%s\n' '{{ recipe }}' -"#; - write(&tmp.path().join(JUSTFILE), justfile); - tmp -} - -fn profile_fixture() -> TempDir { - let tmp = fixture(); - install_profile_noise_wrapper(tmp.path()); - tmp -} - -fn just_available() -> bool { - Command::new("just").arg("--version").output().is_ok() -} - -fn pwsh_available() -> bool { - Command::new("pwsh").arg("--version").output().is_ok() -} - -fn pwsh_path() -> PathBuf { - let output = Command::new("pwsh") - .args(["-NoProfile", "-Command", "(Get-Command pwsh).Source"]) - .output() - .expect("pwsh availability is checked before creating the fixture"); - assert!(output.status.success(), "failed to resolve pwsh path"); - PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()) -} - -fn install_profile_noise_wrapper(root: &Path) { - let bin = root.join("fake-bin"); - std::fs::create_dir_all(&bin).unwrap(); - let real_pwsh = pwsh_path(); - - #[cfg(windows)] - { - let source = bin.join("pwsh.rs"); - let real_pwsh = format!("{:?}", real_pwsh.to_string_lossy()); - write( - &source, - &format!( - r#"use std::io::Write as _; -use std::process::{{Command, exit}}; - -fn main() {{ - let args: Vec<_> = std::env::args_os().skip(1).collect(); - if !args.iter().any(|arg| arg.to_string_lossy().eq_ignore_ascii_case("-NoProfile")) {{ - println!("PROFILE_OUTPUT"); - std::io::stdout().flush().expect("stdout must flush"); - }} - let status = Command::new({real_pwsh}) - .args(&args) - .status() - .expect("real pwsh must start"); - exit(status.code().unwrap_or(1)); -}} -"# - ), - ); - let status = Command::new("rustc") - .arg(&source) - .arg("-o") - .arg(bin.join("pwsh.exe")) - .status() - .expect("rustc is available while running cargo tests"); - assert!(status.success(), "failed to compile the Windows pwsh test shim"); - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - - let escaped = real_pwsh.to_string_lossy().replace('\'', "'\\''"); - let wrapper = format!( - "#!/usr/bin/env sh\ncase \" $* \" in *\" -NoProfile \"*) ;; *) printf 'PROFILE_OUTPUT\\n' ;; esac\nexec '{escaped}' \"$@\"\n" - ); - let path = bin.join("pwsh"); - write(&path, &wrapper); - let mut permissions = std::fs::metadata(&path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).unwrap(); - } -} - -fn path_with_profile_wrapper(root: &Path) -> OsString { - let mut paths = vec![root.join("fake-bin")]; - paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); - std::env::join_paths(paths).unwrap() -} - -fn run(root: &Path, recipes: &[&str], environment: &[(&str, &str)]) -> Output { - let mut command = Command::new("just"); - command.args(["--justfile", root.join(JUSTFILE).to_str().unwrap()]); - command.args(recipes).current_dir(root); - command - .env_remove("ANVIL_RUNNER") - .env_remove("ANVIL_IN_CONTAINER") - .env_remove("ANVIL_IMPACT"); - command.env("PATH", path_with_profile_wrapper(root)); - command.envs(environment.iter().copied()); - command.output().expect("just is required to verify generated tier routing") -} - -#[test] -fn native_routing_preserves_output_and_exit_status() { - if !just_available() { - return; - } - let tmp = fixture(); - let direct = run(tmp.path(), &["_anvil-pr"], &[]); - let routed = run(tmp.path(), &["default"], &[]); - - assert_eq!(routed.status.code(), direct.status.code()); - assert_eq!(routed.stdout, direct.stdout); - assert_eq!(routed.stderr, direct.stderr); -} - -#[test] -fn native_routing_preserves_failure_output_and_exit_status() { - if !just_available() { - return; - } - let tmp = fixture(); - let direct = run(tmp.path(), &["_anvil-fail"], &[]); - let routed = run(tmp.path(), &["failure"], &[]); - - assert_eq!(direct.status.code(), Some(7)); - assert_eq!(routed.status.code(), direct.status.code()); - assert_eq!(routed.stdout, direct.stdout); - assert_eq!(routed.stderr, direct.stderr); -} - -#[test] -fn configured_container_routing_uses_the_container_recipe() { - if !just_available() { - return; - } - let tmp = fixture(); - let output = run(tmp.path(), &["default"], &[("ANVIL_RUNNER", "container")]); - - assert!( - output.status.success(), - "container route failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "container:_anvil-pr"); -} - -#[test] -fn in_container_forces_native_execution() { - if !just_available() { - return; - } - let tmp = fixture(); - let output = run( - tmp.path(), - &["default"], - &[("ANVIL_RUNNER", "container"), ("ANVIL_IN_CONTAINER", "1")], - ); - - assert!( - output.status.success(), - "native recursion guard failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout).lines().collect::>(), - ["first", "second"] - ); -} - -#[test] -fn invalid_runner_value_fails_instead_of_falling_back_to_native() { - if !just_available() { - return; - } - let tmp = fixture(); - let output = run(tmp.path(), &["default"], &[("ANVIL_RUNNER", "Container")]); - - assert!( - !output.status.success(), - "invalid runner unexpectedly succeeded: stdout={}; stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stderr).contains("expected 'native' or 'container'"), - "invalid runner error must be actionable: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn off_impact_argument_exports_anvil_impact_before_dependencies_run() { - if !just_available() { - return; - } - let tmp = fixture(); - // Routed through `_anvil-run` with impact "off": the router must export - // ANVIL_IMPACT=off in the shell *before* re-invoking the private tier, so - // that tier's own dependency (`require-impact-off`, which runs before any - // recipe body) observes it. This is the scheduled/full full-workspace - // backstop -- the guarantee that impact scoping is off for those tiers. - let routed = run(tmp.path(), &["offtier"], &[]); - assert!( - routed.status.success(), - "off-routed tier failed: stdout={}; stderr={}", - String::from_utf8_lossy(&routed.stdout), - String::from_utf8_lossy(&routed.stderr) - ); - assert!( - String::from_utf8_lossy(&routed.stdout).lines().eq(["impact-off", "second"]), - "the off-mode dependency must observe ANVIL_IMPACT=off and run before the rest of the tier; stdout={}", - String::from_utf8_lossy(&routed.stdout) - ); - - // Sanity: invoking the private tier directly (no `_anvil-run`) does NOT set - // the mode, so the same dependency fails with its sentinel exit code -- - // proving the router's shell export is what makes the routed run pass, not - // some ambient default. - let direct = run(tmp.path(), &["_anvil-offtier"], &[]); - assert_eq!( - direct.status.code(), - Some(9), - "direct tier must fail without ANVIL_IMPACT=off: stdout={}; stderr={}", - String::from_utf8_lossy(&direct.stdout), - String::from_utf8_lossy(&direct.stderr) - ); -} - -#[test] -fn powershell_recipe_output_is_independent_of_profiles() { - if !just_available() || !pwsh_available() { - return; - } - let tmp = profile_fixture(); - let noisy = run(tmp.path(), &["profile-dependent"], &[]); - assert!( - noisy.status.success(), - "profile-dependent negative control failed: {}", - String::from_utf8_lossy(&noisy.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&noisy.stdout).lines().collect::>(), - ["PROFILE_OUTPUT", "profile-noisy"], - "the negative control must prove the fake pwsh shim was invoked" - ); - - let output = run(tmp.path(), &["profile-independent"], &[]); - assert!( - output.status.success(), - "profile-independent recipe failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout).lines().collect::>(), - ["profile-safe"] - ); -} diff --git a/justfiles/anvil/checks/aprz.just b/justfiles/anvil/checks/aprz.just index 1627c3a5..9b2454d1 100644 --- a/justfiles/anvil/checks/aprz.just +++ b/justfiles/anvil/checks/aprz.just @@ -7,13 +7,15 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md # cargo-aprz queries the GitHub advisory API. Unauthenticated access is -# capped at 60 requests/hour and fails on a full run; an authenticated -# token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Container drivers mount an existing host GITHUB_TOKEN -# or the host gh CLI's stored token as a temporary read-only secret. -# Native runs borrow the gh CLI token directly. Native runs warn and -# proceed unauthenticated if neither is available; container runs fail -# before cargo-aprz can exhaust the unauthenticated rate limit. +# capped at 60 requests an hour, and on a full workspace it exhausts that +# and then waits for the quota to reset rather than failing; an +# authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -21,26 +23,16 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if ($env:ANVIL_APRZ_ALREADY_RAN -eq '1') { - Write-Host 'anvil-aprz: already completed in an isolated authenticated container' - exit 0 - } if (-not $env:GITHUB_TOKEN) { $tok = $null - $containerTokenFile = '/run/secrets/anvil-github-token' - if ($env:ANVIL_IN_CONTAINER -and (Test-Path -LiteralPath $containerTokenFile -PathType Leaf)) { - try { $tok = Get-Content -LiteralPath $containerTokenFile -Raw } catch { $tok = $null } - } elseif (Get-Command gh -ErrorAction SilentlyContinue) { + if (Get-Command gh -ErrorAction SilentlyContinue) { try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } } if ($tok) { $env:GITHUB_TOKEN = $tok.Trim() } else { - if ($env:ANVIL_IN_CONTAINER) { - throw 'anvil-aprz: GitHub authentication is unavailable. Run `gh auth login` on the host or set host GITHUB_TOKEN, then re-run the container command.' - } Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API (60 requests/hour) and may fail on a full run.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' } } diff --git a/justfiles/anvil/checks/mutants-diff.just b/justfiles/anvil/checks/mutants-diff.just index 25c98ba9..079a54cb 100644 --- a/justfiles/anvil/checks/mutants-diff.just +++ b/justfiles/anvil/checks/mutants-diff.just @@ -41,7 +41,14 @@ anvil-mutants-diff: anvil-mutants-diff-validate-prereqs anvil-impact if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' - git diff "$base..HEAD" --output=$diff_path + # Diff the base against the WORKING TREE, not against HEAD. + # cargo-mutants validates every line of the diff against the file on + # disk and aborts when they disagree, so a commit-to-commit diff fails + # the moment anything is uncommitted -- which is the normal local state, + # since the point of running a tier locally is to check work in progress. + # CI has a clean tree, so the two forms are identical there and this is + # not a behaviour change for it. + git diff "$base" --output=$diff_path if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 86508b25..bdf35146 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -2,24 +2,1064 @@ # Licensed under the MIT License. # GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. # Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. +# Update behaviour: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/updates.md +# +# Containerized execution. `just anvil-container ` runs the given +# argv inside a pinned Linux image; everything else keeps running natively. +# Anvil recipes are reached by naming `just`, like any other command. +# There is no configuration file and no transparent routing: the container is +# reached through this recipe or not at all. +# +# The image tag *is* a hash of the inputs that define it, so the presence of a +# tag is proof that its contents are current -- a changed tool pin names a tag +# that cannot already exist, and a build follows. There is nothing to keep in +# sync and no staleness to detect. +# +# See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md -# Run any Anvil recipe in the pinned local Linux container. With no recipe, -# open an interactive shell. -[windows] +# The container engine, `docker` or `podman`. A host property, never +# committed. Set it in your environment: a `just anvil_container_engine=...` +# override would not reach the nested invocations that resolve the engine. +anvil_container_engine := env_var_or_default("ANVIL_CONTAINER_ENGINE", "docker") + +# Where the repository is mounted inside the container. +anvil_container_workdir := "/workspace" + +# Image and cache-volume prefix, derived from the repository directory name. +# Two checkouts with the same directory name share cache volumes; that is +# harmless (the caches are content-addressed by cargo) but worth knowing before +# `anvil-container-down` removes volumes another checkout is also using. +# +# Every run of non-alphanumerics collapses to a single `-`, and a trailing one +# is trimmed, because a repository name may not end in a separator or repeat +# `.`/`_`. Without that, a checkout in `ox-tools (copy)` yields a reference the +# engine rejects as malformed, from a directory name nobody would suspect. +anvil_container_name := trim_end_matches("anvil-" + replace_regex(lowercase(file_name(justfile_directory())), '[^a-z0-9]+', "-"), "-") + +# Resolve how to invoke the engine, as a pipe-separated command. +# +# There is deliberately no probe *between* engines: presence is not +# reachability, and a silent choice between two installed engines means two +# image stores and an unexplained rebuild. We check that the requested binary +# exists and let every other failure surface the engine's own diagnostic, which +# is more accurate than anything repeated here. +# +# The one fallback is Windows-specific and unambiguous: when the engine is not +# on the Windows PATH, try it inside the default WSL distribution. Installing +# Docker in WSL without Docker Desktop is a documented, common setup, and it +# leaves no Windows CLI behind, so without this fallback a correctly installed +# engine would be unreachable. Docker Desktop and Podman both ship a Windows +# CLI and are found on PATH, so they never take this path. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-engine: + $ErrorActionPreference = 'Stop' + $engine = '{{ replace(anvil_container_engine, "'", "''") }}' + if ($engine -ne 'docker' -and $engine -ne 'podman') { + Write-Error "anvil: ANVIL_CONTAINER_ENGINE must be 'docker' or 'podman', got '$engine'" + exit 1 + } + if (Get-Command $engine -ErrorAction SilentlyContinue) { + Write-Output $engine + exit 0 + } + # --exec, not --: `wsl.exe -- ` hands the rest of the command line to + # the distribution's default shell, which expands $NAME, splits on ;, and + # eats backslashes. Every argument we forward -- the repository path and + # the recipe's own arguments -- would cross that boundary unquoted. + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $engine --version *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "wsl.exe|--exec|$engine" + exit 0 + } + } + Write-Error "anvil: '$engine' was not found on PATH, and is not usable in the default WSL distribution. Install it, or set ANVIL_CONTAINER_ENGINE to the other engine. Setup: https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/containers.md" + exit 1 + +# Translate a host path into what the engine sees. +# +# Identical when the engine runs on this host. When it runs in WSL, a Windows +# path has to become its /mnt/... form or the daemon silently bind-mounts an +# empty directory -- a failure that surfaces much later, as a missing file +# inside the container. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-path host_path: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + if (-not $engine.StartsWith('wsl.exe|')) { + Write-Output '{{ replace(host_path, "'", "''") }}' + exit 0 + } + # --exec for the reason given above. It matters most here: through a shell, + # a path holding `$` loses it, and `wslpath -a` then makes the *truncated* + # path absolute and exits 0, so the guard below never fires and the wrong + # directory is bind-mounted. + $hostPath = '{{ replace(host_path, "'", "''") }}' + $translated = & wsl.exe --exec wslpath -a -u $hostPath + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: could not translate '$hostPath' for the engine running in WSL" + exit 1 + } + Write-Output $translated.Trim() + +# Verify the composed Dockerfile is present under the name the build uses. +# +# The engine derives the ignore file's name from the Dockerfile's: BuildKit +# reads `.dockerignore` and there is no flag to point it elsewhere. +# Anvil owns that artifact at the fixed path `.anvil/container/Dockerfile.dockerignore`, +# so the two names have to agree, and only one of them can move. A case variant +# is therefore refused rather than accommodated: building from `dockerfile` +# would silently use no ignore file at all, streaming the whole worktree into +# the build context and admitting inputs the tag does not cover. +# +# Also the one place that asserts the file exists: the tag's directory walk +# cannot, because a missing Dockerfile simply contributes nothing to the hash +# and yields a confident tag for an image that can never be built. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-dockerfile: + $ErrorActionPreference = 'Stop' + $dir = Join-Path '{{ replace(justfile_directory(), "'", "''") }}' '.anvil/container' + $entries = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue) + # -ceq because PowerShell's -eq on strings is case-insensitive, which would + # make the exact name indistinguishable from a variant on a filesystem that + # can hold both. + if (@($entries | Where-Object { $_.Name -ceq 'Dockerfile' }).Count -eq 1) { + Write-Output '.anvil/container/Dockerfile' + exit 0 + } + $variant = @($entries | Where-Object { $_.Name -ieq 'Dockerfile' })[0] + if ($variant) { + Write-Error "anvil: the container image input must be named exactly '.anvil/container/Dockerfile', but this repository has '.anvil/container/$($variant.Name)'. The engine reads the ignore file as '.dockerignore', and anvil maintains '.anvil/container/Dockerfile.dockerignore', so a differently-cased name would build with no ignore file. Rename it." + exit 1 + } + Write-Error 'anvil: container image input is missing: .anvil/container/Dockerfile' + exit 1 + +# Print the exec image reference for the current inputs, without building it. +# +# The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its +# ignore file, the pinned toolchain, the optional hook, and the whole generated +# recipe tree -- because the image installs its tools by running +# `just anvil-setup`, whose dependency chain reaches the tier, group, check and +# tool recipes alike. Editing any of them can change what the image contains, so +# any of them can rename it. +# +# This is the only recipe that computes the reference; everything else asks it. +# It is public because a publisher needs the tag before there is an image to +# inspect: a pipeline that builds the image tags the result with exactly the +# reference a consumer will later compute, which is what lets presence be +# checked without a second source of truth. + +# Print the exec image reference for the current inputs, without building it. [group("anvil-container")] [script("pwsh", "-NoProfile")] -anvil-container *recipe: - $requested = @('{{ replace(recipe, "'", "''") }}' -split '\s+' | Where-Object { $_ }) - & '.anvil/container/run-in-container.ps1' @requested - exit $LASTEXITCODE +anvil-container-tag: + $ErrorActionPreference = 'Stop' + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $inputs = @('rust-toolchain.toml') + $links = @() + # The declared input and the two walk roots are checked here, because a walk + # only ever reports descendants: a link that *is* the root is traversed or + # read through and never appears in its own output. Same hazard as a link + # below them -- the engine copies the link while everything here follows it. + foreach ($declared in @('rust-toolchain.toml', '.anvil/container', 'justfiles/anvil')) { + $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue + if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $links += $item.FullName + } + } + # The declared inputs are text this tree owns, so their line endings are + # normalized before hashing and a CRLF checkout agrees with an LF one. + # Everything discovered by walking a directory is treated as text only when + # it is a `.just` recipe; anything else is hashed as the bytes the build + # context actually copies. + $declaredText = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + [void]$declaredText.Add($dockerfile) + [void]$declaredText.Add("$dockerfile.dockerignore") + [void]$declaredText.Add($hookRel) + # Everything under `.anvil/container/`, not a fixed list of three files. + # The Dockerfile is composed -- anvil owns regions inside it and the + # repository owns the gaps -- and a repository that adds a `COPY` in one of + # those gaps names a file that shapes the image: a corporate root CA, an + # install script, a patch. A replacement region from a downstream catalog + # does the same. Hashing only the three files anvil happens to know about + # would let any of them change the image under a reference that already + # resolves, which is precisely the hole this digest exists to close. + # + # The hook is picked up by the same walk. Its *output* is deliberately + # never hashed: a credential must not influence a tag. + # + # `.anvil-proposed` siblings are excluded. A region proposal is anvil's own + # review artifact, written beside its host when a template moves under a + # customized region; the build cannot see it and it cannot change what the + # image contains. Digesting it would rename the image for as long as a + # proposal sat undismissed, so two checkouts of one commit would disagree + # on the tag and a published image would stop resolving. + $containerRoot = Join-Path $repoRoot '.anvil/container' + if (Test-Path -LiteralPath $containerRoot) { + foreach ($file in Get-ChildItem -LiteralPath $containerRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + # Every generated recipe file. The image installs its tools by running + # `just anvil-setup`, and that dependency chain runs through the tier, + # group and check recipes before it reaches the install recipes in + # tools.just -- so the routing decides *whether* a tool is installed just + # as surely as tools.just decides *how*. Hashing only the install + # definitions would let a group drop a `-setup` dependency, changing the + # installed set, without renaming the image. + # + # This driver is included too. It is not circular -- the tag is derived + # from file text, and no file contains the tag -- and it belongs in the set + # because it passes the build arguments, the secret mounts and the hook's + # `Anvil-BuildSecrets` output into the build, all of which shape the result. + # Every file in the generated recipe tree, not only `*.just`. The build + # context admits the whole `justfiles/anvil/` directory (see the ignore + # file), so anything an adopter drops there is copied into the image. The + # catalog refuses to *own* a non-recipe file there, but a repository can + # still add one by hand, and a file that reaches the image without reaching + # the tag is precisely the hole this digest exists to close. Hashing what + # the context copies keeps the two sets identical by construction. + # + # -Force because Get-ChildItem omits hidden entries otherwise: a + # dot-prefixed file is copied like any other, and skipping it would let its + # edits ride under an unchanged tag -- and make Windows and Unix disagree. + # + # `.anvil-proposed` siblings are excluded here for the same reason as under + # `.anvil/container/`: this driver is itself an owned artifact, so a + # repository that customizes it gets the proposal written right here. + $recipeRoot = Join-Path $repoRoot 'justfiles/anvil' + if (Test-Path -LiteralPath $recipeRoot) { + foreach ($file in Get-ChildItem -LiteralPath $recipeRoot -Recurse -Force | + Where-Object { -not $_.Name.EndsWith('.anvil-proposed') }) { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $links += $file.FullName } + elseif (-not $file.PSIsContainer) { + $inputs += [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) -replace '\\', '/' + } + } + } + + # A symlink is refused rather than digested. The engine copies the link + # itself while any reading of it here follows it, so a retarget changes the + # image without changing a single byte the walk can see, and a link to a + # directory is not enumerated by the walk at all. Framing link text instead + # would have to work on Windows, where git materializes a symlink as an + # ordinary file unless the checkout was privileged, so the same commit would + # digest differently per platform. Anvil never creates one under these + # trees, so refusing costs nothing and closes the whole class. + if ($links.Count -gt 0) { + $named = ($links | ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_) -replace '\\', '/' }) -join ', ' + Write-Error "anvil: the container image inputs must be regular files, but these are links: $named. The engine copies a link as a link while the image tag is computed from what it points at, so the image would not match its own reference. Replace them with regular files." + exit 1 + } + + # Hash a tagged stream rather than raw concatenation, so no rearrangement of + # names and contents can collide. Line endings are normalized once, here, so + # a CRLF checkout and an LF checkout agree on the tag. Ordinal sort and dedup: + # `Sort-Object -Unique` compares case-insensitively, which would silently drop + # one of two inputs differing only in case on the case-sensitive filesystem + # where the image is actually built. + # Length-prefix the path and the content rather than relying on newlines as + # separators. A bare `file\n\n\n` stream is not + # self-delimiting: content is arbitrary, so a file whose body contains + # "file\n\n" serializes identically to two files whose bodies + # split at that point. That makes distinct input sets nameable by one tag -- + # a hook that exists versus an ignore file whose body ends in the hook's + # path and body, for instance -- and the second state would silently reuse + # the first state's image. Byte counts cannot be forged by content. + # + # Content is hashed as BYTES, not as decoded text. `ReadAllText` decodes + # UTF-8 with a replacing fallback, so every invalid sequence becomes U+FFFD + # before it is hashed: a one-byte file of 0xFF and one of 0xFE both collapse + # to the same replacement character and produce the same tag, while `COPY` + # puts their real, different bytes in the image. That was unreachable while + # only `*.just` was hashed and became reachable the moment the input set + # widened to everything the build context copies -- which is precisely the + # class of file (a stray `.png`, a `.DS_Store`, a UTF-16 fragment) that the + # widening admitted. + # + # Line endings are still normalized, but only for the text this tree owns: + # a `.just` recipe and the declared inputs, which a CRLF checkout and an LF + # checkout must agree on. Normalizing bytes generally would reintroduce the + # same collision from the other direction. + $ordered = [System.Collections.Generic.SortedSet[string]]::new( + [string[]]$inputs, [System.StringComparer]::Ordinal) + # A file's git mode is part of what `COPY` puts in the image -- the + # executable bit, and whether the entry is a regular file or a symlink -- so + # a change that leaves the bytes alone still changes the image and must + # rename the tag. Git's index is the only source of that mode which answers + # identically on every platform: Windows has no executable bit, so reading + # it from the filesystem would make two checkouts of one commit disagree on + # the tag, and a published image would stop resolving for half the people + # who use it. + # + # The index is authoritative only while the working tree agrees with it. A + # mode change that has not been staged would be copied by the build and + # missed by the tag, so it is refused below rather than absorbed. + # + # An untracked file's mode is not an input: it has no committed identity, so + # no other checkout can reproduce it and there is nothing for a shared tag + # to encode. + # + # quotePath=false so a non-ASCII path arrives verbatim rather than + # backslash-escaped, which would key the map on a spelling the walk never + # produces. + # Ordinal, like the sort and the dedup below: PowerShell's `@{}` folds case, + # so two paths differing only in case -- which git permits and the + # case-sensitive filesystem the image is built on can hold -- would collapse + # to one entry and both would be framed with whichever mode was stored last. + $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $tracked = @('.anvil/container', 'justfiles', 'rust-toolchain.toml') + if (Get-Command git -ErrorAction SilentlyContinue) { + $staged = & git -c core.quotePath=false -C $repoRoot ls-files --stage -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $staged) { + if ($entry -match '^(\d{6}) [0-9a-f]+ \d+\t(.+)$') { + $indexMode[$Matches[2]] = $Matches[1] + } + } + } + # `--raw` reports the working-tree mode as its second field, so a + # mode-only change is visible even though the content is identical. On + # Windows core.fileMode is normally false and git reports no drift, + # which is correct: the filesystem has no bit to disagree with. + # + # A zero working-tree mode is a deletion: the path is in neither the + # build context nor the digest, so there is nothing to disagree about. + # Every other entry is present in the context and is compared against + # the mode the digest actually framed, which comes from `ls-files + # --stage` above. The raw index-side mode is not that mode -- an + # intent-to-add entry reports zero there while `ls-files` reports a real + # placeholder -- so comparing the two raw fields would both miss an + # executable `git add -N` file and reject an ordinary one. + $drift = & git -c core.quotePath=false -C $repoRoot diff --no-ext-diff --raw -- @tracked 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($entry in $drift) { + if ($entry -match '^:\d{6} (\d{6}) [0-9a-f]+ [0-9a-f]+ \S+\t(.+)$') { + $worktreeMode, $driftPath = $Matches[1], $Matches[2] + if ($worktreeMode -ne '000000' -and $indexMode.ContainsKey($driftPath) -and $indexMode[$driftPath] -ne $worktreeMode) { + Write-Error "anvil: '$driftPath' has mode $worktreeMode in the working tree, but the image tag was computed from mode $($indexMode[$driftPath]). The build copies the working tree, so the image would not match its own reference. Stage the change (git add) and re-run." + exit 1 + } + } + } + } + } + # ComputeHash rather than the static HashData: the latter arrived in .NET 5, + # and the prerequisite check accepts any PowerShell 7, including 7.0 on + # .NET Core 3.1 where the static overload does not exist. Failing there + # would be a MethodNotFound at tag time, before anything useful happened. + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($rel in $ordered) { + $path = Join-Path $repoRoot $rel + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Error "anvil: container image input is missing: $rel" + exit 1 + } + if ([System.IO.Path]::GetExtension($rel) -eq '.just' -or $declaredText.Contains($rel)) { + $content = [System.Text.Encoding]::UTF8.GetBytes( + ([System.IO.File]::ReadAllText($path) -replace "`r`n", "`n")) + } else { + $content = [System.IO.File]::ReadAllBytes($path) + } + # The whole git mode, not just the executable bit: `COPY` preserves + # a symlink as a symlink, while the walk above reads through it, so + # replacing a regular file with a link to identical bytes would + # otherwise keep the tag. An untracked path has no framed mode. + $mode = if ($indexMode.ContainsKey($rel)) { $indexMode[$rel] } else { '-' } + $header = [System.Text.Encoding]::UTF8.GetBytes( + 'file ' + [System.Text.Encoding]::UTF8.GetByteCount($rel) + ' ' + $rel + ' ' + $mode + ' ' + $content.Length + ' ') + [void]$sha.TransformBlock($header, 0, $header.Length, $null, 0) + if ($content.Length -gt 0) { + [void]$sha.TransformBlock($content, 0, $content.Length, $null, 0) + } + } + [void]$sha.TransformFinalBlock([byte[]]::new(0), 0, 0) + $digest = $sha.Hash + } finally { + $sha.Dispose() + } + # 16 hex characters (64 bits) is far past any practical collision risk for a + # local image set, and keeps `docker images` readable. + $imageId = -join ($digest[0..7] | ForEach-Object { $_.ToString('x2') }) + Write-Output ('{{anvil_container_name}}:' + $imageId) -[unix] +# Resolve the exec image, building it if it is neither present nor resolvable. +# +# Three steps, in order: a local image under the computed tag, then the +# optional `Anvil-ResolveImage` hook (a registry, typically), then a build. +# Resolution comes before the NO_REBUILD guard because fetching a published +# image is not building one. +# +# ANVIL_CONTAINER_NO_REBUILD=1 fails instead of building, which is how a cache +# miss is told apart from a build failure. ANVIL_CONTAINER_NO_RESOLVE=1 skips +# the hook, so a query stays a query -- resolving can mean pulling gigabytes. +# ANVIL_CONTAINER_NO_CACHE=1 rebuilds a tag that already resolves, for the cases +# a content hash cannot see: a moved upstream package, or a base layer that +# changed behind its digest. It skips the hook too -- "ignore what is cached" +# has to mean the remote cache as well, or a rebuild would be undone by the +# next pull. +[private] +[script("pwsh", "-NoProfile")] +_anvil-container-image: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $dockerfile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-dockerfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $dockerfile = "$dockerfile".Trim() + $hookRel = '.anvil/container/hooks.ps1' + + $image = & '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $image = "$image".Trim() + + if ($env:ANVIL_CONTAINER_NO_CACHE -ne '1') { + & $engineExe @enginePrefix image inspect $image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output $image + exit 0 + } + + # Nothing local. Give the optional hook a chance to fetch a published + # image built from these same inputs -- a registry, typically. + # + # The hook returns the reference it made available, and we run that + # reference rather than re-tagging it to the local name: a local tag + # asserts "built here from these inputs", and a fetched image only + # *claims* that, since the hash is over source files and cannot be + # re-derived from layers. Whether that claim holds is a property of the + # registry (immutable tags, restricted push), not of anything this + # recipe can check, so the reference stays honest about where it came + # from. + # + # Every failure here is non-fatal: a missing image, an expired + # credential and a broken hook all fall through to a build, which is + # slower but always correct. A publisher that has not yet caught up + # with a change must not stop the developer who made it. + $hookPath = Join-Path $repoRoot $hookRel + if ($env:ANVIL_CONTAINER_NO_RESOLVE -ne '1' -and (Test-Path -LiteralPath $hookPath -PathType Leaf)) { + # Dot-sourcing is inside the try as well: a hook with a syntax error, + # or one that throws while being loaded, must cost no more than a + # hook that resolves nothing. The recipe runs under + # `$ErrorActionPreference = 'Stop'`, so leaving the load outside + # would abort the run instead of falling through to a build. + $resolved = $null + try { + . $hookPath + if (Get-Command Anvil-ResolveImage -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-ResolveImage from $hookRel") + $resolved = @(Anvil-ResolveImage $image | Where-Object { $_ }) | Select-Object -Last 1 + } + } catch { + [Console]::Error.WriteLine("anvil: $hookRel failed: $($_.Exception.Message)") + $resolved = $null + } + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $resolved = ([string]$resolved).Trim() + # A presence check, not a verification: `image inspect` proves + # something is tagged with that reference, not that its contents + # match the digest the tag claims. Trusting the hook is the + # contract -- this only keeps a reference the hook reported but + # never fetched from failing later, under `--pull=never`, a long + # way from the cause. + & $engineExe @enginePrefix image inspect $resolved *> $null + if ($LASTEXITCODE -eq 0) { + [Console]::Error.WriteLine("anvil: resolved $resolved") + Write-Output $resolved + exit 0 + } + [Console]::Error.WriteLine( + "anvil: Anvil-ResolveImage reported '$resolved' but no such image is present locally") + } + [Console]::Error.WriteLine("anvil: nothing resolved; building locally") + } + } + + # Checked outside the cache guard, so the two variables compose: a caller + # that has NO_CACHE exported would otherwise fall straight through to a + # from-scratch build, which is exactly what NO_REBUILD exists to prevent -- + # and `anvil-container-status`, which sets it, would spend minutes building + # from a query. + if ($env:ANVIL_CONTAINER_NO_REBUILD -eq '1') { + # Still report the reference: a caller that asked not to build is + # usually asking *which* image is missing. + Write-Output $image + [Console]::Error.WriteLine("anvil: $image is not present or not current, and ANVIL_CONTAINER_NO_REBUILD=1") + exit 1 + } + + # Build-time credentials come from the optional hook, never from a committed + # file. Values are handed to BuildKit by environment variable name, so they + # stay out of the host's process command line, and BuildKit keeps them out of + # every image layer. An empty value is fatal: BuildKit would mount an empty + # secret, the build would install a reduced tool set and exit 0, and the + # result would be tagged with the same hash a credentialed build produces -- + # so every later run would reuse the broken image. + $secretArgs = @() + $secretEnv = @() + $hookPath = Join-Path $repoRoot $hookRel + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed, unlike the resolve hook: a build that cannot mint its + # credentials must stop, not proceed to produce a reduced image. The + # try exists only so the cause is named -- loading a hook with a syntax + # error would otherwise surface as a bare parser error with no hint + # that a hook was involved. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load ${hookRel}: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-BuildSecrets -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-BuildSecrets from $hookRel") + try { + # Take the last emitted object, not the whole stream: a hook + # that writes progress with `Write-Output` would otherwise hand + # back an array whose `.Secrets` is silently $null. + $hook = @(Anvil-BuildSecrets | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-BuildSecrets failed: $($_.Exception.Message)" + exit 1 + } + $secrets = if ($null -ne $hook) { $hook.Secrets } else { $null } + # A defined `Anvil-BuildSecrets` that yields nothing is the hazard this + # guard exists for, not a hook opting out: secrets are the only + # thing the phase can contribute, so an empty return means the mint + # failed quietly. A hook with no build-time credentials simply does + # not define the function. + if ($null -eq $secrets -or $secrets.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-BuildSecrets returned no secrets; omit the function if the build needs none" + exit 1 + } + foreach ($id in $secrets.Keys) { + if ([string]::IsNullOrWhiteSpace($secrets[$id])) { + Write-Error "anvil: Anvil-BuildSecrets returned an empty value for secret '$id'" + exit 1 + } + $name = "ANVIL_SECRET_$id" + Set-Item -LiteralPath "Env:$name" -Value $secrets[$id] + $secretEnv += $name + $secretArgs += "id=$id,env=$name" + } + [Console]::Error.WriteLine("anvil: build secrets: $($secrets.Keys -join ', ')") + } + } + + try { + # Progress goes to stderr: callers capture this recipe's stdout to learn + # the image reference, so anything else written there becomes part of it. + [Console]::Error.WriteLine("anvil: building $image (inputs changed or first run)") + # The engine may not share this host's filesystem view, so the context + # and the Dockerfile are given in its terms rather than ours. + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + # Pinned, not inferred from the host. The Dockerfile installs amd64 + # toolchains and verifies amd64 checksums, so an arm host would resolve + # the multi-arch base to arm64 and fail late with an exec-format error. + # It also keeps the identity scheme honest: without this, two hosts of + # different architecture compute the same tag for different images. + $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # BuildKit reads `.dockerignore` on its own; buildah reads + # only a context-root ignore file and needs to be pointed at ours. Named + # rather than probed, because the flag is rejected outright by the engine + # that does not take it, and an unscoped context streams the whole + # worktree -- `target/` included -- on every build. + if ($engineCmd[-1] -eq 'podman') { $buildCmd += @('--ignorefile', "$engineRoot/$dockerfile.dockerignore") } + if ($env:ANVIL_CONTAINER_NO_CACHE -eq '1') { $buildCmd += '--no-cache' } + foreach ($secret in $secretArgs) { $buildCmd += @('--secret', $secret) } + $buildCmd += $engineRoot + # BuildKit is required for --secret; docker enables it by default from + # 23.0 but an older daemon silently ignores the flag, so ask explicitly. + $env:DOCKER_BUILDKIT = '1' + # WSLENV exports the named variables into the WSL environment, which is + # where the engine reads a secret's value from when it runs there. + if ($engineExe -eq 'wsl.exe') { + $bridged = @('DOCKER_BUILDKIT/u') + ($secretEnv | ForEach-Object { "$_/u" }) + $env:WSLENV = (@($env:WSLENV) + $bridged | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @buildCmd | ForEach-Object { [Console]::Error.WriteLine($_) } + if ($LASTEXITCODE -ne 0) { + # Build secrets are the part of this path engines implement least + # consistently -- podman on Windows cannot mount one at all, and + # fails with a path error that names neither the secret nor the + # engine. Say so once, rather than leaving that to be rediscovered. + if ($secretArgs.Count -gt 0) { + [Console]::Error.WriteLine( + "anvil: the build passed $($secretArgs.Count) secret(s) from $hookRel. " + + "If the failure above is about a temp file or a path, the engine may not support " + + "build secrets on this host; see docs/design/containers.md.") + } + exit $LASTEXITCODE + } + } finally { + foreach ($name in $secretEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + } + + Write-Output $image + +# Run a command inside the pinned Linux image. +# +# The tokens after the recipe name are the argv, executed verbatim in the +# image. Anvil recipes are reached by naming `just` like any other command. +# +# just anvil-container just anvil-pr # a tier +# just anvil-container cargo build # any other command +# just anvil-container # interactive shell +# +# ANVIL_IN_CONTAINER is set inside the image, so a nested invocation runs the +# command on the spot and the work happens exactly once. + +# Run a command inside the pinned Linux image (no argument: a shell). [group("anvil-container")] -[script("bash")] -anvil-container *recipe: - requested={{ quote(recipe) }} - if [[ -z "$requested" ]]; then - exec bash '.anvil/container/run-in-container.sh' - fi - read -r -a requested_args <<<"$requested" - exec bash '.anvil/container/run-in-container.sh' "${requested_args[@]}" +[script("pwsh", "-NoProfile")] +anvil-container *command: + $ErrorActionPreference = 'Stop' + # `*command` joins its parts with spaces, so the string is split back into + # argv here. Whitespace is the only separator, so an argument containing a + # space does not survive; pass such a value through the environment. + $argv = @('{{ replace(command, "'", "''") }}' -split '\s+' | Where-Object { $_ }) + if ($env:ANVIL_IN_CONTAINER -eq '1') { + if ($argv.Count -eq 0) { + # The no-argument form asks for a shell in the image, and this is + # that shell. Nothing runs, so exiting 0 would report success for a + # request that was not carried out. + [Console]::Error.WriteLine("anvil: already inside the container; run the command directly") + exit 1 + } + # `just` resolves to the binary running this tree rather than to PATH: + # ANVIL_IN_CONTAINER is a documented control a developer can set on a + # host, and a caller who invoked `just` by absolute path with its + # directory off PATH would otherwise fail here. + $exe = if ($argv[0] -eq 'just') { '{{ replace(just_executable(), "'", "''") }}' } else { $argv[0] } + & $exe @($argv | Select-Object -Skip 1) + exit $LASTEXITCODE + } + + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + $image = (& '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $repoRoot = '{{ replace(justfile_directory(), "'", "''") }}' + $engineRoot = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $repoRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineRoot = "$engineRoot".Trim() + + # Map the caller's working directory to its in-container equivalent so + # relative paths keep working from a subdirectory. `invocation_directory()` + # would be wrong here: with `cygpath` on PATH it reports a Cygwin-style + # path, which shares no prefix with the native `justfile_directory()` above. + # `GetRelativePath` then walks out with `..` segments and the run is placed + # outside the mount, so it fails on a path that does not exist in the + # container. The `_native` form is the one that agrees with the root. + $rel = [System.IO.Path]::GetRelativePath($repoRoot, '{{ replace(invocation_directory_native(), "'", "''") }}') -replace '\\', '/' + if ($rel.StartsWith('..')) { + Write-Error "anvil: run this from inside the repository; $rel is outside $repoRoot" + exit 1 + } + $containerCwd = if ($rel -eq '.' -or [string]::IsNullOrEmpty($rel)) { + '{{anvil_container_workdir}}' + } else { + '{{anvil_container_workdir}}/' + $rel + } + + $interactive = $argv.Count -eq 0 + $runArgs = @('run', '--rm', '--platform', 'linux/amd64') + $runArgs += $interactive ? '-it' : '-i' + $runArgs += @('-v', "${engineRoot}:{{anvil_container_workdir}}") + + # A checkout whose `.git` is a file keeps its real git directory elsewhere: + # a linked worktree points into the main clone, `--separate-git-dir` and a + # submodule point somewhere else again. The path recorded there is a host + # path that does not exist inside the container, so git resolves neither + # HEAD nor origin/main. Mount the common git directory and replace the + # checkout's `.git` with one naming that mount; the `commondir` file in a + # worktree's entry is relative, so it resolves under it. + # + # The predicate is the shape of `.git`, not whether the git directory + # differs from the common one: `--separate-git-dir` redirects without + # differing, and testing for a difference skips it and leaves git pointed at + # a path the container cannot see. + # + # The redirection lives in the checkout rather than in GIT_DIR/GIT_WORK_TREE + # so that it stays scoped to it. Those variables are ambient: every process + # in the container inherits them, and a git command run elsewhere -- `git + # init` in a test's scratch directory -- would operate on this repository + # instead of its own. + # + # An ordinary clone keeps its git directory inside the checkout, where the + # bind mount already carries it, and takes none of this. + # + # Guarded on git being present: the run path needs it only to answer this + # question, and a host with a working engine but no git on PATH keeps + # working rather than failing on a call it does not need. + $gitFile = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $gitDir = & git rev-parse --git-dir 2>$null + $gitCommon = & git rev-parse --git-common-dir 2>$null + if ($LASTEXITCODE -eq 0 -and $gitDir -and $gitCommon -and + (Test-Path -LiteralPath (Join-Path $repoRoot '.git') -PathType Leaf)) { + $gitDirAbs = (Resolve-Path -LiteralPath $gitDir).Path + $gitCommonAbs = (Resolve-Path -LiteralPath $gitCommon).Path + $rel = [System.IO.Path]::GetRelativePath($gitCommonAbs, $gitDirAbs) -replace '\\', '/' + # One mount has to carry both directories, so the git directory must + # sit under the common one. `git worktree` always places it there and + # a redirect without a separate worktree entry makes the two equal; + # anything else cannot be expressed as a single mount, and emitting a + # path that climbs out of it would fail inside the container instead. + if ($rel -eq '.') { + $containerGitDir = '/anvil/gitdir' + } elseif ($rel.StartsWith('../') -or [System.IO.Path]::IsPathRooted($rel)) { + Write-Error "anvil: this checkout's git directory ($gitDirAbs) is not inside its common git directory ($gitCommonAbs), so the two cannot be mounted as one tree. Run the container from an ordinary clone or a git worktree checkout." + exit 1 + } else { + $containerGitDir = "/anvil/gitdir/$rel" + } + $engineGitCommon = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitCommonAbs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitCommon = "$engineGitCommon".Trim() + # LF and no trailing newline: git parses this file strictly. + $gitFile = Join-Path ([System.IO.Path]::GetTempPath()) "anvil-gitfile-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($gitFile, "gitdir: $containerGitDir`n") + $engineGitFile = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-path $gitFile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engineGitFile = "$engineGitFile".Trim() + $runArgs += @('-v', "${engineGitCommon}:/anvil/gitdir") + $runArgs += @('-v', "${engineGitFile}:{{anvil_container_workdir}}/.git:ro") + } + } + + # Cache only what is content-addressed: the downloaded registry and the git + # checkouts. Deliberately NOT $CARGO_HOME or $RUSTUP_HOME themselves -- + # those hold the installed tools and toolchains, and a named volume is + # populated from the image only when it is first created. Mounting them + # would pin the first image's binaries over every later one, so a tool bump + # would change the tag, build a new image, and still run the old tools. + $runArgs += @('-v', '{{anvil_container_name}}-cargo-registry:/usr/local/cargo/registry') + $runArgs += @('-v', '{{anvil_container_name}}-cargo-git:/usr/local/cargo/git') + # Match the caller's uid/gid on Linux. Without this everything the run + # writes under the bind mount -- target/, generated files -- lands as root + # on the host, and the next native cargo build or git clean fails with + # EACCES a long way from the cause. Docker Desktop on Windows and macOS + # already maps ownership, and `id` is not there to ask. + if (-not $IsWindows -and -not $IsMacOS) { + $hostUid = (id -u); $hostGid = (id -g) + if ($LASTEXITCODE -eq 0 -and $hostUid -ne '0') { + $runArgs += @('--user', "${hostUid}:${hostGid}") + # That uid has no passwd entry, so the engine leaves HOME as `/`. + # Anything falling back to $HOME for a cache then writes to a + # read-only root and fails a long way from the cause. + $runArgs += @('-e', 'HOME=/tmp') + } + } + $runArgs += @('-e', 'ANVIL_IN_CONTAINER=1') + + # Run-time credentials come from the optional hook. Forwarded by NAME, never + # as NAME=VALUE: the engine copies the value out of the environment it + # already inherits, so a credential never appears in the host's process + # command line, where endpoint telemetry records and retains it for far + # longer than a short-lived token is meant to live. + # $forwardedEnv is every name passed with -e; $hookEnv is the subset this + # process set, and so the subset it must unset again. + $forwardedEnv = @() + $hookEnv = @() + + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # + # An already-exported GITHUB_TOKEN is forwarded whatever the command is: + # that is exact parity, since a native run exposes it to every process the + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. + # + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { + # A dry run has no side effects, and a target that cannot be planned + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. + # + # A plan covers the bodies just runs itself, not the body of a + # recipe that one of them launches as a child process. The unscoped + # tier wrapper launches its tier that way, so planning + # `anvil-scheduled` shows the wrapper and none of the checks + # underneath it. Follow each nested target a plan names, or a + # wrapped tier reads as needing nothing and runs unauthenticated. + $plan = '' + $targets = [System.Collections.Generic.List[object]]::new() + $targets.Add([string[]]@($argv | Select-Object -Skip 1)) + $planned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($i = 0; $i -lt $targets.Count; $i++) { + $target = [string[]]$targets[$i] + # A recipe reachable twice is planned once, and a body naming + # itself terminates. + if (-not $planned.Add(($target -join ' '))) { continue } + # The same executable that launched this tree, for the reason + # every other nested call uses it: a caller invoking `just` by + # absolute path with its directory off PATH would otherwise fail + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. + $step = '' + try { + $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | + ForEach-Object { $_.ToString() }) -join "`n" + } catch { + $step = '' + } + $plan = "$plan`n$step" + # A launched recipe appears as a quoted argument to `just`. + foreach ($nested in [regex]::Matches($step, "'(_anvil-[^'\s]+)'")) { + $targets.Add([string[]]@($nested.Groups[1].Value)) + } + } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } + } + } + if ($env:GITHUB_TOKEN) { + $forwardedEnv += 'GITHUB_TOKEN' + $runArgs += @('-e', 'GITHUB_TOKEN') + } + + # The recipe contract's own inputs. These are read by generated checks -- + # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its + # CI equivalents, and `anvil-impact` reads ANVIL_IMPACT to decide whether to + # compute scoping, consume a downloaded cache, or skip -- so dropping them at + # the boundary makes the same command mean different things inside and out. + # anvil-pr-title is the sharp case: with PR_TITLE unset it exits 0 with a + # skip notice, so a title a native run rejects passes in a container and the + # tier still reports green. ANVIL_IMPACT is the other: a CI group job exports + # `consume`, and a container that did not inherit it would recompute scoping + # from a diff instead of trusting the artifact the group downloaded. + # + # Forwarded by name and only when set, so an unset variable stays unset + # rather than arriving as an empty string, which several of these treat as + # a value. + foreach ($name in @( + 'PR_TITLE', + 'BASE_REF', 'GITHUB_BASE_REF', 'SYSTEM_PULLREQUEST_TARGETBRANCH', + 'ANVIL_IMPACT')) { + if ((Test-Path -LiteralPath "Env:$name") -and -not [string]::IsNullOrEmpty((Get-Item -LiteralPath "Env:$name").Value)) { + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + } + + $hookPath = Join-Path $repoRoot '.anvil/container/hooks.ps1' + if (Test-Path -LiteralPath $hookPath -PathType Leaf) { + # Fail-closed like Anvil-BuildSecrets: a run that cannot obtain its + # credentials fails inside the container in a far less obvious way. + try { + . $hookPath + } catch { + Write-Error "anvil: failed to load .anvil/container/hooks.ps1: $($_.Exception.Message)" + exit 1 + } + if (Get-Command Anvil-RunEnv -CommandType Function -ErrorAction SilentlyContinue) { + [Console]::Error.WriteLine("anvil: running Anvil-RunEnv from .anvil/container/hooks.ps1") + try { + $hook = @(Anvil-RunEnv | Where-Object { $_ }) | Select-Object -Last 1 + } catch { + Write-Error "anvil: Anvil-RunEnv failed: $($_.Exception.Message)" + exit 1 + } + $hookVars = if ($null -ne $hook) { $hook.Env } else { $null } + if ($null -eq $hookVars -or $hookVars.Keys.Count -eq 0) { + Write-Error "anvil: Anvil-RunEnv returned no variables; omit the function if the run needs none" + exit 1 + } + foreach ($name in $hookVars.Keys) { + if ([string]::IsNullOrWhiteSpace($hookVars[$name])) { + Write-Error "anvil: Anvil-RunEnv returned an empty value for '$name'" + exit 1 + } + Set-Item -LiteralPath "Env:$name" -Value $hookVars[$name] + $hookEnv += $name + $forwardedEnv += $name + $runArgs += @('-e', $name) + } + # Names only, never values: a hook with a broad idea of what to + # forward should be visible, since everything inside the + # container can read it -- including third-party build scripts. + [Console]::Error.WriteLine("anvil: forwarding env: $($hookVars.Keys -join ', ')") + } + } + + try { + # --pull=never: the reference names content that is already here, either + # built locally or fetched by the resolve hook, so a miss is a bug to + # surface rather than an invitation to fetch something unrelated. + $runArgs += @('--pull=never', '-w', $containerCwd, $image) + if (-not $interactive) { $runArgs += $argv } + # WSLENV exports the forwarded names into the WSL environment, which is + # where the engine reads their values from when it runs there. Without + # it, `-e NAME` reaches an engine that cannot see NAME and forwards + # nothing, leaving the variable unset inside the container. + # + # It is not restored afterwards because there is nothing to restore to: + # `just` runs a [script(...)] recipe as its own pwsh process, so this + # assignment dies with that process and never reaches the caller's + # shell. The `finally` below unsets the credential names for hygiene + # within this process, not to protect the parent. + if ($engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0) { + $env:WSLENV = (@($env:WSLENV) + ($forwardedEnv | ForEach-Object { "$_/u" }) | Where-Object { $_ }) -join ':' + } + & $engineExe @enginePrefix @runArgs + exit $LASTEXITCODE + } finally { + foreach ($name in $hookEnv) { Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue } + if ($gitFile) { Remove-Item -LiteralPath $gitFile -Force -ErrorAction SilentlyContinue } + } + +# Report the engine, the exec image, and whether it is present and current. +# +# The tag embeds the hash of the image's inputs, so "absent" and "out of date" +# are the same condition and are reported as one. + +# Report the engine, the exec image, and whether it is present and current. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-status: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + Write-Output ("engine: " + ($engine -replace '\|', ' ')) + Write-Output "workdir: {{anvil_container_workdir}}" + + # NO_REBUILD turns the resolve into a pure query: report the state instead + # of silently spending several minutes building from a status command. + # NO_RESOLVE is the same argument applied to the hook, which would otherwise + # pull gigabytes to answer a question about the local machine. + # + # Compute the tag first and let it fail loudly. It is fatal for a reason a + # query cannot paper over -- a declared input is missing -- and reporting + # that as "not present locally" would be a lie: the next run cannot build + # it either. + $image = (& '{{ replace(just_executable(), "'", "''") }}' anvil-container-tag) | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output "image: $image" + + $env:ANVIL_CONTAINER_NO_REBUILD = '1' + $env:ANVIL_CONTAINER_NO_RESOLVE = '1' + # And explicitly *not* NO_CACHE. A caller who exported it is asking the next + # build to ignore the layer cache, which is a statement about building -- + # but it also makes the resolver skip the local `image inspect` + # short-circuit, so a present image would be reported absent by a command + # that is only ever asking what is on this machine. + $env:ANVIL_CONTAINER_NO_CACHE = $null + & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-image *> $null + if ($LASTEXITCODE -eq 0) { + Write-Output "status: present and current" + exit 0 + } + + # A cache miss and an unreachable daemon both make `image inspect` fail, and + # reporting the second as the first tells a developer to expect a build that + # will not start either. Ask the engine whether it is answering at all: only + # then is absence the honest reading. + $engineCmd = $engine -split '\|' + & $engineCmd[0] @($engineCmd | Select-Object -Skip 1) version *> $null + if ($LASTEXITCODE -ne 0) { + Write-Output "status: unknown -- the engine is not responding (is the daemon running?)" + exit 1 + } + Write-Output "status: not present locally (the next run resolves or builds it)" + exit 0 + +# Only the download caches are volumes, so this discards fetched crates and git +# checkouts and nothing else: the next run re-fetches them, and the image's own +# tools are untouched. To discard the image instead, set +# ANVIL_CONTAINER_NO_CACHE=1 for a single run. + +# Remove this repository's cache volumes. The image is left in place. +[group("anvil-container")] +[script("pwsh", "-NoProfile")] +anvil-container-down: + $ErrorActionPreference = 'Stop' + $engine = & '{{ replace(just_executable(), "'", "''") }}' _anvil-container-engine + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $engine = "$engine".Trim() + $engineCmd = $engine -split '\|' + $engineExe = $engineCmd[0] + $enginePrefix = @($engineCmd | Select-Object -Skip 1) + # Report a teardown that did not happen. $ErrorActionPreference does not + # cover native commands, so a non-serving engine would otherwise print a + # connection error per volume and still exit 0 -- and this recipe is the + # only way to clear a cache volume, so a caller that scripts teardown must + # be able to tell that it failed. `-f` already exits 0 for a volume that + # does not exist, so this cannot fire spuriously. + $failed = @() + foreach ($vol in @('{{anvil_container_name}}-cargo-registry', '{{anvil_container_name}}-cargo-git')) { + & $engineExe @enginePrefix volume rm -f $vol + if ($LASTEXITCODE -ne 0) { $failed += $vol } + } + if ($failed.Count -gt 0) { + Write-Error ("anvil: could not remove: " + ($failed -join ', ')) + exit 1 + } + exit 0 diff --git a/justfiles/anvil/groups/scheduled-advisories.just b/justfiles/anvil/groups/scheduled-advisories.just index 728c82af..d4787562 100644 --- a/justfiles/anvil/groups/scheduled-advisories.just +++ b/justfiles/anvil/groups/scheduled-advisories.just @@ -6,14 +6,14 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled advisory checks. [group("anvil")] -anvil-scheduled-advisories: (_anvil-run "scheduled-advisories" anvil_runner "off") +anvil-scheduled-advisories: (_anvil-unscoped "scheduled-advisories") [private] _anvil-scheduled-advisories: anvil-scheduled-advisories-validate-prereqs \ diff --git a/justfiles/anvil/groups/scheduled-exhaustive.just b/justfiles/anvil/groups/scheduled-exhaustive.just index 2e46be87..9cfd91c1 100644 --- a/justfiles/anvil/groups/scheduled-exhaustive.just +++ b/justfiles/anvil/groups/scheduled-exhaustive.just @@ -6,14 +6,14 @@ # See https://github.com/microsoft/ox-tools/blob/main/crates/cargo-anvil/docs/design/checks.md -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled exhaustive checks. [group("anvil")] -anvil-scheduled-exhaustive: (_anvil-run "scheduled-exhaustive" anvil_runner "off") +anvil-scheduled-exhaustive: (_anvil-unscoped "scheduled-exhaustive") [private] _anvil-scheduled-exhaustive: anvil-scheduled-exhaustive-validate-prereqs \ diff --git a/justfiles/anvil/groups/scheduled-runtime-analysis.just b/justfiles/anvil/groups/scheduled-runtime-analysis.just index 19228447..f443eb5c 100644 --- a/justfiles/anvil/groups/scheduled-runtime-analysis.just +++ b/justfiles/anvil/groups/scheduled-runtime-analysis.just @@ -12,14 +12,14 @@ # and adds the three stricter miri profiles (tree-borrows, strict- # provenance, race-coverage) which are too expensive for PR. -# Full-workspace backstop: routed through _anvil-run with impact "off" so +# Full-workspace backstop: routed through _anvil-unscoped so # ANVIL_IMPACT=off is set before the check dependencies run, regardless of how # the group is invoked. The group does not recompute impact and therefore does # not require cargo-delta. # Run the scheduled runtime analysis. [group("anvil")] -anvil-scheduled-runtime-analysis: (_anvil-run "scheduled-runtime-analysis" anvil_runner "off") +anvil-scheduled-runtime-analysis: (_anvil-unscoped "scheduled-runtime-analysis") [private] _anvil-scheduled-runtime-analysis: anvil-scheduled-runtime-analysis-validate-prereqs \ diff --git a/justfiles/anvil/groups/scheduled-test.just b/justfiles/anvil/groups/scheduled-test.just index c94b6690..d60f03f5 100644 --- a/justfiles/anvil/groups/scheduled-test.just +++ b/justfiles/anvil/groups/scheduled-test.just @@ -9,7 +9,7 @@ # Scheduled groups # Scheduled groups are the full-workspace backstop for PR-tier impact scoping, -# so route through _anvil-run with impact "off": it exports ANVIL_IMPACT=off +# so route through _anvil-unscoped: it exports ANVIL_IMPACT=off # before the check dependencies run, so the group is full-workspace regardless # of how it is invoked (CI, `just anvil-scheduled`, or # `just anvil-scheduled-test` directly). Because these groups never recompute @@ -17,7 +17,7 @@ # Run the scheduled tests. [group("anvil")] -anvil-scheduled-test: (_anvil-run "scheduled-test" anvil_runner "off") +anvil-scheduled-test: (_anvil-unscoped "scheduled-test") [private] _anvil-scheduled-test: anvil-scheduled-test-validate-prereqs \ diff --git a/justfiles/anvil/helpers.just b/justfiles/anvil/helpers.just index 971e607b..5be4b411 100644 --- a/justfiles/anvil/helpers.just +++ b/justfiles/anvil/helpers.just @@ -120,3 +120,22 @@ _anvil-base-ref: } Write-Error 'anvil-base-ref: cannot resolve a base ref. Set BASE_REF, or ensure origin/main or origin/master exists.' exit 1 + +# Run a private recipe with impact scoping disabled. +# +# The only way to reach a whole dependency tree with an environment variable: +# `just` runs each dependency as its own process, and a dependency-only +# recipe's body executes after its dependencies, so exporting from there is +# too late. Invoking `_anvil-` as a child process makes every check +# below it inherit the setting. +# +# The justfile is named explicitly so the child resolves the same file the +# wrapper was defined in, rather than whatever an upward search from the +# working directory happens to find. +[private] +[script("pwsh", "-NoProfile")] +_anvil-unscoped name: + $ErrorActionPreference = 'Stop' + $env:ANVIL_IMPACT = 'off' + & '{{ replace(just_executable(), "'", "''") }}' --justfile '{{ replace(justfile(), "'", "''") }}' '_anvil-{{ replace(name, "'", "''") }}' + exit $LASTEXITCODE \ No newline at end of file diff --git a/justfiles/anvil/mod.just b/justfiles/anvil/mod.just index ba229814..ee29054c 100644 --- a/justfiles/anvil/mod.just +++ b/justfiles/anvil/mod.just @@ -67,7 +67,11 @@ import 'checks/readme-check.just' import 'checks/semver-check.just' import 'checks/spellcheck.just' import 'checks/udeps.just' -import 'container.just' +# Optional: the container artifacts can be removed through `without_artifact`, +# which deletes this file. A hard import would then fail parsing for every +# recipe in the tree, not merely the container ones, so the documented opt-out +# would break the whole Justfile. +import? 'container.just' import 'groups/pr-fast.just' import 'groups/pr-slow.just' import 'groups/pr-test.just' @@ -77,7 +81,6 @@ import 'groups/scheduled-test.just' import 'groups/scheduled-advisories.just' import 'groups/scheduled-runtime-analysis.just' import 'groups/scheduled-exhaustive.just' -import 'runner.just' import 'tiers.just' import 'tools.just' import 'versions.just' diff --git a/justfiles/anvil/runner.just b/justfiles/anvil/runner.just deleted file mode 100644 index 0aecbb7c..00000000 --- a/justfiles/anvil/runner.just +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY. -# Update cargo-anvil and regenerate; repository-specific edits stop automatic updates. - -# Route public tier entry points through the configured execution environment. -# ANVIL_IN_CONTAINER always wins to prevent recursive container launches. -# -# `impact` selects the tier's impact-scoping mode: the default "on" leaves -# scoping enabled (PR tier), while "off" exports ANVIL_IMPACT=off before -# invoking the native tier so every check runs full-workspace -- the -# scheduled/full backstop for PR-tier impact scoping. Setting it here (rather -# than in a dep-only tier recipe) ensures the private `_anvil-` recipe's -# own dependencies, which run before any recipe body, inherit the mode. -[private] -[no-exit-message] -[windows] -[script("pwsh", "-NoProfile")] -_anvil-run tier runner impact="on": - if ('{{ replace(impact, "'", "''") }}' -ceq 'off') { $env:ANVIL_IMPACT = 'off' } - $just = '{{ replace(just_executable(), "'", "''") }}' - $justfile = '{{ replace(justfile(), "'", "''") }}' - $nativeTier = '_anvil-{{ replace(tier, "'", "''") }}' - if ($env:ANVIL_IN_CONTAINER) { - & $just --justfile $justfile $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'container') { - & $just --justfile $justfile anvil-container $nativeTier - } elseif ('{{ replace(runner, "'", "''") }}' -ceq 'native') { - & $just --justfile $justfile $nativeTier - } else { - [Console]::Error.WriteLine("anvil-runner: expected 'native' or 'container', got '{{ replace(runner, "'", "''") }}'.") - exit 2 - } - exit $LASTEXITCODE - -[private] -[no-exit-message] -[unix] -[script("bash")] -_anvil-run tier runner impact="on": - just_path={{ quote(just_executable()) }} - justfile={{ quote(justfile()) }} - tier={{ quote(tier) }} - runner={{ quote(runner) }} - impact={{ quote(impact) }} - if [[ "$impact" == "off" ]]; then - export ANVIL_IMPACT=off - fi - native_tier="_anvil-$tier" - if [[ -n "${ANVIL_IN_CONTAINER:-}" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - elif [[ "$runner" == "container" ]]; then - exec "$just_path" --justfile "$justfile" anvil-container "$native_tier" - elif [[ "$runner" == "native" ]]; then - exec "$just_path" --justfile "$justfile" "$native_tier" - else - echo "anvil-runner: expected 'native' or 'container', got '$runner'." >&2 - exit 2 - fi diff --git a/justfiles/anvil/tiers.just b/justfiles/anvil/tiers.just index 4cc1a0dc..a06e87b4 100644 --- a/justfiles/anvil/tiers.just +++ b/justfiles/anvil/tiers.just @@ -12,10 +12,7 @@ # Run all pull request checks. [group("anvil")] -anvil-pr: (_anvil-run "pr" anvil_runner) - -[private] -_anvil-pr: anvil-pr-validate-prereqs \ +anvil-pr: anvil-pr-validate-prereqs \ anvil-pr-fast \ anvil-pr-slow @@ -24,20 +21,18 @@ _anvil-pr: anvil-pr-validate-prereqs \ # exhaustive checks that don't fit in a PR budget. Runs on a schedule # against `main`, not on PRs. # -# The scheduled tier is deliberately NOT impact-scoped: it is the -# catch-all that backstops PR-tier scoping. Because every impact-scoped -# check depends on `anvil-impact` and self-populates its scope from the -# cache, the tier must run with ANVIL_IMPACT=off so `_anvil-impact-include` -# returns each tier's full-workspace default and the `anvil-impact` -# dependency no-ops. A dependency-only recipe can't set env for its own -# deps (deps run before the body), so the public tier routes through -# `_anvil-run` with the `"off"` impact argument: `_anvil-run` exports -# ANVIL_IMPACT=off before invoking the private `_anvil-scheduled` recipe, -# whose deps then inherit it. +# The scheduled tier is deliberately NOT impact-scoped: it is the catch-all +# that backstops PR-tier scoping. Every impact-scoped check depends on +# `anvil-impact` and populates its own scope from the cache, so the tier runs +# with ANVIL_IMPACT=off, which makes `_anvil-impact-include` return each +# category's full-workspace default and the `anvil-impact` dependency no-op. +# A dependency-only recipe cannot set an environment variable for its own +# dependencies, so the public tier wraps the private one through +# `_anvil-unscoped`. # Run all scheduled checks. [group("anvil")] -anvil-scheduled: (_anvil-run "scheduled" anvil_runner "off") +anvil-scheduled: (_anvil-unscoped "scheduled") [private] _anvil-scheduled: anvil-scheduled-validate-prereqs \ @@ -46,16 +41,15 @@ _anvil-scheduled: anvil-scheduled-validate-prereqs \ anvil-scheduled-runtime-analysis \ anvil-scheduled-exhaustive -# Runs everything full-workspace (ANVIL_IMPACT=off, via the `"off"` impact -# argument to _anvil-run), same wrapper shape as anvil-scheduled. +# Full-workspace for the same reason as the scheduled tier. # Full tier: PR + scheduled, end-to-end. Useful before tagging a release. [group("anvil")] -anvil-full: (_anvil-run "full" anvil_runner "off") +anvil-full: (_anvil-unscoped "full") [private] _anvil-full: anvil-full-validate-prereqs \ - _anvil-pr \ + anvil-pr \ _anvil-scheduled # Tier-level + global setup + validate-prereqs diff --git a/scripts/test-anvil-container.ps1 b/scripts/test-anvil-container.ps1 new file mode 100644 index 00000000..ffb1901f --- /dev/null +++ b/scripts/test-anvil-container.ps1 @@ -0,0 +1,795 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + End-to-end test of cargo-anvil's containerized execution, from a user's seat. + +.DESCRIPTION + Creates a throwaway repository in a temp directory, generates the anvil tree + into it with the locally-built cargo-anvil, and then does only what a + developer would do: run `just anvil-container ` and observe what + happens. + + The setup phase is held to that standard deliberately. If this script has to + hand-write a file anvil should have generated, patch a generated file, or + work around a defect to get green, that is a bug in the product and not + something the script should paper over. + + What it proves: + + 1. A generated repository carries exactly the three container artifacts. + 2. The first run builds an image and runs the recipe inside it. + 3. A second run reuses the image (the tag resolves, nothing is built), + no cache volume masks the tools the image installed, and a host + GITHUB_TOKEN is forwarded — from the environment, or from the gh CLI + when the environment has none. + 3b. A recipe run from a linked worktree can still reach git history. + 4. Changing a hashed input (the pinned toolchain) selects a new tag. + 5. Reverting that input returns to the original tag. + 6. Editing the Dockerfile is preserved by a re-run of the generator. + 7. A credential hook reaches both the build and the run, and the secret + reaches neither a build command nor the image filesystem. + 8. A hook returning an empty value fails closed. + 9. A hook's returned value does not change the tag; its file content does. + 10. The recipes run natively inside the image (no nesting). + +.PARAMETER Engine + Container engine to test against. Defaults to $env:ANVIL_CONTAINER_ENGINE, + then 'docker'. + +.PARAMETER KeepArtifacts + Leave the temp repository and built images in place for inspection. + +.PARAMETER SkipCleanup + Skip the pre-run cleanup of images and volumes left by earlier runs. + +.EXAMPLE + ./scripts/test-anvil-container.ps1 + ./scripts/test-anvil-container.ps1 -Engine podman -KeepArtifacts +#> + +[CmdletBinding()] +param( + [ValidateSet('docker', 'podman')] + [string]$Engine = $(if ($env:ANVIL_CONTAINER_ENGINE) { $env:ANVIL_CONTAINER_ENGINE } else { 'docker' }), + [switch]$KeepArtifacts, + [switch]$SkipCleanup +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# ---------------------------------------------------------------- reporting -- + +$script:Passed = 0 +$script:Failed = 0 +$script:Started = Get-Date + +function Write-Section([string]$Title) { + Write-Host '' + Write-Host "=== $Title " -NoNewline -ForegroundColor Cyan + Write-Host ('=' * [Math]::Max(0, 72 - $Title.Length)) -ForegroundColor Cyan +} + +function Write-Step([string]$Message) { + Write-Host " -> $Message" -ForegroundColor DarkGray +} + +function Write-Detail([string]$Message) { + foreach ($line in ($Message -split "`r?`n")) { + if ($line.Trim()) { Write-Host " | $line" -ForegroundColor DarkGray } + } +} + +function Assert-That([string]$Name, [bool]$Condition, [string]$Detail = '') { + if ($Condition) { + $script:Passed++ + Write-Host " [PASS] $Name" -ForegroundColor Green + } else { + $script:Failed++ + Write-Host " [FAIL] $Name" -ForegroundColor Red + if ($Detail) { Write-Detail $Detail } + } +} + +function Assert-Equal([string]$Name, $Expected, $Actual) { + Assert-That $Name ($Expected -eq $Actual) "expected: $Expected`nactual: $Actual" +} + +# ------------------------------------------------------------------ helpers -- + +function Invoke-Native { + param( + [Parameter(Mandatory)][string]$Command, + [string[]]$Arguments = @(), + [string]$WorkingDirectory, + [hashtable]$Environment = @{}, + [switch]$AllowFailure + ) + + $previous = @{} + foreach ($key in $Environment.Keys) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key) + Set-Item -LiteralPath "Env:$key" -Value $Environment[$key] + } + $entered = $false + try { + if ($WorkingDirectory) { Push-Location $WorkingDirectory; $entered = $true } + $stdoutFile = [System.IO.Path]::GetTempFileName() + $stderrFile = [System.IO.Path]::GetTempFileName() + try { + $process = Start-Process -FilePath $Command -ArgumentList $Arguments -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + $result = [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = (Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction SilentlyContinue) ?? '' + StdErr = (Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue) ?? '' + } + } finally { + Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue + } + } finally { + if ($entered) { Pop-Location } + foreach ($key in $Environment.Keys) { + if ($null -eq $previous[$key]) { + Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue + } else { + Set-Item -LiteralPath "Env:$key" -Value $previous[$key] + } + } + } + + if (-not $AllowFailure -and $result.ExitCode -ne 0) { + Write-Detail $result.StdOut + Write-Detail $result.StdErr + throw "$Command $($Arguments -join ' ') failed with exit code $($result.ExitCode)" + } + $result +} + +function Resolve-Engine { + # Mirrors what container.just does: prefer the engine on PATH, and fall + # back to the default WSL distribution on Windows. The script must not + # assume more than the product does. + if (Get-Command $Engine -ErrorAction SilentlyContinue) { + return [pscustomobject]@{ Exe = $Engine; Prefix = @(); ViaWsl = $false } + } + if ($IsWindows -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $Engine --version *> $null + if ($LASTEXITCODE -eq 0) { + return [pscustomobject]@{ Exe = 'wsl.exe'; Prefix = @('--exec', $Engine); ViaWsl = $true } + } + } + $null +} + +function Invoke-Engine { + param([string[]]$Arguments, [switch]$AllowFailure) + Invoke-Native -Command $script:EngineExe -Arguments ($script:EnginePrefix + $Arguments) -AllowFailure:$AllowFailure +} + +function ConvertTo-EnginePath([string]$Path) { + if (-not $script:EngineViaWsl) { return $Path } + # --exec, not --: plain `wsl.exe --` re-parses through the login shell, + # which eats `$` in a path and still exits 0. + (& wsl.exe --exec wslpath -a -u $Path).Trim() +} + +function Write-Fixture([string]$Path, [string]$Content) { + # LF, no BOM. This script is a CRLF file, so its here-strings carry CRLF; + # writing those verbatim would hand the fixture a repository that + # `anvil-fmt` correctly rejects for its newline style. A user cloning a + # normal repository does not start from that state, so neither should we. + $normalized = ($Content -replace "`r`n", "`n") + if (-not $normalized.EndsWith("`n")) { $normalized += "`n" } + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + [System.IO.File]::WriteAllText($Path, $normalized, [System.Text.UTF8Encoding]::new($false)) +} + +function Invoke-Just { + param( + [Parameter(Mandatory)][string]$Repo, + [Parameter(Mandatory)][string[]]$Arguments, + [hashtable]$Environment = @{}, + [switch]$AllowFailure + ) + $env = @{ ANVIL_CONTAINER_ENGINE = $Engine } + $Environment + Invoke-Native -Command 'just' -Arguments $Arguments -WorkingDirectory $Repo -Environment $env -AllowFailure:$AllowFailure +} + +function Get-ImageReference { + param([Parameter(Mandatory)][string]$Repo, [hashtable]$Environment = @{}) + # anvil-container-status reports the reference without building it. + $status = Invoke-Just -Repo $Repo -Arguments @('anvil-container-status') -Environment $Environment -AllowFailure + $line = ($status.StdOut -split "`r?`n") | Where-Object { $_ -match '^\s*image:\s*(\S+)' } | Select-Object -First 1 + if ($line -match '^\s*image:\s*(\S+)') { return $Matches[1] } + '' +} + +function Test-ImagePresent([string]$Reference) { + if (-not $Reference) { return $false } + (Invoke-Engine -Arguments @('image', 'inspect', $Reference) -AllowFailure).ExitCode -eq 0 +} + +function Remove-AnvilImages([string]$Prefix) { + $images = Invoke-Engine -Arguments @('images', '--format', '{{.Repository}}:{{.Tag}}') -AllowFailure + # Podman reports images fully qualified (`localhost/anvil-…`), docker does + # not, so match anywhere in the reference rather than at the start. + $matching = ($images.StdOut -split "`r?`n") | Where-Object { $_ -like "*$Prefix*" } + foreach ($image in $matching) { + Write-Step "removing image $image" + Invoke-Engine -Arguments @('rmi', '-f', $image) -AllowFailure | Out-Null + } + $volumes = Invoke-Engine -Arguments @('volume', 'ls', '--format', '{{.Name}}') -AllowFailure + $matchingVolumes = ($volumes.StdOut -split "`r?`n") | Where-Object { $_ -like "*$Prefix*" } + foreach ($volume in $matchingVolumes) { + Write-Step "removing volume $volume" + Invoke-Engine -Arguments @('volume', 'rm', '-f', $volume) -AllowFailure | Out-Null + } +} + +# ------------------------------------------------------------ prerequisites -- + +Write-Section 'Prerequisites' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Write-Step "source repository: $repoRoot" +Write-Step "engine: $Engine" + +foreach ($tool in @('just', 'cargo')) { + $found = Get-Command $tool -ErrorAction SilentlyContinue + Assert-That "$tool is on PATH" ([bool]$found) "install $tool" +} + +$resolved = Resolve-Engine +Assert-That "$Engine is reachable" ($null -ne $resolved) ` + "install $Engine so it is callable from this shell, or run it in the default WSL distribution" +if ($script:Failed -gt 0) { + Write-Host "`nPrerequisites missing; aborting." -ForegroundColor Red + exit 1 +} +$script:EngineExe = $resolved.Exe +$script:EnginePrefix = $resolved.Prefix +$script:EngineViaWsl = $resolved.ViaWsl +if ($resolved.ViaWsl) { + Write-Step "engine reached through the default WSL distribution (no Windows CLI on PATH)" +} + +$engineInfo = Invoke-Engine -Arguments @('version', '--format', '{{.Server.Version}}') -AllowFailure +if ($engineInfo.ExitCode -eq 0) { + Write-Step "engine server version: $($engineInfo.StdOut.Trim())" +} else { + Write-Host " [FAIL] $Engine is installed but its daemon is not reachable" -ForegroundColor Red + Write-Detail $engineInfo.StdErr + exit 1 +} + +# The tool under test is the one in this worktree, not whatever is installed. +Write-Step 'building cargo-anvil from this worktree' +Invoke-Native -Command 'cargo' -Arguments @('build', '-q', '-p', 'cargo-anvil') -WorkingDirectory $repoRoot | Out-Null +$anvilExe = Join-Path $repoRoot 'target/debug/cargo-anvil.exe' +if (-not (Test-Path -LiteralPath $anvilExe)) { $anvilExe = Join-Path $repoRoot 'target/debug/cargo-anvil' } +Assert-That 'cargo-anvil built' (Test-Path -LiteralPath $anvilExe) + +# ---------------------------------------------------------------- the repo --- + +Write-Section 'Fixture repository' + +# A stable directory name keeps the image name stable across runs, which is what +# makes the pre-run cleanup below able to find leftovers. +$fixtureName = 'anvil-e2e' +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) 'anvil-container-e2e' +$repo = Join-Path $workRoot $fixtureName +$imagePrefix = "anvil-$fixtureName" + +if (-not $SkipCleanup) { + Write-Step 'pre-run cleanup' + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-AnvilImages -Prefix $imagePrefix +} + +New-Item -ItemType Directory -Path $repo -Force | Out-Null +Write-Step "fixture: $repo" + +# Everything below is what a user would author by hand in a new repository. +Write-Fixture (Join-Path $repo 'Cargo.toml') @' +[package] +name = "anvil-e2e" +version = "0.1.0" +edition = "2021" + +[dependencies] +'@ +New-Item -ItemType Directory -Path (Join-Path $repo 'src') -Force | Out-Null +Write-Fixture (Join-Path $repo 'src/lib.rs') @' +//! A fixture crate for the container end-to-end test. + +/// Adds two numbers. +#[must_use] +pub const fn add(left: u64, right: u64) -> u64 { + left + right +} +'@ +Write-Fixture (Join-Path $repo 'rust-toolchain.toml') @' +[toolchain] +channel = "1.95" +'@ +Write-Fixture (Join-Path $repo 'Justfile') @' +set unstable + +# A repository-owned recipe, to prove that forwarded values arrive. +e2e-show-env: + @echo "E2E:$ANVIL_E2E_RUNTIME" + +# Proves the driver forwards a host token, and invents one when it should not. +# `:-` because just runs recipe lines under `sh -u`, where a bare $NAME that +# was correctly *not* forwarded would abort instead of printing empty. +e2e-show-token: + @echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]" + +# The negative case for the same rule. A derived token is minted only when the +# target's plan reads GITHUB_TOKEN, so this recipe must observe the environment +# *without naming the variable* -- naming it is what would opt it in. Dumping +# every name lets the assertion look for the value without the plan mentioning +# it. +e2e-dump-env: + @env | sed 's/=.*//' | sort | tr '\n' ' ' + +# Proves git resolves inside the container, which a linked worktree breaks +# unless the driver mounts the common git directory. +e2e-show-git: + @echo "E2E-GIT:[$(git rev-parse --abbrev-ref HEAD)]" +'@ + +Invoke-Native -Command 'git' -Arguments @('init', '-q') -WorkingDirectory $repo | Out-Null +# Pin the newline policy: the fixture writes LF, and a developer with +# core.autocrlf=true globally would otherwise fail to stage it. +Invoke-Native -Command 'git' -Arguments @('config', 'core.autocrlf', 'false') -WorkingDirectory $repo | Out-Null + +Write-Step 'generating the anvil tree (cargo anvil --no-backends)' +$generate = Invoke-Native -Command $anvilExe -Arguments @('anvil', '--no-backends') -WorkingDirectory $repo +Write-Detail (($generate.StdOut -split "`r?`n" | Select-Object -Last 3) -join "`n") + +# ------------------------------------------------------- 1. what was emitted -- + +Write-Section '1. Generated artifacts' + +$dockerfile = Join-Path $repo '.anvil/container/Dockerfile' +$dockerignore = Join-Path $repo '.anvil/container/Dockerfile.dockerignore' +$containerJust = Join-Path $repo 'justfiles/anvil/container.just' +$hooks = Join-Path $repo '.anvil/container/hooks.ps1' + +Assert-That 'Dockerfile emitted' (Test-Path -LiteralPath $dockerfile) +Assert-That 'Dockerfile.dockerignore emitted' (Test-Path -LiteralPath $dockerignore) +Assert-That 'container.just emitted' (Test-Path -LiteralPath $containerJust) +Assert-That 'no hook emitted by default' (-not (Test-Path -LiteralPath $hooks)) +Assert-That 'no config file emitted' (-not (Test-Path -LiteralPath (Join-Path $repo 'anvil.toml'))) +Assert-That 'no runner seam emitted' (-not (Test-Path -LiteralPath (Join-Path $repo 'justfiles/anvil/runner.just'))) + +$containerDir = Get-ChildItem -LiteralPath (Join-Path $repo '.anvil/container') -File +Assert-Equal 'container directory holds exactly two files' 2 $containerDir.Count + +$justList = Invoke-Just -Repo $repo -Arguments @('--list') +Assert-That 'anvil-container is discoverable in just --list' ($justList.StdOut -match 'anvil-container') + +# ------------------------------------------------------------- 2. first run -- + +Write-Section '2. First run builds the image' + +$reference = Get-ImageReference -Repo $repo +Assert-That 'status reports an image reference' ($reference -like "$imagePrefix*") "got: '$reference'" +Assert-That 'image is absent before the first run' (-not (Test-ImagePresent $reference)) + +Write-Step "building and running (this takes several minutes on a cold cache)" +$firstRun = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'anvil-fmt') +Write-Detail (($firstRun.StdErr -split "`r?`n" | Select-Object -Last 4) -join "`n") + +Assert-Equal 'anvil-fmt succeeds inside the container' 0 $firstRun.ExitCode +Assert-That 'the run reported building the image' ($firstRun.StdErr -match 'building .*(inputs changed|first run)') +Assert-That 'image is present afterwards' (Test-ImagePresent $reference) + +# ------------------------------------------------------------ 3. second run -- + +Write-Section '3. Second run reuses the image' + +$secondRun = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'anvil-fmt') +Assert-Equal 'anvil-fmt succeeds again' 0 $secondRun.ExitCode +Assert-That 'nothing was rebuilt' (-not ($secondRun.StdErr -match 'building ')) $secondRun.StdErr +Assert-Equal 'the reference is unchanged' $reference (Get-ImageReference -Repo $repo) + +# The argv is executed verbatim, so a program that is not `just` is reachable +# too. This is the branch a recipe name would never exercise. +$bareCommand = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'cargo', '--version') -AllowFailure +Assert-Equal 'a non-just command runs in the image' 0 $bareCommand.ExitCode +Assert-That 'the bare command produced its own output' ($bareCommand.StdOut -match 'cargo\s+\d') $bareCommand.StdOut + +$status = Invoke-Just -Repo $repo -Arguments @('anvil-container-status') +Assert-That 'status reports present and current' ($status.StdOut -match 'present and current') $status.StdOut +Assert-That 'status reports the selected engine' ($status.StdOut -match "engine:\s+.*$Engine") $status.StdOut + +# The image's tools must not be masked by a cache volume. An engine seeds a +# named volume from the image only when the volume is first created, so a +# volume over $CARGO_HOME or $RUSTUP_HOME would pin the first image's binaries +# over every later tag -- a bumped tool would change the tag, build a new +# image, and still run the old binary. +$volumes = (Invoke-Engine -Arguments @('volume', 'ls', '--format', '{{.Name}}')).StdOut +$fixtureVolumes = @($volumes -split "`r?`n" | Where-Object { $_ -like "$imagePrefix*" }) +Assert-That 'a registry cache volume exists' ` + (@($fixtureVolumes | Where-Object { $_ -like '*-cargo-registry' }).Count -eq 1) ($fixtureVolumes -join ', ') +Assert-That 'no volume masks CARGO_HOME or RUSTUP_HOME' ` + (@($fixtureVolumes | Where-Object { $_ -like '*-cargo' -or $_ -like '*-rustup' }).Count -eq 0) ($fixtureVolumes -join ', ') + +# The positive half: a binary the image installed is still visible at run time +# with the caches mounted, so the tools a run uses are the ones the tag names. +# Argument vector deliberately free of spaces -- Start-Process joins +# -ArgumentList without quoting, so `bash -c '...'` would be re-split. +$probe = Invoke-Engine -AllowFailure -Arguments @( + 'run', '--rm', '--platform', 'linux/amd64', + '-v', "$imagePrefix-cargo-registry:/usr/local/cargo/registry", + '-v', "$imagePrefix-cargo-git:/usr/local/cargo/git", + $reference, 'ls', '/usr/local/cargo/bin/cargo-binstall' +) +Assert-Equal 'a tool installed by the image survives the cache mounts' 0 $probe.ExitCode +Assert-That 'the tool resolves inside the image, not a volume' ` + ($probe.StdOut -match '/usr/local/cargo/bin/cargo-binstall') "$($probe.StdOut)$($probe.StdErr)" + +# anvil-aprz runs in scheduled-advisories and blocks on the rate limit without a token, so a +# host token has to reach the container. The driver resolves it the way the +# recipe does natively: the environment first, then the gh CLI. +# +# Failure details are redacted: on a developer machine the value below is a real +# credential, and a test that prints it to the terminal on failure is a leak. +function Hide-Token([string]$Text) { $Text -replace 'E2E-TOKEN:\[[^\]]+\]', 'E2E-TOKEN:[]' } + +$withToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` + -Environment @{ GITHUB_TOKEN = 'e2e-forwarded-token' } +Assert-That 'a host GITHUB_TOKEN reaches a recipe in the container' ` + ($withToken.StdOut -match 'E2E-TOKEN:\[e2e-forwarded-token\]') (Hide-Token "$($withToken.StdOut)$($withToken.StdErr)") + +# No environment token and no gh CLI: nothing is forwarded. gh is hidden by +# dropping its directory from PATH, which is what the driver actually probes -- +# `GH_CONFIG_DIR` does not work here, because modern gh keeps credentials in the +# OS keyring rather than in its config directory. +$pathWithoutGh = $env:PATH +$ghCommand = Get-Command gh -ErrorAction SilentlyContinue +if ($ghCommand) { + $ghDir = (Split-Path $ghCommand.Source).TrimEnd('\', '/') + $separator = if ($IsWindows) { ';' } else { ':' } + $pathWithoutGh = (($env:PATH -split $separator) | + Where-Object { $_ -and $_.TrimEnd('\', '/') -ne $ghDir }) -join $separator +} +$withoutToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` + -Environment @{ GITHUB_TOKEN = ''; GH_TOKEN = ''; PATH = $pathWithoutGh } +Assert-That 'no token is invented when the host has none' ` + ($withoutToken.StdOut -match 'E2E-TOKEN:\[\]') (Hide-Token "$($withoutToken.StdOut)$($withoutToken.StdErr)") + +# The gh fallback itself, which is what keeps a containerized tier from blocking +# for a developer who signed in with `gh auth login` and never exported a token. +# Skipped rather than failed when the host is not signed in, since that is a +# property of the machine running the suite. +$hostGhToken = $null +if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $hostGhToken = (gh auth token --hostname github.com 2>$null) } catch { $hostGhToken = $null } +} +if ($hostGhToken -and $hostGhToken.Trim()) { + $viaGh = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` + -Environment @{ GITHUB_TOKEN = '' } + Assert-That 'the gh CLI token is used when the environment has none' ` + ($viaGh.StdOut -match ('E2E-TOKEN:\[' + [regex]::Escape($hostGhToken.Trim()) + '\]')) ` + (Hide-Token "$($viaGh.StdOut)$($viaGh.StdErr)") + + # The other half of the rule. Minting a credential the developer never put + # in this environment hands it to every build script and proc macro in the + # container, where natively the recipe would mint it in its own process -- + # so a target that never reads the variable must not receive it. + $noNeed = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-dump-env') ` + -Environment @{ GITHUB_TOKEN = '' } + Assert-That 'no token is derived for a target that does not read it' ` + ($noNeed.StdOut -notmatch 'GITHUB_TOKEN') ` + (Hide-Token "$($noNeed.StdOut)$($noNeed.StdErr)") +} else { + Write-Step 'skipping the gh-fallback check: this host has no gh credential' +} + +# --------------------------------------------------------- 3b. worktrees ----- + +Write-Section '3b. A linked worktree resolves its git directory' + +# A linked worktree's `.git` is a file naming an absolute host path outside the +# checkout. Bind-mounting only the worktree leaves that path unreachable, so git +# inside the container resolves nothing -- not HEAD, not origin/* -- and every +# check that needs history fails. This is not exotic: worktrees are the ordinary +# way to work on two branches at once. +# +# The worktree is given the same directory name as the fixture so the image name +# matches, and it checks out the same committed content, so the tag is identical +# and no rebuild is needed. +Invoke-Native -Command 'git' -Arguments @('add', '-A') -WorkingDirectory $repo | Out-Null +Invoke-Native -Command 'git' -Arguments @('-c', 'user.email=e2e@example.invalid', '-c', 'user.name=e2e', + 'commit', '-q', '-m', 'fixture') -WorkingDirectory $repo | Out-Null + +$worktreeParent = Join-Path $workRoot 'wt' +$worktree = Join-Path $worktreeParent $fixtureName +Invoke-Native -Command 'git' -Arguments @('worktree', 'add', '-q', '-b', 'e2e-worktree', $worktree) ` + -WorkingDirectory $repo -AllowFailure | Out-Null +Assert-That 'the fixture worktree was created' (Test-Path -LiteralPath $worktree) $worktree +Assert-That 'its .git is a file, not a directory' ` + (Test-Path -LiteralPath (Join-Path $worktree '.git') -PathType Leaf) 'a linked worktree stores a gitdir pointer' + +$wtReference = Get-ImageReference -Repo $worktree +Assert-Equal 'the worktree selects the same image, so nothing rebuilds' $reference $wtReference + +$wtGit = Invoke-Just -Repo $worktree -Arguments @('anvil-container', 'just', 'e2e-show-git') -AllowFailure +Assert-Equal 'a recipe run from a worktree succeeds' 0 $wtGit.ExitCode +Assert-That 'git resolves the branch inside the container' ` + ($wtGit.StdOut -match 'E2E-GIT:\[e2e-worktree\]') "$($wtGit.StdOut)$($wtGit.StdErr)" + +Invoke-Native -Command 'git' -Arguments @('worktree', 'remove', '--force', $worktree) ` + -WorkingDirectory $repo -AllowFailure | Out-Null + +# ------------------------------------------------------ 4/5. hashed inputs --- + +Write-Section '4. A changed input selects a new tag' + +$toolchainPath = Join-Path $repo 'rust-toolchain.toml' +$originalToolchain = Get-Content -LiteralPath $toolchainPath -Raw +Write-Fixture $toolchainPath @' +[toolchain] +channel = "1.94" +'@ + +$bumped = Get-ImageReference -Repo $repo +Assert-That 'the reference changed with the toolchain' ($bumped -ne $reference) "before: $reference`nafter: $bumped" +Assert-That 'the new tag is not already present' (-not (Test-ImagePresent $bumped)) + +Write-Section '5. Reverting the input returns to the original tag' + +Write-Fixture $toolchainPath $originalToolchain +$reverted = Get-ImageReference -Repo $repo +Assert-Equal 'the original reference is restored' $reference $reverted +Assert-That 'the original image is still present' (Test-ImagePresent $reverted) + +# ---------------------------------------------- 6. composing the Dockerfile -- + +Write-Section '6. A repository composes the Dockerfile around anvil''s regions' + +$dockerfileBody = Get-Content -LiteralPath $dockerfile -Raw + +# The gap between the base and tool regions: where a root CA or a proxy goes, +# and the reason the file is composed rather than owned outright. +$gapMarker = "# <<< anvil-managed: anvil-container-base`n" +Assert-That 'the composed Dockerfile carries the base region' ($dockerfileBody.Contains($gapMarker)) +$composed = $dockerfileBody.Replace($gapMarker, $gapMarker + "`n# a repository-owned edit`nENV ANVIL_E2E_GAP=1`n") +Write-Fixture $dockerfile $composed +$editedReference = Get-ImageReference -Repo $repo +Assert-That 'adding to a gap selects a new tag' ($editedReference -ne $reference) + +Write-Step 're-running the generator over the composed file' +$regen = Invoke-Native -Command $anvilExe -Arguments @('anvil', '--no-backends') -WorkingDirectory $repo -AllowFailure +Assert-Equal 'the generator succeeds over a composed file' 0 $regen.ExitCode +$afterRegen = Get-Content -LiteralPath $dockerfile -Raw +Assert-That 'content in a gap survives regeneration' ($afterRegen -match 'a repository-owned edit') ` + 'anvil must preserve everything outside its own sentinels' +Assert-Equal 'the composed tag is unchanged by regeneration' $editedReference (Get-ImageReference -Repo $repo) + +# Anvil never overwrites repository content, and a region body is no exception: +# an edit inside one is preserved, exactly as `updates.md` §2 preserves an +# edited owned file. That is why the gaps matter -- editing inside a region +# silently freezes the base digest and the tool pins at today's values while +# the tag keeps resolving, so the layout has to make the gaps the obvious place +# to add things rather than relying on the engine to police it. +Write-Step 'editing inside a region, which anvil preserves rather than overwrites' +$frozen = $afterRegen.Replace('ARG JUST_VERSION=', 'ARG JUST_VERSION=0.0.0 # ') +Assert-That 'the edit landed inside the region' ($frozen -ne $afterRegen) +Write-Fixture $dockerfile $frozen +$regen = Invoke-Native -Command $anvilExe -Arguments @('anvil', '--no-backends') -WorkingDirectory $repo -AllowFailure +Assert-Equal 'the generator succeeds over an edited region' 0 $regen.ExitCode +$reclaimed = Get-Content -LiteralPath $dockerfile -Raw +Assert-That 'an edit inside a region is preserved, not overwritten' ($reclaimed -match 'JUST_VERSION=0\.0\.0') ` + 'anvil must never destroy repository content, in a region or a file' +Assert-That 'the surrounding gap content is untouched' ($reclaimed -match 'a repository-owned edit') +Assert-That 'the sentinels survive the edit' ($reclaimed -match '# <<< anvil-managed: anvil-container-base') + +Write-Fixture $dockerfile $dockerfileBody +Assert-Equal 'restoring the Dockerfile restores the tag' $reference (Get-ImageReference -Repo $repo) + +# ----------------------------------------------------------------- 7. hook --- + +Write-Section '7. The credential hook reaches build and run' + +# podman on Windows cannot mount a build secret at all: it composes its own temp +# path from the already-translated build context and joins it with a Windows +# separator. That is an engine defect with no client-side workaround, documented +# in docs/design/containers.md. Reporting it as a failure every run would train +# the reader to ignore red, so it is called out and skipped. +$buildSecretsSupported = -not ($Engine -eq 'podman' -and $IsWindows) +if (-not $buildSecretsSupported) { + Write-Step 'skipping the hook sections: podman on Windows cannot mount build secrets' + Write-Step 'everything above is engine-agnostic and has already run' +} else { +# A user writes this file by hand; the public catalog does not emit one. +Write-Fixture $hooks @' +function Anvil-BuildSecrets { + @{ Secrets = @{ e2e_token = 'build-secret-value' } } +} + +function Anvil-RunEnv { + @{ Env = @{ ANVIL_E2E_RUNTIME = 'run-value' } } +} +'@ + +$hookReference = Get-ImageReference -Repo $repo +Assert-That 'adding a hook selects a new tag' ($hookReference -ne $reference) ` + "the hook file's content must be part of the image identity" + +# The default Dockerfile does not consume the secret, so prove the wiring by +# having the image read it. This is a fixture-side Dockerfile edit, which is a +# supported user action (proved in section 6). +# +# The build *writes* the secret and deletes it in the same layer, which is what +# the real Dockerfile does with the credential files an install leaves behind. +# Reading it alone would make the filesystem assertion below unfalsifiable: +# nothing would have written the string, so `grep` would find nothing whatever +# the layering did. A control build immediately below proves the probe can in +# fact see a leak. +$secretStanza = @' + +# --- e2e: prove the build secret arrives and never lands in a layer --- +RUN --mount=type=secret,id=e2e_token,required=true \ + test -s /run/secrets/e2e_token \ + && echo "e2e: secret length $(wc -c < /run/secrets/e2e_token)" \ + && cp /run/secrets/e2e_token /usr/local/cargo/credentials.toml \ + && rm -f /usr/local/cargo/credentials.toml +'@ + +# The same stanza without the deletion. Built first, so a probe that cannot +# detect a leak fails here rather than passing silently on the real image. +$leakControlStanza = $secretStanza -replace '(?m)\s*&& rm -f /usr/local/cargo/credentials\.toml$', '' +Write-Fixture $dockerfile ($dockerfileBody + $leakControlStanza) +Write-Step 'building a deliberately leaking image to prove the probe works' +$controlRun = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'anvil-fmt') -AllowFailure +Assert-Equal 'the control image builds' 0 $controlRun.ExitCode +$controlLeak = Invoke-Engine -Arguments @( + 'run', '--rm', '--pull=never', (Get-ImageReference -Repo $repo), + 'grep', '-rsq', 'build-secret-value', '/opt/anvil', '/root', '/usr/local/cargo', '/tmp', '/run' +) -AllowFailure +Assert-Equal 'the probe detects a secret left in the filesystem' 0 $controlLeak.ExitCode + +Write-Fixture $dockerfile ($dockerfileBody + $secretStanza) + +Write-Step 'rebuilding with the hook active' +$hookRun = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'anvil-fmt') -AllowFailure +Assert-Equal 'the run with a hook succeeds' 0 $hookRun.ExitCode +Assert-That 'the hook announced itself at build time' ($hookRun.StdErr -match 'Anvil-BuildSecrets') +Assert-That 'the build secret was declared' ($hookRun.StdErr -match 'build secrets: e2e_token') +Assert-That 'the hook announced itself at run time' ($hookRun.StdErr -match 'Anvil-RunEnv') +Assert-That 'forwarded names are reported' ($hookRun.StdErr -match 'forwarding env: ANVIL_E2E_RUNTIME') + +$secretReference = Get-ImageReference -Repo $repo +$layers = Invoke-Engine -Arguments @('history', '--no-trunc', $secretReference) -AllowFailure +Assert-Equal 'the image history is readable' 0 $layers.ExitCode +Assert-That 'no build command records the secret' ` + (-not ($layers.StdOut -match 'build-secret-value')) 'a secret must never reach a build argument' + +# `history` reports the command that created each layer, not its contents, so on +# its own it cannot see a secret that was *written* into the filesystem -- which +# is the hazard the Dockerfile guards against by deleting credential files in +# the same layer as the install. Look at the filesystem the image actually +# carries. grep exits 1 for "no match", which is the result we want; -s keeps an +# unreadable path from turning into exit 2 and passing for the wrong reason. +$leak = Invoke-Engine -Arguments @( + 'run', '--rm', '--pull=never', $secretReference, + 'grep', '-rsq', 'build-secret-value', '/opt/anvil', '/root', '/usr/local/cargo', '/tmp', '/run' +) -AllowFailure +Assert-Equal 'the secret is absent from the image filesystem' 1 $leak.ExitCode + +Write-Step 'checking that the forwarded value arrives inside the container' +$showEnv = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-env') -AllowFailure +Assert-That 'the run-time value reaches a recipe in the container' ` + ($showEnv.StdOut -match 'E2E:run-value') "stdout: $($showEnv.StdOut)`nstderr: $($showEnv.StdErr)" + +# ------------------------------------------------------ 8. hook fails closed -- + +Write-Section '8. An empty hook value fails closed' + +Write-Fixture $hooks @' +function Anvil-BuildSecrets { + @{ Secrets = @{ e2e_token = '' } } +} +'@ + +$emptyHook = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'anvil-fmt') -AllowFailure +Assert-That 'an empty secret aborts the run' ($emptyHook.ExitCode -ne 0) ` + 'BuildKit would mount an empty secret and exit 0, tagging a degraded image with a valid hash' +Assert-That 'the failure names the offending secret' ($emptyHook.StdErr -match "empty value for secret 'e2e_token'") ` + $emptyHook.StdErr + +# ------------------------------------------- 9. hook output is not the tag --- + +Write-Section '9. Hook output does not change the tag' + +Write-Fixture $hooks @' +function Anvil-BuildSecrets { + @{ Secrets = @{ e2e_token = 'build-secret-value' } } +} + +function Anvil-RunEnv { + @{ Env = @{ ANVIL_E2E_RUNTIME = 'run-value' } } +} +'@ +Assert-Equal 'restoring the hook restores the tag' $secretReference (Get-ImageReference -Repo $repo) + +# The invariant that matters: a *minted* credential must never influence the +# tag, or two developers holding different tokens would compute different +# images from identical inputs -- and a rotated token would force a rebuild. +# Proving it needs the hook file to be byte-identical while what it returns +# differs, so the value is read from the environment rather than written into +# the file. Changing the file instead would only re-prove that file content is +# hashed, which section 7 already covers. +Write-Fixture $hooks @' +function Anvil-BuildSecrets { + @{ Secrets = @{ e2e_token = $env:ANVIL_E2E_MINT } } +} + +function Anvil-RunEnv { + @{ Env = @{ ANVIL_E2E_RUNTIME = 'run-value' } } +} +'@ +$mintedA = Get-ImageReference -Repo $repo -Environment @{ ANVIL_E2E_MINT = 'first-minted-value' } +$mintedB = Get-ImageReference -Repo $repo -Environment @{ ANVIL_E2E_MINT = 'a-completely-different-second-value' } +Assert-That 'the tag is stable across two different minted values' ` + ($mintedA -and $mintedA -eq $mintedB) "first: $mintedA`nsecond: $mintedB" + +# ...while the file that produces those values is itself hashed, so a changed +# hook still renames the image. +$hookBodyChanged = $mintedA -ne $secretReference +Assert-That 'a changed hook body still changes the tag' $hookBodyChanged ` + "the hook file is a hashed input; before: $secretReference, after: $mintedA" + +} # end of the build-secret sections (7-9) + +# ------------------------------------------------------ 10. no nested runs --- + +Write-Section '10. Recipes run natively inside the image' + +# Sections 7-9 build the image that carries the secret stanza; without them the +# current reference is the plain one. +$nestedReference = if ($buildSecretsSupported) { $secretReference } else { Get-ImageReference -Repo $repo } +$nested = Invoke-Engine -Arguments @( + 'run', '--rm', '-e', 'ANVIL_IN_CONTAINER=1', '-v', "$(ConvertTo-EnginePath $repo):/workspace", '-w', '/workspace', + $nestedReference, 'just', 'anvil-container', 'just', 'anvil-fmt' +) -AllowFailure +Assert-Equal 'anvil-container passes through inside the image' 0 $nested.ExitCode +Assert-That 'no engine was invoked from inside the container' ` + (-not ($nested.StdErr -match 'building |Cannot connect to the Docker daemon')) $nested.StdErr + +# --------------------------------------------------------------- teardown ---- + +Write-Section 'Teardown' + +if ($KeepArtifacts) { + Write-Step "keeping $repo and images matching $imagePrefix*" +} else { + Write-Step 'removing cache volumes via anvil-container-down' + Invoke-Just -Repo $repo -Arguments @('anvil-container-down') -AllowFailure | Out-Null + Remove-AnvilImages -Prefix $imagePrefix + Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue + Write-Step 'removed the fixture repository' +} + +$elapsed = (Get-Date) - $script:Started +Write-Host '' +Write-Host ('-' * 78) +$summary = "{0}/{1} checks passed in {2:mm\:ss}" -f $script:Passed, ($script:Passed + $script:Failed), $elapsed +if ($script:Failed -eq 0) { + Write-Host "PASS $summary" -ForegroundColor Green + exit 0 +} +Write-Host "FAIL $summary ($($script:Failed) failed)" -ForegroundColor Red +exit 1 diff --git a/scripts/test-anvil-dogfood.ps1 b/scripts/test-anvil-dogfood.ps1 new file mode 100644 index 00000000..b677af32 --- /dev/null +++ b/scripts/test-anvil-dogfood.ps1 @@ -0,0 +1,520 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Dogfood test: run this repository's own anvil checks inside the container. + +.DESCRIPTION + `test-anvil-container.ps1` covers the container mechanism against a + throwaway fixture: artifacts, tags, drift, hooks. This script covers the + other half -- that the mechanism works on a real workspace. It runs the + generated recipes against `ox-tools` itself: a multi-crate workspace with + real dependencies, real lints, and the full pinned tool catalog. + + It runs against both engines by default. On Windows that also covers both + invocation paths, since docker is reached through the default WSL + distribution and podman runs natively. + + Steps are ordered by cost, so a break is reported in seconds rather than + after a full tier: + + 1. Preconditions -- the generated tree is current, and the container + artifacts are present. + 2. The image builds from the repository's own Dockerfile. + 3. Every pinned tool in the catalog executes inside the image. + 4. `anvil-aprz` runs, exercising a prebuilt binary and the advisory API. + 5. Every recipe file defines the image: editing a check, a tier, the + driver, `tools.just` or `versions.just` must all rename it, and + dropping a check's `-setup` dependency from a group -- which changes + the installed tool set while leaving `tools.just` untouched -- must + rename it too. + 6. The requested tier runs to completion inside the image. + 7. A second run reuses the image rather than rebuilding it. + + Step 3 is cheap and broad: `anvil-setup` proves a tool downloaded, while + executing it proves the image can run it. Twenty tools cost seconds here + and would otherwise surface one at a time as tiers reach them. + +.PARAMETER Engine + Which engine(s) to test. 'both' (default) runs the suite against docker and + then podman, reporting them separately. + +.PARAMETER Tier + Recipe(s) to run for step 5. Defaults to `anvil-pr`, the full PR tier, + which includes mutants and runtime analysis and is measured in tens of + minutes. `anvil-pr-fast` covers the same plumbing more quickly. + +.PARAMETER SkipTier + Stop after step 4. The cheap steps cover the container contract. + +.PARAMETER KeepImages + Leave built images and cache volumes in place. + +.PARAMETER Clean + Remove this repository's anvil images and cache volumes before starting, + forcing a cold build. Use when testing the image definition itself. + +.EXAMPLE + ./scripts/test-anvil-dogfood.ps1 + ./scripts/test-anvil-dogfood.ps1 -Engine docker -Tier anvil-pr-fast + ./scripts/test-anvil-dogfood.ps1 -SkipTier -Clean +#> + +[CmdletBinding()] +param( + [ValidateSet('docker', 'podman', 'both')] + [string]$Engine = 'both', + [string[]]$Tier = @('anvil-pr'), + [switch]$SkipTier, + [switch]$KeepImages, + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# ---------------------------------------------------------------- reporting -- + +$script:Passed = 0 +$script:Failed = 0 +$script:Skipped = 0 +$script:Started = Get-Date +$script:Results = [ordered]@{} +$script:CurrentEngine = '' + +function Write-Section([string]$Title) { + Write-Host '' + Write-Host "=== $Title " -NoNewline -ForegroundColor Cyan + Write-Host ('=' * [Math]::Max(0, 72 - $Title.Length)) -ForegroundColor Cyan +} + +function Write-Step([string]$Message) { + Write-Host " -> $Message" -ForegroundColor DarkGray +} + +function Write-Detail([string]$Message) { + if (-not $Message) { return } + foreach ($line in ($Message -split "`r?`n")) { + if ($line.Trim()) { Write-Host " | $line" -ForegroundColor DarkGray } + } +} + +function Write-Tail([string]$Message, [int]$Lines = 25) { + if (-not $Message) { return } + $all = @($Message -split "`r?`n" | Where-Object { $_.Trim() }) + Write-Detail (($all | Select-Object -Last $Lines) -join "`n") +} + +function Assert-That([string]$Name, [bool]$Condition, [string]$Detail = '') { + if ($Condition) { + $script:Passed++ + Write-Host " [PASS] $Name" -ForegroundColor Green + } else { + $script:Failed++ + Write-Host " [FAIL] $Name" -ForegroundColor Red + if ($Detail) { Write-Detail $Detail } + } +} + +function Assert-Equal([string]$Name, $Expected, $Actual) { + Assert-That $Name ($Expected -eq $Actual) "expected: $Expected`nactual: $Actual" +} + +function Write-Skipped([string]$Name, [string]$Why) { + $script:Skipped++ + Write-Host " [SKIP] $Name" -ForegroundColor Yellow + Write-Detail $Why +} + +# ------------------------------------------------------------------ helpers -- + +function Invoke-Native { + param( + [Parameter(Mandatory)][string]$Command, + [string[]]$Arguments = @(), + [string]$WorkingDirectory, + [hashtable]$Environment = @{}, + [switch]$AllowFailure + ) + + $previous = @{} + foreach ($key in $Environment.Keys) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key) + Set-Item -LiteralPath "Env:$key" -Value $Environment[$key] + } + $entered = $false + try { + if ($WorkingDirectory) { Push-Location $WorkingDirectory; $entered = $true } + $stdoutFile = [System.IO.Path]::GetTempFileName() + $stderrFile = [System.IO.Path]::GetTempFileName() + try { + $process = Start-Process -FilePath $Command -ArgumentList $Arguments -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + $result = [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = (Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction SilentlyContinue) ?? '' + StdErr = (Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue) ?? '' + } + } finally { + Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue + } + } finally { + if ($entered) { Pop-Location } + foreach ($key in $Environment.Keys) { + if ($null -eq $previous[$key]) { + Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue + } else { + Set-Item -LiteralPath "Env:$key" -Value $previous[$key] + } + } + } + + if (-not $AllowFailure -and $result.ExitCode -ne 0) { + Write-Detail $result.StdOut + Write-Detail $result.StdErr + throw "$Command $($Arguments -join ' ') failed with exit code $($result.ExitCode)" + } + $result +} + +function Resolve-Engine([string]$Name) { + # Mirrors what container.just does: prefer the engine on PATH, and fall + # back to the default WSL distribution on Windows. The script must not + # assume more than the product does. + if (Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) { + return [pscustomobject]@{ Exe = $Name; Prefix = @(); ViaWsl = $false } + } + if ($IsWindows -and (Get-Command wsl.exe -CommandType Application -ErrorAction SilentlyContinue)) { + & wsl.exe --exec $Name --version *> $null + if ($LASTEXITCODE -eq 0) { + return [pscustomobject]@{ Exe = 'wsl.exe'; Prefix = @('--exec', $Name); ViaWsl = $true } + } + } + $null +} + +function Invoke-Engine { + param([string[]]$Arguments, [switch]$AllowFailure) + Invoke-Native -Command $script:EngineExe -Arguments ($script:EnginePrefix + $Arguments) -AllowFailure:$AllowFailure +} + +function Invoke-Just { + param( + [Parameter(Mandatory)][string[]]$Arguments, + [hashtable]$Environment = @{}, + [switch]$AllowFailure + ) + $env = @{ ANVIL_CONTAINER_ENGINE = $script:CurrentEngine } + $Environment + Invoke-Native -Command 'just' -Arguments $Arguments -WorkingDirectory $RepoRoot -Environment $env -AllowFailure:$AllowFailure +} + +function Get-ImageReference { + # anvil-container-status reports the reference without building it. + $status = Invoke-Just -Arguments @('anvil-container-status') -AllowFailure + $line = ($status.StdOut -split "`r?`n") | Where-Object { $_ -match '^\s*image:\s*(\S+)' } | Select-Object -First 1 + if ($line -match '^\s*image:\s*(\S+)') { return $Matches[1] } + '' +} + +function Test-ImagePresent([string]$Reference) { + if (-not $Reference) { return $false } + (Invoke-Engine -Arguments @('image', 'inspect', $Reference) -AllowFailure).ExitCode -eq 0 +} + +function Remove-AnvilImages([string]$Prefix) { + $images = Invoke-Engine -Arguments @('images', '--format', '{{.Repository}}:{{.Tag}}') -AllowFailure + # Podman reports images fully qualified (`localhost/anvil-...`), docker does + # not, so match anywhere in the reference rather than at the start. + foreach ($image in (($images.StdOut -split "`r?`n") | Where-Object { $_ -like "*$Prefix*" })) { + Write-Step "removing image $image" + Invoke-Engine -Arguments @('rmi', '-f', $image) -AllowFailure | Out-Null + } + $volumes = Invoke-Engine -Arguments @('volume', 'ls', '--format', '{{.Name}}') -AllowFailure + foreach ($volume in (($volumes.StdOut -split "`r?`n") | Where-Object { $_ -like "*$Prefix*" })) { + Write-Step "removing volume $volume" + Invoke-Engine -Arguments @('volume', 'rm', '-f', $volume) -AllowFailure | Out-Null + } +} + +# The pinned catalog, read from the generated recipes rather than restated +# here. A tool added to the catalog is covered without editing this script -- +# the same reason the image installs by running `anvil-setup` instead of +# carrying its own list. +function Get-PinnedTools { + $versions = Join-Path $RepoRoot 'justfiles/anvil/versions.just' + $tools = [ordered]@{} + foreach ($line in (Get-Content -LiteralPath $versions)) { + if ($line -match '^\s*([a-z0-9_]+)_version\s*:=\s*"([^"]+)"') { + $name = $Matches[1] -replace '_', '-' + if ($name -like 'cargo-*') { $tools[$name] = $Matches[2] } + } + } + $tools +} + +# ---------------------------------------------------------------- the suite -- + +function Invoke-Suite([string]$EngineName) { + $script:CurrentEngine = $EngineName + $before = $script:Failed + + Write-Section "$EngineName : preconditions" + + $resolved = Resolve-Engine $EngineName + if (-not $resolved) { + # An engine the caller named explicitly is a precondition, not an + # option: skipping it and exiting 0 reports "PASS 0/0 checks passed" for + # a daemon-backed verification that never reached a daemon. Only the + # default `both` sweep may skip one, and even then the run must fail if + # neither engine turned up (checked after the loop). + if ($Engine -ne 'both') { + Assert-That "$EngineName is available" $false ` + 'not on PATH, and not reachable in the default WSL distribution' + $script:Results[$EngineName] = 'failed' + return + } + Write-Skipped "$EngineName is available" "not on PATH, and not reachable in the default WSL distribution" + $script:Results[$EngineName] = 'skipped' + return + } + $script:EngineExe = $resolved.Exe + $script:EnginePrefix = $resolved.Prefix + $script:EngineViaWsl = $resolved.ViaWsl + Write-Step ("engine: {0}{1}" -f $EngineName, $(if ($resolved.ViaWsl) { ' (via WSL)' } else { ' (native)' })) + + # The dogfood claim is only meaningful if the committed tree is what the + # generator produces. A stale tree would test something no user can obtain. + $dryRun = Invoke-Native -Command 'cargo' -Arguments @('run', '--quiet', '-p', 'cargo-anvil', '--', 'anvil', '--dry-run') ` + -WorkingDirectory $RepoRoot -AllowFailure + Assert-Equal 'the committed anvil tree is current (cargo anvil --dry-run)' 0 $dryRun.ExitCode + + foreach ($artifact in @('.anvil/container/Dockerfile', '.anvil/container/Dockerfile.dockerignore', 'justfiles/anvil/container.just')) { + Assert-That "$artifact is present" (Test-Path -LiteralPath (Join-Path $RepoRoot $artifact)) + } + + $imagePrefix = 'anvil-' + (Split-Path -Leaf $RepoRoot).ToLowerInvariant() + if ($Clean) { + Write-Step 'removing existing images and volumes for a cold build' + Remove-AnvilImages -Prefix $imagePrefix + } + + Write-Section "$EngineName : image" + + $reference = Get-ImageReference + Assert-That 'anvil-container-status reports an image reference' ([bool]$reference) 'no image: line in status output' + Write-Step "reference: $reference" + + $up = Invoke-Just -Arguments @('anvil-container', 'just', 'anvil-container-tag') -AllowFailure + Assert-Equal 'the first run builds the image if it is missing' 0 $up.ExitCode + if ($up.ExitCode -ne 0) { + Write-Tail $up.StdErr + # Everything below needs an image; stop this engine rather than + # reporting a cascade of failures that all have one cause. + $script:Results[$EngineName] = 'failed' + return + } + Assert-That 'the image is present after the first run' (Test-ImagePresent $reference) + + Write-Section "$EngineName : the catalog executes inside the image" + + # `anvil-setup` proves a tool downloaded; executing it proves the image can + # run it. The catalog is installed as prebuilt binaries, so a base older + # than the runner they were built on yields tools that are present and + # unrunnable. + # + # This runs the engine directly rather than through `anvil-container`, + # which dispatches `just ` and so cannot invoke a bare binary. The + # image reference still comes from the product (`anvil-container-status`), + # and the property under test belongs to the image rather than the recipe. + $tools = Get-PinnedTools + Write-Step "$($tools.Count) pinned tools" + $broken = @() + $missing = @() + $unexplained = @() + # Tools with no `--version` that exits 0. Empty: every pinned tool is a + # cargo subcommand or a standalone binary that reports its version. + $noVersionFlag = @() + foreach ($tool in $tools.Keys) { + $run = Invoke-Engine -Arguments @('run', '--rm', $reference, $tool, '--version') -AllowFailure + $combined = "$($run.StdOut)`n$($run.StdErr)" + # The dynamic loader reports an ABI mismatch before main() runs, so + # this is independent of whether a tool implements --version at all. + if ($combined -match 'GLIBC_[0-9.]+.{0,40}not found|error while loading shared libraries|cannot execute binary file') { + $line = (($combined -split "`r?`n") | Where-Object { $_ -match 'GLIBC|shared libraries|cannot execute' } | Select-Object -First 1).Trim() + $broken += "$tool -> $line" + } elseif ($combined -match 'executable file .*not found|no such file or directory') { + $missing += $tool + } elseif ($run.ExitCode -ne 0 -and $tool -notin $noVersionFlag) { + # The exit code is the only signal that covers every remaining + # failure shape: a crash, an illegal instruction, a permission + # error, or a loader diagnostic neither pattern above recognises. + # Without this the assertions below pass on all of them. + $first = (($combined -split "`r?`n") | Where-Object { $_.Trim() } | Select-Object -First 1) + $unexplained += "$tool -> exit $($run.ExitCode): $($first)".Trim() + } + } + Assert-That 'every pinned tool executes inside the image' ($broken.Count -eq 0) ($broken -join "`n") + Assert-That 'every pinned tool is present in the image' ($missing.Count -eq 0) ($missing -join ', ') + Assert-That 'every pinned tool reports its version successfully' ($unexplained.Count -eq 0) ($unexplained -join "`n") + + Write-Section "$EngineName : checks" + + # The check whose prebuilt binary exercises both the loader and the + # advisory API. Kept as its own step so a regression names itself. + $aprz = Invoke-Just -Arguments @('anvil-container', 'just', 'anvil-aprz') -AllowFailure + Assert-Equal 'anvil-aprz runs inside the image' 0 $aprz.ExitCode + if ($aprz.ExitCode -ne 0) { Write-Tail "$($aprz.StdOut)`n$($aprz.StdErr)" } + + # A check that reads the workspace rather than the network, so a failure + # points at the mount rather than at connectivity. + $fmt = Invoke-Just -Arguments @('anvil-container', 'just', 'anvil-fmt') -AllowFailure + Assert-Equal 'anvil-fmt runs inside the image' 0 $fmt.ExitCode + if ($fmt.ExitCode -ne 0) { Write-Tail "$($fmt.StdOut)`n$($fmt.StdErr)" } + + Write-Section "$EngineName : image reuse" + + $secondReference = Get-ImageReference + Assert-Equal 'the tag is stable across runs' $reference $secondReference + $reuse = Invoke-Just -Arguments @('anvil-container', 'just', 'anvil-fmt') -AllowFailure + Assert-Equal 'a later run succeeds' 0 $reuse.ExitCode + Assert-That 'a later run does not rebuild the image' ` + (-not ("$($reuse.StdOut)`n$($reuse.StdErr)" -match 'building |Step 1/|FROM ')) ` + 'a rebuild happened when the tag should have resolved' + + Write-Section "$EngineName : every recipe file defines the image" + + # `just anvil-setup` reaches the install recipes through the tier, group and + # check recipes, so the routing decides *whether* a tool is installed as + # surely as tools.just decides *how*. Hashing only the install definitions + # let a group drop an `anvil--setup` dependency -- changing the + # installed set -- while the tag stayed byte-identical, so the stale image + # was reused forever. The whole tree is hashed for that reason, and these + # cases are what keep it that way. + # + # Edits are made against a byte copy and restored from it. Never + # `git checkout --` on a generated file: that restores the last *commit*, + # not the generated state, and anvil then preserves the stale file as a + # user modification. If this script is killed mid-section, regenerate with + # `cargo run -p cargo-anvil -- anvil` to return the tree to a known state. + $baseline = Get-ImageReference + Assert-That 'a baseline tag is available' ([bool]$baseline) + + $cases = @( + @{ File = 'justfiles/anvil/checks/clippy.just'; Why = 'a check carries the setup dependency that installs its tool' } + @{ File = 'justfiles/anvil/container.just'; Why = 'the driver passes the build args, secrets and PreBuild output into the build' } + @{ File = 'justfiles/anvil/tiers.just'; Why = 'a tier decides which groups, and so which setups, are reached' } + @{ File = 'justfiles/anvil/versions.just'; Why = 'a pin decides which build is installed' } + @{ File = 'justfiles/anvil/tools.just'; Why = 'the install recipes decide what is installed' } + ) + + foreach ($case in $cases) { + $path = Join-Path $RepoRoot $case.File + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Write-Skipped "$($case.File) is present" 'not emitted by this catalog' + continue + } + $backup = [System.IO.Path]::GetTempFileName() + Copy-Item -LiteralPath $path -Destination $backup -Force + try { + Add-Content -LiteralPath $path -Value "`n# dogfood scratch" + $edited = Get-ImageReference + Assert-That "editing $($case.File) renames the image" ($edited -ne $baseline) ` + "$($case.Why); tag stayed $edited" + } finally { + Copy-Item -LiteralPath $backup -Destination $path -Force + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + } + } + + Assert-Equal 'restoring every file restores the original tag' $baseline (Get-ImageReference) + + # The regression that motivated hashing the whole tree, stated in the terms + # it actually occurred in: a group drops a check's `-setup` dependency, so + # the image installs one tool fewer, while tools.just and versions.just are + # untouched. The tag has to move or the reduced image is reused forever. + $group = Join-Path $RepoRoot 'justfiles/anvil/groups/pr-fast.just' + if (Test-Path -LiteralPath $group -PathType Leaf) { + $groupBackup = [System.IO.Path]::GetTempFileName() + Copy-Item -LiteralPath $group -Destination $groupBackup -Force + try { + $kept = @(Get-Content -LiteralPath $group | Where-Object { $_ -notmatch 'anvil-spellcheck-setup' }) + Set-Content -LiteralPath $group -Value $kept + Assert-That 'dropping a setup dependency renames the image' ((Get-ImageReference) -ne $baseline) ` + 'the installed tool set changed while the tag did not' + } finally { + Copy-Item -LiteralPath $groupBackup -Destination $group -Force + Remove-Item -LiteralPath $groupBackup -Force -ErrorAction SilentlyContinue + } + Assert-Equal 'restoring the group restores the original tag' $baseline (Get-ImageReference) + } else { + Write-Skipped 'dropping a setup dependency renames the image' 'no pr-fast group in this catalog' + } + + if ($SkipTier) { + Write-Skipped "$EngineName : tier" '-SkipTier was passed' + } else { + Write-Section "$EngineName : tier" + foreach ($recipe in $Tier) { + Write-Step "running $recipe (this is the long one)" + $started = Get-Date + $run = Invoke-Just -Arguments @('anvil-container', 'just', $recipe) -AllowFailure + $took = (Get-Date) - $started + Assert-Equal ("{0} passes inside the image (took {1:mm\:ss})" -f $recipe, $took) 0 $run.ExitCode + if ($run.ExitCode -ne 0) { Write-Tail "$($run.StdOut)`n$($run.StdErr)" 40 } + } + } + + $script:Results[$EngineName] = $(if ($script:Failed -gt $before) { 'failed' } else { 'passed' }) +} + +# -------------------------------------------------------------------- main ---- + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +Write-Host '' +Write-Host 'anvil containerized execution - dogfood against this repository' -ForegroundColor White +Write-Host "repository: $RepoRoot" -ForegroundColor DarkGray +Write-Host "tier: $(if ($SkipTier) { '(skipped)' } else { $Tier -join ', ' })" -ForegroundColor DarkGray + +foreach ($tool in @('just', 'cargo')) { + if (-not (Get-Command $tool -CommandType Application -ErrorAction SilentlyContinue)) { + Write-Host "FAIL $tool is required on PATH" -ForegroundColor Red + exit 1 + } +} + +$engines = if ($Engine -eq 'both') { @('docker', 'podman') } else { @($Engine) } +foreach ($name in $engines) { + Invoke-Suite $name +} + +if (-not $KeepImages -and -not $Clean) { + # The image is expensive to build and is keyed by content, so keeping it is + # correct: the next run reuses it, and an input change renames it anyway. + Write-Step 'keeping built images (content-addressed; -Clean forces a cold build)' +} + +$elapsed = (Get-Date) - $script:Started +Write-Host '' +Write-Host ('-' * 78) +foreach ($name in $script:Results.Keys) { + $state = $script:Results[$name] + $color = switch ($state) { 'passed' { 'Green' } 'skipped' { 'Yellow' } default { 'Red' } } + Write-Host (" {0,-8} {1}" -f $name, $state) -ForegroundColor $color +} +$summary = "{0}/{1} checks passed in {2:hh\:mm\:ss}" -f $script:Passed, ($script:Passed + $script:Failed), $elapsed +if ($script:Skipped) { $summary += " ($($script:Skipped) skipped)" } +# A sweep where every engine was skipped ran no container at all. Reporting +# that as a pass is the false green this script exists to prevent. A failed +# engine is a different thing and already sets the exit code below. +if (-not ($script:Results.Values | Where-Object { $_ -ne 'skipped' })) { + Write-Host "FAIL no engine was exercised -- install docker or podman, or check the daemon" -ForegroundColor Red + exit 1 +} +if ($script:Failed -eq 0) { + Write-Host "PASS $summary" -ForegroundColor Green + exit 0 +} +Write-Host "FAIL $summary ($($script:Failed) failed)" -ForegroundColor Red +exit 1