//! The container acceptance gate for `.gitea/workflows/gates.yml`, and the one //! place that reads the version out of `build.zig.zon` for that workflow. //! //! It exists for the reason `tools/release.zig` exists (milestone-14 deviation //! 24): decision-bearing logic that only lives inside a YAML `run:` block cannot //! be type-checked, run on a laptop, or covered by a test. What moved in here //! was a `sed` parse of `build.zig.zon` duplicated across two jobs, the naming //! of this run's docker objects, the image-contents assertion, and a smoke test //! whose retry loop advertised a 30-second budget while actually allowing up to //! 210 seconds of connect timeouts. //! //! The workflow keeps what is genuinely the runner's: the job graph, SHA-pinned //! actions, artifact upload and download, and an `always()` cleanup backstop for //! the case where this program never runs at all. //! //! Usage: //! //! container_check version read build.zig.zon into $GITHUB_OUTPUT/$GITHUB_ENV //! container_check gate build the image and run the full acceptance //! //! Configuration comes from the environment, never from arguments, on the same //! grounds as release.zig: `argv` is world-readable through `/proc`. //! //! ## Why the plumbing below is a copy of release.zig's and not a shared module //! //! release.zig is deliberately one self-contained file, and so is this one. A //! shared `tools/ci.zig` would be the right move at three callers; at two it //! buys a coupling between the release path and the gate path that neither //! wants. Revisit when a third tool appears. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const max_input_bytes = 1 << 30; /// The members of the image that must equal the packaged copies. Ruling 6: the /// binary in the image must be the binary in the tarball. Ruling 3: distributing /// the image is distribution, so /LICENSE and /THIRD-PARTY-NOTICES must be in it /// and must be the same files the tarball carries — that is an acceptance /// criterion and, before this gate, nothing checked it. Comparing against the /// staged payload rather than merely asserting the paths exist costs nothing and /// catches a stale or empty copy. const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" }; /// Native triple only: this gate builds a single-architecture image. release.yml /// covers both platforms against the pushed multi-arch index. const native_triple = "x86_64-linux-musl"; /// Leaked objects have to be discoverable by something other than a random name, /// because the name only exists in a `$GITHUB_ENV` file that dies with the /// runner pod. The label is written onto both the image and the container. const ownership_label = "net.mial.nxdns.ci"; /// The health probe's absolute wall-clock budget. The shell loop this replaces /// claimed 30 seconds and meant "30 iterations of up to three 2-second connect /// timeouts plus a 1-second sleep", which is a real ceiling near 210 seconds. A /// deadline is the honest shape: the container either answers within a minute of /// being started or the gate has found something. const probe_budget_ns: u64 = 60 * std.time.ns_per_s; const probe_interval: Io.Clock.Duration = .{ .raw = .fromSeconds(1), .clock = .awake }; /// The ceiling on one curl attempt; see `attemptSeconds`. Ten seconds is long /// enough that a merely slow first response is not mistaken for a wedge, and /// short enough that the budget still buys several attempts. const max_attempt_seconds: u64 = 10; /// The OCI `created` label value. Fixed rather than the current time: this image /// is never published, and a timestamp would be the only thing that changes /// between two builds of the same commit. const created_label = "1970-01-01T00:00:00Z"; // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const Ctx = struct { arena: Allocator, gpa: Allocator, io: Io, env: *std.process.Environ.Map, out: *Io.Writer, failures: usize = 0, fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { ctx.out.print("container-check: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {}; ctx.out.flush() catch {}; } fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void { ctx.out.print("container-check: " ++ template ++ "\n", args) catch {}; ctx.out.flush() catch {}; } /// Records a failure and keeps going. Used where reporting every member of a /// comparison is more useful than stopping at the first mismatch. fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { ctx.failures += 1; ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; ctx.out.flush() catch {}; } /// Names the check and exits. Nothing that holds a live container calls /// this: `std.process.exit` does not run `defer`, so the cleanup paths /// unwind through `error.CheckFailed` instead. fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn { ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; ctx.out.flush() catch {}; std.process.exit(1); } fn get(ctx: *Ctx, name: []const u8) []const u8 { return ctx.env.get(name) orelse ""; } fn require(ctx: *Ctx, name: []const u8) []const u8 { const value = ctx.get(name); if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name}); return value; } fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 { return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM"); } }; /// A phase that already reported why it failed. const CheckFailed = error.CheckFailed; pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const argv = try init.minimal.args.toSlice(arena); var out_buffer: [8192]u8 = undefined; var out = Io.File.stdout().writerStreaming(init.io, &out_buffer); var ctx: Ctx = .{ .arena = arena, .gpa = init.gpa, .io = init.io, .env = init.environ_map, .out = &out.interface, }; if (argv.len < 2) std.process.fatal("usage: container_check ; see tools/container_check.zig", .{}); const command = argv[1]; const result = dispatch(&ctx, command); ctx.out.flush() catch {}; result catch |err| switch (err) { error.CheckFailed => return 1, else => return err, }; return if (ctx.failures == 0) 0 else 1; } fn dispatch(ctx: *Ctx, command: []const u8) !void { if (std.mem.eql(u8, command, "version")) return version(ctx); if (std.mem.eql(u8, command, "gate")) return gate(ctx); std.process.fatal("unknown subcommand '{s}'; see tools/container_check.zig", .{command}); } // --------------------------------------------------------------------------- // Pure helpers. Everything below this line that can be tested without a docker // daemon is tested at the foot of this file. // --------------------------------------------------------------------------- /// The suffix that separates this run's docker objects from every other run's. /// /// Image tags, container names and published host ports are all daemon-global. /// gates.yml is called by both ci.yml and release.yml, and the self-hosted /// runners share one docker daemon, so the fixed `nxdns:ci` and `nxdns-smoke` /// this replaced made two concurrent runs collide: the second `docker create /// --name` fails outright, and worse, whichever run finishes first deletes the /// other's container mid-test. The run id alone is not enough either, because /// two *repositories* on the same daemon can issue the same one — hence the /// random bytes. fn objectSuffix(arena: Allocator, run_id: []const u8, attempt: []const u8, random_hex: []const u8) []const u8 { const id = if (run_id.len != 0) run_id else "0"; const try_number = if (attempt.len != 0) attempt else "1"; return std.fmt.allocPrint(arena, "{s}-{s}-{s}", .{ id, try_number, random_hex }) catch @panic("OOM"); } /// `//`. Label only: the repository component never /// enters a docker object NAME, so nothing here needs sanitising for the /// stricter name grammar. fn labelValue(arena: Allocator, repository: []const u8, run_id: []const u8, attempt: []const u8) []const u8 { const id = if (run_id.len != 0) run_id else "0"; const try_number = if (attempt.len != 0) attempt else "1"; return std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ repository, id, try_number }) catch @panic("OOM"); } /// The staged directory the image is hashed against, as the package job's /// upload laid it out under `zig-out/dist`. fn stagePath(arena: Allocator, version_text: []const u8) []const u8 { return std.fmt.allocPrint( arena, "zig-out/dist/stage/nxdns-{s}-{s}", .{ version_text, native_triple }, ) catch @panic("OOM"); } /// The network names in a `docker inspect ` payload, which is a JSON array /// of one object. An empty result means this program is not running inside a /// container the daemon knows about, which is the host-runner topology. fn inspectNetworks(arena: Allocator, payload: []const u8) []const []const u8 { var list: std.ArrayList([]const u8) = .empty; const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return list.items; const items = switch (parsed) { .array => |array| array.items, else => return list.items, }; for (items) |item| { if (item != .object) continue; const settings = item.object.get("NetworkSettings") orelse continue; if (settings != .object) continue; const networks = settings.object.get("Networks") orelse continue; if (networks != .object) continue; var it = networks.object.iterator(); while (it.next()) |entry| list.append(arena, entry.key_ptr.*) catch @panic("OOM"); } return list.items; } /// The container's address on the first network it joined. Diagnostic only — /// see `probeUrl` for why it is never a passing route. fn inspectAddress(arena: Allocator, payload: []const u8) []const u8 { const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return ""; const items = switch (parsed) { .array => |array| array.items, else => return "", }; for (items) |item| { if (item != .object) continue; const settings = item.object.get("NetworkSettings") orelse continue; if (settings != .object) continue; const networks = settings.object.get("Networks") orelse continue; if (networks != .object) continue; var it = networks.object.iterator(); while (it.next()) |entry| { if (entry.value_ptr.* != .object) continue; const address = entry.value_ptr.object.get("IPAddress") orelse continue; if (address == .string and address.string.len != 0) return address.string; } } return ""; } /// `docker port 8080/tcp` prints one `host:port` line per binding. The /// host half can be an IPv6 literal, so the port is what follows the LAST colon. fn parsePublishedPort(output: []const u8) []const u8 { var lines = std.mem.splitScalar(u8, output, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; const at = std.mem.lastIndexOfScalar(u8, line, ':') orelse continue; const port = line[at + 1 ..]; if (port.len == 0) continue; return port; } return ""; } /// The `--max-time` a single curl attempt gets, in whole seconds. /// /// `--connect-timeout` bounds only the TCP connect. A handler that accepts the /// connection and then wedges — a deadlocked writer, a listener up before the /// database is — holds curl open forever, and the loop's own deadline never gets /// to run, because it is only consulted between attempts. So each attempt is /// capped at the lesser of `max_attempt_seconds` and whatever is left of the /// budget, and never below one second: a zero would mean "no limit" to curl, /// which is the exact failure being defended against. fn attemptSeconds(started_ns: i96, now_ns: i96, budget_ns: u64) u64 { const spent: u128 = if (now_ns <= started_ns) 0 else @intCast(now_ns - started_ns); const left: u128 = if (spent >= budget_ns) 0 else budget_ns - spent; const left_seconds: u64 = @intCast(left / std.time.ns_per_s); return @max(1, @min(max_attempt_seconds, left_seconds)); } /// Which of several joined networks the smoke container should join. /// /// The runner's per-job network is always user-defined, so the three built-in /// names can never be it. Picking one of them would produce a container whose /// name does not resolve — docker's embedded DNS serves user-defined networks /// only — and the failure would read as "the daemon never came up" rather than /// as a wrong network. Returns null when nothing qualifies, which leaves the /// caller with the first entry and a note in the log. fn preferredNetwork(networks: []const []const u8) ?[]const u8 { const builtin = [_][]const u8{ "bridge", "host", "none" }; for (networks) |candidate| { var is_builtin = false; for (builtin) |name| { if (std.mem.eql(u8, candidate, name)) is_builtin = true; } if (!is_builtin) return candidate; } return null; } fn deadlineExpired(started_ns: i96, now_ns: i96, budget_ns: u64) bool { if (now_ns <= started_ns) return false; return @as(u128, @intCast(now_ns - started_ns)) >= budget_ns; } fn elapsedSeconds(started_ns: i96, now_ns: i96) f64 { if (now_ns <= started_ns) return 0; const delta: f64 = @floatFromInt(@as(i64, @intCast(now_ns - started_ns))); return delta / @as(f64, std.time.ns_per_s); } /// The version field of `build.zig.zon`, parsed exactly as /// `tools/verify_dist.zig`'s `checkZonVersion` parses it. Two readers of one /// file must not disagree about what it says, and the `sed` expression this /// replaces disagreed with the zon grammar in every case involving a comment. fn parseZonVersion(arena: Allocator, source: [:0]const u8) ![]const u8 { const Manifest = struct { version: []const u8 }; const manifest = try std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{ .ignore_unknown_fields = true, .free_on_error = false, }); return manifest.version; } fn sha256Hex(bytes: []const u8) [64]u8 { var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); return std.fmt.bytesToHex(digest, .lower); } // --------------------------------------------------------------------------- // Process and file plumbing // --------------------------------------------------------------------------- const Run = struct { code: u8, stdout: []const u8, stderr: []const u8, fn ok(run: Run) bool { return run.code == 0; } fn combined(run: Run, arena: Allocator) []const u8 { return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM"); } fn trimmedStdout(run: Run) []const u8 { return std.mem.trim(u8, run.stdout, " \t\r\n"); } }; const RunOptions = struct { /// Extra environment for the child only. `DOCKER_BUILDKIT` travels this way /// so no sibling process inherits it. env: []const [2][]const u8 = &.{}, cwd: ?[]const u8 = null, }; fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run { var child_env: ?std.process.Environ.Map = null; defer if (child_env) |*map| map.deinit(); if (options.env.len != 0) { var map = try ctx.env.clone(ctx.gpa); for (options.env) |pair| try map.put(pair[0], pair[1]); child_env = map; } const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit; const result = try std.process.run(ctx.gpa, ctx.io, .{ .argv = argv, .cwd = cwd, .environ_map = if (child_env) |*map| map else null, .stdout_limit = .limited(max_input_bytes), .stderr_limit = .limited(max_input_bytes), }); defer ctx.gpa.free(result.stdout); defer ctx.gpa.free(result.stderr); return .{ .code = termCode(result.term), .stdout = try ctx.arena.dupe(u8, result.stdout), .stderr = try ctx.arena.dupe(u8, result.stderr), }; } fn termCode(term: std.process.Child.Term) u8 { return switch (term) { .exited => |code| code, else => 255, }; } /// Runs a command and reports its output before failing. Used wherever a /// non-zero exit is a gate failure rather than information. fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 { const run = try runCommand(ctx, argv, options); if (!run.ok()) { ctx.soft(check, "`{s}` exited {d}: {s}", .{ argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), }); return CheckFailed; } return run.stdout; } fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 { return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)); } fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void { var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{}); defer handle.close(ctx.io); try handle.writeStreamingAll(ctx.io, bytes); // After the write, not through the creation mode, which `open(2)` masks // with the process umask. try handle.setPermissions(ctx.io, .fromMode(mode)); } /// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads /// after the step. There is no append mode on `Io.Dir`, and both files are small. fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void { const path = ctx.get(env_name); if (path.len == 0) { ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line }); return; } const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch ""; const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n"; const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" }); try writeFileMode(ctx, path, merged, 0o644); } fn runnerTemp(ctx: *Ctx) []const u8 { const temp = ctx.get("RUNNER_TEMP"); return if (temp.len != 0) temp else "/tmp"; } /// A fresh directory under `RUNNER_TEMP`. The name is claimed by an exclusive /// create rather than by a random suffix: a collision is a retry, not a silent /// share. /// /// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p` /// semantics — it succeeds on a directory that is already there and never /// reports `error.PathAlreadyExists`, which made the retry below dead code. This /// program then extracted the image into a stale or concurrently-held directory /// and deleted the whole tree on the way out. fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 { const base = runnerTemp(ctx); var attempt: usize = 0; while (attempt < 4096) : (attempt += 1) { const path = ctx.fmt("{s}/{s}.{s}-{d}", .{ base, prefix, ctx.get("GITHUB_RUN_ID"), attempt, }); const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir); if (status == .existed) continue; var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true }); defer dir.close(ctx.io); try dir.setPermissions(ctx.io, .fromMode(0o700)); return path; } ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base }); } // --------------------------------------------------------------------------- // Subcommands // --------------------------------------------------------------------------- /// Ruling 2: build.zig.zon is the only place besides the tag that carries the /// version, and ruling 5 makes verify-dist assert the two agree. The gate builds /// the version the repository declares. This runs once, in the package job, and /// the container job receives the answer as a job output — the two `sed` blocks /// it replaced were two independent parses that could in principle disagree. fn version(ctx: *Ctx) !void { const source = Io.Dir.cwd().readFileAllocOptions( ctx.io, "build.zig.zon", ctx.arena, .limited(max_input_bytes), .of(u8), 0, ) catch |err| { ctx.soft("zon-version", "cannot read build.zig.zon: {t}", .{err}); return CheckFailed; }; const declared = parseZonVersion(ctx.arena, source) catch |err| { ctx.soft("zon-version", "cannot parse build.zig.zon: {t}", .{err}); return CheckFailed; }; if (declared.len == 0) { ctx.soft("zon-version", "build.zig.zon declares an empty version", .{}); return CheckFailed; } try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("version={s}", .{declared})); try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_VERSION={s}", .{declared})); ctx.pass("zon-version", "'{s}'", .{declared}); } /// The four bytes that keep two runs apart, from the `Io` interface's CSPRNG. A /// clock-seeded PRNG would defeat the purpose: two jobs starting in the same /// millisecond are exactly the collision these bytes exist to prevent. fn randomBytes(ctx: *Ctx) [4]u8 { var bytes: [4]u8 = undefined; ctx.io.random(&bytes); return bytes; } const Names = struct { image: []const u8, container: []const u8, label: []const u8, }; /// The identity of this run's docker objects, published to `$GITHUB_ENV` before /// anything is built. The order matters: the workflow's `always()` backstop can /// only remove what it can name, and a build that dies halfway still leaves /// layers behind. fn claimNames(ctx: *Ctx) !Names { const random_hex = std.fmt.bytesToHex(randomBytes(ctx), .lower); const suffix = objectSuffix( ctx.arena, ctx.get("GITHUB_RUN_ID"), ctx.get("GITHUB_RUN_ATTEMPT"), &random_hex, ); const names: Names = .{ .image = ctx.fmt("nxdns:ci-{s}", .{suffix}), .container = ctx.fmt("nxdns-smoke-{s}", .{suffix}), .label = labelValue( ctx.arena, ctx.require("GITHUB_REPOSITORY"), ctx.get("GITHUB_RUN_ID"), ctx.get("GITHUB_RUN_ATTEMPT"), ), }; try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_IMAGE={s}", .{names.image})); try appendLine(ctx, "GITHUB_ENV", ctx.fmt("SMOKE_NAME={s}", .{names.container})); ctx.note("image {s}, container {s}, label {s}={s}", .{ names.image, names.container, ownership_label, names.label, }); return names; } fn gate(ctx: *Ctx) !void { const version_text = ctx.require("CI_VERSION"); const revision = ctx.require("GITHUB_SHA"); const names = try claimNames(ctx); try buildImage(ctx, names, version_text, revision); defer _ = runCommand(ctx, &.{ "docker", "image", "rm", "-f", names.image }, .{}) catch {}; _ = try mustRun(ctx, "image-version-command", &.{ "docker", "run", "--rm", names.image, "version" }, .{}); ctx.pass("image-version-command", "`{s} version` exited 0", .{names.image}); try assertContents(ctx, names, version_text); try smoke(ctx, names); } /// The build args carry the OCI label values (ruling 6); release.yml passes the /// same three and then asserts the resulting `org.opencontainers.image.version` /// label. BuildKit is not optional here: the builder stage is pinned to /// `$BUILDPLATFORM`, which the classic builder does not define, so /// `DOCKER_BUILDKIT=0` fails at the first `FROM`. fn buildImage(ctx: *Ctx, names: Names, version_text: []const u8, revision: []const u8) !void { const buildkit = [_][2][]const u8{.{ "DOCKER_BUILDKIT", "1" }}; _ = try mustRun(ctx, "image-build", &.{ "docker", "build", "-t", names.image, "-f", "deploy/docker/Dockerfile", "--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }), "--build-arg", ctx.fmt("VERSION={s}", .{version_text}), "--build-arg", ctx.fmt("REVISION={s}", .{revision}), "--build-arg", ctx.fmt("CREATED={s}", .{created_label}), ".", }, .{ .env = &buildkit }); ctx.pass("image-build", "{s} built from deploy/docker/Dockerfile", .{names.image}); } /// See `image_members` for what this asserts and why the licence files are in /// it. The comparison hashes file contents in this process rather than shelling /// out to `sha256sum`, and it never compares modes: the artifact zip round-trip /// drops the executable bit, which is survivable only because the Dockerfile /// chmods the binary itself and `verify-dist` already asserted the archive modes /// on the originals in the package job. fn assertContents(ctx: *Ctx, names: Names, version_text: []const u8) !void { const stage = stagePath(ctx.arena, version_text); var stage_dir = Io.Dir.cwd().openDir(ctx.io, stage, .{}) catch |err| { ctx.soft("image-contents", "the staged payload '{s}' is missing: {t}", .{ stage, err }); return CheckFailed; }; stage_dir.close(ctx.io); const out = try makeTempDir(ctx, "container-check"); defer Io.Dir.cwd().deleteTree(ctx.io, out) catch {}; // A stopped container, not a running one: `docker cp` reads the filesystem // of an image's container without ever starting its entrypoint. const created = try mustRun(ctx, "image-contents", &.{ "docker", "create", names.image }, .{}); const cid = std.mem.trim(u8, created, " \t\r\n"); defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", cid }, .{}) catch {}; const before = ctx.failures; for (image_members) |member| { _ = try mustRun(ctx, "image-contents", &.{ "docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }), }, .{}); const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ stage, member }))); const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member }))); if (std.mem.eql(u8, &want, &got)) { ctx.pass("image-contents", "/{s} matches ({s})", .{ member, &got }); } else { ctx.soft("image-contents", "/{s} DIFFERS: image {s}, packaged {s}", .{ member, &got, &want }); } } if (ctx.failures != before) { ctx.note("the image does not carry the artifacts this run packaged", .{}); return CheckFailed; } } /// Where the health probe connects, and why there is exactly one answer. /// /// The gates job itself runs in a container on the runner's per-job network. A /// published port binds on the *daemon's* host, not in here, and docker does not /// route between the default bridge and that network — a bridge-IP curl hangs to /// its connect timeout. So when this program is containerised, the smoke /// container joins the job's own network, where its name resolves and its port /// is reachable, and that name is the sole passing route. On a host runner the /// inspect finds no container and the published port is the sole passing route. /// /// The container's own bridge address used to be a third fallback. It is now /// diagnostic logging only: as a passing route it made the in-container topology /// silently accept a probe that had actually taken the path a real deployment /// never takes, which is the opposite of what a gate is for. const Topology = union(enum) { in_network: []const u8, published, }; fn detectTopology(ctx: *Ctx) Topology { var host_buffer: [std.posix.HOST_NAME_MAX]u8 = undefined; const hostname = std.posix.gethostname(&host_buffer) catch { ctx.note("no hostname; assuming a host runner and probing the published port", .{}); return .published; }; const run = runCommand(ctx, &.{ "docker", "inspect", hostname }, .{}) catch |err| { ctx.note("could not run `docker inspect {s}` ({t}); probing the published port", .{ hostname, err }); return .published; }; if (!run.ok()) { ctx.note("`docker inspect {s}` found no container; this is a host runner", .{hostname}); return .published; } const networks = inspectNetworks(ctx.arena, run.stdout); if (networks.len == 0) { ctx.note("this job's container has joined no network; probing the published port", .{}); return .published; } const chosen = preferredNetwork(networks) orelse networks[0]; if (networks.len > 1) { // Still a guess once more than one network is user-defined, and a guess // that silently picks the wrong one fails as an unreachable name rather // than as anything diagnosable. Say so while it is cheap to read. ctx.note("this job's container is on {d} networks; joining '{s}'", .{ networks.len, chosen }); } return .{ .in_network = chosen }; } fn smoke(ctx: *Ctx, names: Names) !void { const topology = detectTopology(ctx); // No bind mount: the runner talks to the daemon over a mounted socket, so a // `-v` path would resolve on the docker host (where the workspace does not // exist) and mount an empty directory over /etc/nxdns. `docker cp` streams // the fixture through the socket instead. // // `-p 127.0.0.1::8080` takes an ephemeral host port instead of a fixed // 18080, which two concurrent runs on this daemon cannot both bind. The // actual port is read back with `docker port`. // // The command and the sysctl mirror deploy/docker/compose.yaml, because that // is the invocation this gate exists to prove. The invocation is the sole // configuration authority (milestone-20 ruling 1): the image's bare `run` // grades the database, and a fresh /var/lib/nxdns volume holds no upstream, // so it exits 2 with NoUsableUpstreams before it ever binds a port. var argv: std.ArrayList([]const u8) = .empty; try argv.appendSlice(ctx.arena, &.{ "docker", "create", "--name", names.container }); try argv.appendSlice(ctx.arena, &.{ "--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }) }); switch (topology) { .in_network => |network| try argv.appendSlice(ctx.arena, &.{ "--network", network }), .published => {}, } try argv.appendSlice(ctx.arena, &.{ "-p", "127.0.0.1::8080" }); try argv.appendSlice(ctx.arena, &.{ "--sysctl", "net.ipv4.ip_unprivileged_port_start=0" }); try argv.appendSlice(ctx.arena, &.{ names.image, "run", "--config=/etc/nxdns/config.zon" }); _ = try mustRun(ctx, "smoke", argv.items, .{}); defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", names.container }, .{}) catch {}; _ = try mustRun(ctx, "smoke", &.{ "docker", "cp", "tests/fixtures/container-smoke.zon", ctx.fmt("{s}:/etc/nxdns/config.zon", .{names.container}), }, .{}); _ = try mustRun(ctx, "smoke", &.{ "docker", "start", names.container }, .{}); // Before anything that assumes a live container. `docker port` fails on one // that has already exited, and that failure would otherwise be the whole // diagnosis the log gets — the container's own stderr never reaches CI. if (!try isRunning(ctx, names.container)) { ctx.soft("smoke", "the container exited during startup", .{}); dumpLogs(ctx, names.container); return CheckFailed; } const url = try probeUrl(ctx, names, topology); try probeHealth(ctx, names, url); try stopGracefully(ctx, names.container); } fn probeUrl(ctx: *Ctx, names: Names, topology: Topology) ![]const u8 { const inspected = try runCommand(ctx, &.{ "docker", "inspect", names.container }, .{}); const address = if (inspected.ok()) inspectAddress(ctx.arena, inspected.stdout) else ""; switch (topology) { .in_network => { ctx.note("probing by container name over the job's network (container ip {s})", .{ if (address.len != 0) address else "none", }); return ctx.fmt("http://{s}:8080/api/health", .{names.container}); }, .published => { const ports = try mustRun(ctx, "smoke", &.{ "docker", "port", names.container, "8080/tcp" }, .{}); const port = parsePublishedPort(ports); if (port.len == 0) { ctx.soft("smoke", "8080/tcp is not published on the host: '{s}'", .{ std.mem.trimEnd(u8, ports, "\n"), }); return CheckFailed; } ctx.note("probing the published loopback port {s} (container ip {s})", .{ port, if (address.len != 0) address else "none", }); return ctx.fmt("http://127.0.0.1:{s}/api/health", .{port}); }, } } fn probeHealth(ctx: *Ctx, names: Names, url: []const u8) !void { // curl rather than `std.http.Client`: zig 0.16's client has no connect // timeout that survives a black-holed route, and a probe that can hang // forever defeats the deadline this loop exists to enforce. const started = Io.Clock.awake.now(ctx.io); while (true) { // First, so no attempt is ever launched with the budget already spent. // Checking after the attempt instead let a round that started with less // than a second left run on the one-second floor `attemptSeconds` // applies, and report a pass past the deadline. A budget that expires // during the sleep now fails, which is what "absolute deadline" means. const now = Io.Clock.awake.now(ctx.io); if (deadlineExpired(started.nanoseconds, now.nanoseconds, probe_budget_ns)) { ctx.soft("smoke", "no response from {s} within {d:.1}s", .{ url, elapsedSeconds(started.nanoseconds, now.nanoseconds), }); dumpLogs(ctx, names.container); return CheckFailed; } if (!try isRunning(ctx, names.container)) { ctx.soft("smoke", "the container exited after {d:.1}s, before answering {s}", .{ elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), url, }); dumpLogs(ctx, names.container); return CheckFailed; } const max_time = attemptSeconds( started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds, probe_budget_ns, ); const attempt = try runCommand(ctx, &.{ "curl", "-fsS", "--connect-timeout", "2", "--max-time", ctx.fmt("{d}", .{max_time}), url, }, .{}); if (attempt.ok()) { ctx.pass("smoke", "{s} answered after {d:.1}s: {s}", .{ url, elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), std.mem.trim(u8, attempt.stdout, " \t\r\n"), }); return; } probe_interval.sleep(ctx.io) catch |err| { ctx.soft("smoke", "the probe interval was interrupted: {t}", .{err}); return CheckFailed; }; } } /// SIGTERM has to bring the daemon down cleanly, because that is what every /// container runtime sends and a non-zero code there is a shutdown bug. fn stopGracefully(ctx: *Ctx, container: []const u8) !void { _ = try mustRun(ctx, "smoke-stop", &.{ "docker", "stop", "-t", "30", container }, .{}); const inspected = try mustRun(ctx, "smoke-stop", &.{ "docker", "inspect", "-f", "{{.State.ExitCode}}", container, }, .{}); const text = std.mem.trim(u8, inspected, " \t\r\n"); dumpLogs(ctx, container); const code = std.fmt.parseInt(i32, text, 10) catch { ctx.soft("smoke-stop", "docker reported a non-numeric exit code '{s}'", .{text}); return CheckFailed; }; if (code != 0) { ctx.soft("smoke-stop", "the container exited {d} after SIGTERM, not 0", .{code}); return CheckFailed; } ctx.pass("smoke-stop", "the container exited 0 after SIGTERM", .{}); } fn isRunning(ctx: *Ctx, container: []const u8) !bool { const run = try runCommand(ctx, &.{ "docker", "inspect", "-f", "{{.State.Running}}", container, }, .{}); if (!run.ok()) return false; return std.mem.eql(u8, run.trimmedStdout(), "true"); } fn dumpLogs(ctx: *Ctx, container: []const u8) void { const run = runCommand(ctx, &.{ "docker", "logs", container }, .{}) catch return; ctx.note("docker logs {s}:\n{s}", .{ container, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "objectSuffix carries the run identity and the random bytes" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings("42-2-deadbeef", objectSuffix(arena, "42", "2", "deadbeef")); // A workflow_dispatch on a runner that sets neither still has to produce a // usable name rather than `nxdns:ci--`. try testing.expectEqualStrings("0-1-cafe", objectSuffix(arena, "", "", "cafe")); } test "labelValue is repo/run/attempt" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings("m5r/nxdns/42/2", labelValue(arena, "m5r/nxdns", "42", "2")); try testing.expectEqualStrings("m5r/nxdns/0/1", labelValue(arena, "m5r/nxdns", "", "")); } test "stagePath names the native staged directory" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); try testing.expectEqualStrings( "zig-out/dist/stage/nxdns-0.0.2-x86_64-linux-musl", stagePath(arena_state.allocator(), "0.0.2"), ); } test "inspectNetworks reads none, one and several networks" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "[]").len); try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "not json").len); // A daemon error body is an object, not an array, and must not read as a // network list. try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "{\"message\":\"no such object\"}").len); const one = \\[{"NetworkSettings":{"Networks":{"job-net":{"IPAddress":"172.20.0.3"}}}}] ; const single = inspectNetworks(arena, one); try testing.expectEqual(@as(usize, 1), single.len); try testing.expectEqualStrings("job-net", single[0]); try testing.expectEqualStrings("172.20.0.3", inspectAddress(arena, one)); const two = \\[{"NetworkSettings":{"Networks":{"a":{"IPAddress":"172.20.0.3"},"b":{"IPAddress":"172.21.0.4"}}}}] ; try testing.expectEqual(@as(usize, 2), inspectNetworks(arena, two).len); try testing.expectEqualStrings("", inspectAddress(arena, "[]")); } test "parsePublishedPort takes the port after the last colon" { try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n")); // An IPv6 binding carries colons in the host half. try testing.expectEqualStrings("49154", parsePublishedPort(":::49154\n")); try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n:::49154\n")); try testing.expectEqualStrings("", parsePublishedPort("")); try testing.expectEqualStrings("", parsePublishedPort("\n\n")); } test "preferredNetwork skips the built-in networks" { try testing.expectEqualStrings( "job-net", preferredNetwork(&.{ "bridge", "job-net" }).?, ); try testing.expectEqualStrings("job-net", preferredNetwork(&.{"job-net"}).?); // Nothing user-defined to pick, so the caller falls back and says so. try testing.expect(preferredNetwork(&.{ "bridge", "host", "none" }) == null); try testing.expect(preferredNetwork(&.{}) == null); // The first user-defined name wins, not the first name. try testing.expectEqualStrings("a", preferredNetwork(&.{ "host", "a", "b" }).?); } test "each curl attempt is capped by what is left of the budget" { const start: i96 = 1_000_000_000; // Early on, the ceiling rather than the budget is what binds. try testing.expectEqual(@as(u64, 10), attemptSeconds(start, start, probe_budget_ns)); try testing.expectEqual( @as(u64, 10), attemptSeconds(start, start + 40 * std.time.ns_per_s, probe_budget_ns), ); // Near the deadline the remaining budget binds instead, so one attempt can // no longer outlive the loop that is supposed to bound it. try testing.expectEqual( @as(u64, 5), attemptSeconds(start, start + 55 * std.time.ns_per_s, probe_budget_ns), ); // Never zero: curl reads `--max-time 0` as "no limit". `probeHealth` checks // the deadline before it computes this, so a spent budget no longer reaches // the floor through that loop; the floor stays because the helper must not // hand curl an unbounded attempt whatever the caller does. try testing.expectEqual( @as(u64, 1), attemptSeconds(start, start + 60 * std.time.ns_per_s, probe_budget_ns), ); try testing.expectEqual( @as(u64, 1), attemptSeconds(start, start + 600 * std.time.ns_per_s, probe_budget_ns), ); // A clock that reads backwards must not shorten the attempt either. try testing.expectEqual( @as(u64, 10), attemptSeconds(start, start - std.time.ns_per_s, probe_budget_ns), ); } test "the probe deadline is absolute wall clock" { const start: i96 = 1_000_000_000; try testing.expect(!deadlineExpired(start, start, probe_budget_ns)); try testing.expect(!deadlineExpired(start, start + 59 * std.time.ns_per_s, probe_budget_ns)); try testing.expect(deadlineExpired(start, start + 60 * std.time.ns_per_s, probe_budget_ns)); // A clock that reads backwards must not end the loop instantly. try testing.expect(!deadlineExpired(start, start - std.time.ns_per_s, probe_budget_ns)); try testing.expectApproxEqAbs( @as(f64, 1.5), elapsedSeconds(start, start + 1_500_000_000), 0.001, ); } test "parseZonVersion reads the version through the zon grammar" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const source = \\.{ \\ .name = .nxdns, \\ // .version = "9.9.9" is a comment, and sed did not know that \\ .version = "0.0.2", \\ .minimum_zig_version = "0.16.0", \\ .dependencies = .{}, \\ .paths = .{""}, \\} ; try testing.expectEqualStrings("0.0.2", try parseZonVersion(arena, source)); try testing.expectError(error.ParseZon, parseZonVersion(arena, ".{ .name = .nxdns }")); } test "sha256Hex matches the known empty-input digest" { try testing.expectEqualStrings( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", &sha256Hex(""), ); }