//! 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(""),
    );
}
