From fc60214b3e7f0929f4ec244180a41db5a7313e96 Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 15 Aug 2026 12:24:05 +0200 Subject: [PATCH] ci: the container gate and the version parse move into a compiled tool --- .gitea/workflows/gates.yml | 259 +++----- build.zig | 21 + tests/fixtures/README.md | 4 + tests/fixtures/container-smoke.zon | 4 + tools/container_check.zig | 997 +++++++++++++++++++++++++++++ tools/release.zig | 16 +- 6 files changed, 1111 insertions(+), 190 deletions(-) create mode 100644 tests/fixtures/container-smoke.zon create mode 100644 tools/container_check.zig diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index 2b08f89..b1727d4 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -48,8 +48,9 @@ env: # version to exist in the tag and in build.zig.zon and nowhere else, and # ruling 5 makes verify-dist fail when the version under build disagrees with # build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot - # pass this file's own packaging gate. The package and container jobs read - # the version out of build.zig.zon instead. + # pass this file's own packaging gate. The package job reads the version out + # of build.zig.zon instead, and the container job takes it from that job's + # output. jobs: test: @@ -179,6 +180,11 @@ jobs: needs: [frontend] runs-on: ubuntu-24.04 + # The container job needs the version and installs no Zig, so it cannot read + # build.zig.zon the way this job does. + outputs: + version: ${{ steps.zon-version.outputs.version }} + steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -215,12 +221,17 @@ jobs: # Ruling 2: build.zig.zon is the only place besides the tag that carries # the version, and ruling 5 makes verify-dist assert the two agree. The # gate builds the version the repository declares. + # + # This is the only job that reads it. The container job used to run its own + # `sed` over the same file; it now receives this step's output, so the two + # jobs cannot disagree about what the repository declares. The parse itself + # matches verify-dist's, through the zon grammar rather than a regex. + - name: Build the container gate tool + run: zig build container-check-tool + - name: Read the version from build.zig.zon - run: | - set -euo pipefail - version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1) - test -n "$version" - echo "CI_VERSION=$version" >> "$GITHUB_ENV" + id: zon-version + run: ./zig-out/bin/container_check version - name: Build the release artifacts run: | @@ -257,7 +268,13 @@ jobs: # The zip round-trip drops the executable bit. That is survivable only # because the Dockerfile chmods the binary itself and the contents # assertion compares sha256 of file contents, never modes. The archive - # modes are asserted by verify-dist, above, on the originals. + # modes are asserted by verify-dist, above, on the originals. The gate tool + # rides along in the same artifact and the container job chmods it back. + # + # Adding zig-out/bin/container_check moves the artifact's common root from + # zig-out/dist up to zig-out, which is why the container job restores into + # zig-out rather than zig-out/dist. The Dockerfile's COPY paths still + # resolve; they are relative to the repository root either way. - name: Upload the staged payload for the container job uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 with: @@ -265,201 +282,69 @@ jobs: path: | zig-out/dist/bin zig-out/dist/stage + zig-out/bin/container_check if-no-files-found: error container: needs: [package] runs-on: ubuntu-24.04 + env: + # Ruling 2 and ruling 5: one parse of build.zig.zon per run, done in the + # package job. This job installs no Zig and cannot repeat it. + CI_VERSION: ${{ needs.package.outputs.version }} + steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - # Restored under zig-out/dist because that is where the Dockerfile's - # COPY lines look, with the repository root as the build context. This - # job compiles nothing and bundles nothing: the payload is the one the - # package job already built and verify-dist already checked, which is - # also the point — an image built from a second, independent `dist` run - # would prove nothing about the artifacts the release publishes. + # Restored under zig-out because that is where the Dockerfile's COPY lines + # look for dist/, with the repository root as the build context, and + # because the payload also carries bin/container_check. This job compiles + # nothing and bundles nothing: the payload is the one the package job + # already built and verify-dist already checked, which is also the point — + # an image built from a second, independent `dist` run would prove nothing + # about the artifacts the release publishes. - name: Download the staged payload built by the package job uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 with: name: dist-payload - path: zig-out/dist + path: zig-out - # Same single source of truth as the package job (rulings 2 and 5). This - # job still needs the version for the stage directory name it hashes - # against the image and for the VERSION build arg. - - name: Read the version from build.zig.zon - run: | - set -euo pipefail - version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1) - test -n "$version" - echo "CI_VERSION=$version" >> "$GITHUB_ENV" + # The artifact zip carries no modes. + - name: Restore the gate tool's executable bit + run: chmod +x zig-out/bin/container_check - # Image tags, container names and published host ports are all - # daemon-global. This workflow is called by both ci.yml and release.yml - # and the self-hosted runners share one docker daemon, so the fixed - # `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the - # second `docker create --name` fails outright, and worse, whichever run - # finishes first deletes the other's container mid-test. The names below - # carry the run identity plus four random bytes — the run id alone is not - # enough, because two *repositories* on the same daemon can issue the - # same one. The published port becomes ephemeral for the same reason. - - name: Name this run's docker objects - run: | - set -euo pipefail - rand=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n') - suffix="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$rand" - { - echo "CI_IMAGE=nxdns:ci-$suffix" - echo "SMOKE_NAME=nxdns-smoke-$suffix" - } >> "$GITHUB_ENV" - echo "image nxdns:ci-$suffix, container nxdns-smoke-$suffix" + # Everything this gate decides — this run's docker object names, the + # ownership label, the image build, the contents assertion against the + # staged payload, and the smoke test with its topology detection and its + # absolute probe deadline — lives in tools/container_check.zig, along with + # the rationale comments that used to sit in this file. That is the same + # move release.yml made (milestone-14 deviation 24): logic in a `run:` + # block cannot be type-checked, run on a laptop, or covered by a test. + - name: Run the container acceptance gate + run: ./zig-out/bin/container_check gate - # The build args carry the OCI label values (ruling 6); release.yml - # passes the same three and then asserts the resulting - # org.opencontainers.image.version label. BuildKit is not optional here: - # the builder stage is pinned to $BUILDPLATFORM, which the classic - # builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM. - - name: Build the image - env: - DOCKER_BUILDKIT: "1" - run: | - set -euo pipefail - docker build -t "$CI_IMAGE" -f deploy/docker/Dockerfile \ - --build-arg VERSION="$CI_VERSION" \ - --build-arg REVISION="$GITHUB_SHA" \ - --build-arg CREATED="1970-01-01T00:00:00Z" \ - . - - # Ruling 6: the binary in the image must be the binary in the tarball. - # Ruling 3: distributing the image is distribution, so /LICENSE and - # /THIRD-PARTY-NOTICES must be in it and must be the same files the - # tarball carries — that is an acceptance criterion and nothing checked - # it. Comparing against the staged payload rather than merely asserting - # the paths exist costs nothing and catches a stale or empty copy. + # The gate removes its own container and image on every exit path it + # survives. This step is the exit path for a cancelled job and for a step + # that died before the gate's own defers ran — on a long-lived self-hosted + # daemon those accumulate one layer set per run. The name pair only exists + # if the gate got as far as writing $GITHUB_ENV, so the label sweep covers + # the rest of this run. # - # Native triple only: this job builds a single-architecture image. - # release.yml covers both platforms against the pushed multi-arch index. - - name: Assert the image contents match the packaged artifacts - run: | - set -euo pipefail - stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl" - test -d "$stage" - - out=$(mktemp -d) - cid=$(docker create "$CI_IMAGE") - trap 'docker rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$out"' EXIT - - rc=0 - for member in nxdns LICENSE THIRD-PARTY-NOTICES; do - docker cp "$cid:/$member" "$out/$member" - want=$(sha256sum "$stage/$member" | cut -d' ' -f1) - got=$(sha256sum "$out/$member" | cut -d' ' -f1) - if [ "$want" = "$got" ]; then - echo "/$member matches ($got)" - else - echo "/$member DIFFERS: image $got, packaged $want" - rc=1 - fi - done - test "$rc" -eq 0 - - - name: Smoke test the container - run: | - set -euo pipefail - - docker run --rm "$CI_IMAGE" version - - mkdir -p etc-nxdns - cat > etc-nxdns/config.zon <<'EOF' - .{ - .groups = .{ .{ .name = "default" } }, - .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, - } - EOF - - # No bind mount: the runner talks to the daemon over a mounted - # socket, so a -v path would resolve on the docker host (where the - # workspace does not exist) and mount an empty directory over - # /etc/nxdns. docker cp streams the file through the socket instead. - # - # Networking: this job itself runs in a container on the runner's - # per-job network. A published port binds on the daemon's host, not - # here, and docker does not route between the default bridge and - # that network — a bridge-IP curl hangs to its connect timeout. So - # the smoke container joins the job's own network, where its name - # resolves and its port is reachable. On a host runner the inspect - # finds no container and the published-port path covers it. - # - # `-p 127.0.0.1::8080` takes an ephemeral host port instead of a - # fixed 18080, which two concurrent runs on this daemon cannot both - # bind. The actual port is read back with `docker port`. - # - # The command and the sysctl mirror deploy/docker/compose.yaml, - # because that is the invocation this gate exists to prove. The - # invocation is the sole configuration authority (milestone-20 ruling - # 1): the image's bare `run` grades the database, and a fresh - # /var/lib/nxdns volume holds no upstream, so it exits 2 with - # NoUsableUpstreams before it ever binds a port. - net=$(docker inspect "$(hostname)" \ - -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \ - 2>/dev/null || true) - cid=$(docker create --name "$SMOKE_NAME" \ - ${net:+--network "$net"} \ - -p 127.0.0.1::8080 \ - --sysctl net.ipv4.ip_unprivileged_port_start=0 \ - "$CI_IMAGE" run --config=/etc/nxdns/config.zon) - trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT - docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon" - docker start "$SMOKE_NAME" - - # Before anything that assumes a live container. `docker port` fails - # on one that already exited, and under `set -e` that failure is the - # whole diagnosis the log gets — the container's own stderr never - # reaches CI. - if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then - echo "container exited during startup" - docker logs "$cid" || true - exit 1 - fi - - hostport=$(docker port "$SMOKE_NAME" 8080/tcp | head -1 | awk -F: '{ print $NF }') - ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid") - echo "published host port: ${hostport:-none}, container ip: ${ip:-none}" - healthy="" - for _ in $(seq 1 30); do - if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then - echo "container exited during startup" - docker logs "$cid" || true - exit 1 - fi - if curl -fsS --connect-timeout 2 "http://$SMOKE_NAME:8080/api/health" \ - || { [ -n "$hostport" ] && curl -fsS --connect-timeout 2 "http://127.0.0.1:$hostport/api/health"; } \ - || { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then - healthy=1 - break - fi - sleep 1 - done - if [ -z "$healthy" ]; then - echo "no /api/health response within 30 seconds" - docker logs "$cid" || true - exit 1 - fi - - docker stop -t 30 "$SMOKE_NAME" - exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE_NAME") - echo "exit code after SIGTERM: $exit_code" - docker logs "$SMOKE_NAME" || true - test "$exit_code" -eq 0 - - # The per-step traps only cover the step that set them. This is the exit - # path for a cancelled job, a step that died before its trap was - # installed, and the image itself, which no trap ever removed — on a - # long-lived self-hosted daemon those accumulate one layer set per run. + # It covers no more than that. `always()` does not run when the runner or + # the pod itself dies, and the filter below names THIS attempt's label + # value exactly — deliberately, since a concurrent run of another + # repository must not be swept, but that also means a later attempt cannot + # collect an earlier one's leak. What the label buys for those cases is + # discovery, not recovery: `docker ps -a --filter + # label=net.mial.nxdns.ci` and the matching `docker images` list every + # object this workflow has ever left behind, with the repository, run and + # attempt that owns each one. Reclaiming them is a manual sweep today, and + # the hook a janitor job would use later. - name: Remove this run's docker objects if: always() + env: + CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }} run: | set -uo pipefail if [ -n "${SMOKE_NAME:-}" ]; then @@ -468,4 +353,10 @@ jobs: if [ -n "${CI_IMAGE:-}" ]; then docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true fi + for cid in $(docker ps -aq --filter "label=$CI_LABEL"); do + docker rm -f "$cid" >/dev/null 2>&1 || true + done + for iid in $(docker images -q --filter "label=$CI_LABEL"); do + docker image rm -f "$iid" >/dev/null 2>&1 || true + done exit 0 diff --git a/build.zig b/build.zig index ff2e04f..b05a307 100644 --- a/build.zig +++ b/build.zig @@ -248,6 +248,27 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(release_tests).step); + // The container acceptance gate. Installed rather than run from the build + // graph for the same reason as the release tool: it needs a live docker + // daemon and the workflow's environment, neither of which a Run step in this + // graph can supply. + const container_check_tool = hostTool(b, "container_check"); + b.step("container-check-tool", "Install the container gate tool into zig-out/bin") + .dependOn(&b.addInstallArtifact(container_check_tool, .{}).step); + + // Its pure decisions — object naming, the ownership label, the inspect-JSON + // topology read, the probe deadline, the build.zig.zon version parse — are + // what the shell it replaced could never be tested on. + const container_check_tests = b.addTest(.{ + .name = "container-check-tool", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/container_check.zig"), + .target = b.graph.host, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(container_check_tests).step); + addDist(b, options, web_assets, .{ .version = version_option, .version_string = version_string, diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 03cf56a..9d743ed 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -35,3 +35,7 @@ Regenerate with: ```sh openssl ecparam -name prime256v1 -genkey -noout -out mismatched_key.pem ``` + +`container-smoke.zon` is the smallest configuration that lets the daemon reach +`serving`. `tools/container_check.zig` copies it into the smoke container, which +is the only thing that reads it. diff --git a/tests/fixtures/container-smoke.zon b/tests/fixtures/container-smoke.zon new file mode 100644 index 0000000..6c850b6 --- /dev/null +++ b/tests/fixtures/container-smoke.zon @@ -0,0 +1,4 @@ +.{ + .groups = .{ .{ .name = "default" } }, + .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, +} diff --git a/tools/container_check.zig b/tools/container_check.zig new file mode 100644 index 0000000..e4f379d --- /dev/null +++ b/tools/container_check.zig @@ -0,0 +1,997 @@ +//! The container acceptance gate for `.gitea/workflows/gates.yml`, and the one +//! place that reads the version out of `build.zig.zon` for that workflow. +//! +//! It exists for the reason `tools/release.zig` exists (milestone-14 deviation +//! 24): decision-bearing logic that only lives inside a YAML `run:` block cannot +//! be type-checked, run on a laptop, or covered by a test. What moved in here +//! was a `sed` parse of `build.zig.zon` duplicated across two jobs, the naming +//! of this run's docker objects, the image-contents assertion, and a smoke test +//! whose retry loop advertised a 30-second budget while actually allowing up to +//! 210 seconds of connect timeouts. +//! +//! The workflow keeps what is genuinely the runner's: the job graph, SHA-pinned +//! actions, artifact upload and download, and an `always()` cleanup backstop for +//! the case where this program never runs at all. +//! +//! Usage: +//! +//! container_check version read build.zig.zon into $GITHUB_OUTPUT/$GITHUB_ENV +//! container_check gate build the image and run the full acceptance +//! +//! Configuration comes from the environment, never from arguments, on the same +//! grounds as release.zig: `argv` is world-readable through `/proc`. +//! +//! ## Why the plumbing below is a copy of release.zig's and not a shared module +//! +//! release.zig is deliberately one self-contained file, and so is this one. A +//! shared `tools/ci.zig` would be the right move at three callers; at two it +//! buys a coupling between the release path and the gate path that neither +//! wants. Revisit when a third tool appears. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const max_input_bytes = 1 << 30; + +/// The members of the image that must equal the packaged copies. Ruling 6: the +/// binary in the image must be the binary in the tarball. Ruling 3: distributing +/// the image is distribution, so /LICENSE and /THIRD-PARTY-NOTICES must be in it +/// and must be the same files the tarball carries — that is an acceptance +/// criterion and, before this gate, nothing checked it. Comparing against the +/// staged payload rather than merely asserting the paths exist costs nothing and +/// catches a stale or empty copy. +const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" }; + +/// Native triple only: this gate builds a single-architecture image. release.yml +/// covers both platforms against the pushed multi-arch index. +const native_triple = "x86_64-linux-musl"; + +/// Leaked objects have to be discoverable by something other than a random name, +/// because the name only exists in a `$GITHUB_ENV` file that dies with the +/// runner pod. The label is written onto both the image and the container. +const ownership_label = "net.mial.nxdns.ci"; + +/// The health probe's absolute wall-clock budget. The shell loop this replaces +/// claimed 30 seconds and meant "30 iterations of up to three 2-second connect +/// timeouts plus a 1-second sleep", which is a real ceiling near 210 seconds. A +/// deadline is the honest shape: the container either answers within a minute of +/// being started or the gate has found something. +const probe_budget_ns: u64 = 60 * std.time.ns_per_s; + +const probe_interval: Io.Clock.Duration = .{ .raw = .fromSeconds(1), .clock = .awake }; + +/// The ceiling on one curl attempt; see `attemptSeconds`. Ten seconds is long +/// enough that a merely slow first response is not mistaken for a wedge, and +/// short enough that the budget still buys several attempts. +const max_attempt_seconds: u64 = 10; + +/// The OCI `created` label value. Fixed rather than the current time: this image +/// is never published, and a timestamp would be the only thing that changes +/// between two builds of the same commit. +const created_label = "1970-01-01T00:00:00Z"; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const Ctx = struct { + arena: Allocator, + gpa: Allocator, + io: Io, + env: *std.process.Environ.Map, + out: *Io.Writer, + failures: usize = 0, + + fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + ctx.out.print("container-check: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void { + ctx.out.print("container-check: " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + /// Records a failure and keeps going. Used where reporting every member of a + /// comparison is more useful than stopping at the first mismatch. + fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + ctx.failures += 1; + ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + /// Names the check and exits. Nothing that holds a live container calls + /// this: `std.process.exit` does not run `defer`, so the cleanup paths + /// unwind through `error.CheckFailed` instead. + fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn { + ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + std.process.exit(1); + } + + fn get(ctx: *Ctx, name: []const u8) []const u8 { + return ctx.env.get(name) orelse ""; + } + + fn require(ctx: *Ctx, name: []const u8) []const u8 { + const value = ctx.get(name); + if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name}); + return value; + } + + fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 { + return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM"); + } +}; + +/// A phase that already reported why it failed. +const CheckFailed = error.CheckFailed; + +pub fn main(init: std.process.Init) !u8 { + const arena = init.arena.allocator(); + const argv = try init.minimal.args.toSlice(arena); + + var out_buffer: [8192]u8 = undefined; + var out = Io.File.stdout().writerStreaming(init.io, &out_buffer); + + var ctx: Ctx = .{ + .arena = arena, + .gpa = init.gpa, + .io = init.io, + .env = init.environ_map, + .out = &out.interface, + }; + + if (argv.len < 2) std.process.fatal("usage: container_check ; see tools/container_check.zig", .{}); + const command = argv[1]; + + const result = dispatch(&ctx, command); + ctx.out.flush() catch {}; + result catch |err| switch (err) { + error.CheckFailed => return 1, + else => return err, + }; + return if (ctx.failures == 0) 0 else 1; +} + +fn dispatch(ctx: *Ctx, command: []const u8) !void { + if (std.mem.eql(u8, command, "version")) return version(ctx); + if (std.mem.eql(u8, command, "gate")) return gate(ctx); + std.process.fatal("unknown subcommand '{s}'; see tools/container_check.zig", .{command}); +} + +// --------------------------------------------------------------------------- +// Pure helpers. Everything below this line that can be tested without a docker +// daemon is tested at the foot of this file. +// --------------------------------------------------------------------------- + +/// The suffix that separates this run's docker objects from every other run's. +/// +/// Image tags, container names and published host ports are all daemon-global. +/// gates.yml is called by both ci.yml and release.yml, and the self-hosted +/// runners share one docker daemon, so the fixed `nxdns:ci` and `nxdns-smoke` +/// this replaced made two concurrent runs collide: the second `docker create +/// --name` fails outright, and worse, whichever run finishes first deletes the +/// other's container mid-test. The run id alone is not enough either, because +/// two *repositories* on the same daemon can issue the same one — hence the +/// random bytes. +fn objectSuffix(arena: Allocator, run_id: []const u8, attempt: []const u8, random_hex: []const u8) []const u8 { + const id = if (run_id.len != 0) run_id else "0"; + const try_number = if (attempt.len != 0) attempt else "1"; + return std.fmt.allocPrint(arena, "{s}-{s}-{s}", .{ id, try_number, random_hex }) catch @panic("OOM"); +} + +/// `//`. Label only: the repository component never +/// enters a docker object NAME, so nothing here needs sanitising for the +/// stricter name grammar. +fn labelValue(arena: Allocator, repository: []const u8, run_id: []const u8, attempt: []const u8) []const u8 { + const id = if (run_id.len != 0) run_id else "0"; + const try_number = if (attempt.len != 0) attempt else "1"; + return std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ repository, id, try_number }) catch @panic("OOM"); +} + +/// The staged directory the image is hashed against, as the package job's +/// upload laid it out under `zig-out/dist`. +fn stagePath(arena: Allocator, version_text: []const u8) []const u8 { + return std.fmt.allocPrint( + arena, + "zig-out/dist/stage/nxdns-{s}-{s}", + .{ version_text, native_triple }, + ) catch @panic("OOM"); +} + +/// The network names in a `docker inspect ` payload, which is a JSON array +/// of one object. An empty result means this program is not running inside a +/// container the daemon knows about, which is the host-runner topology. +fn inspectNetworks(arena: Allocator, payload: []const u8) []const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return list.items; + const items = switch (parsed) { + .array => |array| array.items, + else => return list.items, + }; + for (items) |item| { + if (item != .object) continue; + const settings = item.object.get("NetworkSettings") orelse continue; + if (settings != .object) continue; + const networks = settings.object.get("Networks") orelse continue; + if (networks != .object) continue; + var it = networks.object.iterator(); + while (it.next()) |entry| list.append(arena, entry.key_ptr.*) catch @panic("OOM"); + } + return list.items; +} + +/// The container's address on the first network it joined. Diagnostic only — +/// see `probeUrl` for why it is never a passing route. +fn inspectAddress(arena: Allocator, payload: []const u8) []const u8 { + const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return ""; + const items = switch (parsed) { + .array => |array| array.items, + else => return "", + }; + for (items) |item| { + if (item != .object) continue; + const settings = item.object.get("NetworkSettings") orelse continue; + if (settings != .object) continue; + const networks = settings.object.get("Networks") orelse continue; + if (networks != .object) continue; + var it = networks.object.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.* != .object) continue; + const address = entry.value_ptr.object.get("IPAddress") orelse continue; + if (address == .string and address.string.len != 0) return address.string; + } + } + return ""; +} + +/// `docker port 8080/tcp` prints one `host:port` line per binding. The +/// host half can be an IPv6 literal, so the port is what follows the LAST colon. +fn parsePublishedPort(output: []const u8) []const u8 { + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const at = std.mem.lastIndexOfScalar(u8, line, ':') orelse continue; + const port = line[at + 1 ..]; + if (port.len == 0) continue; + return port; + } + return ""; +} + +/// The `--max-time` a single curl attempt gets, in whole seconds. +/// +/// `--connect-timeout` bounds only the TCP connect. A handler that accepts the +/// connection and then wedges — a deadlocked writer, a listener up before the +/// database is — holds curl open forever, and the loop's own deadline never gets +/// to run, because it is only consulted between attempts. So each attempt is +/// capped at the lesser of `max_attempt_seconds` and whatever is left of the +/// budget, and never below one second: a zero would mean "no limit" to curl, +/// which is the exact failure being defended against. +fn attemptSeconds(started_ns: i96, now_ns: i96, budget_ns: u64) u64 { + const spent: u128 = if (now_ns <= started_ns) 0 else @intCast(now_ns - started_ns); + const left: u128 = if (spent >= budget_ns) 0 else budget_ns - spent; + const left_seconds: u64 = @intCast(left / std.time.ns_per_s); + return @max(1, @min(max_attempt_seconds, left_seconds)); +} + +/// Which of several joined networks the smoke container should join. +/// +/// The runner's per-job network is always user-defined, so the three built-in +/// names can never be it. Picking one of them would produce a container whose +/// name does not resolve — docker's embedded DNS serves user-defined networks +/// only — and the failure would read as "the daemon never came up" rather than +/// as a wrong network. Returns null when nothing qualifies, which leaves the +/// caller with the first entry and a note in the log. +fn preferredNetwork(networks: []const []const u8) ?[]const u8 { + const builtin = [_][]const u8{ "bridge", "host", "none" }; + for (networks) |candidate| { + var is_builtin = false; + for (builtin) |name| { + if (std.mem.eql(u8, candidate, name)) is_builtin = true; + } + if (!is_builtin) return candidate; + } + return null; +} + +fn deadlineExpired(started_ns: i96, now_ns: i96, budget_ns: u64) bool { + if (now_ns <= started_ns) return false; + return @as(u128, @intCast(now_ns - started_ns)) >= budget_ns; +} + +fn elapsedSeconds(started_ns: i96, now_ns: i96) f64 { + if (now_ns <= started_ns) return 0; + const delta: f64 = @floatFromInt(@as(i64, @intCast(now_ns - started_ns))); + return delta / @as(f64, std.time.ns_per_s); +} + +/// The version field of `build.zig.zon`, parsed exactly as +/// `tools/verify_dist.zig`'s `checkZonVersion` parses it. Two readers of one +/// file must not disagree about what it says, and the `sed` expression this +/// replaces disagreed with the zon grammar in every case involving a comment. +fn parseZonVersion(arena: Allocator, source: [:0]const u8) ![]const u8 { + const Manifest = struct { version: []const u8 }; + const manifest = try std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{ + .ignore_unknown_fields = true, + .free_on_error = false, + }); + return manifest.version; +} + +fn sha256Hex(bytes: []const u8) [64]u8 { + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + return std.fmt.bytesToHex(digest, .lower); +} + +// --------------------------------------------------------------------------- +// Process and file plumbing +// --------------------------------------------------------------------------- + +const Run = struct { + code: u8, + stdout: []const u8, + stderr: []const u8, + + fn ok(run: Run) bool { + return run.code == 0; + } + + fn combined(run: Run, arena: Allocator) []const u8 { + return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM"); + } + + fn trimmedStdout(run: Run) []const u8 { + return std.mem.trim(u8, run.stdout, " \t\r\n"); + } +}; + +const RunOptions = struct { + /// Extra environment for the child only. `DOCKER_BUILDKIT` travels this way + /// so no sibling process inherits it. + env: []const [2][]const u8 = &.{}, + cwd: ?[]const u8 = null, +}; + +fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run { + var child_env: ?std.process.Environ.Map = null; + defer if (child_env) |*map| map.deinit(); + if (options.env.len != 0) { + var map = try ctx.env.clone(ctx.gpa); + for (options.env) |pair| try map.put(pair[0], pair[1]); + child_env = map; + } + + const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit; + + const result = try std.process.run(ctx.gpa, ctx.io, .{ + .argv = argv, + .cwd = cwd, + .environ_map = if (child_env) |*map| map else null, + .stdout_limit = .limited(max_input_bytes), + .stderr_limit = .limited(max_input_bytes), + }); + defer ctx.gpa.free(result.stdout); + defer ctx.gpa.free(result.stderr); + + return .{ + .code = termCode(result.term), + .stdout = try ctx.arena.dupe(u8, result.stdout), + .stderr = try ctx.arena.dupe(u8, result.stderr), + }; +} + +fn termCode(term: std.process.Child.Term) u8 { + return switch (term) { + .exited => |code| code, + else => 255, + }; +} + +/// Runs a command and reports its output before failing. Used wherever a +/// non-zero exit is a gate failure rather than information. +fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 { + const run = try runCommand(ctx, argv, options); + if (!run.ok()) { + ctx.soft(check, "`{s}` exited {d}: {s}", .{ + argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + return run.stdout; +} + +fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 { + return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)); +} + +fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void { + var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{}); + defer handle.close(ctx.io); + try handle.writeStreamingAll(ctx.io, bytes); + // After the write, not through the creation mode, which `open(2)` masks + // with the process umask. + try handle.setPermissions(ctx.io, .fromMode(mode)); +} + +/// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads +/// after the step. There is no append mode on `Io.Dir`, and both files are small. +fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void { + const path = ctx.get(env_name); + if (path.len == 0) { + ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line }); + return; + } + const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch ""; + const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n"; + const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" }); + try writeFileMode(ctx, path, merged, 0o644); +} + +fn runnerTemp(ctx: *Ctx) []const u8 { + const temp = ctx.get("RUNNER_TEMP"); + return if (temp.len != 0) temp else "/tmp"; +} + +/// A fresh directory under `RUNNER_TEMP`. The name is claimed by an exclusive +/// create rather than by a random suffix: a collision is a retry, not a silent +/// share. +/// +/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p` +/// semantics — it succeeds on a directory that is already there and never +/// reports `error.PathAlreadyExists`, which made the retry below dead code. This +/// program then extracted the image into a stale or concurrently-held directory +/// and deleted the whole tree on the way out. +fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 { + const base = runnerTemp(ctx); + var attempt: usize = 0; + while (attempt < 4096) : (attempt += 1) { + const path = ctx.fmt("{s}/{s}.{s}-{d}", .{ + base, prefix, ctx.get("GITHUB_RUN_ID"), attempt, + }); + const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir); + if (status == .existed) continue; + var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true }); + defer dir.close(ctx.io); + try dir.setPermissions(ctx.io, .fromMode(0o700)); + return path; + } + ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base }); +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +/// Ruling 2: build.zig.zon is the only place besides the tag that carries the +/// version, and ruling 5 makes verify-dist assert the two agree. The gate builds +/// the version the repository declares. This runs once, in the package job, and +/// the container job receives the answer as a job output — the two `sed` blocks +/// it replaced were two independent parses that could in principle disagree. +fn version(ctx: *Ctx) !void { + const source = Io.Dir.cwd().readFileAllocOptions( + ctx.io, + "build.zig.zon", + ctx.arena, + .limited(max_input_bytes), + .of(u8), + 0, + ) catch |err| { + ctx.soft("zon-version", "cannot read build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + const declared = parseZonVersion(ctx.arena, source) catch |err| { + ctx.soft("zon-version", "cannot parse build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + if (declared.len == 0) { + ctx.soft("zon-version", "build.zig.zon declares an empty version", .{}); + return CheckFailed; + } + + try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("version={s}", .{declared})); + try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_VERSION={s}", .{declared})); + ctx.pass("zon-version", "'{s}'", .{declared}); +} + +/// The four bytes that keep two runs apart, from the `Io` interface's CSPRNG. A +/// clock-seeded PRNG would defeat the purpose: two jobs starting in the same +/// millisecond are exactly the collision these bytes exist to prevent. +fn randomBytes(ctx: *Ctx) [4]u8 { + var bytes: [4]u8 = undefined; + ctx.io.random(&bytes); + return bytes; +} + +const Names = struct { + image: []const u8, + container: []const u8, + label: []const u8, +}; + +/// The identity of this run's docker objects, published to `$GITHUB_ENV` before +/// anything is built. The order matters: the workflow's `always()` backstop can +/// only remove what it can name, and a build that dies halfway still leaves +/// layers behind. +fn claimNames(ctx: *Ctx) !Names { + const random_hex = std.fmt.bytesToHex(randomBytes(ctx), .lower); + + const suffix = objectSuffix( + ctx.arena, + ctx.get("GITHUB_RUN_ID"), + ctx.get("GITHUB_RUN_ATTEMPT"), + &random_hex, + ); + const names: Names = .{ + .image = ctx.fmt("nxdns:ci-{s}", .{suffix}), + .container = ctx.fmt("nxdns-smoke-{s}", .{suffix}), + .label = labelValue( + ctx.arena, + ctx.require("GITHUB_REPOSITORY"), + ctx.get("GITHUB_RUN_ID"), + ctx.get("GITHUB_RUN_ATTEMPT"), + ), + }; + + try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_IMAGE={s}", .{names.image})); + try appendLine(ctx, "GITHUB_ENV", ctx.fmt("SMOKE_NAME={s}", .{names.container})); + ctx.note("image {s}, container {s}, label {s}={s}", .{ + names.image, names.container, ownership_label, names.label, + }); + return names; +} + +fn gate(ctx: *Ctx) !void { + const version_text = ctx.require("CI_VERSION"); + const revision = ctx.require("GITHUB_SHA"); + const names = try claimNames(ctx); + + try buildImage(ctx, names, version_text, revision); + defer _ = runCommand(ctx, &.{ "docker", "image", "rm", "-f", names.image }, .{}) catch {}; + + _ = try mustRun(ctx, "image-version-command", &.{ "docker", "run", "--rm", names.image, "version" }, .{}); + ctx.pass("image-version-command", "`{s} version` exited 0", .{names.image}); + + try assertContents(ctx, names, version_text); + try smoke(ctx, names); +} + +/// The build args carry the OCI label values (ruling 6); release.yml passes the +/// same three and then asserts the resulting `org.opencontainers.image.version` +/// label. BuildKit is not optional here: the builder stage is pinned to +/// `$BUILDPLATFORM`, which the classic builder does not define, so +/// `DOCKER_BUILDKIT=0` fails at the first `FROM`. +fn buildImage(ctx: *Ctx, names: Names, version_text: []const u8, revision: []const u8) !void { + const buildkit = [_][2][]const u8{.{ "DOCKER_BUILDKIT", "1" }}; + _ = try mustRun(ctx, "image-build", &.{ + "docker", "build", + "-t", names.image, + "-f", "deploy/docker/Dockerfile", + "--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }), + "--build-arg", ctx.fmt("VERSION={s}", .{version_text}), + "--build-arg", ctx.fmt("REVISION={s}", .{revision}), + "--build-arg", ctx.fmt("CREATED={s}", .{created_label}), + ".", + }, .{ .env = &buildkit }); + ctx.pass("image-build", "{s} built from deploy/docker/Dockerfile", .{names.image}); +} + +/// See `image_members` for what this asserts and why the licence files are in +/// it. The comparison hashes file contents in this process rather than shelling +/// out to `sha256sum`, and it never compares modes: the artifact zip round-trip +/// drops the executable bit, which is survivable only because the Dockerfile +/// chmods the binary itself and `verify-dist` already asserted the archive modes +/// on the originals in the package job. +fn assertContents(ctx: *Ctx, names: Names, version_text: []const u8) !void { + const stage = stagePath(ctx.arena, version_text); + var stage_dir = Io.Dir.cwd().openDir(ctx.io, stage, .{}) catch |err| { + ctx.soft("image-contents", "the staged payload '{s}' is missing: {t}", .{ stage, err }); + return CheckFailed; + }; + stage_dir.close(ctx.io); + + const out = try makeTempDir(ctx, "container-check"); + defer Io.Dir.cwd().deleteTree(ctx.io, out) catch {}; + + // A stopped container, not a running one: `docker cp` reads the filesystem + // of an image's container without ever starting its entrypoint. + const created = try mustRun(ctx, "image-contents", &.{ "docker", "create", names.image }, .{}); + const cid = std.mem.trim(u8, created, " \t\r\n"); + defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", cid }, .{}) catch {}; + + const before = ctx.failures; + for (image_members) |member| { + _ = try mustRun(ctx, "image-contents", &.{ + "docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }), + }, .{}); + const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ stage, member }))); + const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member }))); + if (std.mem.eql(u8, &want, &got)) { + ctx.pass("image-contents", "/{s} matches ({s})", .{ member, &got }); + } else { + ctx.soft("image-contents", "/{s} DIFFERS: image {s}, packaged {s}", .{ member, &got, &want }); + } + } + if (ctx.failures != before) { + ctx.note("the image does not carry the artifacts this run packaged", .{}); + return CheckFailed; + } +} + +/// Where the health probe connects, and why there is exactly one answer. +/// +/// The gates job itself runs in a container on the runner's per-job network. A +/// published port binds on the *daemon's* host, not in here, and docker does not +/// route between the default bridge and that network — a bridge-IP curl hangs to +/// its connect timeout. So when this program is containerised, the smoke +/// container joins the job's own network, where its name resolves and its port +/// is reachable, and that name is the sole passing route. On a host runner the +/// inspect finds no container and the published port is the sole passing route. +/// +/// The container's own bridge address used to be a third fallback. It is now +/// diagnostic logging only: as a passing route it made the in-container topology +/// silently accept a probe that had actually taken the path a real deployment +/// never takes, which is the opposite of what a gate is for. +const Topology = union(enum) { + in_network: []const u8, + published, +}; + +fn detectTopology(ctx: *Ctx) Topology { + var host_buffer: [std.posix.HOST_NAME_MAX]u8 = undefined; + const hostname = std.posix.gethostname(&host_buffer) catch { + ctx.note("no hostname; assuming a host runner and probing the published port", .{}); + return .published; + }; + const run = runCommand(ctx, &.{ "docker", "inspect", hostname }, .{}) catch |err| { + ctx.note("could not run `docker inspect {s}` ({t}); probing the published port", .{ hostname, err }); + return .published; + }; + if (!run.ok()) { + ctx.note("`docker inspect {s}` found no container; this is a host runner", .{hostname}); + return .published; + } + const networks = inspectNetworks(ctx.arena, run.stdout); + if (networks.len == 0) { + ctx.note("this job's container has joined no network; probing the published port", .{}); + return .published; + } + const chosen = preferredNetwork(networks) orelse networks[0]; + if (networks.len > 1) { + // Still a guess once more than one network is user-defined, and a guess + // that silently picks the wrong one fails as an unreachable name rather + // than as anything diagnosable. Say so while it is cheap to read. + ctx.note("this job's container is on {d} networks; joining '{s}'", .{ networks.len, chosen }); + } + return .{ .in_network = chosen }; +} + +fn smoke(ctx: *Ctx, names: Names) !void { + const topology = detectTopology(ctx); + + // No bind mount: the runner talks to the daemon over a mounted socket, so a + // `-v` path would resolve on the docker host (where the workspace does not + // exist) and mount an empty directory over /etc/nxdns. `docker cp` streams + // the fixture through the socket instead. + // + // `-p 127.0.0.1::8080` takes an ephemeral host port instead of a fixed + // 18080, which two concurrent runs on this daemon cannot both bind. The + // actual port is read back with `docker port`. + // + // The command and the sysctl mirror deploy/docker/compose.yaml, because that + // is the invocation this gate exists to prove. The invocation is the sole + // configuration authority (milestone-20 ruling 1): the image's bare `run` + // grades the database, and a fresh /var/lib/nxdns volume holds no upstream, + // so it exits 2 with NoUsableUpstreams before it ever binds a port. + var argv: std.ArrayList([]const u8) = .empty; + try argv.appendSlice(ctx.arena, &.{ "docker", "create", "--name", names.container }); + try argv.appendSlice(ctx.arena, &.{ "--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }) }); + switch (topology) { + .in_network => |network| try argv.appendSlice(ctx.arena, &.{ "--network", network }), + .published => {}, + } + try argv.appendSlice(ctx.arena, &.{ "-p", "127.0.0.1::8080" }); + try argv.appendSlice(ctx.arena, &.{ "--sysctl", "net.ipv4.ip_unprivileged_port_start=0" }); + try argv.appendSlice(ctx.arena, &.{ names.image, "run", "--config=/etc/nxdns/config.zon" }); + + _ = try mustRun(ctx, "smoke", argv.items, .{}); + defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", names.container }, .{}) catch {}; + + _ = try mustRun(ctx, "smoke", &.{ + "docker", "cp", "tests/fixtures/container-smoke.zon", + ctx.fmt("{s}:/etc/nxdns/config.zon", .{names.container}), + }, .{}); + _ = try mustRun(ctx, "smoke", &.{ "docker", "start", names.container }, .{}); + + // Before anything that assumes a live container. `docker port` fails on one + // that has already exited, and that failure would otherwise be the whole + // diagnosis the log gets — the container's own stderr never reaches CI. + if (!try isRunning(ctx, names.container)) { + ctx.soft("smoke", "the container exited during startup", .{}); + dumpLogs(ctx, names.container); + return CheckFailed; + } + + const url = try probeUrl(ctx, names, topology); + try probeHealth(ctx, names, url); + try stopGracefully(ctx, names.container); +} + +fn probeUrl(ctx: *Ctx, names: Names, topology: Topology) ![]const u8 { + const inspected = try runCommand(ctx, &.{ "docker", "inspect", names.container }, .{}); + const address = if (inspected.ok()) inspectAddress(ctx.arena, inspected.stdout) else ""; + + switch (topology) { + .in_network => { + ctx.note("probing by container name over the job's network (container ip {s})", .{ + if (address.len != 0) address else "none", + }); + return ctx.fmt("http://{s}:8080/api/health", .{names.container}); + }, + .published => { + const ports = try mustRun(ctx, "smoke", &.{ "docker", "port", names.container, "8080/tcp" }, .{}); + const port = parsePublishedPort(ports); + if (port.len == 0) { + ctx.soft("smoke", "8080/tcp is not published on the host: '{s}'", .{ + std.mem.trimEnd(u8, ports, "\n"), + }); + return CheckFailed; + } + ctx.note("probing the published loopback port {s} (container ip {s})", .{ + port, if (address.len != 0) address else "none", + }); + return ctx.fmt("http://127.0.0.1:{s}/api/health", .{port}); + }, + } +} + +fn probeHealth(ctx: *Ctx, names: Names, url: []const u8) !void { + // curl rather than `std.http.Client`: zig 0.16's client has no connect + // timeout that survives a black-holed route, and a probe that can hang + // forever defeats the deadline this loop exists to enforce. + const started = Io.Clock.awake.now(ctx.io); + while (true) { + // First, so no attempt is ever launched with the budget already spent. + // Checking after the attempt instead let a round that started with less + // than a second left run on the one-second floor `attemptSeconds` + // applies, and report a pass past the deadline. A budget that expires + // during the sleep now fails, which is what "absolute deadline" means. + const now = Io.Clock.awake.now(ctx.io); + if (deadlineExpired(started.nanoseconds, now.nanoseconds, probe_budget_ns)) { + ctx.soft("smoke", "no response from {s} within {d:.1}s", .{ + url, elapsedSeconds(started.nanoseconds, now.nanoseconds), + }); + dumpLogs(ctx, names.container); + return CheckFailed; + } + + if (!try isRunning(ctx, names.container)) { + ctx.soft("smoke", "the container exited after {d:.1}s, before answering {s}", .{ + elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), url, + }); + dumpLogs(ctx, names.container); + return CheckFailed; + } + + const max_time = attemptSeconds( + started.nanoseconds, + Io.Clock.awake.now(ctx.io).nanoseconds, + probe_budget_ns, + ); + const attempt = try runCommand(ctx, &.{ + "curl", "-fsS", + "--connect-timeout", "2", + "--max-time", ctx.fmt("{d}", .{max_time}), + url, + }, .{}); + if (attempt.ok()) { + ctx.pass("smoke", "{s} answered after {d:.1}s: {s}", .{ + url, + elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), + std.mem.trim(u8, attempt.stdout, " \t\r\n"), + }); + return; + } + + probe_interval.sleep(ctx.io) catch |err| { + ctx.soft("smoke", "the probe interval was interrupted: {t}", .{err}); + return CheckFailed; + }; + } +} + +/// SIGTERM has to bring the daemon down cleanly, because that is what every +/// container runtime sends and a non-zero code there is a shutdown bug. +fn stopGracefully(ctx: *Ctx, container: []const u8) !void { + _ = try mustRun(ctx, "smoke-stop", &.{ "docker", "stop", "-t", "30", container }, .{}); + const inspected = try mustRun(ctx, "smoke-stop", &.{ + "docker", "inspect", "-f", "{{.State.ExitCode}}", container, + }, .{}); + const text = std.mem.trim(u8, inspected, " \t\r\n"); + dumpLogs(ctx, container); + const code = std.fmt.parseInt(i32, text, 10) catch { + ctx.soft("smoke-stop", "docker reported a non-numeric exit code '{s}'", .{text}); + return CheckFailed; + }; + if (code != 0) { + ctx.soft("smoke-stop", "the container exited {d} after SIGTERM, not 0", .{code}); + return CheckFailed; + } + ctx.pass("smoke-stop", "the container exited 0 after SIGTERM", .{}); +} + +fn isRunning(ctx: *Ctx, container: []const u8) !bool { + const run = try runCommand(ctx, &.{ + "docker", "inspect", "-f", "{{.State.Running}}", container, + }, .{}); + if (!run.ok()) return false; + return std.mem.eql(u8, run.trimmedStdout(), "true"); +} + +fn dumpLogs(ctx: *Ctx, container: []const u8) void { + const run = runCommand(ctx, &.{ "docker", "logs", container }, .{}) catch return; + ctx.note("docker logs {s}:\n{s}", .{ + container, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "objectSuffix carries the run identity and the random bytes" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectEqualStrings("42-2-deadbeef", objectSuffix(arena, "42", "2", "deadbeef")); + // A workflow_dispatch on a runner that sets neither still has to produce a + // usable name rather than `nxdns:ci--`. + try testing.expectEqualStrings("0-1-cafe", objectSuffix(arena, "", "", "cafe")); +} + +test "labelValue is repo/run/attempt" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectEqualStrings("m5r/nxdns/42/2", labelValue(arena, "m5r/nxdns", "42", "2")); + try testing.expectEqualStrings("m5r/nxdns/0/1", labelValue(arena, "m5r/nxdns", "", "")); +} + +test "stagePath names the native staged directory" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + try testing.expectEqualStrings( + "zig-out/dist/stage/nxdns-0.0.2-x86_64-linux-musl", + stagePath(arena_state.allocator(), "0.0.2"), + ); +} + +test "inspectNetworks reads none, one and several networks" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "[]").len); + try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "not json").len); + // A daemon error body is an object, not an array, and must not read as a + // network list. + try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "{\"message\":\"no such object\"}").len); + + const one = + \\[{"NetworkSettings":{"Networks":{"job-net":{"IPAddress":"172.20.0.3"}}}}] + ; + const single = inspectNetworks(arena, one); + try testing.expectEqual(@as(usize, 1), single.len); + try testing.expectEqualStrings("job-net", single[0]); + try testing.expectEqualStrings("172.20.0.3", inspectAddress(arena, one)); + + const two = + \\[{"NetworkSettings":{"Networks":{"a":{"IPAddress":"172.20.0.3"},"b":{"IPAddress":"172.21.0.4"}}}}] + ; + try testing.expectEqual(@as(usize, 2), inspectNetworks(arena, two).len); + try testing.expectEqualStrings("", inspectAddress(arena, "[]")); +} + +test "parsePublishedPort takes the port after the last colon" { + try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n")); + // An IPv6 binding carries colons in the host half. + try testing.expectEqualStrings("49154", parsePublishedPort(":::49154\n")); + try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n:::49154\n")); + try testing.expectEqualStrings("", parsePublishedPort("")); + try testing.expectEqualStrings("", parsePublishedPort("\n\n")); +} + +test "preferredNetwork skips the built-in networks" { + try testing.expectEqualStrings( + "job-net", + preferredNetwork(&.{ "bridge", "job-net" }).?, + ); + try testing.expectEqualStrings("job-net", preferredNetwork(&.{"job-net"}).?); + // Nothing user-defined to pick, so the caller falls back and says so. + try testing.expect(preferredNetwork(&.{ "bridge", "host", "none" }) == null); + try testing.expect(preferredNetwork(&.{}) == null); + // The first user-defined name wins, not the first name. + try testing.expectEqualStrings("a", preferredNetwork(&.{ "host", "a", "b" }).?); +} + +test "each curl attempt is capped by what is left of the budget" { + const start: i96 = 1_000_000_000; + // Early on, the ceiling rather than the budget is what binds. + try testing.expectEqual(@as(u64, 10), attemptSeconds(start, start, probe_budget_ns)); + try testing.expectEqual( + @as(u64, 10), + attemptSeconds(start, start + 40 * std.time.ns_per_s, probe_budget_ns), + ); + // Near the deadline the remaining budget binds instead, so one attempt can + // no longer outlive the loop that is supposed to bound it. + try testing.expectEqual( + @as(u64, 5), + attemptSeconds(start, start + 55 * std.time.ns_per_s, probe_budget_ns), + ); + // Never zero: curl reads `--max-time 0` as "no limit". `probeHealth` checks + // the deadline before it computes this, so a spent budget no longer reaches + // the floor through that loop; the floor stays because the helper must not + // hand curl an unbounded attempt whatever the caller does. + try testing.expectEqual( + @as(u64, 1), + attemptSeconds(start, start + 60 * std.time.ns_per_s, probe_budget_ns), + ); + try testing.expectEqual( + @as(u64, 1), + attemptSeconds(start, start + 600 * std.time.ns_per_s, probe_budget_ns), + ); + // A clock that reads backwards must not shorten the attempt either. + try testing.expectEqual( + @as(u64, 10), + attemptSeconds(start, start - std.time.ns_per_s, probe_budget_ns), + ); +} + +test "the probe deadline is absolute wall clock" { + const start: i96 = 1_000_000_000; + try testing.expect(!deadlineExpired(start, start, probe_budget_ns)); + try testing.expect(!deadlineExpired(start, start + 59 * std.time.ns_per_s, probe_budget_ns)); + try testing.expect(deadlineExpired(start, start + 60 * std.time.ns_per_s, probe_budget_ns)); + // A clock that reads backwards must not end the loop instantly. + try testing.expect(!deadlineExpired(start, start - std.time.ns_per_s, probe_budget_ns)); + try testing.expectApproxEqAbs( + @as(f64, 1.5), + elapsedSeconds(start, start + 1_500_000_000), + 0.001, + ); +} + +test "parseZonVersion reads the version through the zon grammar" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + \\.{ + \\ .name = .nxdns, + \\ // .version = "9.9.9" is a comment, and sed did not know that + \\ .version = "0.0.2", + \\ .minimum_zig_version = "0.16.0", + \\ .dependencies = .{}, + \\ .paths = .{""}, + \\} + ; + try testing.expectEqualStrings("0.0.2", try parseZonVersion(arena, source)); + try testing.expectError(error.ParseZon, parseZonVersion(arena, ".{ .name = .nxdns }")); +} + +test "sha256Hex matches the known empty-input digest" { + try testing.expectEqualStrings( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + &sha256Hex(""), + ); +} diff --git a/tools/release.zig b/tools/release.zig index b1428bf..1bd736d 100644 --- a/tools/release.zig +++ b/tools/release.zig @@ -717,8 +717,14 @@ fn runnerTemp(ctx: *Ctx) []const u8 { } /// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name -/// is claimed by an exclusive `makeDir` rather than by a random suffix: a -/// collision is a retry, not a silent share. +/// is claimed by an exclusive create rather than by a random suffix: a collision +/// is a retry, not a silent share. +/// +/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p` +/// semantics — it succeeds on a directory that is already there and never +/// reports `error.PathAlreadyExists`, which made the retry below dead code. This +/// program then adopted a stale or concurrently-held directory, wrote the +/// signing material into it, and deleted the whole tree on the way out. fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 { const base = runnerTemp(ctx); var attempt: usize = 0; @@ -726,10 +732,8 @@ fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 { const path = ctx.fmt("{s}/{s}.{s}-{d}", .{ base, prefix, ctx.get("GITHUB_RUN_ID"), attempt, }); - Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) { - error.PathAlreadyExists => continue, - else => return err, - }; + const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir); + if (status == .existed) continue; var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true }); defer dir.close(ctx.io); try dir.setPermissions(ctx.io, .fromMode(0o700));