Compare commits
2
Commits
3c794b645b
...
efbe355070
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efbe355070
|
||
|
|
fc60214b3e
|
+75
-184
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Vendored
+4
@@ -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.
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
.{
|
||||||
|
.groups = .{ .{ .name = "default" } },
|
||||||
|
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user