998 lines
43 KiB
Zig
998 lines
43 KiB
Zig
//! 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(""),
|
|
);
|
|
}
|