ci: the container gate and the version parse move into a compiled tool

This commit is contained in:
2026-08-15 12:24:05 +02:00
parent 3c794b645b
commit fc60214b3e
6 changed files with 1111 additions and 190 deletions
+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
/// is claimed by an exclusive `makeDir` rather than by a random suffix: a
/// collision is a retry, not a silent share.
/// is claimed by an exclusive create rather than by a random suffix: a collision
/// is a retry, not a silent share.
///
/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p`
/// semantics — it succeeds on a directory that is already there and never
/// reports `error.PathAlreadyExists`, which made the retry below dead code. This
/// program then adopted a stale or concurrently-held directory, wrote the
/// signing material into it, and deleted the whole tree on the way out.
fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const base = runnerTemp(ctx);
var attempt: usize = 0;
@@ -726,10 +732,8 @@ fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
});
Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) {
error.PathAlreadyExists => continue,
else => return err,
};
const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir);
if (status == .existed) continue;
var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
defer dir.close(ctx.io);
try dir.setPermissions(ctx.io, .fromMode(0o700));