Hedronite · Ops Lesson · K8s track · Mon 2026-10-12 · BACKFILL

Nix flake package the Duha API and the Asr web app, with checks

One flake, two packages, one image, and checks that fail for honest reasons.

Lesson Class: Ops (T7 · phase B)
Referent: buildRustPackage · buildNpmPackage · dockerTools
Inputs: Duha + Asr of 2026-10-11
Paired Cert: CKA etcd snapshot and restore
One Lock per Language
Cargo.lock and package-lock.json.
Fake Hash, Real Hash
Copy the got: line.
A Check Must Fail for a Reason
Prove the cause first.
61 ms in the shell, 191 ms in the builder.

One flake, two packages, one image, and checks that fail for honest reasons. Then make them stop failing for dishonest ones.

§I. Frame

Sunday shipped two artifacts: the Tokio health binary (Duha) and a Vite 8 React app (Asr). Neither is deployable yet. This lesson packages both in one flake, adds a Linux OCI image for EKS, and wires nix flake check so CI can gate them.

Done bar: nix build .#api .#web produces both outputs; nix flake check exits 0 for a reason you can defend.

§II. Three techniques

One Lock per Language (named technique). Nix builds offline in a sandbox, so each package needs a lockfile Nix can read up front.

PackageBuilderLock input
apirustPlatform.buildRustPackagecargoLock.lockFile = ./api/Cargo.lock
webbuildNpmPackagepackage-lock.json plus npmDepsHash

The Asr app installs with Bun, but buildNpmPackage reads package-lock.json, so the lab generated one with npm install --package-lock-only. Two lockfiles for one app is a cost; write it down rather than hide it.

Fake Hash, Real Hash (named technique). Put a placeholder in npmDepsHash, build, and copy the hash Nix reports. Real output from the first build:

         specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
            got:    sha256-zh3sKXdOw8aTk3ITUnlY9f5C1k9lO8IuGEBfeiNmeW0=

The hash pins the dependency set. Change package-lock.json and the build fails until you update it.

A Check Must Fail for a Reason (named technique). A red check is useful only if the cause is the code. Read the failure before touching the flake.

§III. The flake

packages = forAll (pkgs: rec {
  api = pkgs.rustPlatform.buildRustPackage {
    pname = "duha-health"; version = "0.1.0";
    src = ./api;
    cargoLock.lockFile = ./api/Cargo.lock;
    cargoBuildFlags = [ "--bin" "health" ];
    doCheck = false;
    meta.mainProgram = "health";
  };
  web = pkgs.buildNpmPackage {
    pname = "asr-web"; version = "0.0.0";
    src = ./web;
    npmDepsHash = "sha256-zh3sKXdOw8aTk3ITUnlY9f5C1k9lO8IuGEBfeiNmeW0=";
    installPhase = "cp -r dist $out";
  };
  default = api;
} // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
  image = pkgs.dockerTools.buildLayeredImage {
    name = "duha-health"; tag = "0.1.0";
    contents = [ self.packages.${pkgs.stdenv.hostPlatform.system}.api ];
    config = { Cmd = [ "/bin/health" ]; Env = [ "HEALTH_ADDR=0.0.0.0:8787" ];
               ExposedPorts."8787/tcp" = { }; };
  };
});

nix build .#web .#api exited 0 on the lab Mac. result holds index.html and assets/; result-1/bin/health is the binary. The image output exists only on Linux systems, so on darwin it is absent from nix flake show (the lab Mac omits the Linux systems). On a Linux builder, nix build .#image gives a tarball for docker load and then ECR. HEALTH_ADDR=0.0.0.0:8787 matters in a pod: the binary binds 127.0.0.1 by default, which a Service cannot reach.

§IV. The check that lied

checks.api-tests reruns the package with doCheck = true. First nix flake check (real):

duha-health> test tests::async_sleep_overlaps_on_one_thread ... FAILED
duha-health> test tests::spawn_blocking_frees_the_one_thread ... FAILED
duha-health> took 191 ms
duha-health> took 198 ms

These tests assert "eight 50 ms async sleeps finish under 100 ms". They pass under cargo test on the same machine (61 ms and 57 ms on Sunday). Under the Nix builder they took about 200 ms. Adding --test-threads=1 did not help (201 ms and 199 ms), so parallel tests were not the cause. The upper bound depends on how the build host schedules timers, and the Nix builder is a different host from your shell.

The two lower-bound tests ("blocking takes at least 400 ms") passed every time. The fix keeps those in the gate and leaves the upper bounds to the dev loop:

checkFlags = [ "--test-threads=1" "--skip" "async_sleep_overlaps" "--skip" "spawn_blocking_frees" ];

Third run: 2 passed; 0 failed; 2 filtered out, nix flake check exit 0. Skipping a test is a decision; the comment in the flake records why.

§V. Drills

  1. buildNpmPackage fails with a hash mismatch after a dependency bump. Fix? (Copy the got: hash into npmDepsHash.)
  2. The pod is Running but the Service gets connection refused. First suspect? (The binary bound 127.0.0.1; set HEALTH_ADDR=0.0.0.0:8787.)
  3. Why is image missing on the lab Mac? (It is gated on hostPlatform.isLinux.)
  4. Which kind of timing assertion is safe in a Nix check: "at least" or "at most"? (At least.)

Close

One Lock per Language, Fake Hash Real Hash, A Check Must Fail for a Reason. Package both apps, gate on checks, and when a check fails, prove the cause before you change the gate.

Related