2 Commits
Author SHA1 Message Date
mokhtar efbe355070 changelog: 0.0.3 releases today
Gates / frontend (push) Successful in 1m3s
Gates / test (push) Successful in 1m39s
Gates / test-aarch64 (push) Successful in 6m28s
Gates / container (push) Successful in 18s
Release / guard (push) Successful in 1m30s
Gates / test-aarch64 (push) Successful in 5m45s
Gates / package (push) Successful in 24s
Release / gates (push) Successful in 7m54s
Gates / package (push) Successful in 5m34s
CI / gates (push) Successful in 14m5s
Gates / frontend (push) Successful in 58s
Gates / test (push) Successful in 1m29s
Gates / container (push) Successful in 10s
Release / publish (push) Successful in 9m12s
2026-08-15 12:24:05 +02:00
mokhtar fc60214b3e ci: the container gate and the version parse move into a compiled tool 2026-08-15 12:24:05 +02:00
8 changed files with 1154 additions and 191 deletions
+75 -184
View File
@@ -48,8 +48,9 @@ env:
# version to exist in the tag and in build.zig.zon and nowhere else, and # 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 # 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 # 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 # pass this file's own packaging gate. The package job reads the version out
# the version out of build.zig.zon instead. # of build.zig.zon instead, and the container job takes it from that job's
# output.
jobs: jobs:
test: test:
@@ -179,6 +180,11 @@ jobs:
needs: [frontend] needs: [frontend]
runs-on: ubuntu-24.04 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: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - 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 # 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 # the version, and ruling 5 makes verify-dist assert the two agree. The
# gate builds the version the repository declares. # 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 - name: Read the version from build.zig.zon
run: | id: zon-version
set -euo pipefail run: ./zig-out/bin/container_check version
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
- name: Build the release artifacts - name: Build the release artifacts
run: | run: |
@@ -257,7 +268,13 @@ jobs:
# The zip round-trip drops the executable bit. That is survivable only # The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents # because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive # 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 - name: Upload the staged payload for the container job
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with: with:
@@ -265,201 +282,69 @@ jobs:
path: | path: |
zig-out/dist/bin zig-out/dist/bin
zig-out/dist/stage zig-out/dist/stage
zig-out/bin/container_check
if-no-files-found: error if-no-files-found: error
container: container:
needs: [package] needs: [package]
runs-on: ubuntu-24.04 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: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Restored under zig-out/dist because that is where the Dockerfile's # Restored under zig-out because that is where the Dockerfile's COPY lines
# COPY lines look, with the repository root as the build context. This # look for dist/, with the repository root as the build context, and
# job compiles nothing and bundles nothing: the payload is the one the # because the payload also carries bin/container_check. This job compiles
# package job already built and verify-dist already checked, which is # nothing and bundles nothing: the payload is the one the package job
# also the point — an image built from a second, independent `dist` run # already built and verify-dist already checked, which is also the point —
# would prove nothing about the artifacts the release publishes. # 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 - name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with: with:
name: dist-payload name: dist-payload
path: zig-out/dist path: zig-out
# Same single source of truth as the package job (rulings 2 and 5). This # The artifact zip carries no modes.
# job still needs the version for the stage directory name it hashes - name: Restore the gate tool's executable bit
# against the image and for the VERSION build arg. run: chmod +x zig-out/bin/container_check
- 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"
# Image tags, container names and published host ports are all # Everything this gate decides — this run's docker object names, the
# daemon-global. This workflow is called by both ci.yml and release.yml # ownership label, the image build, the contents assertion against the
# and the self-hosted runners share one docker daemon, so the fixed # staged payload, and the smoke test with its topology detection and its
# `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the # absolute probe deadline — lives in tools/container_check.zig, along with
# second `docker create --name` fails outright, and worse, whichever run # the rationale comments that used to sit in this file. That is the same
# finishes first deletes the other's container mid-test. The names below # move release.yml made (milestone-14 deviation 24): logic in a `run:`
# carry the run identity plus four random bytes — the run id alone is not # block cannot be type-checked, run on a laptop, or covered by a test.
# enough, because two *repositories* on the same daemon can issue the - name: Run the container acceptance gate
# same one. The published port becomes ephemeral for the same reason. run: ./zig-out/bin/container_check gate
- 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"
# The build args carry the OCI label values (ruling 6); release.yml # The gate removes its own container and image on every exit path it
# passes the same three and then asserts the resulting # survives. This step is the exit path for a cancelled job and for a step
# org.opencontainers.image.version label. BuildKit is not optional here: # that died before the gate's own defers ran — on a long-lived self-hosted
# the builder stage is pinned to $BUILDPLATFORM, which the classic # daemon those accumulate one layer set per run. The name pair only exists
# builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM. # if the gate got as far as writing $GITHUB_ENV, so the label sweep covers
- name: Build the image # the rest of this run.
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.
# #
# Native triple only: this job builds a single-architecture image. # It covers no more than that. `always()` does not run when the runner or
# release.yml covers both platforms against the pushed multi-arch index. # the pod itself dies, and the filter below names THIS attempt's label
- name: Assert the image contents match the packaged artifacts # value exactly — deliberately, since a concurrent run of another
run: | # repository must not be swept, but that also means a later attempt cannot
set -euo pipefail # collect an earlier one's leak. What the label buys for those cases is
stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl" # discovery, not recovery: `docker ps -a --filter
test -d "$stage" # label=net.mial.nxdns.ci` and the matching `docker images` list every
# object this workflow has ever left behind, with the repository, run and
out=$(mktemp -d) # attempt that owns each one. Reclaiming them is a manual sweep today, and
cid=$(docker create "$CI_IMAGE") # the hook a janitor job would use later.
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.
- name: Remove this run's docker objects - name: Remove this run's docker objects
if: always() if: always()
env:
CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }}
run: | run: |
set -uo pipefail set -uo pipefail
if [ -n "${SMOKE_NAME:-}" ]; then if [ -n "${SMOKE_NAME:-}" ]; then
@@ -468,4 +353,10 @@ jobs:
if [ -n "${CI_IMAGE:-}" ]; then if [ -n "${CI_IMAGE:-}" ]; then
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
fi 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 exit 0
+42
View File
@@ -10,6 +10,48 @@ subject rarely does.
## [Unreleased] ## [Unreleased]
## [0.0.3] - 2026-08-15
Devices name themselves: the clients table asks the router over reverse DNS
instead of waiting for the operator to type every name. The CI container gate
also moved from workflow shell into a compiled, tested tool, which fixed a
latent temp-directory bug shared with the release tool.
### Added
- **Client names learned over reverse DNS.** A client row that carries no
hand-typed name gets one from the network: each tracker flush pass takes up
to 16 unnamed rows, builds each address's reverse name, matches it against
the declared `forward_zones`, and on a match sends one PTR query to that
zone's resolver, storing the answer as a *learned* name. This requires a
conditional forward zone covering the LAN's reverse space — for example
`168.192.in-addr.arpa` pointed at the router; without one, nothing is sent
anywhere. A hand-typed name always wins, learned names never appear in
`nxdns export` and are never set by `nxdns import`, and each row refreshes
once a day (an hour after a failure), so a rename can show stale for up to
24 hours. The API's `Client` object gains a `learned_name` field and the
clients page shows it.
### Changed
- The container CI gate — image build, image-contents assertion against the
packaged artifacts, and the startup/shutdown smoke test — moved from
workflow shell into `tools/container_check.zig`, compiled and unit-tested by
`zig build test` and runnable on a laptop against a local docker daemon.
The health probe now runs under a real 60-second deadline (the shell loop's
"30 seconds" could stretch past three minutes), and the gate's docker
objects carry an ownership label so anything a dead runner leaks is
discoverable. The version in CI is parsed from `build.zig.zon` through the
zon grammar, once, instead of by two copies of a `sed` regex.
### Fixed
- The release tool's temporary-directory claim was not exclusive: the
"create" it relied on succeeds on a directory that already exists, so a
stale or concurrent directory could be silently adopted, written into, and
deleted on exit. Both the release tool and the new container gate now claim
their directories exclusively and retry on collision.
## [0.0.2] - 2026-08-14 ## [0.0.2] - 2026-08-14
Configuration can now be a file that every boot converges to, filtering gains Configuration can now be a file that every boot converges to, filtering gains
+21
View File
@@ -248,6 +248,27 @@ pub fn build(b: *std.Build) void {
}); });
test_step.dependOn(&b.addRunArtifact(release_tests).step); 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, .{ addDist(b, options, web_assets, .{
.version = version_option, .version = version_option,
.version_string = version_string, .version_string = version_string,
+1 -1
View File
@@ -1,6 +1,6 @@
.{ .{
.name = .nxdns, .name = .nxdns,
.version = "0.0.2", .version = "0.0.3",
.minimum_zig_version = "0.16.0", .minimum_zig_version = "0.16.0",
.paths = .{""}, .paths = .{""},
.fingerprint = 0x3307b311dded1d91, .fingerprint = 0x3307b311dded1d91,
+4
View File
@@ -35,3 +35,7 @@ Regenerate with:
```sh ```sh
openssl ecparam -name prime256v1 -genkey -noout -out mismatched_key.pem 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.
+4
View File
@@ -0,0 +1,4 @@
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
+997
View File
@@ -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 <subcommand>; 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");
}
/// `<repo>/<run_id>/<attempt>`. 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 <id>` 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 <name> 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(""),
);
}
+10 -6
View File
@@ -717,8 +717,14 @@ fn runnerTemp(ctx: *Ctx) []const u8 {
} }
/// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name /// 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 /// is claimed by an exclusive create rather than by a random suffix: a collision
/// collision is a retry, not a silent share. /// 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 { fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const base = runnerTemp(ctx); const base = runnerTemp(ctx);
var attempt: usize = 0; 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}", .{ const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
base, prefix, ctx.get("GITHUB_RUN_ID"), attempt, base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
}); });
Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) { const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir);
error.PathAlreadyExists => continue, if (status == .existed) continue;
else => return err,
};
var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true }); var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
defer dir.close(ctx.io); defer dir.close(ctx.io);
try dir.setPermissions(ctx.io, .fromMode(0o700)); try dir.setPermissions(ctx.io, .fromMode(0o700));