OXYGENHUB

Compare main … newer-pipeline

⇄

0 changes in newer-pipeline not in main; 131 the other way. 77 files differ between the two states.

hide whitespace changes
removed .config/nextest.toml +0 −8
1 -
# The CI profile (oxygenflow#82): every test runs, and a JUnit report is
2 -
# written to target/nextest/ci/junit.xml for the hub's test report
3 -
# (the job's `junit` artifact).
4 -
[profile.ci]
5 -
fail-fast = false
6 -
7 -
[profile.ci.junit]
8 -
path = "junit.xml"
removed .flow/checks.wasm +0 −0

No line changes.

added .flow/jobs/layout.sh +5 −0
1 +
# The workspace has the crates and documents it should.
2 +
for f in Cargo.toml docs/m0.md crates/flow-core/src/pipeline.rs crates/flow-init/src/main.rs crates/flow/src/microvm.rs; do
3 +
    test -f "$f" || { echo "missing: $f" >&2; exit 1; }
4 +
    echo "found $f"
5 +
done
added .flow/jobs/size.sh +5 −0
1 +
# Lines of Rust per crate.
2 +
for c in crates/*/; do
3 +
    n=$(find "$c" -name '*.rs' -exec cat {} + | wc -l)
4 +
    printf '%-24s %6d lines\n' "$c" "$n"
5 +
done
added .flow/jobs/summary.sh +1 −0
1 +
echo "$PROJECT: every check passed on this state."
added .flow/jobs/todos.sh +4 −0
1 +
# Notes left in the code, for the log (they do not fail the job).
2 +
n=$(grep -rn -E 'TODO|FIXME|XXX' crates | wc -l)
3 +
echo "$n TODO/FIXME notes in crates/"
4 +
grep -rn -E 'TODO|FIXME|XXX' crates | head -20 || true
modified .flow/pipeline.kdl +15 −100
…
10 unchanged lines
11 11
    network "isolated"
12 12
}
13 13
14 -
// The cheap checks as one WASI module (#96): crates/flow-checks, built by
15 -
// scripts/checks-wasm.sh and committed as .flow/checks.wasm (the fmt job
16 -
// fails if it is not what the source builds). No image and no KVM, so any
17 -
// runner with the WASM tier takes them: the phone, plex, windows-1.
18 -
sandbox "wasm" {
19 -
    runtime "wasm"
20 -
    memory-limit "64MiB"
21 -
}
22 -
23 14
job "layout" {
24 -
    sandbox "wasm"
25 -
    exec ".flow/checks.wasm" "layout" "Cargo.toml" "docs/m0.md" "crates/flow-core/src/pipeline.rs" "crates/flow-init/src/main.rs" "crates/flow/src/microvm.rs"
15 +
    sandbox "alpine"
16 +
    exec "sh" "-eu" ".flow/jobs/layout.sh"
26 17
}
27 18
28 19
job "todos" {
29 -
    sandbox "wasm"
30 -
    scope "crates/**" ".flow/checks.wasm"
31 -
    exec ".flow/checks.wasm" "todos" "crates"
32 -
}
33 -
34 -
// The changelog's shape, and a section for the version Cargo.toml says, so
35 -
// a release never finds its notes missing.
36 -
job "changelog" {
37 -
    sandbox "wasm"
38 -
    scope "CHANGELOG.md" "Cargo.toml" ".flow/checks.wasm"
39 -
    exec ".flow/checks.wasm" "changelog" "CHANGELOG.md" "Cargo.toml"
20 +
    sandbox "alpine"
21 +
    scope "crates/**"
22 +
    exec "sh" "-eu" ".flow/jobs/todos.sh"
40 23
}
41 24
42 25
job "size" {
43 -
    sandbox "wasm"
44 -
    scope "crates/**" ".flow/checks.wasm"
45 -
    exec ".flow/checks.wasm" "size" "crates"
26 +
    sandbox "alpine"
27 +
    scope "crates/**"
28 +
    exec "sh" "-eu" ".flow/jobs/size.sh"
46 29
}
47 30
48 31
job "isolation" {
…
5 unchanged lines
54 37
// vendored crates. oxygen comes from the pinned subrepo in deps/.
55 38
sandbox "rust" {
56 39
    runtime "microvm"
57 -
    rootfs "cas://blob/e52c41a173956b6bacc270eb81208a1b118b652412b84d15fa24708e6f8ec392"
40 +
    rootfs "cas://blob/7705d699a4fc06862e4297b7069396cf3b5b35b1d3f15191c46dc2031af17133"
58 41
    memory-limit "8GiB"
59 42
    cpu-limit 4
60 43
    network "loopback"
…
9 unchanged lines
70 53
71 54
sandbox "rust-small" {
72 55
    runtime "microvm"
73 -
    rootfs "cas://blob/e52c41a173956b6bacc270eb81208a1b118b652412b84d15fa24708e6f8ec392"
56 +
    rootfs "cas://blob/7705d699a4fc06862e4297b7069396cf3b5b35b1d3f15191c46dc2031af17133"
74 57
    memory-limit "1GiB"
75 58
    cpu-limit 1
76 59
    network "isolated"
…
1 unchanged lines
78 61
79 62
job "fmt" {
80 63
    sandbox "rust-small"
81 -
    // This workspace only (--all would reach the pinned oxygen in deps/),
82 -
    // and the checks' committed WASI module is what its source builds (#96).
83 -
    exec "sh" "-euc" "cargo fmt --check -p flow -p flow-core -p flow-init -p flow-casfs -p flow-checks && sh scripts/checks-wasm.sh --check"
64 +
    // This workspace only: --all would reach the pinned oxygen in deps/.
65 +
    exec "cargo" "fmt" "--check" "-p" "flow" "-p" "flow-core" "-p" "flow-init" "-p" "flow-casfs"
84 66
}
85 67
86 68
job "clippy" {
…
3 unchanged lines
90 72
        source "ephemeral://disk"
91 73
        target "/src/target"
92 74
        size "16GiB"
93 -
        // Kept from this job's last passing run (oxygenflow#28): cargo
94 -
        // builds only what changed since.
95 -
        cache "target"
96 -
        key "Cargo.lock"
97 -
    }
98 -
    // One build identity for every CI state, so the cached target stays
99 -
    // fresh from one push to the next (oxygenflow#52).
100 -
    env {
101 -
        OXY_BUILD "ci"
102 75
    }
103 76
    exec "sh" ".flow/warm-target.sh" "cargo" "clippy" "--workspace" "--all-targets" "--locked" "--" "-D" "warnings"
104 77
}
…
5 unchanged lines
110 83
        source "ephemeral://disk"
111 84
        target "/src/target"
112 85
        size "16GiB"
113 -
        // Kept from this job's last passing run (oxygenflow#28): cargo
114 -
        // builds only what changed since.
115 -
        cache "target"
116 -
        key "Cargo.lock"
117 86
    }
118 -
    // One build identity for every CI state, so the cached target stays
119 -
    // fresh from one push to the next (oxygenflow#52).
120 -
    env {
121 -
        OXY_BUILD "ci"
122 -
    }
123 -
    // The tests under cargo-nextest and cargo-llvm-cov (#82): a test report
124 -
    // (JUnit, kept whether or not they pass) and line coverage (LCOV) from
125 -
    // one run, for the hub's Tests and Coverage tabs.
126 -
    exec "sh" ".flow/warm-target.sh" "cargo" "llvm-cov" "nextest" "--workspace" "--locked" "--profile" "ci" "--lcov" "--output-path" "/src/target/lcov.info"
127 -
    output {
128 -
        artifact "junit" {
129 -
            path "/src/target/nextest/ci/junit.xml"
130 -
            when "always"
131 -
        }
132 -
        artifact "coverage" {
133 -
            path "/src/target/lcov.info"
134 -
        }
135 -
    }
136 -
}
137 -
138 -
// Runner builds for releases (oxygenhub#322): flow-linux-ARCH for x86_64,
139 -
// aarch64 and riscv64, which runners that update themselves are offered.
140 -
job "dist" {
141 -
    sandbox "rust"
142 -
    depends-on "fmt"
143 -
    scope "Cargo.toml" "scripts/dist.sh"
144 -
    mount {
145 -
        source "ephemeral://disk"
146 -
        target "/src/target"
147 -
        size "24GiB"
148 -
        cache "target"
149 -
        key "Cargo.lock"
150 -
    }
151 -
    env {
152 -
        DIST_FLAT "/src/target/dist-flat"
153 -
        ZIG_GLOBAL_CACHE_DIR "/src/target/zig-cache"
154 -
    }
155 -
    exec "bash" "scripts/dist.sh"
156 -
    output {
157 -
        artifact "flow-linux-x86_64" {
158 -
            path "/src/target/dist-flat/flow-linux-x86_64"
159 -
        }
160 -
        artifact "flow-linux-aarch64" {
161 -
            path "/src/target/dist-flat/flow-linux-aarch64"
162 -
        }
163 -
        artifact "flow-linux-riscv64" {
164 -
            path "/src/target/dist-flat/flow-linux-riscv64"
165 -
        }
166 -
        artifact "flow-windows-x86_64.exe" {
167 -
            path "/src/target/dist-flat/flow-windows-x86_64.exe"
168 -
        }
169 -
        artifact "SHA256SUMS" {
170 -
            path "/src/target/dist-flat/SHA256SUMS"
171 -
        }
172 -
    }
87 +
    exec "sh" ".flow/warm-target.sh" "cargo" "test" "--workspace" "--locked"
173 88
}
174 89
175 90
job "summary" {
176 -
    sandbox "wasm"
91 +
    sandbox "alpine"
177 92
    depends-on "layout" "todos" "size" "isolation" "clippy" "test"
178 93
    env {
179 94
        PROJECT "OxygenFlow"
180 95
    }
181 -
    exec ".flow/checks.wasm" "summary"
96 +
    exec "sh" "-eu" ".flow/jobs/summary.sh"
182 97
}
removed .oxyattributes +0 −3
1 -
# oxygen#145: two changes that each add a changelog entry in the same
2 -
# place keep both, without a conflict.
3 -
CHANGELOG.md merge=union
modified .oxyignore +0 −4
…
2 unchanged lines
3 3
images/rust/vendor/
4 4
images/rust/*.tar
5 5
images/rust/npm-cache/
6 -
images/android/dl/
7 -
images/android/app/
8 -
images/android/vendor/
9 -
images/android/*.tar
modified CHANGELOG.md +2 −114
1 1
# Changelog
2 2
3 -
0.2.0 is OxygenFlow's first tagged release; until then the runners ran `main`. This file starts on 2026-10-01; earlier history is in `oxy log`.
4 -
5 -
## 0.5.3 (2026-11-04)
6 -
7 -
- **Added: a `changelog` check on the WASM tier.** `.flow/checks.wasm changelog CHANGELOG.md [MANIFEST]` checks the changelog's shape (`## Unreleased` first if at all, then `## X.Y.Z (YYYY-MM-DD)` sections newest first, none empty) and that the version a `Cargo.toml`, `.properties` or `package.json` says has its section, so a release never finds its notes missing. It is a job of OxygenFlow's pipeline, and of OxygenHub's, Oxygen's and the Android app's, any runner with the WASM tier taking it.
8 -
- **Added: `flow deps check` (#101).** Jobs have no network, so every crate a `Cargo.lock` needs must be vendored in the build image at that version. `flow deps check [Cargo.lock]` says whether it is, before the push: each missing crate with the versions the image does have and the `cargo update --precise` that pins one, exit 1. It reads the image's crate list (`--list`), a vendor directory (`--vendor`), or the lock files the image is built from (`--image-lock`, repeatable); inside a job, the image's own. `flow deps list` prints the set.
9 -
- **Changed: the build image publishes its crate list.** `images/rust/build.sh` writes `crates.txt` (`NAME VERSION` a line) beside the tarball and at `/opt/crates.txt` in the image.
10 -
- **What a runner is running, for its host (oxygenhub-android#8):** `flow::runner::running()` lists the jobs this runner is running now (`owner/name #run job`), each from just before it starts until it ends, so the Android app can show them on its card and notification.
11 -
- **`flow` builds and runs on FreeBSD (#99):** FreeBSD 15.1 with pkg's Rust 1.98 builds `flow` with the WASM tier (wasmtime runs there); the `noatime` check is Linux's only, as on Haiku. A FreeBSD machine can be a WASM runner (`flow tiers`: wasm available; the microvm and syscall tiers say they are Linux's).
12 -
13 -
## 0.5.2 (2026-10-29)
14 -
15 -
- **Runner builds through the swarm (#84):** a runner in a swarm fetches an offered build as the image its BLAKE3 names, from its site's peers and the hub together, as it fetches images, and checks its size and BLAKE3 before installing it; over HTTP as before when it is in no swarm, the hub has not shared the build yet, or the swarm cannot get it. The hub shares a build the first time it offers it (OxygenHub 0.9.15), so a site's runners fetch it from each other and the hub serves it about once per site. The build stays in the runner's images for its peers.
16 -
- **`flow` builds on Haiku (#91):** with `--no-default-features`, `flow` now builds natively on Haiku R1/beta6 (HaikuPorts' Rust 1.94): the syscall tier, the guest's job code and the load average are Linux's (and Android's) only, and the `noatime` check takes reads as recorded where statvfs has no such flag. It checks pipelines and runs `flow tiers`, which says no tier runs there yet: the host tier (#90) is next.
17 -
- **The WASM tier can be left out of a build (#91):** the `wasm` cargo feature (on by default) carries wasmtime; `--no-default-features` builds `flow` for systems wasmtime does not run on, such as Haiku, whose runners then offer no WASM tier and say why.
18 -
19 -
## 0.5.1 (2026-10-28)
20 -
21 -
- **Sealed alerts (`flow_core::alert`, oxygenhub-android#16):** what a phone is told about a job is encrypted to an X25519 key the phone made (a fresh key per alert, HKDF-SHA256, ChaCha20-Poly1305), so the push service carrying it sees only ciphertext. `seal` for the hub, `open` and `new_secret`/`public_key` for the phone, from crates already in the tree (curve25519-dalek, ring).
3 +
OxygenFlow has no release yet: the runner on Earth runs `main`. This file starts on 2026-10-01; earlier history is in `oxy log`.
22 4
23 -
- **`flow runner register --ca` keeps the CA in the Flow home (#98):** it wrote the path as given, so `--ca hub-ca.pem` left `ca = "hub-ca.pem"` in runner.toml and a service started elsewhere could not find it. Register now copies the file to the home's `hub-ca.pem` and records that copy's absolute path, and a relative `ca` already in runner.toml is read against the home.
24 -
25 -
- **The cheap checks run as WASM (#96):** `layout`, `todos`, `size` and `summary` are one WASI module, `crates/flow-checks`, built by `scripts/checks-wasm.sh` and committed as `.flow/checks.wasm`, so they need no image or microVM and any runner with the WASM tier takes them (the phone, plex, windows-1); each runs in under 100 ms. The fmt job fails unless the committed module is what the source builds, and the build image has the wasm32-wasip1 target for that.
26 -
27 -
## 0.5.0 (2026-10-27)
28 -
29 -
- **A job's report says where its time went (#35).** The finish report carries `phases` in milliseconds: `image` (fetching its image, when the runner lacked it), `prepare` (its workspace and disks), `run` (the sandbox from start to stop) and `finish` (outputs, caches and receipt), so a slow job shows whether it waited on an image, a fetch or itself.
30 -
- **The swarm reads images in order (#97).** A fetcher took equally rare chunks at random, so a peer read its whole image in random order, a seek per chunk on a spinning disk: plex-1 got 2.3 GiB from earth-1 at about 2 MB/s. Neighbouring chunks now come in runs, shuffled so runners starting together still spread, and the picker no longer rescans what it has: picking a 142,244-chunk image went from 19 s to 0.35 s.
31 -
- **The swarm moves an image nobody pushed (#94).** A runner that serves its peers tells the hub, at start and every ten minutes, which images it holds, and sends the manifest of each the hub keeps none of: the manifest alone, not the image. With a hub that keeps such manifests (oxygenhub#342), runners in the same site fetch the image from it. A fetch told the hub holds no chunks asks peers alone, and fails plainly when none can serve it. Design: docs/jetstream.md §8.1.1.
32 -
- **A WASM job stopped while its module compiles stops (#93).** The time limit or a cancel moved the engine's epoch once; a store made after that set its deadline past it, so the job ran on forever (seen as a test spinning 35 minutes under load). Once stopped, the epoch now moves every tick until the job ends.
33 -
- **A runner claims only what its host can start (#92).** An idle runner told the hub it had no memory limit, so it was offered sandboxes bigger than its machine, fetched their images, and declined them one by one (mars-1, 7 GB, and the 8 GiB jobs). A claim now asks for the budget's room capped at the host's free memory.
34 -
- **`flow rootfs push` waits for the hub to check a big image (oxygenhub#341).** The hub reads the whole image before it accepts the manifest; the manifest request now waits up to an hour, not the 120 s every other request gets.
35 -
- **The Android image (#19).** `images/android`: OpenJDK 17, the Android SDK (command-line tools 23.0, platform 37.0, build-tools 36.0.0, NDK 30.0), Gradle 9.6.0, Rust with the Android targets and cargo-ndk, and the OxygenHub app's crates and Gradle dependencies, so the app builds and tests offline. Each download is checked against its published hash.
36 -
- **`flow runner bench` says when load is not measured (#83):** a Windows runner printed `load 0.00`; it now says `load not measured here`. Its kernel line reads `Windows 10.0.26300` (checked with 0.4.0 in the Windows VM).
37 -
## 0.4.0 (2026-10-26)
38 -
39 -
- **A Windows runner reports its Windows version (#83):** as its kernel fact (`Windows 10.0.26100`, from `RtlGetVersion`), shown on the hub's runner page and in `flow runner bench`. Built and linted for Windows; not yet run there.
40 -
- **`flow runner drain` (#83):** asks the home's running runner to drain, as SIGTERM does on Linux, through a `drain.request` file its loop reads: the way to stop a Windows runner without losing its job. A starting runner clears a stale request.
41 -
- **A Windows runner updates itself (#83, #85):** `flow runner update` on Windows checks a PE build, renames `flow.exe` to `flow.prev.exe` and `flow.new.exe` into its place with `MoveFileExW` (retrying while a virus scanner holds a new file; a running program cannot rename its own file, so a copy of it, `flow __rename`, does), tells the running runner through an `update.request` file in its home (no SIGUSR1 there), and the runner leaves and exits 75 so a Task Scheduler task set to restart on failure starts the new build, which proves itself by reaching the hub. Tried in the Windows VM: install, leave, exit 75, restart, proven. A rollback runs the restored build and ends with it. Spec §11.5.3.
42 -
- **`flow` builds for Windows (#83, step 2 begun):** `cargo build --target x86_64-pc-windows-gnu` gives a `flow.exe` that offers the WASM tier: the syscall tier, the guest helpers and the egress proxy are Linux's only (a Windows build says the syscall tier "runs only on Linux"), cache pruning by reads stays on Linux, and file modes, links and sizes go through a small `portable` module (modes are not kept on Windows). Its claims report free disk (`GetDiskFreeSpaceExW`); load and kernel are not measured there yet, and a Windows runner does not update itself. `dist` builds `flow-windows-x86_64.exe` too. Nothing changes on Linux or Android. Tried in the Windows VM on Earth: `flow.exe tiers` offers wasm, a local `flow run` of a 120-step WASM job passes, and registered as runner `windows-1` (label `windows`) it ran andymccall/windows-runner-demo's job from the hub (run #1). Helpers start from the running binary's path on Windows (`/proc/self/exe` on Linux and Android).
43 -
- **The syscall tier's loopback reaches TCP (#73):** with `network "loopback"`, a job may open IPv4 and IPv6 sockets, which in its own network namespace reach only its `lo`, so a server it starts on 127.0.0.1 answers it (a conformance suite over an emulator's HTTP control port, which skipped every test on narvi-1). With `network "isolated"` sockets other than `AF_UNIX` are still refused.
44 -
- **Runner builds in releases (oxygenhub#322):** a `dist` job (`scripts/dist.sh`, on a change to `Cargo.toml` or the script) builds `flow` stripped for Linux x86_64, aarch64 and riscv64 (glibc 2.28 and later) as `flow-linux-ARCH`, named by the architecture a runner reports, with `SHA256SUMS`: the files a release carries for runners that update themselves.
45 -
- **Runners ask the hub for their update (oxygenhub#322):** once the hub has answered a claim, and every 15 minutes, a runner asks `GET /api/v1/flow/runner/update`; when the hub offers a build, it downloads it (kept in the home's `updates/`), refuses it unless its size and BLAKE3 are those the hub attests, and installs it as `flow runner update` does, leaving within 600 s, or for a dry run checks it and says what would happen. What it did goes back to the hub once per outcome, and a proof or a rollback is told at the next start. `[runner.update] allow = false` refuses and says so; a hub without runner updates (404) is simply asked again later.
46 -
- **The runner update protocol's types (oxygenhub#322):** `UpdateAnswer`, `UpdatePolicy`, `UpdateOffer` and `UpdateReport` in flow-core, for `GET`/`POST /api/v1/flow/runner/update`: the policy that applies to a runner once every level is applied, and the release asset it should run.
47 -
- **`flow runner update --from FILE` (#85):** the runner's side of self-updating runners (docs/jetstream.md §8.2). It checks a new build (this machine's target, `--version`, a tier it can run here), keeps the installed one as `flow.prev`, swaps the new one in with a single rename, and tells the running runner (SIGUSR1, its pid in `runner.pid`) to leave within `--notice` seconds (600 by default): jobs that fit finish, the rest are handed back with their checkpoints, and the unit starts the new build. The new build proves itself by getting an answer from the hub; four starts or ten minutes without one put `flow.prev` back. `--dry-run` changes nothing; `[runner.update] allow = false` in `flow.toml` refuses every update; `updates.log` in the home records each install, proof and rollback. Spec §11.5.3.
48 -
- **A local `flow run` of a WASM or syscall job copies the workspace whole:** it copied into a directory it never made, so a file at the top of the workspace (a README) failed with `No such file or directory`.
49 -
- **Checkpoints: a handed-back job resumes (#77):** `checkpoint "/src/…"` on a job names directories that hold its progress; a runner handing the job back packs and keeps them (`POST …/checkpoint`), and the next attempt's runner restores them before running the same exec, so it carries on rather than starting again. The receipt lists the checkpoint among its inputs. WASM and syscall tiers for now. Spec §11.5.2.
50 -
- **A leaving runner finishes what fits its notice (#76):** a claim carries `expected_ms` (the median of the job's recent passing runs, from OxygenHub 0.9.4); `runner::leave_within(reason, notice)` finishes and reports a job whose expected rest fits the notice and hands back the others, and any still running when it is up. `runner::leave` is the same with no notice. Spec §11.5.2.
51 -
- **OxygenFlow's own CI writes a test report and coverage (#82):** its test job runs under cargo-nextest and cargo-llvm-cov (both in the Rust image, #81), keeping a JUnit report (whether or not the tests pass) and LCOV coverage for the hub's Tests and Coverage tabs; `.config/nextest.toml` has the `ci` profile.
52 -
- **A runner that leaves hands its jobs back (#76):** `runner::leave(reason)` (the runner as a library, as the Android app runs it) stops claiming, stops the jobs it holds, sends what they logged and hands them back with `POST /api/v1/flow/jobs/{id}/release`; the hub (OxygenHub #312) queues them again ahead of new work for another runner, and hands back the jobs of a runner silent for 3 minutes the same way. Spec §11.5.2.
53 -
- **A runner reads its load and kernel without /proc:** `sysinfo` and `uname`, the same numbers; an Android app may not read `/proc/loadavg` or `/proc/sys/kernel/osrelease`, so the phone runner was refused, and logged, at every claim (oxygenhub-android#8).
54 -
- **Built on oxygen's main with #140 and #141:** the runner's store puts objects in place where hard links are refused (Android, so the phone runner can clone), and the `deps/oxygen` pin conflict two replacements in one change left on main is settled with `oxy resolve deps/oxygen`.
55 -
- **The WASM tier (#75):** `runtime "wasm"` runs WASI preview 1 modules with wasmtime 49 on any runner (`flow tiers` offers `wasm` everywhere): no rootfs, no network, the job's files at `/src`, its mounts at their targets, the memory limit enforced by wasmtime. `exec` names a module or a glob of them (`target/wasm32-wasip1/debug/deps/*.wasm` runs each test binary in turn); the receipt names wasmtime's version and every module's hash. The tier phones and browsers will run (docs/handover.md). Secrets and identities are not offered in it yet.
56 -
- **The runner as a library, for the phone (oxygenhub-android#8):** `flow` is now a library and the command built on it, and the whole runner builds for Android (`aarch64-linux-android`); `runner::serve_embedded` and `runner::stop` run it inside an app and drain it. There the WASM tier runs modules in a thread rather than a child process (also with `FLOW_WASM_IN_PROCESS`), streaming their output from pipes, stopping them at the time limit or when the run is cancelled through wasmtime's epoch, and reporting the most memory they asked for. Two portability fixes on the way (the seccomp filter's BPF opcodes, for arm64 and x86_64, and an ioctl request type).
57 -
- **cargo-nextest in the Rust image (#81):** for JUnit test reports beside `cargo llvm-cov`'s coverage.
58 -
59 -
## 0.3.1 (2026-10-25)
5 +
## Unreleased
60 6
61 -
- **Runners read repositories with oxy 0.6.0:** a tag withdrawn on the hub (oxygenhub#301) is dropped when a runner fetches, a keyless agent's changes verify in a repository that pins its owner's newest key (oxygen#135) or has never seen it (oxygen#134), and shallow clones of big repositories work (oxygen#137). The runner itself is unchanged.
62 -
63 -
## 0.3.0 (2026-10-21)
64 -
65 -
- **A runner's own metrics (#35):** `metrics = "127.0.0.1:9464"` under `[runner]` serves Prometheus text at `/metrics`: claims, declines, finished jobs by result, the seconds they ran, the jobs running now, version and uptime, optionally behind `metrics_token`. Off unless set.
66 -
- **Built with oxy 0.5.1.** A runner reads repositories whose release tags a keyless agent made (oxygen#129), and declares `certified-tags` to the hub (oxygen#131).
67 -
- **Build caches are pruned before they are kept (#72, spec §5.4).** A kept cache no longer grows until the job's disk is full. When a passing job's cache holds more than `cache_prune_percent` of its mount's size (runner config, default 60), the files the job did not read, write or share a cargo unit hash with are removed, oldest first, until it is under; one still over is not kept, and the next run starts empty. A microVM's guest init prunes its disk before it powers off; the runner prunes the syscall tier's directories. The job's log says how much was kept and pruned, or why the cache was not kept.
68 -
69 -
## 0.2.0 (2026-10-20)
70 -
71 -
- **Approval gates and environments (#57, #59, oxygenhub#235, spec §5.3.2, §5.3.3).** `approval { by "maintain"; others-only; expires "24h" }` on a job makes the hub hold it until a person with that role (write, maintain by default, or admin) approves it, and with `others-only` not the person who pushed. `approval { sealed #true }` asks for an approval signed with the approver's own key (`oxy seal --flow`), the default for a job that names an `environment "prod"`; the hub names the environment in the job's identity tokens only once it is approved. Both are part of the job's hash.
72 -
73 -
- **Artifacts between jobs (#58, spec §5.3.1).** `input { artifact "x" from "job"; path "/src/x" }` takes an artifact from a job named in `depends-on`, up to 8 per job. The runner fetches each from the hub, refuses the job when the blob is not the one the producer's receipt names, writes it at its path and lists it in the receipt's inputs.
74 -
75 -
- **Allow-listed egress (#55, spec §4.3.1).** `network "egress" { allow "host" "*.example.com" }` lets a microVM job reach those names on port 443 and nothing else. The VM still has no network device: a proxy on the runner takes only `CONNECT host:443` for an allowed name, refuses non-public addresses and logs every decision in the job's log; the job gets `HTTPS_PROXY`. Off unless a runner sets `egress = true` under `[runner]`; such jobs go only to runners that do.
76 -
77 -
- **Receipts signed with a key that dies with the job (oxygenhub#241).** With `keyless = true` under `[runner]`, the runner makes a fresh key just before signing a job's receipt, has the hub certify it with the job's token, signs, attaches the certificate and drops the key. If the hub cannot certify, the runner's own key signs and the job's log says why.
78 -
79 -
- **A pipeline may lower its jobs' priority (oxygenhub#244).** `priority "low"` (or `"normal"`) on a job, or at the top of the file for every job, puts it behind other work in the hub's queue. `high` and `urgent` are refused with the reason: they are the hub's to give.
80 -
81 -
- **Triggers (#24).** `on { push; tag; manual; schedule }` for the pipeline and each job: `push` and `tag` take globs, `manual` is Run pipeline, and `schedule` takes five-field cron lines. A job's own `on` wins over the pipeline's; without one a job runs on every push and by hand, as before.
82 -
83 -
- **Runner labels (oxygenhub#41).** A sandbox or a job may say `runs-on "LABEL"`; a job goes only to a runner that has every label it and its sandbox name (`flow runner register --label`).
84 -
85 -
- **Images by hash (#39, #44).** `flow rootfs push HASH` sends an image to a hub, only the chunks it lacks, and `flow rootfs pull HASH` fetches one. A runner whose job's image is missing fetches it before starting, saying so in the job's log, and keeps the chunks, so a rebuilt image costs only its changed chunks. Both commands use the login `oh auth login` saved when `OXY_TOKEN` is not set.
86 -
87 -
- **Runners say what they have and what jobs used (#40, #41).** Each claim carries the runner's free disk, load, kernel and a speed class from a short benchmark, taken at start and every 15 minutes while idle (`flow runner bench` prints them). A job's finish carries its peak disk and memory, for the hub to learn what its next run needs.
88 -
89 -
- **Reports (oxygenhub#127).** `report "NAME" { path "…"; when "…" }` in a job's output block uploads a directory holding `report.toml`, file by file with a manifest, within 2,000 files and 512 MiB; the receipt names the manifest.
90 -
91 -
- **Artifacts from a failing job (#37).** An artifact with `when "always"` is uploaded when the job fails or times out too, so a test report survives the failure it describes. It never goes in the receipt of a job that did not pass.
92 -
93 -
- **A cancelled run stops its jobs (#32).** A job whose run is cancelled is stopped: its microVM ends, or never boots if the job was still being prepared, and nothing more is sent.
94 -
95 -
- **The OpenTofu image (#56).** `images/tofu`: OpenTofu 1.13.1 with a provider mirror (hashicorp/google, cloudflare/cloudflare), TFLint with its Google ruleset, and curl, jq, xz, sfdisk and debugfs, for deploy jobs. Providers come only from the mirror, so `tofu init` and linting need no network.
96 -
97 -
- **Coverage in the Rust build image (oxygenhub#113).** `cargo-llvm-cov` and the toolchain's `llvm-tools` are on the guest's `PATH`, so a job can write an LCOV report for the hub's Coverage tab.
98 -
99 -
- **The syscall tier keeps its build caches too (#70, spec §5.4).** A cached `ephemeral://disk` mount in a `runtime "syscall"` job now starts from the directory a passing job of the same slot kept, and goes back into the runner's pool when the job passes, with the same order, bookmark rules, `cache_gb` eviction and claim reporting as the microVM tier's disks, so jobs go where their cache is on boards without KVM as well. Nothing kept is writable by others or set-id, and the pool is the runner's alone.
100 -
101 -
- **The build cache pays off (#51, #52, spec §5.4).** A cached disk keeps the content hashes and times of the sources it was built from, and the next job gives every unchanged file its old time back, so cargo rebuilds only the crates that changed instead of every path crate. Claims list the caches a runner holds, and the hub sends a job to the runner holding its cache, as it does for images.
102 -
103 -
- **Restarts cost no job (#49, #50, spec §11.5.1).** A job's uploads and declines wait out a hub that is away for up to three minutes (a deploy restarting it), through refused connections as well as server errors, and log lines it could not send go with the next batch instead of being dropped. A runner told to stop drains: it claims nothing more, finishes and reports the jobs it holds, then exits; a second signal stops it at once. With `KillMode=mixed` and a `TimeoutStopSec` longer than the longest job in its unit, deploying a runner no longer has to wait for it to be idle.
104 -
105 -
- **A build cache (#28, spec §5.4).** A disk mount may name a `cache` and the `key` files it depends on, such as `Cargo.lock`. The runner keeps passing jobs' disks and starts the next job of the same repository, job and bookmark from one, so builds carry their compiled dependencies forward; another bookmark copies it rather than taking it. Failing jobs' disks are never kept, the receipt names what a disk started from, and the pool stays under `cache_gb` (default 64 GiB).
106 -
107 -
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers talk over TLS: each runner keeps its own certificate, the hub hands its fingerprint on with the peer, and a fetcher trusts no other. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
108 -
109 7
- **A runner declines a job it cannot run (#36, spec §11.5).** Before fetching anything it checks the tier, the architecture, that the image is there and verifies, and that the sandbox's memory is free. If any check fails it hands the job back to the hub with the reason instead of running and failing it.
110 8
111 9
- **The syscall tier (#36, docs/m0.md §3).** `runtime "syscall"` jobs run without KVM, so boards such as the OrangePi RV2 can take them. The sandbox uses user, mount, PID, network, IPC and UTS namespaces on the image's unpacked tree, read-only. Landlock limits writes to `/src` and `/tmp`, and a seccomp filter refuses network sockets, tracing, mounts, BPF, keys, modules and io_uring. A delegated cgroup v2 group enforces the CPU and memory limits, and a job killed for memory is told so. A runner offers the tier only where all of this works. `flow tiers` shows which tiers a machine can run and why the others cannot, and `flow run` runs syscall jobs too.
…
11 unchanged lines
123 21
- **Variables from the hub (oxygenhub#102).** A claim carries `env`, the variables the hub holds for the repository; they join the job's environment under `FLOW_*` and the pipeline's own `env`. `flow run --var NAME=VALUE` does the same locally.
124 22
125 23
- **A job says how long it may run (#23).** `timeout "90m"` on a job, or on a sandbox for its jobs (hours, minutes, seconds; a minute to a day). The hub caps it at the site's maximum.
126 -
127 -
- **A busy runner still says it is there (oxygenhub#243).** A runner whose slots are full, or that is draining, claims nothing, so the hub showed it offline through a quiet job. It now sends a heartbeat every 30 s while it cannot claim.
128 -
129 -
- **One slow fetch no longer holds every job on a runner (#63).** Workspaces were prepared under one runner-wide lock, so a job fetching a large repository from a bucket held all the others (one waited 819 s, and another's token expired before its fetch began). Fetches and exports now take only their repository's lock, and the job's log says how long it waited and how long fetching took.
130 -
131 -
- **An image with no recorded architecture is read, not trusted (#53).** Such an image (imported before architectures were recorded, or fetched from a hub that did not know it) matched every runner, so a riscv64 runner could be handed an x86_64 image and fail. The runner now reads the architecture from the image's shell once, records it, reports it and declines a job for another architecture.
132 -
133 -
- **`flow check` says when a pipeline needs a newer flow (#43).** A pipeline that names a node, output or event this flow does not know was called "not valid". `flow check` and `flow run` now say it uses something this version of flow does not know, and that a pipeline written for a newer OxygenFlow needs a newer flow.
134 -
135 -
- **Pushing a large image works against any hub (oxygenhub#147).** `flow rootfs push` asked about 50,000 chunks at once, over a hub's 2 MB body limit, so an 11 GB image failed with 413. It now asks 20,000 at a time.
136 24
137 25
- **A pinned file is re-read from the disk before its hash is called wrong (#22).**
138 26
…
7 unchanged lines
modified Cargo.lock +28 −1513
…
2 unchanged lines
3 3
version = 4
4 4
5 5
[[package]]
6 -
name = "addr2line"
7 -
version = "0.26.1"
8 -
source = "registry+https://github.com/rust-lang/crates.io-index"
9 -
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
10 -
dependencies = [
11 -
 "gimli",
12 -
]
13 -
14 -
[[package]]
15 6
name = "adler2"
16 7
version = "2.0.1"
17 8
source = "registry+https://github.com/rust-lang/crates.io-index"
…
9 unchanged lines
27 18
]
28 19
29 20
[[package]]
30 -
name = "allocator-api2"
31 -
version = "0.2.21"
32 -
source = "registry+https://github.com/rust-lang/crates.io-index"
33 -
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
34 -
35 -
[[package]]
36 -
name = "ambient-authority"
37 -
version = "0.0.2"
38 -
source = "registry+https://github.com/rust-lang/crates.io-index"
39 -
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
40 -
41 -
[[package]]
42 21
name = "anstream"
43 22
version = "1.0.0"
44 23
source = "registry+https://github.com/rust-lang/crates.io-index"
…
50 unchanged lines
95 74
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
96 75
97 76
[[package]]
98 -
name = "arbitrary"
99 -
version = "1.5.0"
100 -
source = "registry+https://github.com/rust-lang/crates.io-index"
101 -
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
102 -
103 -
[[package]]
104 77
name = "arrayvec"
105 78
version = "0.7.8"
106 79
source = "registry+https://github.com/rust-lang/crates.io-index"
107 80
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
108 -
109 -
[[package]]
110 -
name = "asn1-rs"
111 -
version = "0.7.2"
112 -
source = "registry+https://github.com/rust-lang/crates.io-index"
113 -
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
114 -
dependencies = [
115 -
 "asn1-rs-derive",
116 -
 "asn1-rs-impl",
117 -
 "displaydoc",
118 -
 "nom",
119 -
 "num-traits",
120 -
 "rusticata-macros",
121 -
 "thiserror 2.0.21",
122 -
 "time",
123 -
]
124 81
125 82
[[package]]
126 -
name = "asn1-rs-derive"
127 -
version = "0.6.0"
128 -
source = "registry+https://github.com/rust-lang/crates.io-index"
129 -
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
130 -
dependencies = [
131 -
 "proc-macro2",
132 -
 "quote",
133 -
 "syn 2.0.119",
134 -
 "synstructure 0.13.2",
135 -
]
136 -
137 -
[[package]]
138 -
name = "asn1-rs-impl"
139 -
version = "0.2.0"
140 -
source = "registry+https://github.com/rust-lang/crates.io-index"
141 -
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
142 -
dependencies = [
143 -
 "proc-macro2",
144 -
 "quote",
145 -
 "syn 2.0.119",
146 -
]
147 -
148 -
[[package]]
149 -
name = "async-trait"
150 -
version = "0.1.92"
151 -
source = "registry+https://github.com/rust-lang/crates.io-index"
152 -
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
153 -
dependencies = [
154 -
 "proc-macro2",
155 -
 "quote",
156 -
 "syn 3.0.6",
157 -
]
158 -
159 -
[[package]]
160 83
name = "autocfg"
161 84
version = "1.5.1"
162 85
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
167 90
version = "0.22.1"
168 91
source = "registry+https://github.com/rust-lang/crates.io-index"
169 92
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
170 -
171 -
[[package]]
172 -
name = "base64"
173 -
version = "0.23.1"
174 -
source = "registry+https://github.com/rust-lang/crates.io-index"
175 -
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
176 93
177 94
[[package]]
178 95
name = "base64ct"
179 96
version = "1.8.3"
180 97
source = "registry+https://github.com/rust-lang/crates.io-index"
181 98
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
182 -
183 -
[[package]]
184 -
name = "bit-vec"
185 -
version = "0.9.1"
186 -
source = "registry+https://github.com/rust-lang/crates.io-index"
187 -
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
188 -
dependencies = [
189 -
 "serde",
190 -
]
191 99
192 100
[[package]]
193 101
name = "bitflags"
…
11 unchanged lines
205 113
 "cc",
206 114
 "cfg-if",
207 115
 "constant_time_eq",
208 -
 "cpufeatures 0.3.1",
116 +
 "cpufeatures",
209 117
 "rayon-core",
210 118
]
211 119
212 120
[[package]]
213 121
name = "block-buffer"
214 -
version = "0.10.4"
215 -
source = "registry+https://github.com/rust-lang/crates.io-index"
216 -
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
217 -
dependencies = [
218 -
 "generic-array",
219 -
]
220 -
221 -
[[package]]
222 -
name = "block-buffer"
223 122
version = "0.12.1"
224 123
source = "registry+https://github.com/rust-lang/crates.io-index"
225 124
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
…
9 unchanged lines
235 134
dependencies = [
236 135
 "memchr",
237 136
 "serde_core",
238 -
]
239 -
240 -
[[package]]
241 -
name = "bumpalo"
242 -
version = "3.20.3"
243 -
source = "registry+https://github.com/rust-lang/crates.io-index"
244 -
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
245 -
dependencies = [
246 -
 "allocator-api2",
247 -
]
248 -
249 -
[[package]]
250 -
name = "bytes"
251 -
version = "1.12.1"
252 -
source = "registry+https://github.com/rust-lang/crates.io-index"
253 -
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
254 -
255 -
[[package]]
256 -
name = "cap-primitives"
257 -
version = "4.0.3"
258 -
source = "registry+https://github.com/rust-lang/crates.io-index"
259 -
checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18"
260 -
dependencies = [
261 -
 "ambient-authority",
262 -
 "fs-set-times",
263 -
 "io-extras",
264 -
 "io-lifetimes 3.0.1",
265 -
 "ipnet",
266 -
 "maybe-owned",
267 -
 "rustix",
268 -
 "rustix-linux-procfs",
269 -
 "windows-sys 0.61.2",
270 -
 "winx",
271 137
]
272 138
273 139
[[package]]
…
19 unchanged lines
293 159
version = "0.2.2"
294 160
source = "registry+https://github.com/rust-lang/crates.io-index"
295 161
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
296 -
297 -
[[package]]
298 -
name = "chacha20"
299 -
version = "0.10.2"
300 -
source = "registry+https://github.com/rust-lang/crates.io-index"
301 -
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
302 -
dependencies = [
303 -
 "cfg-if",
304 -
 "cpufeatures 0.3.1",
305 -
 "rand_core 0.10.1",
306 -
]
307 162
308 163
[[package]]
309 164
name = "clap"
…
40 unchanged lines
350 205
version = "0.5.4"
351 206
source = "registry+https://github.com/rust-lang/crates.io-index"
352 207
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
353 -
354 -
[[package]]
355 -
name = "cobs"
356 -
version = "0.3.0"
357 -
source = "registry+https://github.com/rust-lang/crates.io-index"
358 -
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
359 -
dependencies = [
360 -
 "thiserror 2.0.21",
361 -
]
362 208
363 209
[[package]]
364 210
name = "colorchoice"
…
12 unchanged lines
377 223
version = "0.4.2"
378 224
source = "registry+https://github.com/rust-lang/crates.io-index"
379 225
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
380 -
381 -
[[package]]
382 -
name = "core_detect"
383 -
version = "1.0.0"
384 -
source = "registry+https://github.com/rust-lang/crates.io-index"
385 -
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
386 -
387 -
[[package]]
388 -
name = "cpp_demangle"
389 -
version = "0.5.1"
390 -
source = "registry+https://github.com/rust-lang/crates.io-index"
391 -
checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def"
392 -
dependencies = [
393 -
 "cfg-if",
394 -
]
395 -
396 -
[[package]]
397 -
name = "cpufeatures"
398 -
version = "0.2.17"
399 -
source = "registry+https://github.com/rust-lang/crates.io-index"
400 -
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
401 -
dependencies = [
402 -
 "libc",
403 -
]
404 226
405 227
[[package]]
406 228
name = "cpufeatures"
407 229
version = "0.3.1"
408 230
source = "registry+https://github.com/rust-lang/crates.io-index"
409 231
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
410 -
dependencies = [
411 -
 "libc",
412 -
]
413 -
414 -
[[package]]
415 -
name = "cranelift-assembler-x64"
416 -
version = "0.136.4"
417 -
source = "registry+https://github.com/rust-lang/crates.io-index"
418 -
checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254"
419 -
dependencies = [
420 -
 "cranelift-assembler-x64-meta",
421 -
]
422 -
423 -
[[package]]
424 -
name = "cranelift-assembler-x64-meta"
425 -
version = "0.136.4"
426 -
source = "registry+https://github.com/rust-lang/crates.io-index"
427 -
checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb"
428 232
dependencies = [
429 -
 "cranelift-srcgen",
430 -
]
431 -
432 -
[[package]]
433 -
name = "cranelift-bforest"
434 -
version = "0.136.4"
435 -
source = "registry+https://github.com/rust-lang/crates.io-index"
436 -
checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1"
437 -
dependencies = [
438 -
 "cranelift-entity",
439 -
 "wasmtime-internal-core",
440 -
]
441 -
442 -
[[package]]
443 -
name = "cranelift-bitset"
444 -
version = "0.136.4"
445 -
source = "registry+https://github.com/rust-lang/crates.io-index"
446 -
checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299"
447 -
dependencies = [
448 -
 "serde",
449 -
 "serde_derive",
450 -
 "wasmtime-internal-core",
451 -
]
452 -
453 -
[[package]]
454 -
name = "cranelift-codegen"
455 -
version = "0.136.4"
456 -
source = "registry+https://github.com/rust-lang/crates.io-index"
457 -
checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b"
458 -
dependencies = [
459 -
 "bumpalo",
460 -
 "cranelift-assembler-x64",
461 -
 "cranelift-bforest",
462 -
 "cranelift-bitset",
463 -
 "cranelift-codegen-meta",
464 -
 "cranelift-codegen-shared",
465 -
 "cranelift-control",
466 -
 "cranelift-entity",
467 -
 "cranelift-isle",
468 -
 "gimli",
469 -
 "hashbrown 0.17.1",
470 -
 "libm",
471 -
 "log",
472 -
 "postcard",
473 -
 "pulley-interpreter",
474 -
 "regalloc2",
475 -
 "rustc-hash",
476 -
 "serde",
477 -
 "serde_derive",
478 -
 "sha2 0.10.9",
479 -
 "smallvec",
480 -
 "target-lexicon",
481 -
 "wasmtime-internal-core",
482 -
]
483 -
484 -
[[package]]
485 -
name = "cranelift-codegen-meta"
486 -
version = "0.136.4"
487 -
source = "registry+https://github.com/rust-lang/crates.io-index"
488 -
checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6"
489 -
dependencies = [
490 -
 "cranelift-assembler-x64-meta",
491 -
 "cranelift-codegen-shared",
492 -
 "cranelift-srcgen",
493 -
 "heck",
494 -
 "pulley-interpreter",
495 -
]
496 -
497 -
[[package]]
498 -
name = "cranelift-codegen-shared"
499 -
version = "0.136.4"
500 -
source = "registry+https://github.com/rust-lang/crates.io-index"
501 -
checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81"
502 -
503 -
[[package]]
504 -
name = "cranelift-control"
505 -
version = "0.136.4"
506 -
source = "registry+https://github.com/rust-lang/crates.io-index"
507 -
checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58"
508 -
dependencies = [
509 -
 "arbitrary",
510 -
]
511 -
512 -
[[package]]
513 -
name = "cranelift-entity"
514 -
version = "0.136.4"
515 -
source = "registry+https://github.com/rust-lang/crates.io-index"
516 -
checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39"
517 -
dependencies = [
518 -
 "cranelift-bitset",
519 -
 "serde",
520 -
 "serde_derive",
521 -
 "wasmtime-internal-core",
522 -
]
523 -
524 -
[[package]]
525 -
name = "cranelift-frontend"
526 -
version = "0.136.4"
527 -
source = "registry+https://github.com/rust-lang/crates.io-index"
528 -
checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de"
529 -
dependencies = [
530 -
 "cranelift-codegen",
531 -
 "hashbrown 0.17.1",
532 -
 "log",
533 -
 "smallvec",
534 -
 "target-lexicon",
535 -
]
536 -
537 -
[[package]]
538 -
name = "cranelift-isle"
539 -
version = "0.136.4"
540 -
source = "registry+https://github.com/rust-lang/crates.io-index"
541 -
checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05"
542 -
543 -
[[package]]
544 -
name = "cranelift-native"
545 -
version = "0.136.4"
546 -
source = "registry+https://github.com/rust-lang/crates.io-index"
547 -
checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1"
548 -
dependencies = [
549 -
 "cranelift-codegen",
550 233
 "libc",
551 -
 "target-lexicon",
552 234
]
553 -
554 -
[[package]]
555 -
name = "cranelift-srcgen"
556 -
version = "0.136.4"
557 -
source = "registry+https://github.com/rust-lang/crates.io-index"
558 -
checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769"
559 235
560 236
[[package]]
561 237
name = "crc32fast"
…
31 unchanged lines
593 269
594 270
[[package]]
595 271
name = "crypto-common"
596 -
version = "0.1.7"
597 -
source = "registry+https://github.com/rust-lang/crates.io-index"
598 -
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
599 -
dependencies = [
600 -
 "generic-array",
601 -
 "typenum",
602 -
]
603 -
604 -
[[package]]
605 -
name = "crypto-common"
606 272
version = "0.2.2"
607 273
source = "registry+https://github.com/rust-lang/crates.io-index"
608 274
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
…
19 unchanged lines
628 294
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
629 295
dependencies = [
630 296
 "cfg-if",
631 -
 "cpufeatures 0.3.1",
297 +
 "cpufeatures",
632 298
 "curve25519-dalek-derive",
633 -
 "digest 0.11.3",
299 +
 "digest",
634 300
 "fiat-crypto",
635 301
 "rustc_version",
636 302
 "subtle",
…
10 unchanged lines
647 313
 "quote",
648 314
 "syn 2.0.119",
649 315
]
650 -
651 -
[[package]]
652 -
name = "data-encoding"
653 -
version = "2.11.1"
654 -
source = "registry+https://github.com/rust-lang/crates.io-index"
655 -
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
656 316
657 317
[[package]]
658 318
name = "der"
…
3 unchanged lines
662 322
dependencies = [
663 323
 "const-oid",
664 324
 "zeroize",
665 -
]
666 -
667 -
[[package]]
668 -
name = "der-parser"
669 -
version = "10.0.0"
670 -
source = "registry+https://github.com/rust-lang/crates.io-index"
671 -
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
672 -
dependencies = [
673 -
 "asn1-rs",
674 -
 "displaydoc",
675 -
 "nom",
676 -
 "num-bigint",
677 -
 "num-traits",
678 -
 "rusticata-macros",
679 -
]
680 -
681 -
[[package]]
682 -
name = "deranged"
683 -
version = "0.5.8"
684 -
source = "registry+https://github.com/rust-lang/crates.io-index"
685 -
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
686 -
687 -
[[package]]
688 -
name = "digest"
689 -
version = "0.10.7"
690 -
source = "registry+https://github.com/rust-lang/crates.io-index"
691 -
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
692 -
dependencies = [
693 -
 "block-buffer 0.10.4",
694 -
 "crypto-common 0.1.7",
695 325
]
696 326
697 327
[[package]]
…
2 unchanged lines
700 330
source = "registry+https://github.com/rust-lang/crates.io-index"
701 331
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
702 332
dependencies = [
703 -
 "block-buffer 0.12.1",
704 -
 "const-oid",
705 -
 "crypto-common 0.2.2",
333 +
 "block-buffer",
334 +
 "crypto-common",
706 335
]
707 336
708 337
[[package]]
…
24 unchanged lines
733 362
dependencies = [
734 363
 "curve25519-dalek",
735 364
 "ed25519",
736 -
 "sha2 0.11.0",
365 +
 "sha2",
737 366
 "subtle",
738 367
 "zeroize",
739 368
]
…
5 unchanged lines
745 374
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
746 375
747 376
[[package]]
748 -
name = "embedded-io"
749 -
version = "0.4.0"
750 -
source = "registry+https://github.com/rust-lang/crates.io-index"
751 -
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
752 -
753 -
[[package]]
754 -
name = "embedded-io"
755 -
version = "0.6.1"
756 -
source = "registry+https://github.com/rust-lang/crates.io-index"
757 -
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
758 -
759 -
[[package]]
760 -
name = "encoding_rs"
761 -
version = "0.8.42"
762 -
source = "registry+https://github.com/rust-lang/crates.io-index"
763 -
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
764 -
dependencies = [
765 -
 "cfg-if",
766 -
 "core_detect",
767 -
 "multiversion_no_op",
768 -
 "rustversion",
769 -
 "scopeguard",
770 -
 "simdutf8",
771 -
]
772 -
773 -
[[package]]
774 377
name = "equivalent"
775 378
version = "1.0.2"
776 379
source = "registry+https://github.com/rust-lang/crates.io-index"
…
40 unchanged lines
817 420
818 421
[[package]]
819 422
name = "flow"
820 -
version = "0.5.3"
423 +
version = "0.1.0"
821 424
dependencies = [
822 425
 "anyhow",
823 426
 "blake3",
824 427
 "clap",
825 428
 "flow-core",
826 -
 "flow-init",
827 429
 "hex",
828 430
 "ignore",
829 431
 "jet",
830 432
 "libc",
831 433
 "oxy-core",
832 -
 "rcgen",
833 -
 "rustls",
834 434
 "serde",
835 435
 "serde_json",
836 436
 "tempfile",
837 437
 "toml",
838 438
 "ureq",
839 -
 "wasmtime",
840 -
 "wasmtime-wasi",
841 -
 "wat",
842 -
 "windows-sys 0.61.2",
843 439
]
844 440
845 441
[[package]]
846 442
name = "flow-casfs"
847 -
version = "0.5.3"
443 +
version = "0.1.0"
848 444
dependencies = [
849 445
 "anyhow",
850 446
 "fuser",
851 447
 "libc",
852 448
 "oxy-core",
853 -
 "tempfile",
854 -
]
855 -
856 -
[[package]]
857 -
name = "flow-checks"
858 -
version = "0.5.3"
859 -
dependencies = [
860 449
 "tempfile",
861 450
]
862 451
863 452
[[package]]
864 453
name = "flow-core"
865 -
version = "0.5.3"
454 +
version = "0.1.0"
866 455
dependencies = [
867 456
 "blake3",
868 -
 "curve25519-dalek",
869 457
 "globset",
870 458
 "hex",
871 459
 "kdl",
872 460
 "miette",
873 461
 "oxy-core",
874 -
 "ring",
875 462
 "serde",
876 463
 "serde_json",
877 -
 "thiserror 1.0.69",
464 +
 "thiserror",
878 465
]
879 466
880 467
[[package]]
881 468
name = "flow-init"
882 -
version = "0.5.3"
469 +
version = "0.1.0"
883 470
dependencies = [
884 471
 "libc",
885 472
 "serde",
886 473
 "serde_json",
887 -
 "tempfile",
888 474
]
889 -
890 -
[[package]]
891 -
name = "fnv"
892 -
version = "1.0.7"
893 -
source = "registry+https://github.com/rust-lang/crates.io-index"
894 -
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
895 -
896 -
[[package]]
897 -
name = "foldhash"
898 -
version = "0.2.0"
899 -
source = "registry+https://github.com/rust-lang/crates.io-index"
900 -
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
901 475
902 476
[[package]]
903 477
name = "form_urlencoded"
…
2 unchanged lines
906 480
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
907 481
dependencies = [
908 482
 "percent-encoding",
909 -
]
910 -
911 -
[[package]]
912 -
name = "fs-set-times"
913 -
version = "0.20.3"
914 -
source = "registry+https://github.com/rust-lang/crates.io-index"
915 -
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
916 -
dependencies = [
917 -
 "io-lifetimes 2.0.4",
918 -
 "rustix",
919 -
 "windows-sys 0.52.0",
920 483
]
921 484
922 485
[[package]]
…
9 unchanged lines
932 495
 "page_size",
933 496
 "smallvec",
934 497
 "zerocopy",
935 -
]
936 -
937 -
[[package]]
938 -
name = "futures"
939 -
version = "0.3.34"
940 -
source = "registry+https://github.com/rust-lang/crates.io-index"
941 -
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
942 -
dependencies = [
943 -
 "futures-channel",
944 -
 "futures-core",
945 -
 "futures-io",
946 -
 "futures-sink",
947 -
 "futures-task",
948 -
 "futures-util",
949 -
]
950 -
951 -
[[package]]
952 -
name = "futures-channel"
953 -
version = "0.3.34"
954 -
source = "registry+https://github.com/rust-lang/crates.io-index"
955 -
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
956 -
dependencies = [
957 -
 "futures-core",
958 -
 "futures-sink",
959 498
]
960 499
961 500
[[package]]
962 -
name = "futures-core"
963 -
version = "0.3.34"
964 -
source = "registry+https://github.com/rust-lang/crates.io-index"
965 -
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
966 -
967 -
[[package]]
968 -
name = "futures-io"
969 -
version = "0.3.34"
970 -
source = "registry+https://github.com/rust-lang/crates.io-index"
971 -
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
972 -
973 -
[[package]]
974 -
name = "futures-sink"
975 -
version = "0.3.34"
976 -
source = "registry+https://github.com/rust-lang/crates.io-index"
977 -
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
978 -
979 -
[[package]]
980 -
name = "futures-task"
981 -
version = "0.3.34"
982 -
source = "registry+https://github.com/rust-lang/crates.io-index"
983 -
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
984 -
985 -
[[package]]
986 -
name = "futures-util"
987 -
version = "0.3.34"
988 -
source = "registry+https://github.com/rust-lang/crates.io-index"
989 -
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
990 -
dependencies = [
991 -
 "futures-channel",
992 -
 "futures-core",
993 -
 "futures-io",
994 -
 "futures-sink",
995 -
 "futures-task",
996 -
 "memchr",
997 -
 "pin-project-lite",
998 -
 "slab",
999 -
]
1000 -
1001 -
[[package]]
1002 -
name = "generic-array"
1003 -
version = "0.14.7"
1004 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1005 -
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1006 -
dependencies = [
1007 -
 "typenum",
1008 -
 "version_check",
1009 -
]
1010 -
1011 -
[[package]]
1012 501
name = "getrandom"
1013 502
version = "0.2.17"
1014 503
source = "registry+https://github.com/rust-lang/crates.io-index"
…
14 unchanged lines
1029 518
 "libc",
1030 519
 "r-efi",
1031 520
 "rand_core 0.10.1",
1032 -
]
1033 -
1034 -
[[package]]
1035 -
name = "gimli"
1036 -
version = "0.33.0"
1037 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1038 -
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
1039 -
dependencies = [
1040 -
 "fnv",
1041 -
 "hashbrown 0.16.1",
1042 -
 "indexmap",
1043 -
 "stable_deref_trait",
1044 521
]
1045 522
1046 523
[[package]]
…
8 unchanged lines
1055 532
 "regex-automata",
1056 533
 "regex-syntax",
1057 534
]
1058 -
1059 -
[[package]]
1060 -
name = "hashbrown"
1061 -
version = "0.16.1"
1062 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1063 -
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1064 535
1065 536
[[package]]
1066 537
name = "hashbrown"
1067 538
version = "0.17.1"
1068 539
source = "registry+https://github.com/rust-lang/crates.io-index"
1069 540
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1070 -
dependencies = [
1071 -
 "foldhash",
1072 -
 "serde",
1073 -
 "serde_core",
1074 -
]
1075 541
1076 542
[[package]]
1077 543
name = "heck"
…
99 unchanged lines
1177 643
 "zerotrie",
1178 644
 "zerovec",
1179 645
]
1180 -
1181 -
[[package]]
1182 -
name = "id-arena"
1183 -
version = "2.3.0"
1184 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1185 -
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
1186 646
1187 647
[[package]]
1188 648
name = "idna"
…
39 unchanged lines
1228 688
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1229 689
dependencies = [
1230 690
 "equivalent",
1231 -
 "hashbrown 0.17.1",
1232 -
 "serde",
1233 -
 "serde_core",
1234 -
]
1235 -
1236 -
[[package]]
1237 -
name = "io-extras"
1238 -
version = "0.19.0"
1239 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1240 -
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
1241 -
dependencies = [
1242 -
 "io-lifetimes 3.0.1",
1243 -
 "windows-sys 0.52.0",
691 +
 "hashbrown",
1244 692
]
1245 693
1246 694
[[package]]
1247 -
name = "io-lifetimes"
1248 -
version = "2.0.4"
1249 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1250 -
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
1251 -
1252 -
[[package]]
1253 -
name = "io-lifetimes"
1254 -
version = "3.0.1"
1255 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1256 -
checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
1257 -
1258 -
[[package]]
1259 -
name = "ipnet"
1260 -
version = "2.12.2"
1261 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1262 -
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1263 -
1264 -
[[package]]
1265 695
name = "is_terminal_polyfill"
1266 696
version = "1.70.2"
1267 697
source = "registry+https://github.com/rust-lang/crates.io-index"
1268 698
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1269 -
1270 -
[[package]]
1271 -
name = "itertools"
1272 -
version = "0.14.0"
1273 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1274 -
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1275 -
dependencies = [
1276 -
 "either",
1277 -
]
1278 699
1279 700
[[package]]
1280 701
name = "itoa"
…
3 unchanged lines
1284 705
1285 706
[[package]]
1286 707
name = "jet"
1287 -
version = "0.5.3"
708 +
version = "0.1.0"
1288 709
dependencies = [
1289 710
 "blake3",
1290 711
 "hex",
…
1 unchanged lines
1292 713
 "serde",
1293 714
 "serde_json",
1294 715
 "tempfile",
1295 -
 "thiserror 1.0.69",
716 +
 "thiserror",
1296 717
]
1297 718
1298 719
[[package]]
…
25 unchanged lines
1324 745
checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
1325 746
dependencies = [
1326 747
 "cfg-if",
1327 -
 "cpufeatures 0.3.1",
748 +
 "cpufeatures",
1328 749
]
1329 -
1330 -
[[package]]
1331 -
name = "lazy_static"
1332 -
version = "1.5.1"
1333 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1334 -
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1335 -
1336 -
[[package]]
1337 -
name = "leb128"
1338 -
version = "0.2.7"
1339 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1340 -
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
1341 -
1342 -
[[package]]
1343 -
name = "leb128fmt"
1344 -
version = "0.1.0"
1345 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1346 -
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1347 750
1348 751
[[package]]
1349 752
name = "libc"
…
2 unchanged lines
1352 755
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1353 756
1354 757
[[package]]
1355 -
name = "libm"
1356 -
version = "0.2.16"
1357 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1358 -
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1359 -
1360 -
[[package]]
1361 758
name = "linux-raw-sys"
1362 759
version = "0.12.1"
1363 760
source = "registry+https://github.com/rust-lang/crates.io-index"
…
10 unchanged lines
1374 771
version = "0.4.34"
1375 772
source = "registry+https://github.com/rust-lang/crates.io-index"
1376 773
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1377 -
1378 -
[[package]]
1379 -
name = "mach2"
1380 -
version = "0.6.0"
1381 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1382 -
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
1383 774
1384 775
[[package]]
1385 -
name = "maybe-owned"
1386 -
version = "0.3.4"
1387 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1388 -
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
1389 -
1390 -
[[package]]
1391 776
name = "memchr"
1392 777
version = "2.8.3"
1393 778
source = "registry+https://github.com/rust-lang/crates.io-index"
1394 779
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
1395 -
1396 -
[[package]]
1397 -
name = "memfd"
1398 -
version = "0.6.6"
1399 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1400 -
checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90"
1401 -
dependencies = [
1402 -
 "rustix",
1403 -
]
1404 780
1405 781
[[package]]
1406 782
name = "miette"
…
3 unchanged lines
1410 786
dependencies = [
1411 787
 "cfg-if",
1412 788
 "miette-derive",
1413 -
 "unicode-width 0.1.14",
789 +
 "unicode-width",
1414 790
]
1415 791
1416 792
[[package]]
…
6 unchanged lines
1423 799
 "quote",
1424 800
 "syn 2.0.119",
1425 801
]
1426 -
1427 -
[[package]]
1428 -
name = "minimal-lexical"
1429 -
version = "0.2.1"
1430 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1431 -
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1432 802
1433 803
[[package]]
1434 804
name = "miniz_oxide"
…
3 unchanged lines
1438 808
dependencies = [
1439 809
 "adler2",
1440 810
 "simd-adler32",
1441 -
]
1442 -
1443 -
[[package]]
1444 -
name = "mio"
1445 -
version = "1.2.4"
1446 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1447 -
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
1448 -
dependencies = [
1449 -
 "libc",
1450 -
 "wasi",
1451 -
 "windows-sys 0.61.2",
1452 811
]
1453 812
1454 813
[[package]]
…
3 unchanged lines
1458 817
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
1459 818
dependencies = [
1460 819
 "const-oid",
1461 -
 "crypto-common 0.2.2",
820 +
 "crypto-common",
1462 821
 "ctutils",
1463 822
 "hybrid-array",
1464 823
 "module-lattice",
…
14 unchanged lines
1479 838
]
1480 839
1481 840
[[package]]
1482 -
name = "multiversion_no_op"
1483 -
version = "1.0.0"
1484 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1485 -
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
1486 -
1487 -
[[package]]
1488 841
name = "nix"
1489 842
version = "0.29.0"
1490 843
source = "registry+https://github.com/rust-lang/crates.io-index"
…
3 unchanged lines
1494 847
 "cfg-if",
1495 848
 "cfg_aliases",
1496 849
 "libc",
1497 -
]
1498 -
1499 -
[[package]]
1500 -
name = "nom"
1501 -
version = "7.1.3"
1502 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1503 -
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1504 -
dependencies = [
1505 -
 "memchr",
1506 -
 "minimal-lexical",
1507 -
]
1508 -
1509 -
[[package]]
1510 -
name = "num-bigint"
1511 -
version = "0.4.8"
1512 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1513 -
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1514 -
dependencies = [
1515 -
 "num-integer",
1516 -
 "num-traits",
1517 -
]
1518 -
1519 -
[[package]]
1520 -
name = "num-conv"
1521 -
version = "0.2.2"
1522 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1523 -
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1524 -
1525 -
[[package]]
1526 -
name = "num-integer"
1527 -
version = "0.1.47"
1528 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1529 -
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1530 -
dependencies = [
1531 -
 "num-traits",
1532 850
]
1533 851
1534 852
[[package]]
…
3 unchanged lines
1538 856
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1539 857
dependencies = [
1540 858
 "autocfg",
1541 -
]
1542 -
1543 -
[[package]]
1544 -
name = "object"
1545 -
version = "0.40.0"
1546 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1547 -
checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c"
1548 -
dependencies = [
1549 -
 "crc32fast",
1550 -
 "hashbrown 0.17.1",
1551 -
 "indexmap",
1552 -
 "memchr",
1553 -
]
1554 -
1555 -
[[package]]
1556 -
name = "oid-registry"
1557 -
version = "0.8.1"
1558 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1559 -
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
1560 -
dependencies = [
1561 -
 "asn1-rs",
1562 859
]
1563 860
1564 861
[[package]]
…
10 unchanged lines
1575 872
1576 873
[[package]]
1577 874
name = "oxy-core"
1578 -
version = "0.9.0"
875 +
version = "0.4.3"
1579 876
dependencies = [
1580 877
 "blake3",
1581 878
 "ed25519-dalek",
…
3 unchanged lines
1585 882
 "libc",
1586 883
 "memchr",
1587 884
 "ml-dsa",
1588 -
 "rand 0.8.8",
885 +
 "rand",
1589 886
 "rayon",
1590 887
 "rustls",
1591 888
 "rustls-pemfile",
1592 -
 "serde_json",
1593 -
 "sha2 0.11.0",
1594 889
 "similar",
1595 -
 "thiserror 1.0.69",
890 +
 "thiserror",
1596 891
 "ureq",
1597 892
 "webpki-roots 1.0.9",
1598 893
 "zstd",
…
7 unchanged lines
1606 901
dependencies = [
1607 902
 "libc",
1608 903
 "winapi",
1609 -
]
1610 -
1611 -
[[package]]
1612 -
name = "pem"
1613 -
version = "4.0.0"
1614 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1615 -
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
1616 -
dependencies = [
1617 -
 "base64 0.23.1",
1618 -
 "serde_core",
1619 904
]
1620 905
1621 906
[[package]]
…
1 unchanged lines
1623 908
version = "2.3.2"
1624 909
source = "registry+https://github.com/rust-lang/crates.io-index"
1625 910
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1626 -
1627 -
[[package]]
1628 -
name = "pin-project-lite"
1629 -
version = "0.2.17"
1630 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1631 -
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1632 911
1633 912
[[package]]
1634 913
name = "pkcs8"
…
12 unchanged lines
1647 926
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1648 927
1649 928
[[package]]
1650 -
name = "postcard"
1651 -
version = "1.1.3"
1652 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1653 -
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
1654 -
dependencies = [
1655 -
 "cobs",
1656 -
 "embedded-io 0.4.0",
1657 -
 "embedded-io 0.6.1",
1658 -
 "serde",
1659 -
]
1660 -
1661 -
[[package]]
1662 929
name = "potential_utf"
1663 930
version = "0.1.6"
1664 931
source = "registry+https://github.com/rust-lang/crates.io-index"
…
3 unchanged lines
1668 935
]
1669 936
1670 937
[[package]]
1671 -
name = "powerfmt"
1672 -
version = "0.2.1"
1673 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1674 -
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
1675 -
1676 -
[[package]]
1677 938
name = "ppv-lite86"
1678 939
version = "0.2.21"
1679 940
source = "registry+https://github.com/rust-lang/crates.io-index"
…
12 unchanged lines
1692 953
]
1693 954
1694 955
[[package]]
1695 -
name = "pulley-interpreter"
1696 -
version = "49.0.4"
1697 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1698 -
checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3"
1699 -
dependencies = [
1700 -
 "cranelift-bitset",
1701 -
 "log",
1702 -
 "pulley-macros",
1703 -
 "wasmtime-internal-core",
1704 -
]
1705 -
1706 -
[[package]]
1707 -
name = "pulley-macros"
1708 -
version = "49.0.4"
1709 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1710 -
checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847"
1711 -
dependencies = [
1712 -
 "proc-macro2",
1713 -
 "quote",
1714 -
 "syn 2.0.119",
1715 -
]
1716 -
1717 -
[[package]]
1718 956
name = "quote"
1719 957
version = "1.0.47"
1720 958
source = "registry+https://github.com/rust-lang/crates.io-index"
…
17 unchanged lines
1738 976
 "libc",
1739 977
 "rand_chacha",
1740 978
 "rand_core 0.6.4",
1741 -
]
1742 -
1743 -
[[package]]
1744 -
name = "rand"
1745 -
version = "0.10.3"
1746 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1747 -
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
1748 -
dependencies = [
1749 -
 "chacha20",
1750 -
 "getrandom 0.4.3",
1751 -
 "rand_core 0.10.1",
1752 979
]
1753 980
1754 981
[[package]]
…
42 unchanged lines
1797 1024
]
1798 1025
1799 1026
[[package]]
1800 -
name = "rcgen"
1801 -
version = "0.14.10"
1802 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1803 -
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
1804 -
dependencies = [
1805 -
 "pem",
1806 -
 "ring",
1807 -
 "rustls-pki-types",
1808 -
 "time",
1809 -
 "x509-parser",
1810 -
 "yasna",
1811 -
]
1812 -
1813 -
[[package]]
1814 -
name = "regalloc2"
1815 -
version = "0.15.2"
1816 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1817 -
checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85"
1818 -
dependencies = [
1819 -
 "allocator-api2",
1820 -
 "bumpalo",
1821 -
 "hashbrown 0.17.1",
1822 -
 "log",
1823 -
 "rustc-hash",
1824 -
 "serde",
1825 -
 "smallvec",
1826 -
]
1827 -
1828 -
[[package]]
1829 1027
name = "regex-automata"
1830 1028
version = "0.4.18"
1831 1029
source = "registry+https://github.com/rust-lang/crates.io-index"
…
23 unchanged lines
1855 1053
 "untrusted",
1856 1054
 "windows-sys 0.52.0",
1857 1055
]
1858 -
1859 -
[[package]]
1860 -
name = "rustc-demangle"
1861 -
version = "0.1.28"
1862 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1863 -
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
1864 -
1865 -
[[package]]
1866 -
name = "rustc-hash"
1867 -
version = "2.1.3"
1868 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1869 -
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1870 1056
1871 1057
[[package]]
1872 1058
name = "rustc_version"
…
2 unchanged lines
1875 1061
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1876 1062
dependencies = [
1877 1063
 "semver",
1878 -
]
1879 -
1880 -
[[package]]
1881 -
name = "rusticata-macros"
1882 -
version = "4.1.0"
1883 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1884 -
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
1885 -
dependencies = [
1886 -
 "nom",
1887 1064
]
1888 1065
1889 1066
[[package]]
…
10 unchanged lines
1900 1077
]
1901 1078
1902 1079
[[package]]
1903 -
name = "rustix-linux-procfs"
1904 -
version = "0.1.1"
1905 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1906 -
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
1907 -
dependencies = [
1908 -
 "once_cell",
1909 -
 "rustix",
1910 -
]
1911 -
1912 -
[[package]]
1913 1080
name = "rustls"
1914 1081
version = "0.23.45"
1915 1082
source = "registry+https://github.com/rust-lang/crates.io-index"
…
36 unchanged lines
1952 1119
 "rustls-pki-types",
1953 1120
 "untrusted",
1954 1121
]
1955 -
1956 -
[[package]]
1957 -
name = "rustversion"
1958 -
version = "1.0.23"
1959 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1960 -
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1961 1122
1962 1123
[[package]]
1963 1124
name = "same-file"
…
3 unchanged lines
1967 1128
dependencies = [
1968 1129
 "winapi-util",
1969 1130
]
1970 -
1971 -
[[package]]
1972 -
name = "scopeguard"
1973 -
version = "1.2.0"
1974 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1975 -
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1976 1131
1977 1132
[[package]]
1978 1133
name = "semver"
1979 1134
version = "1.0.28"
1980 1135
source = "registry+https://github.com/rust-lang/crates.io-index"
1981 1136
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1982 -
dependencies = [
1983 -
 "serde",
1984 -
 "serde_core",
1985 -
]
1986 1137
1987 1138
[[package]]
1988 1139
name = "serde"
…
45 unchanged lines
2034 1185
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
2035 1186
dependencies = [
2036 1187
 "serde",
2037 -
]
2038 -
2039 -
[[package]]
2040 -
name = "sha2"
2041 -
version = "0.10.9"
2042 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2043 -
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
2044 -
dependencies = [
2045 -
 "cfg-if",
2046 -
 "cpufeatures 0.2.17",
2047 -
 "digest 0.10.7",
2048 1188
]
2049 1189
2050 1190
[[package]]
…
3 unchanged lines
2054 1194
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
2055 1195
dependencies = [
2056 1196
 "cfg-if",
2057 -
 "cpufeatures 0.3.1",
2058 -
 "digest 0.11.3",
1197 +
 "cpufeatures",
1198 +
 "digest",
2059 1199
]
2060 1200
2061 1201
[[package]]
…
2 unchanged lines
2064 1204
source = "registry+https://github.com/rust-lang/crates.io-index"
2065 1205
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
2066 1206
dependencies = [
2067 -
 "digest 0.11.3",
1207 +
 "digest",
2068 1208
 "keccak",
2069 1209
 "sponge-cursor",
2070 1210
]
…
10 unchanged lines
2081 1221
source = "registry+https://github.com/rust-lang/crates.io-index"
2082 1222
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
2083 1223
dependencies = [
2084 -
 "digest 0.11.3",
1224 +
 "digest",
2085 1225
 "rand_core 0.10.1",
2086 1226
]
2087 1227
…
2 unchanged lines
2090 1230
version = "0.3.10"
2091 1231
source = "registry+https://github.com/rust-lang/crates.io-index"
2092 1232
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
2093 -
2094 -
[[package]]
2095 -
name = "simdutf8"
2096 -
version = "0.1.5"
2097 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2098 -
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2099 1233
2100 1234
[[package]]
2101 1235
name = "similar"
2102 1236
version = "2.7.0"
2103 1237
source = "registry+https://github.com/rust-lang/crates.io-index"
2104 1238
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
2105 -
2106 -
[[package]]
2107 -
name = "slab"
2108 -
version = "0.4.12"
2109 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2110 -
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
2111 1239
2112 1240
[[package]]
2113 1241
name = "smallvec"
2114 1242
version = "1.16.2"
2115 1243
source = "registry+https://github.com/rust-lang/crates.io-index"
2116 1244
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
2117 -
dependencies = [
2118 -
 "serde",
2119 -
]
2120 -
2121 -
[[package]]
2122 -
name = "socket2"
2123 -
version = "0.6.5"
2124 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2125 -
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
2126 -
dependencies = [
2127 -
 "libc",
2128 -
 "windows-sys 0.61.2",
2129 -
]
2130 1245
2131 1246
[[package]]
2132 1247
name = "spki"
…
49 unchanged lines
2182 1297
 "proc-macro2",
2183 1298
 "quote",
2184 1299
 "unicode-ident",
2185 -
]
2186 -
2187 -
[[package]]
2188 -
name = "synstructure"
2189 -
version = "0.13.2"
2190 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2191 -
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2192 -
dependencies = [
2193 -
 "proc-macro2",
2194 -
 "quote",
2195 -
 "syn 2.0.119",
2196 1300
]
2197 1301
2198 1302
[[package]]
…
6 unchanged lines
2205 1309
 "quote",
2206 1310
 "syn 3.0.6",
2207 1311
]
2208 -
2209 -
[[package]]
2210 -
name = "target-lexicon"
2211 -
version = "0.13.5"
2212 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2213 -
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
2214 1312
2215 1313
[[package]]
2216 1314
name = "tempfile"
…
6 unchanged lines
2223 1321
 "once_cell",
2224 1322
 "rustix",
2225 1323
 "windows-sys 0.61.2",
2226 -
]
2227 -
2228 -
[[package]]
2229 -
name = "termcolor"
2230 -
version = "1.4.1"
2231 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2232 -
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
2233 -
dependencies = [
2234 -
 "winapi-util",
2235 1324
]
2236 1325
2237 1326
[[package]]
…
2 unchanged lines
2240 1329
source = "registry+https://github.com/rust-lang/crates.io-index"
2241 1330
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2242 1331
dependencies = [
2243 -
 "thiserror-impl 1.0.69",
2244 -
]
2245 -
2246 -
[[package]]
2247 -
name = "thiserror"
2248 -
version = "2.0.21"
2249 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2250 -
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
2251 -
dependencies = [
2252 -
 "thiserror-impl 2.0.21",
1332 +
 "thiserror-impl",
2253 1333
]
2254 1334
2255 1335
[[package]]
…
5 unchanged lines
2261 1341
 "proc-macro2",
2262 1342
 "quote",
2263 1343
 "syn 2.0.119",
2264 -
]
2265 -
2266 -
[[package]]
2267 -
name = "thiserror-impl"
2268 -
version = "2.0.21"
2269 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2270 -
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
2271 -
dependencies = [
2272 -
 "proc-macro2",
2273 -
 "quote",
2274 -
 "syn 3.0.6",
2275 -
]
2276 -
2277 -
[[package]]
2278 -
name = "time"
2279 -
version = "0.3.55"
2280 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2281 -
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
2282 -
dependencies = [
2283 -
 "deranged",
2284 -
 "num-conv",
2285 -
 "powerfmt",
2286 -
 "serde_core",
2287 -
 "time-core",
2288 -
 "time-macros",
2289 -
]
2290 -
2291 -
[[package]]
2292 -
name = "time-core"
2293 -
version = "0.1.9"
2294 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2295 -
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
2296 -
2297 -
[[package]]
2298 -
name = "time-macros"
2299 -
version = "0.2.32"
2300 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2301 -
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
2302 -
dependencies = [
2303 -
 "num-conv",
2304 -
 "time-core",
2305 1344
]
2306 1345
2307 1346
[[package]]
…
4 unchanged lines
2312 1351
dependencies = [
2313 1352
 "displaydoc",
2314 1353
 "zerovec",
2315 -
]
2316 -
2317 -
[[package]]
2318 -
name = "tokio"
2319 -
version = "1.54.1"
2320 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2321 -
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
2322 -
dependencies = [
2323 -
 "bytes",
2324 -
 "libc",
2325 -
 "mio",
2326 -
 "pin-project-lite",
2327 -
 "socket2",
2328 -
 "windows-sys 0.61.2",
2329 1354
]
2330 1355
2331 1356
[[package]]
…
36 unchanged lines
2368 1393
version = "0.1.2"
2369 1394
source = "registry+https://github.com/rust-lang/crates.io-index"
2370 1395
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
2371 -
2372 -
[[package]]
2373 -
name = "tracing"
2374 -
version = "0.1.44"
2375 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2376 -
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2377 -
dependencies = [
2378 -
 "pin-project-lite",
2379 -
 "tracing-attributes",
2380 -
 "tracing-core",
2381 -
]
2382 1396
2383 1397
[[package]]
2384 -
name = "tracing-attributes"
2385 -
version = "0.1.31"
2386 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2387 -
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2388 -
dependencies = [
2389 -
 "proc-macro2",
2390 -
 "quote",
2391 -
 "syn 2.0.119",
2392 -
]
2393 -
2394 -
[[package]]
2395 -
name = "tracing-core"
2396 -
version = "0.1.36"
2397 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2398 -
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2399 -
dependencies = [
2400 -
 "once_cell",
2401 -
]
2402 -
2403 -
[[package]]
2404 1398
name = "typenum"
2405 1399
version = "1.20.1"
2406 1400
source = "registry+https://github.com/rust-lang/crates.io-index"
…
10 unchanged lines
2417 1411
version = "0.1.14"
2418 1412
source = "registry+https://github.com/rust-lang/crates.io-index"
2419 1413
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
2420 -
2421 -
[[package]]
2422 -
name = "unicode-width"
2423 -
version = "0.2.2"
2424 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2425 -
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
2426 1414
2427 1415
[[package]]
2428 1416
name = "untrusted"
…
7 unchanged lines
2436 1424
source = "registry+https://github.com/rust-lang/crates.io-index"
2437 1425
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
2438 1426
dependencies = [
2439 -
 "base64 0.22.1",
1427 +
 "base64",
2440 1428
 "flate2",
2441 1429
 "log",
2442 1430
 "once_cell",
…
28 unchanged lines
2471 1459
version = "0.2.2"
2472 1460
source = "registry+https://github.com/rust-lang/crates.io-index"
2473 1461
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2474 -
2475 -
[[package]]
2476 -
name = "version_check"
2477 -
version = "0.9.5"
2478 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2479 -
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2480 1462
2481 1463
[[package]]
2482 1464
name = "walkdir"
…
10 unchanged lines
2493 1475
version = "0.11.1+wasi-snapshot-preview1"
2494 1476
source = "registry+https://github.com/rust-lang/crates.io-index"
2495 1477
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2496 -
2497 -
[[package]]
2498 -
name = "wasm-encoder"
2499 -
version = "0.258.3"
2500 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2501 -
checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f"
2502 -
dependencies = [
2503 -
 "leb128fmt",
2504 -
 "wasmparser 0.258.3",
2505 -
]
2506 -
2507 -
[[package]]
2508 -
name = "wasm-encoder"
2509 -
version = "0.262.0"
2510 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2511 -
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
2512 -
dependencies = [
2513 -
 "leb128fmt",
2514 -
 "wasmparser 0.262.0",
2515 -
]
2516 -
2517 -
[[package]]
2518 -
name = "wasm-metadata"
2519 -
version = "0.258.3"
2520 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2521 -
checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f"
2522 -
dependencies = [
2523 -
 "anyhow",
2524 -
 "indexmap",
2525 -
 "wasm-encoder 0.258.3",
2526 -
 "wasmparser 0.258.3",
2527 -
]
2528 -
2529 -
[[package]]
2530 -
name = "wasmparser"
2531 -
version = "0.258.3"
2532 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2533 -
checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1"
2534 -
dependencies = [
2535 -
 "bitflags",
2536 -
 "hashbrown 0.17.1",
2537 -
 "indexmap",
2538 -
 "semver",
2539 -
 "serde",
2540 -
]
2541 -
2542 -
[[package]]
2543 -
name = "wasmparser"
2544 -
version = "0.262.0"
2545 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2546 -
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
2547 -
dependencies = [
2548 -
 "bitflags",
2549 -
 "indexmap",
2550 -
 "semver",
2551 -
]
2552 -
2553 -
[[package]]
2554 -
name = "wasmprinter"
2555 -
version = "0.258.3"
2556 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2557 -
checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b"
2558 -
dependencies = [
2559 -
 "anyhow",
2560 -
 "termcolor",
2561 -
 "wasmparser 0.258.3",
2562 -
]
2563 -
2564 -
[[package]]
2565 -
name = "wasmtime"
2566 -
version = "49.0.4"
2567 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2568 -
checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea"
2569 -
dependencies = [
2570 -
 "addr2line",
2571 -
 "async-trait",
2572 -
 "bitflags",
2573 -
 "bumpalo",
2574 -
 "cc",
2575 -
 "encoding_rs",
2576 -
 "futures",
2577 -
 "libc",
2578 -
 "log",
2579 -
 "mach2",
2580 -
 "memfd",
2581 -
 "object",
2582 -
 "once_cell",
2583 -
 "postcard",
2584 -
 "pulley-interpreter",
2585 -
 "rayon",
2586 -
 "rustix",
2587 -
 "semver",
2588 -
 "serde",
2589 -
 "serde_derive",
2590 -
 "smallvec",
2591 -
 "target-lexicon",
2592 -
 "wasmparser 0.258.3",
2593 -
 "wasmtime-environ",
2594 -
 "wasmtime-internal-component-macro",
2595 -
 "wasmtime-internal-component-util",
2596 -
 "wasmtime-internal-core",
2597 -
 "wasmtime-internal-cranelift",
2598 -
 "wasmtime-internal-fiber",
2599 -
 "wasmtime-internal-jit-debug",
2600 -
 "wasmtime-internal-jit-icache-coherence",
2601 -
 "wasmtime-internal-unwinder",
2602 -
 "wasmtime-internal-versioned-export-macros",
2603 -
 "windows-sys 0.61.2",
2604 -
]
2605 1478
2606 1479
[[package]]
2607 -
name = "wasmtime-environ"
2608 -
version = "49.0.4"
2609 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2610 -
checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca"
2611 -
dependencies = [
2612 -
 "anyhow",
2613 -
 "cpp_demangle",
2614 -
 "cranelift-bforest",
2615 -
 "cranelift-bitset",
2616 -
 "cranelift-entity",
2617 -
 "gimli",
2618 -
 "hashbrown 0.17.1",
2619 -
 "indexmap",
2620 -
 "log",
2621 -
 "object",
2622 -
 "postcard",
2623 -
 "rustc-demangle",
2624 -
 "semver",
2625 -
 "serde",
2626 -
 "serde_derive",
2627 -
 "sha2 0.10.9",
2628 -
 "smallvec",
2629 -
 "target-lexicon",
2630 -
 "wasm-encoder 0.258.3",
2631 -
 "wasmparser 0.258.3",
2632 -
 "wasmprinter",
2633 -
 "wasmtime-internal-component-util",
2634 -
 "wasmtime-internal-core",
2635 -
]
2636 -
2637 -
[[package]]
2638 -
name = "wasmtime-internal-component-macro"
2639 -
version = "49.0.4"
2640 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2641 -
checksum = "d4fca03bd4425856574c0807d1dee16fad43f947feaf4e9ae1d2129596ee4de4"
2642 -
dependencies = [
2643 -
 "anyhow",
2644 -
 "proc-macro2",
2645 -
 "quote",
2646 -
 "syn 2.0.119",
2647 -
 "wasmtime-internal-component-util",
2648 -
 "wasmtime-internal-wit-bindgen",
2649 -
 "wit-parser",
2650 -
]
2651 -
2652 -
[[package]]
2653 -
name = "wasmtime-internal-component-util"
2654 -
version = "49.0.4"
2655 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2656 -
checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16"
2657 -
2658 -
[[package]]
2659 -
name = "wasmtime-internal-core"
2660 -
version = "49.0.4"
2661 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2662 -
checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525"
2663 -
dependencies = [
2664 -
 "hashbrown 0.17.1",
2665 -
 "libm",
2666 -
 "serde",
2667 -
]
2668 -
2669 -
[[package]]
2670 -
name = "wasmtime-internal-cranelift"
2671 -
version = "49.0.4"
2672 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2673 -
checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750"
2674 -
dependencies = [
2675 -
 "cranelift-codegen",
2676 -
 "cranelift-control",
2677 -
 "cranelift-entity",
2678 -
 "cranelift-frontend",
2679 -
 "cranelift-native",
2680 -
 "gimli",
2681 -
 "itertools",
2682 -
 "log",
2683 -
 "object",
2684 -
 "pulley-interpreter",
2685 -
 "smallvec",
2686 -
 "target-lexicon",
2687 -
 "thiserror 2.0.21",
2688 -
 "wasmparser 0.258.3",
2689 -
 "wasmtime-environ",
2690 -
 "wasmtime-internal-core",
2691 -
 "wasmtime-internal-unwinder",
2692 -
 "wasmtime-internal-versioned-export-macros",
2693 -
]
2694 -
2695 -
[[package]]
2696 -
name = "wasmtime-internal-fiber"
2697 -
version = "49.0.4"
2698 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2699 -
checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307"
2700 -
dependencies = [
2701 -
 "cc",
2702 -
 "libc",
2703 -
 "rustix",
2704 -
 "wasmtime-environ",
2705 -
 "wasmtime-internal-versioned-export-macros",
2706 -
 "windows-sys 0.61.2",
2707 -
]
2708 -
2709 -
[[package]]
2710 -
name = "wasmtime-internal-jit-debug"
2711 -
version = "49.0.4"
2712 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2713 -
checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8"
2714 -
dependencies = [
2715 -
 "cc",
2716 -
 "wasmtime-internal-versioned-export-macros",
2717 -
]
2718 -
2719 -
[[package]]
2720 -
name = "wasmtime-internal-jit-icache-coherence"
2721 -
version = "49.0.4"
2722 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2723 -
checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173"
2724 -
dependencies = [
2725 -
 "libc",
2726 -
 "wasmtime-internal-core",
2727 -
 "windows-sys 0.61.2",
2728 -
]
2729 -
2730 -
[[package]]
2731 -
name = "wasmtime-internal-unwinder"
2732 -
version = "49.0.4"
2733 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2734 -
checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b"
2735 -
dependencies = [
2736 -
 "cranelift-codegen",
2737 -
 "log",
2738 -
 "object",
2739 -
 "wasmtime-environ",
2740 -
]
2741 -
2742 -
[[package]]
2743 -
name = "wasmtime-internal-versioned-export-macros"
2744 -
version = "49.0.4"
2745 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2746 -
checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883"
2747 -
dependencies = [
2748 -
 "proc-macro2",
2749 -
 "quote",
2750 -
 "syn 2.0.119",
2751 -
]
2752 -
2753 -
[[package]]
2754 -
name = "wasmtime-internal-wit-bindgen"
2755 -
version = "49.0.4"
2756 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2757 -
checksum = "b4eaf45d4da7681d234bb6efd846b4a528b08a75384a7fc23f125d0145364b06"
2758 -
dependencies = [
2759 -
 "anyhow",
2760 -
 "bitflags",
2761 -
 "heck",
2762 -
 "indexmap",
2763 -
 "wit-component",
2764 -
 "wit-parser",
2765 -
]
2766 -
2767 -
[[package]]
2768 -
name = "wasmtime-wasi"
2769 -
version = "49.0.4"
2770 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2771 -
checksum = "821e45765c35555d3b0bdf9277cb99478f7d1f29a17b3315ba76e01220f02a38"
2772 -
dependencies = [
2773 -
 "async-trait",
2774 -
 "bitflags",
2775 -
 "bytes",
2776 -
 "cap-primitives",
2777 -
 "futures",
2778 -
 "rand 0.10.3",
2779 -
 "rustix",
2780 -
 "rustix-linux-procfs",
2781 -
 "thiserror 2.0.21",
2782 -
 "tokio",
2783 -
 "tracing",
2784 -
 "url",
2785 -
 "wasmtime",
2786 -
 "wasmtime-wasi-io",
2787 -
 "wiggle",
2788 -
 "windows-sys 0.61.2",
2789 -
 "winx",
2790 -
]
2791 -
2792 -
[[package]]
2793 -
name = "wasmtime-wasi-io"
2794 -
version = "49.0.4"
2795 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2796 -
checksum = "8cdff38d1c730bdaf4fcfbe0eedf3265fb9c7a4deb119deecbfc1c1778950716"
2797 -
dependencies = [
2798 -
 "async-trait",
2799 -
 "bytes",
2800 -
 "futures",
2801 -
 "tracing",
2802 -
 "wasmtime",
2803 -
]
2804 -
2805 -
[[package]]
2806 -
name = "wast"
2807 -
version = "35.0.2"
2808 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2809 -
checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68"
2810 -
dependencies = [
2811 -
 "leb128",
2812 -
]
2813 -
2814 -
[[package]]
2815 -
name = "wast"
2816 -
version = "262.0.0"
2817 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2818 -
checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
2819 -
dependencies = [
2820 -
 "bumpalo",
2821 -
 "leb128fmt",
2822 -
 "memchr",
2823 -
 "unicode-width 0.2.2",
2824 -
 "wasm-encoder 0.262.0",
2825 -
]
2826 -
2827 -
[[package]]
2828 -
name = "wat"
2829 -
version = "1.262.0"
2830 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2831 -
checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
2832 -
dependencies = [
2833 -
 "wast 262.0.0",
2834 -
]
2835 -
2836 -
[[package]]
2837 1480
name = "webpki-roots"
2838 1481
version = "0.26.11"
2839 1482
source = "registry+https://github.com/rust-lang/crates.io-index"
…
9 unchanged lines
2849 1492
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
2850 1493
dependencies = [
2851 1494
 "rustls-pki-types",
2852 -
]
2853 -
2854 -
[[package]]
2855 -
name = "wiggle"
2856 -
version = "49.0.4"
2857 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2858 -
checksum = "af3872869836d404f722e2361f869c09630e43a73fcc7060f7892a929b74b6a3"
2859 -
dependencies = [
2860 -
 "bitflags",
2861 -
 "thiserror 2.0.21",
2862 -
 "tracing",
2863 -
 "wasmtime",
2864 -
 "wasmtime-environ",
2865 -
 "wiggle-macro",
2866 -
]
2867 -
2868 -
[[package]]
2869 -
name = "wiggle-generate"
2870 -
version = "49.0.4"
2871 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2872 -
checksum = "cf7e31dec6de37e7e848aecbad54777dd246cd440a1a547ab1a1e31c27f55e49"
2873 -
dependencies = [
2874 -
 "heck",
2875 -
 "proc-macro2",
2876 -
 "quote",
2877 -
 "syn 2.0.119",
2878 -
 "wasmtime-environ",
2879 -
 "witx",
2880 -
]
2881 -
2882 -
[[package]]
2883 -
name = "wiggle-macro"
2884 -
version = "49.0.4"
2885 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2886 -
checksum = "a2af323f33b28ea84b24e2bccef19e51c7a75aa1d2929280ad1a4865f2877dcc"
2887 -
dependencies = [
2888 -
 "proc-macro2",
2889 -
 "quote",
2890 -
 "syn 2.0.119",
2891 -
 "wiggle-generate",
2892 1495
]
2893 1496
2894 1497
[[package]]
…
122 unchanged lines
3017 1620
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
3018 1621
dependencies = [
3019 1622
 "memchr",
3020 -
]
3021 -
3022 -
[[package]]
3023 -
name = "winx"
3024 -
version = "0.36.4"
3025 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3026 -
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
3027 -
dependencies = [
3028 -
 "bitflags",
3029 -
 "windows-sys 0.52.0",
3030 -
]
3031 -
3032 -
[[package]]
3033 -
name = "wit-component"
3034 -
version = "0.258.3"
3035 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3036 -
checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6"
3037 -
dependencies = [
3038 -
 "anyhow",
3039 -
 "bitflags",
3040 -
 "indexmap",
3041 -
 "log",
3042 -
 "serde",
3043 -
 "serde_derive",
3044 -
 "serde_json",
3045 -
 "wasm-encoder 0.258.3",
3046 -
 "wasm-metadata",
3047 -
 "wasmparser 0.258.3",
3048 -
 "wit-parser",
3049 -
]
3050 -
3051 -
[[package]]
3052 -
name = "wit-parser"
3053 -
version = "0.258.3"
3054 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3055 -
checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533"
3056 -
dependencies = [
3057 -
 "anyhow",
3058 -
 "hashbrown 0.17.1",
3059 -
 "id-arena",
3060 -
 "indexmap",
3061 -
 "log",
3062 -
 "semver",
3063 -
 "serde",
3064 -
 "serde_derive",
3065 -
 "serde_json",
3066 -
 "unicode-ident",
3067 -
 "wasmparser 0.258.3",
3068 -
]
3069 -
3070 -
[[package]]
3071 -
name = "witx"
3072 -
version = "0.9.1"
3073 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3074 -
checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b"
3075 -
dependencies = [
3076 -
 "anyhow",
3077 -
 "log",
3078 -
 "thiserror 1.0.69",
3079 -
 "wast 35.0.2",
3080 1623
]
3081 1624
3082 1625
[[package]]
…
1 unchanged lines
3084 1627
version = "0.6.4"
3085 1628
source = "registry+https://github.com/rust-lang/crates.io-index"
3086 1629
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
3087 -
3088 -
[[package]]
3089 -
name = "x509-parser"
3090 -
version = "0.18.1"
3091 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3092 -
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
3093 -
dependencies = [
3094 -
 "asn1-rs",
3095 -
 "data-encoding",
3096 -
 "der-parser",
3097 -
 "lazy_static",
3098 -
 "nom",
3099 -
 "oid-registry",
3100 -
 "ring",
3101 -
 "rusticata-macros",
3102 -
 "thiserror 2.0.21",
3103 -
 "time",
3104 -
]
3105 -
3106 -
[[package]]
3107 -
name = "yasna"
3108 -
version = "0.6.0"
3109 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3110 -
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
3111 -
dependencies = [
3112 -
 "bit-vec",
3113 -
 "time",
3114 -
]
3115 1630
3116 1631
[[package]]
3117 1632
name = "yoke"
…
15 unchanged lines
3133 1648
 "proc-macro2",
3134 1649
 "quote",
3135 1650
 "syn 3.0.6",
3136 -
 "synstructure 0.14.0",
1651 +
 "synstructure",
3137 1652
]
3138 1653
3139 1654
[[package]]
…
34 unchanged lines
3174 1689
 "proc-macro2",
3175 1690
 "quote",
3176 1691
 "syn 3.0.6",
3177 -
 "synstructure 0.14.0",
1692 +
 "synstructure",
3178 1693
]
3179 1694
3180 1695
[[package]]
…
77 unchanged lines
modified Cargo.toml +2 −3
1 1
[workspace]
2 -
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs", "crates/jet", "crates/flow-checks"]
2 +
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs", "crates/jet"]
3 3
# oxygen as a pinned subrepo (oxy submodule): the state flow builds against.
4 4
exclude = ["deps"]
5 5
resolver = "2"
6 6
7 7
[workspace.package]
8 -
version = "0.5.3"
8 +
version = "0.1.0"
9 9
edition = "2021"
10 10
license = "MIT OR Apache-2.0"
11 11
…
1 unchanged lines
13 13
oxy-core = { path = "deps/oxygen/crates/oxy-core" }
14 14
flow-core = { path = "crates/flow-core" }
15 15
jet = { path = "crates/jet" }
16 -
flow-init = { path = "crates/flow-init" }
17 16
serde = { version = "1", features = ["derive"] }
18 17
serde_json = "1"
19 18
blake3 = "1"
…
7 unchanged lines
modified Makefile +1 −1
…
21 unchanged lines
22 22
		mv bin/flow.new bin/flow; mv bin/flow-init.new bin/flow-init; \
23 23
		h=$$(bin/flow hash bin/flow-init | cut -d" " -f1); \
24 24
		sed -i "s|^init = .*|init = { path = \"bin/flow-init\", blake3 = \"$$h\" }|" flow.toml; \
25 -
		sudo systemctl restart --no-block flow-runner; echo "flow-runner: restarting (it drains: the job it holds finishes first)"'
25 +
		sudo systemctl restart flow-runner; systemctl is-active flow-runner'
26 26
27 27
# Move the pinned oxygen (deps/oxygen) to its main on Earth; the next commit
28 28
# records the new pin. Push oxygen first.
…
3 unchanged lines
modified README.md +0 −23
…
53 unchanged lines
54 54
under `/home/claude/flow` (keeping the previous files as `.prev`), re-pins
55 55
`flow-init` in `flow.toml` and restarts the `flow-runner` service.
56 56
57 -
## A Windows runner
58 -
59 -
A Windows machine runs WASM jobs (the other tiers are Linux's). Each
60 -
release carries `flow-windows-x86_64.exe` (from the `dist` job):
61 -
62 -
    flow.exe --home C:\flow runner register https://oxygenhub.io --token flowreg_… --name win-1 --label windows
63 -
    flow.exe --home C:\flow runner
64 -
65 -
The token comes from the hub (Admin › Runners, or `POST /api/v1/user/runner-token`
66 -
for a runner of your own repositories). A pipeline sends it work with a
67 -
`runtime "wasm"` sandbox and `runs-on "windows"`. To keep it running, a
68 -
Task Scheduler task at start-up with that command (restarting on failure)
69 -
does; set it to restart on failure, as a runner that leaves for a new build
70 -
exits 75 (`flow runner update`, spec §11.5.3). To stop it without losing
71 -
its job, `flow.exe --home C:\flow runner drain` (Windows has no SIGTERM;
72 -
ending the task kills it). What a Windows runner does not report yet: load
73 -
and kernel (oxygenflow#83).
74 -
75 57
## Commands
76 58
77 59
    flow check                 # validate .flow/pipeline.kdl, print jobs and the pipeline hash
…
3 unchanged lines
81 63
    flow rootfs list
82 64
    flow hash FILE             # BLAKE3, for pinning in flow.toml
83 65
    flow runner                # claim and run jobs for the hub in flow.toml
84 -
    flow runner update --from FILE [--dry-run]   # install a new flow build in place of this one (spec §11.5.3)
85 -
    flow runner drain          # the running runner claims nothing more and stops once its jobs are reported
86 66
87 67
## A Flow home
88 68
…
44 unchanged lines
133 113
```
134 114
135 115
## Tests
136 -
137 -
**Changelog:** a commit that changes code adds a line under `## Unreleased` in CHANGELOG.md (Added, Changed, Fixed or Security, with the issue number); versions change only at release. `scripts/changelog-check.sh [PATH...]` checks it for the paths a commit takes (the working copy's changes without any) and runs in the gate; `NO_CHANGELOG=1` for code changes users won't notice.
138 -
139 116
140 117
`cargo test --workspace` runs every test that needs no VM. The microVM
141 118
conformance test also runs when `FLOW_TEST_HOME` names a Flow home and
…
7 unchanged lines
removed crates/flow-checks/Cargo.toml +0 −16
1 -
[package]
2 -
name = "flow-checks"
3 -
version.workspace = true
4 -
edition.workspace = true
5 -
license.workspace = true
6 -
description = "OxygenFlow's cheap checks as one WASI module, so any runner with the WASM tier can take them (#96)"
7 -
8 -
# Built for wasm32-wasip1 by scripts/checks-wasm.sh and committed as
9 -
# .flow/checks.wasm; std only, so the build is the same on any machine with
10 -
# the same toolchain.
11 -
[[bin]]
12 -
name = "flow-checks"
13 -
path = "src/main.rs"
14 -
15 -
[dev-dependencies]
16 -
tempfile.workspace = true
removed crates/flow-checks/src/main.rs +0 −362
1 -
//! OxygenFlow's cheap checks as one WASI module (#96): what
2 -
//! `.flow/jobs/{layout,todos,size,summary}.sh` did in an Alpine microVM, so
3 -
//! that any runner offering the WASM tier (a phone, plex, windows-1) can take
4 -
//! them. The job's files are preopened as `/src`, which is also the current
5 -
//! directory, so every path here is relative to the workspace.
6 -
//!
7 -
//! - `layout PATH...`: each path is a file; fails naming the first missing.
8 -
//! - `todos DIR...`: the lines holding a note marker, counted, the first 20
9 -
//!   listed; never fails.
10 -
//! - `size DIR`: lines of Rust in each crate directory under DIR.
11 -
//! - `summary`: one line naming `$PROJECT`.
12 -
//! - `changelog FILE [MANIFEST]`: the changelog's shape: `## Unreleased`
13 -
//!   first if at all, then `## X.Y.Z (YYYY-MM-DD)` sections, newest first,
14 -
//!   none empty; with a manifest (`Cargo.toml`, `*.properties`,
15 -
//!   `package.json`), its version has a section, as a release needs.
16 -
17 -
use std::fs;
18 -
use std::io::{self, Write};
19 -
use std::path::{Path, PathBuf};
20 -
use std::process::ExitCode;
21 -
22 -
/// The note markers, spelt in pieces so this file does not count itself.
23 -
const MARKERS: [&str; 3] = [concat!("TO", "DO"), concat!("FIX", "ME"), concat!("X", "XX")];
24 -
25 -
/// How many noted lines `todos` lists.
26 -
const LISTED: usize = 20;
27 -
28 -
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]";
29 -
30 -
fn main() -> ExitCode {
31 -
    let args: Vec<String> = std::env::args().skip(1).collect();
32 -
    let root = Path::new(".");
33 -
    let mut out = io::stdout().lock();
34 -
    let done = match args.split_first() {
35 -
        Some((cmd, rest)) => match cmd.as_str() {
36 -
            "layout" => layout(root, rest, &mut out),
37 -
            "todos" => todos(root, rest, &mut out),
38 -
            "size" => match rest {
39 -
                [dir] => size(root, dir, &mut out),
40 -
                _ => Err(USAGE.to_string()),
41 -
            },
42 -
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
43 -
            "changelog" => match rest {
44 -
                [file] => changelog(root, file, None, &mut out),
45 -
                [file, manifest] => changelog(root, file, Some(manifest), &mut out),
46 -
                _ => Err(USAGE.to_string()),
47 -
            },
48 -
            _ => Err(USAGE.to_string()),
49 -
        },
50 -
        None => Err(USAGE.to_string()),
51 -
    };
52 -
    match done {
53 -
        Ok(()) => ExitCode::SUCCESS,
54 -
        Err(e) => {
55 -
            eprintln!("{e}");
56 -
            ExitCode::FAILURE
57 -
        }
58 -
    }
59 -
}
60 -
61 -
type Done = Result<(), String>;
62 -
63 -
fn emit(out: &mut impl Write, line: &str) -> Done {
64 -
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
65 -
}
66 -
67 -
/// The workspace has the files it should.
68 -
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
69 -
    if paths.is_empty() {
70 -
        return Err(USAGE.to_string());
71 -
    }
72 -
    for p in paths {
73 -
        if !root.join(p).is_file() {
74 -
            return Err(format!("missing: {p}"));
75 -
        }
76 -
        emit(out, &format!("found {p}"))?;
77 -
    }
78 -
    Ok(())
79 -
}
80 -
81 -
/// Notes left in the code, for the log.
82 -
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
83 -
    if dirs.is_empty() {
84 -
        return Err(USAGE.to_string());
85 -
    }
86 -
    let mut noted = Vec::new();
87 -
    for d in dirs {
88 -
        for file in files(&root.join(d))? {
89 -
            // Text only, as grep reports a binary file rather than its lines.
90 -
            let Ok(text) = fs::read_to_string(&file) else { continue };
91 -
            let name = shown(root, &file);
92 -
            for (i, line) in text.lines().enumerate() {
93 -
                if MARKERS.iter().any(|m| line.contains(m)) {
94 -
                    noted.push(format!("{name}:{}:{line}", i + 1));
95 -
                }
96 -
            }
97 -
        }
98 -
    }
99 -
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
100 -
    for line in noted.iter().take(LISTED) {
101 -
        emit(out, line)?;
102 -
    }
103 -
    Ok(())
104 -
}
105 -
106 -
/// Lines of Rust per crate: each directory directly under `dir`.
107 -
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
108 -
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
109 -
        let mut lines = 0;
110 -
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
111 -
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
112 -
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
113 -
        }
114 -
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
115 -
    }
116 -
    Ok(())
117 -
}
118 -
119 -
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
120 -
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
121 -
}
122 -
123 -
/// A directory's entries, sorted by name so every runner prints the same.
124 -
/// A version as three numbers, or `None`.
125 -
fn version(s: &str) -> Option<(u64, u64, u64)> {
126 -
    let mut parts = s.split('.').map(|p| p.parse::<u64>().ok());
127 -
    let v = (parts.next()??, parts.next()??, parts.next()??);
128 -
    parts.next().is_none().then_some(v)
129 -
}
130 -
131 -
/// A `YYYY-MM-DD` date with a real month and day.
132 -
fn is_date(s: &str) -> bool {
133 -
    let b = s.as_bytes();
134 -
    b.len() == 10
135 -
        && b[4] == b'-'
136 -
        && b[7] == b'-'
137 -
        && s[..4].parse::<u32>().is_ok()
138 -
        && s[5..7].parse::<u32>().is_ok_and(|m| (1..=12).contains(&m))
139 -
        && s[8..].parse::<u32>().is_ok_and(|d| (1..=31).contains(&d))
140 -
}
141 -
142 -
/// The version a manifest declares: `version = "…"` in `Cargo.toml`'s
143 -
/// `[package]` or `[workspace.package]`, a `….version=` key of a
144 -
/// `.properties` file, or `"version"` in a `package.json`.
145 -
fn manifest_version(path: &str, text: &str) -> Option<String> {
146 -
    let unquote = |v: &str| v.trim().trim_matches('"').trim_end_matches(',').trim_matches('"').to_string();
147 -
    if path.ends_with(".properties") {
148 -
        return text.lines().find_map(|l| {
149 -
            let (k, v) = l.split_once('=')?;
150 -
            (k.trim() == "version" || k.trim().ends_with(".version")).then(|| v.trim().to_string())
151 -
        });
152 -
    }
153 -
    if path.ends_with(".json") {
154 -
        return text.lines().find_map(|l| l.trim().strip_prefix("\"version\":").map(unquote));
155 -
    }
156 -
    let mut section = "";
157 -
    for l in text.lines() {
158 -
        let l = l.trim();
159 -
        if l.starts_with('[') {
160 -
            section = l;
161 -
        } else if matches!(section, "[package]" | "[workspace.package]") {
162 -
            if let Some(v) = l.strip_prefix("version").and_then(|r| r.trim_start().strip_prefix('=')) {
163 -
                return Some(unquote(v));
164 -
            }
165 -
        }
166 -
    }
167 -
    None
168 -
}
169 -
170 -
/// The changelog's shape, and the manifest's version in it.
171 -
fn changelog(root: &Path, file: &str, manifest: Option<&String>, out: &mut impl Write) -> Done {
172 -
    let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?;
173 -
    let mut sections: Vec<(usize, String, usize)> = Vec::new(); // (line, heading, entries)
174 -
    for (i, line) in text.lines().enumerate() {
175 -
        if let Some(h) = line.strip_prefix("## ") {
176 -
            sections.push((i + 1, h.trim().to_string(), 0));
177 -
        } else if let Some(last) = sections.last_mut() {
178 -
            if line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") {
179 -
                last.2 += 1;
180 -
            }
181 -
        }
182 -
    }
183 -
    let mut problems = Vec::new();
184 -
    let mut last: Option<(u64, u64, u64)> = None;
185 -
    let mut released = Vec::new();
186 -
    for (k, (line, h, n)) in sections.iter().enumerate() {
187 -
        if h == "Unreleased" {
188 -
            if k != 0 {
189 -
                problems.push(format!("{file}:{line}: ## Unreleased comes first"));
190 -
            }
191 -
            continue;
192 -
        }
193 -
        let (v, rest) = h.split_once(' ').unwrap_or((h.as_str(), ""));
194 -
        let date = rest.trim().strip_prefix('(').and_then(|r| r.strip_suffix(')'));
195 -
        match (version(v), date) {
196 -
            (Some(ver), Some(d)) if is_date(d) => {
197 -
                if last.is_some_and(|l| ver >= l) {
198 -
                    problems.push(format!("{file}:{line}: {v} is not older than the section above it"));
199 -
                }
200 -
                last = Some(ver);
201 -
                released.push(v.to_string());
202 -
                if *n == 0 {
203 -
                    problems.push(format!("{file}:{line}: ## {h} has no entries"));
204 -
                }
205 -
            }
206 -
            _ => problems.push(format!("{file}:{line}: \"## {h}\" is not \"## X.Y.Z (YYYY-MM-DD)\" or \"## Unreleased\"")),
207 -
        }
208 -
    }
209 -
    let mut said = format!("changelog: {} released section{}", released.len(), if released.len() == 1 { "" } else { "s" });
210 -
    if let Some(m) = manifest {
211 -
        let mtext = fs::read_to_string(root.join(m)).map_err(|e| format!("{m}: {e}"))?;
212 -
        match manifest_version(m, &mtext) {
213 -
            Some(v) if released.contains(&v) => said.push_str(&format!(", {m} at {v} has its section")),
214 -
            Some(v) => problems.push(format!(
215 -
                "{m} says version {v}, but {file} has no \"## {v} (YYYY-MM-DD)\" section: a release of it would have no notes"
216 -
            )),
217 -
            None => problems.push(format!("{m}: no version found")),
218 -
        }
219 -
    }
220 -
    if problems.is_empty() {
221 -
        return emit(out, &said);
222 -
    }
223 -
    Err(problems.join("\n"))
224 -
}
225 -
226 -
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
227 -
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
228 -
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
229 -
    all.sort();
230 -
    Ok(all)
231 -
}
232 -
233 -
/// Every file under `dir`, depth first, in name order; links are not followed.
234 -
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
235 -
    let mut found = Vec::new();
236 -
    for p in entries(dir)? {
237 -
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
238 -
        if kind.is_dir() {
239 -
            found.extend(files(&p)?);
240 -
        } else if kind.is_file() {
241 -
            found.push(p);
242 -
        }
243 -
    }
244 -
    Ok(found)
245 -
}
246 -
247 -
/// A path as the workspace names it, with `/` between its parts.
248 -
fn shown(root: &Path, p: &Path) -> String {
249 -
    let rel = p.strip_prefix(root).unwrap_or(p);
250 -
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
251 -
}
252 -
253 -
fn shown_dirs(dirs: &[String]) -> String {
254 -
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
255 -
}
256 -
257 -
#[cfg(test)]
258 -
mod tests {
259 -
    use super::*;
260 -
261 -
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
262 -
        let dir = tempfile::tempdir().unwrap();
263 -
        for (path, text) in files {
264 -
            let p = dir.path().join(path);
265 -
            fs::create_dir_all(p.parent().unwrap()).unwrap();
266 -
            fs::write(p, text).unwrap();
267 -
        }
268 -
        dir
269 -
    }
270 -
271 -
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
272 -
        let mut out = Vec::new();
273 -
        let done = f(&mut out);
274 -
        (done, String::from_utf8(out).unwrap())
275 -
    }
276 -
277 -
    fn args(a: &[&str]) -> Vec<String> {
278 -
        a.iter().map(|s| s.to_string()).collect()
279 -
    }
280 -
281 -
    #[test]
282 -
    fn changelog_shape_and_the_manifest_version() {
283 -
        let good = "# Changelog\n\nIntro.\n\n## Unreleased\n\n## 0.2.0 (2026-11-03)\n\n- **New.**\n\n## 0.1.9 (2026-10-01)\n\n- Old.\n";
284 -
        let dir = tree(&[
285 -
            ("CHANGELOG.md", good),
286 -
            ("Cargo.toml", "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.2.0\"\n"),
287 -
            ("gradle.properties", "android.useAndroidX=true\noxygenhub.version=0.1.9\n"),
288 -
            ("package.json", "{\n  \"name\": \"x\",\n  \"version\": \"0.3.0\",\n}\n"),
289 -
        ]);
290 -
        let r = |m: Option<&str>| run(|o| changelog(dir.path(), "CHANGELOG.md", m.map(String::from).as_ref(), o));
291 -
        let (done, out) = r(None);
292 -
        assert_eq!((done, out.trim()), (Ok(()), "changelog: 2 released sections"));
293 -
        assert!(r(Some("Cargo.toml")).1.contains("Cargo.toml at 0.2.0 has its section"));
294 -
        assert!(r(Some("gradle.properties")).0.is_ok(), "an older release's section counts");
295 -
        let e = r(Some("package.json")).0.unwrap_err();
296 -
        assert!(e.contains("version 0.3.0") && e.contains("no \"## 0.3.0 (YYYY-MM-DD)\""), "{e}");
297 -
        let bad = "## 0.1.0 (2026-10-01)\n\n- a\n\n## Unreleased\n\n## 0.2.0 (2026-13-01)\n\n- b\n\n## 0.0.9 (2026-09-01)\n\n## v1\n";
298 -
        let dir = tree(&[("CHANGELOG.md", bad)]);
299 -
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
300 -
        assert!(e.contains(":5: ## Unreleased comes first"), "{e}");
301 -
        assert!(e.contains(":7: \"## 0.2.0 (2026-13-01)\" is not"), "a month 13: {e}");
302 -
        assert!(e.contains(":11: ## 0.0.9 (2026-09-01) has no entries"), "{e}");
303 -
        assert!(e.contains(":13: \"## v1\""), "{e}");
304 -
        let order = "## 0.1.0 (2026-10-01)\n\n- a\n\n## 0.2.0 (2026-11-01)\n\n- b\n";
305 -
        let dir = tree(&[("CHANGELOG.md", order)]);
306 -
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
307 -
        assert!(e.contains("0.2.0 is not older than the section above it"), "{e}");
308 -
    }
309 -
310 -
    #[test]
311 -
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
312 -
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
313 -
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
314 -
        assert_eq!(done, Ok(()));
315 -
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
316 -
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
317 -
        assert_eq!(done, Err("missing: nope.rs".to_string()));
318 -
        assert_eq!(out, "found Cargo.toml\n");
319 -
        // A directory is not the file asked for.
320 -
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
321 -
    }
322 -
323 -
    #[test]
324 -
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
325 -
        let marker = MARKERS[0];
326 -
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
327 -
        let t = tree(&[
328 -
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
329 -
            ("crates/b/src/many.rs", &many),
330 -
            ("crates/b/blob.bin", "\u{0}"),
331 -
        ]);
332 -
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
333 -
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
334 -
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
335 -
        assert_eq!(done, Ok(()));
336 -
        let lines: Vec<&str> = out.lines().collect();
337 -
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
338 -
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
339 -
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
340 -
        assert_eq!(lines.len(), 1 + LISTED);
341 -
    }
342 -
343 -
    #[test]
344 -
    fn size_counts_rust_lines_per_crate_in_name_order() {
345 -
        let t = tree(&[
346 -
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
347 -
            ("crates/alpha/src/lib.rs", "a\n"),
348 -
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
349 -
            ("crates/alpha/README.md", "not rust\n"),
350 -
            ("crates/loose.txt", "a file, not a crate\n"),
351 -
        ]);
352 -
        let (done, out) = run(|o| size(t.path(), "crates", o));
353 -
        assert_eq!(done, Ok(()));
354 -
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
355 -
    }
356 -
357 -
    #[test]
358 -
    fn summary_names_the_project() {
359 -
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
360 -
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
361 -
    }
362 -
}
modified crates/flow-core/Cargo.toml +0 −3
…
14 unchanged lines
15 15
kdl = "6"
16 16
miette = "7"
17 17
globset = "0.4"
18 -
# Sealed alerts (alert.rs): both already in the tree through oxy-core.
19 -
curve25519-dalek = "5"
20 -
ring = "0.17"
removed crates/flow-core/src/alert.rs +0 −94
1 -
//! Sealed alerts (oxygenhub-android#16): what a phone is told about a job,
2 -
//! encrypted to a key the phone made, so the push service that carries it
3 -
//! (Firebase Cloud Messaging) sees only ciphertext.
4 -
//!
5 -
//! The phone keeps an X25519 secret and gives the hub its public key. For
6 -
//! each alert the hub makes a fresh X25519 key, agrees a secret with the
7 -
//! phone's key, derives a ChaCha20-Poly1305 key with HKDF-SHA256 (salted
8 -
//! with both public keys) and encrypts once under it with a zero nonce:
9 -
//! the key is never used twice. A sealed alert is the fresh public key (32
10 -
//! bytes) followed by the ciphertext and its tag.
11 -
12 -
use curve25519_dalek::montgomery::MontgomeryPoint;
13 -
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305};
14 -
use ring::hkdf;
15 -
use ring::rand::{SecureRandom, SystemRandom};
16 -
17 -
/// What HKDF binds the key to, so a key for anything else never fits.
18 -
const INFO: &[u8] = b"oxygenhub alert v1";
19 -
20 -
/// A new X25519 secret, for a phone to keep.
21 -
pub fn new_secret() -> [u8; 32] {
22 -
    let mut s = [0u8; 32];
23 -
    SystemRandom::new().fill(&mut s).expect("the system's random source");
24 -
    s
25 -
}
26 -
27 -
/// The public key of `secret`, for the hub.
28 -
pub fn public_key(secret: &[u8; 32]) -> [u8; 32] {
29 -
    MontgomeryPoint::mul_base_clamped(*secret).to_bytes()
30 -
}
31 -
32 -
fn key(shared: &MontgomeryPoint, ephemeral: &[u8; 32], recipient: &[u8; 32]) -> Option<LessSafeKey> {
33 -
    // An all-zero agreement means a low-order point was given: refuse it.
34 -
    if shared.to_bytes() == [0u8; 32] {
35 -
        return None;
36 -
    }
37 -
    let mut salt = [0u8; 64];
38 -
    salt[..32].copy_from_slice(ephemeral);
39 -
    salt[32..].copy_from_slice(recipient);
40 -
    let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &salt).extract(shared.as_bytes());
41 -
    let okm = prk.expand(&[INFO], &CHACHA20_POLY1305).ok()?;
42 -
    let mut bytes = [0u8; 32];
43 -
    okm.fill(&mut bytes).ok()?;
44 -
    Some(LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &bytes).ok()?))
45 -
}
46 -
47 -
/// Seal `message` to the phone whose public key is `recipient`; `None`
48 -
/// when that key is unusable.
49 -
pub fn seal(recipient: &[u8; 32], message: &[u8]) -> Option<Vec<u8>> {
50 -
    let e = new_secret();
51 -
    let ephemeral = public_key(&e);
52 -
    let shared = MontgomeryPoint(*recipient).mul_clamped(e);
53 -
    let key = key(&shared, &ephemeral, recipient)?;
54 -
    let mut sealed = message.to_vec();
55 -
    key.seal_in_place_append_tag(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut sealed).ok()?;
56 -
    let mut out = ephemeral.to_vec();
57 -
    out.extend(sealed);
58 -
    Some(out)
59 -
}
60 -
61 -
/// Open an alert sealed to the phone whose secret is `secret`; `None` when
62 -
/// it was not sealed to it or was changed on the way.
63 -
pub fn open(secret: &[u8; 32], sealed: &[u8]) -> Option<Vec<u8>> {
64 -
    if sealed.len() < 32 + CHACHA20_POLY1305.tag_len() {
65 -
        return None;
66 -
    }
67 -
    let ephemeral: [u8; 32] = sealed[..32].try_into().ok()?;
68 -
    let shared = MontgomeryPoint(ephemeral).mul_clamped(*secret);
69 -
    let key = key(&shared, &ephemeral, &public_key(secret))?;
70 -
    let mut body = sealed[32..].to_vec();
71 -
    let plain = key.open_in_place(Nonce::assume_unique_for_key([0u8; 12]), Aad::empty(), &mut body).ok()?;
72 -
    Some(plain.to_vec())
73 -
}
74 -
75 -
#[cfg(test)]
76 -
mod tests {
77 -
    use super::*;
78 -
79 -
    #[test]
80 -
    fn a_sealed_alert_opens_only_with_its_phones_key() {
81 -
        let phone = new_secret();
82 -
        let sealed = seal(&public_key(&phone), b"run #5: test passed").unwrap();
83 -
        assert_eq!(open(&phone, &sealed).as_deref(), Some(&b"run #5: test passed"[..]));
84 -
        // Each alert has its own key: the same message seals differently.
85 -
        assert_ne!(seal(&public_key(&phone), b"run #5: test passed").unwrap(), sealed);
86 -
        assert!(open(&new_secret(), &sealed).is_none(), "another phone");
87 -
        let mut changed = sealed.clone();
88 -
        *changed.last_mut().unwrap() ^= 1;
89 -
        assert!(open(&phone, &changed).is_none(), "changed on the way");
90 -
        assert!(open(&phone, &sealed[..40]).is_none(), "cut short");
91 -
        // A low-order public key (all zeros) is refused, not sealed to.
92 -
        assert!(seal(&[0u8; 32], b"x").is_none());
93 -
    }
94 -
}
modified crates/flow-core/src/api.rs +0 −184
…
47 unchanged lines
48 48
    /// docs/jetstream.md §6). Absent: an older runner.
49 49
    #[serde(default, skip_serializing_if = "Option::is_none")]
50 50
    pub facts: Option<Facts>,
51 -
    /// Where this runner serves image chunks to its peers (docs/jetstream.md
52 -
    /// §8.1). Absent: it is not in a swarm.
53 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
54 -
    pub swarm: Option<SwarmAddr>,
55 -
    /// The build caches this runner holds a disk of (oxygenflow#51), each
56 -
    /// [`cache_slot`] of a repository and job, so the hub can send a job
57 -
    /// where its cache is. Absent: an older runner.
58 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
59 -
    pub caches: Option<Vec<String>>,
60 -
    /// When each of [`ClaimRequest::caches`] last kept a disk, Unix ms, so
61 -
    /// the hub can send a job to the runner whose copy is freshest when
62 -
    /// several hold one (oxygenflow#54). Absent: an older runner.
63 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
64 -
    pub cache_times: Option<std::collections::BTreeMap<String, u64>>,
65 -
}
66 -
67 -
/// A name safe as one path segment: kept readable, with a short hash so
68 -
/// two names that clean up alike stay apart. The build cache's pool is laid
69 -
/// out by these (oxygenflow#28).
70 -
pub fn segment(s: &str) -> String {
71 -
    let clean: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '_' }).take(48).collect();
72 -
    format!("{clean}-{}", &blake3::hash(s.as_bytes()).to_hex()[..8])
73 -
}
74 -
75 -
/// Where a repository's job keeps its build caches, as runners report
76 -
/// them in [`ClaimRequest::caches`] (oxygenflow#51): `repo/job`, each part
77 -
/// a [`segment`].
78 -
pub fn cache_slot(repo: &str, job: &str) -> String {
79 -
    format!("{}/{}", segment(repo), segment(job))
80 -
}
81 -
82 -
/// A runner's place in the image swarm: the address peers reach it on,
83 -
/// the site it is in (peers are only ever in the same site), and the
84 -
/// BLAKE3 of its TLS certificate, which peers check it by.
85 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86 -
pub struct SwarmAddr {
87 -
    pub addr: String,
88 -
    #[serde(default)]
89 -
    pub site: String,
90 -
    #[serde(default)]
91 -
    pub cert: String,
92 -
}
93 -
94 -
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
95 -
/// fetch of `image`.
96 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97 -
pub struct Announce {
98 -
    pub image: String,
99 -
    /// `fetching`, `done` or `stopped`.
100 -
    pub state: String,
101 -
}
102 -
103 -
/// The hub's answer to an announce: the peers to ask, and the ticket that
104 -
/// admits this runner to them.
105 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
106 -
pub struct Announced {
107 -
    pub ticket: String,
108 -
    pub peers: Vec<SwarmPeer>,
109 -
    /// Announce again after this many seconds while still fetching.
110 -
    pub interval_s: u64,
111 -
    /// Whether the hub holds the image's chunks and so may be asked for
112 -
    /// them (jetstream.md §8.1.1): `Some(false)` for an image only runners
113 -
    /// hold; absent from a hub that predates it, which always seeds.
114 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
115 -
    pub seed: Option<bool>,
116 -
}
117 -
118 -
/// `POST /flow/images/held`: the images a runner holds, by hash
119 -
/// (oxygenflow#94).
120 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
121 -
pub struct HeldImages {
122 -
    pub images: Vec<String>,
123 -
}
124 -
125 -
/// The hub's answer to [`HeldImages`]: those it keeps no manifest for, which
126 -
/// the runner sends with `PUT /flow/images/{hash}/held`.
127 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
128 -
pub struct WantedManifests {
129 -
    pub wanted: Vec<String>,
130 -
}
131 -
132 -
/// A peer in the swarm for one image.
133 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134 -
pub struct SwarmPeer {
135 -
    pub runner: i64,
136 -
    pub name: String,
137 -
    pub addr: String,
138 -
    /// The BLAKE3 of its TLS certificate: a peer that shows another is not
139 -
    /// this runner.
140 -
    pub cert: String,
141 -
    /// It holds the whole image (rather than fetching it now).
142 -
    pub complete: bool,
143 51
}
144 52
145 53
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
…
153 unchanged lines
299 207
    /// them for the repository (oxygenhub#107).
300 208
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 209
    pub secrets: Vec<SecretInput>,
302 -
    /// The job's inputs (oxygenflow#58), in the job's order, each with the
303 -
    /// blob the producing job's receipt names: fetched from
304 -
    /// `/api/v1/flow/jobs/{job_id}/inputs/{index}` and checked against it.
305 -
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 -
    pub inputs: Vec<ArtifactInput>,
307 -
    /// How long the job is expected to take, from its recent passing runs,
308 -
    /// when the hub has learned it (#76): a runner leaving with some notice
309 -
    /// finishes a job whose rest fits it and hands back the others.
310 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
311 -
    pub expected_ms: Option<u64>,
312 -
    /// The checkpoint an earlier attempt saved before its runner handed the
313 -
    /// job back (oxygenflow#77): the blob to fetch from
314 -
    /// `/api/v1/flow/jobs/{job_id}/checkpoint` and check, then restore
315 -
    /// before the job runs.
316 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
317 -
    pub checkpoint: Option<String>,
318 -
}
319 -
320 -
/// An artifact a job takes (oxygenflow#58), as the hub hands it over.
321 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322 -
pub struct ArtifactInput {
323 -
    pub artifact: String,
324 -
    /// The producing job.
325 -
    pub from: String,
326 -
    /// Where it goes, under `/src`.
327 -
    pub path: String,
328 -
    /// The Oxygen blob reference its receipt names, hex.
329 -
    pub blob: String,
330 210
}
331 211
332 212
impl Claim {
…
41 unchanged lines
374 254
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
375 255
pub struct Decline {
376 256
    pub reason: String,
377 -
}
378 -
379 -
/// `GET /api/v1/flow/runner/update` (docs/jetstream.md §8.2, oxygenhub#322):
380 -
/// the update policy that applies to the asking runner, and the build it
381 -
/// should run when that is not the one it runs.
382 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
383 -
pub struct UpdateAnswer {
384 -
    pub policy: UpdatePolicy,
385 -
    /// The build to install now; none when updates are off, the runner is
386 -
    /// up to date, it is outside its window, or no build suits it.
387 -
    #[serde(default)]
388 -
    pub offer: Option<UpdateOffer>,
389 -
    /// Why there is no offer, or what the offer is for, in words.
390 -
    #[serde(default)]
391 -
    pub note: String,
392 -
}
393 -
394 -
/// A runner's update policy once every level is applied (nearest wins).
395 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
396 -
pub struct UpdatePolicy {
397 -
    /// Off unless some level turned it on.
398 -
    pub updates: bool,
399 -
    /// `stable`, `canary`, or a version such as `0.3.4`.
400 -
    pub channel: String,
401 -
    /// Hours (UTC) in which it may update, from and to, when limited.
402 -
    #[serde(default)]
403 -
    pub window: Option<(u8, u8)>,
404 -
    /// Fetch and check, say what would happen, do not swap.
405 -
    #[serde(default)]
406 -
    pub dry_run: bool,
407 -
    /// Where each setting came from: `site`, `owner acme`, `group canary`,
408 -
    /// `runner`, or `default`, in the order updates, channel, window, dry run.
409 -
    #[serde(default)]
410 -
    pub from: Vec<String>,
411 -
}
412 -
413 -
/// A build to install: a release asset of the site's runner releases.
414 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
415 -
pub struct UpdateOffer {
416 -
    pub version: String,
417 -
    pub tag: String,
418 -
    /// The asset's name, `flow-linux-x86_64`.
419 -
    pub asset: String,
420 -
    /// Where to download it, relative to the hub.
421 -
    pub url: String,
422 -
    /// Its BLAKE3, as the hub stores and attests it: the download must match.
423 -
    pub blake3: String,
424 -
    pub size: u64,
425 -
    /// Older than the runner's build: a pinned channel going back.
426 -
    #[serde(default)]
427 -
    pub older: bool,
428 257
}
429 258
430 -
/// `POST /api/v1/flow/runner/update`: what the runner did about its update,
431 -
/// shown on its page.
432 -
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
433 -
pub struct UpdateReport {
434 -
    pub note: String,
435 -
}
436 -
437 259
/// `POST /api/v1/flow/jobs/{id}/log`.
438 260
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
439 261
pub struct LogBatch {
…
20 unchanged lines
460 282
    pub peak_disk_mib: Option<u64>,
461 283
    #[serde(default, skip_serializing_if = "Option::is_none")]
462 284
    pub peak_memory_mib: Option<u64>,
463 -
    /// Where the job's time went, in milliseconds (oxygenflow#35): `image`
464 -
    /// (fetching its image, when the runner lacked it), `prepare` (its
465 -
    /// workspace and disks), `run` (the sandbox, from start to stop) and
466 -
    /// `finish` (its outputs, caches and receipt).
467 -
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
468 -
    pub phases: std::collections::BTreeMap<String, u64>,
469 285
}
470 286
471 287
/// Longest text of one record the hub keeps; longer lines are cut.
…
3 unchanged lines
modified crates/flow-core/src/canonical.rs +1 −29
…
46 unchanged lines
47 47
    if let Some(a) = &s.arch {
48 48
        fields.push((7, text(a)));
49 49
    }
50 -
    // The hosts egress may reach, sorted; absent without egress.
51 -
    if !s.egress_allow.is_empty() {
52 -
        fields.push((8, texts(&s.egress_allow)));
53 -
    }
54 50
    Value::map(fields)
55 51
}
56 52
…
15 unchanged lines
72 68
            Value::Array(
73 69
                j.mounts
74 70
                    .iter()
75 -
                    .map(|m| {
76 -
                        // A cache mount adds its name and key files; a plain
77 -
                        // one encodes as before caches existed.
78 -
                        let mut v = vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)];
79 -
                        if let Some(c) = &m.cache {
80 -
                            v.push(text(c));
81 -
                            v.push(Value::Array(m.key.iter().map(|k| text(k)).collect()));
82 -
                        }
83 -
                        Value::Array(v)
84 -
                    })
71 +
                    .map(|m| Value::Array(vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)]))
85 72
                    .collect(),
86 73
            ),
87 74
        ));
…
17 unchanged lines
105 92
    // Identities as [audience, mount path] sorted by audience; absent when none.
106 93
    if !j.identities.is_empty() {
107 94
        fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect())));
108 -
    }
109 -
    // Inputs as [artifact, from, path] sorted by path; absent when none.
110 -
    // Approval as [by, others-only, expires seconds]; absent without one.
111 -
    if let Some(a) = &j.approval {
112 -
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s), Value::Bool(a.sealed)])));
113 -
    }
114 -
    // The environment it deploys to; absent without one.
115 -
    if let Some(e) = &j.environment {
116 -
        fields.push((12, text(e)));
117 -
    }
118 -
    if !j.inputs.is_empty() {
119 -
        fields.push((
120 -
            10,
121 -
            Value::Array(j.inputs.iter().map(|i| Value::Array(vec![text(&i.artifact), text(&i.from), text(&i.path)])).collect()),
122 -
        ));
123 95
    }
124 96
    Value::map(fields)
125 97
}
…
42 unchanged lines
modified crates/flow-core/src/lib.rs +2 −3
…
3 unchanged lines
4 4
/// This library's version, for hosts that report what they are built on.
5 5
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
6 6
7 -
pub mod alert;
8 7
pub mod api;
9 8
pub mod canonical;
10 9
pub mod cron;
…
2 unchanged lines
13 12
14 13
pub use canonical::{object_hash, scope_tree_hash, Hash};
15 14
pub use pipeline::{
16 -
    arch_ok, egress_allows, egress_pattern_ok, Approval, Identity, Input, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline,
17 -
    Problem, Problems, Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH,
15 +
    arch_ok, Identity, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, Problems, Runtime, Sandbox, Secret,
16 +
    PIPELINE_PATH,
18 17
};
19 18
pub use receipt::{Environment, Receipt};
20 19
…
2 unchanged lines
modified crates/flow-core/src/pipeline.rs +13 −406
…
152 unchanged lines
153 153
    /// Labels a runner must have to run jobs here (`runs-on`), sorted.
154 154
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 155
    pub runs_on: Vec<String>,
156 -
    /// The hosts a `network "egress"` sandbox may reach (oxygenflow#55):
157 -
    /// names, or `*.` and a domain for any name under it; sorted. Its jobs
158 -
    /// reach them through the runner's proxy, and nothing else.
159 -
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 -
    pub egress_allow: Vec<String>,
161 156
    /// How long each job here may run, in seconds (`timeout "2h"`), when
162 157
    /// the job does not say; the hub caps it at the site's maximum.
163 158
    #[serde(default, skip_serializing_if = "Option::is_none")]
…
26 unchanged lines
190 185
    /// Files the job produces, sorted by name (spec §5.2 `output`).
191 186
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 187
    pub outputs: Vec<Output>,
193 -
    /// Artifacts of jobs this one depends on, put in its workspace before it
194 -
    /// starts (oxygenflow#58), sorted by path. The runner checks each against
195 -
    /// the producing job's receipt; this job's receipt lists them as inputs.
196 -
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 -
    pub inputs: Vec<Input>,
198 -
    /// Wait for a person before running (oxygenflow#57): `approval { by
199 -
    /// "maintain"; others-only #true; expires "24h" }`.
200 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
201 -
    pub approval: Option<Approval>,
202 -
    /// The environment the job deploys to (oxygenflow#59), e.g. `prod`: the
203 -
    /// hub records what each environment runs, and the job's identity token
204 -
    /// names it once the job has been approved.
205 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
206 -
    pub environment: Option<String>,
207 188
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
208 189
    /// supplies each by name, the runner mounts it as a file.
209 190
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
…
13 unchanged lines
223 204
    /// When it runs, over the pipeline's `on` (oxygenflow#24).
224 205
    #[serde(default, skip_serializing_if = "Option::is_none")]
225 206
    pub on: Option<On>,
226 -
    /// `low` or `normal` (`priority "low"`, or the pipeline's): a pipeline
227 -
    /// may lower its jobs below the repository's level, never raise them
228 -
    /// (oxygenhub#244); higher levels are the hub's to give.
229 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
230 -
    pub priority: Option<String>,
231 -
    /// Directories under /src that hold the job's progress (`checkpoint
232 -
    /// "/src/target"`, oxygenflow#77), sorted: saved when its runner hands
233 -
    /// it back, restored before the next attempt runs the same exec.
234 -
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 -
    pub checkpoints: Vec<String>,
236 207
}
237 208
238 209
impl Job {
…
23 unchanged lines
262 233
}
263 234
264 235
/// An ephemeral scratch mount: empty at the start of every job and gone
265 -
/// after it, so it cannot carry anything between jobs (spec §2.2 I4) --
266 -
/// unless it names a `cache` (spec §5.4, #28): then a disk kept from an
267 -
/// earlier passing job may start it, and the receipt says which.
236 +
/// after it, so it cannot carry anything between jobs (spec §2.2 I4).
268 237
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
269 238
pub struct Mount {
270 239
    pub source: MountSource,
271 240
    /// An absolute path under `/src` or `/tmp`.
272 241
    pub target: String,
273 242
    pub size_mib: u32,
274 -
    /// `cache "name"`: keep this disk after a passing job and start later
275 -
    /// jobs from it (disk mounts only).
276 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
277 -
    pub cache: Option<String>,
278 -
    /// `key "Cargo.lock" ...`: files whose content picks the cache entry.
279 -
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 -
    pub key: Vec<String>,
281 243
}
282 244
283 245
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
…
32 unchanged lines
316 278
    pub kind: OutputKind,
317 279
}
318 280
319 -
/// An artifact a job takes from a job it depends on (oxygenflow#58):
320 -
/// `input { artifact "tfplan" from "plan"; path "/src/tfplan" }`.
321 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322 -
pub struct Input {
323 -
    /// The producing job's artifact.
324 -
    pub artifact: String,
325 -
    /// The producing job: one this job depends on.
326 -
    pub from: String,
327 -
    /// Where it is put: a clean absolute path under `/src`.
328 -
    pub path: String,
329 -
}
330 -
331 -
/// Who must approve a job before it runs, and for how long it waits
332 -
/// (oxygenflow#57).
333 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334 -
pub struct Approval {
335 -
    /// The least role that approves: `write`, `maintain` (the default) or `admin`.
336 -
    pub by: String,
337 -
    /// Not the person whose push started the run.
338 -
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
339 -
    pub others_only: bool,
340 -
    /// How long it waits once the jobs it depends on are done, in seconds.
341 -
    pub expires_s: u64,
342 -
    /// The approval must be signed with the approver's own key
343 -
    /// (`oxy seal --flow`; oxygenhub#235). Defaults to on for a job that
344 -
    /// names an environment.
345 -
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
346 -
    pub sealed: bool,
347 -
}
348 -
349 -
/// The longest a job waits for approval: 30 days.
350 -
const MAX_APPROVAL_S: u64 = 30 * 24 * 3600;
351 -
352 -
/// The most inputs a job takes.
353 -
const MAX_INPUTS: usize = 8;
354 -
355 281
/// What an [`Output`] is.
356 282
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
357 283
#[serde(rename_all = "lowercase")]
…
245 unchanged lines
603 529
        let mut sandboxes = BTreeMap::new();
604 530
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
605 531
        let mut on = None;
606 -
        let mut priority = None;
607 532
        for node in doc.nodes() {
608 533
            match node.name().value() {
609 -
                "priority" => {
610 -
                    if priority.is_some() {
611 -
                        ctx.err(node.span(), "`priority` is given twice");
612 -
                    }
613 -
                    priority = parse_priority(&mut ctx, node);
614 -
                }
615 534
                "on" => {
616 535
                    if on.is_some() {
617 536
                        ctx.err(node.span(), "`on` is given twice");
…
27 unchanged lines
645 564
                    }
646 565
                }
647 566
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
648 -
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, priority, sandbox or job")),
567 +
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, sandbox or job")),
649 568
            }
650 569
        }
651 570
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
…
12 unchanged lines
664 583
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
665 584
                } else if d == &job.name {
666 585
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
667 -
                }
668 -
            }
669 -
            // Inputs come from a job this one depends on, which declares
670 -
            // that artifact (oxygenflow#58).
671 -
            for i in &job.inputs {
672 -
                let Some((producer, _)) = jobs.get(&i.from) else {
673 -
                    ctx.err(*span, format!("job {:?} takes an input from {:?}, which is not declared", job.name, i.from));
674 -
                    continue;
675 -
                };
676 -
                if !job.depends_on.contains(&i.from) {
677 -
                    ctx.err(*span, format!("job {:?} takes {:?} from {:?}: add depends-on \"{}\"", job.name, i.artifact, i.from, i.from));
678 586
                }
679 -
                match producer.outputs.iter().find(|o| o.name == i.artifact) {
680 -
                    Some(o) if o.kind == OutputKind::Artifact => {}
681 -
                    Some(_) => ctx.err(*span, format!("{:?} of job {:?} is a report; inputs are artifacts", i.artifact, i.from)),
682 -
                    None => ctx.err(*span, format!("job {:?} declares no artifact {:?}", i.from, i.artifact)),
683 -
                }
684 587
            }
685 588
        }
686 589
        let graph: BTreeMap<&str, Vec<&str>> =
…
6 unchanged lines
693 596
            ctx.problems.sort_by_key(|p| p.line);
694 597
            return Err(Problems(ctx.problems));
695 598
        }
696 -
        // The pipeline's priority is each job's that does not say.
697 -
        let jobs = jobs
698 -
            .into_iter()
699 -
            .map(|(k, (mut j, _))| {
700 -
                if j.priority.is_none() {
701 -
                    j.priority.clone_from(&priority);
702 -
                }
703 -
                (k, j)
704 -
            })
705 -
            .collect();
706 -
        Ok(Pipeline { schema_version: 1, sandboxes, jobs, on })
599 +
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect(), on })
707 600
    }
708 601
709 602
    /// Jobs in an order where each comes after what it depends on (ties by name).
…
93 unchanged lines
803 696
    let mut runs_on = Vec::new();
804 697
    let mut timeout_s = None;
805 698
    let mut arch: Option<String> = None;
806 -
    let mut egress_allow = Vec::new();
807 699
    for c in block(ctx, node) {
808 700
        let key = c.name().value();
809 701
        if key != "runs-on" && !seen.insert(key.to_string()) {
…
8 unchanged lines
818 710
            parse_labels(ctx, c, &mut runs_on);
819 711
            continue;
820 712
        }
821 -
        if key != "network" {
822 -
            ctx.no_children(c);
823 -
        }
713 +
        ctx.no_children(c);
824 714
        match key {
825 715
            "runtime" => {
826 716
                runtime = match ctx.one_string(c).as_deref() {
827 717
                    Some("microvm") => Some(Runtime::Microvm),
828 718
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
829 -
                    // A WASI module run by wasmtime (spec §4.1, oxygenflow#75).
830 -
                    Some("wasm") => Some(Runtime::Wasm),
719 +
                    Some("wasm") => {
720 +
                        ctx.err(c.span(), "runtime \"wasm\" is not in M0 yet; use \"microvm\" or \"syscall\"");
721 +
                        None
722 +
                    }
831 723
                    Some(other) => {
832 724
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
833 725
                        None
…
52 unchanged lines
886 778
                }
887 779
            }
888 780
            "network" => {
889 -
                let kind = ctx.one_string(c);
890 -
                if kind.as_deref() != Some("egress") {
891 -
                    ctx.no_children(c);
892 -
                }
893 -
                network = match kind.as_deref() {
781 +
                network = match ctx.one_string(c).as_deref() {
894 782
                    Some("isolated") => Some(Network::Isolated),
895 783
                    // Loopback only: the guest's `lo` comes up, and it still has
896 784
                    // no device that reaches anything outside it.
897 785
                    Some("loopback") => Some(Network::Loopback),
898 -
                    // Allow-listed egress (oxygenflow#55): still no network
899 -
                    // device; a proxy in the guest relays to the runner, which
900 -
                    // lets through only the hosts named here.
901 786
                    Some("egress") => {
902 -
                        egress_allow = parse_egress(ctx, c);
903 -
                        Some(Network::Egress)
787 +
                        ctx.err(c.span(), "network \"egress\" is not in M0; M0 has \"isolated\" and \"loopback\"");
788 +
                        None
904 789
                    }
905 790
                    Some(other) => {
906 791
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
…
6 unchanged lines
913 798
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
914 799
        }
915 800
    }
916 -
    // A WASM sandbox has no image: its module is a file of the job (its
917 -
    // scope or an earlier job's artifact), and it reaches no network.
918 -
    let wasm = runtime == Some(Runtime::Wasm);
919 -
    if wasm {
920 -
        if seen.contains("rootfs") {
921 -
            ctx.err(node.span(), format!("sandbox {name:?}: runtime \"wasm\" takes no rootfs; its module is a file the job has"));
922 -
        }
923 -
        if network.is_some_and(|n| n != Network::Isolated) {
924 -
            ctx.err(node.span(), format!("sandbox {name:?}: runtime \"wasm\" has no network; leave out `network` or say \"isolated\""));
925 -
        }
926 -
        rootfs = Some(String::new());
927 -
        network = Some(Network::Isolated);
928 -
    } else if rootfs.is_none() && !seen.contains("rootfs") {
801 +
    if rootfs.is_none() && !seen.contains("rootfs") {
929 802
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
930 803
    }
931 -
    if network == Some(Network::Egress) {
932 -
        if runtime == Some(Runtime::Syscall) {
933 -
            ctx.err(node.span(), format!("sandbox {name:?}: network \"egress\" needs runtime \"microvm\""));
934 -
        }
935 -
        // Only a runner that allows egress offers this label.
936 -
        if !runs_on.iter().any(|l| l == EGRESS_LABEL) {
937 -
            runs_on.push(EGRESS_LABEL.to_string());
938 -
            runs_on.sort();
939 -
        }
940 -
    }
941 804
    if !named {
942 805
        return None;
943 806
    }
…
6 unchanged lines
950 813
        network: network?,
951 814
        env,
952 815
        runs_on,
953 -
        egress_allow,
954 816
        timeout_s,
955 817
        arch,
956 -
    })
957 -
}
958 -
959 -
/// The label a runner offers when it allows egress, and an egress sandbox
960 -
/// needs (oxygenflow#55).
961 -
pub const EGRESS_LABEL: &str = "egress";
962 -
963 -
/// The most hosts one sandbox may name.
964 -
const MAX_EGRESS_HOSTS: usize = 32;
965 -
966 -
/// Whether `pattern` is a host name, or `*.` and a domain: lower-case
967 -
/// letters, digits, `-` and dots, at least two labels.
968 -
pub fn egress_pattern_ok(pattern: &str) -> bool {
969 -
    let name = pattern.strip_prefix("*.").unwrap_or(pattern);
970 -
    let labels: Vec<&str> = name.split('.').collect();
971 -
    name.len() <= 253
972 -
        && labels.len() >= 2
973 -
        && labels.iter().all(|l| {
974 -
            !l.is_empty()
975 -
                && l.len() <= 63
976 -
                && !l.starts_with('-')
977 -
                && !l.ends_with('-')
978 -
                && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
979 -
        })
980 -
}
981 -
982 -
/// Whether `host` is one `patterns` allow: a name exactly, or a name under
983 -
/// a `*.` domain (not the domain itself). Case and a final dot are ignored.
984 -
pub fn egress_allows(patterns: &[String], host: &str) -> bool {
985 -
    let host = host.trim_end_matches('.').to_ascii_lowercase();
986 -
    patterns.iter().any(|p| match p.strip_prefix("*.") {
987 -
        Some(domain) => host.len() > domain.len() + 1 && host.ends_with(domain) && host[..host.len() - domain.len()].ends_with('.'),
988 -
        None => host == *p,
989 818
    })
990 819
}
991 820
992 -
/// `network "egress" { allow "*.googleapis.com" "registry.opentofu.org" }`.
993 -
fn parse_egress(ctx: &mut Ctx, node: &KdlNode) -> Vec<String> {
994 -
    let mut out = Vec::new();
995 -
    for c in block(ctx, node) {
996 -
        ctx.no_children(c);
997 -
        if c.name().value() != "allow" {
998 -
            ctx.err(c.span(), format!("unknown egress setting `{}`; egress takes `allow \"host\"...`", c.name().value()));
999 -
            continue;
1000 -
        }
1001 -
        for h in ctx.strings(c) {
1002 -
            if egress_pattern_ok(&h) {
1003 -
                out.push(h);
1004 -
            } else {
1005 -
                ctx.err(
1006 -
                    c.span(),
1007 -
                    format!("allow {h:?}: expected a lower-case host name such as \"registry.opentofu.org\", or \"*.\" and a domain"),
1008 -
                );
1009 -
            }
1010 -
        }
1011 -
    }
1012 -
    out.sort();
1013 -
    out.dedup();
1014 -
    if out.is_empty() {
1015 -
        ctx.err(node.span(), "network \"egress\" needs at least one `allow \"host\"`");
1016 -
    } else if out.len() > MAX_EGRESS_HOSTS {
1017 -
        ctx.err(node.span(), format!("network \"egress\" may allow at most {MAX_EGRESS_HOSTS} hosts"));
1018 -
    }
1019 -
    out
1020 -
}
1021 -
1022 821
/// An `on` block: `push` and `tag` with globs (none: any), `manual`,
1023 822
/// `schedule` with five-field cron lines.
1024 823
fn parse_on(ctx: &mut Ctx, node: &KdlNode) -> On {
…
67 unchanged lines
1092 891
}
1093 892
1094 893
/// A `timeout` line: one duration, from a minute to a day.
1095 -
/// `priority "low"`: a pipeline may lower its jobs, never raise them
1096 -
/// (oxygenhub#244).
1097 -
fn parse_priority(ctx: &mut Ctx, c: &KdlNode) -> Option<String> {
1098 -
    ctx.no_children(c);
1099 -
    let p = ctx.one_string(c)?;
1100 -
    match p.as_str() {
1101 -
        "low" | "normal" => Some(p),
1102 -
        "high" | "urgent" => {
1103 -
            ctx.err(
1104 -
                c.span(),
1105 -
                format!("priority {p:?}: a pipeline may lower its jobs (`low`, `normal`), not raise them; higher levels are set on the hub, and a person may expedite a run"),
1106 -
            );
1107 -
            None
1108 -
        }
1109 -
        _ => {
1110 -
            ctx.err(c.span(), format!("priority {p:?}: expected `low` or `normal`"));
1111 -
            None
1112 -
        }
1113 -
    }
1114 -
}
1115 -
1116 894
fn parse_timeout(ctx: &mut Ctx, c: &KdlNode) -> Option<u64> {
1117 895
    let t = ctx.one_string(c)?;
1118 896
    match parse_duration(&t) {
…
55 unchanged lines
1174 952
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
1175 953
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
1176 954
    let (mut source, mut target, mut size) = (None, None, None);
1177 -
    let (mut cache, mut keys) = (None, Vec::new());
1178 955
    let mut seen = BTreeSet::new();
1179 956
    for c in block(ctx, node) {
1180 957
        let key = c.name().value();
…
43 unchanged lines
1224 1001
                            None
1225 1002
                        }
1226 1003
                    },
1227 -
                    None => None,
1228 -
                }
1229 -
            }
1230 -
            "cache" => {
1231 -
                cache = match ctx.one_string(c) {
1232 -
                    Some(n) if !n.is_empty() && n.len() <= 64 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) => {
1233 -
                        Some(n)
1234 -
                    }
1235 -
                    Some(n) => {
1236 -
                        ctx.err(c.span(), format!("cache name {n:?}: letters, digits, '.', '_' and '-', at most 64"));
1237 -
                        None
1238 -
                    }
1239 1004
                    None => None,
1240 1005
                }
1241 1006
            }
1242 -
            "key" => {
1243 -
                keys = ctx.strings(c);
1244 -
                if keys.is_empty() {
1245 -
                    ctx.err(c.span(), "`key` names one or more files, as \"Cargo.lock\"");
1246 -
                }
1247 -
                for k in &keys {
1248 -
                    if k.starts_with('/') || k.split('/').any(|p| p.is_empty() || p == "." || p == "..") {
1249 -
                        ctx.err(c.span(), format!("key file {k:?} must be a path in the repository, without . or .."));
1250 -
                    }
1251 -
                }
1252 -
            }
1253 1007
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
1254 1008
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
1255 1009
        }
…
3 unchanged lines
1259 1013
            ctx.err(node.span(), format!("a mount needs a {name}"));
1260 1014
        }
1261 1015
    }
1262 -
    if source == Some(MountSource::Tmpfs) && cache.is_some() {
1263 -
        ctx.err(node.span(), "only an ephemeral://disk mount can be a cache");
1264 -
    }
1265 -
    if !keys.is_empty() && cache.is_none() {
1266 -
        ctx.err(node.span(), "`key` goes with `cache`");
1267 -
    }
1268 -
    Some(Mount { source: source?, target: target?, size_mib: size?, cache, key: keys })
1016 +
    Some(Mount { source: source?, target: target?, size_mib: size? })
1269 1017
}
1270 1018
1271 1019
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
…
140 unchanged lines
1412 1160
    }
1413 1161
}
1414 1162
1415 -
/// `approval { by "maintain"; others-only #true; expires "24h" }`, each
1416 -
/// optional: `maintain`, anyone with the role, 24 hours.
1417 -
/// The approval, and whether it said `sealed` itself (else the job's
1418 -
/// environment decides).
1419 -
fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<(Approval, bool)> {
1420 -
    if !node.entries().is_empty() {
1421 -
        ctx.err(node.span(), "`approval` takes a block: approval { by \"maintain\"; others-only #true; expires \"24h\" }");
1422 -
    }
1423 -
    let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600, sealed: false };
1424 -
    let mut sealed_said = false;
1425 -
    let Some(children) = node.children() else { return Some((a, false)) };
1426 -
    for c in children.nodes() {
1427 -
        ctx.no_children(c);
1428 -
        match c.name().value() {
1429 -
            "by" => match ctx.one_string(c).as_deref() {
1430 -
                Some(r @ ("write" | "maintain" | "admin")) => a.by = r.to_string(),
1431 -
                Some(r) => ctx.err(c.span(), format!("approval by {r:?}: expected \"write\", \"maintain\" or \"admin\"")),
1432 -
                None => {}
1433 -
            },
1434 -
            "others-only" => match c.entries() {
1435 -
                [e] if e.name().is_none() => match e.value() {
1436 -
                    KdlValue::Bool(b) => a.others_only = *b,
1437 -
                    _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1438 -
                },
1439 -
                _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1440 -
            },
1441 -
            "sealed" => match c.entries() {
1442 -
                [e] if e.name().is_none() => match e.value() {
1443 -
                    KdlValue::Bool(b) => {
1444 -
                        a.sealed = *b;
1445 -
                        sealed_said = true;
1446 -
                    }
1447 -
                    _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1448 -
                },
1449 -
                _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1450 -
            },
1451 -
            "expires" => {
1452 -
                let Some(t) = ctx.one_string(c) else { continue };
1453 -
                match parse_duration(&t) {
1454 -
                    Some(secs) if (60..=MAX_APPROVAL_S).contains(&secs) => a.expires_s = secs,
1455 -
                    Some(_) => ctx.err(c.span(), format!("expires {t:?} must be from 1m to 720h")),
1456 -
                    None => ctx.err(c.span(), format!("expires {t:?}: expected hours, minutes and seconds, as \"24h\"")),
1457 -
                }
1458 -
            }
1459 -
            other => {
1460 -
                ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only`, `expires` and `sealed`"))
1461 -
            }
1462 -
        }
1463 -
    }
1464 -
    Some((a, sealed_said))
1465 -
}
1466 -
1467 -
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
1468 -
fn parse_input(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1469 -
    if !node.entries().is_empty() {
1470 -
        ctx.err(node.span(), "`input` takes a block: input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\" }");
1471 -
    }
1472 -
    let (mut artifact, mut from, mut path) = (None, None, None);
1473 -
    for c in block(ctx, node) {
1474 -
        ctx.no_children(c);
1475 -
        match c.name().value() {
1476 -
            "artifact" => match ctx.strings(c).as_slice() {
1477 -
                [name, word, producer] if word == "from" => {
1478 -
                    if ctx.name_ok(c.span(), "artifact", name) && ctx.name_ok(c.span(), "job", producer) {
1479 -
                        artifact = Some(name.clone());
1480 -
                        from = Some(producer.clone());
1481 -
                    }
1482 -
                }
1483 -
                _ => ctx.err(c.span(), "`artifact` names the artifact and its job: artifact \"tfplan\" from \"plan\""),
1484 -
            },
1485 -
            "path" => match ctx.one_string(c) {
1486 -
                Some(p) if output_path_ok(&p) => path = Some(p),
1487 -
                Some(p) => {
1488 -
                    ctx.err(c.span(), format!("input path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"))
1489 -
                }
1490 -
                None => {}
1491 -
            },
1492 -
            other => ctx.err(c.span(), format!("unknown input setting `{other}`; an input has `artifact` and `path`")),
1493 -
        }
1494 -
    }
1495 -
    match (artifact, from, path) {
1496 -
        (Some(artifact), Some(from), Some(path)) => {
1497 -
            if job.inputs.iter().any(|i| i.path == path) {
1498 -
                ctx.err(node.span(), format!("two inputs are put at {path:?}"));
1499 -
            } else if job.inputs.len() == MAX_INPUTS {
1500 -
                ctx.err(node.span(), format!("a job takes at most {MAX_INPUTS} inputs"));
1501 -
            } else {
1502 -
                job.inputs.push(Input { artifact, from, path });
1503 -
            }
1504 -
        }
1505 -
        (a, _, p) => {
1506 -
            if a.is_some() && p.is_none() {
1507 -
                ctx.err(node.span(), "an input needs a `path`");
1508 -
            } else if a.is_none() && p.is_some() {
1509 -
                ctx.err(node.span(), "an input needs `artifact \"NAME\" from \"JOB\"`");
1510 -
            }
1511 -
        }
1512 -
    }
1513 -
}
1514 -
1515 1163
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
1516 1164
    let name = ctx.one_string(node)?;
1517 1165
    let named = ctx.name_ok(node.span(), "job", &name);
1518 -
    let mut approval: Option<(Approval, bool)> = None;
1519 1166
    let mut job = Job {
1520 1167
        name: name.clone(),
1521 1168
        sandbox: String::new(),
…
5 unchanged lines
1527 1174
        secrets: vec![],
1528 1175
        identities: vec![],
1529 1176
        outputs: vec![],
1530 -
        inputs: vec![],
1531 -
        approval: None,
1532 -
        environment: None,
1533 1177
        runs_on: vec![],
1534 1178
        timeout_s: None,
1535 1179
        on: None,
1536 -
        priority: None,
1537 -
        checkpoints: Vec::new(),
1538 1180
    };
1539 1181
    let mut seen = BTreeSet::new();
1540 1182
    let mut outputs_at = None;
…
5 unchanged lines
1546 1188
            && key != "secret"
1547 1189
            && key != "identity"
1548 1190
            && key != "runs-on"
1549 -
            && key != "checkpoint"
1550 1191
            && !seen.insert(key.to_string())
1551 1192
        {
1552 1193
            ctx.err(c.span(), format!("`{key}` is given twice"));
…
2 unchanged lines
1555 1196
            "sandbox" => {
1556 1197
                ctx.no_children(c);
1557 1198
                job.sandbox = ctx.one_string(c).unwrap_or_default();
1558 -
            }
1559 -
            "checkpoint" => {
1560 -
                ctx.no_children(c);
1561 -
                for p in ctx.strings(c) {
1562 -
                    if output_path_ok(&p) && p.trim_end_matches('/') != "/src" {
1563 -
                        job.checkpoints.push(p);
1564 -
                    } else {
1565 -
                        ctx.err(c.span(), format!("checkpoint {p:?} must be a directory under /src, without . or .., quotes or spaces"));
1566 -
                    }
1567 -
                }
1568 1199
            }
1569 1200
            "scope" => {
1570 1201
                ctx.no_children(c);
…
16 unchanged lines
1587 1218
                ctx.no_children(c);
1588 1219
                job.timeout_s = parse_timeout(ctx, c);
1589 1220
            }
1590 -
            "priority" => job.priority = parse_priority(ctx, c),
1591 1221
            "on" => job.on = Some(parse_on(ctx, c)),
1592 1222
            "env" => parse_env(ctx, c, &mut job.env),
1593 1223
            "exec" => {
…
26 unchanged lines
1620 1250
            "output" => {
1621 1251
                outputs_at = Some(c.span());
1622 1252
                parse_output(ctx, c, &mut job);
1623 -
            }
1624 -
            "input" => parse_input(ctx, c, &mut job),
1625 -
            "approval" => approval = parse_approval(ctx, c),
1626 -
            "environment" => {
1627 -
                ctx.no_children(c);
1628 -
                if let Some(e) = ctx.one_string(c) {
1629 -
                    let ok = !e.is_empty() && e.len() <= 32 && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
1630 -
                    if ok {
1631 -
                        job.environment = Some(e);
1632 -
                    } else {
1633 -
                        ctx.err(c.span(), format!("environment {e:?}: 1 to 32 of a-z 0-9 -, as \"prod\""));
1634 -
                    }
1635 -
                }
1636 1253
            }
1637 1254
            "secret" => parse_secret(ctx, c, &mut job),
1638 1255
            "identity" => parse_identity(ctx, c, &mut job),
…
1 unchanged lines
1640 1257
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
1641 1258
        }
1642 1259
    }
1643 -
    // A job for an environment takes a sealed approval unless it says not.
1644 -
    job.approval = approval.map(|(mut a, said)| {
1645 -
        if !said {
1646 -
            a.sealed = job.environment.is_some();
1647 -
        }
1648 -
        a
1649 -
    });
1650 1260
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1651 1261
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1652 -
    job.inputs.sort_by(|a, b| a.path.cmp(&b.path));
1653 1262
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
1654 1263
    job.identities.sort_by(|a, b| a.audience.cmp(&b.audience));
1655 1264
    // A tmpfs is gone when the job ends; nothing on it can be kept.
…
16 unchanged lines
1672 1281
    }
1673 1282
    job.depends_on.sort();
1674 1283
    job.depends_on.dedup();
1675 -
    job.checkpoints.sort();
1676 -
    job.checkpoints.dedup();
1677 1284
    // Kept even when incomplete, so its references are checked too; the
1678 1285
    // problems above already make the pipeline invalid.
1679 1286
    named.then_some(job)
…
1 unchanged lines
modified crates/flow-core/src/tests.rs +9 −200
…
94 unchanged lines
95 95
96 96
#[test]
97 97
fn m0_refuses_what_it_cannot_honour() {
98 -
    let src = format!("schema-version 1\nsandbox \"s\" {{\n  runtime \"wasm\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"wifi\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n");
98 +
    let src = format!("schema-version 1\nsandbox \"s\" {{\n  runtime \"wasm\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"egress\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n");
99 99
    let ps = problems(&src);
100 -
    // A WASM sandbox has no image (oxygenflow#75).
101 -
    assert!(ps.iter().any(|(l, m)| *l == 2 && m.contains("takes no rootfs")), "{ps:?}");
100 +
    assert!(ps.iter().any(|(l, m)| *l == 3 && m.contains("wasm")), "{ps:?}");
102 101
    assert!(ps.iter().any(|(l, m)| *l == 5 && m.contains("isolated")), "{ps:?}");
103 102
    assert!(problems("schema-version 2\n").iter().any(|(_, m)| m.contains("schema-version 2")));
104 103
    assert!(problems(&format!(
…
99 unchanged lines
204 203
    assert_eq!(
205 204
        b.mounts,
206 205
        vec![
207 -
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
208 -
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
206 +
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
207 +
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
209 208
        ],
210 209
        "sorted by target"
211 210
    );
…
9 unchanged lines
221 220
}
222 221
223 222
#[test]
224 -
fn a_disk_mount_can_be_a_cache_keyed_by_files() {
225 -
    let src = |extra: &str| {
226 -
        doc(&format!(
227 -
            "job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"8GiB\"\n{extra}    }}\n    exec \"cargo\" \"build\"\n}}\n"
228 -
        ))
229 -
    };
230 -
    let plain = Pipeline::parse(&src("")).unwrap();
231 -
    let cached = Pipeline::parse(&src("        cache \"target\"\n        key \"Cargo.lock\" \"rust-toolchain.toml\"\n")).unwrap();
232 -
    let m = &cached.jobs["b"].mounts[0];
233 -
    assert_eq!((m.cache.as_deref(), m.key.clone()), (Some("target"), vec!["Cargo.lock".to_string(), "rust-toolchain.toml".to_string()]));
234 -
    assert_ne!(cached.hash(), plain.hash(), "a cache is part of the job");
235 -
    let keyless = Pipeline::parse(&src("        cache \"target\"\n")).unwrap();
236 -
    assert_ne!(keyless.hash(), cached.hash());
237 -
    let ps = problems(&src("        cache \"no spaces\"\n"));
238 -
    assert!(ps.iter().any(|(_, m)| m.contains("cache name")), "{ps:?}");
239 -
    let ps = problems(&src("        key \"Cargo.lock\"\n"));
240 -
    assert!(ps.iter().any(|(_, m)| m.contains("goes with `cache`")), "{ps:?}");
241 -
    let ps = problems(&src("        cache \"t\"\n        key \"../x\"\n"));
242 -
    assert!(ps.iter().any(|(_, m)| m.contains("path in the repository")), "{ps:?}");
243 -
    let tmpfs = doc("job \"b\" {\n    sandbox \"alpine\"\n    mount {\n        source \"ephemeral://tmpfs\"\n        target \"/tmp/c\"\n        size \"64MiB\"\n        cache \"c\"\n    }\n    exec \"true\"\n}\n");
244 -
    assert!(problems(&tmpfs).iter().any(|(_, m)| m.contains("only an ephemeral://disk")));
245 -
}
246 -
247 -
#[test]
248 223
fn mounts_are_checked() {
249 224
    let job = |mount: &str| doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n{mount}\n    }}\n    exec \"true\"\n}}\n"));
250 225
    let has = |src: String, want: &str| {
…
25 unchanged lines
276 251
}
277 252
278 253
#[test]
279 -
fn loopback_and_allow_listed_egress() {
254 +
fn loopback_is_in_m0_and_egress_is_not() {
280 255
    let src = |net: &str| {
281 -
        format!("schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  network {net}\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n")
256 +
        format!("schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"{net}\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n")
282 257
    };
283 -
    let p = Pipeline::parse(&src("\"loopback\"")).unwrap();
258 +
    let p = Pipeline::parse(&src("loopback")).unwrap();
284 259
    assert_eq!(p.sandboxes["s"].network, Network::Loopback);
285 -
    assert_ne!(p.hash(), Pipeline::parse(&src("\"isolated\"")).unwrap().hash(), "the network is part of the sandbox");
286 -
287 -
    // Egress names its hosts (oxygenflow#55), sorted; the sandbox then needs
288 -
    // a runner offering the egress label.
289 -
    let p =
290 -
        Pipeline::parse(&src("\"egress\" { allow \"registry.opentofu.org\" \"*.googleapis.com\"; allow \"*.googleapis.com\"; }")).unwrap();
291 -
    let s = &p.sandboxes["s"];
292 -
    assert_eq!(s.network, Network::Egress);
293 -
    assert_eq!(s.egress_allow, ["*.googleapis.com", "registry.opentofu.org"]);
294 -
    assert_eq!(s.runs_on, [crate::EGRESS_LABEL]);
295 -
    let other = Pipeline::parse(&src("\"egress\" { allow \"registry.opentofu.org\"; }")).unwrap();
296 -
    assert_ne!(p.hash(), other.hash(), "the allow-list is part of the sandbox");
297 -
298 -
    let has = |net: &str, want: &str| {
299 -
        let ps = problems(&src(net));
300 -
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{net}: {ps:?}");
301 -
    };
302 -
    has("\"egress\"", "needs a block");
303 -
    has("\"egress\" { }", "at least one `allow");
304 -
    has("\"egress\" { allow \"Example.COM\"; }", "lower-case host name");
305 -
    has("\"egress\" { allow \"localhost\"; }", "lower-case host name");
306 -
    has("\"egress\" { allow \"example.com:8443\"; }", "lower-case host name");
307 -
    has("\"egress\" { deny \"example.com\"; }", "unknown egress setting");
308 -
    has("\"loopback\" { allow \"example.com\"; }", "takes no block");
309 -
    let syscall = format!(
310 -
        "schema-version 1\nsandbox \"s\" {{\n  runtime \"syscall\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"egress\" {{ allow \"example.com\"; }}\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n"
311 -
    );
312 -
    assert!(problems(&syscall).iter().any(|(_, m)| m.contains("needs runtime \"microvm\"")));
313 -
}
314 -
315 -
#[test]
316 -
fn egress_patterns_match_names_and_names_under_a_domain() {
317 -
    let allow = vec!["*.googleapis.com".to_string(), "registry.opentofu.org".to_string()];
318 -
    for ok in ["storage.googleapis.com", "a.b.googleapis.com", "REGISTRY.opentofu.org.", "registry.opentofu.org"] {
319 -
        assert!(crate::egress_allows(&allow, ok), "{ok}");
320 -
    }
321 -
    for no in ["googleapis.com", "evilgoogleapis.com", "googleapis.com.evil.org", "opentofu.org", "x.registry.opentofu.org", ""] {
322 -
        assert!(!crate::egress_allows(&allow, no), "{no}");
323 -
    }
260 +
    assert_ne!(p.hash(), Pipeline::parse(&src("isolated")).unwrap().hash(), "the network is part of the sandbox");
261 +
    assert!(problems(&src("egress")).iter().any(|(l, m)| *l == 4 && m.contains("not in M0")));
324 262
}
325 263
326 264
#[test]
…
117 unchanged lines
444 382
        run_number: 3,
445 383
        env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(),
446 384
        secrets: vec![],
447 -
        inputs: vec![],
448 -
        expected_ms: Some(17_700),
449 -
        checkpoint: None,
450 385
    };
451 386
    let env = job.with_flow_env(&claim.flow_env()).env;
452 387
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
…
14 unchanged lines
467 402
    };
468 403
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
469 404
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
470 -
    // The expected time goes with the claim, and an older hub's claim
471 -
    // without it still reads (#76).
472 -
    let json = serde_json::to_value(&old).unwrap();
473 -
    assert_eq!(json["expected_ms"], 17_700);
474 -
    let mut without = json.clone();
475 -
    without.as_object_mut().unwrap().remove("expected_ms");
476 -
    assert_eq!(serde_json::from_value::<crate::api::Claim>(without).unwrap().expected_ms, None);
477 405
}
478 406
479 407
#[test]
…
113 unchanged lines
593 521
    assert!(none.iter().any(|(_, m)| m.contains("needs one or more labels")), "{none:?}");
594 522
}
595 523
596 -
/// oxygenhub#244: a pipeline may lower its jobs' priority, for all of them
597 -
/// or one by one, never raise it.
598 -
#[test]
599 -
fn a_pipeline_lowers_its_jobs_priority_and_never_raises_it() {
600 -
    let jobs = "job \"nightly\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\njob \"check\" {\n    sandbox \"alpine\"\n    priority \"normal\"\n    exec \"true\"\n}\n";
601 -
    let p = Pipeline::parse(&format!("priority \"low\"\n{}", doc(jobs))).unwrap();
602 -
    assert_eq!(p.jobs["nightly"].priority.as_deref(), Some("low"), "the pipeline's");
603 -
    assert_eq!(p.jobs["check"].priority.as_deref(), Some("normal"), "its own");
604 -
    let one = Pipeline::parse(&doc("job \"a\" {\n    sandbox \"alpine\"\n    priority \"low\"\n    exec \"true\"\n}\n")).unwrap();
605 -
    assert_eq!(one.jobs["a"].priority.as_deref(), Some("low"));
606 -
    // None given: serialised as before, so its jobs' hashes stay.
607 -
    let plain = Pipeline::parse(&doc("job \"x\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\n")).unwrap();
608 -
    assert!(!serde_json::to_string(&plain.jobs["x"]).unwrap().contains("priority"));
609 -
    for high in ["high", "urgent"] {
610 -
        let bad = problems(&doc(&format!("job \"x\" {{\n    sandbox \"alpine\"\n    priority \"{high}\"\n    exec \"true\"\n}}\n")));
611 -
        assert!(bad.iter().any(|(_, m)| m.contains("not raise them")), "{high}: {bad:?}");
612 -
    }
613 -
    let odd = problems(&doc("job \"x\" {\n    sandbox \"alpine\"\n    priority \"soon\"\n    exec \"true\"\n}\n"));
614 -
    assert!(odd.iter().any(|(_, m)| m.contains("expected `low` or `normal`")), "{odd:?}");
615 -
}
616 -
617 524
/// oxygenflow#23: `timeout` on a job, or on its sandbox for every job in
618 525
/// it, as a duration from a minute to a day.
619 526
#[test]
…
53 unchanged lines
673 580
        let e = Pipeline::parse(&t).unwrap_err().to_string();
674 581
        assert!(e.contains(why), "{bad:?}: {e}");
675 582
    }
676 -
}
677 -
678 -
#[test]
679 -
fn a_job_takes_an_artifact_of_a_job_it_depends_on() {
680 -
    let jobs = |apply: &str| {
681 -
        doc(&format!(
682 -
            "job \"plan\" {{\n    sandbox \"alpine\"\n    output {{ artifact \"tfplan\" {{ path \"/src/out/tfplan\"; }}; report \"r\" {{ path \"/src/out/r\"; }}; }}\n    exec \"plan\"\n}}\njob \"apply\" {{\n    sandbox \"alpine\"\n{apply}    exec \"apply\"\n}}\n"
683 -
        ))
684 -
    };
685 -
    let ok = jobs("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n");
686 -
    let p = Pipeline::parse(&ok).unwrap();
687 -
    assert_eq!(p.jobs["apply"].inputs, [crate::Input { artifact: "tfplan".into(), from: "plan".into(), path: "/src/tfplan".into() }]);
688 -
    let without = Pipeline::parse(&jobs("    depends-on \"plan\"\n")).unwrap();
689 -
    assert_ne!(p.hash(), without.hash(), "inputs are part of the job");
690 -
691 -
    let has = |apply: &str, want: &str| {
692 -
        let ps = problems(&jobs(apply));
693 -
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{apply}: {ps:?}");
694 -
    };
695 -
    has("    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n", "add depends-on \"plan\"");
696 -
    has("    depends-on \"plan\"\n    input { artifact \"nope\" from \"plan\"; path \"/src/x\"; }\n", "declares no artifact \"nope\"");
697 -
    has("    depends-on \"plan\"\n    input { artifact \"r\" from \"plan\"; path \"/src/x\"; }\n", "is a report");
698 -
    has("    input { artifact \"tfplan\" from \"ghost\"; path \"/src/x\"; }\n", "which is not declared");
699 -
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/etc/x\"; }\n", "absolute path under /src");
700 -
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" \"plan\"; path \"/src/x\"; }\n", "artifact \"tfplan\" from \"plan\"");
701 -
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; }\n", "needs a `path`");
702 -
    has(
703 -
        "    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n",
704 -
        "two inputs are put at",
705 -
    );
706 -
}
707 -
708 -
#[test]
709 -
fn a_job_can_wait_for_approval() {
710 -
    let job = |approval: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{approval}    exec \"apply\"\n}}\n"));
711 -
    let p = Pipeline::parse(&job("    approval { by \"maintain\"; others-only #true; expires \"2h\"; }\n")).unwrap();
712 -
    assert_eq!(
713 -
        p.jobs["apply"].approval,
714 -
        Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200, sealed: false })
715 -
    );
716 -
    let plain = Pipeline::parse(&job("    approval\n")).unwrap();
717 -
    assert_eq!(
718 -
        plain.jobs["apply"].approval,
719 -
        Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400, sealed: false }),
720 -
        "defaults"
721 -
    );
722 -
    // Sealed (oxygenhub#235): on by default for a job with an environment,
723 -
    // as it says otherwise.
724 -
    let sealed = |extra: &str| Pipeline::parse(&job(extra)).unwrap().jobs["apply"].approval.clone().unwrap().sealed;
725 -
    assert!(sealed("    environment \"prod\"\n    approval\n"));
726 -
    assert!(!sealed("    environment \"prod\"\n    approval { sealed #false; }\n"));
727 -
    assert!(sealed("    approval { sealed #true; }\n"));
728 -
    assert_ne!(Pipeline::parse(&job("    approval { sealed #true; }\n")).unwrap().hash(), plain.hash(), "part of the job");
729 -
    assert_eq!(Pipeline::parse(&job("")).unwrap().jobs["apply"].approval, None);
730 -
    assert_ne!(p.hash(), plain.hash(), "the approval is part of the job");
731 -
    let has = |a: &str, want: &str| {
732 -
        let ps = problems(&job(a));
733 -
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{a}: {ps:?}");
734 -
    };
735 -
    has("    approval { by \"read\"; }\n", "expected \"write\", \"maintain\" or \"admin\"");
736 -
    has("    approval { others-only \"yes\"; }\n", "#true or #false");
737 -
    has("    approval { expires \"800h\"; }\n", "from 1m to 720h");
738 -
    has("    approval { when \"later\"; }\n", "unknown approval setting");
739 -
    has("    approval { sealed \"yes\"; }\n", "#true or #false");
740 -
}
741 -
742 -
#[test]
743 -
fn a_job_names_the_environment_it_deploys_to() {
744 -
    let job = |e: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{e}    exec \"apply\"\n}}\n"));
745 -
    let p = Pipeline::parse(&job("    environment \"prod\"\n")).unwrap();
746 -
    assert_eq!(p.jobs["apply"].environment.as_deref(), Some("prod"));
747 -
    assert_ne!(p.hash(), Pipeline::parse(&job("")).unwrap().hash(), "part of the job");
748 -
    assert!(problems(&job("    environment \"Prod env\"\n")).iter().any(|(_, m)| m.contains("1 to 32 of a-z 0-9 -")));
749 -
}
750 -
751 -
/// oxygenflow#75: a WASM sandbox needs no rootfs and has no network; its
752 -
/// job's exec names the module.
753 -
#[test]
754 -
fn a_wasm_sandbox_runs_a_module() {
755 -
    let src = "schema-version 1\nsandbox \"w\" {\n  runtime \"wasm\"\n  memory-limit \"512MiB\"\n}\njob \"t\" {\n  sandbox \"w\"\n  exec \"target/wasm32-wasip1/debug/deps/*.wasm\" \"--quiet\"\n}\n";
756 -
    let p = Pipeline::parse(src).unwrap_or_else(|ps| panic!("{:?}", ps.0));
757 -
    let sb = &p.sandboxes["w"];
758 -
    assert_eq!((sb.runtime, sb.rootfs.as_str(), sb.network, sb.memory_mib), (Runtime::Wasm, "", Network::Isolated, 512));
759 -
    let networked =
760 -
        "schema-version 1\nsandbox \"w\" {\n  runtime \"wasm\"\n  network \"loopback\"\n}\njob \"t\" { sandbox \"w\"; exec \"a.wasm\"; }\n";
761 -
    assert!(problems(networked).iter().any(|(_, m)| m.contains("has no network")));
762 -
}
763 -
764 -
#[test]
765 -
fn a_finish_says_where_its_time_went_and_an_older_one_still_reads() {
766 -
    let old = r#"{"exit_code":0,"duration_ms":5,"receipt":null,"error":null}"#;
767 -
    let mut f: crate::api::Finish = serde_json::from_str(old).unwrap();
768 -
    assert!(f.phases.is_empty());
769 -
    assert!(!serde_json::to_string(&f).unwrap().contains("phases"), "absent when there are none");
770 -
    f.phases.insert("prepare".into(), 1200);
771 -
    f.phases.insert("run".into(), 3400);
772 -
    let back: crate::api::Finish = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
773 -
    assert_eq!(back.phases["run"], 3400);
774 583
}
modified crates/flow-init/Cargo.toml +0 −3
…
8 unchanged lines
9 9
libc = "0.2"
10 10
serde.workspace = true
11 11
serde_json.workspace = true
12 -
13 -
[dev-dependencies]
14 -
tempfile.workspace = true
removed crates/flow-init/src/egress.rs +0 −80
1 -
//! The guest's half of allow-listed egress (oxygenflow#55): a proxy on
2 -
//! 127.0.0.1:3128 that relays each connection, unread, over vsock to the
3 -
//! runner (host CID 2), which decides what may pass. The VM has no network
4 -
//! device; this is its only way out.
5 -
6 -
use std::fs::File;
7 -
use std::io::{Read, Write};
8 -
use std::net::{TcpListener, TcpStream};
9 -
use std::os::fd::{AsRawFd, FromRawFd};
10 -
11 -
/// Where the job's tools reach the proxy, and the vsock port behind it.
12 -
const LISTEN: &str = "127.0.0.1:3128";
13 -
const VSOCK_PORT: u32 = 3128;
14 -
15 -
/// Start relaying in the background (before the job starts, so its first
16 -
/// connection finds the proxy listening).
17 -
pub fn start() -> Result<(), String> {
18 -
    let listener = TcpListener::bind(LISTEN).map_err(|e| format!("egress proxy on {LISTEN}: {e}"))?;
19 -
    std::thread::spawn(move || {
20 -
        for conn in listener.incoming().flatten() {
21 -
            std::thread::spawn(move || relay(conn));
22 -
        }
23 -
    });
24 -
    Ok(())
25 -
}
26 -
27 -
fn vsock_to_host(port: u32) -> std::io::Result<File> {
28 -
    let fd = unsafe { libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
29 -
    if fd < 0 {
30 -
        return Err(std::io::Error::last_os_error());
31 -
    }
32 -
    // Owned from here, so it is closed on every path.
33 -
    let file = unsafe { File::from_raw_fd(fd) };
34 -
    let mut addr: libc::sockaddr_vm = unsafe { std::mem::zeroed() };
35 -
    addr.svm_family = libc::AF_VSOCK as libc::sa_family_t;
36 -
    addr.svm_cid = libc::VMADDR_CID_HOST;
37 -
    addr.svm_port = port;
38 -
    let rc = unsafe {
39 -
        libc::connect(
40 -
            fd,
41 -
            &addr as *const libc::sockaddr_vm as *const libc::sockaddr,
42 -
            std::mem::size_of::<libc::sockaddr_vm>() as libc::socklen_t,
43 -
        )
44 -
    };
45 -
    if rc != 0 {
46 -
        return Err(std::io::Error::last_os_error());
47 -
    }
48 -
    Ok(file)
49 -
}
50 -
51 -
fn relay(tcp: TcpStream) {
52 -
    let Ok(host) = vsock_to_host(VSOCK_PORT) else {
53 -
        let mut tcp = tcp;
54 -
        let _ = tcp.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\nthe runner's egress proxy did not answer\n");
55 -
        return;
56 -
    };
57 -
    let (Ok(mut tcp_r), Ok(mut host_w)) = (tcp.try_clone(), host.try_clone()) else { return };
58 -
    let up = std::thread::spawn(move || {
59 -
        copy(&mut tcp_r, &mut host_w);
60 -
        unsafe { libc::shutdown(host_w.as_raw_fd(), libc::SHUT_WR) };
61 -
    });
62 -
    let (mut host_r, mut tcp_w) = (host, tcp);
63 -
    copy(&mut host_r, &mut tcp_w);
64 -
    let _ = tcp_w.shutdown(std::net::Shutdown::Write);
65 -
    let _ = up.join();
66 -
}
67 -
68 -
fn copy(from: &mut impl Read, to: &mut impl Write) {
69 -
    let mut buf = [0u8; 16 * 1024];
70 -
    loop {
71 -
        match from.read(&mut buf) {
72 -
            Ok(0) | Err(_) => return,
73 -
            Ok(n) => {
74 -
                if to.write_all(&buf[..n]).is_err() {
75 -
                    return;
76 -
                }
77 -
            }
78 -
        }
79 -
    }
80 -
}
modified crates/flow-init/src/job.rs +2 −3
…
69 unchanged lines
70 70
        req.ifr_name[i] = *b as libc::c_char;
71 71
    }
72 72
    let ok = unsafe {
73 -
        // `as _`: the request's type differs between libcs (Android's is c_int).
74 -
        libc::ioctl(fd, libc::SIOCGIFFLAGS as _, &mut req) == 0 && {
73 +
        libc::ioctl(fd, libc::SIOCGIFFLAGS, &mut req) == 0 && {
75 74
            req.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
76 -
            libc::ioctl(fd, libc::SIOCSIFFLAGS as _, &req) == 0
75 +
            libc::ioctl(fd, libc::SIOCSIFFLAGS, &req) == 0
77 76
        }
78 77
    };
79 78
    let err = std::io::Error::last_os_error();
…
58 unchanged lines
removed crates/flow-init/src/lib.rs +0 −5
1 -
//! The parts of the guest init the runner shares: pruning a build cache
2 -
//! before it is kept (oxygenflow#72), done in the guest for a microVM's
3 -
//! disks and on the runner for the syscall tier's directories.
4 -
5 -
pub mod prune;
modified crates/flow-init/src/main.rs +4 −71
…
6 unchanged lines
7 7
//!
8 8
//! Frames, one per line on the console: `\x1eO ` stdout, `\x1eE ` stderr,
9 9
//! `\x1eX <code>` the exit code (128 + signal when killed), `\x1eF <text>`
10 -
//! a failure of init itself, `\x1eC <json>` a build cache pruned before
11 -
//! the guest powers off (oxygenflow#72): `{"target": ..., "pruned":
12 -
//! flow_init::prune::Pruned}`, sent before the exit code.
10 +
//! a failure of init itself.
13 11
14 -
use flow_init::prune;
15 12
use serde::Deserialize;
16 13
use std::ffi::CString;
17 14
use std::io::{Read, Write};
18 15
use std::os::fd::FromRawFd;
19 16
use std::sync::{Arc, Mutex};
20 17
21 -
mod egress;
22 18
mod job;
23 19
use job::{dev_links, job_env, loopback_up, resolve_program, PATH};
24 20
…
6 unchanged lines
31 27
    /// `network "loopback"`: bring up `lo` (the guest has no other device).
32 28
    #[serde(default)]
33 29
    loopback: bool,
34 -
    /// `network "egress"` (oxygenflow#55): `lo` comes up and a proxy on
35 -
    /// 127.0.0.1:3128 relays over vsock to the runner.
36 -
    #[serde(default)]
37 -
    egress: bool,
38 30
    /// Secrets to write before the job starts (spec §9): each at `path`
39 31
    /// under `/run/secrets`, 0400 and the job's user's, on a tmpfs.
40 32
    #[serde(default)]
…
15 unchanged lines
56 48
    size_mib: u32,
57 49
    #[serde(default)]
58 50
    device: Option<String>,
59 -
    /// A build cache's disk (oxygenflow#72): mounted `strictatime`, so
60 -
    /// what the job reads is seen, and after a passing job pruned to this
61 -
    /// percentage of its size before the guest powers off.
62 -
    #[serde(default)]
63 -
    prune: Option<u8>,
64 -
    /// Paths on the disk, relative to it, that pruning leaves: the job's
65 -
    /// outputs, which the runner reads after the guest stops.
66 -
    #[serde(default)]
67 -
    keep: Vec<String>,
68 51
}
69 52
70 53
fn main() {
…
102 unchanged lines
173 156
        match (m.kind.as_str(), &m.device) {
174 157
            ("tmpfs", _) => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,uid={uid},gid={gid},mode=755", m.size_mib))?,
175 158
            ("disk", Some(dev)) => {
176 -
                let atime = if m.prune.is_some() { libc::MS_STRICTATIME | libc::MS_LAZYTIME } else { 0 };
177 -
                mount(dev, &target, "ext4", nosuid | atime, "")?;
159 +
                mount(dev, &target, "ext4", nosuid, "")?;
178 160
                chown(&target, uid, gid)?;
179 161
            }
180 162
            (kind, _) => return Err(format!("unknown mount kind {kind}")),
…
22 unchanged lines
203 185
                .map_err(|e| format!("chmod {}: {e}", s.path))?;
204 186
        }
205 187
    }
206 -
    if job.loopback || job.egress {
188 +
    if job.loopback {
207 189
        loopback_up()?;
208 190
    }
209 -
    if job.egress {
210 -
        egress::start()?;
211 -
    }
212 191
    let root = cstr("/newroot")?;
213 192
    if unsafe { libc::chroot(root.as_ptr()) } != 0 || std::env::set_current_dir("/src").is_err() {
214 193
        return Err(format!("enter the rootfs: {}", std::io::Error::last_os_error()));
215 194
    }
216 195
217 -
    // What the job reads or writes on a cache disk from now on is what
218 -
    // this run used.
219 -
    let started = job.mounts.iter().any(|m| m.prune.is_some()).then(prune::start_clock);
220 196
    let argv: Vec<CString> = job.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
221 197
    // glibc's execvpe searches the caller's PATH, and PID 1 has none of its
222 198
    // own, so the program is found here, in the job's PATH, before forking.
…
53 unchanged lines
276 252
    for r in readers {
277 253
        let _ = r.join();
278 254
    }
279 -
    let code = if libc::WIFEXITED(status) { libc::WEXITSTATUS(status) } else { 128 + libc::WTERMSIG(status) };
280 -
    // A failing job's disks are thrown away: only a passing one's are
281 -
    // worth pruning.
282 -
    if let (Some(started), 0) = (started, code) {
283 -
        prune_caches(console, &job.mounts, started);
284 -
    }
285 -
    Ok(code)
286 -
}
287 -
288 -
/// Prune each cache disk and say so to the host. Whatever the job left
289 -
/// running stops first, so nothing changes the disks meanwhile.
290 -
fn prune_caches(console: &Mutex<std::io::Stdout>, mounts: &[MountSpec], started: std::time::SystemTime) {
291 -
    unsafe {
292 -
        libc::kill(-1, libc::SIGKILL);
293 -
        let mut status = 0;
294 -
        while libc::waitpid(-1, &mut status, 0) > 0 {}
295 -
    }
296 -
    for m in mounts {
297 -
        let Some(percent) = m.prune else { continue };
298 -
        let root = std::path::Path::new(&m.target);
299 -
        let pruned = match prune::usage(root) {
300 -
            Ok((used, size)) => {
301 -
                let mut p = prune::prune(root, started, &m.keep, size, used, percent).unwrap_or_else(|e| prune::Pruned {
302 -
                    size,
303 -
                    percent,
304 -
                    before: used,
305 -
                    after: used,
306 -
                    unpruned: Some(format!("pruning failed: {e}")),
307 -
                    ..Default::default()
308 -
                });
309 -
                // What is left as the file system counts it.
310 -
                if p.freed > 0 {
311 -
                    unsafe { libc::sync() };
312 -
                    if let Ok((now, _)) = prune::usage(root) {
313 -
                        p.after = now;
314 -
                    }
315 -
                }
316 -
                p
317 -
            }
318 -
            Err(e) => prune::Pruned { percent, unpruned: Some(format!("statvfs {}: {e}", m.target)), ..Default::default() },
319 -
        };
320 -
        let msg = serde_json::json!({ "target": m.target, "pruned": pruned });
321 -
        frame(console, b'C', msg.to_string().as_bytes());
322 -
    }
255 +
    Ok(if libc::WIFEXITED(status) { libc::WEXITSTATUS(status) } else { 128 + libc::WTERMSIG(status) })
323 256
}
324 257
325 258
/// Frame a stream line by line; a long line is cut at 16 KiB.
…
23 unchanged lines
removed crates/flow-init/src/prune.rs +0 −405
1 -
//! Pruning a build cache before it is kept (oxygenflow#72, spec §5.4).
2 -
//!
3 -
//! A kept disk starts the next run, so without pruning it carries every
4 -
//! build's files: each crate's artifacts under every hash a lockfile or a
5 -
//! feature set ever gave it. Before a passing job's cache disk goes back
6 -
//! into the pool, the files this run did not use are removed, oldest
7 -
//! first, until what is on the disk is at most a share of its size (the
8 -
//! cap). The microVM's guest init prunes its disks before it powers off,
9 -
//! since only it can see inside them; the runner prunes the syscall
10 -
//! tier's directories itself. Both use this module.
11 -
//!
12 -
//! What the run used is told by the files' times against when the job
13 -
//! started: written (mtime), changed (ctime) or read (atime). Reads are
14 -
//! seen only where the file system records them: the guest mounts a cache
15 -
//! disk `strictatime`; on the runner, where a directory sits on whatever
16 -
//! file system the runner has, each file's atime is set to the epoch
17 -
//! before the job ([`forget_reads`]), so even `relatime` records its
18 -
//! first read. A file system mounted `noatime` records none, and then
19 -
//! nothing is pruned.
20 -
//!
21 -
//! A build tool does not read everything it relies on. Cargo, finding a
22 -
//! crate fresh, reads its fingerprint and only looks at its rlib and
23 -
//! binaries, so their times say nothing. Every name cargo gives a unit's
24 -
//! files carries the unit's hash (`.fingerprint/foo-<hash>/`,
25 -
//! `deps/libfoo-<hash>.rlib`, `build/foo-<hash>/`), so a file stays when
26 -
//! a file this run used carries one of the hashes in its path: a fresh
27 -
//! crate's fingerprint keeps its artifacts. A file with another link
28 -
//! that stays, or under a path the job's outputs name, stays too. What
29 -
//! goes is what this run's build did not touch, which a later build needs
30 -
//! only if an older lockfile comes back, and then cargo rebuilds it.
31 -
32 -
use serde::{Deserialize, Serialize};
33 -
#[cfg(unix)]
34 -
use std::collections::{HashMap, HashSet};
35 -
#[cfg(unix)]
36 -
use std::os::unix::fs::MetadataExt;
37 -
use std::path::Path;
38 -
#[cfg(unix)]
39 -
use std::path::PathBuf;
40 -
use std::time::{Duration, SystemTime};
41 -
42 -
/// The share of a cache disk a kept cache may fill when no runner says
43 -
/// otherwise: room for this run's build and as much again.
44 -
pub const DEFAULT_PERCENT: u8 = 60;
45 -
46 -
/// What pruning a cache found and did, in bytes of blocks on its disk.
47 -
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
48 -
pub struct Pruned {
49 -
    /// The disk's size, and the share of it the cache may fill.
50 -
    pub size: u64,
51 -
    pub percent: u8,
52 -
    /// What was on the disk before pruning, and after.
53 -
    pub before: u64,
54 -
    pub after: u64,
55 -
    /// What went: bytes and files.
56 -
    pub freed: u64,
57 -
    pub files: u64,
58 -
    /// Why nothing could be pruned, when so (no access times, or an
59 -
    /// error): only the size then decides.
60 -
    #[serde(default, skip_serializing_if = "Option::is_none")]
61 -
    pub unpruned: Option<String>,
62 -
}
63 -
64 -
impl Pruned {
65 -
    /// The most the cache may keep: `percent` of `size`.
66 -
    pub fn cap(&self) -> u64 {
67 -
        cap(self.size, self.percent)
68 -
    }
69 -
70 -
    /// Whether what is left may be kept.
71 -
    pub fn fits(&self) -> bool {
72 -
        self.after <= self.cap()
73 -
    }
74 -
}
75 -
76 -
fn cap(size: u64, percent: u8) -> u64 {
77 -
    (size as u128 * percent.min(100) as u128 / 100) as u64
78 -
}
79 -
80 -
/// Now, as a job's start to compare file times against. File systems
81 -
/// stamp times from a clock that may lag this one by a tick, so the
82 -
/// moments either side of it are left to pass: nothing done before the
83 -
/// call (such as [`forget_reads`]) can look as though it came after.
84 -
pub fn start_clock() -> SystemTime {
85 -
    std::thread::sleep(Duration::from_millis(20));
86 -
    let t = SystemTime::now();
87 -
    std::thread::sleep(Duration::from_millis(20));
88 -
    t
89 -
}
90 -
91 -
#[cfg(unix)]
92 -
/// Whether the file system holding `path` records reads (not `noatime`).
93 -
pub fn records_reads(path: &Path) -> bool {
94 -
    let Ok(c) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { return false };
95 -
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
96 -
    if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
97 -
        return false;
98 -
    }
99 -
    // Only Linux's statvfs has a `noatime` flag: elsewhere (Haiku, FreeBSD)
100 -
    // reads are taken as recorded, and the access-time check decides.
101 -
    #[cfg(any(target_os = "linux", target_os = "android"))]
102 -
    return st.f_flag & libc::ST_NOATIME == 0;
103 -
    #[cfg(not(any(target_os = "linux", target_os = "android")))]
104 -
    true
105 -
}
106 -
107 -
#[cfg(unix)]
108 -
/// The blocks in use on the file system mounted at `path`, and its size
109 -
/// (as `df` counts them).
110 -
pub fn usage(path: &Path) -> std::io::Result<(u64, u64)> {
111 -
    let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
112 -
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
113 -
    if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
114 -
        return Err(std::io::Error::last_os_error());
115 -
    }
116 -
    let unit = st.f_frsize as u64;
117 -
    Ok(((st.f_blocks as u64).saturating_sub(st.f_bfree as u64) * unit, st.f_blocks as u64 * unit))
118 -
}
119 -
120 -
#[cfg(unix)]
121 -
/// Set every regular file's atime under `root` to the epoch, its mtime
122 -
/// kept, so the job's first read of each is recorded even on `relatime`
123 -
/// (which records a read only of a file read no later than it changed).
124 -
/// Links are not followed. The count of files.
125 -
pub fn forget_reads(root: &Path) -> std::io::Result<u64> {
126 -
    let mut n = 0;
127 -
    let mut err = None;
128 -
    walk(root, &mut |p, md| {
129 -
        if !md.file_type().is_file() {
130 -
            return;
131 -
        }
132 -
        let Ok(c) = std::ffi::CString::new(p.as_os_str().as_encoded_bytes()) else { return };
133 -
        let times = [libc::timespec { tv_sec: 0, tv_nsec: 0 }, libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_OMIT }];
134 -
        if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) } == 0 {
135 -
            n += 1;
136 -
        } else if err.is_none() {
137 -
            err = Some(std::io::Error::last_os_error());
138 -
        }
139 -
    })?;
140 -
    match err {
141 -
        Some(e) => Err(e),
142 -
        None => Ok(n),
143 -
    }
144 -
}
145 -
146 -
#[cfg(unix)]
147 -
/// Each entry under `root` (not `root` itself) with its metadata, parents
148 -
/// before children; links are not followed, nor other file systems
149 -
/// entered.
150 -
fn walk(root: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) -> std::io::Result<()> {
151 -
    let dev = std::fs::symlink_metadata(root)?.dev();
152 -
    let mut dirs = vec![root.to_path_buf()];
153 -
    while let Some(d) = dirs.pop() {
154 -
        let Ok(entries) = std::fs::read_dir(&d) else { continue };
155 -
        for e in entries.flatten() {
156 -
            let p = e.path();
157 -
            let Ok(md) = std::fs::symlink_metadata(&p) else { continue };
158 -
            if md.dev() != dev {
159 -
                continue;
160 -
            }
161 -
            f(&p, &md);
162 -
            if md.is_dir() {
163 -
                dirs.push(p);
164 -
            }
165 -
        }
166 -
    }
167 -
    Ok(())
168 -
}
169 -
170 -
#[cfg(unix)]
171 -
/// The unit hashes in `rel`: a 16-digit lowercase hex word after a `-`
172 -
/// in any of its names, as cargo names a unit's files.
173 -
fn hashes(rel: &Path) -> impl Iterator<Item = String> + '_ {
174 -
    rel.components().filter_map(|c| c.as_os_str().to_str()).flat_map(|name| {
175 -
        name.split('-').skip(1).filter_map(|w| {
176 -
            let w = w.split('.').next().unwrap_or("");
177 -
            (w.len() == 16 && w.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))).then(|| w.to_string())
178 -
        })
179 -
    })
180 -
}
181 -
182 -
#[cfg(unix)]
183 -
fn nanos(s: i64, ns: i64) -> i128 {
184 -
    s as i128 * 1_000_000_000 + ns as i128
185 -
}
186 -
187 -
#[cfg(unix)]
188 -
/// One file, however many names it has.
189 -
struct File {
190 -
    paths: Vec<PathBuf>,
191 -
    links: u64,
192 -
    bytes: u64,
193 -
    /// When it was last used as far as can be told: the later of its
194 -
    /// atime and mtime.
195 -
    last: i128,
196 -
    used: bool,
197 -
}
198 -
199 -
#[cfg(unix)]
200 -
/// Prune the cache at `root`, a disk of `size` bytes with `before` in
201 -
/// use, down to `percent` of its size, removing only regular files this
202 -
/// run did not use (see the module's notes), those used longest ago
203 -
/// first. `started` is when the job started ([`start_clock`]); `keep` are
204 -
/// paths relative to `root` (the job's outputs) that stay whatever their
205 -
/// times. Under the cap nothing is touched. `after` is `before` less what
206 -
/// was freed.
207 -
pub fn prune(root: &Path, started: SystemTime, keep: &[String], size: u64, before: u64, percent: u8) -> std::io::Result<Pruned> {
208 -
    let mut out = Pruned { size, percent, before, after: before, ..Pruned::default() };
209 -
    if before <= out.cap() {
210 -
        return Ok(out);
211 -
    }
212 -
    let since = started.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos() as i128).unwrap_or(0);
213 -
    let keep: Vec<PathBuf> = keep.iter().map(|k| PathBuf::from(k.trim_start_matches('/'))).filter(|k| !k.as_os_str().is_empty()).collect();
214 -
    let mut files: HashMap<(u64, u64), File> = HashMap::new();
215 -
    walk(root, &mut |p, md| {
216 -
        if !md.file_type().is_file() {
217 -
            return;
218 -
        }
219 -
        let rel = p.strip_prefix(root).unwrap_or(p);
220 -
        let used = [nanos(md.atime(), md.atime_nsec()), nanos(md.mtime(), md.mtime_nsec()), nanos(md.ctime(), md.ctime_nsec())]
221 -
            .into_iter()
222 -
            .any(|t| t >= since)
223 -
            || keep.iter().any(|k| rel.starts_with(k));
224 -
        let f = files.entry((md.dev(), md.ino())).or_insert_with(|| File {
225 -
            paths: Vec::new(),
226 -
            links: md.nlink(),
227 -
            bytes: md.blocks() * 512,
228 -
            last: nanos(md.atime(), md.atime_nsec()).max(nanos(md.mtime(), md.mtime_nsec())),
229 -
            used: false,
230 -
        });
231 -
        f.paths.push(p.to_path_buf());
232 -
        f.used |= used;
233 -
    })?;
234 -
    // A hash any used file carries keeps every file carrying it.
235 -
    let live: HashSet<String> =
236 -
        files.values().filter(|f| f.used).flat_map(|f| f.paths.iter().flat_map(|p| hashes(p.strip_prefix(root).unwrap_or(p)))).collect();
237 -
    let mut gone: Vec<File> = files
238 -
        .into_values()
239 -
        .filter(|f| {
240 -
            !f.used
241 -
                // A link from outside the cache: removing it frees nothing.
242 -
                && f.links == f.paths.len() as u64
243 -
                && !f.paths.iter().any(|p| hashes(p.strip_prefix(root).unwrap_or(p)).any(|h| live.contains(&h)))
244 -
        })
245 -
        .collect();
246 -
    gone.sort_by_key(|f| f.last);
247 -
    let mut emptied: HashSet<PathBuf> = HashSet::new();
248 -
    for f in gone {
249 -
        if out.after <= out.cap() {
250 -
            break;
251 -
        }
252 -
        let mut removed = true;
253 -
        for p in &f.paths {
254 -
            if std::fs::remove_file(p).is_err() {
255 -
                removed = false;
256 -
            } else if let Some(parent) = p.parent() {
257 -
                emptied.insert(parent.to_path_buf());
258 -
            }
259 -
        }
260 -
        if removed {
261 -
            out.after = out.after.saturating_sub(f.bytes);
262 -
            out.freed += f.bytes;
263 -
            out.files += 1;
264 -
        }
265 -
    }
266 -
    // Directories pruning emptied go too, deepest first; never `root`.
267 -
    let mut dirs: Vec<PathBuf> = emptied.into_iter().collect();
268 -
    dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
269 -
    for d in dirs {
270 -
        let mut d = d.as_path();
271 -
        while d != root && d.starts_with(root) && std::fs::remove_dir(d).is_ok() {
272 -
            match d.parent() {
273 -
                Some(p) => d = p,
274 -
                None => break,
275 -
            }
276 -
        }
277 -
    }
278 -
    Ok(out)
279 -
}
280 -
281 -
// Elsewhere (a Windows runner, which has no microVM tier and so no cache
282 -
// disks to prune) nothing records reads, so nothing is pruned.
283 -
284 -
#[cfg(not(unix))]
285 -
pub fn records_reads(_path: &Path) -> bool {
286 -
    false
287 -
}
288 -
289 -
#[cfg(not(unix))]
290 -
pub fn usage(_path: &Path) -> std::io::Result<(u64, u64)> {
291 -
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
292 -
}
293 -
294 -
#[cfg(not(unix))]
295 -
pub fn forget_reads(_root: &Path) -> std::io::Result<u64> {
296 -
    Ok(0)
297 -
}
298 -
299 -
#[cfg(not(unix))]
300 -
pub fn prune(_root: &Path, _started: SystemTime, _keep: &[String], _size: u64, _before: u64, _percent: u8) -> std::io::Result<Pruned> {
301 -
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
302 -
}
303 -
304 -
#[cfg(all(test, unix))]
305 -
mod tests {
306 -
    use super::*;
307 -
308 -
    /// Times around a job that started at `T`, ahead of the clock: a file
309 -
    /// whose times are set is changed (ctime) now, before it.
310 -
    const T: u64 = 4_000_000_000;
311 -
312 -
    fn at(secs: u64) -> SystemTime {
313 -
        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
314 -
    }
315 -
316 -
    /// A file of `kib` KiB at `rel`, last read and written at `secs`.
317 -
    fn file(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
318 -
        let p = root.join(rel);
319 -
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
320 -
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
321 -
        std::fs::write(&p, bytes).unwrap();
322 -
        let f = std::fs::File::options().write(true).open(&p).unwrap();
323 -
        f.set_times(std::fs::FileTimes::new().set_accessed(at(secs)).set_modified(at(secs))).unwrap();
324 -
        p
325 -
    }
326 -
327 -
    fn bytes(p: &Path) -> u64 {
328 -
        std::fs::metadata(p).unwrap().blocks() * 512
329 -
    }
330 -
331 -
    #[test]
332 -
    fn hashes_are_cargos_unit_hashes() {
333 -
        let h = |p: &str| hashes(Path::new(p)).collect::<Vec<_>>();
334 -
        assert_eq!(h("debug/deps/libdep-8f2b19826c505967.rlib"), ["8f2b19826c505967"]);
335 -
        assert_eq!(h("debug/.fingerprint/dep-8f2b19826c505967/lib-dep"), ["8f2b19826c505967"]);
336 -
        assert_eq!(h("debug/build/my-crate-0123456789abcdef/out/x.o"), ["0123456789abcdef"]);
337 -
        assert!(h("debug/incremental/app-2dadnjnzlrswc/s-hndl5uwdpw-1uaho4m.lock").is_empty());
338 -
        assert!(h("debug/app").is_empty() && h("debug/deps/x-0123456789ABCDEF").is_empty() && h("x-0123456789abcde").is_empty());
339 -
    }
340 -
341 -
    #[test]
342 -
    fn under_the_cap_nothing_is_touched() {
343 -
        let dir = tempfile::tempdir().unwrap();
344 -
        let old = file(dir.path(), "debug/deps/libold-1111111111111111.rlib", 64, 1_000);
345 -
        let p = prune(dir.path(), at(T), &[], 1 << 30, 64 << 10, 60).unwrap();
346 -
        assert!(old.exists() && p.fits() && p.freed == 0 && p.after == p.before, "{p:?}");
347 -
    }
348 -
349 -
    /// A cargo target as a run leaves it: this lockfile's units used this
350 -
    /// run (one rebuilt, one fresh: only its fingerprint read), an older
351 -
    /// lockfile's not, and an output the run did not touch.
352 -
    #[test]
353 -
    fn what_this_run_did_not_use_goes_oldest_first_and_its_units_stay_whole() {
354 -
        let dir = tempfile::tempdir().unwrap();
355 -
        let root = dir.path();
356 -
        let app = file(root, "debug/deps/app-aaaaaaaaaaaaaaaa", 256, T + 100);
357 -
        let app_fp = file(root, "debug/.fingerprint/app-aaaaaaaaaaaaaaaa/bin-app", 1, T + 100);
358 -
        let dep_rlib = file(root, "debug/deps/libdep-bbbbbbbbbbbbbbbb.rlib", 256, T - 5_000);
359 -
        let dep_fp = file(root, "debug/.fingerprint/dep-bbbbbbbbbbbbbbbb/lib-dep", 1, T - 5_000);
360 -
        std::fs::File::open(&dep_fp).unwrap().set_times(std::fs::FileTimes::new().set_accessed(at(T + 50))).unwrap();
361 -
        let script = file(root, "debug/build/dep-bbbbbbbbbbbbbbbb/out/gen.rs", 64, T - 5_000);
362 -
        // Uplifted: another name of the rebuilt binary.
363 -
        std::fs::hard_link(&app, root.join("debug/app")).unwrap();
364 -
        // An older lockfile's units, untouched, the oldest first to go.
365 -
        let oldest = file(root, "debug/deps/libdep-cccccccccccccccc.rlib", 256, T - 9_000);
366 -
        let oldest_fp = file(root, "debug/.fingerprint/dep-cccccccccccccccc/lib-dep", 1, T - 8_999);
367 -
        let older = file(root, "debug/deps/libdep-dddddddddddddddd.rlib", 256, T - 8_000);
368 -
        // A declared output, older still, that the run did not touch.
369 -
        let output = file(root, "release/tool", 128, T - 10_000);
370 -
        let all = [&app, &app_fp, &dep_rlib, &dep_fp, &script, &oldest, &oldest_fp, &older, &output];
371 -
        let before: u64 = all.iter().map(|p| bytes(p)).sum();
372 -
        // Room for all but the oldest unit.
373 -
        let want = before - bytes(&oldest) - bytes(&oldest_fp);
374 -
        let size = (want * 100).div_ceil(60);
375 -
        let p = prune(root, at(T), &["/release/tool".into()], size, before, 60).unwrap();
376 -
        assert!(p.fits(), "{p:?}");
377 -
        assert!(
378 -
            !oldest.exists() && !root.join("debug/.fingerprint/dep-cccccccccccccccc").exists(),
379 -
            "the oldest unit went, its emptied directory too"
380 -
        );
381 -
        assert!(older.exists(), "and pruning stopped under the cap: {p:?}");
382 -
        for kept in [&app, &app_fp, &dep_rlib, &dep_fp, &script, &output, &root.join("debug/app")] {
383 -
            assert!(kept.exists(), "{} stays", kept.display());
384 -
        }
385 -
        assert_eq!((p.files, p.freed, p.after), (2, p.before - want, want));
386 -
        // With no room at all: still only what the run did not use goes.
387 -
        let p = prune(root, at(T), &["release/tool".into()], 1, p.after, 60).unwrap();
388 -
        assert!(!older.exists() && !p.fits() && p.files == 1, "{p:?}");
389 -
        for kept in [&app, &dep_rlib, &dep_fp, &script, &output] {
390 -
            assert!(kept.exists(), "{} stays", kept.display());
391 -
        }
392 -
        assert!(root.join("debug/deps").is_dir(), "a directory still holding files stays");
393 -
    }
394 -
395 -
    #[test]
396 -
    fn reads_are_forgotten_so_the_next_is_seen() {
397 -
        let dir = tempfile::tempdir().unwrap();
398 -
        let p = file(dir.path(), "a/b", 1, 5_000);
399 -
        std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
400 -
        assert_eq!(forget_reads(dir.path()).unwrap(), 1);
401 -
        let md = std::fs::metadata(&p).unwrap();
402 -
        assert_eq!((md.atime(), md.mtime()), (0, 5_000), "the mtime is kept");
403 -
        assert!(std::fs::symlink_metadata(dir.path().join("link")).unwrap().atime() > 0, "a link is not followed");
404 -
    }
405 -
}
modified crates/flow/Cargo.toml +0 −24
…
4 unchanged lines
5 5
license.workspace = true
6 6
description = "OxygenFlow: check pipelines, run them locally, and run jobs for OxygenHub"
7 7
8 -
[lib]
9 -
name = "flow"
10 -
path = "src/lib.rs"
11 -
12 8
[[bin]]
13 9
name = "flow"
14 10
path = "src/main.rs"
…
1 unchanged lines
16 12
[dependencies]
17 13
flow-core.workspace = true
18 14
jet.workspace = true
19 -
flow-init.workspace = true
20 15
oxy-core.workspace = true
21 16
serde.workspace = true
22 17
serde_json.workspace = true
…
5 unchanged lines
28 23
ureq = { version = "2", features = ["json"] }
29 24
ignore = "0.4"
30 25
libc = "0.2"
31 -
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
32 -
rcgen = "0.14"
33 -
# The WASM tier (oxygenflow#75): Cranelift-compiled WASI preview 1 modules.
34 -
wasmtime = { version = "=49.0.4", optional = true, default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] }
35 -
wasmtime-wasi = { version = "=49.0.4", optional = true, default-features = false, features = ["p1"] }
36 -
37 -
# A Windows runner's free disk, version, and renaming a running flow.exe
38 -
# (oxygenflow#83);
39 -
# already in the tree through wasmtime.
40 -
[target.'cfg(windows)'.dependencies]
41 -
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_SystemInformation", "Wdk_System_SystemServices"] }
42 -
43 -
[features]
44 -
default = ["wasm"]
45 -
# The WASM tier: off for systems wasmtime does not build or run on (Haiku,
46 -
# oxygenflow#91).
47 -
wasm = ["dep:wasmtime", "dep:wasmtime-wasi"]
48 26
49 27
[dev-dependencies]
50 28
tempfile.workspace = true
51 -
# Test modules written as text.
52 -
wat = "1"
modified crates/flow/build.rs +1 −5
…
2 unchanged lines
3 3
//! building, else "local build".
4 4
fn main() {
5 5
    println!("cargo:rerun-if-env-changed=OXY_BUILD");
6 -
    // The heads name the build only when OXY_BUILD does not: a CI job that
7 -
    // sets it stays fresh from one state to the next (oxygenflow#52).
8 -
    if !std::env::var("OXY_BUILD").is_ok_and(|b| !b.trim().is_empty()) {
9 -
        println!("cargo:rerun-if-env-changed=FLOW_HEADS");
10 -
    }
6 +
    println!("cargo:rerun-if-env-changed=FLOW_HEADS");
11 7
    let build = std::env::var("OXY_BUILD")
12 8
        .ok()
13 9
        .map(|b| b.trim().to_string())
…
11 unchanged lines
removed crates/flow/src/cache.rs +0 −965
1 -
//! The build cache (oxygenflow#28, spec §5.4): a disk mount that names a
2 -
//! `cache` is started from a disk kept by an earlier passing job, rather
3 -
//! than empty, so a build carries its compiled dependencies forward.
4 -
//!
5 -
//! Each runner keeps a pool under `<home>/cache`, by repository, job,
6 -
//! cache name and bookmark, each disk named by its key: the BLAKE3 of the
7 -
//! image, the job, the cache name and the content of its key files. A job
8 -
//! takes, in order:
9 -
//!
10 -
//! 1. its bookmark's disk with the same key (moved: no copying);
11 -
//! 2. its bookmark's newest disk (moved; cargo rebuilds what changed);
12 -
//! 3. another bookmark's disk with the same key, then its newest (copied,
13 -
//!    so a proposal never takes main's cache away);
14 -
//! 4. else an empty disk, as without a cache.
15 -
//!
16 -
//! A passing job's disk goes back into its bookmark's place; a failing
17 -
//! job's is thrown away, so nothing it wrote reaches a later job. The
18 -
//! receipt names what the job started from. The pool is kept under its
19 -
//! size limit by dropping the disks used longest ago.
20 -
//!
21 -
//! A disk is in the [`Form`] its tier mounts (oxygenflow#70): a microVM's
22 -
//! is an ext4 image, `<key>.ext4`; the syscall tier's is the host
23 -
//! directory it binds at the mount's target, `<key>.tree`, taken and kept
24 -
//! the same way. A directory goes into the pool with nothing in it
25 -
//! writable by others or set-id, and the pool itself is the runner's
26 -
//! alone.
27 -
//!
28 -
//! Beside each disk, `<key>.sources` (`<key>.tree-sources` for a
29 -
//! directory) lists the sources it was built from (oxygenflow#52): each
30 -
//! file's content hash and the mtime it had. A job that takes the disk
31 -
//! gives every file whose content is unchanged that mtime back, so a build
32 -
//! tool that judges freshness by time (cargo) rebuilds only what changed;
33 -
//! a changed file keeps the time of its checkout, newer than anything on
34 -
//! the disk, so it is always rebuilt.
35 -
//!
36 -
//! A passing job's disk is pruned before it is kept (oxygenflow#72; see
37 -
//! [`flow_init::prune`]): over [`flow_init::prune::DEFAULT_PERCENT`] of
38 -
//! its size (the runner's `cache_prune_percent`), the files the job did
39 -
//! not use go, oldest first, and a cache still over it is not kept, so a
40 -
//! kept target never grows until the job's disk is full. A microVM's
41 -
//! guest prunes its image before it stops (the runner cannot look inside
42 -
//! an ext4 image without mounting it); the runner prunes a directory
43 -
//! itself, having set its files' atimes to the epoch before the job
44 -
//! ([`forget_reads`]) so that every read is seen. The job's log says what
45 -
//! was kept and pruned.
46 -
47 -
use anyhow::{Context, Result};
48 -
use flow_core::pipeline::Mount;
49 -
use flow_init::prune::Pruned;
50 -
use std::path::{Path, PathBuf};
51 -
use std::time::SystemTime;
52 -
53 -
/// The pool's default size limit.
54 -
pub const DEFAULT_GB: u64 = 64;
55 -
56 -
/// The sources a cached disk was built from: path to content hash and
57 -
/// mtime, in whole seconds (what the workspace image keeps).
58 -
pub type Sources = std::collections::BTreeMap<String, (String, i64)>;
59 -
60 -
/// What a job started a cached disk from.
61 -
pub struct Taken {
62 -
    /// For the log and the receipt.
63 -
    pub how: String,
64 -
    /// The sources it was built from, when they were kept with it.
65 -
    pub sources: Option<Sources>,
66 -
}
67 -
68 -
/// How a tier holds a disk mount's files (oxygenflow#70).
69 -
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70 -
pub enum Form {
71 -
    /// An ext4 image of the mount's size, attached to a microVM.
72 -
    Image,
73 -
    /// A host directory, bound into the syscall sandbox.
74 -
    Dir,
75 -
}
76 -
77 -
impl Form {
78 -
    fn ext(self) -> &'static str {
79 -
        match self {
80 -
            Form::Image => "ext4",
81 -
            Form::Dir => "tree",
82 -
        }
83 -
    }
84 -
}
85 -
86 -
/// Which form a kept entry of the pool is in, by its name.
87 -
fn form_of(p: &Path) -> Option<Form> {
88 -
    match p.extension()?.to_str()? {
89 -
        "ext4" => Some(Form::Image),
90 -
        "tree" => Some(Form::Dir),
91 -
        _ => None,
92 -
    }
93 -
}
94 -
95 -
fn sources_of(disk: &Path) -> PathBuf {
96 -
    match form_of(disk) {
97 -
        Some(Form::Dir) => disk.with_extension("tree-sources"),
98 -
        _ => disk.with_extension("sources"),
99 -
    }
100 -
}
101 -
102 -
fn read_sources(disk: &Path) -> Option<Sources> {
103 -
    serde_json::from_slice(&std::fs::read(sources_of(disk)).ok()?).ok()
104 -
}
105 -
106 -
/// How a passing job's cache disk is pruned before it is kept
107 -
/// (oxygenflow#72).
108 -
pub struct Prune<'a> {
109 -
    /// When the job started ([`flow_init::prune::start_clock`]): what it
110 -
    /// read or wrote since is what it used.
111 -
    pub started: SystemTime,
112 -
    /// The share of the disk the kept cache may fill.
113 -
    pub percent: u8,
114 -
    /// What the guest did to a microVM's image, when it said.
115 -
    pub guest: Option<&'a Pruned>,
116 -
}
117 -
118 -
/// Where a job's cached disks come from and go back to.
119 -
pub struct Pool {
120 -
    root: PathBuf,
121 -
    limit: u64,
122 -
}
123 -
124 -
/// Which job: the pool's coordinates other than the key, and the form its
125 -
/// tier keeps a disk in.
126 -
pub struct Ctx<'a> {
127 -
    pub repo: &'a str,
128 -
    pub bookmark: &'a str,
129 -
    pub job: &'a str,
130 -
    pub image: &'a str,
131 -
    pub form: Form,
132 -
}
133 -
134 -
use flow_core::api::segment;
135 -
136 -
/// Held while the pool changes: jobs run side by side, and a directory,
137 -
/// unlike an image, can be half moved or half removed while another job
138 -
/// copies it.
139 -
static CHANGING: std::sync::Mutex<()> = std::sync::Mutex::new(());
140 -
141 -
fn changing() -> std::sync::MutexGuard<'static, ()> {
142 -
    CHANGING.lock().unwrap_or_else(|p| p.into_inner())
143 -
}
144 -
145 -
impl Pool {
146 -
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
147 -
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
148 -
    }
149 -
150 -
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
151 -
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
152 -
    }
153 -
154 -
    /// A mount's key: the image, job, cache name and its key files'
155 -
    /// content hashes (`hash_of` gives a file's, `None` when absent).
156 -
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
157 -
        let mut h = blake3::Hasher::new();
158 -
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
159 -
            h.update(part.as_bytes());
160 -
            h.update(b"\n");
161 -
        }
162 -
        for k in &m.key {
163 -
            h.update(k.as_bytes());
164 -
            h.update(b"=");
165 -
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
166 -
            h.update(b"\n");
167 -
        }
168 -
        h.finalize().to_hex()[..32].to_string()
169 -
    }
170 -
171 -
    /// Start `dest` from the pool for mount `m`: an image of `m.size_mib`,
172 -
    /// or a directory, as `ctx.form` says; `dest` must not exist yet. What
173 -
    /// it started from, or `None` (the caller makes an empty disk).
174 -
    ///
175 -
    /// A disk this job's bookmark kept is moved out of the pool, so a job
176 -
    /// of the same slot running beside this one can never take it too.
177 -
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
178 -
        let name = m.cache.as_deref()?;
179 -
        let _changing = changing();
180 -
        let dir = self.cache_dir(ctx, name);
181 -
        let size = m.size_mib as u64 * (1 << 20);
182 -
        let form = ctx.form;
183 -
        let fits = |p: &Path| match form {
184 -
            Form::Image => std::fs::metadata(p).is_ok_and(|md| md.len() == size),
185 -
            Form::Dir => std::fs::symlink_metadata(p).is_ok_and(|md| md.is_dir()),
186 -
        };
187 -
        let ext = form.ext();
188 -
        let mine = dir.join(segment(ctx.bookmark));
189 -
        let exact = mine.join(format!("{key}.{ext}"));
190 -
        // A moved disk takes its sources with it.
191 -
        if fits(&exact) {
192 -
            let sources = read_sources(&exact);
193 -
            if std::fs::rename(&exact, dest).is_ok() {
194 -
                let _ = std::fs::remove_file(sources_of(&exact));
195 -
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
196 -
            }
197 -
        }
198 -
        if let Some(p) = newest(&mine, form, |p| fits(p)) {
199 -
            let sources = read_sources(&p);
200 -
            if std::fs::rename(&p, dest).is_ok() {
201 -
                let _ = std::fs::remove_file(sources_of(&p));
202 -
                return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources });
203 -
            }
204 -
        }
205 -
        let others: Vec<PathBuf> =
206 -
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
207 -
        let pick = others
208 -
            .iter()
209 -
            .map(|d| d.join(format!("{key}.{ext}")))
210 -
            .filter(|p| fits(p))
211 -
            .max_by_key(|p| modified(p))
212 -
            .map(|p| (p, "same key"))
213 -
            .or_else(|| {
214 -
                others.iter().filter_map(|d| newest(d, form, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key"))
215 -
            });
216 -
        if let Some((p, which)) = pick {
217 -
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
218 -
            if copy(form, &p, dest).is_ok() {
219 -
                return Some(Taken {
220 -
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
221 -
                    sources: read_sources(&p),
222 -
                });
223 -
            }
224 -
        }
225 -
        None
226 -
    }
227 -
228 -
    /// Keep a passing job's disk for mount `m` under its key, then keep
229 -
    /// the pool under its limit.
230 -
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
231 -
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
232 -
        let _changing = changing();
233 -
        // Whatever the job left, nothing in the pool is anyone else's to
234 -
        // write or runs as the runner (oxygenflow#70).
235 -
        if ctx.form == Form::Dir {
236 -
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
237 -
        }
238 -
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
239 -
        crate::portable::create_private_dir(&mine)?;
240 -
        crate::portable::set_mode(&self.root, 0o700)?;
241 -
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
242 -
        if ctx.form == Form::Dir {
243 -
            // Another job of this slot kept one since: this one is newer.
244 -
            crate::workspace::remove_tree(&dest)?;
245 -
        }
246 -
        if std::fs::rename(disk, &dest).is_err() {
247 -
            copy(ctx.form, disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
248 -
        }
249 -
        // Moved disks keep their old time: mark it used now.
250 -
        std::fs::File::open(&dest)?.set_modified(SystemTime::now())?;
251 -
        let sidecar = sources_of(&dest);
252 -
        let tmp = sidecar.with_extension("sources-part");
253 -
        let _ = std::fs::remove_file(&tmp);
254 -
        {
255 -
            use std::io::Write;
256 -
            crate::portable::private(std::fs::OpenOptions::new().write(true).create_new(true))
257 -
                .open(&tmp)?
258 -
                .write_all(&serde_json::to_vec(sources)?)?;
259 -
        }
260 -
        std::fs::rename(&tmp, sidecar)?;
261 -
        // One disk per key is enough; older keys of this bookmark go once
262 -
        // the pool is over its limit.
263 -
        self.evict();
264 -
        Ok(())
265 -
    }
266 -
267 -
    /// After the job: a passing job's disk is pruned and kept, unless it
268 -
    /// is still over its cap; a failing one's is thrown away, so nothing
269 -
    /// it wrote reaches a later job. For a kept or refused cache, what to
270 -
    /// say in the job's log.
271 -
    #[allow(clippy::too_many_arguments)]
272 -
    pub fn settle(
273 -
        &self,
274 -
        ctx: &Ctx,
275 -
        m: &Mount,
276 -
        key: &str,
277 -
        disk: &Path,
278 -
        sources: &Sources,
279 -
        passed: bool,
280 -
        prune: &Prune,
281 -
    ) -> Result<Option<String>> {
282 -
        if passed {
283 -
            let Some(name) = m.cache.as_deref() else { return Ok(None) };
284 -
            let pruned = pruned(ctx.form, m, disk, prune)?;
285 -
            let line = describe(name, &pruned);
286 -
            if pruned.fits() {
287 -
                self.keep(ctx, m, key, disk, sources)?;
288 -
            } else {
289 -
                discard(ctx.form, disk)?;
290 -
            }
291 -
            return Ok(Some(line));
292 -
        }
293 -
        discard(ctx.form, disk)?;
294 -
        Ok(None)
295 -
    }
296 -
297 -
    /// The repository and job slots holding at least one disk, as
298 -
    /// [`flow_core::api::cache_slot`] names them, for the claim
299 -
    /// (oxygenflow#51), each with when its newest disk was kept (Unix ms):
300 -
    /// a disk's time is set when it is kept, so the newest is the freshest
301 -
    /// build (oxygenflow#54). Images and directories alike, so a job goes
302 -
    /// where its cache is whichever tier keeps it.
303 -
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
304 -
        let mut out = std::collections::BTreeMap::new();
305 -
        for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() {
306 -
            for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() {
307 -
                let mut newest: Option<SystemTime> = None;
308 -
                // `<job>/<cache>/<bookmark>/<disk>`
309 -
                kept(&job.path(), 2, &mut |p, _| {
310 -
                    let t = modified(p);
311 -
                    newest = Some(newest.map_or(t, |n| n.max(t)));
312 -
                });
313 -
                if let Some(t) = newest {
314 -
                    let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
315 -
                    out.insert(format!("{}/{}", repo.file_name().to_string_lossy(), job.file_name().to_string_lossy()), ms);
316 -
                }
317 -
            }
318 -
        }
319 -
        out
320 -
    }
321 -
322 -
    /// Drop the disks used longest ago until the pool's blocks fit: an
323 -
    /// image's blocks, or everything in a directory. The caller holds
324 -
    /// [`changing`].
325 -
    fn evict(&self) -> u64 {
326 -
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
327 -
        // `<repo>/<job>/<cache>/<bookmark>/<disk>`
328 -
        kept(&self.root, 4, &mut |p, form| {
329 -
            if let Ok(md) = std::fs::symlink_metadata(p) {
330 -
                let bytes = match form {
331 -
                    Form::Image => crate::portable::disk_bytes(&md),
332 -
                    Form::Dir => tree_bytes(p),
333 -
                };
334 -
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
335 -
            }
336 -
        });
337 -
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
338 -
        disks.sort();
339 -
        let mut freed = 0;
340 -
        for (_, bytes, p) in disks {
341 -
            if total <= self.limit {
342 -
                break;
343 -
            }
344 -
            let gone = match form_of(&p) {
345 -
                Some(Form::Dir) => crate::workspace::remove_tree(&p),
346 -
                _ => std::fs::remove_file(&p),
347 -
            };
348 -
            if gone.is_ok() {
349 -
                let _ = std::fs::remove_file(sources_of(&p));
350 -
                total -= bytes;
351 -
                freed += bytes;
352 -
            }
353 -
        }
354 -
        freed
355 -
    }
356 -
}
357 -
358 -
/// Throw a disk away.
359 -
fn discard(form: Form, disk: &Path) -> Result<()> {
360 -
    let gone = match form {
361 -
        Form::Dir => crate::workspace::remove_tree(disk),
362 -
        Form::Image => std::fs::remove_file(disk).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }),
363 -
    };
364 -
    gone.with_context(|| format!("discard {}", disk.display()))
365 -
}
366 -
367 -
/// Before a syscall job: set the atime of every file of a directory it
368 -
/// took from the pool to the epoch, so that whatever the file system's
369 -
/// atime rule, the job's first read of each is seen when the directory is
370 -
/// pruned (oxygenflow#72). A microVM's guest mounts its disk
371 -
/// `strictatime` instead.
372 -
pub fn forget_reads(form: Form, disk: &Path) -> Result<()> {
373 -
    if form == Form::Dir && disk.is_dir() {
374 -
        flow_init::prune::forget_reads(disk).with_context(|| format!("set the atimes in {}", disk.display()))?;
375 -
    }
376 -
    Ok(())
377 -
}
378 -
379 -
/// Prune a passing job's disk, or say why it was not: a directory here,
380 -
/// an image by what its guest said (else only its size is measured).
381 -
fn pruned(form: Form, m: &Mount, disk: &Path, prune: &Prune) -> Result<Pruned> {
382 -
    let size = m.size_mib as u64 * (1 << 20);
383 -
    let percent = prune.percent;
384 -
    let unpruned = |before: u64, why: String| Pruned { size, percent, before, after: before, unpruned: Some(why), ..Pruned::default() };
385 -
    Ok(match form {
386 -
        Form::Dir => {
387 -
            let before = tree_bytes(disk);
388 -
            if !flow_init::prune::records_reads(disk) {
389 -
                unpruned(before, "this runner's file system records no reads (noatime), so what the job used cannot be told".into())
390 -
            } else {
391 -
                flow_init::prune::prune(disk, prune.started, &[], size, before, percent)
392 -
                    .unwrap_or_else(|e| unpruned(tree_bytes(disk), format!("pruning failed: {e}")))
393 -
            }
394 -
        }
395 -
        Form::Image => match prune.guest.filter(|g| g.size > 0) {
396 -
            Some(g) => g.clone(),
397 -
            None => {
398 -
                let (before, size) = image_usage(disk)?;
399 -
                let why = prune.guest.and_then(|g| g.unpruned.clone()).unwrap_or_else(|| "the guest did not prune it".into());
400 -
                Pruned { size, ..unpruned(before, why) }
401 -
            }
402 -
        },
403 -
    })
404 -
}
405 -
406 -
/// What an ext4 image holds and its size, as its superblock counts them.
407 -
fn image_usage(image: &Path) -> Result<(u64, u64)> {
408 -
    let out = std::process::Command::new("dumpe2fs").arg("-h").arg(image).output().context("run dumpe2fs (e2fsprogs)")?;
409 -
    if !out.status.success() {
410 -
        anyhow::bail!("dumpe2fs {}: {}", image.display(), String::from_utf8_lossy(&out.stderr).trim());
411 -
    }
412 -
    superblock_usage(&String::from_utf8_lossy(&out.stdout)).with_context(|| format!("dumpe2fs {}: no block counts", image.display()))
413 -
}
414 -
415 -
fn superblock_usage(header: &str) -> Option<(u64, u64)> {
416 -
    let field = |name: &str| -> Option<u64> {
417 -
        header.lines().find_map(|l| l.strip_prefix(name)?.strip_prefix(':')).and_then(|v| v.trim().parse().ok())
418 -
    };
419 -
    let (count, free, block) = (field("Block count")?, field("Free blocks")?, field("Block size")?);
420 -
    Some((count.saturating_sub(free) * block, count * block))
421 -
}
422 -
423 -
/// The job log's line for a passing job's cache: what was kept, or why
424 -
/// not (oxygenflow#72).
425 -
pub fn describe(name: &str, p: &Pruned) -> String {
426 -
    use crate::images::human;
427 -
    let cap = format!("{}% of its {} disk", p.percent, human(p.size));
428 -
    let pruned = format!("{} in {} file{} this run did not use", human(p.freed), p.files, if p.files == 1 { "" } else { "s" });
429 -
    if p.fits() && p.files > 0 {
430 -
        format!("cache {name}: kept {} of {} (pruned {pruned}; the cap is {cap})", human(p.after), human(p.before))
431 -
    } else if p.fits() {
432 -
        format!("cache {name}: kept {}, under its cap of {cap}", human(p.after))
433 -
    } else {
434 -
        let after = if p.files > 0 { format!("{} after pruning {pruned}", human(p.after)) } else { human(p.after) };
435 -
        let why = p.unpruned.as_ref().map(|u| format!(" ({u})")).unwrap_or_default();
436 -
        format!(
437 -
            "cache {name}: not kept: {after} is over its cap of {} ({cap}){why}; the next run starts empty, and a larger `size` on the mount gives the cache room",
438 -
            human(p.cap())
439 -
        )
440 -
    }
441 -
}
442 -
443 -
/// Where a job's `n`th disk mount is in its scratch directory, in the form
444 -
/// its tier mounts it: `microvm::run` attaches `mount-N.ext4`, and
445 -
/// `syscall::run` binds the directory `mount-N`.
446 -
pub fn disk_path(form: Form, scratch: &Path, n: usize) -> PathBuf {
447 -
    match form {
448 -
        Form::Image => scratch.join(format!("mount-{n}.ext4")),
449 -
        Form::Dir => crate::syscall::disk_dir(scratch, n),
450 -
    }
451 -
}
452 -
453 -
fn modified(p: &Path) -> SystemTime {
454 -
    std::fs::symlink_metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
455 -
}
456 -
457 -
fn newest(dir: &Path, form: Form, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
458 -
    std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| form_of(p) == Some(form) && ok(p)).max_by_key(|p| modified(p))
459 -
}
460 -
461 -
/// Each kept image or directory `depth` levels of directories below `dir`,
462 -
/// with its form; never inside a kept directory.
463 -
fn kept(dir: &Path, depth: usize, f: &mut dyn FnMut(&Path, Form)) {
464 -
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
465 -
        let p = e.path();
466 -
        let Ok(t) = e.file_type() else { continue };
467 -
        if depth == 0 {
468 -
            match form_of(&p) {
469 -
                Some(Form::Image) if t.is_file() => f(&p, Form::Image),
470 -
                Some(Form::Dir) if t.is_dir() => f(&p, Form::Dir),
471 -
                _ => {}
472 -
            }
473 -
        } else if t.is_dir() {
474 -
            kept(&p, depth - 1, f);
475 -
        }
476 -
    }
477 -
}
478 -
479 -
/// The blocks a directory's files take, each file once however many links
480 -
/// it has; links are not followed.
481 -
fn tree_bytes(dir: &Path) -> u64 {
482 -
    fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 {
483 -
        let Ok(md) = std::fs::symlink_metadata(p) else { return 0 };
484 -
        if crate::portable::shared_identity(&md).is_some_and(|id| !seen.insert(id)) {
485 -
            return 0;
486 -
        }
487 -
        let mut bytes = crate::portable::disk_bytes(&md);
488 -
        if md.is_dir() {
489 -
            for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
490 -
                bytes += walk(&e.path(), seen);
491 -
            }
492 -
        }
493 -
        bytes
494 -
    }
495 -
    walk(dir, &mut Default::default())
496 -
}
497 -
498 -
/// Make a directory a job left fit for the pool: nothing in it writable by
499 -
/// group or others, nothing set-id or sticky, and everything readable by
500 -
/// the runner (directories searchable and writable too, so it can be
501 -
/// copied and removed). Links are not followed. The job ran as the
502 -
/// runner's user, so all of it is the runner's to change; times are kept.
503 -
fn seal(p: &Path) -> std::io::Result<()> {
504 -
    let md = std::fs::symlink_metadata(p)?;
505 -
    if md.file_type().is_symlink() {
506 -
        return Ok(());
507 -
    }
508 -
    let mode = crate::portable::mode(&md);
509 -
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
510 -
    if want != mode {
511 -
        crate::portable::set_mode(p, want)?;
512 -
    }
513 -
    if md.is_dir() {
514 -
        for e in std::fs::read_dir(p)? {
515 -
            seal(&e?.path())?;
516 -
        }
517 -
    }
518 -
    Ok(())
519 -
}
520 -
521 -
fn copy(form: Form, from: &Path, to: &Path) -> Result<()> {
522 -
    match form {
523 -
        Form::Image => sparse_copy(from, to),
524 -
        Form::Dir => tree_copy(from, to),
525 -
    }
526 -
}
527 -
528 -
/// Copy a sparse disk without writing its holes.
529 -
fn sparse_copy(from: &Path, to: &Path) -> Result<()> {
530 -
    let tmp = to.with_extension("part");
531 -
    let st = std::process::Command::new("cp").arg("--sparse=always").arg(from).arg(&tmp).status().context("run cp")?;
532 -
    if !st.success() {
533 -
        let _ = std::fs::remove_file(&tmp);
534 -
        anyhow::bail!("cp {} failed", from.display());
535 -
    }
536 -
    std::fs::rename(&tmp, to)?;
537 -
    Ok(())
538 -
}
539 -
540 -
/// Copy a directory with its modes and times (a build tool judges by
541 -
/// them), sharing blocks where the filesystem can.
542 -
fn tree_copy(from: &Path, to: &Path) -> Result<()> {
543 -
    let tmp = to.with_extension("tree-part");
544 -
    crate::workspace::remove_tree(&tmp)?;
545 -
    let st = std::process::Command::new("cp")
546 -
        .args(["-a", "--reflink=auto", "--no-target-directory"])
547 -
        .arg(from)
548 -
        .arg(&tmp)
549 -
        .status()
550 -
        .context("run cp")?;
551 -
    if !st.success() {
552 -
        let _ = crate::workspace::remove_tree(&tmp);
553 -
        anyhow::bail!("cp {} failed", from.display());
554 -
    }
555 -
    std::fs::rename(&tmp, to).inspect_err(|_| {
556 -
        let _ = crate::workspace::remove_tree(&tmp);
557 -
    })?;
558 -
    Ok(())
559 -
}
560 -
561 -
#[cfg(all(test, unix))]
562 -
mod tests {
563 -
    use super::*;
564 -
    use flow_core::pipeline::MountSource;
565 -
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
566 -
567 -
    /// Pruning that never prunes: the cap is the whole disk.
568 -
    fn keep_all() -> Prune<'static> {
569 -
        Prune { started: SystemTime::now(), percent: 100, guest: None }
570 -
    }
571 -
572 -
    fn mount(key: &[&str]) -> Mount {
573 -
        Mount {
574 -
            source: MountSource::Disk,
575 -
            target: "/src/target".into(),
576 -
            size_mib: 1,
577 -
            cache: Some("target".into()),
578 -
            key: key.iter().map(|s| s.to_string()).collect(),
579 -
        }
580 -
    }
581 -
582 -
    fn disk(path: &Path, fill: &[u8]) {
583 -
        let f = std::fs::File::create(path).unwrap();
584 -
        f.set_len(1 << 20).unwrap();
585 -
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
586 -
    }
587 -
588 -
    fn first(path: &Path) -> Vec<u8> {
589 -
        std::fs::read(path).unwrap()[..4].to_vec()
590 -
    }
591 -
592 -
    #[test]
593 -
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
594 -
        let home = tempfile::tempdir().unwrap();
595 -
        let work = tempfile::tempdir().unwrap();
596 -
        let pool = Pool::new(home.path(), None);
597 -
        let m = mount(&["Cargo.lock"]);
598 -
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img", form: Form::Image };
599 -
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
600 -
        // Nothing yet: the caller makes an empty disk.
601 -
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
602 -
        // A passing job's disk is kept and moved into the next job's place.
603 -
        disk(&work.path().join("a.ext4"), b"MAIN");
604 -
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
605 -
        pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap();
606 -
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
607 -
        let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
608 -
        assert!(taken.how.contains("same key"), "{}", taken.how);
609 -
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
610 -
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
611 -
        pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap();
612 -
        // A changed key takes the bookmark's newest disk all the same.
613 -
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
614 -
        assert_ne!(key, key2);
615 -
        let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
616 -
        assert!(taken.how.contains("older key"), "{}", taken.how);
617 -
        assert_eq!(taken.sources.as_ref(), Some(&built));
618 -
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap();
619 -
        assert_eq!(
620 -
            pool.slot_times().into_keys().collect::<Vec<_>>(),
621 -
            vec![flow_core::api::cache_slot("andy/hub", "test")],
622 -
            "what the claim reports (#51)"
623 -
        );
624 -
        // With when it was last kept (#54): just now.
625 -
        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64;
626 -
        let kept = pool.slot_times()[&flow_core::api::cache_slot("andy/hub", "test")];
627 -
        assert!(now.abs_diff(kept) < 60_000, "kept {kept}, now {now}");
628 -
        // Another bookmark copies it, leaving main's in place.
629 -
        let feature = Ctx { bookmark: "feature", ..main };
630 -
        let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
631 -
        assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how);
632 -
        assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them");
633 -
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
634 -
        let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap();
635 -
        assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources");
636 -
        // Another job or a different size never shares it.
637 -
        let other_job = Ctx { job: "build", ..main };
638 -
        assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none());
639 -
        let mut bigger = mount(&["Cargo.lock"]);
640 -
        bigger.size_mib = 2;
641 -
        assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none());
642 -
    }
643 -
644 -
    #[test]
645 -
    fn the_key_follows_the_image_the_job_and_the_key_files() {
646 -
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1", form: Form::Image };
647 -
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
648 -
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
649 -
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
650 -
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
651 -
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
652 -
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
653 -
    }
654 -
655 -
    #[test]
656 -
    fn the_pool_drops_the_disks_used_longest_ago() {
657 -
        let home = tempfile::tempdir().unwrap();
658 -
        let work = tempfile::tempdir().unwrap();
659 -
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
660 -
        let m = mount(&[]);
661 -
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
662 -
        let full = |p: &Path| {
663 -
            let f = std::fs::File::create(p).unwrap();
664 -
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
665 -
        };
666 -
        full(&work.path().join("a.ext4"));
667 -
        // Over a limit of nothing: kept, then dropped at once.
668 -
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap();
669 -
        assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none());
670 -
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
671 -
        full(&work.path().join("c.ext4"));
672 -
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
673 -
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
674 -
    }
675 -
676 -
    /// A directory as the syscall tier leaves one: `marker` in
677 -
    /// `debug/marker`, and a file of `kib` KiB.
678 -
    fn tree(path: &Path, marker: &[u8], kib: usize) {
679 -
        std::fs::create_dir_all(path.join("debug")).unwrap();
680 -
        std::fs::write(path.join("debug/marker"), marker).unwrap();
681 -
        // Not all one byte, so a compressing filesystem stores it whole.
682 -
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
683 -
        std::fs::write(path.join("debug/blob"), bytes).unwrap();
684 -
    }
685 -
686 -
    fn dir_ctx<'a>(bookmark: &'a str) -> Ctx<'a> {
687 -
        Ctx { repo: "andy/flow", bookmark, job: "test", image: "img", form: Form::Dir }
688 -
    }
689 -
690 -
    /// oxygenflow#70: the syscall tier's directories are taken and kept as
691 -
    /// a microVM's images are, and never mixed with them.
692 -
    #[test]
693 -
    fn a_kept_directory_starts_the_next_syscall_job_and_others_copy_it() {
694 -
        let home = tempfile::tempdir().unwrap();
695 -
        let work = tempfile::tempdir().unwrap();
696 -
        let pool = Pool::new(home.path(), None);
697 -
        let m = mount(&["Cargo.lock"]);
698 -
        let main = dir_ctx("main");
699 -
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
700 -
        let w = |n: &str| work.path().join(n);
701 -
        assert!(pool.take(&main, &m, &key, &w("a")).is_none(), "nothing kept yet");
702 -
        // A passing job's directory goes into the pool, times and all.
703 -
        tree(&w("a"), b"FIRST", 4);
704 -
        // Named like an image: the pool never looks inside a directory.
705 -
        std::fs::write(w("a/debug/inner.ext4"), b"not a disk").unwrap();
706 -
        let old = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
707 -
        std::fs::File::open(w("a/debug/marker")).unwrap().set_modified(old).unwrap();
708 -
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
709 -
        pool.settle(&main, &m, &key, &w("a"), &built, true, &keep_all()).unwrap();
710 -
        assert!(!w("a").exists(), "moved, not copied");
711 -
        assert_eq!(
712 -
            pool.slot_times().into_keys().collect::<Vec<_>>(),
713 -
            vec![flow_core::api::cache_slot("andy/flow", "test")],
714 -
            "the claim reports it like an image (#51)"
715 -
        );
716 -
        // The next job of its bookmark moves it out: a job of the same
717 -
        // slot beside it finds nothing to take.
718 -
        let taken = pool.take(&main, &m, &key, &w("b")).unwrap();
719 -
        assert!(taken.how.contains("this bookmark's, same key"), "{}", taken.how);
720 -
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
721 -
        assert_eq!(std::fs::read(w("b/debug/marker")).unwrap(), b"FIRST");
722 -
        assert_eq!(modified(&w("b/debug/marker")), old, "its files keep their times");
723 -
        assert_eq!(std::fs::read(w("b/debug/inner.ext4")).unwrap(), b"not a disk");
724 -
        assert!(pool.take(&main, &m, &key, &w("c")).is_none(), "taken once");
725 -
        assert!(pool.slot_times().is_empty());
726 -
        // Kept again, and under a changed key the newest is taken all the same.
727 -
        std::fs::write(w("b/debug/marker"), b"SECOND").unwrap();
728 -
        pool.settle(&main, &m, &key, &w("b"), &built, true, &keep_all()).unwrap();
729 -
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
730 -
        let taken = pool.take(&main, &m, &key2, &w("d")).unwrap();
731 -
        assert!(taken.how.contains("older key"), "{}", taken.how);
732 -
        assert_eq!(std::fs::read(w("d/debug/marker")).unwrap(), b"SECOND");
733 -
        pool.settle(&main, &m, &key2, &w("d"), &built, true, &keep_all()).unwrap();
734 -
        // Another bookmark copies it, with its times, and main keeps its own.
735 -
        let feature = dir_ctx("feature");
736 -
        let taken = pool.take(&feature, &m, &key2, &w("e")).unwrap();
737 -
        assert!(taken.how.contains("copied from another bookmark's (main-"), "{}", taken.how);
738 -
        assert_eq!(taken.sources.as_ref(), Some(&built));
739 -
        assert_eq!(std::fs::read(w("e/debug/marker")).unwrap(), b"SECOND");
740 -
        assert!(pool.take(&main, &m, &key2, &w("f")).is_some(), "main's is still there");
741 -
        assert_eq!(modified(&w("e/debug/marker")), modified(&w("f/debug/marker")), "a copy keeps the times");
742 -
        pool.settle(&main, &m, &key2, &w("f"), &built, true, &keep_all()).unwrap();
743 -
        // A microVM job of the same slot never takes a directory, nor a
744 -
        // syscall job an image.
745 -
        let image = Ctx { form: Form::Image, ..main };
746 -
        assert!(pool.take(&image, &m, &key2, &w("g.ext4")).is_none());
747 -
        disk(&w("h.ext4"), b"DISK");
748 -
        pool.keep(&image, &m, &key2, &w("h.ext4"), &built).unwrap();
749 -
        let taken = pool.take(&main, &m, &key2, &w("i")).unwrap();
750 -
        assert!(w("i/debug/marker").is_file(), "{}", taken.how);
751 -
        assert!(pool.take(&main, &m, &key2, &w("j")).is_none(), "the image is not a directory");
752 -
        assert!(pool.take(&image, &m, &key2, &w("k.ext4")).is_some(), "and is still there for a microVM");
753 -
    }
754 -
755 -
    #[test]
756 -
    fn a_failing_jobs_directory_is_thrown_away() {
757 -
        let home = tempfile::tempdir().unwrap();
758 -
        let work = tempfile::tempdir().unwrap();
759 -
        let pool = Pool::new(home.path(), None);
760 -
        let m = mount(&[]);
761 -
        let main = dir_ctx("main");
762 -
        tree(&work.path().join("a"), b"GOOD", 1);
763 -
        pool.settle(&main, &m, "k", &work.path().join("a"), &Sources::new(), true, &keep_all()).unwrap();
764 -
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
765 -
        assert!(taken.is_some());
766 -
        // The job that took it fails after writing to it: not kept, gone.
767 -
        std::fs::write(work.path().join("b/debug/marker"), b"POISON").unwrap();
768 -
        pool.settle(&main, &m, "k", &work.path().join("b"), &Sources::new(), false, &keep_all()).unwrap();
769 -
        assert!(!work.path().join("b").exists());
770 -
        assert!(pool.take(&main, &m, "k", &work.path().join("c")).is_none(), "nothing it wrote reaches a later job");
771 -
        assert!(pool.slot_times().is_empty());
772 -
        // A failed image is thrown away the same way.
773 -
        let image = Ctx { form: Form::Image, ..main };
774 -
        disk(&work.path().join("d.ext4"), b"BAD!");
775 -
        pool.settle(&image, &m, "k", &work.path().join("d.ext4"), &Sources::new(), false, &keep_all()).unwrap();
776 -
        assert!(!work.path().join("d.ext4").exists() && pool.slot_times().is_empty());
777 -
    }
778 -
779 -
    #[test]
780 -
    fn directories_are_evicted_by_their_size_oldest_first() {
781 -
        let home = tempfile::tempdir().unwrap();
782 -
        let work = tempfile::tempdir().unwrap();
783 -
        // Room for one 512 KiB directory, not two.
784 -
        let pool = Pool { root: home.path().join("cache"), limit: 800 << 10 };
785 -
        let m = mount(&[]);
786 -
        let (main, feature) = (dir_ctx("main"), dir_ctx("feature"));
787 -
        tree(&work.path().join("a"), b"OLD", 512);
788 -
        pool.keep(&main, &m, "k1", &work.path().join("a"), &Sources::new()).unwrap();
789 -
        let kept = pool.cache_dir(&main, "target").join(segment("main")).join("k1.tree");
790 -
        assert!(kept.is_dir(), "under the limit it stays");
791 -
        assert!(tree_bytes(&kept) >= 512 << 10, "a directory counts what is in it");
792 -
        // Older than the next one, however fast this test runs.
793 -
        std::fs::File::open(&kept).unwrap().set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)).unwrap();
794 -
        tree(&work.path().join("b"), b"NEW", 512);
795 -
        pool.keep(&feature, &m, "k1", &work.path().join("b"), &Sources::new()).unwrap();
796 -
        assert!(!kept.exists() && !kept.with_extension("tree-sources").exists(), "the one used longest ago goes, sources and all");
797 -
        let taken = pool.take(&main, &m, "k1", &work.path().join("c")).unwrap();
798 -
        assert!(taken.how.contains("bookmark's (feature-"), "{}", taken.how);
799 -
        assert_eq!(std::fs::read(work.path().join("c/debug/marker")).unwrap(), b"NEW");
800 -
    }
801 -
802 -
    /// oxygenflow#70: the job wrote what it liked; the pool keeps nothing
803 -
    /// others may write or that runs as the runner, and is the runner's.
804 -
    #[test]
805 -
    fn nothing_kept_is_writable_by_others_or_set_id() {
806 -
        let home = tempfile::tempdir().unwrap();
807 -
        let work = tempfile::tempdir().unwrap();
808 -
        let pool = Pool::new(home.path(), None);
809 -
        let m = mount(&[]);
810 -
        let main = dir_ctx("main");
811 -
        let a = work.path().join("a");
812 -
        tree(&a, b"X", 1);
813 -
        let mode = |p: &Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
814 -
        std::fs::write(a.join("open"), b"").unwrap();
815 -
        mode(&a.join("open"), 0o777);
816 -
        std::fs::write(a.join("suid"), b"").unwrap();
817 -
        mode(&a.join("suid"), 0o4755);
818 -
        std::fs::write(a.join("debug/sealed"), b"secret").unwrap();
819 -
        mode(&a.join("debug/sealed"), 0o000);
820 -
        std::os::unix::fs::symlink("/etc/passwd", a.join("link")).unwrap();
821 -
        let when = modified(&a.join("debug/marker"));
822 -
        mode(&a.join("debug"), 0o1777);
823 -
        mode(&a, 0o500);
824 -
        pool.keep(&main, &m, "k", &a, &Sources::new()).unwrap();
825 -
        let root = home.path().join("cache");
826 -
        assert_eq!(std::fs::metadata(&root).unwrap().mode() & 0o777, 0o700, "the pool is the runner's alone");
827 -
        let mut seen = 0;
828 -
        fn each(p: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
829 -
            let md = std::fs::symlink_metadata(p).unwrap();
830 -
            f(p, &md);
831 -
            if md.is_dir() {
832 -
                for e in std::fs::read_dir(p).unwrap() {
833 -
                    each(&e.unwrap().path(), f);
834 -
                }
835 -
            }
836 -
        }
837 -
        each(&root, &mut |p, md| {
838 -
            seen += 1;
839 -
            if !md.file_type().is_symlink() {
840 -
                assert_eq!(md.mode() & 0o7022, 0, "{} is {:o}", p.display(), md.mode());
841 -
            }
842 -
        });
843 -
        assert!(seen >= 10, "{seen}");
844 -
        // Still readable, with its times, so the next job builds on it.
845 -
        let taken = pool.take(&main, &m, "k", &work.path().join("b"));
846 -
        assert!(taken.is_some());
847 -
        let b = work.path().join("b");
848 -
        assert_eq!(std::fs::read(b.join("debug/sealed")).unwrap(), b"secret");
849 -
        assert_eq!(std::fs::metadata(b.join("open")).unwrap().mode() & 0o777, 0o755);
850 -
        assert_eq!(std::fs::read_link(b.join("link")).unwrap(), Path::new("/etc/passwd"), "a link is kept, not followed");
851 -
        assert_eq!(modified(&b.join("debug/marker")), when);
852 -
    }
853 -
    /// A file of `kib` KiB at `rel` under `root`, last read and written
854 -
    /// at `secs` (ahead of the clock, so the time it was changed, now,
855 -
    /// is before a job starting at [`T`]).
856 -
    fn aged(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
857 -
        let p = root.join(rel);
858 -
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
859 -
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
860 -
        std::fs::write(&p, bytes).unwrap();
861 -
        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
862 -
        std::fs::File::options()
863 -
            .write(true)
864 -
            .open(&p)
865 -
            .unwrap()
866 -
            .set_times(std::fs::FileTimes::new().set_accessed(t).set_modified(t))
867 -
            .unwrap();
868 -
        p
869 -
    }
870 -
871 -
    const T: u64 = 4_000_000_000;
872 -
873 -
    fn at_t() -> SystemTime {
874 -
        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(T)
875 -
    }
876 -
877 -
    /// oxygenflow#72: a syscall job's directory is pruned of what the job
878 -
    /// did not use before it is kept, and the log says so; one still over
879 -
    /// its cap is not kept at all.
880 -
    #[test]
881 -
    fn a_directory_is_pruned_of_what_the_job_did_not_use_before_it_is_kept() {
882 -
        let home = tempfile::tempdir().unwrap();
883 -
        let work = tempfile::tempdir().unwrap();
884 -
        let pool = Pool::new(home.path(), None);
885 -
        let main = dir_ctx("main");
886 -
        let mut m = mount(&[]);
887 -
        m.size_mib = 8;
888 -
        let a = work.path().join("a");
889 -
        // This run's build, and an older lockfile's that it did not touch.
890 -
        aged(&a, "debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib", 2 << 10, T + 10);
891 -
        aged(&a, "debug/deps/libold-bbbbbbbbbbbbbbbb.rlib", 3 << 10, T - 1_000);
892 -
        let prune = Prune { started: at_t(), percent: 50, guest: None };
893 -
        let line = pool.settle(&main, &m, "k", &a, &Sources::new(), true, &prune).unwrap().unwrap();
894 -
        assert_eq!(
895 -
            line,
896 -
            "cache target: kept 2 MiB of 5 MiB (pruned 3 MiB in 1 file this run did not use; the cap is 50% of its 8 MiB disk)"
897 -
        );
898 -
        let b = work.path().join("b");
899 -
        assert!(pool.take(&main, &m, "k", &b).unwrap().how.contains("same key"));
900 -
        assert!(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib").is_file() && !b.join("debug/deps/libold-bbbbbbbbbbbbbbbb.rlib").exists());
901 -
        // Before the next job its reads are forgotten, its writes not.
902 -
        forget_reads(Form::Dir, &b).unwrap();
903 -
        let md = std::fs::metadata(b.join("debug/deps/libnew-aaaaaaaaaaaaaaaa.rlib")).unwrap();
904 -
        assert_eq!((md.atime(), md.mtime()), (0, (T + 10) as i64));
905 -
        // This run's own files over the cap: nothing it used goes, and the
906 -
        // cache is not kept, so the next run starts empty.
907 -
        aged(&b, "debug/deps/libbig-cccccccccccccccc.rlib", 3 << 10, T + 20);
908 -
        let line = pool.settle(&main, &m, "k", &b, &Sources::new(), true, &prune).unwrap().unwrap();
909 -
        assert!(
910 -
            line.starts_with("cache target: not kept: 5 MiB is over its cap of 4 MiB (50% of its 8 MiB disk); the next run starts empty"),
911 -
            "{line}"
912 -
        );
913 -
        assert!(!b.exists() && pool.take(&main, &m, "k", &work.path().join("c")).is_none());
914 -
    }
915 -
916 -
    /// oxygenflow#72: a microVM's image is kept or not by what its guest
917 -
    /// said; without word from it, by what its superblock says is in use.
918 -
    #[test]
919 -
    fn an_image_is_kept_by_what_its_guest_pruned_or_else_by_its_size() {
920 -
        let home = tempfile::tempdir().unwrap();
921 -
        let work = tempfile::tempdir().unwrap();
922 -
        let pool = Pool::new(home.path(), None);
923 -
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i", form: Form::Image };
924 -
        let mut m = mount(&[]);
925 -
        m.size_mib = 16;
926 -
        let fits = Pruned { size: 16 << 20, percent: 60, before: 12 << 20, after: 6 << 20, freed: 6 << 20, files: 40, unpruned: None };
927 -
        let over = Pruned { after: 11 << 20, ..fits.clone() };
928 -
        disk(&work.path().join("a.ext4"), b"KEEP");
929 -
        let line = pool
930 -
            .settle(&ctx, &m, "k", &work.path().join("a.ext4"), &Sources::new(), true, &Prune { guest: Some(&fits), ..keep_all() })
931 -
            .unwrap()
932 -
            .unwrap();
933 -
        assert_eq!(
934 -
            line,
935 -
            "cache target: kept 6 MiB of 12 MiB (pruned 6 MiB in 40 files this run did not use; the cap is 60% of its 16 MiB disk)"
936 -
        );
937 -
        assert!(pool.take(&ctx, &m, "k", &work.path().join("b.ext4")).is_none(), "a 1 MiB disk is not the mount's 16 MiB");
938 -
        disk(&work.path().join("c.ext4"), b"OVER");
939 -
        let line = pool
940 -
            .settle(&ctx, &m, "k", &work.path().join("c.ext4"), &Sources::new(), true, &Prune { guest: Some(&over), ..keep_all() })
941 -
            .unwrap()
942 -
            .unwrap();
943 -
        assert!(line.starts_with("cache target: not kept: 11 MiB after pruning 6 MiB in 40 files"), "{line}");
944 -
        assert!(!work.path().join("c.ext4").exists());
945 -
        // No word from the guest: an empty file system is well under.
946 -
        let img = work.path().join("d.ext4");
947 -
        crate::workspace::empty_ext4(&img, 16, "scratch").unwrap();
948 -
        let line = pool.settle(&ctx, &m, "k", &img, &Sources::new(), true, &Prune { percent: 60, ..keep_all() }).unwrap().unwrap();
949 -
        assert!(line.starts_with("cache target: kept ") && line.contains("under its cap of 60% of its 16 MiB disk"), "{line}");
950 -
        assert!(pool.take(&ctx, &m, "k", &work.path().join("e.ext4")).is_some());
951 -
        // ... and one nearly all in use is not kept, saying why.
952 -
        let line = pool
953 -
            .settle(&ctx, &m, "k", &work.path().join("e.ext4"), &Sources::new(), true, &Prune { percent: 1, ..keep_all() })
954 -
            .unwrap()
955 -
            .unwrap();
956 -
        assert!(line.contains("not kept") && line.contains("(the guest did not prune it)"), "{line}");
957 -
    }
958 -
959 -
    #[test]
960 -
    fn the_superblock_says_what_is_in_use() {
961 -
        let h = "Filesystem volume name:   scratch\nBlock count:              4096\nFree blocks:              1024\nBlock size:               4096\n";
962 -
        assert_eq!(superblock_usage(h), Some((3072 * 4096, 4096 * 4096)));
963 -
        assert_eq!(superblock_usage("Block count: 1\n"), None);
964 -
    }
965 -
}
removed crates/flow/src/checkpoint.rs +0 −212
1 -
//! Checkpoints (oxygenflow#77, docs/handover.md §7): the directories a job
2 -
//! declares with `checkpoint "/src/…"`, saved when its runner hands it back
3 -
//! and restored before the next attempt runs the same exec, so a tool that
4 -
//! skips finished work (cargo, make) carries on where it stopped.
5 -
//!
6 -
//! The format is the runner's own, so CI builds need no new crate: a magic
7 -
//! line, then entries (a kind byte, the path under /src, mode, modification
8 -
//! time and, for a file, its bytes). Files keep their times, since cargo's
9 -
//! fingerprints read them. Links are left out. Unpacking accepts only paths
10 -
//! under the job's own checkpoints, without `.` or `..`.
11 -
12 -
use anyhow::{bail, Context, Result};
13 -
use flow_core::pipeline::{Job, MountSource};
14 -
use std::path::{Path, PathBuf};
15 -
use std::time::{Duration, SystemTime, UNIX_EPOCH};
16 -
17 -
/// At most this much is saved: the hub keeps it as it keeps an artifact.
18 -
pub const MAX_BYTES: usize = 512 << 20;
19 -
20 -
const MAGIC: &[u8] = b"OXYFLOW-CHECKPOINT 1\n";
21 -
const DIR: u8 = 0;
22 -
const FILE: u8 = 1;
23 -
24 -
/// What was saved or restored.
25 -
#[derive(Debug, Default, PartialEq, Eq)]
26 -
pub struct Counts {
27 -
    pub files: usize,
28 -
    pub dirs: usize,
29 -
    pub bytes: u64,
30 -
}
31 -
32 -
/// Where checkpoint `path` (under /src) is on this host, for a job of a
33 -
/// directory tier (WASM, syscall): in a disk mount's directory when a mount
34 -
/// holds it, else in the job's tree. `None` for one under a tmpfs, which
35 -
/// the syscall tier keeps in memory.
36 -
pub fn host_dir(path: &str, job: &Job, tree: &Path, scratch: &Path, wasm: bool) -> Option<PathBuf> {
37 -
    let under = |t: &str| path == t || path.starts_with(&format!("{}/", t.trim_end_matches('/')));
38 -
    let mut disks = 0;
39 -
    let mut best: Option<(usize, PathBuf)> = None;
40 -
    for (i, m) in job.mounts.iter().enumerate() {
41 -
        let dir = match m.source {
42 -
            MountSource::Disk => {
43 -
                disks += 1;
44 -
                Some(crate::syscall::disk_dir(scratch, disks - 1))
45 -
            }
46 -
            MountSource::Tmpfs if wasm => Some(scratch.join(format!("tmpfs-{i}"))),
47 -
            MountSource::Tmpfs => None,
48 -
        };
49 -
        if under(&m.target) && best.as_ref().is_none_or(|(len, _)| m.target.len() > *len) {
50 -
            let rest = path[m.target.trim_end_matches('/').len()..].trim_start_matches('/');
51 -
            best = Some((m.target.len(), dir.map(|d| d.join(rest)).unwrap_or_default()));
52 -
        }
53 -
    }
54 -
    match best {
55 -
        Some((_, d)) if d.as_os_str().is_empty() => None,
56 -
        Some((_, d)) => Some(d),
57 -
        None => Some(tree.join(path.trim_start_matches("/src").trim_start_matches('/'))),
58 -
    }
59 -
}
60 -
61 -
/// Pack `dirs` (each a checkpoint path and where it is on this host).
62 -
pub fn pack(dirs: &[(String, PathBuf)]) -> Result<(Vec<u8>, Counts)> {
63 -
    let mut out = MAGIC.to_vec();
64 -
    let mut n = Counts::default();
65 -
    for (path, host) in dirs {
66 -
        if !host.is_dir() {
67 -
            continue;
68 -
        }
69 -
        let mut stack = vec![(host.clone(), path.trim_end_matches('/').to_string())];
70 -
        while let Some((at, name)) = stack.pop() {
71 -
            let meta = std::fs::symlink_metadata(&at)?;
72 -
            if meta.is_dir() {
73 -
                entry(&mut out, DIR, &name, &meta, &[]);
74 -
                n.dirs += 1;
75 -
                let mut children: Vec<_> = std::fs::read_dir(&at)?.collect::<std::io::Result<_>>()?;
76 -
                children.sort_by_key(|e| e.file_name());
77 -
                for c in children.into_iter().rev() {
78 -
                    let Some(child) = c.file_name().to_str().map(String::from) else { continue };
79 -
                    stack.push((c.path(), format!("{name}/{child}")));
80 -
                }
81 -
            } else if meta.is_file() {
82 -
                let bytes = std::fs::read(&at).with_context(|| format!("read {}", at.display()))?;
83 -
                n.files += 1;
84 -
                n.bytes += bytes.len() as u64;
85 -
                entry(&mut out, FILE, &name, &meta, &bytes);
86 -
            }
87 -
            if out.len() > MAX_BYTES {
88 -
                bail!("the checkpoint is over {} MiB", MAX_BYTES >> 20);
89 -
            }
90 -
        }
91 -
    }
92 -
    Ok((out, n))
93 -
}
94 -
95 -
fn entry(out: &mut Vec<u8>, kind: u8, path: &str, meta: &std::fs::Metadata, bytes: &[u8]) {
96 -
    let since = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).unwrap_or_default();
97 -
    out.push(kind);
98 -
    out.extend((path.len() as u32).to_le_bytes());
99 -
    out.extend(path.as_bytes());
100 -
    out.extend(crate::portable::mode(meta).to_le_bytes());
101 -
    out.extend(since.as_secs().to_le_bytes());
102 -
    out.extend(since.subsec_nanos().to_le_bytes());
103 -
    out.extend((bytes.len() as u64).to_le_bytes());
104 -
    out.extend(bytes);
105 -
}
106 -
107 -
/// Restore a checkpoint: each entry at `place(checkpoint)` joined with the
108 -
/// rest of its path, for the job's own `checkpoints` only.
109 -
pub fn unpack(bytes: &[u8], checkpoints: &[String], place: impl Fn(&str) -> Option<PathBuf>) -> Result<Counts> {
110 -
    let Some(mut rest) = bytes.strip_prefix(MAGIC) else { bail!("not a checkpoint") };
111 -
    let mut n = Counts::default();
112 -
    let take = |rest: &mut &[u8], k: usize| -> Result<Vec<u8>> {
113 -
        if rest.len() < k {
114 -
            bail!("the checkpoint is cut short");
115 -
        }
116 -
        let (a, b) = rest.split_at(k);
117 -
        *rest = b;
118 -
        Ok(a.to_vec())
119 -
    };
120 -
    while !rest.is_empty() {
121 -
        let kind = take(&mut rest, 1)?[0];
122 -
        let len = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap()) as usize;
123 -
        let path = String::from_utf8(take(&mut rest, len)?).context("a path that is not UTF-8")?;
124 -
        let mode = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap());
125 -
        let secs = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap());
126 -
        let nanos = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap());
127 -
        let size = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap()) as usize;
128 -
        let data = take(&mut rest, size)?;
129 -
        // Under one of the job's checkpoints, without . or .. on the way.
130 -
        let Some(root) = checkpoints.iter().map(|c| c.trim_end_matches('/')).find(|c| path == *c || path.starts_with(&format!("{c}/")))
131 -
        else {
132 -
            bail!("{path} is not under the job's checkpoints");
133 -
        };
134 -
        let below = path[root.len()..].trim_start_matches('/');
135 -
        if below.split('/').any(|p| p == "." || p == ".." || (p.is_empty() && !below.is_empty())) {
136 -
            bail!("{path}: . or .. in a checkpoint path");
137 -
        }
138 -
        let Some(base) = place(root) else { continue };
139 -
        let at = if below.is_empty() { base } else { base.join(below) };
140 -
        match kind {
141 -
            DIR => {
142 -
                std::fs::create_dir_all(&at)?;
143 -
                n.dirs += 1;
144 -
            }
145 -
            FILE => {
146 -
                if let Some(parent) = at.parent() {
147 -
                    std::fs::create_dir_all(parent)?;
148 -
                }
149 -
                // Never through a link already there.
150 -
                if std::fs::symlink_metadata(&at).is_ok_and(|m| !m.is_file()) {
151 -
                    bail!("{path}: something other than a file is in the way");
152 -
                }
153 -
                std::fs::write(&at, &data)?;
154 -
                let f = std::fs::File::options().write(true).open(&at)?;
155 -
                crate::portable::set_mode(&at, mode & 0o777)?;
156 -
                f.set_modified(SystemTime::UNIX_EPOCH + Duration::new(secs, nanos))?;
157 -
                n.files += 1;
158 -
                n.bytes += data.len() as u64;
159 -
            }
160 -
            other => bail!("{path}: unknown entry kind {other}"),
161 -
        }
162 -
    }
163 -
    Ok(n)
164 -
}
165 -
166 -
#[cfg(test)]
167 -
mod tests {
168 -
    use super::*;
169 -
170 -
    #[test]
171 -
    fn a_checkpoint_comes_back_with_its_times_and_only_where_it_belongs() {
172 -
        let from = tempfile::tempdir().unwrap();
173 -
        let target = from.path().join("target");
174 -
        std::fs::create_dir_all(target.join("debug/deps")).unwrap();
175 -
        std::fs::write(target.join("debug/deps/libx.rlib"), b"built").unwrap();
176 -
        std::fs::write(target.join("debug/.fingerprint"), b"fp").unwrap();
177 -
        let old = SystemTime::UNIX_EPOCH + Duration::new(1_700_000_000, 123_000_000);
178 -
        std::fs::File::options().write(true).open(target.join("debug/deps/libx.rlib")).unwrap().set_modified(old).unwrap();
179 -
        let (bytes, n) = pack(&[("/src/target".into(), target.clone())]).unwrap();
180 -
        assert_eq!((n.files, n.dirs, n.bytes), (2, 3, 7));
181 -
182 -
        let to = tempfile::tempdir().unwrap();
183 -
        let place = |root: &str| (root == "/src/target").then(|| to.path().join("t"));
184 -
        let back = unpack(&bytes, &["/src/target".into()], place).unwrap();
185 -
        assert_eq!(back, n);
186 -
        let restored = to.path().join("t/debug/deps/libx.rlib");
187 -
        assert_eq!(std::fs::read(&restored).unwrap(), b"built");
188 -
        assert_eq!(std::fs::metadata(&restored).unwrap().modified().unwrap(), old, "cargo reads the times");
189 -
190 -
        // Not the job's checkpoint, or a path that climbs out: refused.
191 -
        assert!(unpack(&bytes, &["/src/other".into()], place).is_err());
192 -
        let mut evil = MAGIC.to_vec();
193 -
        let meta = std::fs::metadata(target.join("debug/.fingerprint")).unwrap();
194 -
        entry(&mut evil, FILE, "/src/target/../escape", &meta, b"x");
195 -
        assert!(unpack(&evil, &["/src/target".into()], place).is_err());
196 -
        assert!(!to.path().join("escape").exists());
197 -
        assert!(unpack(b"junk", &["/src/target".into()], place).is_err());
198 -
    }
199 -
200 -
    #[test]
201 -
    fn a_checkpoint_under_a_disk_mount_is_in_the_mount_s_directory() {
202 -
        let p = flow_core::Pipeline::parse(
203 -
            "schema-version 1\nsandbox \"w\" {\n    runtime \"wasm\"\n}\njob \"j\" {\n    sandbox \"w\"\n    mount {\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"1GiB\"\n    }\n    checkpoint \"/src/target\" \"/src/state\"\n    exec \"x.wasm\"\n}\n",
204 -
        )
205 -
        .unwrap();
206 -
        let job = &p.jobs["j"];
207 -
        assert_eq!(job.checkpoints, ["/src/state", "/src/target"]);
208 -
        let (tree, scratch) = (Path::new("/w/tree"), Path::new("/w"));
209 -
        assert_eq!(host_dir("/src/target", job, tree, scratch, true), Some(crate::syscall::disk_dir(scratch, 0)));
210 -
        assert_eq!(host_dir("/src/state", job, tree, scratch, true), Some(PathBuf::from("/w/tree/state")));
211 -
    }
212 -
}
removed crates/flow/src/deps.rs +0 −129
1 -
//! `flow deps check` (#101): whether every crate a `Cargo.lock` needs is
2 -
//! vendored in the build image at that version. Jobs run with no network,
3 -
//! so a crate the image lacks fails the run with "no matching package";
4 -
//! this says so before the push, naming the versions the image does have.
5 -
6 -
use std::collections::{BTreeMap, BTreeSet};
7 -
use std::path::Path;
8 -
9 -
/// A crate at a version.
10 -
pub type Crate = (String, String);
11 -
12 -
/// The packages of a `Cargo.lock` that come from somewhere (a registry or
13 -
/// git): the ones `cargo vendor` copies. A workspace's own members have no
14 -
/// `source` and are left out.
15 -
pub fn from_lock(text: &str) -> Result<BTreeSet<Crate>, String> {
16 -
    let lock: toml::Value = text.parse().map_err(|e| format!("not a Cargo.lock: {e}"))?;
17 -
    let mut out = BTreeSet::new();
18 -
    for p in lock.get("package").and_then(|p| p.as_array()).into_iter().flatten() {
19 -
        let (Some(name), Some(version)) = (p.get("name").and_then(|v| v.as_str()), p.get("version").and_then(|v| v.as_str())) else {
20 -
            continue;
21 -
        };
22 -
        if p.get("source").is_some() {
23 -
            out.insert((name.to_string(), version.to_string()));
24 -
        }
25 -
    }
26 -
    Ok(out)
27 -
}
28 -
29 -
/// A crate list as `flow deps list` and the image's `crates.txt` write it:
30 -
/// `NAME VERSION` a line; blank lines and `#` comments are skipped.
31 -
pub fn from_list(text: &str) -> BTreeSet<Crate> {
32 -
    text.lines()
33 -
        .map(str::trim)
34 -
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
35 -
        .filter_map(|l| {
36 -
            let mut w = l.split_whitespace();
37 -
            Some((w.next()?.to_string(), w.next()?.to_string()))
38 -
        })
39 -
        .collect()
40 -
}
41 -
42 -
/// The crates of a `cargo vendor` directory (the image's `/opt/vendor`):
43 -
/// each subdirectory's `Cargo.toml` names its package and version.
44 -
pub fn from_vendor(dir: &Path) -> Result<BTreeSet<Crate>, String> {
45 -
    let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
46 -
    let mut out = BTreeSet::new();
47 -
    for e in entries.flatten() {
48 -
        let manifest = e.path().join("Cargo.toml");
49 -
        let Ok(text) = std::fs::read_to_string(&manifest) else { continue };
50 -
        let Ok(v) = text.parse::<toml::Value>() else { continue };
51 -
        let pkg = v.get("package");
52 -
        let field = |k: &str| pkg.and_then(|p| p.get(k)).and_then(|x| x.as_str()).map(str::to_string);
53 -
        if let (Some(n), Some(ver)) = (field("name"), field("version")) {
54 -
            out.insert((n, ver));
55 -
        }
56 -
    }
57 -
    Ok(out)
58 -
}
59 -
60 -
/// What `need` lacks from `have`, each with the versions of that crate
61 -
/// `have` does hold (empty when it holds none).
62 -
pub fn missing(need: &BTreeSet<Crate>, have: &BTreeSet<Crate>) -> Vec<(Crate, Vec<String>)> {
63 -
    let mut versions: BTreeMap<&str, Vec<String>> = BTreeMap::new();
64 -
    for (n, v) in have {
65 -
        versions.entry(n.as_str()).or_default().push(v.clone());
66 -
    }
67 -
    need.iter().filter(|c| !have.contains(*c)).map(|c| (c.clone(), versions.get(c.0.as_str()).cloned().unwrap_or_default())).collect()
68 -
}
69 -
70 -
/// A crate list in the form [`from_list`] reads.
71 -
pub fn render_list(crates: &BTreeSet<Crate>) -> String {
72 -
    crates.iter().map(|(n, v)| format!("{n} {v}\n")).collect()
73 -
}
74 -
75 -
#[cfg(test)]
76 -
mod tests {
77 -
    use super::*;
78 -
79 -
    const LOCK: &str = r#"
80 -
version = 4
81 -
82 -
[[package]]
83 -
name = "mine"
84 -
version = "0.1.0"
85 -
dependencies = ["rustls"]
86 -
87 -
[[package]]
88 -
name = "rustls"
89 -
version = "0.23.45"
90 -
source = "registry+https://github.com/rust-lang/crates.io-index"
91 -
92 -
[[package]]
93 -
name = "tokio-rustls"
94 -
version = "0.26.5"
95 -
source = "registry+https://github.com/rust-lang/crates.io-index"
96 -
97 -
[[package]]
98 -
name = "deranged"
99 -
version = "0.5.8"
100 -
source = "registry+https://github.com/rust-lang/crates.io-index"
101 -
"#;
102 -
103 -
    #[test]
104 -
    fn names_what_the_image_lacks_and_what_it_has_instead() {
105 -
        let need = from_lock(LOCK).unwrap();
106 -
        assert_eq!(need.len(), 3, "the workspace's own package is not vendored");
107 -
        let have = from_list("# the image\nrustls 0.23.45\ntokio-rustls 0.26.4\n\n");
108 -
        let m = missing(&need, &have);
109 -
        assert_eq!(
110 -
            m,
111 -
            vec![(("deranged".into(), "0.5.8".into()), vec![]), (("tokio-rustls".into(), "0.26.5".into()), vec!["0.26.4".into()]),]
112 -
        );
113 -
        assert!(missing(&need, &need).is_empty());
114 -
        assert_eq!(from_list(&render_list(&need)), need);
115 -
        assert!(from_lock("not = [toml").is_err());
116 -
    }
117 -
118 -
    #[test]
119 -
    fn reads_a_vendor_directory() {
120 -
        let dir = tempfile::tempdir().unwrap();
121 -
        for (d, n, v) in [("rustls-0.23.45", "rustls", "0.23.45"), ("pre-1.0.0-rc.1", "pre", "1.0.0-rc.1")] {
122 -
            std::fs::create_dir(dir.path().join(d)).unwrap();
123 -
            std::fs::write(dir.path().join(d).join("Cargo.toml"), format!("[package]\nname = \"{n}\"\nversion = \"{v}\"\n")).unwrap();
124 -
        }
125 -
        std::fs::create_dir(dir.path().join("junk")).unwrap();
126 -
        let got = from_vendor(dir.path()).unwrap();
127 -
        assert_eq!(got, from_list("rustls 0.23.45\npre 1.0.0-rc.1\n"));
128 -
    }
129 -
}
removed crates/flow/src/egress.rs +0 −328
1 -
//! Allow-listed egress for a microVM job (oxygenflow#55). The VM has no
2 -
//! network device: `flow-init` serves a proxy on the guest's 127.0.0.1
3 -
//! and relays each connection over vsock to this end, a Unix socket that
4 -
//! Firecracker connects for the guest. Here each `CONNECT host:443` is
5 -
//! checked against the sandbox's allow-list, the host is resolved (the
6 -
//! guest never resolves), any address that is not public is refused, the
7 -
//! decision goes into the job log, and the bytes are relayed untouched (no
8 -
//! TLS is opened here).
9 -
10 -
#[cfg(unix)]
11 -
use std::io::{Read, Write};
12 -
use std::net::IpAddr;
13 -
#[cfg(unix)]
14 -
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
15 -
#[cfg(unix)]
16 -
use std::os::unix::net::{UnixListener, UnixStream};
17 -
use std::path::Path;
18 -
#[cfg(unix)]
19 -
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20 -
use std::sync::mpsc;
21 -
#[cfg(unix)]
22 -
use std::sync::Arc;
23 -
#[cfg(unix)]
24 -
use std::time::Duration;
25 -
26 -
/// The vsock port the guest's relay connects to on the host (CID 2).
27 -
pub const VSOCK_PORT: u32 = 3128;
28 -
/// Where the job's tools find the proxy inside the guest.
29 -
pub const GUEST_PROXY: &str = "http://127.0.0.1:3128";
30 -
/// The only port relayed: TLS.
31 -
const PORT: u16 = 443;
32 -
#[cfg(unix)]
33 -
/// Connections at once, per job.
34 -
const MAX_OPEN: usize = 64;
35 -
#[cfg(unix)]
36 -
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
37 -
#[cfg(unix)]
38 -
/// The longest request head read.
39 -
const MAX_HEAD: usize = 8 * 1024;
40 -
41 -
/// What a CONNECT asked for, or why it was refused.
42 -
#[derive(Debug, PartialEq, Eq)]
43 -
pub enum Ask {
44 -
    Connect { host: String, port: u16 },
45 -
    Refused(String),
46 -
}
47 -
48 -
/// Read a request head: `CONNECT host:port HTTP/1.x`, and only that.
49 -
pub fn parse_head(head: &str) -> Ask {
50 -
    let line = head.lines().next().unwrap_or("");
51 -
    let mut parts = line.split_whitespace();
52 -
    let (method, target, version) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
53 -
    if method != "CONNECT" {
54 -
        return Ask::Refused(format!("only https is relayed (CONNECT), not {method:?}"));
55 -
    }
56 -
    if !version.starts_with("HTTP/1.") {
57 -
        return Ask::Refused("not an HTTP/1 request".into());
58 -
    }
59 -
    let Some((host, port)) = target.rsplit_once(':') else { return Ask::Refused(format!("CONNECT {target:?} names no port")) };
60 -
    let Ok(port) = port.parse::<u16>() else { return Ask::Refused(format!("CONNECT {target:?} has no valid port")) };
61 -
    let host = host.trim_start_matches('[').trim_end_matches(']').trim_end_matches('.').to_ascii_lowercase();
62 -
    if host.is_empty() {
63 -
        return Ask::Refused("CONNECT names no host".into());
64 -
    }
65 -
    Ask::Connect { host, port }
66 -
}
67 -
68 -
/// Whether the job may go to `host:port`: the port is 443, the host is a
69 -
/// name (an address is refused: the allow-list names hosts) the list allows.
70 -
pub fn decide(allow: &[String], host: &str, port: u16) -> Result<(), String> {
71 -
    if port != PORT {
72 -
        return Err(format!("only port {PORT} is relayed"));
73 -
    }
74 -
    if host.parse::<IpAddr>().is_ok() {
75 -
        return Err("an address, not a name: the allow-list names hosts".into());
76 -
    }
77 -
    if !flow_core::egress_allows(allow, host) {
78 -
        return Err("not in the sandbox's egress allow-list".into());
79 -
    }
80 -
    Ok(())
81 -
}
82 -
83 -
/// Whether an address is on the public internet: not loopback, private,
84 -
/// shared (CGNAT), link-local (cloud metadata among them), multicast,
85 -
/// unspecified or documentation, nor an IPv6 local range or a mapped form.
86 -
pub fn public(ip: IpAddr) -> bool {
87 -
    match ip {
88 -
        IpAddr::V4(v4) => {
89 -
            let o = v4.octets();
90 -
            !(v4.is_loopback()
91 -
                || v4.is_private()
92 -
                || v4.is_link_local()
93 -
                || v4.is_multicast()
94 -
                || v4.is_broadcast()
95 -
                || v4.is_unspecified()
96 -
                || v4.is_documentation()
97 -
                || o[0] == 0
98 -
                || (o[0] == 100 && (o[1] & 0xc0) == 64)
99 -
                || (o[0] == 192 && o[1] == 0 && o[2] == 0)
100 -
                || o[0] >= 240)
101 -
        }
102 -
        IpAddr::V6(v6) => {
103 -
            if let Some(v4) = v6.to_ipv4_mapped() {
104 -
                return public(IpAddr::V4(v4));
105 -
            }
106 -
            let s = v6.segments();
107 -
            !(v6.is_loopback()
108 -
                || v6.is_unspecified()
109 -
                || v6.is_multicast()
110 -
                || (s[0] & 0xfe00) == 0xfc00
111 -
                || (s[0] & 0xffc0) == 0xfe80
112 -
                || (s[0] == 0x64 && s[1] == 0xff9b)
113 -
                || (s[0] == 0x2001 && s[1] == 0x0db8))
114 -
        }
115 -
    }
116 -
}
117 -
118 -
#[cfg(unix)]
119 -
/// The proxy for one job: listening until dropped.
120 -
pub struct Proxy {
121 -
    stop: Arc<AtomicBool>,
122 -
    path: std::path::PathBuf,
123 -
}
124 -
125 -
#[cfg(unix)]
126 -
impl Drop for Proxy {
127 -
    fn drop(&mut self) {
128 -
        self.stop.store(true, Ordering::SeqCst);
129 -
        // Wake the accept loop so it sees the stop.
130 -
        let _ = UnixStream::connect(&self.path);
131 -
        let _ = std::fs::remove_file(&self.path);
132 -
    }
133 -
}
134 -
135 -
#[cfg(unix)]
136 -
/// Listen on `socket` (Firecracker's `uds_path` and `_3128`) for the
137 -
/// guest's connections; each decision is sent to `log` as a line.
138 -
pub fn start(socket: &Path, allow: Vec<String>, log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
139 -
    let _ = std::fs::remove_file(socket);
140 -
    let listener = UnixListener::bind(socket)?;
141 -
    let stop = Arc::new(AtomicBool::new(false));
142 -
    let open = Arc::new(AtomicUsize::new(0));
143 -
    let allow = Arc::new(allow);
144 -
    let s = stop.clone();
145 -
    std::thread::spawn(move || {
146 -
        for conn in listener.incoming() {
147 -
            if s.load(Ordering::SeqCst) {
148 -
                break;
149 -
            }
150 -
            let Ok(conn) = conn else { continue };
151 -
            if open.load(Ordering::SeqCst) >= MAX_OPEN {
152 -
                let _ = refuse(conn, "too many connections at once");
153 -
                let _ = log.send(format!("egress: refused a connection: {MAX_OPEN} already open"));
154 -
                continue;
155 -
            }
156 -
            open.fetch_add(1, Ordering::SeqCst);
157 -
            let (allow, log, open) = (allow.clone(), log.clone(), open.clone());
158 -
            std::thread::spawn(move || {
159 -
                serve(conn, &allow, &log);
160 -
                open.fetch_sub(1, Ordering::SeqCst);
161 -
            });
162 -
        }
163 -
    });
164 -
    Ok(Proxy { stop, path: socket.to_path_buf() })
165 -
}
166 -
167 -
#[cfg(unix)]
168 -
fn refuse(mut conn: UnixStream, why: &str) -> std::io::Result<()> {
169 -
    let body = format!("{why}\n");
170 -
    write!(conn, "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
171 -
}
172 -
173 -
#[cfg(unix)]
174 -
/// Read up to the blank line that ends a request head.
175 -
fn read_head(conn: &mut UnixStream) -> std::io::Result<String> {
176 -
    let mut head = Vec::new();
177 -
    let mut byte = [0u8; 1];
178 -
    while !head.ends_with(b"\r\n\r\n") {
179 -
        if head.len() >= MAX_HEAD || conn.read(&mut byte)? == 0 {
180 -
            break;
181 -
        }
182 -
        head.push(byte[0]);
183 -
    }
184 -
    Ok(String::from_utf8_lossy(&head).into_owned())
185 -
}
186 -
187 -
#[cfg(unix)]
188 -
fn serve(mut conn: UnixStream, allow: &[String], log: &mpsc::Sender<String>) {
189 -
    let _ = conn.set_read_timeout(Some(Duration::from_secs(30)));
190 -
    let head = match read_head(&mut conn) {
191 -
        Ok(h) => h,
192 -
        Err(_) => return,
193 -
    };
194 -
    let (host, port) = match parse_head(&head) {
195 -
        Ask::Connect { host, port } => (host, port),
196 -
        Ask::Refused(why) => {
197 -
            let _ = log.send(format!("egress: refused: {why}"));
198 -
            let _ = refuse(conn, &why);
199 -
            return;
200 -
        }
201 -
    };
202 -
    if let Err(why) = decide(allow, &host, port) {
203 -
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
204 -
        let _ = refuse(conn, &format!("{host}:{port}: {why}"));
205 -
        return;
206 -
    }
207 -
    let addrs: Vec<SocketAddr> = match (host.as_str(), port).to_socket_addrs() {
208 -
        Ok(a) => a.collect(),
209 -
        Err(e) => {
210 -
            let _ = log.send(format!("egress: refused {host}:{port}: it could not be resolved ({e})"));
211 -
            let _ = refuse(conn, &format!("{host} could not be resolved"));
212 -
            return;
213 -
        }
214 -
    };
215 -
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
216 -
        let why = format!("it resolves to {}, which is not a public address", bad.ip());
217 -
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
218 -
        let _ = refuse(conn, &format!("{host}: {why}"));
219 -
        return;
220 -
    }
221 -
    let out = addrs.iter().find_map(|a| TcpStream::connect_timeout(a, CONNECT_TIMEOUT).ok());
222 -
    let Some(out) = out else {
223 -
        let _ = log.send(format!("egress: {host}:{port} is allowed but did not answer"));
224 -
        let _ = conn.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
225 -
        return;
226 -
    };
227 -
    let _ = log.send(format!("egress: allowed {host}:{port}"));
228 -
    if conn.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").is_err() {
229 -
        return;
230 -
    }
231 -
    let _ = conn.set_read_timeout(None);
232 -
    relay(conn, out);
233 -
}
234 -
235 -
#[cfg(unix)]
236 -
/// Copy both ways until either side closes.
237 -
fn relay(guest: UnixStream, out: TcpStream) {
238 -
    let (Ok(mut guest_r), Ok(mut out_w)) = (guest.try_clone(), out.try_clone()) else { return };
239 -
    let up = std::thread::spawn(move || {
240 -
        let _ = std::io::copy(&mut guest_r, &mut out_w);
241 -
        let _ = out_w.shutdown(std::net::Shutdown::Write);
242 -
    });
243 -
    let (mut out_r, mut guest_w) = (out, guest);
244 -
    let _ = std::io::copy(&mut out_r, &mut guest_w);
245 -
    let _ = guest_w.shutdown(std::net::Shutdown::Write);
246 -
    let _ = up.join();
247 -
}
248 -
249 -
/// The egress proxy serves a microVM's vsock, which only Linux has.
250 -
#[cfg(not(unix))]
251 -
pub struct Proxy;
252 -
253 -
#[cfg(not(unix))]
254 -
pub fn start(_socket: &Path, _allow: Vec<String>, _log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
255 -
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "egress serves microVMs, which run only on Linux"))
256 -
}
257 -
258 -
#[cfg(all(test, unix))]
259 -
mod tests {
260 -
    use super::*;
261 -
262 -
    fn allow() -> Vec<String> {
263 -
        vec!["*.googleapis.com".into(), "registry.opentofu.org".into()]
264 -
    }
265 -
266 -
    #[test]
267 -
    fn only_connect_to_an_allowed_name_on_443_is_let_through() {
268 -
        assert_eq!(
269 -
            parse_head("CONNECT storage.googleapis.com:443 HTTP/1.1\r\nHost: x\r\n\r\n"),
270 -
            Ask::Connect { host: "storage.googleapis.com".into(), port: 443 }
271 -
        );
272 -
        assert!(matches!(parse_head("GET http://example.com/ HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("only https")));
273 -
        assert!(matches!(parse_head("CONNECT example.com HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("no port")));
274 -
        assert_eq!(decide(&allow(), "storage.googleapis.com", 443), Ok(()));
275 -
        assert_eq!(decide(&allow(), "registry.opentofu.org", 443), Ok(()));
276 -
        assert!(decide(&allow(), "example.com", 443).unwrap_err().contains("allow-list"));
277 -
        assert!(decide(&allow(), "registry.opentofu.org", 22).unwrap_err().contains("only port 443"));
278 -
        assert!(decide(&allow(), "142.250.1.1", 443).unwrap_err().contains("names hosts"));
279 -
    }
280 -
281 -
    #[test]
282 -
    fn private_addresses_are_not_public() {
283 -
        for ip in [
284 -
            "127.0.0.1",
285 -
            "10.1.2.3",
286 -
            "192.168.1.1",
287 -
            "172.16.0.1",
288 -
            "169.254.169.254",
289 -
            "100.64.0.1",
290 -
            "0.0.0.0",
291 -
            "::1",
292 -
            "fd00::1",
293 -
            "fe80::1",
294 -
            "::ffff:10.0.0.1",
295 -
        ] {
296 -
            assert!(!public(ip.parse().unwrap()), "{ip}");
297 -
        }
298 -
        for ip in ["142.250.1.1", "2a00:1450:4009::200e"] {
299 -
            assert!(public(ip.parse().unwrap()), "{ip}");
300 -
        }
301 -
    }
302 -
303 -
    #[test]
304 -
    fn the_proxy_refuses_and_logs_a_host_not_allowed() {
305 -
        let dir = tempfile::tempdir().unwrap();
306 -
        let sock = dir.path().join("v.sock_3128");
307 -
        let (tx, rx) = mpsc::channel();
308 -
        let proxy = start(&sock, allow(), tx).unwrap();
309 -
        let ask = |head: &str| {
310 -
            let mut c = UnixStream::connect(&sock).unwrap();
311 -
            c.write_all(head.as_bytes()).unwrap();
312 -
            let mut answer = String::new();
313 -
            c.read_to_string(&mut answer).unwrap();
314 -
            answer
315 -
        };
316 -
        let a = ask("CONNECT example.com:443 HTTP/1.1\r\n\r\n");
317 -
        assert!(a.starts_with("HTTP/1.1 403") && a.contains("not in the sandbox's egress allow-list"), "{a}");
318 -
        assert_eq!(
319 -
            rx.recv_timeout(Duration::from_secs(5)).unwrap(),
320 -
            "egress: refused example.com:443: not in the sandbox's egress allow-list"
321 -
        );
322 -
        let a = ask("GET http://storage.googleapis.com/ HTTP/1.1\r\n\r\n");
323 -
        assert!(a.starts_with("HTTP/1.1 403"), "{a}");
324 -
        assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().contains("only https"));
325 -
        drop(proxy);
326 -
        assert!(!sock.exists(), "the socket goes with the job");
327 -
    }
328 -
}
modified crates/flow/src/facts.rs +9 −66
…
16 unchanged lines
17 17
        load_x100: load_x100(),
18 18
        speed,
19 19
        speed_class: speed.map(Facts::class_of),
20 -
        kernel: kernel(),
21 -
    }
22 -
}
23 -
24 -
/// The kernel's release, as uname says. Not read from /proc: an Android
25 -
/// app may not read it there, and is refused (and logged) every claim.
26 -
#[cfg(unix)]
27 -
pub fn kernel() -> Option<String> {
28 -
    let mut u: libc::utsname = unsafe { std::mem::zeroed() };
29 -
    // SAFETY: `u` is a writable utsname.
30 -
    if unsafe { libc::uname(&mut u) } != 0 {
31 -
        return None;
20 +
        kernel: std::fs::read_to_string("/proc/sys/kernel/osrelease").ok().map(|s| s.trim().to_string()),
32 21
    }
33 -
    // SAFETY: uname writes NUL-terminated strings.
34 -
    let release = unsafe { std::ffi::CStr::from_ptr(u.release.as_ptr()) };
35 -
    Some(release.to_string_lossy().into_owned())
36 22
}
37 23
38 -
/// Windows' version (`Windows 10.0.26100`), from `RtlGetVersion`, which
39 -
/// unlike `GetVersionEx` does not depend on the program's manifest.
40 -
#[cfg(windows)]
41 -
pub fn kernel() -> Option<String> {
42 -
    use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
43 -
    // SAFETY: an all-zero OSVERSIONINFOW is valid; its size is set before the call.
44 -
    let mut v: OSVERSIONINFOW = unsafe { std::mem::zeroed() };
45 -
    v.dwOSVersionInfoSize = std::mem::size_of::<OSVERSIONINFOW>() as u32;
46 -
    // SAFETY: `v` is a writable OSVERSIONINFOW with its size set.
47 -
    let status = unsafe { windows_sys::Wdk::System::SystemServices::RtlGetVersion(&mut v) };
48 -
    (status == 0).then(|| format!("Windows {}.{}.{}", v.dwMajorVersion, v.dwMinorVersion, v.dwBuildNumber))
49 -
}
50 -
51 -
/// Free space for an unprivileged writer on the file system holding `path`,
52 -
/// in MiB.
53 -
#[cfg(unix)]
24 +
/// Free space for an unprivileged writer on the file system holding `path`, in MiB.
54 25
pub fn free_disk_mib(path: &std::path::Path) -> Option<u64> {
55 26
    use std::os::unix::ffi::OsStrExt;
56 27
    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
…
5 unchanged lines
62 33
    Some((s.f_bavail as u64).saturating_mul(s.f_frsize as u64) >> 20)
63 34
}
64 35
65 -
#[cfg(windows)]
66 -
pub fn free_disk_mib(path: &std::path::Path) -> Option<u64> {
67 -
    use std::os::windows::ffi::OsStrExt;
68 -
    let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
69 -
    let mut free: u64 = 0;
70 -
    // SAFETY: `wide` is a NUL-terminated path and `free` a writable u64;
71 -
    // the totals it may also fill are not asked for.
72 -
    let ok = unsafe {
73 -
        windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(wide.as_ptr(), &mut free, std::ptr::null_mut(), std::ptr::null_mut())
74 -
    };
75 -
    (ok != 0).then_some(free >> 20)
76 -
}
77 -
78 36
/// The disk the files under `dir` take (their blocks, not their sizes: a
79 37
/// job's disks are sparse), in MiB; links are not followed.
80 38
pub fn allocated_mib(dir: &std::path::Path) -> Option<u64> {
39 +
    use std::os::unix::fs::MetadataExt;
81 40
    let mut total: u64 = 0;
82 41
    let mut stack = vec![dir.to_path_buf()];
83 42
    while let Some(d) = stack.pop() {
…
2 unchanged lines
86 45
            if m.is_dir() {
87 46
                stack.push(e.path());
88 47
            } else {
89 -
                total = total.saturating_add(crate::portable::disk_bytes(&m));
48 +
                total = total.saturating_add(m.blocks() * 512);
90 49
            }
91 50
        }
92 51
    }
…
5 unchanged lines
98 57
    let text = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
99 58
    let kib: u64 = text.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
100 59
    Some(kib / 1024)
101 -
}
102 -
103 -
/// The 1-minute load average, times 100, from sysinfo (the kernel's own
104 -
/// numbers behind /proc/loadavg, which an Android app may not read); not
105 -
/// measured on Windows.
106 -
#[cfg(not(any(target_os = "linux", target_os = "android")))]
107 -
pub fn load_x100() -> Option<u32> {
108 -
    None
109 60
}
110 61
111 -
#[cfg(any(target_os = "linux", target_os = "android"))]
62 +
/// The 1-minute load average from /proc/loadavg, times 100.
112 63
pub fn load_x100() -> Option<u32> {
113 -
    let mut i: libc::sysinfo = unsafe { std::mem::zeroed() };
114 -
    // SAFETY: `i` is a writable sysinfo.
115 -
    if unsafe { libc::sysinfo(&mut i) } != 0 {
116 -
        return None;
117 -
    }
118 -
    // Fixed point with 16 fraction bits (SI_LOAD_SHIFT).
119 -
    Some(((i.loads[0] as f64) / 65536.0 * 100.0).round() as u32)
64 +
    let text = std::fs::read_to_string("/proc/loadavg").ok()?;
65 +
    let first: f64 = text.split_whitespace().next()?.parse().ok()?;
66 +
    Some((first * 100.0).round() as u32)
120 67
}
121 68
122 69
/// The benchmark, one thread: BLAKE3 over 256 MiB (spec §11.1), and a sort
…
30 unchanged lines
153 100
    #[test]
154 101
    fn the_cheap_facts_are_read_and_classes_double() {
155 102
        assert!(free_disk_mib(std::path::Path::new("/")).is_some());
156 -
        // The same numbers /proc gives, read without it (an Android app may not).
157 -
        let proc_load: f64 = std::fs::read_to_string("/proc/loadavg").unwrap().split_whitespace().next().unwrap().parse().unwrap();
158 -
        let load = load_x100().unwrap() as f64 / 100.0;
159 -
        assert!((load - proc_load).abs() < 1.0 + proc_load / 4.0, "sysinfo {load}, /proc {proc_load}");
160 -
        assert_eq!(kernel().unwrap(), std::fs::read_to_string("/proc/sys/kernel/osrelease").unwrap().trim());
103 +
        assert!(load_x100().is_some());
161 104
        assert!(peak_rss_mib(std::process::id()).is_some_and(|m| m > 0));
162 105
        let dir = tempfile::tempdir().unwrap();
163 106
        let f = std::fs::File::create(dir.path().join("sparse")).unwrap();
…
8 unchanged lines
modified crates/flow/src/home.rs +7 −122
…
39 unchanged lines
40 40
/// Where `flow runner register` keeps the runner's hub and credential.
41 41
pub const RUNNER_TOML: &str = "runner.toml";
42 42
43 -
/// The hub's CA certificate as `flow runner register --ca` keeps it (#98).
44 -
pub const CA_PEM: &str = "hub-ca.pem";
45 -
46 43
#[derive(Clone, Debug, Deserialize)]
47 44
pub struct RunnerConfig {
48 45
    pub hub: String,
…
11 unchanged lines
60 57
    pub memory_mib: Option<u32>,
61 58
    #[serde(default)]
62 59
    pub cpus: Option<u32>,
63 -
    /// Sharing images with other runners (docs/jetstream.md §8.1): absent,
64 -
    /// the runner neither serves nor asks peers.
65 -
    #[serde(default)]
66 -
    pub swarm: Option<SwarmConfig>,
67 -
    /// The build cache's size limit in GiB (#28; default 64).
68 -
    #[serde(default)]
69 -
    pub cache_gb: Option<u64>,
70 -
    /// The share of its disk, in percent, a kept build cache may fill
71 -
    /// (oxygenflow#72; default 60): over it, the files the job did not use
72 -
    /// are pruned, oldest first, and a cache still over it is not kept.
73 -
    #[serde(default)]
74 -
    pub cache_prune_percent: Option<u8>,
75 -
    /// Let microVM jobs reach the hosts their sandbox allows, through this
76 -
    /// runner (oxygenflow#55); off unless set. The runner then offers the
77 -
    /// `egress` label, which such jobs need.
78 -
    #[serde(default)]
79 -
    pub egress: bool,
80 -
    /// Sign each job's receipt with a fresh key the hub certifies for that
81 -
    /// job, just before signing (keyless, oxygenhub#241), rather than with
82 -
    /// the runner's own key; off unless set. When the hub cannot certify,
83 -
    /// the runner's key signs, and the job's log says why.
84 -
    #[serde(default)]
85 -
    pub keyless: bool,
86 -
    /// Serve this runner's metrics at this address, e.g. `127.0.0.1:9464`
87 -
    /// (oxygenflow#35); off unless set.
88 -
    #[serde(default)]
89 -
    pub metrics: Option<String>,
90 -
    /// A bearer token a scrape of the metrics must send.
91 -
    #[serde(default)]
92 -
    pub metrics_token: Option<String>,
93 -
    /// `[runner.update]`: whether this runner takes updates (#85).
94 -
    #[serde(default)]
95 -
    pub update: Option<UpdateConfig>,
96 -
}
97 -
98 -
/// `[runner.update]` in flow.toml (docs/jetstream.md §8.2): the runner's
99 -
/// last word on updates, whatever the hub's policy says.
100 -
#[derive(Clone, Debug, Deserialize)]
101 -
pub struct UpdateConfig {
102 -
    /// `false` refuses every update, so a site can hold a machine back.
103 -
    #[serde(default = "yes")]
104 -
    pub allow: bool,
105 -
}
106 -
107 -
fn yes() -> bool {
108 -
    true
109 60
}
110 61
111 -
impl RunnerConfig {
112 -
    /// The labels offered to the hub: the configured ones, and `egress`
113 -
    /// when egress is on.
114 -
    pub fn offered_labels(&self) -> Vec<String> {
115 -
        let mut l = self.labels.clone();
116 -
        if self.egress && !l.iter().any(|x| x == flow_core::EGRESS_LABEL) {
117 -
            l.push(flow_core::EGRESS_LABEL.to_string());
118 -
        }
119 -
        l
120 -
    }
121 -
}
122 -
123 -
/// `[runner.swarm]` in flow.toml, or `[swarm]` in runner.toml.
124 -
#[derive(Clone, Debug, Deserialize)]
125 -
pub struct SwarmConfig {
126 -
    /// Where peers reach this runner, e.g. `0.0.0.0:7401`.
127 -
    pub listen: String,
128 -
    /// The address peers are given (default: `listen`).
129 -
    #[serde(default)]
130 -
    pub advertise: Option<String>,
131 -
    /// Peers are only ever in the same site.
132 -
    #[serde(default)]
133 -
    pub site: String,
134 -
    /// The most this runner sends to peers, MiB a second (default 50).
135 -
    #[serde(default)]
136 -
    pub upload_mib_s: Option<u32>,
137 -
}
138 -
139 62
#[derive(Clone, Debug, Default, Deserialize)]
140 63
pub struct Config {
141 64
    pub microvm: Option<MicrovmConfig>,
…
28 unchanged lines
170 93
        let registered = root.join(RUNNER_TOML);
171 94
        if config.runner.is_none() && registered.is_file() {
172 95
            let text = std::fs::read_to_string(&registered).with_context(|| format!("read {}", registered.display()))?;
173 -
            let mut runner: RunnerConfig = toml::from_str(&text).with_context(|| format!("parse {}", registered.display()))?;
174 -
            // A relative CA there is the home's (#98): register keeps its
175 -
            // copy there, and the service may start in any directory.
176 -
            runner.ca = runner.ca.map(|ca| if ca.is_absolute() { ca } else { root.join(ca) });
177 -
            config.runner = Some(runner);
96 +
            config.runner = Some(toml::from_str(&text).with_context(|| format!("parse {}", registered.display()))?);
178 97
        }
179 98
        for d in ["cas", "work", "repos"] {
180 99
            std::fs::create_dir_all(root.join(d)).with_context(|| format!("create {}", root.join(d).display()))?;
…
20 unchanged lines
201 120
        self.root.join("cas").join(format!("{hash}.json"))
202 121
    }
203 122
204 -
    /// The architecture an image is for: as its manifest records, else
205 -
    /// read from the image's own shell (spec §11.2) and recorded, so an
206 -
    /// image fetched or imported without one is read once and a runner
207 -
    /// declines what it cannot run instead of failing it. `None` when the
208 -
    /// image is not here or names no architecture.
123 +
    /// The architecture an image was imported for, when its manifest says.
209 124
    pub fn image_arch(&self, hash: &str) -> Option<String> {
210 -
        let path = self.image_manifest(hash);
211 -
        let mut v: serde_json::Value =
212 -
            std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| serde_json::json!({}));
213 -
        if let Some(a) = v["arch"].as_str() {
214 -
            return Some(a.to_string());
215 -
        }
216 -
        let image = self.rootfs(hash);
217 -
        if v.get("arch_read_ms").is_some() || !image.is_file() {
218 -
            return None;
219 -
        }
220 -
        let arch = jet::rootfs::arch_of_file(&image);
221 -
        let o = v.as_object_mut()?;
222 -
        if let Some(a) = arch {
223 -
            o.insert("arch".into(), a.into());
224 -
        }
225 -
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
226 -
        o.insert("arch_read_ms".into(), now.into());
227 -
        let tmp = path.with_extension("json.part");
228 -
        if std::fs::write(&tmp, v.to_string()).is_ok() {
229 -
            let _ = std::fs::rename(&tmp, &path);
230 -
        }
231 -
        arch.map(String::from)
125 +
        let text = std::fs::read_to_string(self.image_manifest(hash)).ok()?;
126 +
        let v: serde_json::Value = serde_json::from_str(&text).ok()?;
127 +
        v["arch"].as_str().map(String::from)
232 128
    }
233 129
234 130
    /// Every image with a known architecture (spec §11.2).
…
98 unchanged lines
333 229
        let tmp = self.root.join("runner.key.tmp");
334 230
        {
335 231
            use std::io::Write;
336 -
            let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
232 +
            use std::os::unix::fs::OpenOptionsExt;
233 +
            let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
337 234
            f.write_all(&secret.encode(&identity.hash()))?;
338 235
            f.sync_all()?;
339 236
        }
…
19 unchanged lines
359 256
        assert_eq!(a.identity, b.identity, "the same key after a restart");
360 257
        let sig = b.secret.sign(oxy_core::identity::Domain::Attest, &b.identity.hash(), &oxy_core::hash::ObjectRef([1; 32]));
361 258
        oxy_core::identity::verify(&a.identity, &sig, oxy_core::identity::Domain::Attest, &oxy_core::hash::ObjectRef([1; 32])).unwrap();
362 -
    }
363 -
364 -
    /// #98: runner.toml's relative CA is the home's, wherever flow starts.
365 -
    #[test]
366 -
    fn a_registered_runners_relative_ca_is_in_its_home() {
367 -
        let dir = tempfile::tempdir().unwrap();
368 -
        std::fs::write(dir.path().join(RUNNER_TOML), "hub = \"https://hub\"\ntoken = \"t\"\nca = \"hub-ca.pem\"\n").unwrap();
369 -
        let home = Home::locate(Some(dir.path())).unwrap();
370 -
        assert_eq!(home.config.runner.unwrap().ca, Some(dir.path().join("hub-ca.pem")));
371 -
        std::fs::write(dir.path().join(RUNNER_TOML), "hub = \"https://hub\"\ntoken = \"t\"\nca = \"/etc/ssl/hub.pem\"\n").unwrap();
372 -
        let home = Home::locate(Some(dir.path())).unwrap();
373 -
        assert_eq!(home.config.runner.unwrap().ca, Some(PathBuf::from("/etc/ssl/hub.pem")));
374 259
    }
375 260
}
modified crates/flow/src/images.rs +14 −117
…
14 unchanged lines
15 15
16 16
/// Requests in flight at once.
17 17
const PARALLEL: usize = 8;
18 -
/// Chunk names per `missing` question.
19 -
const ASK: usize = 20_000;
20 18
21 19
/// A hub's image endpoints, with a token.
22 20
pub struct Client {
23 -
    root: String,
24 21
    base: String,
25 22
    token: String,
26 23
    agent: ureq::Agent,
…
3 unchanged lines
30 27
pub struct Moved {
31 28
    pub chunks: usize,
32 29
    pub bytes: u64,
33 -
    /// Of `bytes`, those that came from peers rather than the hub.
34 -
    pub peer_bytes: u64,
35 30
    pub elapsed: Duration,
36 31
}
37 32
…
1 unchanged lines
39 34
    pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> {
40 35
        let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
41 36
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
42 -
        let root = hub.trim_end_matches('/').to_string();
43 -
        Ok(Client { base: format!("{root}/api/v1"), root, token: token.into(), agent })
37 +
        Ok(Client { base: format!("{}/api/v1", hub.trim_end_matches('/')), token: token.into(), agent })
44 38
    }
45 39
46 40
    fn auth(&self) -> String {
…
24 unchanged lines
71 65
        }
72 66
    }
73 67
74 -
    /// The hub's identity (`/.well-known/oxyhub-key`): the key its swarm
75 -
    /// tickets are signed with.
76 -
    pub fn hub_identity(&self) -> Result<oxy_core::identity::Identity> {
77 -
        let url = format!("{}/.well-known/oxyhub-key", self.root);
78 -
        let r = self.agent.get(&url).call().map_err(|e| Self::fail(&url, e))?;
79 -
        let mut out = Vec::new();
80 -
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
81 -
        oxy_core::identity::Identity::decode(&out).map_err(|e| anyhow::anyhow!("{url}: not a hub identity: {e}"))
82 -
    }
83 -
84 -
    /// Tell the hub's tracker this runner is fetching image `hash`, or has
85 -
    /// stopped: the peers to ask and the ticket they admit.
86 -
    pub fn announce(&self, hash: &str, state: &str) -> Result<flow_core::api::Announced> {
87 -
        let url = format!("{}/flow/swarm/announce", self.base);
88 -
        let body = flow_core::api::Announce { image: hash.into(), state: state.into() };
89 -
        Ok(self.agent.post(&url).set("Authorization", &self.auth()).send_json(&body).map_err(|e| Self::fail(&url, e))?.into_json()?)
90 -
    }
91 -
92 -
    pub(crate) fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
68 +
    fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
93 69
        let url = format!("{}/flow/images/chunks/{hash}", self.base);
94 70
        let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?;
95 71
        let mut out = Vec::new();
…
14 unchanged lines
110 86
            bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]);
111 87
        }
112 88
        let mut missing: HashSet<String> = HashSet::new();
113 -
        // 20,000 names is about 1.3 MB of JSON: under a hub's 2 MB default
114 -
        // body limit, which refused 50,000 at a time (oxygenhub#147).
115 -
        for ask in m.needed().chunks(ASK) {
89 +
        for ask in m.needed().chunks(50_000) {
116 90
            let url = format!("{}/flow/images/missing", self.base);
117 91
            let r: serde_json::Value = self
118 92
                .agent
…
34 unchanged lines
153 127
            return Err(e);
154 128
        }
155 129
        let url = format!("{}/flow/images/{hash}", self.base);
156 -
        // The hub reads the whole image before it answers, which on a busy
157 -
        // disk takes far longer than the agent's 120 s (oxygenhub#341).
158 -
        self.agent
159 -
            .put(&url)
160 -
            .timeout(Duration::from_secs(3600))
161 -
            .set("Authorization", &self.auth())
162 -
            .send_json(&m)
163 -
            .map_err(|e| Self::fail(&url, e))?;
130 +
        self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
164 131
        let bytes = *bytes.lock().unwrap();
165 -
        Ok(Moved { chunks: count, bytes, peer_bytes: 0, elapsed: start.elapsed() })
132 +
        Ok(Moved { chunks: count, bytes, elapsed: start.elapsed() })
166 133
    }
167 134
168 135
    /// Fetch image `hash` into `home`: the chunks its cache lacks, the
…
6 unchanged lines
175 142
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
176 143
        }
177 144
        let cache = chunk_cache(home);
178 -
        save_manifest(&home.root, &m)?;
179 145
        let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect();
180 146
        let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>));
181 147
        let next = Mutex::new(wanted.iter());
…
26 unchanged lines
208 174
        if let Some(e) = failed.into_inner().unwrap() {
209 175
            return Err(e);
210 176
        }
211 -
        write_image(home, &m)?;
212 -
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), peer_bytes: 0, elapsed: start.elapsed() }))
213 -
    }
214 -
}
215 -
216 -
/// Write image `m` into `home` from the chunk cache, checked against its
217 -
/// hash, and record its architecture.
218 -
pub(crate) fn write_image(home: &Home, m: &Manifest) -> Result<()> {
219 -
    let cache = chunk_cache(home);
220 -
    let out = home.rootfs(&m.hash);
221 -
    std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
222 -
    images::assemble(m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
223 -
        .with_context(|| format!("write image {}", &m.hash[..12]))?;
224 -
    if let Some(arch) = &m.arch {
225 -
        let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" });
226 -
        std::fs::write(home.image_manifest(&m.hash), serde_json::to_vec(&manifest)?)?;
227 -
    }
228 -
    Ok(())
229 -
}
230 -
231 -
impl Client {
232 -
    /// Tell the hub which images this home holds, and send the manifest of
233 -
    /// each it keeps none for (oxygenflow#94, jetstream.md §8.1.1): the
234 -
    /// swarm can then carry an image nobody pushed. A manifest not kept here
235 -
    /// yet is made by reading the image once, and kept. How many were sent;
236 -
    /// none from a hub that predates it.
237 -
    pub fn offer_held(&self, home: &Home) -> Result<usize> {
238 -
        let images = home.images();
239 -
        if images.is_empty() {
240 -
            return Ok(0);
241 -
        }
242 -
        let url = format!("{}/flow/images/held", self.base);
243 -
        let wanted: flow_core::api::WantedManifests =
244 -
            match self.agent.post(&url).set("Authorization", &self.auth()).send_json(flow_core::api::HeldImages { images }) {
245 -
                Ok(r) => r.into_json()?,
246 -
                // A hub that predates it: no such route (404), or a route
247 -
                // that takes other methods only (405).
248 -
                Err(ureq::Error::Status(404 | 405, _)) => return Ok(0),
249 -
                Err(e) => return Err(Self::fail(&url, e)),
250 -
            };
251 -
        let mut sent = 0;
252 -
        for hash in wanted.wanted.iter().filter(|h| images::is_hash(h)) {
253 -
            let path = home.rootfs(hash);
254 -
            if !path.is_file() {
255 -
                continue;
256 -
            }
257 -
            let kept = std::fs::read(manifest_path(&home.root, hash)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok());
258 -
            let m = match kept.filter(|m| m.hash == *hash) {
259 -
                Some(m) => m,
260 -
                None => {
261 -
                    let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?;
262 -
                    if m.hash != *hash {
263 -
                        continue;
264 -
                    }
265 -
                    save_manifest(&home.root, &m)?;
266 -
                    m
267 -
                }
268 -
            };
269 -
            let url = format!("{}/flow/images/{hash}/held", self.base);
270 -
            self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
271 -
            sent += 1;
177 +
        let out = home.rootfs(hash);
178 +
        std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
179 +
        images::assemble(&m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
180 +
            .with_context(|| format!("write image {}", &hash[..12]))?;
181 +
        if let Some(arch) = &m.arch {
182 +
            let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" });
183 +
            std::fs::write(home.image_manifest(hash), serde_json::to_vec(&manifest)?)?;
272 184
        }
273 -
        Ok(sent)
185 +
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), elapsed: start.elapsed() }))
274 186
    }
275 -
}
276 -
277 -
/// Where an image's chunk manifest is kept (`manifests/<hash>.json`), so
278 -
/// the runner can serve its chunks to peers out of the image itself.
279 -
pub(crate) fn manifest_path(root: &Path, hash: &str) -> PathBuf {
280 -
    root.join("manifests").join(format!("{hash}.json"))
281 -
}
282 -
283 -
pub(crate) fn save_manifest(root: &Path, m: &Manifest) -> Result<()> {
284 -
    let p = manifest_path(root, &m.hash);
285 -
    std::fs::create_dir_all(p.parent().expect("the manifests directory"))?;
286 -
    let tmp = p.with_extension("part");
287 -
    std::fs::write(&tmp, serde_json::to_vec(m)?)?;
288 -
    std::fs::rename(&tmp, &p)?;
289 -
    Ok(())
290 187
}
291 188
292 189
fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) {
…
16 unchanged lines
309 206
    home.root.join("chunks")
310 207
}
311 208
312 -
pub(crate) fn cache_path(cache: &Path, hash: &str) -> PathBuf {
209 +
fn cache_path(cache: &Path, hash: &str) -> PathBuf {
313 210
    cache.join(&hash[..2]).join(hash)
314 211
}
315 212
…
8 unchanged lines
removed crates/flow/src/lib.rs +0 −35
1 -
//! OxygenFlow's runner and tiers as a library: the `flow` command is built
2 -
//! on it, and so is the Android app's runner (oxygenhub-android#8), which
3 -
//! runs the WASM tier in its own process (docs/handover.md §10).
4 -
5 -
pub mod cache;
6 -
pub mod checkpoint;
7 -
pub mod cpio;
8 -
pub mod deps;
9 -
pub mod egress;
10 -
pub mod facts;
11 -
/// The job-starting code `flow-init` uses in a microVM, shared with the
12 -
/// syscall tier.
13 -
#[cfg(any(target_os = "linux", target_os = "android"))]
14 -
#[path = "../../flow-init/src/job.rs"]
15 -
pub mod guest;
16 -
pub mod home;
17 -
pub mod images;
18 -
pub mod metrics;
19 -
pub mod microvm;
20 -
pub mod outputs;
21 -
pub mod portable;
22 -
pub mod runner;
23 -
pub mod swarm;
24 -
// Linux's (and Android's) namespaces, Landlock, seccomp and cgroups:
25 -
// elsewhere (Windows, Haiku, FreeBSD) the tier says why it is not offered.
26 -
#[cfg(any(target_os = "linux", target_os = "android"))]
27 -
pub mod syscall;
28 -
#[cfg(not(any(target_os = "linux", target_os = "android")))]
29 -
#[path = "syscall_none.rs"]
30 -
pub mod syscall;
31 -
pub mod tier;
32 -
pub mod update;
33 -
pub mod wait;
34 -
pub mod wasm;
35 -
pub mod workspace;
modified crates/flow/src/main.rs +44 −210
1 1
//! `flow`: check pipelines, run them locally, and run jobs for OxygenHub
2 2
//! (docs/spec.md, docs/m0.md).
3 3
4 -
use flow::{facts, home, images, microvm, runner, syscall, wait, wasm, workspace};
4 +
mod cpio;
5 +
mod facts;
6 +
/// The job-starting code `flow-init` uses in a microVM, shared with the
7 +
/// syscall tier.
8 +
#[path = "../../flow-init/src/job.rs"]
9 +
mod guest;
10 +
mod home;
11 +
mod images;
12 +
mod microvm;
13 +
mod outputs;
14 +
mod runner;
15 +
mod syscall;
16 +
mod wait;
17 +
mod workspace;
5 18
6 19
use anyhow::{bail, Context, Result};
7 20
use clap::{Parser, Subcommand};
…
36 unchanged lines
44 57
    /// Measure this machine as a runner does: its speed score (100 on the
45 58
    /// reference machine) and class, free disk and load (spec §11.1)
46 59
    Bench,
47 -
    /// Ask this home's running runner to drain: claim nothing more, finish
48 -
    /// and report what it holds, then stop (as SIGTERM does on Linux; on
49 -
    /// Windows, the way to stop a runner without losing its job)
50 -
    Drain,
51 -
    /// Install a new flow build in place of this one (docs/jetstream.md
52 -
    /// §8.2): check it, keep this one as flow.prev, and have the running
53 -
    /// runner leave with notice so its unit starts the new build. A build
54 -
    /// that does not reach the hub goes back to flow.prev by itself
55 -
    Update {
56 -
        /// The new flow binary
57 -
        #[arg(long)]
58 -
        from: PathBuf,
59 -
        /// Seconds the running runner has to finish what it holds; jobs
60 -
        /// that will not finish in time are handed back with their checkpoints
61 -
        #[arg(long, default_value_t = 600)]
62 -
        notice: u64,
63 -
        /// Check the build and say what would happen, changing nothing
64 -
        #[arg(long)]
65 -
        dry_run: bool,
66 -
        /// Install a build older than this one
67 -
        #[arg(long)]
68 -
        allow_older: bool,
69 -
        /// The installed binary to replace (default: this one)
70 -
        #[arg(long)]
71 -
        exe: Option<PathBuf>,
72 -
    },
73 60
}
74 61
75 62
#[derive(Subcommand)]
76 -
enum DepsCmd {
77 -
    /// Whether every crate a Cargo.lock needs is in the image at that
78 -
    /// version; exit 1 naming each missing one and the versions it has
79 -
    Check {
80 -
        /// The lock file (default: Cargo.lock)
81 -
        #[arg(default_value = "Cargo.lock")]
82 -
        lock: PathBuf,
83 -
        #[command(flatten)]
84 -
        image: DepsSource,
85 -
    },
86 -
    /// Print the image's crates as `NAME VERSION` lines (its crates.txt)
87 -
    List {
88 -
        #[command(flatten)]
89 -
        image: DepsSource,
90 -
    },
91 -
}
92 -
93 -
/// Where the image's crate set comes from: its crate list (the crates.txt
94 -
/// published with the image), a vendor directory, or the lock files it is
95 -
/// built from; inside a job, /opt/vendor.
96 -
#[derive(clap::Args)]
97 -
#[group(multiple = false)]
98 -
struct DepsSource {
99 -
    /// The image's crate list (crates.txt, written by images/rust/build.sh)
100 -
    #[arg(long, value_name = "FILE")]
101 -
    list: Option<PathBuf>,
102 -
    /// A cargo vendor directory (default inside a job: /opt/crates.txt, else /opt/vendor)
103 -
    #[arg(long, value_name = "DIR")]
104 -
    vendor: Option<PathBuf>,
105 -
    /// The lock files the image vendors (oxygen's, oxygenhub's,
106 -
    /// oxygenflow's), as images/rust/build.sh does (repeatable)
107 -
    #[arg(long = "image-lock", value_name = "LOCK")]
108 -
    locks: Vec<PathBuf>,
109 -
}
110 -
111 -
impl DepsSource {
112 -
    fn crates(&self) -> Result<BTreeSet<flow::deps::Crate>> {
113 -
        let read = |p: &Path| std::fs::read_to_string(p).with_context(|| p.display().to_string());
114 -
        if let Some(f) = &self.list {
115 -
            return Ok(flow::deps::from_list(&read(f)?));
116 -
        }
117 -
        if !self.locks.is_empty() {
118 -
            let mut all = BTreeSet::new();
119 -
            for l in &self.locks {
120 -
                all.extend(flow::deps::from_lock(&read(l)?).map_err(|e| anyhow::anyhow!("{}: {e}", l.display()))?);
121 -
            }
122 -
            return Ok(all);
123 -
        }
124 -
        // Inside a job: the image's own list, else its vendor directory.
125 -
        if self.vendor.is_none() && Path::new("/opt/crates.txt").is_file() {
126 -
            return Ok(flow::deps::from_list(&read(Path::new("/opt/crates.txt"))?));
127 -
        }
128 -
        let dir = self.vendor.clone().unwrap_or_else(|| PathBuf::from("/opt/vendor"));
129 -
        if self.vendor.is_none() && !dir.is_dir() {
130 -
            bail!("say what the image holds: --list crates.txt (published with the image), --vendor DIR, or --image-lock for each lock file it is built from; inside a job, /opt/vendor is read");
131 -
        }
132 -
        flow::deps::from_vendor(&dir).map_err(|e| anyhow::anyhow!(e))
133 -
    }
134 -
}
135 -
136 -
#[derive(Subcommand)]
137 63
enum Cmd {
138 64
    /// Validate a pipeline and print its jobs and hash
139 65
    Check {
…
2 unchanged lines
142 68
    },
143 69
    /// Print a file's BLAKE3, for pinning in flow.toml
144 70
    Hash { file: PathBuf },
145 -
    /// The crates the build image vendors, for jobs with no network (#101)
146 -
    Deps {
147 -
        #[command(subcommand)]
148 -
        cmd: DepsCmd,
149 -
    },
150 71
    /// Rootfs images for sandboxes
151 72
    Rootfs {
152 73
        #[command(subcommand)]
…
45 unchanged lines
198 119
    /// Check that this machine can run the syscall tier
199 120
    #[command(name = "__sandbox-probe", hide = true)]
200 121
    SandboxProbe,
201 -
    /// Rename a program that cannot rename its own file (Windows; #83)
202 -
    #[command(name = "__rename", hide = true)]
203 -
    Rename { from: PathBuf, to: PathBuf },
204 -
    /// The WASM tier's helper, started by the runner for each job
205 -
    #[command(name = "__wasm", hide = true)]
206 -
    Wasm { spec: PathBuf },
207 122
}
208 123
209 124
#[derive(Subcommand)]
…
13 unchanged lines
223 138
    /// List the images this home holds
224 139
    List,
225 140
    /// Send an image to a hub, so its runners fetch it instead of having it
226 -
    /// copied to them: only the chunks the hub lacks travel. Signs in as
227 -
    /// `oh` is signed in to that hub, or with $OXY_TOKEN and $OXY_CA
141 +
    /// copied to them: only the chunks the hub lacks travel. Uses
142 +
    /// $OXY_TOKEN (a token that may write repositories) and $OXY_CA
228 143
    Push {
229 144
        /// The image's hash, as pipelines pin it (cas://blob/HASH)
230 145
        hash: String,
231 -
        /// The hub (default: $OH_HUB, else the runner's, from runner.toml or flow.toml)
146 +
        /// The hub (default: the runner's, from runner.toml or flow.toml)
232 147
        #[arg(long)]
233 148
        hub: Option<String>,
234 149
    },
235 150
    /// Fetch an image a hub holds into this home, as a runner does for a
236 -
    /// job whose image it lacks. Signs in as `oh` is, or with $OXY_TOKEN
151 +
    /// job whose image it lacks. Uses $OXY_TOKEN and $OXY_CA
237 152
    Pull {
238 153
        hash: String,
239 154
        #[arg(long)]
…
13 unchanged lines
253 168
    match cli.cmd {
254 169
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
255 170
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
256 -
        Cmd::Rename { from, to } => {
257 -
            flow::portable::rename(&from, &to).with_context(|| format!("renaming {} to {}", from.display(), to.display()))?;
258 -
            Ok(())
259 -
        }
260 -
        Cmd::Wasm { spec } => std::process::exit(wasm::wasm_main(&spec)),
261 171
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
262 -
        Cmd::Deps { cmd: DepsCmd::List { image } } => {
263 -
            print!("{}", flow::deps::render_list(&image.crates()?));
264 -
            Ok(())
265 -
        }
266 -
        Cmd::Deps { cmd: DepsCmd::Check { lock, image } } => {
267 -
            let text = std::fs::read_to_string(&lock).with_context(|| lock.display().to_string())?;
268 -
            let need = flow::deps::from_lock(&text).map_err(|e| anyhow::anyhow!("{}: {e}", lock.display()))?;
269 -
            let missing = flow::deps::missing(&need, &image.crates()?);
270 -
            if missing.is_empty() {
271 -
                println!("deps: all {} crates of {} are in the image", need.len(), lock.display());
272 -
                return Ok(());
273 -
            }
274 -
            eprintln!("deps: {} of {} crates of {} are not in the image:", missing.len(), need.len(), lock.display());
275 -
            for ((name, version), has) in &missing {
276 -
                let instead = match has.as_slice() {
277 -
                    [] => "the image has no version of it".to_string(),
278 -
                    vs => format!("the image has {}: cargo update -p {name}@{version} --precise {}", vs.join(", "), vs.last().unwrap()),
279 -
                };
280 -
                eprintln!("  {name} {version}  ({instead})");
281 -
            }
282 -
            eprintln!("pin a version the image has, or rebuild the image with this workspace's lock (images/rust/build.sh)");
283 -
            std::process::exit(1);
284 -
        }
285 172
        Cmd::Hash { file } => {
286 173
            println!("{}", home::hash_file(&file)?);
287 174
            Ok(())
…
62 unchanged lines
350 237
            let f = facts::now(&home, Some(speed));
351 238
            println!("speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
352 239
            println!("free disk {} MiB at {}", f.free_disk_mib.unwrap_or(0), home.root.display());
353 -
            match f.load_x100 {
354 -
                Some(l) => println!("load {:.2}", l as f64 / 100.0),
355 -
                None => println!("load not measured here"),
356 -
            }
240 +
            println!("load {:.2}", f.load_x100.unwrap_or(0) as f64 / 100.0);
357 241
            if let Some(k) = f.kernel {
358 242
                println!("kernel {k}");
359 243
            }
…
18 unchanged lines
378 262
            };
379 263
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
380 264
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
381 -
            Ok(())
382 -
        }
383 -
        Cmd::Runner { cmd: Some(RunnerCmd::Drain) } => {
384 -
            let home = Home::locate(cli.home.as_deref())?;
385 -
            if flow::update::ask_to_drain(&home.root)? {
386 -
                println!("asked the runner to drain: it claims nothing more and stops once its jobs are done and reported");
387 -
            } else {
388 -
                println!("no runner of this home is running; the next one starts afresh");
389 -
            }
390 -
            Ok(())
391 -
        }
392 -
        Cmd::Runner { cmd: Some(RunnerCmd::Update { from, notice, dry_run, allow_older, exe }) } => {
393 -
            let home = Home::locate(cli.home.as_deref())?;
394 -
            let exe = match exe {
395 -
                Some(e) => e,
396 -
                None => std::env::current_exe().context("finding this flow binary")?,
397 -
            };
398 -
            let done = flow::update::install(&home, &exe, &from, Duration::from_secs(notice), dry_run, allow_older)?;
399 -
            if dry_run {
400 -
                println!(
401 -
                    "flow {} checks out: it would replace flow {} at {} (dry run: nothing changed)",
402 -
                    done.to,
403 -
                    done.from,
404 -
                    done.exe.display()
405 -
                );
406 -
                return Ok(());
407 -
            }
408 -
            println!("flow {} installed at {}; flow {} kept as {}", done.to, done.exe.display(), done.from, flow::update::PREV);
409 -
            match done.told {
410 -
                Some(pid) => println!("the runner (pid {pid}) leaves within {notice} s, then its unit starts flow {}", done.to),
411 -
                None => println!(
412 -
                    "no runner of this home was told (none runs, or one from before runner updates, which does not listen): \
413 -
                     restart its unit, e.g. `systemctl restart --no-block flow-runner`; it drains and starts flow {}",
414 -
                    done.to
415 -
                ),
416 -
            }
417 265
            Ok(())
418 266
        }
419 267
        Cmd::Runner { cmd: None } => {
420 268
            let home = Home::locate(cli.home.as_deref())?;
421 -
            // A new build on trial that keeps failing to start goes back to
422 -
            // the one before, which then runs in its place (#85).
423 -
            if let flow::update::Start::RolledBack(p) = flow::update::on_start(&home.root)? {
424 -
                #[cfg(unix)]
425 -
                {
426 -
                    use std::os::unix::process::CommandExt;
427 -
                    let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
428 -
                    bail!("starting flow {} again after the rollback: {err}", p.from);
429 -
                }
430 -
                // Windows has no exec: run the restored build and end with it.
431 -
                #[cfg(not(unix))]
432 -
                {
433 -
                    let status = std::process::Command::new(&p.exe)
434 -
                        .args(std::env::args_os().skip(1))
435 -
                        .status()
436 -
                        .with_context(|| format!("starting flow {} again after the rollback", p.from))?;
437 -
                    std::process::exit(status.code().unwrap_or(1));
438 -
                }
439 -
            }
440 269
            // The runner's oxy state (identity, user config) stays in its home.
441 270
            if std::env::var_os("OXY_HOME").is_none() {
442 271
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
…
25 unchanged lines
468 297
    }
469 298
}
470 299
471 -
/// The hub image commands talk to: `--hub`, else $OH_HUB, else the
472 -
/// runner's. The token: $OXY_TOKEN, else the login `oh auth login` (or
473 -
/// `oxy auth login`) saved for that hub in Oxygen's credential store
474 -
/// (oxygenflow#44), else the runner's own.
300 +
/// The hub image commands talk to: `--hub`, else the runner's; with
301 +
/// $OXY_TOKEN, else the runner's own token.
475 302
fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> {
476 303
    let runner = home.config.runner.clone();
477 -
    let hub = hub
478 -
        .or_else(|| std::env::var("OH_HUB").ok().filter(|h| !h.is_empty()))
479 -
        .or_else(|| runner.as_ref().map(|r| r.hub.clone()))
480 -
        .context("name the hub with --hub https://HUB (or set OH_HUB)")?;
481 -
    let (saved_token, saved_ca) = oxy_core::credentials::Credentials::user().lookup(&hub);
304 +
    let hub = hub.or_else(|| runner.as_ref().map(|r| r.hub.clone())).context("name the hub with --hub https://HUB")?;
482 305
    let token = std::env::var("OXY_TOKEN")
483 306
        .ok()
484 -
        .filter(|t| !t.is_empty())
485 -
        .or(saved_token)
486 307
        .or_else(|| runner.as_ref().map(|r| r.token.clone()))
487 -
        .with_context(|| format!("no token for {hub}: sign in with `oh auth login --hub {hub}`, or set OXY_TOKEN"))?;
488 -
    let ca = std::env::var_os("OXY_CA").map(PathBuf::from).or(saved_ca).or_else(|| runner.as_ref().and_then(|r| r.ca.clone()));
308 +
        .context("set OXY_TOKEN to a token of yours on the hub")?;
309 +
    let ca = std::env::var_os("OXY_CA").map(PathBuf::from).or_else(|| runner.as_ref().and_then(|r| r.ca.clone()));
489 310
    images::Client::new(&hub, &token, ca.as_deref())
490 311
}
491 312
…
48 unchanged lines
540 361
541 362
/// The architecture an ELF file was built for, by its header.
542 363
fn elf_arch(path: &Path) -> Option<&'static str> {
543 -
    let mut head = [0u8; 20];
544 -
    std::io::Read::read_exact(&mut std::fs::File::open(path).ok()?, &mut head).ok()?;
545 -
    jet::rootfs::elf_arch(&head)
364 +
    let bytes = std::fs::read(path).ok()?;
365 +
    if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" {
366 +
        return None;
367 +
    }
368 +
    let little = bytes[5] == 1;
369 +
    let machine = if little { u16::from_le_bytes([bytes[18], bytes[19]]) } else { u16::from_be_bytes([bytes[18], bytes[19]]) };
370 +
    Some(match machine {
371 +
        0x3E => "x86_64",
372 +
        0xB7 => "aarch64",
373 +
        0xF3 => "riscv64",
374 +
        0x03 => "x86",
375 +
        0x28 => "arm",
376 +
        0x15 => "powerpc64",
377 +
        0x16 => "s390x",
378 +
        _ => return None,
379 +
    })
546 380
}
547 381
548 382
/// The architecture of an unpacked rootfs: its shell's, or the first
…
119 unchanged lines
668 502
            continue;
669 503
        }
670 504
        let sb = &p.sandboxes[&job.sandbox];
505 +
        if sb.runtime == Runtime::Wasm {
506 +
            bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str());
507 +
        }
671 508
        println!("── {} ({})", job.name, job.exec.join(" "));
672 509
        let js = scratch.join(&job.name);
673 510
        std::fs::create_dir_all(&js)?;
…
17 unchanged lines
691 528
            })
692 529
            .collect::<Result<Vec<_>>>()?;
693 530
        // Each job starts from the same files: jobs do not share writes.
694 -
        let out = if sb.runtime == Runtime::Wasm {
695 -
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
696 -
            wasm::run(sb, job, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r))?
697 -
        } else if sb.runtime == Runtime::Syscall {
531 +
        let out = if sb.runtime == Runtime::Syscall {
698 532
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
699 533
            let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy());
700 534
            syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)?
…
56 unchanged lines
removed crates/flow/src/metrics.rs +0 −149
1 -
//! The runner's own metrics (oxygenflow#35, oxygenhub#212): a Prometheus
2 -
//! text page on an address of the runner's choosing, off unless
3 -
//! `metrics = "127.0.0.1:9464"` is set under `[runner]`. With
4 -
//! `metrics_token`, a scrape must send it as a bearer token.
5 -
6 -
use std::io::{BufRead, BufReader, Write};
7 -
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
8 -
use std::time::Instant;
9 -
10 -
/// Jobs this runner claimed.
11 -
pub static CLAIMS: AtomicU64 = AtomicU64::new(0);
12 -
/// Jobs declined before running (the preflight said no).
13 -
pub static DECLINED: AtomicU64 = AtomicU64::new(0);
14 -
/// Finished jobs by result.
15 -
pub static PASSED: AtomicU64 = AtomicU64::new(0);
16 -
pub static FAILED: AtomicU64 = AtomicU64::new(0);
17 -
pub static ERRORS: AtomicU64 = AtomicU64::new(0);
18 -
pub static CANCELLED: AtomicU64 = AtomicU64::new(0);
19 -
/// Milliseconds finished jobs ran, together.
20 -
pub static JOB_MS: AtomicU64 = AtomicU64::new(0);
21 -
/// Jobs running now.
22 -
pub static RUNNING: AtomicU64 = AtomicU64::new(0);
23 -
24 -
/// Count a job's end: by exit code, its error, and how long it ran.
25 -
pub fn finished(exit_code: Option<i32>, error: Option<&str>, duration_ms: u64, cancelled: bool) {
26 -
    let counter = if cancelled {
27 -
        &CANCELLED
28 -
    } else if error.is_some() {
29 -
        &ERRORS
30 -
    } else if exit_code == Some(0) {
31 -
        &PASSED
32 -
    } else {
33 -
        &FAILED
34 -
    };
35 -
    counter.fetch_add(1, Relaxed);
36 -
    JOB_MS.fetch_add(duration_ms, Relaxed);
37 -
}
38 -
39 -
/// The page.
40 -
pub fn render(name: &str, started: Instant) -> String {
41 -
    let n = name.replace('\\', "\\\\").replace('"', "\\\"");
42 -
    let mut out = String::new();
43 -
    let mut family = |metric: &str, help: &str, kind: &str, samples: &[(String, u64)]| {
44 -
        out.push_str(&format!("# HELP {metric} {help}\n# TYPE {metric} {kind}\n"));
45 -
        for (labels, v) in samples {
46 -
            let sep = if labels.is_empty() { "" } else { "," };
47 -
            out.push_str(&format!("{metric}{{runner=\"{n}\"{sep}{labels}}} {v}\n"));
48 -
        }
49 -
    };
50 -
    family("flow_runner_build_info", "This runner's version.", "gauge", &[(format!("version=\"{}\"", env!("CARGO_PKG_VERSION")), 1)]);
51 -
    family("flow_runner_uptime_seconds", "Seconds since the runner started.", "gauge", &[(String::new(), started.elapsed().as_secs())]);
52 -
    family("flow_runner_claims_total", "Jobs claimed from the hub.", "counter", &[(String::new(), CLAIMS.load(Relaxed))]);
53 -
    family("flow_runner_declined_total", "Claimed jobs declined before running.", "counter", &[(String::new(), DECLINED.load(Relaxed))]);
54 -
    family(
55 -
        "flow_runner_jobs_finished_total",
56 -
        "Finished jobs by result.",
57 -
        "counter",
58 -
        &[
59 -
            ("result=\"passed\"".into(), PASSED.load(Relaxed)),
60 -
            ("result=\"failed\"".into(), FAILED.load(Relaxed)),
61 -
            ("result=\"error\"".into(), ERRORS.load(Relaxed)),
62 -
            ("result=\"cancelled\"".into(), CANCELLED.load(Relaxed)),
63 -
        ],
64 -
    );
65 -
    family(
66 -
        "flow_runner_job_seconds_total",
67 -
        "Seconds finished jobs ran, together.",
68 -
        "counter",
69 -
        &[(String::new(), JOB_MS.load(Relaxed) / 1000)],
70 -
    );
71 -
    family("flow_runner_jobs_running", "Jobs running now.", "gauge", &[(String::new(), RUNNING.load(Relaxed))]);
72 -
    out
73 -
}
74 -
75 -
/// Serve the page at `addr` on a thread of its own; a failure to listen is
76 -
/// said and the runner goes on without it.
77 -
pub fn serve(addr: &str, token: Option<String>, name: String) {
78 -
    let listener = match std::net::TcpListener::bind(addr) {
79 -
        Ok(l) => l,
80 -
        Err(e) => {
81 -
            eprintln!("flow runner {name}: not serving metrics on {addr}: {e}");
82 -
            return;
83 -
        }
84 -
    };
85 -
    eprintln!("flow runner {name}: metrics on http://{addr}/metrics");
86 -
    let started = Instant::now();
87 -
    std::thread::spawn(move || {
88 -
        for stream in listener.incoming().flatten() {
89 -
            let _ = answer(stream, token.as_deref(), &name, started);
90 -
        }
91 -
    });
92 -
}
93 -
94 -
fn answer(mut stream: std::net::TcpStream, token: Option<&str>, name: &str, started: Instant) -> std::io::Result<()> {
95 -
    stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
96 -
    let mut reader = BufReader::new(stream.try_clone()?);
97 -
    let mut line = String::new();
98 -
    reader.read_line(&mut line)?;
99 -
    let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
100 -
    let mut authorized = token.is_none();
101 -
    loop {
102 -
        let mut h = String::new();
103 -
        if reader.read_line(&mut h)? == 0 || h.trim().is_empty() {
104 -
            break;
105 -
        }
106 -
        if let (Some(t), Some((k, v))) = (token, h.split_once(':')) {
107 -
            if k.trim().eq_ignore_ascii_case("authorization") && v.trim() == format!("Bearer {t}") {
108 -
                authorized = true;
109 -
            }
110 -
        }
111 -
    }
112 -
    let (status, body) = match (path.as_str(), authorized) {
113 -
        ("/metrics", true) => ("200 OK", render(name, started)),
114 -
        ("/metrics", false) => ("401 Unauthorized", "a bearer token is needed\n".to_string()),
115 -
        _ => ("404 Not Found", "not found\n".to_string()),
116 -
    };
117 -
    write!(
118 -
        stream,
119 -
        "HTTP/1.1 {status}\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
120 -
        body.len()
121 -
    )
122 -
}
123 -
124 -
#[cfg(test)]
125 -
mod tests {
126 -
    use super::*;
127 -
    use std::io::Read;
128 -
129 -
    #[test]
130 -
    fn a_scrape_needs_the_token_and_reads_the_counters() {
131 -
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
132 -
        let addr = l.local_addr().unwrap().to_string();
133 -
        drop(l);
134 -
        serve(&addr, Some("t0k".into()), "earth-1".into());
135 -
        CLAIMS.fetch_add(1, Relaxed);
136 -
        finished(Some(0), None, 2500, false);
137 -
        let get = |auth: &str| {
138 -
            let mut s = std::net::TcpStream::connect(&addr).unwrap();
139 -
            write!(s, "GET /metrics HTTP/1.1\r\nHost: x\r\n{auth}\r\n").unwrap();
140 -
            let mut body = String::new();
141 -
            s.read_to_string(&mut body).unwrap();
142 -
            body
143 -
        };
144 -
        assert!(get("").starts_with("HTTP/1.1 401"));
145 -
        let page = get("Authorization: Bearer t0k\r\n");
146 -
        assert!(page.starts_with("HTTP/1.1 200"), "{page}");
147 -
        assert!(page.contains("flow_runner_claims_total{runner=\"earth-1\"} ") && page.contains("result=\"passed\"}"), "{page}");
148 -
    }
149 -
}
modified crates/flow/src/microvm.rs +10 −127
…
21 unchanged lines
22 22
    pub cancelled: bool,
23 23
    /// The most memory the sandbox held, in MiB (oxygenflow#41).
24 24
    pub peak_memory_mib: Option<u64>,
25 -
    /// Each cache disk the guest pruned before it stopped, by its target
26 -
    /// (oxygenflow#72); the syscall tier's are pruned by the runner.
27 -
    pub pruned: Vec<(String, flow_init::prune::Pruned)>,
28 25
}
29 26
30 27
/// Where a job's records go, and whether it is still wanted: asked a few
…
55 unchanged lines
86 83
    let rootfs = home.verified(&rootfs, &sandbox.rootfs)?;
87 84
    let environment = environment(home, sandbox, job)?;
88 85
89 -
    let percent = home.config.runner.as_ref().and_then(|r| r.cache_prune_percent).unwrap_or(flow_init::prune::DEFAULT_PERCENT);
90 -
    let mut spec = guest_spec(job, percent);
86 +
    let mut spec = guest_spec(job);
91 87
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
92 -
    // Allow-listed egress (oxygenflow#55): a vsock device and the runner's
93 -
    // proxy behind it; the guest relays 127.0.0.1:3128 to it.
94 -
    let egress = sandbox.network == flow_core::Network::Egress;
95 -
    let (egress_tx, egress_rx) = mpsc::channel::<String>();
96 -
    let mut _proxy = None;
97 -
    if egress {
98 -
        if !home.config.runner.as_ref().is_some_and(|r| r.egress) {
99 -
            bail!("this runner does not allow network egress (a runner allows it with `egress = true` under [runner] in flow.toml)");
100 -
        }
101 -
        _proxy = Some(
102 -
            crate::egress::start(&scratch.join(format!("v.sock_{}", crate::egress::VSOCK_PORT)), sandbox.egress_allow.clone(), egress_tx)
103 -
                .context("start the egress proxy")?,
104 -
        );
105 -
        spec["egress"] = serde_json::Value::Bool(true);
106 -
        add_proxy_env(&mut spec);
107 -
        sink.record(Record {
108 -
            t: 0,
109 -
            stream: "flow".into(),
110 -
            text: format!("egress: through this runner, to {} only", sandbox.egress_allow.join(", ")),
111 -
        });
112 -
    }
113 88
    // Secrets ride in the guest's job file, which only flow-init reads,
114 89
    // outside the root the job is confined to; it writes each as a file.
115 90
    if !secrets.is_empty() {
…
5 unchanged lines
121 96
    let mut disks = Vec::new();
122 97
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
123 98
        let path = scratch.join(format!("mount-{i}.ext4"));
124 -
        // A disk already in place came from the build cache (#28).
125 -
        if !path.exists() {
126 -
            crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
127 -
        }
99 +
        crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
128 100
        disks.push(
129 101
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
130 102
        );
…
22 unchanged lines
153 125
        "drives": drives,
154 126
        "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib },
155 127
    });
156 -
    let mut vm = vm;
157 -
    if egress {
158 -
        // Relative to the VMM's working directory (the scratch directory),
159 -
        // which keeps the socket path short.
160 -
        vm["vsock"] = serde_json::json!({ "guest_cid": 3, "uds_path": "v.sock" });
161 -
    }
162 128
    let vm_path = scratch.join("vm.json");
163 129
    std::fs::write(&vm_path, serde_json::to_vec_pretty(&vm)?)?;
164 130
…
22 unchanged lines
187 153
    // The VMM's peak resident memory is the guest memory it touched: read
188 154
    // while it runs, since it is gone once it exits.
189 155
    let mut peak_memory_mib = None;
190 -
    let mut pruned = Vec::new();
191 156
    loop {
192 157
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
193 158
            peak_memory_mib = Some(m);
194 159
        }
195 -
        while let Ok(line) = egress_rx.try_recv() {
196 -
            sink.record(Record { t: t(&started), stream: "flow".into(), text: line });
197 -
        }
198 160
        match rx.recv_timeout(Duration::from_millis(200)) {
199 161
            Ok((from_console, line)) => {
200 162
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
…
8 unchanged lines
209 171
                        "E" => sink.record(rec("err", body)),
210 172
                        "X" => exit_code = body.trim().parse().ok(),
211 173
                        "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))),
212 -
                        "C" => match pruned_frame(body) {
213 -
                            Some(p) => pruned.push(p),
214 -
                            None => sink.record(rec("console", &text)),
215 -
                        },
216 174
                        _ => sink.record(rec("console", &text)),
217 175
                    }
218 176
                } else if !text.is_empty() {
…
36 unchanged lines
255 213
        timed_out,
256 214
        cancelled,
257 215
        peak_memory_mib,
258 -
        pruned,
259 216
    })
260 -
}
261 -
262 -
/// A `\x1eC` frame: a cache disk the guest pruned (oxygenflow#72).
263 -
fn pruned_frame(body: &str) -> Option<(String, flow_init::prune::Pruned)> {
264 -
    #[derive(serde::Deserialize)]
265 -
    struct Frame {
266 -
        target: String,
267 -
        pruned: flow_init::prune::Pruned,
268 -
    }
269 -
    let f: Frame = serde_json::from_str(body).ok()?;
270 -
    Some((f.target, f.pruned))
271 217
}
272 218
273 -
/// The proxy variables a job with egress gets, in both spellings tools
274 -
/// read, unless the job sets them itself.
275 -
fn add_proxy_env(spec: &mut serde_json::Value) {
276 -
    let Some(env) = spec["env"].as_array_mut() else { return };
277 -
    let set: Vec<String> = env.iter().filter_map(|kv| kv[0].as_str().map(String::from)).collect();
278 -
    for (k, v) in [
279 -
        ("HTTPS_PROXY", crate::egress::GUEST_PROXY),
280 -
        ("https_proxy", crate::egress::GUEST_PROXY),
281 -
        ("HTTP_PROXY", crate::egress::GUEST_PROXY),
282 -
        ("http_proxy", crate::egress::GUEST_PROXY),
283 -
        ("NO_PROXY", "localhost,127.0.0.1"),
284 -
        ("no_proxy", "localhost,127.0.0.1"),
285 -
    ] {
286 -
        if !set.iter().any(|s| s == k) {
287 -
            env.push(serde_json::json!([k, v]));
288 -
        }
289 -
    }
290 -
}
291 -
292 219
/// What `flow-init` reads from `/job.json`: argv, env, and the mounts
293 -
/// with the device each disk mount is attached as (vdc onwards). A cache
294 -
/// disk is pruned to `percent` of its size after a passing job
295 -
/// (oxygenflow#72), leaving the job's outputs on it.
296 -
pub fn guest_spec(job: &Job, percent: u8) -> serde_json::Value {
220 +
/// with the device each disk mount is attached as (vdc onwards).
221 +
pub fn guest_spec(job: &Job) -> serde_json::Value {
297 222
    let mut next_disk = b'c';
298 223
    let mounts: Vec<serde_json::Value> = job
299 224
        .mounts
…
3 unchanged lines
303 228
            MountSource::Disk => {
304 229
                let dev = format!("/dev/vd{}", next_disk as char);
305 230
                next_disk += 1;
306 -
                let mut disk = serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev });
307 -
                if m.cache.is_some() {
308 -
                    let prefix = format!("{}/", m.target);
309 -
                    let keep: Vec<&str> = job.outputs.iter().filter_map(|o| o.path.strip_prefix(&prefix)).collect();
310 -
                    disk["prune"] = percent.into();
311 -
                    disk["keep"] = keep.into();
312 -
                }
313 -
                disk
231 +
                serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev })
314 232
            }
315 233
        })
316 234
        .collect();
…
23 unchanged lines
340 258
mod tests {
341 259
    use super::*;
342 260
    use flow_core::Mount;
343 -
344 -
    #[test]
345 -
    fn a_job_with_egress_gets_the_proxy_variables_unless_it_sets_them() {
346 -
        let mut spec = serde_json::json!({ "env": [["HTTPS_PROXY", "http://mine:1"]] });
347 -
        super::add_proxy_env(&mut spec);
348 -
        let env = spec["env"].as_array().unwrap();
349 -
        let get = |k: &str| env.iter().find(|kv| kv[0] == k).map(|kv| kv[1].as_str().unwrap().to_string());
350 -
        assert_eq!(get("HTTPS_PROXY").as_deref(), Some("http://mine:1"), "the job's own is kept");
351 -
        assert_eq!(get("https_proxy").as_deref(), Some("http://127.0.0.1:3128"));
352 -
        assert_eq!(get("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
353 -
    }
354 261
355 262
    #[test]
356 263
    fn guest_spec_numbers_disks_after_the_workspace() {
…
1 unchanged lines
358 265
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"]
359 266
        }))
360 267
        .unwrap();
361 -
        assert_eq!(guest_spec(&job, 60)["mounts"], serde_json::json!([]), "no mounts");
268 +
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
362 269
        job.mounts = vec![
363 -
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
364 -
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
365 -
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
270 +
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
271 +
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
272 +
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64 },
366 273
        ];
367 -
        let spec = guest_spec(&job, 60);
274 +
        let spec = guest_spec(&job);
368 275
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
369 276
        assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256}));
370 277
        assert_eq!(spec["mounts"][2]["device"], "/dev/vdd");
371 278
        assert_eq!(spec["env"], serde_json::json!([["A", "1"]]));
372 -
    }
373 -
374 -
    /// oxygenflow#72: a cache disk is pruned in the guest, leaving the
375 -
    /// job's outputs on it; a plain disk is not, and the guest's report
376 -
    /// comes back as a frame.
377 -
    #[test]
378 -
    fn a_cache_disk_is_pruned_in_the_guest_but_its_outputs_stay() {
379 -
        let mut job: Job = serde_json::from_value(serde_json::json!({
380 -
            "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {}, "exec": ["true"],
381 -
            "outputs": [{"name": "bin", "path": "/src/target/release/tool"}, {"name": "w", "path": "/src/w"}]
382 -
        }))
383 -
        .unwrap();
384 -
        job.mounts = vec![
385 -
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: Some("t".into()), key: vec![] },
386 -
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
387 -
        ];
388 -
        let spec = guest_spec(&job, 45);
389 -
        assert_eq!((spec["mounts"][0]["prune"].as_u64(), &spec["mounts"][0]["keep"]), (Some(45), &serde_json::json!(["release/tool"])));
390 -
        assert!(spec["mounts"][1].get("prune").is_none() && spec["mounts"][1].get("keep").is_none());
391 -
        let (target, p) =
392 -
            pruned_frame(r#"{"target":"/src/target","pruned":{"size":100,"percent":60,"before":90,"after":50,"freed":40,"files":3}}"#)
393 -
                .unwrap();
394 -
        assert_eq!((target.as_str(), p.after, p.cap(), p.fits(), p.unpruned), ("/src/target", 50, 60, true, None));
395 -
        assert!(pruned_frame("not json").is_none());
396 279
    }
397 280
}
modified crates/flow/src/outputs.rs +9 −48
…
34 unchanged lines
35 35
/// [`MAX_ARTIFACT_BYTES`]. The job wrote that tree, so no link is
36 36
/// followed on the way: each directory is opened with `O_NOFOLLOW`, and
37 37
/// so is the file.
38 -
#[cfg(unix)]
39 38
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
40 39
    use std::ffi::CString;
40 +
    use std::io::Read;
41 41
    use std::os::fd::FromRawFd;
42 -
    let parts = clean_parts(inner)?;
42 +
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
43 +
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
44 +
        bail!("{inner} is not a clean path");
45 +
    }
43 46
    let c = |s: &str| CString::new(s).context("a path holds NUL");
44 47
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
45 48
    if dir < 0 {
…
15 unchanged lines
61 64
    if fd < 0 {
62 65
        bail!("{inner} is not a regular file in the job's files ({err})");
63 66
    }
64 -
    read_open(unsafe { std::fs::File::from_raw_fd(fd) }, inner)
65 -
}
66 -
67 -
/// [`read_dir_file`] on Windows (oxygenflow#83), where a WASM job's files
68 -
/// are a directory: each step is checked to be a directory and not a link
69 -
/// (nor a junction), then the file to be a regular file, before it is read.
70 -
#[cfg(not(unix))]
71 -
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
72 -
    let parts = clean_parts(inner)?;
73 -
    let mut at = base.to_path_buf();
74 -
    let (last, dirs) = parts.split_last().unwrap();
75 -
    for d in dirs {
76 -
        at.push(d);
77 -
        let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner}: {d} is not in the job's files"))?;
78 -
        if !md.is_dir() || md.file_type().is_symlink() {
79 -
            bail!("{inner}: {d} is not a directory in the job's files");
80 -
        }
81 -
    }
82 -
    at.push(last);
83 -
    let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner} is not in the job's files"))?;
84 -
    if !md.is_file() || md.file_type().is_symlink() {
85 -
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
86 -
    }
87 -
    read_open(std::fs::File::open(&at)?, inner)
88 -
}
89 -
90 -
/// `inner`'s parts, refusing an empty path, `.` and `..`.
91 -
fn clean_parts(inner: &str) -> Result<Vec<&str>> {
92 -
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
93 -
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
94 -
        bail!("{inner} is not a clean path");
95 -
    }
96 -
    Ok(parts)
97 -
}
98 -
99 -
/// Read an opened artifact file, at most [`MAX_ARTIFACT_BYTES`].
100 -
fn read_open(file: std::fs::File, inner: &str) -> Result<Vec<u8>> {
101 -
    use std::io::Read;
67 +
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
102 68
    let meta = file.metadata()?;
103 69
    if !meta.is_file() {
104 70
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
…
148 unchanged lines
253 219
            secrets: vec![],
254 220
            identities: vec![],
255 221
            mounts: vec![
256 -
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
257 -
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
258 -
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
222 +
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 },
223 +
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 },
224 +
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 },
259 225
            ],
260 226
            outputs: vec![],
261 -
            inputs: vec![],
262 -
            approval: None,
263 -
            environment: None,
264 227
            runs_on: vec![],
265 228
            timeout_s: None,
266 229
            on: None,
267 -
            priority: None,
268 -
            checkpoints: Vec::new(),
269 230
        }
270 231
    }
271 232
…
81 unchanged lines
removed crates/flow/src/portable.rs +0 −138
1 -
//! What differs between the systems a runner runs on (oxygenflow#83): file
2 -
//! modes, file identities and links are Unix's. On Windows, where a runner
3 -
//! offers the WASM tier only, modes are not kept (files read as 0644 and
4 -
//! directories as 0755), each file is its own, and sizes are lengths.
5 -
6 -
use std::fs::{Metadata, OpenOptions};
7 -
use std::io;
8 -
use std::path::Path;
9 -
10 -
/// Set `p`'s permission bits; nothing on Windows.
11 -
pub fn set_mode(p: &Path, mode: u32) -> io::Result<()> {
12 -
    #[cfg(unix)]
13 -
    {
14 -
        use std::os::unix::fs::PermissionsExt;
15 -
        std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
16 -
    }
17 -
    #[cfg(not(unix))]
18 -
    {
19 -
        let _ = (p, mode);
20 -
        Ok(())
21 -
    }
22 -
}
23 -
24 -
/// A file's permission bits (`0o7777` of its mode).
25 -
pub fn mode(md: &Metadata) -> u32 {
26 -
    #[cfg(unix)]
27 -
    {
28 -
        use std::os::unix::fs::MetadataExt;
29 -
        md.mode() & 0o7777
30 -
    }
31 -
    #[cfg(not(unix))]
32 -
    {
33 -
        let base = if md.is_dir() { 0o755 } else { 0o644 };
34 -
        if md.permissions().readonly() {
35 -
            base & !0o222
36 -
        } else {
37 -
            base
38 -
        }
39 -
    }
40 -
}
41 -
42 -
/// Open a new file readable and writable by its owner only.
43 -
pub fn private(o: &mut OpenOptions) -> &mut OpenOptions {
44 -
    #[cfg(unix)]
45 -
    {
46 -
        use std::os::unix::fs::OpenOptionsExt;
47 -
        o.mode(0o600)
48 -
    }
49 -
    #[cfg(not(unix))]
50 -
    {
51 -
        o
52 -
    }
53 -
}
54 -
55 -
/// Make `p` and its parents, the last only its owner's.
56 -
pub fn create_private_dir(p: &Path) -> io::Result<()> {
57 -
    #[cfg(unix)]
58 -
    {
59 -
        std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(p)
60 -
    }
61 -
    #[cfg(not(unix))]
62 -
    {
63 -
        std::fs::create_dir_all(p)
64 -
    }
65 -
}
66 -
67 -
/// The disk a file takes: its blocks on Unix, its length elsewhere.
68 -
pub fn disk_bytes(md: &Metadata) -> u64 {
69 -
    #[cfg(unix)]
70 -
    {
71 -
        use std::os::unix::fs::MetadataExt;
72 -
        md.blocks() * 512
73 -
    }
74 -
    #[cfg(not(unix))]
75 -
    {
76 -
        md.len()
77 -
    }
78 -
}
79 -
80 -
/// For a file with more than one name, what names it once (device, inode);
81 -
/// `None` for a file with one name, a directory, or anywhere but Unix.
82 -
pub fn shared_identity(md: &Metadata) -> Option<(u64, u64)> {
83 -
    #[cfg(unix)]
84 -
    {
85 -
        use std::os::unix::fs::MetadataExt;
86 -
        (md.nlink() > 1 && !md.is_dir()).then(|| (md.dev(), md.ino()))
87 -
    }
88 -
    #[cfg(not(unix))]
89 -
    {
90 -
        let _ = md;
91 -
        None
92 -
    }
93 -
}
94 -
95 -
/// Make `link` a symbolic link to `target` (a file link on Windows, which
96 -
/// needs Developer Mode or the right to make them).
97 -
pub fn symlink(target: &Path, link: &Path) -> io::Result<()> {
98 -
    #[cfg(unix)]
99 -
    {
100 -
        std::os::unix::fs::symlink(target, link)
101 -
    }
102 -
    #[cfg(windows)]
103 -
    {
104 -
        std::os::windows::fs::symlink_file(target, link)
105 -
    }
106 -
}
107 -
108 -
/// Rename `from` to `to`, which must not exist. On Windows through
109 -
/// `MoveFileExW`, which renames a program that is running, trying again
110 -
/// while another program (a virus scanner) holds the file.
111 -
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
112 -
    #[cfg(windows)]
113 -
    {
114 -
        use std::os::windows::ffi::OsStrExt;
115 -
        let wide = |p: &Path| p.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<u16>>();
116 -
        let (f, t) = (wide(from), wide(to));
117 -
        // A virus scanner holds a new program for a while after it is
118 -
        // written (a sharing violation, 32, or access denied, 5): try again
119 -
        // for half a minute.
120 -
        let mut tries = 0;
121 -
        loop {
122 -
            // SAFETY: both are NUL-terminated wide paths.
123 -
            if unsafe { windows_sys::Win32::Storage::FileSystem::MoveFileExW(f.as_ptr(), t.as_ptr(), 0) } != 0 {
124 -
                return Ok(());
125 -
            }
126 -
            let e = io::Error::last_os_error();
127 -
            tries += 1;
128 -
            if tries >= 30 || !matches!(e.raw_os_error(), Some(32) | Some(5)) {
129 -
                return Err(e);
130 -
            }
131 -
            std::thread::sleep(std::time::Duration::from_secs(1));
132 -
        }
133 -
    }
134 -
    #[cfg(not(windows))]
135 -
    {
136 -
        std::fs::rename(from, to)
137 -
    }
138 -
}
modified crates/flow/src/runner.rs +116 −1411
…
9 unchanged lines
10 10
use flow_core::{scope_tree_hash, Receipt};
11 11
use oxy_core::hash::ChangeHash;
12 12
use oxy_core::repo::Repo;
13 -
use std::path::{Path, PathBuf};
13 +
use std::path::PathBuf;
14 14
use std::time::{Duration, Instant};
15 15
16 16
/// A request the hub refused (4xx). On a job's endpoints, 409 means the
…
30 unchanged lines
47 47
        error: Some(CANCELLED.into()),
48 48
        peak_disk_mib: None,
49 49
        peak_memory_mib: None,
50 -
        phases: Default::default(),
51 50
    }
52 51
}
53 52
…
19 unchanged lines
73 72
74 73
    fn post_with(&self, path: &str, body: &impl serde::Serialize, delays: &[Duration]) -> Result<Option<ureq::Response>> {
75 74
        let url = format!("{}/api/v1{path}", self.base);
76 -
        let r = retrying(&url, delays, false, || {
77 -
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
78 -
        })?;
79 -
        Ok(if r.status() == 204 { None } else { Some(r) })
80 -
    }
81 -
82 -
    /// POST what a job leaves behind (oxygenflow#49): through a hub that is
83 -
    /// away for a few minutes, as while a deploy restarts it, so a job's
84 -
    /// declines and uploads outlast it.
85 -
    fn post_patiently(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
86 -
        let url = format!("{}/api/v1{path}", self.base);
87 -
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
88 -
            self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body).map_err(Box::new)
89 -
        })?;
90 -
        Ok(if r.status() == 204 { None } else { Some(r) })
75 +
        let mut delays = delays.iter();
76 +
        loop {
77 +
            match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
78 +
                Ok(r) if r.status() == 204 => return Ok(None),
79 +
                Ok(r) => return Ok(Some(r)),
80 +
                Err(ureq::Error::Status(code, r)) => {
81 +
                    let text = r.into_string().unwrap_or_default();
82 +
                    match delays.next() {
83 +
                        Some(d) if code >= 500 => std::thread::sleep(*d),
84 +
                        _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)),
85 +
                        _ => bail!("{url}: HTTP {code}: {text}"),
86 +
                    }
87 +
                }
88 +
                Err(e) => bail!("{url}: {e}"),
89 +
            }
90 +
        }
91 91
    }
92 92
93 93
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
…
5 unchanged lines
99 99
100 100
    /// Hand job `job` back, saying why this runner will not run it.
101 101
    pub fn decline(&self, job: i64, reason: &str) -> Result<()> {
102 -
        self.post_patiently(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
103 -
        Ok(())
104 -
    }
105 -
106 -
    /// Hand back a job this runner holds as it leaves (oxygenflow#76): the
107 -
    /// hub queues it again ahead of new work for another runner.
108 -
    pub fn release(&self, job: i64, reason: &str) -> Result<()> {
109 -
        self.post_patiently(&format!("/flow/jobs/{job}/release"), &Decline { reason: reason.into() })?;
110 -
        Ok(())
111 -
    }
112 -
113 -
    /// Say this runner is there while it claims nothing: its slots are full,
114 -
    /// or it is draining (oxygenhub#243). Once, no retries: the next comes
115 -
    /// soon. A hub without the endpoint (404) is not an error.
116 -
    /// A ten-minute certificate for `identity`, asked with a job's token
117 -
    /// (keyless, oxygenhub#241), and the identity of the hub's key that
118 -
    /// issued it, from `/.well-known/oxyhub-key`.
119 -
    pub fn certify(
120 -
        &self,
121 -
        job_token: &str,
122 -
        identity: &oxy_core::identity::Identity,
123 -
        proof: &oxy_core::identity::Signature,
124 -
    ) -> Result<(oxy_core::cert::SignedCertificate, oxy_core::identity::Identity)> {
125 -
        use oxy_core::dsse::{base64, base64_decode};
126 -
        let body = serde_json::json!({
127 -
            "identity": base64(&identity.encode()),
128 -
            "proof": base64(&oxy_core::cbor::encode(&proof.to_value())),
129 -
            "scope": "seal",
130 -
        });
131 -
        let url = format!("{}/api/v1/keyless/certify", self.base);
132 -
        let r = self
133 -
            .agent
134 -
            .post(&url)
135 -
            .set("Authorization", &format!("Bearer {job_token}"))
136 -
            .send_json(body)
137 -
            .map_err(|e| anyhow::anyhow!("asking the hub to certify the key: {e}"))?;
138 -
        let v: serde_json::Value = r.into_json()?;
139 -
        let cert = base64_decode(v["certificate"].as_str().unwrap_or(""))
140 -
            .context("the hub's answer has no certificate")
141 -
            .and_then(|b| oxy_core::cert::SignedCertificate::decode(&b).map_err(|e| anyhow::anyhow!("{e}")))?;
142 -
        let mut bytes = Vec::new();
143 -
        std::io::Read::read_to_end(
144 -
            &mut self.agent.get(&format!("{}/.well-known/oxyhub-key", self.base)).call()?.into_reader(),
145 -
            &mut bytes,
146 -
        )?;
147 -
        let issuer = oxy_core::identity::Identity::decode(&bytes)?;
148 -
        if issuer.hash() != cert.cert.issuer || cert.verify(&issuer).is_err() {
149 -
            bail!("the certificate is not signed by the hub's published key");
150 -
        }
151 -
        if cert.cert.key_id() != identity.hash() {
152 -
            bail!("the hub certified another key");
153 -
        }
154 -
        Ok((cert, issuer))
155 -
    }
156 -
157 -
    pub fn heartbeat(&self) -> Result<()> {
158 -
        match self.post_with("/flow/runners/heartbeat", &serde_json::json!({}), &[]) {
159 -
            Ok(_) => Ok(()),
160 -
            Err(e) if format!("{e:#}").contains("404") => Ok(()),
161 -
            Err(e) => Err(e),
162 -
        }
163 -
    }
164 -
165 -
    /// The update policy for this runner and the build it should run
166 -
    /// (oxygenhub#322); `None` from a hub without runner updates.
167 -
    pub fn update_answer(&self) -> Result<Option<flow_core::api::UpdateAnswer>> {
168 -
        let url = format!("{}/api/v1/flow/runner/update", self.base);
169 -
        match self.agent.get(&url).set("Authorization", &format!("Bearer {}", self.token)).call() {
170 -
            Ok(r) => Ok(Some(r.into_json()?)),
171 -
            Err(ureq::Error::Status(404, _)) => Ok(None),
172 -
            Err(e) => Err(anyhow::anyhow!("{url}: {e}")),
173 -
        }
174 -
    }
175 -
176 -
    /// Tell the hub what this runner did about its update.
177 -
    pub fn report_update(&self, note: &str) -> Result<()> {
178 -
        self.post_with("/flow/runner/update", &flow_core::api::UpdateReport { note: note.to_string() }, &[])?;
179 -
        Ok(())
180 -
    }
181 -
182 -
    /// Download an offered build to `to`, refusing it unless its BLAKE3
183 -
    /// is the one the hub attests and it is the size it said.
184 -
    pub fn download_build(&self, offer: &flow_core::api::UpdateOffer, to: &Path) -> Result<()> {
185 -
        use std::io::{Read, Write};
186 -
        let url = format!("{}{}", self.base, offer.url);
187 -
        let r = self.agent.get(&url).timeout(Duration::from_secs(1800)).call().map_err(|e| anyhow::anyhow!("{url}: {e}"))?;
188 -
        let part = to.with_extension("partial");
189 -
        let mut out = std::fs::File::create(&part).with_context(|| format!("writing {}", part.display()))?;
190 -
        let (mut hasher, mut n, mut buf) = (blake3::Hasher::new(), 0u64, vec![0u8; 1 << 16]);
191 -
        let mut body = r.into_reader().take(offer.size + 1);
192 -
        loop {
193 -
            let k = body.read(&mut buf)?;
194 -
            if k == 0 {
195 -
                break;
196 -
            }
197 -
            hasher.update(&buf[..k]);
198 -
            out.write_all(&buf[..k])?;
199 -
            n += k as u64;
200 -
        }
201 -
        drop(out);
202 -
        let got = hasher.finalize().to_hex().to_string();
203 -
        if n != offer.size || got != offer.blake3 {
204 -
            let _ = std::fs::remove_file(&part);
205 -
            bail!("{} does not match what the hub attests ({n} bytes, BLAKE3 {}…)", offer.asset, &got[..12]);
206 -
        }
207 -
        std::fs::rename(&part, to)?;
102 +
        self.post(&format!("/flow/jobs/{job}/decline"), &Decline { reason: reason.into() })?;
208 103
        Ok(())
209 104
    }
210 105
…
6 unchanged lines
217 112
    /// a blob of the job's repository and answers with its ObjectRef.
218 113
    pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> {
219 114
        let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base);
220 -
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
221 -
            self.agent
222 -
                .post(&url)
223 -
                .timeout(Duration::from_secs(600))
224 -
                .set("Authorization", &format!("Bearer {}", self.token))
225 -
                .set("Content-Type", "application/octet-stream")
226 -
                .send_bytes(bytes)
227 -
                .map_err(Box::new)
228 -
        })?;
229 -
        let v: serde_json::Value = r.into_json()?;
230 -
        v["blob"].as_str().map(String::from).context("the hub named no blob")
231 -
    }
232 -
233 -
    /// Keep a checkpoint of job `job` (oxygenflow#77) as its runner hands
234 -
    /// it back: the hub answers with its blob, which the next attempt's
235 -
    /// claim names.
236 -
    pub fn save_checkpoint(&self, job: i64, bytes: &[u8]) -> Result<String> {
237 -
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
238 -
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
239 -
            self.agent
115 +
        let mut delays = RETRY_DELAYS.iter();
116 +
        loop {
117 +
            let req = self
118 +
                .agent
240 119
                .post(&url)
241 120
                .timeout(Duration::from_secs(600))
242 121
                .set("Authorization", &format!("Bearer {}", self.token))
243 -
                .set("Content-Type", "application/octet-stream")
244 -
                .send_bytes(bytes)
245 -
                .map_err(Box::new)
246 -
        })?;
247 -
        let v: serde_json::Value = r.into_json()?;
248 -
        v["blob"].as_str().map(String::from).context("the hub named no blob")
249 -
    }
250 -
251 -
    /// The checkpoint an earlier attempt of job `job` saved (oxygenflow#77),
252 -
    /// which the caller checks against the blob its claim names.
253 -
    pub fn checkpoint(&self, job: i64) -> Result<Vec<u8>> {
254 -
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
255 -
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
256 -
            self.agent
257 -
                .get(&url)
258 -
                .timeout(Duration::from_secs(600))
259 -
                .set("Authorization", &format!("Bearer {}", self.token))
260 -
                .call()
261 -
                .map_err(Box::new)
262 -
        })?;
263 -
        let mut bytes = Vec::new();
264 -
        std::io::Read::read_to_end(&mut std::io::Read::take(r.into_reader(), crate::checkpoint::MAX_BYTES as u64 + 1), &mut bytes)?;
265 -
        Ok(bytes)
266 -
    }
267 -
268 -
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
269 -
    /// producing job's artifact, which the caller checks against the blob the
270 -
    /// claim names.
271 -
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
272 -
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
273 -
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
274 -
            self.agent
275 -
                .get(&url)
276 -
                .timeout(Duration::from_secs(600))
277 -
                .set("Authorization", &format!("Bearer {}", self.token))
278 -
                .call()
279 -
                .map_err(Box::new)
280 -
        })?;
281 -
        let mut bytes = Vec::new();
282 -
        let mut reader = r.into_reader();
283 -
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
284 -
        if bytes.len() > MAX_INPUT_BYTES {
285 -
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
122 +
                .set("Content-Type", "application/octet-stream");
123 +
            match req.send_bytes(bytes) {
124 +
                Ok(r) => {
125 +
                    let v: serde_json::Value = r.into_json()?;
126 +
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
127 +
                }
128 +
                Err(ureq::Error::Status(code, r)) => {
129 +
                    let text = r.into_string().unwrap_or_default();
130 +
                    match delays.next() {
131 +
                        Some(d) if code >= 500 => std::thread::sleep(*d),
132 +
                        _ => bail!("{url}: HTTP {code}: {text}"),
133 +
                    }
134 +
                }
135 +
                Err(e) => bail!("{url}: {e}"),
136 +
            }
286 137
        }
287 -
        Ok(bytes)
288 138
    }
289 139
290 140
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
…
1 unchanged lines
292 142
    /// report's manifest follows through [`Hub::artifact`].
293 143
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
294 144
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
295 -
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
296 -
            self.agent
145 +
        let mut delays = RETRY_DELAYS.iter();
146 +
        loop {
147 +
            let req = self
148 +
                .agent
297 149
                .post(&url)
298 150
                .timeout(Duration::from_secs(600))
299 151
                .set("Authorization", &format!("Bearer {}", self.token))
300 152
                .set("Content-Type", "application/octet-stream")
301 -
                .set("X-Flow-Report-Path", path)
302 -
                .send_bytes(bytes)
303 -
                .map_err(Box::new)
304 -
        })?;
305 -
        let v: serde_json::Value = r.into_json()?;
306 -
        v["blob"].as_str().map(String::from).context("the hub named no blob")
153 +
                .set("X-Flow-Report-Path", path);
154 +
            match req.send_bytes(bytes) {
155 +
                Ok(r) => {
156 +
                    let v: serde_json::Value = r.into_json()?;
157 +
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
158 +
                }
159 +
                Err(ureq::Error::Status(code, r)) => {
160 +
                    let text = r.into_string().unwrap_or_default();
161 +
                    match delays.next() {
162 +
                        Some(d) if code >= 500 => std::thread::sleep(*d),
163 +
                        _ => bail!("{url}: HTTP {code}: {text}"),
164 +
                    }
165 +
                }
166 +
                Err(e) => bail!("{url}: {e}"),
167 +
            }
168 +
        }
307 169
    }
308 170
309 171
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
…
90 unchanged lines
400 262
        got.credential, got.name
401 263
    );
402 264
    if let Some(ca) = &ca {
403 -
        let kept = keep_ca(&home.root, ca)?;
404 -
        text.push_str(&format!("ca = {:?}\n", kept.display().to_string()));
265 +
        text.push_str(&format!("ca = {:?}\n", ca.display().to_string()));
405 266
    }
406 267
    if !labels.is_empty() {
407 268
        text.push_str(&format!("labels = {labels:?}\n"));
…
1 unchanged lines
409 270
    let path = home.root.join(crate::home::RUNNER_TOML);
410 271
    {
411 272
        use std::io::Write;
273 +
        use std::os::unix::fs::OpenOptionsExt;
412 274
        let tmp = path.with_extension("toml.tmp");
413 -
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
275 +
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
414 276
        f.write_all(text.as_bytes())?;
415 277
        f.sync_all()?;
416 278
        std::fs::rename(&tmp, &path)?;
417 279
    }
418 280
    Ok(got)
419 -
}
420 -
421 -
/// Copy the hub's CA certificate `ca` into the Flow home `root` (#98) and
422 -
/// give the copy's absolute path, for runner.toml: a service starts in
423 -
/// another directory, and the file given may move or go.
424 -
fn keep_ca(root: &Path, ca: &Path) -> Result<PathBuf> {
425 -
    let kept = root.join(crate::home::CA_PEM);
426 -
    let same = matches!((std::fs::canonicalize(ca), std::fs::canonicalize(&kept)), (Ok(a), Ok(b)) if a == b);
427 -
    if !same {
428 -
        std::fs::copy(ca, &kept).with_context(|| format!("copy {} into {}", ca.display(), root.display()))?;
429 -
    }
430 -
    Ok(std::fs::canonicalize(&kept)?)
431 281
}
432 282
433 283
/// Waits before trying a hub request again after a server error.
434 284
const RETRY_DELAYS: [Duration; 3] = [Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(4)];
435 285
436 -
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
437 -
/// all, longer than a hub takes to restart.
438 -
/// Check an input's bytes against the blob the producing job's receipt
439 -
/// names, then write them at its path under `tree` (the workspace's `/src`).
440 -
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
441 -
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
442 -
    else {
443 -
        bail!("path {:?} is not a clean path under /src", i.path);
444 -
    };
445 -
    let blob = Repo::blob_ref(bytes);
446 -
    if blob.to_hex() != i.blob {
447 -
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
448 -
    }
449 -
    let dest = tree.join(rel);
450 -
    if let Some(dir) = dest.parent() {
451 -
        std::fs::create_dir_all(dir)?;
452 -
    }
453 -
    std::fs::write(&dest, bytes)?;
454 -
    Ok(blob.0)
455 -
}
456 -
457 -
/// The largest input a job takes: the hub's artifact limit.
458 -
pub const MAX_INPUT_BYTES: usize = 512 << 20;
459 -
460 -
const PATIENT_DELAYS: [Duration; 15] = {
461 -
    let mut d = [Duration::from_secs(15); 15];
462 -
    d[0] = Duration::from_secs(1);
463 -
    d[1] = Duration::from_secs(2);
464 -
    d[2] = Duration::from_secs(4);
465 -
    d[3] = Duration::from_secs(8);
466 -
    d
467 -
};
468 -
469 -
/// Send a request until the hub answers, waiting `delays` in turn after a
470 -
/// server error (5xx: nothing was committed) and, when `connection`, after
471 -
/// the hub could not be reached at all. A refusal (4xx) is final, as a
472 -
/// [`Refused`] the caller can look at.
473 -
fn retrying(
474 -
    url: &str,
475 -
    delays: &[Duration],
476 -
    connection: bool,
477 -
    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
478 -
) -> Result<ureq::Response> {
479 -
    let mut delays = delays.iter();
480 -
    loop {
481 -
        let why = match send().map_err(|e| *e) {
482 -
            Ok(r) => return Ok(r),
483 -
            Err(ureq::Error::Status(code, r)) if code < 500 => {
484 -
                let text = r.into_string().unwrap_or_default();
485 -
                return Err(anyhow::Error::new(Refused { code, text }).context(url.to_string()));
486 -
            }
487 -
            Err(ureq::Error::Status(code, r)) => format!("HTTP {code}: {}", r.into_string().unwrap_or_default()),
488 -
            Err(e) if connection => e.to_string(),
489 -
            Err(e) => bail!("{url}: {e}"),
490 -
        };
491 -
        match delays.next() {
492 -
            Some(d) => std::thread::sleep(*d),
493 -
            None => bail!("{url}: {why}"),
494 -
        }
495 -
    }
496 -
}
497 -
498 286
/// What the runner may run at once (flow.toml `[runner]`): at most
499 287
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
500 288
#[derive(Debug, Default, PartialEq, Eq)]
…
46 unchanged lines
547 335
    let mut missing = Vec::new();
548 336
    let kvm = std::fs::OpenOptions::new().read(true).write(true).open("/dev/kvm");
549 337
    match (kvm, home.config.microvm.is_some()) {
550 -
        _ if !cfg!(target_os = "linux") => missing.push("microvm: the tier runs only on Linux (Firecracker and KVM)".to_string()),
551 338
        (Ok(_), true) => have.push("microvm".to_string()),
552 339
        (Ok(_), false) => missing.push("microvm: flow.toml has no [microvm] section".to_string()),
553 340
        (Err(e), _) => missing.push(format!(
…
4 unchanged lines
558 345
        Ok(()) => have.push("syscall".to_string()),
559 346
        Err(e) => missing.push(format!("syscall: {e}")),
560 347
    }
561 -
    // wasmtime runs on every machine a build with it does (oxygenflow#75).
562 -
    if crate::wasm::AVAILABLE {
563 -
        have.push("wasm".to_string());
564 -
    } else {
565 -
        missing.push("wasm: this build has no WASM tier".to_string());
566 -
    }
567 348
    if have.is_empty() {
568 349
        return Err(format!("no isolation tier can run here: {}", missing.join("; ")));
569 350
    }
…
3 unchanged lines
573 354
/// Fetch `claim`'s image from the hub when this home lacks it
574 355
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
575 356
/// leaves it missing, and the preflight declines the job as before.
576 -
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) -> Option<u64> {
357 +
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) {
577 358
    let hash = &claim.sandbox.rootfs;
578 359
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
579 -
        return None;
360 +
        return;
580 361
    }
581 362
    let note = |text: String| {
582 363
        eprintln!("job {}: {text}", claim.job_id);
…
3 unchanged lines
586 367
    static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(());
587 368
    let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner());
588 369
    if home.rootfs(hash).is_file() {
589 -
        return None;
370 +
        return;
590 371
    }
591 372
    let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) {
592 373
        Ok(c) => c,
593 -
        Err(e) => {
594 -
            note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12]));
595 -
            return None;
596 -
        }
597 -
    };
598 -
    let fetching = Instant::now();
599 -
    let fetched = match swarm {
600 -
        Some(sw) => {
601 -
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
602 -
            sw.fetch(home, hash)
603 -
        }
604 -
        None => {
605 -
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
606 -
            client.fetch(home, hash)
607 -
        }
374 +
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
608 375
    };
609 -
    let took = fetching.elapsed().as_millis() as u64;
610 -
    match fetched {
376 +
    note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
377 +
    match client.fetch(home, hash) {
611 378
        Ok(Some(m)) => note(format!(
612 -
            "image {}: fetched {} chunks, {}{} in {:.1} s",
379 +
            "image {}: fetched {} chunks, {} in {:.1} s",
613 380
            &hash[..12],
614 381
            m.chunks,
615 382
            crate::images::human(m.bytes),
616 -
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
617 383
            m.elapsed.as_secs_f64()
618 384
        )),
619 385
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
620 386
        Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])),
621 387
    }
622 -
    Some(took)
623 388
}
624 389
625 390
/// Why this runner cannot run `claim` now (spec §11.5), checked before
…
16 unchanged lines
642 407
                return Some(format!("the syscall tier cannot run here: {e}"));
643 408
            }
644 409
        }
645 -
        // No image and no architecture: only the memory matters.
646 -
        flow_core::Runtime::Wasm => {
647 -
            return available_memory_mib()
648 -
                .filter(|free| *free < u64::from(sb.memory_mib))
649 -
                .map(|free| format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
650 -
        }
410 +
        flow_core::Runtime::Wasm => return Some("this runner has no wasm tier".into()),
651 411
    }
652 412
    sandbox_problem(home, sb, std::env::consts::ARCH, available_memory_mib())
653 413
}
…
19 unchanged lines
673 433
        return Some(format!("only {free} MiB of memory is free here; the sandbox needs {} MiB", sb.memory_mib));
674 434
    }
675 435
    None
676 -
}
677 -
678 -
/// The memory to claim with: the budget's room, never more than the host
679 -
/// has free now. An idle runner's budget is unlimited (a lone job may use
680 -
/// it all), so without this it was offered sandboxes it then declined for
681 -
/// want of memory, one after another, having fetched their images first
682 -
/// (an 8 GiB job on a 7 GB machine).
683 -
fn claim_memory(room: Option<u32>, host_free_mib: Option<u64>) -> Option<u32> {
684 -
    match (room, host_free_mib) {
685 -
        (Some(r), Some(h)) => Some(r.min(u32::try_from(h).unwrap_or(u32::MAX))),
686 -
        (None, Some(h)) => Some(u32::try_from(h).unwrap_or(u32::MAX)),
687 -
        (room, None) => room,
688 -
    }
689 436
}
690 437
691 438
/// `MemAvailable` from /proc/meminfo, in MiB.
…
4 unchanged lines
696 443
}
697 444
698 445
pub fn serve(home: &Home) -> Result<()> {
699 -
    drain_on_stop();
700 -
    crate::update::note_runner(&home.root);
701 -
    // `flow runner update` swapped a new build in and asks this one to
702 -
    // leave with the notice it gave (#85).
703 -
    #[cfg(unix)]
704 -
    {
705 -
        let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
706 -
        unsafe { libc::signal(libc::SIGUSR1, handler) };
707 -
    }
708 -
    if let Some(p) = crate::update::pending(&home.root) {
709 -
        // On trial: a build that does not reach the hub in time goes back.
710 -
        let root = home.root.clone();
711 -
        std::thread::spawn(move || {
712 -
            std::thread::sleep(crate::update::PROVE_WITHIN);
713 -
            if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) {
714 -
                let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60);
715 -
                if crate::update::roll_back(&root, &p, &why).is_ok() {
716 -
                    std::process::exit(1);
717 -
                }
718 -
            }
719 -
        });
720 -
    }
721 -
    let served = serve_until_drained(home);
722 -
    crate::update::forget_runner(&home.root);
723 -
    // Left for a new build: on Windows, end as a failure, so a Task
724 -
    // Scheduler task set to restart on failure starts it (systemd's
725 -
    // Restart=always starts it after a clean exit).
726 -
    if cfg!(windows) && served.is_ok() && LEFT_FOR_UPDATE.load(std::sync::atomic::Ordering::SeqCst) {
727 -
        eprintln!("flow runner: left for the new build; exiting 75 so it is started");
728 -
        std::process::exit(75);
729 -
    }
730 -
    served
731 -
}
732 -
733 -
/// Set once the runner leaves for a new build.
734 -
static LEFT_FOR_UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
735 -
736 -
/// Set by SIGUSR1: a new build is in place; leave for it.
737 -
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
738 -
739 -
#[cfg(unix)]
740 -
extern "C" fn on_update(_: libc::c_int) {
741 -
    UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
742 -
}
743 -
744 -
/// The runner as a library, as the Android app runs it (oxygenhub-android#8):
745 -
/// no signal handlers; [`stop`] drains it, and this returns once the jobs
746 -
/// it holds are done and reported. It may be started again afterwards.
747 -
pub fn serve_embedded(home: &Home) -> Result<()> {
748 -
    STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
749 -
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = None;
750 -
    serve_until_drained(home)
751 -
}
752 -
753 -
/// A departure ([`leave_within`]): why, and when the runner must be gone.
754 -
#[derive(Clone, Debug)]
755 -
struct Leave {
756 -
    reason: String,
757 -
    by: Instant,
758 -
}
759 -
760 -
/// Set once the runner is leaving: a job whose expected rest fits before
761 -
/// the deadline finishes; the others are stopped and handed back
762 -
/// (oxygenflow#76).
763 -
static LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
764 -
765 -
fn leaving() -> Option<String> {
766 -
    LEAVING.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(|l| l.reason.clone())
767 -
}
768 -
769 -
/// Ask a runner started with [`serve_embedded`] to leave now: claim nothing
770 -
/// more, stop the jobs it holds and hand them back to the hub, which queues
771 -
/// them ahead of new work for another runner (oxygenflow#76), then return.
772 -
/// For a departure with no time to finish: a phone off the allowed network.
773 -
pub fn leave(reason: &str) {
774 -
    leave_within(reason, Duration::ZERO);
775 -
}
776 -
777 -
/// [`leave`], with `notice`: a job the hub expects to finish within it
778 -
/// (the claim's `expected_ms`, less the time it has run) finishes and is
779 -
/// reported; the others, and any still running when the notice is up, are
780 -
/// handed back. For a phone taken off charge, a spot reclaim.
781 -
pub fn leave_within(reason: &str, notice: Duration) {
782 -
    *LEAVING.lock().unwrap_or_else(|p| p.into_inner()) = Some(Leave { reason: reason.to_string(), by: Instant::now() + notice });
783 -
    stop();
784 -
}
785 -
786 -
/// Ask a runner started with [`serve_embedded`] to drain: claim nothing
787 -
/// more, finish and report what it holds, then return (oxygenflow#76's
788 -
/// drain, for a phone taken off charge or off the allowed network).
789 -
pub fn stop() {
790 -
    STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
791 -
}
792 -
793 -
fn serve_until_drained(home: &Home) -> Result<()> {
794 446
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
795 447
    // A runner offers only what it can run: a machine without KVM claims
796 448
    // nothing rather than taking MicroVM jobs and failing them (#36).
…
5 unchanged lines
802 454
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
803 455
    let budget = Budget::of(&cfg);
804 456
    let key = home.runner_key(&name)?;
805 -
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
806 -
    // peers still runs jobs, fetching images from the hub alone.
807 -
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
808 -
        eprintln!("flow runner: not joining the swarm: {e:#}");
809 -
        None
810 -
    });
811 457
    eprintln!(
812 458
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
813 459
        cfg.hub,
…
1 unchanged lines
815 461
        runtimes.join(", "),
816 462
        key.identity.hash().to_hex()
817 463
    );
818 -
    if let Some(addr) = &cfg.metrics {
819 -
        crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone());
820 -
    }
821 -
    // The manifests of images held here that the hub keeps none of, so
822 -
    // peers in this runner's site can fetch them without anyone pushing
823 -
    // them (oxygenflow#94): at start and every ten minutes, which also
824 -
    // catches an image imported while it runs. Only a runner that serves
825 -
    // its peers has anything to offer.
826 -
    if swarm.is_some() {
827 -
        let (root, cfg) = (home.root.clone(), cfg.clone());
828 -
        std::thread::spawn(move || loop {
829 -
            let offered = Home::locate(Some(&root))
830 -
                .and_then(|home| crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?.offer_held(&home));
831 -
            match offered {
832 -
                Ok(0) => {}
833 -
                Ok(n) => eprintln!("flow runner: sent the hub the manifests of {n} image(s) only runners hold"),
834 -
                Err(e) => eprintln!("flow runner: offering held images: {e:#}"),
835 -
            }
836 -
            std::thread::sleep(Duration::from_secs(600));
837 -
        });
838 -
    }
839 464
    let used = std::sync::Mutex::new(Used::default());
840 465
    let mut idle = Duration::from_secs(2);
841 466
    let pending = report_pending(home);
…
20 unchanged lines
862 487
        };
863 488
        let mut speed = measure();
864 489
        let mut benched = Instant::now();
865 -
        let mut said_draining = false;
866 -
        // When the hub last heard from us (oxygenhub#243): a claim, or a
867 -
        // heartbeat while there is no room to claim.
868 -
        let mut heard = Instant::now();
869 -
        let beat = |heard: &mut Instant| {
870 -
            if heard.elapsed() >= HUB_HEARTBEAT {
871 -
                if let Err(e) = hub.heartbeat() {
872 -
                    eprintln!("flow runner {name}: heartbeat: {e:#}");
873 -
                }
874 -
                *heard = Instant::now();
875 -
            }
876 -
        };
877 -
        let mut on_trial = crate::update::pending(&home.root).is_some();
878 -
        // Runner updates (oxygenhub#322): asked of the hub once it has
879 -
        // answered a claim, then every 15 minutes.
880 -
        let mut update_asked: Option<Instant> = None;
881 -
        let mut update_said = String::new();
882 490
        loop {
883 -
            if !stopping() && crate::update::drain_requested(&home.root) {
884 -
                eprintln!("flow runner {name}: asked to drain (flow runner drain)");
885 -
                stop();
886 -
            }
887 -
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) || crate::update::requested(&home.root) {
888 -
                let p = crate::update::pending(&home.root);
889 -
                let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs));
890 -
                let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to));
891 -
                eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs());
892 -
                LEFT_FOR_UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
893 -
                leave_within(&format!("updating to {to}"), notice);
894 -
            }
895 -
            // Draining (oxygenflow#50): told to stop, claim nothing more and
896 -
            // stop once every job held is done and reported.
897 -
            if stopping() {
898 -
                let (jobs, holding) = (used.lock().unwrap().jobs, held.lock().unwrap().len());
899 -
                if jobs == 0 && holding == 0 {
900 -
                    eprintln!("flow runner {name}: drained, stopping");
901 -
                    break;
902 -
                }
903 -
                if !said_draining {
904 -
                    eprintln!("flow runner {name}: draining: claiming nothing more, stopping when the {jobs} job(s) held are done (stop again to stop now)");
905 -
                    said_draining = true;
906 -
                }
907 -
                beat(&mut heard);
908 -
                std::thread::sleep(Duration::from_millis(500));
909 -
                continue;
910 -
            }
911 491
            if used.lock().unwrap().jobs == 0 && benched.elapsed() >= Duration::from_secs(15 * 60) {
912 492
                speed = measure();
913 493
                benched = Instant::now();
914 494
            }
915 495
            let room = budget.room(*used.lock().unwrap());
916 496
            let Some((free_memory_mib, free_cpus)) = room else {
917 -
                beat(&mut heard);
918 497
                std::thread::sleep(Duration::from_millis(500));
919 498
                continue;
920 499
            };
921 -
            heard = Instant::now();
922 -
            let cache_times = crate::cache::Pool::new(&home.root, cfg.cache_gb).slot_times();
923 500
            let req = ClaimRequest {
924 501
                runner: name.clone(),
925 502
                runtimes: runtimes.clone(),
926 -
                free_memory_mib: claim_memory(free_memory_mib, available_memory_mib()),
503 +
                free_memory_mib,
927 504
                free_cpus,
928 505
                info: Some(RunnerInfo {
929 506
                    version: env!("OXY_VERSION_LONG").into(),
930 -
                    ..RunnerInfo::here(cfg.offered_labels(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
507 +
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
931 508
                }),
932 509
                // Read each time: an image imported while the runner runs counts.
933 510
                images: Some(home.images()),
…
1 unchanged lines
935 512
                holding: Some(held.lock().unwrap().iter().copied().collect()),
936 513
                fetches_images: true,
937 514
                facts: Some(crate::facts::now(home, Some(speed))),
938 -
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
939 -
                caches: Some(cache_times.keys().cloned().collect()),
940 -
                cache_times: Some(cache_times),
941 515
            };
942 -
            let answer = hub.claim(&req);
943 -
            if on_trial && answer.is_ok() {
944 -
                crate::update::proven(&home.root);
945 -
                on_trial = false;
946 -
            }
947 -
            if answer.is_ok() && update_asked.is_none_or(|t| t.elapsed() >= UPDATE_EVERY) {
948 -
                update_asked = Some(Instant::now());
949 -
                if let Some(note) = crate::update::take_note(&home.root) {
950 -
                    let _ = hub.report_update(&note);
951 -
                }
952 -
                consider_update(home, &hub, swarm.as_ref(), &mut update_said);
953 -
            }
954 -
            match answer {
516 +
            match hub.claim(&req) {
955 517
                Ok(Some(claim)) => {
956 518
                    idle = Duration::from_secs(2);
957 -
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
958 519
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
959 520
                    {
960 521
                        let mut u = used.lock().unwrap();
…
2 unchanged lines
963 524
                        u.cpus += cpus;
964 525
                    }
965 526
                    held.lock().unwrap().insert(claim.job_id);
966 -
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
527 +
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
967 528
                    scope.spawn(move || {
968 529
                        let id = claim.job_id;
969 -
                        let image_ms = fetch_image(home, hub, cfg, swarm, &claim);
530 +
                        fetch_image(home, hub, cfg, &claim);
970 531
                        if let Some(why) = preflight(home, &claim) {
971 532
                            eprintln!("job {id}: declined: {why}");
972 -
                            crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
973 533
                            if let Err(e) = hub.decline(id, &why) {
974 534
                                eprintln!("job {id}: decline: {e:#}");
975 535
                            }
…
10 unchanged lines
986 546
                            claim.job.name,
987 547
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
988 548
                        );
989 -
                        crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
990 -
                        let _shown = Running::start(id, format!("{} #{} {}", claim.repo, claim.run_number, claim.job.name));
991 -
                        let finish = run_claim(home, hub, cfg, key, &claim, image_ms).unwrap_or_else(|e| Finish {
549 +
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
992 550
                            exit_code: None,
993 551
                            duration_ms: 0,
994 552
                            receipt: None,
…
1 unchanged lines
996 554
                            error: Some(format!("{e:#}")),
997 555
                            peak_disk_mib: None,
998 556
                            peak_memory_mib: None,
999 -
                            phases: Default::default(),
1000 557
                        });
1001 558
                        eprintln!(
1002 559
                            "job {id}: exit {:?}{}",
1003 560
                            finish.exit_code,
1004 561
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
1005 562
                        );
1006 -
                        crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1007 -
                        let cancelled = finish.error.as_deref() == Some(CANCELLED);
1008 -
                        crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled);
1009 -
                        match leaving() {
1010 -
                            // Stopped as the runner leaves: another runner takes it.
1011 -
                            Some(why) if cancelled => {
1012 -
                                eprintln!("job {id}: handed back: {why}");
1013 -
                                if let Err(e) = hub.release(id, &why) {
1014 -
                                    eprintln!("job {id}: handing back: {e:#}; the hub requeues it once this runner's lease lapses");
1015 -
                                }
1016 -
                            }
1017 -
                            _ if !cancelled => report_finish(home, hub, id, &finish),
1018 -
                            _ => {}
563 +
                        if finish.error.as_deref() != Some(CANCELLED) {
564 +
                            report_finish(home, hub, id, &finish);
1019 565
                        }
1020 566
                        held.lock().unwrap().remove(&id);
1021 567
                        let mut u = used.lock().unwrap();
…
10 unchanged lines
1032 578
                }
1033 579
            }
1034 580
        }
1035 -
        Ok(())
1036 581
    })
1037 -
}
1038 -
1039 -
/// Pack and keep a handed-back job's checkpoints (oxygenflow#77); what
1040 -
/// happened goes to its log, and a failure only means the next attempt
1041 -
/// starts again.
1042 -
#[allow(clippy::too_many_arguments)]
1043 -
fn save_checkpoint(hub: &Hub, id: i64, job: &flow_core::Job, scratch: &Path, dir_tier: bool, wasm: bool, mut say: impl FnMut(String)) {
1044 -
    if !dir_tier {
1045 -
        say("checkpoint: only the WASM and syscall tiers save one yet; the next attempt starts again".into());
1046 -
        return;
1047 -
    }
1048 -
    let dirs: Vec<(String, PathBuf)> = job
1049 -
        .checkpoints
1050 -
        .iter()
1051 -
        .filter_map(|c| crate::checkpoint::host_dir(c, job, &scratch.join("tree"), scratch, wasm).map(|d| (c.clone(), d)))
1052 -
        .collect();
1053 -
    match crate::checkpoint::pack(&dirs).and_then(|(bytes, n)| Ok((hub.save_checkpoint(id, &bytes)?, n))) {
1054 -
        Ok((blob, n)) => {
1055 -
            say(format!("checkpoint {}: {} files, {} bytes, kept for the next attempt", &blob[..12.min(blob.len())], n.files, n.bytes))
1056 -
        }
1057 -
        Err(e) => say(format!("checkpoint: not kept ({e:#}); the next attempt starts again")),
1058 -
    }
1059 -
}
1060 -
1061 -
/// How many times the runner was told to stop (SIGTERM or SIGINT).
1062 -
static STOPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1063 -
1064 -
extern "C" fn on_stop(_: libc::c_int) {
1065 -
    // A second stop means now: only async-signal-safe calls here.
1066 -
    if STOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 {
1067 -
        unsafe { libc::_exit(143) };
1068 -
    }
1069 -
}
1070 -
1071 -
/// Drain instead of dying on the first SIGTERM or SIGINT (oxygenflow#50),
1072 -
/// so restarting a runner never kills the job it runs: the unit's
1073 -
/// `KillMode=mixed` sends the signal to the runner alone, not its microVMs.
1074 -
fn drain_on_stop() {
1075 -
    let handler = on_stop as extern "C" fn(libc::c_int) as libc::sighandler_t;
1076 -
    unsafe {
1077 -
        libc::signal(libc::SIGTERM, handler);
1078 -
        libc::signal(libc::SIGINT, handler);
1079 -
    }
1080 -
}
1081 -
1082 -
fn stopping() -> bool {
1083 -
    STOPS.load(std::sync::atomic::Ordering::SeqCst) > 0
1084 582
}
1085 583
1086 584
/// Batches records to the hub every second (or every 200 lines). A quiet
…
5 unchanged lines
1092 590
    buf: Vec<Record>,
1093 591
    last: Instant,
1094 592
    gone: bool,
1095 -
    /// Set when the runner leaves ([`LEAVING`]): the job stops, to be
1096 -
    /// handed back, unless its expected rest fits the notice.
1097 -
    leaving: &'a std::sync::Mutex<Option<Leave>>,
1098 -
    /// When the job started, and how long the hub expects it to take.
1099 -
    started: Instant,
1100 -
    expected: Option<Duration>,
1101 593
}
1102 -
1103 -
/// The most log lines sent at once, as a busy job's sink flushes them.
1104 -
const BATCH: usize = 200;
1105 594
1106 -
/// The most log lines kept while the hub cannot be reached.
1107 -
const MAX_UNSENT: usize = 50_000;
1108 -
1109 595
/// How often a quiet job asks whether it was cancelled.
1110 596
const HEARTBEAT: Duration = Duration::from_secs(5);
1111 597
…
3 unchanged lines
1115 601
    }
1116 602
1117 603
    fn cancelled(&mut self) -> bool {
1118 -
        let leave = self.leaving.lock().unwrap_or_else(|p| p.into_inner()).clone();
1119 -
        if let Some(l) = leave {
1120 -
            // Finished if what is left of it fits the notice; else handed
1121 -
            // back as the runner leaves, what the job said so far first.
1122 -
            let now = Instant::now();
1123 -
            let fits = self.expected.is_some_and(|e| now + e.saturating_sub(self.started.elapsed()) <= l.by);
1124 -
            if !fits || now >= l.by {
1125 -
                self.flush();
1126 -
                return true;
1127 -
            }
1128 -
        }
1129 604
        if !self.gone && self.last.elapsed() >= HEARTBEAT {
1130 605
            self.send();
1131 606
        }
…
14 unchanged lines
1146 621
            r.text.truncate(cut);
1147 622
        }
1148 623
        self.buf.push(r);
1149 -
        if self.buf.len() >= BATCH || self.last.elapsed() >= Duration::from_secs(1) {
624 +
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
1150 625
            self.flush();
1151 626
        }
1152 627
    }
…
5 unchanged lines
1158 633
        self.last = Instant::now();
1159 634
    }
1160 635
1161 -
    /// Send what is buffered, even nothing, in batches of at most
1162 -
    /// `BATCH` lines.
636 +
    /// Send what is buffered, even nothing.
1163 637
    fn send(&mut self) {
1164 -
        loop {
1165 -
            if self.gone {
1166 -
                return;
1167 -
            }
1168 -
            let n = self.buf.len().min(BATCH);
1169 -
            let batch: Vec<Record> = self.buf.drain(..n).collect();
1170 -
            match self.hub.log(self.job, batch.clone()) {
1171 -
                Ok(()) => {}
1172 -
                Err(e) if job_gone(&e) => self.gone = true,
1173 -
                Err(e) => {
1174 -
                    // The hub is away (a restart, say): keep the lines for
1175 -
                    // the next batch, up to a bound, so the log loses none
1176 -
                    // (oxygenflow#49).
1177 -
                    eprintln!("job {}: log: {e:#}; kept for the next batch", self.job);
1178 -
                    let mut kept = batch;
1179 -
                    kept.append(&mut self.buf);
1180 -
                    let over = kept.len().saturating_sub(MAX_UNSENT);
1181 -
                    if over > 0 {
1182 -
                        kept.drain(..over);
1183 -
                        kept.insert(
1184 -
                            0,
1185 -
                            Record {
1186 -
                                t: 0,
1187 -
                                stream: "flow".into(),
1188 -
                                text: format!("({over} log lines lost while the hub could not be reached)"),
1189 -
                            },
1190 -
                        );
1191 -
                    }
1192 -
                    self.buf = kept;
1193 -
                    break;
1194 -
                }
1195 -
            }
1196 -
            if self.buf.is_empty() {
1197 -
                break;
1198 -
            }
1199 -
        }
1200 -
        self.last = Instant::now();
1201 -
    }
1202 -
}
1203 -
1204 -
/// How often a runner with no room to claim tells the hub it is there:
1205 -
/// well inside the hub's 90 s before it shows a runner offline.
1206 -
const HUB_HEARTBEAT: Duration = Duration::from_secs(30);
1207 -
1208 -
/// How often a runner asks the hub whether it should run another build.
1209 -
const UPDATE_EVERY: Duration = Duration::from_secs(15 * 60);
1210 -
1211 -
/// Notice a runner gives itself to leave for an update the hub offered.
1212 -
const UPDATE_NOTICE: Duration = Duration::from_secs(600);
1213 -
1214 -
/// Ask the hub for this runner's update, and act on it: download the build
1215 -
/// it offers, check it against the hub's BLAKE3 and as a runner build, and
1216 -
/// install it, or for a dry run say what would happen. What it did goes to
1217 -
/// the hub once per outcome (`said`).
1218 -
fn consider_update(home: &Home, hub: &Hub, swarm: Option<&crate::swarm::Swarm>, said: &mut String) {
1219 -
    let answer = match hub.update_answer() {
1220 -
        Ok(Some(a)) => a,
1221 -
        Ok(None) => return,
1222 -
        Err(e) => {
1223 -
            eprintln!("flow runner: asking for an update: {e:#}");
638 +
        if self.gone {
1224 639
            return;
1225 -
        }
1226 -
    };
1227 -
    let Some(offer) = answer.offer else { return };
1228 -
    let mut say = |note: String| {
1229 -
        if *said != note {
1230 -
            eprintln!("flow runner: {note}");
1231 -
            if let Err(e) = hub.report_update(&note) {
1232 -
                eprintln!("flow runner: telling the hub: {e:#}");
1233 -
            }
1234 -
            *said = note;
1235 640
        }
1236 -
    };
1237 -
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
1238 -
        return say(format!("refuses flow {}: flow.toml says [runner.update] allow = false", offer.version));
1239 -
    }
1240 -
    let dir = home.root.join("updates");
1241 -
    let file = dir.join(format!("flow-{}", offer.version));
1242 -
    let fresh = std::fs::read(&file).is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == offer.blake3);
1243 -
    if !fresh {
1244 -
        if let Err(e) =
1245 -
            std::fs::create_dir_all(&dir).map_err(anyhow::Error::from).and_then(|_| fetch_build(home, hub, swarm, &offer, &file))
1246 -
        {
1247 -
            return say(format!("flow {}: not fetched: {e:#}", offer.version));
641 +
        match self.hub.log(self.job, std::mem::take(&mut self.buf)) {
642 +
            Ok(()) => {}
643 +
            Err(e) if job_gone(&e) => self.gone = true,
644 +
            Err(e) => eprintln!("job {}: log: {e:#}", self.job),
1248 645
        }
1249 -
    }
1250 -
    let exe = match std::env::current_exe() {
1251 -
        Ok(e) => e,
1252 -
        Err(e) => return say(format!("flow {}: cannot find its own binary: {e}", offer.version)),
1253 -
    };
1254 -
    match crate::update::install(home, &exe, &file, UPDATE_NOTICE, answer.policy.dry_run, offer.older) {
1255 -
        Ok(done) if answer.policy.dry_run => say(format!("dry run: flow {} checks out here and would replace flow {}", done.to, done.from)),
1256 -
        Ok(done) => {
1257 -
            let _ = std::fs::remove_file(&file);
1258 -
            say(format!("installed flow {} (was {}); leaving within {} s to start it", done.to, done.from, UPDATE_NOTICE.as_secs()))
1259 -
        }
1260 -
        Err(e) => say(format!("flow {}: not installed: {e:#}", offer.version)),
646 +
        self.last = Instant::now();
1261 647
    }
1262 648
}
1263 649
1264 -
/// Fetch an offered build to `file`: through the swarm when this runner is
1265 -
/// in one (oxygenflow#84), as an image named by the build's BLAKE3, which
1266 -
/// the hub chunks when it first offers it, so runners of a site fetch it
1267 -
/// from each other and the hub serves it about once per site; over HTTP
1268 -
/// otherwise, or when the swarm cannot get it. Either way it must be the
1269 -
/// size and BLAKE3 the hub attests.
1270 -
fn fetch_build(
1271 -
    home: &Home,
1272 -
    hub: &Hub,
1273 -
    swarm: Option<&crate::swarm::Swarm>,
1274 -
    offer: &flow_core::api::UpdateOffer,
1275 -
    file: &Path,
1276 -
) -> Result<()> {
1277 -
    if let Some(sw) = swarm.filter(|_| jet::images::is_hash(&offer.blake3)) {
1278 -
        match sw.fetch(home, &offer.blake3) {
1279 -
            Ok(Some(m)) => {
1280 -
                let image = home.rootfs(&offer.blake3);
1281 -
                match crate::update::copy_checked(&image, file, offer.size, &offer.blake3) {
1282 -
                    Ok(()) => {
1283 -
                        eprintln!(
1284 -
                            "flow runner: fetched flow {} through the swarm: {} chunks, {} ({} from peers)",
1285 -
                            offer.version,
1286 -
                            m.chunks,
1287 -
                            crate::images::human(m.bytes),
1288 -
                            crate::images::human(m.peer_bytes)
1289 -
                        );
1290 -
                        return Ok(());
1291 -
                    }
1292 -
                    Err(e) => eprintln!("flow runner: flow {} from the swarm: {e:#}; fetching it over HTTP", offer.version),
1293 -
                }
1294 -
            }
1295 -
            Ok(None) => eprintln!("flow runner: the hub has not shared flow {} with the swarm yet; fetching it over HTTP", offer.version),
1296 -
            Err(e) => eprintln!("flow runner: flow {} through the swarm: {e:#}; fetching it over HTTP", offer.version),
1297 -
        }
1298 -
    }
1299 -
    hub.download_build(offer, file)
1300 -
}
1301 -
1302 -
/// The jobs this runner is running now, for a host to show (the Android
1303 -
/// app's card and notification, oxygenhub-android#8): by job id,
1304 -
/// "owner/name #run job".
1305 -
static RUNNING_NOW: std::sync::Mutex<std::collections::BTreeMap<i64, String>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
1306 -
1307 -
/// A job shown in [`running`] while this lives.
1308 -
struct Running(i64);
1309 -
1310 -
impl Running {
1311 -
    fn start(id: i64, what: String) -> Running {
1312 -
        RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).insert(id, what);
1313 -
        Running(id)
1314 -
    }
1315 -
}
1316 -
1317 -
impl Drop for Running {
1318 -
    fn drop(&mut self) {
1319 -
        RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).remove(&self.0);
1320 -
    }
1321 -
}
1322 -
1323 -
/// What this runner is running now: "owner/name #run job" for each job, in
1324 -
/// the order claimed.
1325 -
pub fn running() -> Vec<String> {
1326 -
    RUNNING_NOW.lock().unwrap_or_else(|p| p.into_inner()).values().cloned().collect()
1327 -
}
1328 -
1329 -
/// Held while a job takes its cache disks and images its workspace (see
1330 -
/// `run_claim`): one job at a time on the runner's disk and cache pool.
650 +
/// Held while a job's workspace is prepared (see `run_claim`).
1331 651
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1332 -
1333 -
/// Held while the first clone of any repository passes its token through
1334 -
/// the process environment.
1335 -
static CLONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
1336 652
1337 -
/// One lock per cached repository: fetches into it, and exports from it,
1338 -
/// never overlap, while jobs of other repositories go on. A slow repository
1339 -
/// (one kept in a bucket, say) once held every job on the runner waiting
1340 -
/// behind a single global lock.
1341 -
fn repo_lock(name: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
1342 -
    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>> =
1343 -
        std::sync::OnceLock::new();
1344 -
    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
1345 -
    locks.entry(name.to_string()).or_default().clone()
1346 -
}
1347 -
1348 -
/// Seconds, to one decimal place, for the job's log.
1349 -
fn secs(d: std::time::Duration) -> String {
1350 -
    format!("{:.1} s", d.as_secs_f64())
1351 -
}
1352 -
1353 -
/// `image_ms`: how long fetching its image took, when the runner lacked it.
1354 -
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim, image_ms: Option<u64>) -> Result<Finish> {
653 +
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> {
1355 654
    let started = Instant::now();
1356 -
    let mut sink = LogSink {
1357 -
        hub,
1358 -
        job: claim.job_id,
1359 -
        buf: Vec::new(),
1360 -
        last: Instant::now(),
1361 -
        gone: false,
1362 -
        leaving: &LEAVING,
1363 -
        started: Instant::now(),
1364 -
        expected: claim.expected_ms.map(Duration::from_millis),
1365 -
    };
655 +
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false };
1366 656
    let note =
1367 657
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
1368 658
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
1369 659
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
1370 -
    // Preparing a workspace: fetches into a cached repository, and exports
1371 -
    // from it, are one job at a time per repository (`repo_lock`); taking
1372 -
    // cache disks and imaging the workspace are one job at a time on the
1373 -
    // runner (`PREPARE`); a first clone, whose token goes through the process
1374 -
    // environment, one at a time (`CLONE`).
1375 -
    let main_lock = repo_lock(&claim.repo);
1376 -
    let asked = Instant::now();
1377 -
    let mut main_guard = Some(main_lock.lock().unwrap_or_else(|p| p.into_inner()));
1378 -
    if asked.elapsed().as_secs() >= 2 {
1379 -
        note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), claim.repo));
1380 -
    }
1381 -
    let fetching = Instant::now();
660 +
    // Preparing a workspace (the cached repositories, the export, the
661 +
    // image) is one job at a time: fetches into a cached repository must not
662 +
    // overlap, and the first clone passes its token through the process
663 +
    // environment. Only the microVMs run side by side.
664 +
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
1382 665
    let repo = cached_repo(home, cfg, claim)?;
1383 -
    let fetched = fetching.elapsed();
1384 -
    let mut preparing = None;
1385 666
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
1386 667
    let _ = std::fs::remove_dir_all(&scratch);
1387 668
    std::fs::create_dir_all(scratch.join("tree"))?;
1388 669
    let result = (|| -> Result<Finish> {
1389 -
        let exporting = Instant::now();
1390 670
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
1391 -
        main_guard.take();
1392 -
        note(&mut sink, format!("fetched in {}, {} files exported in {}", secs(fetched), files.len(), secs(exporting.elapsed())));
1393 671
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
1394 672
        // workspace and of the scope hash.
1395 673
        for sub in &claim.subrepos {
…
9 unchanged lines
1405 683
                    sub.path
1406 684
                ),
1407 685
            );
1408 -
            let sub_lock = repo_lock(&sub.repo);
1409 -
            let asked = Instant::now();
1410 -
            let sub_guard = sub_lock.lock().unwrap_or_else(|p| p.into_inner());
1411 -
            if asked.elapsed().as_secs() >= 2 {
1412 -
                note(&mut sink, format!("waited {} for another job fetching {}", secs(asked.elapsed()), sub.repo));
1413 -
            }
1414 686
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
1415 687
            let sub_heads: Vec<ChangeHash> =
1416 688
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
1417 689
            let dir = scratch.join("tree").join(&sub.path);
1418 690
            std::fs::create_dir_all(&dir)?;
1419 691
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
1420 -
            drop(sub_guard);
1421 692
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
1422 693
        }
1423 694
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
1424 -
        // Inputs (oxygenflow#58): each producing job's artifact, checked
1425 -
        // against the blob its receipt names, then put at its path. Not part
1426 -
        // of the scope: they are the run's, not the repository's.
1427 -
        let mut input_blobs = Vec::new();
1428 -
        for (index, i) in claim.inputs.iter().enumerate() {
1429 -
            let what = format!("input {} from {}", i.artifact, i.from);
1430 -
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
1431 -
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
1432 -
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
1433 -
            input_blobs.push(blob);
1434 -
        }
1435 695
        // The syscall tier runs on the tree itself; a microVM gets an image.
1436 696
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
1437 -
        let wasm_tier = claim.sandbox.runtime == flow_core::Runtime::Wasm;
1438 -
        // The syscall and WASM tiers see the job's files as a directory;
1439 -
        // the MicroVM tier as an ext4 image.
1440 -
        let dir_tier = syscall_tier || wasm_tier;
1441 -
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
1442 -
        // Disk mounts that name a cache start from a kept disk (#28): a
1443 -
        // microVM takes an image already in place as it is, the syscall tier
1444 -
        // a directory (#70). Taken before the workspace is imaged: the
1445 -
        // sources the disk was built from give unchanged files their old
1446 -
        // times back (#52).
1447 -
        let asked = Instant::now();
1448 -
        preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
1449 -
        if asked.elapsed().as_secs() >= 2 {
1450 -
            note(&mut sink, format!("waited {} for another job imaging its workspace", secs(asked.elapsed())));
1451 -
        }
1452 -
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
1453 -
        let form = if dir_tier { crate::cache::Form::Dir } else { crate::cache::Form::Image };
1454 -
        let cache_ctx =
1455 -
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs, form };
1456 -
        let mut cached: Vec<(usize, String, String)> = Vec::new();
1457 -
        let mut built_from: Option<crate::cache::Sources> = None;
1458 -
        for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
1459 -
            let Some(name) = &m.cache else { continue };
1460 -
            let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
1461 -
            let how = match pool.take(&cache_ctx, m, &key, &crate::cache::disk_path(form, &scratch, i)) {
1462 -
                Some(t) => {
1463 -
                    if built_from.is_none() {
1464 -
                        built_from = t.sources;
1465 -
                    }
1466 -
                    t.how
1467 -
                }
1468 -
                None => format!("cache {name}: none kept yet, an empty disk"),
1469 -
            };
1470 -
            note(&mut sink, format!("{} ({})", how, m.target));
1471 -
            // What the job reads is seen when the disk is pruned (#72).
1472 -
            if let Err(e) = crate::cache::forget_reads(form, &crate::cache::disk_path(form, &scratch, i)) {
1473 -
                note(&mut sink, format!("cache {name}: {e:#}"));
1474 -
            }
1475 -
            cached.push((i, key, how));
1476 -
        }
1477 -
        // What the job reads or writes on its cache from now on is what it
1478 -
        // used; the rest may be pruned before the cache is kept (#72).
1479 -
        let cache_started = if cached.is_empty() { std::time::SystemTime::now() } else { flow_init::prune::start_clock() };
1480 -
        let sources = if cached.is_empty() {
1481 -
            crate::cache::Sources::new()
1482 -
        } else {
1483 -
            let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref());
1484 -
            if built_from.is_some() {
1485 -
                note(
1486 -
                    &mut sink,
1487 -
                    format!(
1488 -
                        "cache: {restored} of {} files and directories unchanged since the cached build, their times kept",
1489 -
                        sources.len()
1490 -
                    ),
1491 -
                );
1492 -
            }
1493 -
            sources
1494 -
        };
1495 -
        // An earlier attempt's checkpoint (oxygenflow#77): back in place,
1496 -
        // over the cache, before the same exec runs again; the receipt
1497 -
        // lists it among the job's inputs.
1498 -
        if let Some(blob) = &claim.checkpoint {
1499 -
            if dir_tier {
1500 -
                let bytes = hub.checkpoint(claim.job_id).context("fetching the checkpoint")?;
1501 -
                if Repo::blob_ref(&bytes).to_hex() != *blob {
1502 -
                    bail!("the checkpoint fetched is not blob {}, as the claim names", &blob[..12.min(blob.len())]);
1503 -
                }
1504 -
                let place = |root: &str| crate::checkpoint::host_dir(root, &job, &scratch.join("tree"), &scratch, wasm_tier);
1505 -
                let n = crate::checkpoint::unpack(&bytes, &job.checkpoints, place)?;
1506 -
                note(
1507 -
                    &mut sink,
1508 -
                    format!("restored checkpoint {}: {} files, {} bytes, from the attempt handed back", &blob[..12], n.files, n.bytes),
1509 -
                );
1510 -
                input_blobs.push(Repo::blob_ref(&bytes).0);
1511 -
            } else {
1512 -
                note(&mut sink, "a checkpoint was saved, but only the WASM and syscall tiers restore one yet: starting again".into());
1513 -
            }
1514 -
        }
1515 -
        if !dir_tier {
697 +
        if !syscall_tier {
1516 698
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
1517 699
            std::fs::remove_dir_all(scratch.join("tree"))?;
1518 700
        }
…
2 unchanged lines
1521 703
        if microvm::Sink::cancelled(&mut sink) {
1522 704
            return Ok(cancelled());
1523 705
        }
1524 -
        if wasm_tier {
1525 -
            note(&mut sink, format!("starting WASM (wasmtime {}): {} MiB", crate::wasm::WASMTIME, claim.sandbox.memory_mib));
1526 -
        } else {
1527 -
            note(
1528 -
                &mut sink,
1529 -
                format!(
1530 -
                    "{}: {} {}, {} MiB, rootfs {}",
1531 -
                    if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
1532 -
                    claim.sandbox.cpus,
1533 -
                    if syscall_tier { "CPU" } else { "vCPU" },
1534 -
                    claim.sandbox.memory_mib,
1535 -
                    &claim.sandbox.rootfs[..12]
1536 -
                ),
1537 -
            );
1538 -
        }
706 +
        note(
707 +
            &mut sink,
708 +
            format!(
709 +
                "{}: {} {}, {} MiB, rootfs {}",
710 +
                if syscall_tier { "starting a syscall sandbox" } else { "booting a microVM" },
711 +
                claim.sandbox.cpus,
712 +
                if syscall_tier { "CPU" } else { "vCPU" },
713 +
                claim.sandbox.memory_mib,
714 +
                &claim.sandbox.rootfs[..12]
715 +
            ),
716 +
        );
717 +
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
1539 718
        let timeout = Duration::from_secs(claim.timeout_s);
1540 -
        // Where the time goes (oxygenflow#35): what came before the
1541 -
        // sandbox, the sandbox, and what comes after.
1542 -
        let prepare_ms = started.elapsed().as_millis() as u64;
1543 -
        let sandbox_started = Instant::now();
1544 -
        let mut outcome = if wasm_tier {
1545 -
            crate::wasm::run(&claim.sandbox, &job, &scratch.join("tree"), &scratch, timeout, &mut sink)?
1546 -
        } else if syscall_tier {
719 +
        let outcome = if syscall_tier {
1547 720
            let group = format!("job-{}", claim.job_id);
1548 721
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
1549 722
        } else {
1550 723
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1551 724
        };
1552 -
        let run_ms = sandbox_started.elapsed().as_millis() as u64;
1553 725
        if outcome.cancelled {
1554 -
            // Handed back as the runner leaves: its checkpoints first, for
1555 -
            // the next attempt (oxygenflow#77).
1556 -
            if leaving().is_some() && !job.checkpoints.is_empty() {
1557 -
                save_checkpoint(hub, claim.job_id, &job, &scratch, dir_tier, wasm_tier, |t| note(&mut sink, t));
1558 -
            }
1559 726
            return Ok(cancelled());
1560 -
        }
1561 -
        // The receipt says what a cached disk started from.
1562 -
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
1563 -
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
1564 -
        for (i, key, how) in &cached {
1565 -
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
1566 727
        }
1567 728
        // A passing job's artifacts, read from its disks and uploaded; a
1568 729
        // declared artifact that cannot be kept fails the job. A failing
…
5 unchanged lines
1574 735
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
1575 736
        for o in wanted {
1576 737
            if o.kind == flow_core::OutputKind::Report {
1577 -
                let read = if dir_tier {
738 +
                let read = if syscall_tier {
1578 739
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1579 740
                    crate::outputs::read_report_dir(&dir, &inner)
1580 741
                } else {
…
18 unchanged lines
1599 760
                }
1600 761
                continue;
1601 762
            }
1602 -
            let read = if dir_tier {
763 +
            let read = if syscall_tier {
1603 764
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
1604 765
                crate::outputs::read_dir_file(&dir, &inner)
1605 766
            } else {
…
27 unchanged lines
1633 794
            job_name: claim.job.name.clone(),
1634 795
            pipeline_hash: hex32(&claim.pipeline_hash),
1635 796
            scope_tree_hash: scope_hash,
1636 -
            inputs: input_blobs.clone(),
797 +
            inputs: vec![],
1637 798
            environment: outcome.environment.hash(),
1638 799
            outputs,
1639 800
            exit_code: code as u32,
1640 801
            duration_ms: outcome.duration_ms,
1641 802
        });
1642 803
        let signed_receipt = match &receipt {
1643 -
            Some(r) => {
1644 -
                let mut signed = sign_receipt(&repo, &heads, cfg, key, claim, r)?;
1645 -
                if cfg.keyless {
1646 -
                    match sign_keyless(&repo, &heads, cfg, hub, claim, r) {
1647 -
                        Ok(k) => signed = k,
1648 -
                        Err(e) => {
1649 -
                            note(&mut sink, format!("receipt: signed with the runner's key; a short-lived key was not certified: {e:#}"))
1650 -
                        }
1651 -
                    }
1652 -
                }
1653 -
                Some(hex::encode(signed.encode()))
1654 -
            }
804 +
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
1655 805
            None => None,
1656 806
        };
1657 -
        // Measured before a cached disk leaves the scratch directory.
1658 -
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
1659 -
        // A passing job's cached disks are pruned and kept (#72); a
1660 -
        // failing job's are thrown away, so nothing it wrote reaches a
1661 -
        // later job.
1662 -
        let kept = passed && output_error.is_none();
1663 -
        for (i, key, _) in &cached {
1664 -
            let disk = crate::cache::disk_path(form, &scratch, *i);
1665 -
            let m = disk_mounts[*i];
1666 -
            let prune = crate::cache::Prune {
1667 -
                started: cache_started,
1668 -
                percent: cfg.cache_prune_percent.unwrap_or(flow_init::prune::DEFAULT_PERCENT),
1669 -
                guest: outcome.pruned.iter().find(|(t, _)| *t == m.target).map(|(_, p)| p),
1670 -
            };
1671 -
            match pool.settle(&cache_ctx, m, key, &disk, &sources, kept, &prune) {
1672 -
                Ok(Some(line)) => note(&mut sink, line),
1673 -
                Ok(None) => {}
1674 -
                Err(e) => note(&mut sink, format!("cache: not {}: {e:#}", if kept { "kept" } else { "discarded" })),
1675 -
            }
1676 -
        }
1677 -
        let total_ms = started.elapsed().as_millis() as u64;
1678 -
        let mut phases: std::collections::BTreeMap<String, u64> =
1679 -
            [("prepare", prepare_ms), ("run", run_ms), ("finish", total_ms.saturating_sub(prepare_ms + run_ms))]
1680 -
                .into_iter()
1681 -
                .map(|(k, v)| (k.to_string(), v))
1682 -
                .collect();
1683 -
        if let Some(ms) = image_ms {
1684 -
            phases.insert("image".into(), ms);
1685 -
        }
1686 807
        Ok(Finish {
1687 -
            phases,
1688 808
            exit_code: outcome.exit_code,
1689 -
            duration_ms: total_ms,
809 +
            duration_ms: started.elapsed().as_millis() as u64,
1690 810
            receipt,
1691 811
            signed_receipt,
1692 812
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
1693 813
            // What the job took (oxygenflow#41): its disks are sparse files
1694 814
            // in the scratch directory, so their blocks now are their peak.
1695 -
            peak_disk_mib,
815 +
            peak_disk_mib: crate::facts::allocated_mib(&scratch),
1696 816
            peak_memory_mib: outcome.peak_memory_mib,
1697 817
        })
1698 818
    })();
…
42 unchanged lines
1741 861
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &key.identity, &key.secret))
1742 862
}
1743 863
1744 -
/// Sign `receipt` with a fresh key the hub certifies for this job now,
1745 -
/// just before signing, so the certificate covers the moment the hub
1746 -
/// accepts it (keyless, oxygenhub#241). The key is dropped with the
1747 -
/// receipt signed: nothing long-lived signs.
1748 -
fn sign_keyless(
1749 -
    repo: &Repo,
1750 -
    heads: &[ChangeHash],
1751 -
    cfg: &RunnerConfig,
1752 -
    hub: &Hub,
1753 -
    claim: &Claim,
1754 -
    receipt: &Receipt,
1755 -
) -> Result<oxy_core::receipt::SignedReceipt> {
1756 -
    let sk = oxy_core::identity::SecretKey::generate();
1757 -
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
1758 -
    let name = format!("{} job {}", cfg.name.as_deref().unwrap_or("runner"), claim.job_id);
1759 -
    let id = sk.identity(&name, "", now);
1760 -
    let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge(&claim.token)).map_err(|e| anyhow::anyhow!("{e}"))?;
1761 -
    let (cert, issuer) = hub.certify(&claim.token, &id, &proof)?;
1762 -
    let state = state_of(heads);
1763 -
    let run = format!("{}/{}/flow/{}", cfg.hub.trim_end_matches('/'), claim.repo, claim.run_number);
1764 -
    Ok(oxy_core::receipt::SignedReceipt::sign(receipt.to_oxy(), repo.id()?, state, &run, &id, &sk).with_certificate(cert, issuer))
1765 -
}
1766 -
1767 864
/// The ObjectRef of the state whose heads are `heads` (Oxygen spec §10.1).
1768 865
pub fn state_of(heads: &[ChangeHash]) -> oxy_core::hash::ObjectRef {
1769 866
    use oxy_core::hash::{hash_object, ObjectType};
…
73 unchanged lines
1843 940
        with_retries(&FETCH_WAITS, || repo.fetch("origin").map(|_| ()).map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}")))?;
1844 941
        return Ok(repo);
1845 942
    }
1846 -
    // First job for this repository: clone with the job's token, which goes
1847 -
    // through the process environment, so one clone at a time.
1848 -
    let _clone = CLONE.lock().unwrap_or_else(|p| p.into_inner());
943 +
    // First job for this repository: clone with the job's token.
1849 944
    std::env::set_var("OXY_TOKEN", token);
1850 945
    if let Some(ca) = &cfg.ca {
1851 946
        std::env::set_var("OXY_CA", ca);
…
14 unchanged lines
1866 961
1867 962
#[cfg(test)]
1868 963
mod tests {
1869 -
1870 -
    #[test]
1871 -
    fn running_jobs_show_while_they_run() {
1872 -
        let a = super::Running::start(-41, "andy/demo #7 test".into());
1873 -
        let b = super::Running::start(-40, "andy/demo #7 lint".into());
1874 -
        let now = super::running();
1875 -
        assert!(now.contains(&"andy/demo #7 test".to_string()) && now.contains(&"andy/demo #7 lint".to_string()));
1876 -
        drop(a);
1877 -
        assert!(!super::running().contains(&"andy/demo #7 test".to_string()));
1878 -
        drop(b);
1879 -
        assert!(!super::running().iter().any(|r| r.starts_with("andy/demo #7")));
1880 -
    }
1881 -
1882 -
    /// #98: register keeps a copy of the CA in the home, by absolute path,
1883 -
    /// and keeping the home's own copy again is a no-op.
1884 -
    #[test]
1885 -
    fn the_ca_is_kept_in_the_home_by_absolute_path() {
1886 -
        let home = tempfile::tempdir().unwrap();
1887 -
        let elsewhere = tempfile::tempdir().unwrap();
1888 -
        let given = elsewhere.path().join("ca.pem");
1889 -
        std::fs::write(&given, "PEM").unwrap();
1890 -
        let kept = super::keep_ca(home.path(), &given).unwrap();
1891 -
        assert!(kept.is_absolute() && kept.ends_with(crate::home::CA_PEM), "{kept:?}");
1892 -
        assert_eq!(std::fs::read_to_string(&kept).unwrap(), "PEM");
1893 -
        std::fs::remove_file(&given).unwrap();
1894 -
        assert_eq!(super::keep_ca(home.path(), &kept).unwrap(), kept, "the home's copy again");
1895 -
        assert_eq!(std::fs::read_to_string(&kept).unwrap(), "PEM");
1896 -
    }
1897 -
1898 -
    #[test]
1899 -
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
1900 -
        let dir = tempfile::tempdir().unwrap();
1901 -
        let bytes = b"the reviewed plan";
1902 -
        let blob = Repo::blob_ref(bytes).to_hex();
1903 -
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
1904 -
            artifact: "tfplan".into(),
1905 -
            from: "plan".into(),
1906 -
            path: path.into(),
1907 -
            blob: blob.into(),
1908 -
        };
1909 -
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
1910 -
        assert_eq!(hex::encode(got), blob);
1911 -
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
1912 -
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
1913 -
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
1914 -
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
1915 -
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
1916 -
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1917 -
        }
1918 -
    }
1919 -
1920 964
    use super::*;
1921 965
1922 966
    #[test]
…
20 unchanged lines
1943 987
        std::fs::remove_file(home.image_manifest(&hash)).unwrap();
1944 988
        std::fs::write(home.rootfs(&hash), b"changed").unwrap();
1945 989
        assert!(problem(&sb, None).unwrap().contains("does not verify"));
1946 -
    }
1947 -
1948 -
    #[test]
1949 -
    fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() {
1950 -
        // An image fetched or imported with no recorded architecture, built
1951 -
        // for riscv64: the runner reads its shell, records it, and declines
1952 -
        // instead of running it and failing (spec §11.2, §11.5).
1953 -
        let dir = tempfile::tempdir().unwrap();
1954 -
        let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap();
1955 -
        let tree = dir.path().join("tree");
1956 -
        std::fs::create_dir_all(tree.join("bin")).unwrap();
1957 -
        let mut elf = vec![0u8; 64];
1958 -
        elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
1959 -
        elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes());
1960 -
        std::fs::write(tree.join("bin/sh"), &elf).unwrap();
1961 -
        let img = dir.path().join("rootfs.ext4");
1962 -
        let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
1963 -
        if !made.is_ok_and(|s| s.success()) {
1964 -
            eprintln!("mkfs.ext4 not found: skipped");
1965 -
            return;
1966 -
        }
1967 -
        let hash = crate::home::hash_file(&img).unwrap();
1968 -
        std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap();
1969 -
        std::fs::rename(&img, home.rootfs(&hash)).unwrap();
1970 -
        assert!(!home.image_manifest(&hash).exists());
1971 -
        let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
1972 -
            "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated"
1973 -
        }))
1974 -
        .unwrap();
1975 -
        assert_eq!(
1976 -
            sandbox_problem(&home, &sb, "x86_64", None).unwrap(),
1977 -
            format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])
1978 -
        );
1979 -
        assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it");
1980 -
        // Learned: recorded once, and reported with the runner's claims.
1981 -
        let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap();
1982 -
        assert_eq!(manifest["arch"], "riscv64");
1983 -
        assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64"));
1984 990
    }
1985 991
1986 992
    #[test]
…
29 unchanged lines
2016 1022
2017 1023
    /// A hub that answers each request with the next of `codes`, counting them.
2018 1024
    fn fake_hub(codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
2019 -
        fake_hub_on(std::net::TcpListener::bind("127.0.0.1:0").unwrap(), codes)
2020 -
    }
2021 -
2022 -
    /// A hub at `listener` answering each request with the next of `codes`.
2023 -
    fn fake_hub_on(listener: std::net::TcpListener, codes: Vec<u16>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
2024 1025
        use std::io::{Read, Write};
1026 +
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2025 1027
        let addr = listener.local_addr().unwrap();
2026 1028
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2027 1029
        let count = seen.clone();
…
95 unchanged lines
2123 1125
        assert_eq!(cfg.labels, ["gpu"]);
2124 1126
    }
2125 1127
2126 -
    /// A hub that answers certify with a certificate `sign_as` signs for
2127 -
    /// `cert_key`, and publishes `published` as its key: two requests.
2128 -
    fn fake_certify_hub(
2129 -
        sign_as: (oxy_core::identity::Identity, oxy_core::identity::SecretKey),
2130 -
        cert_key: oxy_core::identity::Identity,
2131 -
        published: oxy_core::identity::Identity,
2132 -
    ) -> String {
2133 -
        use std::io::{Read, Write};
2134 -
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2135 -
        let addr = listener.local_addr().unwrap();
2136 -
        std::thread::spawn(move || {
2137 -
            for _ in 0..2 {
2138 -
                let Ok((mut s, _)) = listener.accept() else { return };
2139 -
                let mut req = Vec::new();
2140 -
                let mut buf = [0u8; 65536];
2141 -
                loop {
2142 -
                    let n = s.read(&mut buf).unwrap_or(0);
2143 -
                    if n == 0 {
2144 -
                        break;
2145 -
                    }
2146 -
                    req.extend_from_slice(&buf[..n]);
2147 -
                    let text = String::from_utf8_lossy(&req);
2148 -
                    if let Some(end) = text.find("\r\n\r\n") {
2149 -
                        let len = text[..end]
2150 -
                            .lines()
2151 -
                            .find_map(|l| {
2152 -
                                l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))
2153 -
                            })
2154 -
                            .unwrap_or(0);
2155 -
                        if req.len() >= end + 4 + len {
2156 -
                            break;
2157 -
                        }
2158 -
                    }
2159 -
                }
2160 -
                let line = String::from_utf8_lossy(&req).lines().next().unwrap_or("").to_string();
2161 -
                let body: Vec<u8> = if line.contains("/keyless/certify") {
2162 -
                    let cert = oxy_core::cert::SignedCertificate::sign(
2163 -
                        oxy_core::cert::Certificate {
2164 -
                            key: cert_key.clone(),
2165 -
                            issuer: sign_as.0.hash(),
2166 -
                            subject: "repo:andy/demo:bookmark:main".into(),
2167 -
                            claims: vec![],
2168 -
                            scope: oxy_core::identity::Scope::SEAL,
2169 -
                            not_before: 1,
2170 -
                            expires: 600_001,
2171 -
                            grant: None,
2172 -
                        },
2173 -
                        &sign_as.0,
2174 -
                        &sign_as.1,
2175 -
                    );
2176 -
                    serde_json::json!({ "certificate": oxy_core::dsse::base64(&cert.encode()) }).to_string().into_bytes()
2177 -
                } else {
2178 -
                    published.encode()
2179 -
                };
2180 -
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
2181 -
                let _ = s.write_all(&body);
2182 -
            }
2183 -
        });
2184 -
        format!("http://{addr}")
2185 -
    }
2186 -
2187 -
    /// keyless (oxygenhub#241): the runner takes a certificate only when it
2188 -
    /// is for its fresh key and signed by the key the hub publishes.
2189 -
    #[test]
2190 -
    fn a_certificate_is_taken_only_for_this_key_from_the_hubs_key() {
2191 -
        use oxy_core::identity::SecretKey;
2192 -
        let hub_sk = SecretKey::generate();
2193 -
        let hub_id = hub_sk.identity("hub", "", 1);
2194 -
        let sk = SecretKey::generate();
2195 -
        let id = sk.identity("earth-1 job 7", "", 1);
2196 -
        let proof = sk.sign_challenge(&id.hash(), &oxy_core::cert::certify_challenge("flowjob_x")).unwrap();
2197 -
2198 -
        let base = fake_certify_hub((hub_id.clone(), hub_sk.clone()), id.clone(), hub_id.clone());
2199 -
        let (cert, issuer) = hub_at(base).certify("flowjob_x", &id, &proof).unwrap();
2200 -
        assert_eq!((cert.cert.key_id(), issuer.hash()), (id.hash(), hub_id.hash()));
2201 -
2202 -
        let mallory_sk = SecretKey::generate();
2203 -
        let mallory = mallory_sk.identity("mallory", "", 1);
2204 -
        let base = fake_certify_hub((mallory.clone(), mallory_sk), id.clone(), hub_id.clone());
2205 -
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
2206 -
        assert!(e.contains("not signed by the hub's published key"), "{e}");
2207 -
2208 -
        let other = SecretKey::generate().identity("other", "", 1);
2209 -
        let base = fake_certify_hub((hub_id.clone(), hub_sk), other, hub_id);
2210 -
        let e = hub_at(base).certify("flowjob_x", &id, &proof).unwrap_err().to_string();
2211 -
        assert!(e.contains("another key"), "{e}");
2212 -
    }
2213 -
2214 1128
    fn hub_at(base: String) -> Hub {
2215 1129
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
2216 1130
    }
2217 1131
2218 -
    /// oxygenhub#322: an offered build is kept only if it is the bytes the
2219 -
    /// hub attests; a peer or a proxy cannot change what is installed.
2220 -
    #[test]
2221 -
    fn a_build_is_kept_only_if_it_matches_the_hubs_blake3() {
2222 -
        let (body, other) = (b"#!flow build 0.3.2".to_vec(), b"#!something else!!".to_vec());
2223 -
        let serve = |bytes: Vec<u8>| {
2224 -
            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2225 -
            let addr = listener.local_addr().unwrap();
2226 -
            std::thread::spawn(move || {
2227 -
                use std::io::{Read, Write};
2228 -
                let (mut s, _) = listener.accept().unwrap();
2229 -
                let mut buf = [0u8; 4096];
2230 -
                let _ = s.read(&mut buf);
2231 -
                let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len());
2232 -
                let _ = s.write_all(&bytes);
2233 -
            });
2234 -
            format!("http://{addr}")
2235 -
        };
2236 -
        let offer = flow_core::api::UpdateOffer {
2237 -
            version: "0.3.2".into(),
2238 -
            asset: "flow-linux-x86_64".into(),
2239 -
            url: "/andy/oxygenflow/asset/1/flow-linux-x86_64".into(),
2240 -
            blake3: blake3::hash(&body).to_hex().to_string(),
2241 -
            size: body.len() as u64,
2242 -
            ..Default::default()
2243 -
        };
2244 -
        let dir = tempfile::tempdir().unwrap();
2245 -
        let to = dir.path().join("flow-0.3.2");
2246 -
        hub_at(serve(body.clone())).download_build(&offer, &to).unwrap();
2247 -
        assert_eq!(std::fs::read(&to).unwrap(), body);
2248 -
        std::fs::remove_file(&to).unwrap();
2249 -
        let e = hub_at(serve(other)).download_build(&offer, &to).unwrap_err();
2250 -
        assert!(e.to_string().contains("does not match what the hub attests"), "{e:#}");
2251 -
        assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing of it is left");
2252 -
    }
2253 -
2254 1132
    /// oxygenhub#71: a job's result is offered until the hub takes it,
2255 1133
    /// through server errors, and a refusal ends the trying at once.
2256 1134
    #[test]
…
6 unchanged lines
2263 1141
            error: None,
2264 1142
            peak_disk_mib: None,
2265 1143
            peak_memory_mib: None,
2266 -
            phases: Default::default(),
2267 1144
        };
2268 1145
        let quick = Duration::from_millis(10);
2269 1146
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
…
20 unchanged lines
2290 1167
        let (base, seen) = fake_hub(vec![401]);
2291 1168
        assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err());
2292 1169
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
2293 -
    }
2294 -
2295 -
    static NOT_LEAVING: std::sync::Mutex<Option<Leave>> = std::sync::Mutex::new(None);
2296 -
2297 -
    /// oxygenflow#76: once the runner leaves, a job's sink says it is
2298 -
    /// cancelled, having sent what the job said so far, and the job is
2299 -
    /// handed back to the hub.
2300 -
    #[test]
2301 -
    fn a_leaving_runner_stops_its_job_and_hands_it_back() {
2302 -
        use microvm::Sink;
2303 -
        let leaving = std::sync::Mutex::new(None);
2304 -
        let (base, seen) = fake_hub(vec![204, 204]);
2305 -
        let hub = hub_at(base);
2306 -
        let mut sink = LogSink {
2307 -
            hub: &hub,
2308 -
            job: 7,
2309 -
            buf: Vec::new(),
2310 -
            last: Instant::now(),
2311 -
            gone: false,
2312 -
            leaving: &leaving,
2313 -
            started: Instant::now(),
2314 -
            expected: None,
2315 -
        };
2316 -
        sink.buf.push(Record { t: 1, stream: "out".into(), text: "step 1".into() });
2317 -
        assert!(!sink.cancelled());
2318 -
        *leaving.lock().unwrap() = Some(Leave { reason: "off Wi-Fi".into(), by: Instant::now() });
2319 -
        assert!(sink.cancelled());
2320 -
        assert!(sink.buf.is_empty(), "flushed before the job stops");
2321 -
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
2322 -
        hub.release(7, "off Wi-Fi").unwrap();
2323 -
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
2324 1170
    }
2325 1171
2326 -
    /// oxygenflow#76: with notice, a job the hub expects to finish within
2327 -
    /// it runs on; one that will not, or whose time is unknown, is handed
2328 -
    /// back at once, and any job still running when the notice is up.
2329 -
    #[test]
2330 -
    fn a_job_that_fits_the_notice_finishes_and_the_others_go_back() {
2331 -
        use microvm::Sink;
2332 -
        let (base, _) = fake_hub(vec![204; 8]);
2333 -
        let hub = hub_at(base);
2334 -
        let leaving = std::sync::Mutex::new(Some(Leave { reason: "off charge".into(), by: Instant::now() + Duration::from_secs(60) }));
2335 -
        let sink = |expected: Option<u64>, ran: u64| LogSink {
2336 -
            hub: &hub,
2337 -
            job: 1,
2338 -
            buf: Vec::new(),
2339 -
            last: Instant::now(),
2340 -
            gone: false,
2341 -
            leaving: &leaving,
2342 -
            started: Instant::now() - Duration::from_secs(ran),
2343 -
            expected: expected.map(Duration::from_secs),
2344 -
        };
2345 -
        assert!(!sink(Some(70), 30).cancelled(), "40 s left of it, within a minute: it finishes");
2346 -
        assert!(sink(Some(600), 30).cancelled(), "nearly ten minutes left: handed back");
2347 -
        assert!(sink(None, 0).cancelled(), "no idea how long: handed back");
2348 -
        *leaving.lock().unwrap() = Some(Leave { reason: "off charge".into(), by: Instant::now() - Duration::from_secs(1) });
2349 -
        assert!(sink(Some(70), 30).cancelled(), "the notice is up: handed back whatever was expected");
2350 -
    }
2351 -
2352 1172
    /// oxygenflow#32: a quiet job asks the hub every few seconds whether
2353 1173
    /// it still runs; a 409 marks it cancelled, and nothing more is sent.
2354 1174
    #[test]
…
3 unchanged lines
2358 1178
        let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1);
2359 1179
        let (base, seen) = fake_hub(vec![204]);
2360 1180
        let hub = hub_at(base);
2361 -
        let mut sink = LogSink {
2362 -
            hub: &hub,
2363 -
            job: 1,
2364 -
            buf: Vec::new(),
2365 -
            last: quiet,
2366 -
            gone: false,
2367 -
            leaving: &NOT_LEAVING,
2368 -
            started: Instant::now(),
2369 -
            expected: None,
2370 -
        };
1181 +
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
2371 1182
        assert!(!sink.cancelled());
2372 1183
        assert!(!sink.cancelled(), "asked again only after the heartbeat");
2373 1184
        assert_eq!(n(&seen), 1);
2374 1185
2375 1186
        let (base, seen) = fake_hub(vec![409]);
2376 1187
        let hub = hub_at(base);
2377 -
        let mut sink = LogSink {
2378 -
            hub: &hub,
2379 -
            job: 1,
2380 -
            buf: Vec::new(),
2381 -
            last: quiet,
2382 -
            gone: false,
2383 -
            leaving: &NOT_LEAVING,
2384 -
            started: Instant::now(),
2385 -
            expected: None,
2386 -
        };
1188 +
        let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false };
2387 1189
        assert!(sink.cancelled());
2388 1190
        sink.record(Record { t: 0, stream: "out".into(), text: "late".into() });
2389 1191
        sink.flush();
2390 1192
        assert!(sink.cancelled());
2391 1193
        assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job");
2392 -
    }
2393 -
2394 -
    /// oxygenflow#49: a hub that is away for a while (a deploy restarting
2395 -
    /// it) costs a job's uploads a wait, not the upload, and costs its log
2396 -
    /// no lines.
2397 -
    #[test]
2398 -
    fn a_job_outlasts_a_hub_restart() {
2399 -
        let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst);
2400 -
        // A port nobody listens on yet: the hub is restarting.
2401 -
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2402 -
        let base = format!("http://127.0.0.1:{port}");
2403 -
        let hub = hub_at(base.clone());
2404 -
2405 -
        // Log lines sent while it is away are kept, and go when it is back.
2406 -
        let mut sink = LogSink {
2407 -
            hub: &hub,
2408 -
            job: 1,
2409 -
            buf: Vec::new(),
2410 -
            last: Instant::now(),
2411 -
            gone: false,
2412 -
            leaving: &NOT_LEAVING,
2413 -
            started: Instant::now(),
2414 -
            expected: None,
2415 -
        };
2416 -
        for i in 0..450 {
2417 -
            sink.buf.push(Record { t: i, stream: "out".into(), text: format!("line {i}") });
2418 -
        }
2419 -
        sink.send();
2420 -
        assert_eq!(sink.buf.len(), 450, "nothing lost while the hub is away");
2421 -
        let (_, seen) = fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![204, 204, 204]);
2422 -
        sink.send();
2423 -
        assert!(sink.buf.is_empty() && !sink.gone);
2424 -
        assert_eq!(n(&seen), 3, "in batches of 200: 200, 200 and 50");
2425 -
2426 -
        // An upload waits for the hub to come back, through refused
2427 -
        // connections and server errors alike.
2428 -
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2429 -
        let url = format!("http://127.0.0.1:{port}/x");
2430 -
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build();
2431 -
        let later = std::thread::spawn(move || {
2432 -
            std::thread::sleep(Duration::from_millis(150));
2433 -
            fake_hub_on(std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(), vec![502, 204])
2434 -
        });
2435 -
        let quick = [Duration::from_millis(50); 20];
2436 -
        let r = retrying(&url, &quick, true, || agent.post(&url).send_string("{}").map_err(Box::new)).unwrap();
2437 -
        assert_eq!(r.status(), 204);
2438 -
        assert_eq!(n(&later.join().unwrap().1), 2, "a 502 from the proxy, then the hub");
2439 -
        // Without `connection`, a hub that cannot be reached ends it at once.
2440 -
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2441 -
        let url = format!("http://127.0.0.1:{port}/x");
2442 -
        assert!(retrying(&url, &quick, false, || agent.post(&url).send_string("{}").map_err(Box::new)).is_err());
2443 -
        assert!(PATIENT_DELAYS.iter().sum::<Duration>() >= Duration::from_secs(180), "longer than a hub takes to restart");
2444 -
    }
2445 -
2446 -
    /// oxygenflow#50: the first stop drains (the second, not called here,
2447 -
    /// exits at once).
2448 -
    #[test]
2449 -
    fn a_stop_drains_the_runner() {
2450 -
        assert!(!stopping());
2451 -
        on_stop(libc::SIGTERM);
2452 -
        assert!(stopping(), "told to stop, it claims nothing more");
2453 -
        // An embedded runner starts afresh, and stop() drains it the same way.
2454 -
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
2455 -
        stop();
2456 -
        assert!(stopping());
2457 -
        STOPS.store(0, std::sync::atomic::Ordering::SeqCst);
2458 1194
    }
2459 1195
2460 -
    /// Lines kept for a hub that stays away are bounded, saying how many went.
2461 1196
    #[test]
2462 -
    fn unsent_log_lines_are_bounded() {
2463 -
        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
2464 -
        let hub = hub_at(format!("http://127.0.0.1:{port}"));
2465 -
        let mut sink = LogSink {
2466 -
            hub: &hub,
2467 -
            job: 1,
2468 -
            buf: Vec::new(),
2469 -
            last: Instant::now(),
2470 -
            gone: false,
2471 -
            leaving: &NOT_LEAVING,
2472 -
            started: Instant::now(),
2473 -
            expected: None,
2474 -
        };
2475 -
        sink.buf = (0..MAX_UNSENT as u64 + 10).map(|i| Record { t: i, stream: "out".into(), text: String::new() }).collect();
2476 -
        sink.send();
2477 -
        assert_eq!(sink.buf.len(), MAX_UNSENT + 1);
2478 -
        assert_eq!(sink.buf[0].text, "(10 log lines lost while the hub could not be reached)");
2479 -
        assert_eq!(sink.buf[1].t, 10, "the oldest go first");
2480 -
    }
2481 -
2482 -
    #[test]
2483 1197
    fn the_budget_admits_jobs_that_fit() {
2484 1198
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
2485 1199
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
…
6 unchanged lines
2492 1206
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
2493 1207
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
2494 1208
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
2495 -
    }
2496 -
2497 -
    #[test]
2498 -
    fn a_claim_never_asks_for_more_memory_than_the_host_has_free() {
2499 -
        assert_eq!(claim_memory(None, Some(6541)), Some(6541), "idle: the host's free memory, not unlimited");
2500 -
        assert_eq!(claim_memory(Some(16_384), Some(6541)), Some(6541));
2501 -
        assert_eq!(claim_memory(Some(4096), Some(6541)), Some(4096), "the budget when it is smaller");
2502 -
        assert_eq!(claim_memory(None, None), None, "nothing known: no limit, as before");
2503 -
        assert_eq!(claim_memory(Some(4096), None), Some(4096));
2504 1209
    }
2505 1210
}
removed crates/flow/src/swarm.rs +0 −1041
1 -
//! The swarm (oxygenflow#47, docs/jetstream.md §8.1): a runner with a
2 -
//! `[runner.swarm]` section serves the image chunks it holds to its peers,
3 -
//! and fetches an image it lacks from its peers and the hub together.
4 -
//!
5 -
//! The server is a small HTTPS endpoint on the swarm address: a peer
6 -
//! shows the ticket its hub signed and gets a session, then asks which
7 -
//! chunks of an image this runner has and for chunks by hash. A chunk is
8 -
//! served from the chunk cache, or read out of a whole image by its
9 -
//! manifest. Every chunk is checked by the side that asked for it, so a
10 -
//! peer can only fail to help.
11 -
//!
12 -
//! Each runner makes its own TLS certificate on first use and reports its
13 -
//! BLAKE3 with its swarm address; the hub hands that on with the peer, and
14 -
//! a fetcher talks only to a peer that shows that certificate and holds
15 -
//! its key. Nobody on the network reads the chunks, tickets or sessions.
16 -
17 -
use crate::home::{Home, RunnerConfig};
18 -
use crate::images::{cache_path, chunk_cache, manifest_path, save_manifest, write_image, Client, Moved};
19 -
use anyhow::{bail, Context, Result};
20 -
use flow_core::api::{SwarmAddr, SwarmPeer};
21 -
use jet::images::{is_hash, Manifest};
22 -
use jet::swarm::{Bits, Pick, Picker, Source, Ticket};
23 -
use oxy_core::identity::Identity;
24 -
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
25 -
use rustls::crypto::CryptoProvider;
26 -
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
27 -
use rustls::{DigitallySignedStruct, SignatureScheme};
28 -
use std::collections::HashMap;
29 -
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
30 -
use std::net::{TcpListener, TcpStream};
31 -
use std::path::{Path, PathBuf};
32 -
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
33 -
use std::sync::{Arc, Mutex};
34 -
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
35 -
36 -
/// Connections served at once; more are told 503 and come back shortly.
37 -
const MAX_CONNS: usize = 16;
38 -
/// Connections past this are closed without a word.
39 -
const MAX_OPEN: usize = 4 * MAX_CONNS;
40 -
/// Requests in flight to one peer, and to the hub.
41 -
const PEER_WORKERS: usize = 4;
42 -
const HUB_WORKERS: usize = 8;
43 -
/// How often a fetcher asks its peers what they have.
44 -
const REFRESH: Duration = Duration::from_millis(500);
45 -
/// Hub failures a fetch survives before it gives up.
46 -
const HUB_FAILURES: usize = 5;
47 -
/// The largest request body the server reads: a ticket.
48 -
const MAX_BODY: usize = 64 * 1024;
49 -
/// Names the part files of chunks two sources fetch at once apart.
50 -
static PART: AtomicU64 = AtomicU64::new(0);
51 -
52 -
fn now_ms() -> u64 {
53 -
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
54 -
}
55 -
56 -
/// An image this runner offers: which of its needed chunks are here
57 -
/// (`None`: all of them, the image is whole) and which it is fetching now.
58 -
struct Offer {
59 -
    bits: Option<Bits>,
60 -
    fetching: Bits,
61 -
}
62 -
63 -
struct State {
64 -
    root: PathBuf,
65 -
    site: String,
66 -
    client: Client,
67 -
    /// The hub's identity, which signs tickets; fetched when first needed.
68 -
    hub: Mutex<Option<Identity>>,
69 -
    offers: Mutex<HashMap<String, Offer>>,
70 -
    /// Where a chunk lies in a whole image: the image, offset and length.
71 -
    located: Mutex<HashMap<String, (PathBuf, u64, u32)>>,
72 -
    /// Sessions: token to (peer name, expiry).
73 -
    sessions: Mutex<HashMap<String, (String, u64)>>,
74 -
    conns: AtomicUsize,
75 -
    /// The upload allowance: bytes in hand (negative: owed) and when it
76 -
    /// was last topped up.
77 -
    bucket: Mutex<(f64, Instant)>,
78 -
    rate: f64,
79 -
    tls: Arc<rustls::ServerConfig>,
80 -
}
81 -
82 -
/// This runner's place in the swarm.
83 -
pub struct Swarm {
84 -
    state: Arc<State>,
85 -
    pub addr: SwarmAddr,
86 -
}
87 -
88 -
impl Swarm {
89 -
    /// Start serving peers when the runner is configured for it.
90 -
    pub fn start(home: &Home, cfg: &RunnerConfig) -> Result<Option<Swarm>> {
91 -
        let Some(sc) = &cfg.swarm else { return Ok(None) };
92 -
        let listener = TcpListener::bind(&sc.listen).with_context(|| format!("swarm: listen on {}", sc.listen))?;
93 -
        let client = Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?;
94 -
        let hub = client.hub_identity().map_err(|e| eprintln!("swarm: the hub's key: {e:#}; trying again when a peer comes")).ok();
95 -
        let rate = sc.upload_mib_s.unwrap_or(50).max(1) as f64 * (1 << 20) as f64;
96 -
        let (cert, key) = certificate(&home.root)?;
97 -
        let fingerprint = blake3::hash(&cert).to_hex().to_string();
98 -
        let tls = server_tls(cert, key)?;
99 -
        let state = Arc::new(State {
100 -
            root: home.root.clone(),
101 -
            site: sc.site.clone(),
102 -
            client,
103 -
            hub: Mutex::new(hub),
104 -
            offers: Mutex::new(HashMap::new()),
105 -
            located: Mutex::new(HashMap::new()),
106 -
            sessions: Mutex::new(HashMap::new()),
107 -
            conns: AtomicUsize::new(0),
108 -
            bucket: Mutex::new((rate, Instant::now())),
109 -
            rate,
110 -
            tls,
111 -
        });
112 -
        // Whole images: offered from their manifests; one without a
113 -
        // manifest (imported here, not fetched) is scanned for one, in the
114 -
        // background and one at a time.
115 -
        let mut unscanned = Vec::new();
116 -
        for h in home.images() {
117 -
            match std::fs::read(manifest_path(&home.root, &h)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok()) {
118 -
                Some(m) if m.hash == h => offer_whole(&state, &m, &home.rootfs(&h)),
119 -
                _ => unscanned.push((h.clone(), home.rootfs(&h), home.image_arch(&h))),
120 -
            }
121 -
        }
122 -
        let scanning = state.clone();
123 -
        std::thread::spawn(move || {
124 -
            for (h, path, arch) in unscanned {
125 -
                match jet::images::scan_file(&path, arch, |_, _| Ok(())) {
126 -
                    Ok(m) if m.hash == h => {
127 -
                        save_manifest(&scanning.root, &m).ok();
128 -
                        offer_whole(&scanning, &m, &path);
129 -
                    }
130 -
                    Ok(_) => eprintln!("swarm: {} does not hold the image its name says; not offering it", path.display()),
131 -
                    Err(e) => eprintln!("swarm: scan {}: {e}", path.display()),
132 -
                }
133 -
            }
134 -
        });
135 -
        let serving = state.clone();
136 -
        std::thread::spawn(move || accept(listener, serving));
137 -
        let addr = SwarmAddr { addr: sc.advertise.clone().unwrap_or_else(|| sc.listen.clone()), site: sc.site.clone(), cert: fingerprint };
138 -
        eprintln!(
139 -
            "swarm: serving image chunks on {} over TLS (certificate {}, site {:?}, up to {} MiB/s)",
140 -
            sc.listen,
141 -
            &addr.cert[..12],
142 -
            sc.site,
143 -
            sc.upload_mib_s.unwrap_or(50)
144 -
        );
145 -
        Ok(Some(Swarm { state, addr }))
146 -
    }
147 -
148 -
    /// Fetch image `hash` into `home` from the peers and the hub (§8.1):
149 -
    /// the rarest chunks from peers first, the hub for what no peer has.
150 -
    /// `Ok(None)` when the hub does not hold it.
151 -
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
152 -
        let start = Instant::now();
153 -
        let client = &self.state.client;
154 -
        let Some(m) = client.manifest(hash)? else { return Ok(None) };
155 -
        if m.hash != hash {
156 -
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
157 -
        }
158 -
        save_manifest(&home.root, &m)?;
159 -
        let needed: Vec<String> = m.needed().into_iter().map(String::from).collect();
160 -
        let cache = chunk_cache(home);
161 -
        let mut have = Bits::new(needed.len());
162 -
        for (i, h) in needed.iter().enumerate() {
163 -
            if cache_path(&cache, h).is_file() {
164 -
                have.set(i);
165 -
            }
166 -
        }
167 -
        self.state.offers.lock().unwrap().insert(hash.into(), Offer { bits: Some(have.clone()), fetching: Bits::new(needed.len()) });
168 -
        let announced = client
169 -
            .announce(hash, "fetching")
170 -
            .map_err(|e| eprintln!("swarm: announce {}: {e:#}; fetching from the hub alone", &hash[..12]))
171 -
            .ok();
172 -
        let seed = now_ms() ^ (std::process::id() as u64) << 32;
173 -
        let picker = Mutex::new(Picker::new(needed.len(), have, seed));
174 -
        let peers: Mutex<Vec<Arc<Peer>>> = Mutex::new(Vec::new());
175 -
        if let Some(a) = &announced {
176 -
            self.join(&a.peers, &a.ticket, hash, needed.len(), &peers, &picker);
177 -
        }
178 -
        let (bytes, peer_bytes, got) = (AtomicU64::new(0), AtomicU64::new(0), AtomicUsize::new(0));
179 -
        let (failed, hub_failures) = (Mutex::new(None::<anyhow::Error>), AtomicUsize::new(0));
180 -
        {
181 -
            let (picker, peers, needed, cache) = (&picker, &peers, &needed, &cache);
182 -
            let (bytes, peer_bytes, got, failed, hub_failures) = (&bytes, &peer_bytes, &got, &failed, &hub_failures);
183 -
            let (state, m) = (&self.state, &m);
184 -
            let worker = move |src: Source| loop {
185 -
                if failed.lock().unwrap().is_some() {
186 -
                    break;
187 -
                }
188 -
                let peer = match src {
189 -
                    Source::Peer(p) => Some(peers.lock().unwrap()[p].clone()),
190 -
                    Source::Hub => None,
191 -
                };
192 -
                if peer.as_ref().is_some_and(|p| !p.alive.load(Ordering::Relaxed)) {
193 -
                    break;
194 -
                }
195 -
                // Taken apart from the match, so the lock is not held
196 -
                // while waiting.
197 -
                let pick = picker.lock().unwrap().pick(src);
198 -
                let i = match pick {
199 -
                    Pick::Done => break,
200 -
                    Pick::Wait => {
201 -
                        std::thread::sleep(Duration::from_millis(20));
202 -
                        continue;
203 -
                    }
204 -
                    Pick::Chunk(i) => i,
205 -
                };
206 -
                if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
207 -
                    o.fetching.set(i);
208 -
                }
209 -
                let h = &needed[i];
210 -
                let fetched = match &peer {
211 -
                    Some(p) => self.peer_chunk(p, h),
212 -
                    None => client.chunk(h),
213 -
                };
214 -
                let kept = fetched.and_then(|b| {
215 -
                    if blake3::hash(&b).to_hex().as_str() != h {
216 -
                        bail!("chunk {} came back with other bytes", &h[..12]);
217 -
                    }
218 -
                    let p = cache_path(cache, h);
219 -
                    std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
220 -
                    let tmp = p.with_extension(format!("part{}", PART.fetch_add(1, Ordering::Relaxed)));
221 -
                    std::fs::write(&tmp, &b)?;
222 -
                    std::fs::rename(&tmp, &p)?;
223 -
                    Ok(b.len() as u64)
224 -
                });
225 -
                match kept {
226 -
                    Ok(n) => {
227 -
                        if let Some(Offer { bits: Some(b), fetching }) = state.offers.lock().unwrap().get_mut(&m.hash) {
228 -
                            b.set(i);
229 -
                            fetching.clear(i);
230 -
                        }
231 -
                        picker.lock().unwrap().got(i);
232 -
                        bytes.fetch_add(n, Ordering::Relaxed);
233 -
                        got.fetch_add(1, Ordering::Relaxed);
234 -
                        if peer.is_some() {
235 -
                            peer_bytes.fetch_add(n, Ordering::Relaxed);
236 -
                        }
237 -
                    }
238 -
                    Err(e) => {
239 -
                        picker.lock().unwrap().failed(i);
240 -
                        if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
241 -
                            o.fetching.clear(i);
242 -
                        }
243 -
                        match (&peer, src) {
244 -
                            // A busy peer is asked again shortly.
245 -
                            (Some(_), _) if e.is::<Busy>() => std::thread::sleep(Duration::from_millis(200)),
246 -
                            (Some(p), Source::Peer(idx)) => {
247 -
                                eprintln!("swarm: peer {} failed ({e:#}); carrying on without it", p.name);
248 -
                                p.alive.store(false, Ordering::Relaxed);
249 -
                                picker.lock().unwrap().drop_peer(idx);
250 -
                                break;
251 -
                            }
252 -
                            _ => {
253 -
                                if hub_failures.fetch_add(1, Ordering::Relaxed) + 1 >= HUB_FAILURES {
254 -
                                    *failed.lock().unwrap() = Some(e);
255 -
                                    break;
256 -
                                }
257 -
                                std::thread::sleep(Duration::from_millis(500));
258 -
                            }
259 -
                        }
260 -
                    }
261 -
                }
262 -
            };
263 -
            // An image only runners hold (jetstream.md §8.1.1): the hub
264 -
            // has its manifest but none of its chunks, so only peers are asked.
265 -
            let hub_seeds = announced.as_ref().is_none_or(|a| a.seed != Some(false));
266 -
            std::thread::scope(|s| {
267 -
                if hub_seeds {
268 -
                    for _ in 0..HUB_WORKERS {
269 -
                        s.spawn(move || worker(Source::Hub));
270 -
                    }
271 -
                }
272 -
                let mut spawned = 0;
273 -
                let mut spawn_new = || {
274 -
                    let n = peers.lock().unwrap().len();
275 -
                    for p in spawned..n {
276 -
                        for _ in 0..PEER_WORKERS {
277 -
                            s.spawn(move || worker(Source::Peer(p)));
278 -
                        }
279 -
                    }
280 -
                    spawned = n;
281 -
                };
282 -
                spawn_new();
283 -
                // Keep the peers' maps fresh, and find new peers while
284 -
                // fetching, until every chunk is here.
285 -
                let mut asked_hub = Instant::now();
286 -
                let interval = Duration::from_secs(announced.as_ref().map(|a| a.interval_s).unwrap_or(30).max(5));
287 -
                let mut last = Instant::now();
288 -
                let mut peerless = Instant::now();
289 -
                while picker.lock().unwrap().missing() > 0 && failed.lock().unwrap().is_none() {
290 -
                    std::thread::sleep(Duration::from_millis(100));
291 -
                    if !hub_seeds {
292 -
                        if peers.lock().unwrap().iter().any(|p| p.alive.load(Ordering::Relaxed)) {
293 -
                            peerless = Instant::now();
294 -
                        } else if peerless.elapsed() >= interval * 2 {
295 -
                            *failed.lock().unwrap() = Some(anyhow::anyhow!(
296 -
                                "image {} is held only by runners, and none in this site could serve it for {} s; send it to the hub with `flow rootfs push`",
297 -
                                &hash[..12],
298 -
                                (interval * 2).as_secs()
299 -
                            ));
300 -
                        }
301 -
                    }
302 -
                    if last.elapsed() < REFRESH {
303 -
                        continue;
304 -
                    }
305 -
                    last = Instant::now();
306 -
                    let now: Vec<(usize, Arc<Peer>)> =
307 -
                        peers.lock().unwrap().iter().cloned().enumerate().filter(|(_, p)| p.alive.load(Ordering::Relaxed)).collect();
308 -
                    for (idx, p) in now {
309 -
                        match self.peer_have(&p, hash, needed.len()) {
310 -
                            Ok((bits, fetching)) => picker.lock().unwrap().peer_fetching(idx, bits, fetching),
311 -
                            Err(e) if e.is::<Busy>() => {}
312 -
                            Err(_) => {
313 -
                                p.alive.store(false, Ordering::Relaxed);
314 -
                                picker.lock().unwrap().drop_peer(idx);
315 -
                            }
316 -
                        }
317 -
                    }
318 -
                    if announced.is_some() && asked_hub.elapsed() >= interval {
319 -
                        asked_hub = Instant::now();
320 -
                        if let Ok(a) = client.announce(hash, "fetching") {
321 -
                            self.join(&a.peers, &a.ticket, hash, needed.len(), peers, picker);
322 -
                            spawn_new();
323 -
                        }
324 -
                    }
325 -
                }
326 -
            });
327 -
        }
328 -
        if let Some(e) = failed.into_inner().unwrap() {
329 -
            client.announce(hash, "stopped").ok();
330 -
            return Err(e);
331 -
        }
332 -
        write_image(home, &m)?;
333 -
        offer_whole(&self.state, &m, &home.rootfs(hash));
334 -
        client.announce(hash, "done").ok();
335 -
        Ok(Some(Moved {
336 -
            chunks: got.into_inner(),
337 -
            bytes: bytes.into_inner(),
338 -
            peer_bytes: peer_bytes.into_inner(),
339 -
            elapsed: start.elapsed(),
340 -
        }))
341 -
    }
342 -
343 -
    /// Admit this runner to the peers it does not know yet: a session from
344 -
    /// each, and what each has.
345 -
    fn join(&self, found: &[SwarmPeer], ticket: &str, hash: &str, len: usize, peers: &Mutex<Vec<Arc<Peer>>>, picker: &Mutex<Picker>) {
346 -
        for sp in found {
347 -
            if peers.lock().unwrap().iter().any(|p| p.runner == sp.runner) {
348 -
                continue;
349 -
            }
350 -
            if !is_hash(&sp.cert) {
351 -
                eprintln!("swarm: peer {} gave no certificate (an older flow); not asking it", sp.name);
352 -
                continue;
353 -
            }
354 -
            let joined = hello(&sp.addr, &sp.cert, ticket).and_then(|(agent, session)| {
355 -
                let p = Peer {
356 -
                    runner: sp.runner,
357 -
                    name: sp.name.clone(),
358 -
                    base: format!("https://{}/swarm/v1", sp.addr),
359 -
                    agent,
360 -
                    session,
361 -
                    alive: AtomicBool::new(true),
362 -
                };
363 -
                let (bits, fetching) = self.peer_have(&p, hash, len)?;
364 -
                Ok((p, bits, fetching))
365 -
            });
366 -
            match joined {
367 -
                Ok((p, bits, fetching)) => {
368 -
                    let mut list = peers.lock().unwrap();
369 -
                    picker.lock().unwrap().peer_fetching(list.len(), bits, fetching);
370 -
                    list.push(Arc::new(p));
371 -
                }
372 -
                Err(e) => eprintln!("swarm: peer {} ({}): {e:#}", sp.name, sp.addr),
373 -
            }
374 -
        }
375 -
    }
376 -
377 -
    /// What a peer has of image `hash`, and what it is fetching now:
378 -
    /// nothing yet when it has not begun (the tracker may name it a moment
379 -
    /// before it starts).
380 -
    fn peer_have(&self, p: &Peer, hash: &str, len: usize) -> Result<(Bits, Bits)> {
381 -
        let url = format!("{}/images/{hash}/have", p.base);
382 -
        let r = match p.agent.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call() {
383 -
            Ok(r) => r,
384 -
            Err(ureq::Error::Status(404, _)) => return Ok((Bits::new(len), Bits::new(len))),
385 -
            Err(e) => return Err(peer_error(&url, e)),
386 -
        };
387 -
        let mut body = Vec::new();
388 -
        r.into_reader().take(len as u64 / 4 + 64).read_to_end(&mut body)?;
389 -
        if body == b"complete" {
390 -
            return Ok((Bits::full(len), Bits::new(len)));
391 -
        }
392 -
        let (have, fetching) = body.split_at(len.div_ceil(8).min(body.len()));
393 -
        Ok((Bits::from_bytes(len, have), Bits::from_bytes(len, fetching)))
394 -
    }
395 -
396 -
    fn peer_chunk(&self, p: &Peer, hash: &str) -> Result<Vec<u8>> {
397 -
        let url = format!("{}/chunks/{hash}", p.base);
398 -
        let r = p.agent.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call().map_err(|e| peer_error(&url, e))?;
399 -
        let mut out = Vec::new();
400 -
        r.into_reader().take(oxy_core::chunk::MAX_SIZE as u64 + 1).read_to_end(&mut out)?;
401 -
        Ok(out)
402 -
    }
403 -
}
404 -
405 -
/// A peer this fetch asks for chunks, over a connection that trusts only
406 -
/// its certificate.
407 -
struct Peer {
408 -
    runner: i64,
409 -
    name: String,
410 -
    base: String,
411 -
    agent: ureq::Agent,
412 -
    session: String,
413 -
    alive: AtomicBool,
414 -
}
415 -
416 -
/// A peer at its connection limit: ask it again shortly.
417 -
#[derive(Debug)]
418 -
struct Busy;
419 -
420 -
impl std::fmt::Display for Busy {
421 -
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422 -
        f.write_str("the peer is busy")
423 -
    }
424 -
}
425 -
426 -
impl std::error::Error for Busy {}
427 -
428 -
fn peer_error(url: &str, e: ureq::Error) -> anyhow::Error {
429 -
    match e {
430 -
        ureq::Error::Status(503, _) => anyhow::Error::new(Busy),
431 -
        ureq::Error::Status(code, r) => anyhow::anyhow!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default().trim()),
432 -
        e => anyhow::anyhow!("{url}: {e}"),
433 -
    }
434 -
}
435 -
436 -
/// Show `ticket` to the peer at `addr`, trusting only the certificate
437 -
/// `cert`: a connection to it and a session.
438 -
fn hello(addr: &str, cert: &str, ticket: &str) -> Result<(ureq::Agent, String)> {
439 -
    let agent = peer_agent(cert)?;
440 -
    let url = format!("https://{addr}/swarm/v1/hello");
441 -
    let r: serde_json::Value = agent.post(&url).send_string(ticket).map_err(|e| peer_error(&url, e))?.into_json()?;
442 -
    let session = r["session"].as_str().map(String::from).context("the peer gave no session")?;
443 -
    Ok((agent, session))
444 -
}
445 -
446 -
fn provider() -> Arc<CryptoProvider> {
447 -
    Arc::new(rustls::crypto::ring::default_provider())
448 -
}
449 -
450 -
/// A client that trusts one certificate, by its BLAKE3, whatever name
451 -
/// the peer goes by: the one the hub said this peer has.
452 -
fn peer_agent(cert: &str) -> Result<ureq::Agent> {
453 -
    let provider = provider();
454 -
    let cfg = rustls::ClientConfig::builder_with_provider(provider.clone())
455 -
        .with_safe_default_protocol_versions()?
456 -
        .dangerous()
457 -
        .with_custom_certificate_verifier(Arc::new(Pinned { cert: cert.to_string(), provider }))
458 -
        .with_no_client_auth();
459 -
    Ok(ureq::AgentBuilder::new().tls_config(Arc::new(cfg)).timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(20)).build())
460 -
}
461 -
462 -
/// Trusts the certificate with this BLAKE3 and no other. The handshake's
463 -
/// signature is still checked against it, so only the holder of its key
464 -
/// passes.
465 -
#[derive(Debug)]
466 -
struct Pinned {
467 -
    cert: String,
468 -
    provider: Arc<CryptoProvider>,
469 -
}
470 -
471 -
impl ServerCertVerifier for Pinned {
472 -
    fn verify_server_cert(
473 -
        &self,
474 -
        end_entity: &CertificateDer<'_>,
475 -
        _intermediates: &[CertificateDer<'_>],
476 -
        _name: &ServerName<'_>,
477 -
        _ocsp: &[u8],
478 -
        _now: UnixTime,
479 -
    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
480 -
        if blake3::hash(end_entity.as_ref()).to_hex().as_str() == self.cert {
481 -
            Ok(ServerCertVerified::assertion())
482 -
        } else {
483 -
            Err(rustls::Error::General("not the peer the hub named: its certificate is another".into()))
484 -
        }
485 -
    }
486 -
487 -
    fn verify_tls12_signature(
488 -
        &self,
489 -
        message: &[u8],
490 -
        cert: &CertificateDer<'_>,
491 -
        dss: &DigitallySignedStruct,
492 -
    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
493 -
        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
494 -
    }
495 -
496 -
    fn verify_tls13_signature(
497 -
        &self,
498 -
        message: &[u8],
499 -
        cert: &CertificateDer<'_>,
500 -
        dss: &DigitallySignedStruct,
501 -
    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
502 -
        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
503 -
    }
504 -
505 -
    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
506 -
        self.provider.signature_verification_algorithms.supported_schemes()
507 -
    }
508 -
}
509 -
510 -
/// The runner's swarm certificate and key (`swarm/cert.der`, and
511 -
/// `swarm/key.der` readable by the runner alone), made on first use and
512 -
/// kept, so its fingerprint holds across restarts.
513 -
fn certificate(root: &Path) -> Result<(Vec<u8>, Vec<u8>)> {
514 -
    let dir = root.join("swarm");
515 -
    let (cert_path, key_path) = (dir.join("cert.der"), dir.join("key.der"));
516 -
    if let (Ok(c), Ok(k)) = (std::fs::read(&cert_path), std::fs::read(&key_path)) {
517 -
        return Ok((c, k));
518 -
    }
519 -
    std::fs::create_dir_all(&dir)?;
520 -
    let made = rcgen::generate_simple_self_signed(vec!["flow-swarm".to_string()]).context("make the swarm's certificate")?;
521 -
    let (cert, key) = (made.cert.der().to_vec(), made.signing_key.serialize_der());
522 -
    {
523 -
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&key_path)?;
524 -
        f.write_all(&key)?;
525 -
    }
526 -
    std::fs::write(&cert_path, &cert)?;
527 -
    Ok((cert, key))
528 -
}
529 -
530 -
fn server_tls(cert: Vec<u8>, key: Vec<u8>) -> Result<Arc<rustls::ServerConfig>> {
531 -
    let cfg = rustls::ServerConfig::builder_with_provider(provider())
532 -
        .with_safe_default_protocol_versions()?
533 -
        .with_no_client_auth()
534 -
        .with_single_cert(vec![CertificateDer::from(cert)], PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))
535 -
        .context("the swarm's certificate")?;
536 -
    Ok(Arc::new(cfg))
537 -
}
538 -
539 -
/// Offer a whole image: every chunk of it can be read out of `path`.
540 -
fn offer_whole(state: &State, m: &Manifest, path: &Path) {
541 -
    let mut located = state.located.lock().unwrap();
542 -
    let mut at = 0u64;
543 -
    for c in &m.chunks {
544 -
        if !c.is_zeros() {
545 -
            located.entry(c.hash().to_string()).or_insert_with(|| (path.to_path_buf(), at, c.1));
546 -
        }
547 -
        at += c.1 as u64;
548 -
    }
549 -
    state.offers.lock().unwrap().insert(m.hash.clone(), Offer { bits: None, fetching: Bits::new(0) });
550 -
}
551 -
552 -
fn accept(listener: TcpListener, state: Arc<State>) {
553 -
    for conn in listener.incoming() {
554 -
        let Ok(conn) = conn else { continue };
555 -
        if state.conns.load(Ordering::Relaxed) >= MAX_OPEN {
556 -
            continue;
557 -
        }
558 -
        let state = state.clone();
559 -
        std::thread::spawn(move || {
560 -
            // Past the limit a peer is still answered, with 503, so it
561 -
            // comes back rather than giving up on this runner.
562 -
            let busy = state.conns.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS;
563 -
            serve_tls(conn, &state, busy).ok();
564 -
            state.conns.fetch_sub(1, Ordering::Relaxed);
565 -
        });
566 -
    }
567 -
}
568 -
569 -
fn serve_tls(conn: TcpStream, state: &State, busy: bool) -> std::io::Result<()> {
570 -
    conn.set_read_timeout(Some(Duration::from_secs(30)))?;
571 -
    conn.set_write_timeout(Some(Duration::from_secs(30)))?;
572 -
    conn.set_nodelay(true)?;
573 -
    let tls = rustls::ServerConnection::new(state.tls.clone()).map_err(std::io::Error::other)?;
574 -
    serve_conn(rustls::StreamOwned::new(tls, conn), state, busy)
575 -
}
576 -
577 -
fn respond(conn: &mut impl Write, code: u16, kind: &str, body: &[u8]) -> std::io::Result<()> {
578 -
    let reason = match code {
579 -
        200 => "OK",
580 -
        400 => "Bad Request",
581 -
        401 => "Unauthorized",
582 -
        403 => "Forbidden",
583 -
        404 => "Not Found",
584 -
        503 => "Service Unavailable",
585 -
        _ => "Error",
586 -
    };
587 -
    write!(conn, "HTTP/1.1 {code} {reason}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n\r\n", body.len())?;
588 -
    conn.write_all(body)?;
589 -
    conn.flush()
590 -
}
591 -
592 -
/// One connection: requests one after another while the peer keeps it.
593 -
fn serve_conn<S: Read + Write>(conn: S, state: &State, busy: bool) -> std::io::Result<()> {
594 -
    let mut r = BufReader::new(conn);
595 -
    loop {
596 -
        let mut line = String::new();
597 -
        if r.read_line(&mut line)? == 0 {
598 -
            return Ok(());
599 -
        }
600 -
        let mut parts = line.split_whitespace();
601 -
        let (method, path) = (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").to_string());
602 -
        let (mut length, mut auth, mut close) = (0usize, String::new(), false);
603 -
        loop {
604 -
            let mut h = String::new();
605 -
            if r.read_line(&mut h)? == 0 {
606 -
                return Ok(());
607 -
            }
608 -
            let h = h.trim_end();
609 -
            if h.is_empty() {
610 -
                break;
611 -
            }
612 -
            if let Some((k, v)) = h.split_once(':') {
613 -
                match k.trim().to_ascii_lowercase().as_str() {
614 -
                    "content-length" => length = v.trim().parse().unwrap_or(usize::MAX),
615 -
                    "authorization" => auth = v.trim().to_string(),
616 -
                    "connection" => close = v.trim().eq_ignore_ascii_case("close"),
617 -
                    _ => {}
618 -
                }
619 -
            }
620 -
        }
621 -
        if length > MAX_BODY {
622 -
            return respond(r.get_mut(), 400, "text/plain", b"too large");
623 -
        }
624 -
        let mut body = vec![0; length];
625 -
        r.read_exact(&mut body)?;
626 -
        if busy {
627 -
            return respond(r.get_mut(), 503, "text/plain", b"busy: ask again shortly");
628 -
        }
629 -
        let (code, kind, reply) = route(state, &method, &path, &auth, &body);
630 -
        if code == 200 && kind == "application/octet-stream" {
631 -
            throttle(state, reply.len());
632 -
        }
633 -
        respond(r.get_mut(), code, kind, &reply)?;
634 -
        if close {
635 -
            return Ok(());
636 -
        }
637 -
    }
638 -
}
639 -
640 -
fn route(state: &State, method: &str, path: &str, auth: &str, body: &[u8]) -> (u16, &'static str, Vec<u8>) {
641 -
    let text = |code, s: &str| (code, "text/plain", s.as_bytes().to_vec());
642 -
    if method == "POST" && path == "/swarm/v1/hello" {
643 -
        return match admit(state, &String::from_utf8_lossy(body)) {
644 -
            Ok(session) => (200, "application/json", serde_json::json!({ "session": session }).to_string().into_bytes()),
645 -
            Err(why) => text(403, &why),
646 -
        };
647 -
    }
648 -
    let session = auth.strip_prefix("Bearer ").unwrap_or("");
649 -
    let admitted = {
650 -
        let mut sessions = state.sessions.lock().unwrap();
651 -
        let now = now_ms();
652 -
        sessions.retain(|_, (_, until)| *until > now);
653 -
        sessions.contains_key(session)
654 -
    };
655 -
    if !admitted {
656 -
        return text(401, "show a ticket at /swarm/v1/hello first");
657 -
    }
658 -
    let rest = path.strip_prefix("/swarm/v1/").unwrap_or("");
659 -
    match (method, rest.split('/').collect::<Vec<_>>().as_slice()) {
660 -
        ("GET", ["images", image, "have"]) => match state.offers.lock().unwrap().get(*image) {
661 -
            Some(Offer { bits: None, .. }) => (200, "text/plain", b"complete".to_vec()),
662 -
            Some(Offer { bits: Some(b), fetching }) => (200, "application/x-have", [b.to_bytes(), fetching.to_bytes()].concat()),
663 -
            None => text(404, "this runner does not hold that image"),
664 -
        },
665 -
        ("GET", ["chunks", chunk]) if is_hash(chunk) => match read_chunk(state, chunk) {
666 -
            Some(b) => (200, "application/octet-stream", b),
667 -
            None => text(404, "no such chunk here"),
668 -
        },
669 -
        _ => text(404, "not a swarm path"),
670 -
    }
671 -
}
672 -
673 -
/// Admit a peer that shows a ticket from this runner's hub, for this
674 -
/// site: a session until the ticket expires.
675 -
fn admit(state: &State, ticket: &str) -> std::result::Result<String, String> {
676 -
    let hub = {
677 -
        let mut hub = state.hub.lock().unwrap();
678 -
        if hub.is_none() {
679 -
            *hub = state.client.hub_identity().ok();
680 -
        }
681 -
        hub.clone().ok_or("this runner cannot reach its hub to check tickets")?
682 -
    };
683 -
    let t = Ticket::open(ticket, &hub, now_ms())?;
684 -
    if t.site != state.site {
685 -
        return Err(format!("this runner is in site {:?}, the ticket's runner in {:?}", state.site, t.site));
686 -
    }
687 -
    let session = random_token().map_err(|e| format!("make a session: {e}"))?;
688 -
    state.sessions.lock().unwrap().insert(session.clone(), (t.name, t.expires_ms));
689 -
    Ok(session)
690 -
}
691 -
692 -
fn random_token() -> std::io::Result<String> {
693 -
    let mut b = [0u8; 32];
694 -
    std::fs::File::open("/dev/urandom")?.read_exact(&mut b)?;
695 -
    Ok(hex::encode(b))
696 -
}
697 -
698 -
fn read_chunk(state: &State, hash: &str) -> Option<Vec<u8>> {
699 -
    if let Ok(b) = std::fs::read(cache_path(&state.root.join("chunks"), hash)) {
700 -
        return Some(b);
701 -
    }
702 -
    let (path, at, len) = state.located.lock().unwrap().get(hash).cloned()?;
703 -
    let mut f = std::fs::File::open(path).ok()?;
704 -
    f.seek(SeekFrom::Start(at)).ok()?;
705 -
    let mut b = vec![0; len as usize];
706 -
    f.read_exact(&mut b).ok()?;
707 -
    Some(b)
708 -
}
709 -
710 -
/// Hold back until sending `n` more bytes keeps within the upload rate.
711 -
fn throttle(state: &State, n: usize) {
712 -
    let wait = {
713 -
        let mut b = state.bucket.lock().unwrap();
714 -
        let now = Instant::now();
715 -
        b.0 = (b.0 + now.duration_since(b.1).as_secs_f64() * state.rate).min(state.rate);
716 -
        b.1 = now;
717 -
        b.0 -= n as f64;
718 -
        if b.0 < 0.0 {
719 -
            -b.0 / state.rate
720 -
        } else {
721 -
            0.0
722 -
        }
723 -
    };
724 -
    if wait > 0.0 {
725 -
        std::thread::sleep(Duration::from_secs_f64(wait));
726 -
    }
727 -
}
728 -
729 -
#[cfg(test)]
730 -
mod tests {
731 -
    use super::*;
732 -
    use oxy_core::identity::SecretKey;
733 -
734 -
    /// A hub that holds one image and runs the tracker: it serves the
735 -
    /// manifest and chunks (counting the chunks), and answers announces
736 -
    /// with every other runner as a peer and a ticket for the caller,
737 -
    /// known by its token.
738 -
    struct FakeHub {
739 -
        url: String,
740 -
        chunks_served: Arc<AtomicUsize>,
741 -
        runners: Arc<Mutex<Vec<(String, SwarmPeer)>>>,
742 -
    }
743 -
744 -
    fn fake_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub {
745 -
        hub_with(m, image, site, Duration::ZERO, true)
746 -
    }
747 -
748 -
    /// A hub that takes `delay` over each chunk.
749 -
    fn slow_hub(m: Manifest, image: Vec<u8>, site: &str, delay: Duration) -> FakeHub {
750 -
        hub_with(m, image, site, delay, true)
751 -
    }
752 -
753 -
    /// A hub that keeps the image's manifest but none of its chunks, as for
754 -
    /// an image only runners hold (jetstream.md §8.1.1).
755 -
    fn peer_only_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub {
756 -
        hub_with(m, image, site, Duration::ZERO, false)
757 -
    }
758 -
759 -
    fn hub_with(m: Manifest, image: Vec<u8>, site: &str, delay: Duration, seeds: bool) -> FakeHub {
760 -
        let sk = SecretKey::generate();
761 -
        let id = sk.identity("hub", "hub@example", 1);
762 -
        let key = id.hash();
763 -
        let mut chunks = HashMap::new();
764 -
        let mut at = 0usize;
765 -
        for c in &m.chunks {
766 -
            chunks.insert(c.hash().to_string(), image[at..at + c.len()].to_vec());
767 -
            at += c.len();
768 -
        }
769 -
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
770 -
        let url = format!("http://{}", listener.local_addr().unwrap());
771 -
        let served = Arc::new(AtomicUsize::new(0));
772 -
        let runners: Arc<Mutex<Vec<(String, SwarmPeer)>>> = Arc::new(Mutex::new(Vec::new()));
773 -
        let (count, known, site) = (served.clone(), runners.clone(), site.to_string());
774 -
        let shared = Arc::new((m, chunks, id, key, sk));
775 -
        std::thread::spawn(move || {
776 -
            for conn in listener.incoming() {
777 -
                let Ok(conn) = conn else { continue };
778 -
                let (shared, count, known, site) = (shared.clone(), count.clone(), known.clone(), site.clone());
779 -
                std::thread::spawn(move || {
780 -
                    let (m, chunks, id, key, sk) = &*shared;
781 -
                    let mut out = conn.try_clone().unwrap();
782 -
                    let mut r = BufReader::new(conn);
783 -
                    loop {
784 -
                        let mut line = String::new();
785 -
                        if r.read_line(&mut line).unwrap_or(0) == 0 {
786 -
                            return;
787 -
                        }
788 -
                        let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
789 -
                        let (mut len, mut token) = (0, String::new());
790 -
                        loop {
791 -
                            let mut h = String::new();
792 -
                            r.read_line(&mut h).unwrap();
793 -
                            let h = h.trim_end().to_string();
794 -
                            if h.is_empty() {
795 -
                                break;
796 -
                            }
797 -
                            let lower = h.to_ascii_lowercase();
798 -
                            if let Some(v) = lower.strip_prefix("content-length:") {
799 -
                                len = v.trim().parse().unwrap();
800 -
                            }
801 -
                            if let Some(v) = h.strip_prefix("Authorization: Bearer ") {
802 -
                                token = v.to_string();
803 -
                            }
804 -
                        }
805 -
                        let mut body = vec![0; len];
806 -
                        r.read_exact(&mut body).unwrap();
807 -
                        let (code, reply): (u16, Vec<u8>) = if path == "/.well-known/oxyhub-key" {
808 -
                            (200, id.encode())
809 -
                        } else if path == format!("/api/v1/flow/images/{}", m.hash) {
810 -
                            (200, serde_json::to_vec(m).unwrap())
811 -
                        } else if let Some(c) = path.strip_prefix("/api/v1/flow/images/chunks/").filter(|_| seeds) {
812 -
                            count.fetch_add(1, Ordering::Relaxed);
813 -
                            std::thread::sleep(delay);
814 -
                            (200, chunks[c].clone())
815 -
                        } else if path == "/api/v1/flow/swarm/announce" {
816 -
                            let runners = known.lock().unwrap();
817 -
                            let me = runners.iter().find(|(t, _)| *t == token).map(|(_, p)| p.clone()).unwrap();
818 -
                            let ticket = Ticket {
819 -
                                hub: key.to_hex(),
820 -
                                runner: me.runner,
821 -
                                name: me.name.clone(),
822 -
                                site: site.clone(),
823 -
                                expires_ms: now_ms() + 60_000,
824 -
                            };
825 -
                            let peers: Vec<SwarmPeer> = runners.iter().filter(|(t, _)| *t != token).map(|(_, p)| p.clone()).collect();
826 -
                            let a = flow_core::api::Announced {
827 -
                                ticket: ticket.seal(key, sk),
828 -
                                peers,
829 -
                                interval_s: 5,
830 -
                                seed: (!seeds).then_some(false),
831 -
                            };
832 -
                            (200, serde_json::to_vec(&a).unwrap())
833 -
                        } else {
834 -
                            (404, b"no".to_vec())
835 -
                        };
836 -
                        let head = format!("HTTP/1.1 {code} X\r\nContent-Length: {}\r\n\r\n", reply.len());
837 -
                        if out.write_all(head.as_bytes()).and_then(|_| out.write_all(&reply)).is_err() {
838 -
                            return;
839 -
                        }
840 -
                    }
841 -
                });
842 -
            }
843 -
        });
844 -
        FakeHub { url, chunks_served: served, runners }
845 -
    }
846 -
847 -
    fn free_port() -> u16 {
848 -
        TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
849 -
    }
850 -
851 -
    /// A runner home in `dir` with a swarm on `port`, known to `hub` by
852 -
    /// `token` as runner `id`.
853 -
    fn runner(dir: &Path, hub: &FakeHub, id: i64, token: &str, port: u16, site: &str) -> (Home, Swarm) {
854 -
        let toml = format!(
855 -
            "hub = \"{}\"\ntoken = \"{token}\"\nname = \"r{id}\"\n[swarm]\nlisten = \"127.0.0.1:{port}\"\nsite = \"{site}\"\n",
856 -
            hub.url
857 -
        );
858 -
        std::fs::write(dir.join(crate::home::RUNNER_TOML), toml).unwrap();
859 -
        let home = Home::locate(Some(dir)).unwrap();
860 -
        let cfg = home.config.runner.clone().unwrap();
861 -
        let swarm = Swarm::start(&home, &cfg).unwrap().unwrap();
862 -
        hub.runners.lock().unwrap().push((
863 -
            token.into(),
864 -
            SwarmPeer {
865 -
                runner: id,
866 -
                name: format!("r{id}"),
867 -
                addr: format!("127.0.0.1:{port}"),
868 -
                cert: swarm.addr.cert.clone(),
869 -
                complete: true,
870 -
            },
871 -
        ));
872 -
        (home, swarm)
873 -
    }
874 -
875 -
    fn image() -> Vec<u8> {
876 -
        // 3 MiB of varied bytes, with a run of zeros the swarm never moves.
877 -
        let mut x = 0x9e3779b97f4a7c15u64;
878 -
        let mut b: Vec<u8> = (0..3 << 20)
879 -
            .map(|_| {
880 -
                x ^= x << 13;
881 -
                x ^= x >> 7;
882 -
                x ^= x << 17;
883 -
                x as u8
884 -
            })
885 -
            .collect();
886 -
        b[1 << 20..(1 << 20) + (300 << 10)].fill(0);
887 -
        b
888 -
    }
889 -
890 -
    #[test]
891 -
    fn a_runner_fetches_an_image_from_its_peer_and_not_the_hub() {
892 -
        let bytes = image();
893 -
        let m = jet::images::scan(&bytes[..], Some("x86_64".into()), |_, _| Ok(())).unwrap();
894 -
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
895 -
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
896 -
        // Runner A holds the image, imported rather than fetched: its swarm
897 -
        // scans it for a manifest before offering it.
898 -
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
899 -
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
900 -
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
901 -
        let deadline = Instant::now() + Duration::from_secs(20);
902 -
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
903 -
            assert!(Instant::now() < deadline, "runner A never offered its image");
904 -
            std::thread::sleep(Duration::from_millis(20));
905 -
        }
906 -
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
907 -
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
908 -
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes, "the image came back whole");
909 -
        assert_eq!(moved.peer_bytes, moved.bytes, "every chunk came from the peer");
910 -
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), 0, "the hub sent no chunks");
911 -
        assert_eq!(moved.chunks, m.needed().len());
912 -
        // B now offers the whole image too, read out of its rootfs.
913 -
        assert!(matches!(b.state.offers.lock().unwrap().get(&m.hash), Some(Offer { bits: None, .. })));
914 -
    }
915 -
916 -
    #[test]
917 -
    fn an_image_only_runners_hold_comes_from_a_peer_and_fails_plainly_without_one() {
918 -
        let bytes = image();
919 -
        let m = jet::images::scan(&bytes[..], Some("x86_64".into()), |_, _| Ok(())).unwrap();
920 -
        let hub = peer_only_hub(m.clone(), bytes.clone(), "lab");
921 -
        // No peer yet: no hub to fall back on, so the fetch says why.
922 -
        let lone = tempfile::tempdir().unwrap();
923 -
        let (lone_home, lone_runner) = runner(lone.path(), &hub, 3, "tc", free_port(), "lab");
924 -
        let e = lone_runner.fetch(&lone_home, &m.hash).err().expect("a fetch with no peer and no seed fails");
925 -
        assert!(e.to_string().contains("held only by runners"), "{e:#}");
926 -
        // With a peer holding it, it all comes from the peer.
927 -
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
928 -
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
929 -
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
930 -
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
931 -
        let deadline = Instant::now() + Duration::from_secs(20);
932 -
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
933 -
            assert!(Instant::now() < deadline, "runner A never offered its image");
934 -
            std::thread::sleep(Duration::from_millis(20));
935 -
        }
936 -
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
937 -
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
938 -
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
939 -
        assert_eq!(moved.peer_bytes, moved.bytes, "every chunk came from the peer");
940 -
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), 0);
941 -
    }
942 -
943 -
    #[test]
944 -
    fn runners_starting_together_share_what_each_has_fetched() {
945 -
        let bytes = image();
946 -
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
947 -
        let needed = m.needed().len();
948 -
        // A slow hub, as a remote one is: the runners have time to swap.
949 -
        let hub = slow_hub(m.clone(), bytes.clone(), "lab", Duration::from_millis(400));
950 -
        let (b_dir, c_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
951 -
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
952 -
        let (c_home, c) = runner(c_dir.path(), &hub, 3, "tc", free_port(), "lab");
953 -
        let (mb, mc) = std::thread::scope(|s| {
954 -
            let fb = s.spawn(|| b.fetch(&b_home, &m.hash).unwrap().unwrap());
955 -
            let fc = s.spawn(|| c.fetch(&c_home, &m.hash).unwrap().unwrap());
956 -
            (fb.join().unwrap(), fc.join().unwrap())
957 -
        });
958 -
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
959 -
        assert_eq!(std::fs::read(c_home.rootfs(&m.hash)).unwrap(), bytes);
960 -
        let served = hub.chunks_served.load(Ordering::Relaxed);
961 -
        assert!(served < 2 * needed, "the hub sent {served} chunks for two runners needing {needed} each");
962 -
        assert!(mb.peer_bytes + mc.peer_bytes > 0, "they shared");
963 -
    }
964 -
965 -
    #[test]
966 -
    fn without_peers_the_hub_sends_everything_and_a_ticket_from_another_site_is_refused() {
967 -
        let bytes = image();
968 -
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
969 -
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
970 -
        let dir = tempfile::tempdir().unwrap();
971 -
        let (home, s) = runner(dir.path(), &hub, 1, "t1", free_port(), "lab");
972 -
        let moved = s.fetch(&home, &m.hash).unwrap().unwrap();
973 -
        assert_eq!(moved.peer_bytes, 0);
974 -
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), m.needed().len());
975 -
        assert_eq!(std::fs::read(home.rootfs(&m.hash)).unwrap(), bytes);
976 -
977 -
        // A runner in another site shows a ticket for "lab": refused.
978 -
        let other = tempfile::tempdir().unwrap();
979 -
        let port = free_port();
980 -
        let (_h, o) = runner(other.path(), &hub, 3, "t3", port, "elsewhere");
981 -
        let ticket = s.state.client.announce(&m.hash, "fetching").unwrap().ticket;
982 -
        let err = hello(&format!("127.0.0.1:{port}"), &o.addr.cert, &ticket).unwrap_err();
983 -
        assert!(format!("{err:#}").contains("HTTP 403"), "{err:#}");
984 -
        // And no session, no chunks.
985 -
        let url = format!("https://127.0.0.1:{port}/swarm/v1/chunks/{}", m.chunks[0].hash());
986 -
        assert!(matches!(peer_agent(&o.addr.cert).unwrap().get(&url).call(), Err(ureq::Error::Status(401, _))));
987 -
        // Nor any answer at all without TLS.
988 -
        assert!(ureq::get(&url.replacen("https", "http", 1)).call().is_err());
989 -
    }
990 -
991 -
    #[test]
992 -
    fn a_peer_with_another_certificate_is_not_trusted() {
993 -
        let bytes = image();
994 -
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
995 -
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
996 -
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
997 -
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
998 -
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
999 -
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
1000 -
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
1001 -
            std::thread::sleep(Duration::from_millis(20));
1002 -
        }
1003 -
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
1004 -
        // The hub names A, but with B's certificate: whoever answers at A's
1005 -
        // address is not who the hub vouched for.
1006 -
        hub.runners.lock().unwrap()[0].1.cert = b.addr.cert.clone();
1007 -
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
1008 -
        assert_eq!(moved.peer_bytes, 0, "nothing was taken from the stranger");
1009 -
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), m.needed().len());
1010 -
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
1011 -
        // The certificate is kept: the same runner, the same fingerprint.
1012 -
        let mut cfg = b_home.config.runner.clone().unwrap();
1013 -
        cfg.swarm.as_mut().unwrap().listen = "127.0.0.1:0".into();
1014 -
        let again = Swarm::start(&b_home, &cfg).unwrap().unwrap();
1015 -
        assert_eq!(again.addr.cert, b.addr.cert);
1016 -
    }
1017 -
1018 -
    #[test]
1019 -
    fn the_upload_rate_holds_back_a_burst() {
1020 -
        let dir = tempfile::tempdir().unwrap();
1021 -
        let (cert, key) = certificate(dir.path()).unwrap();
1022 -
        let state = State {
1023 -
            tls: server_tls(cert, key).unwrap(),
1024 -
            root: PathBuf::new(),
1025 -
            site: String::new(),
1026 -
            client: Client::new("http://127.0.0.1:1", "t", None).unwrap(),
1027 -
            hub: Mutex::new(None),
1028 -
            offers: Mutex::new(HashMap::new()),
1029 -
            located: Mutex::new(HashMap::new()),
1030 -
            sessions: Mutex::new(HashMap::new()),
1031 -
            conns: AtomicUsize::new(0),
1032 -
            bucket: Mutex::new((1_000_000.0, Instant::now())),
1033 -
            rate: 1_000_000.0,
1034 -
        };
1035 -
        let start = Instant::now();
1036 -
        throttle(&state, 1_000_000);
1037 -
        assert!(start.elapsed() < Duration::from_millis(100), "the first second's worth goes at once");
1038 -
        throttle(&state, 300_000);
1039 -
        assert!(start.elapsed() >= Duration::from_millis(250), "then it waits for the rate");
1040 -
    }
1041 -
}
modified crates/flow/src/syscall.rs +46 −58
…
2 unchanged lines
3 3
//! network, IPC and UTS namespaces, on the rootfs image's tree (unpacked
4 4
//! once per hash, read-only) with the workspace directory at `/src` and a
5 5
//! tmpfs at `/tmp`. Landlock limits writes to `/src`, `/tmp` and `/dev`; a
6 -
//! seccomp filter refuses sockets other than `AF_UNIX` (and, with
7 -
//! `network "loopback"`, IPv4 and IPv6 on its own `lo`) and the calls that
6 +
//! seccomp filter refuses sockets other than `AF_UNIX` and the calls that
8 7
//! reach past the sandbox (`ptrace`, `mount`, `bpf`, `perf_event_open`,
9 8
//! keys, modules, io_uring); and the sandbox's CPU and memory limits apply
10 9
//! through a cgroup v2 group of the runner's own delegated cgroup. Without
…
9 unchanged lines
20 19
use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH};
21 20
use crate::home::Home;
22 21
use crate::microvm::{Outcome, Sink};
23 -
pub use crate::tier::disk_dir;
24 -
pub(crate) use crate::tier::{lines, self_hash, FAILED};
25 22
use anyhow::{bail, Context, Result};
26 23
use flow_core::api::{Record, SecretInput};
27 24
use flow_core::{Environment, Job, MountSource, Network, Sandbox};
28 25
use serde::{Deserialize, Serialize};
29 26
use std::ffi::CString;
27 +
use std::io::{BufRead, BufReader, Read};
30 28
use std::path::{Path, PathBuf};
31 29
use std::sync::{mpsc, OnceLock};
32 30
use std::time::{Duration, Instant};
…
23 unchanged lines
56 54
    }
57 55
}
58 56
57 +
fn self_hash() -> &'static str {
58 +
    static HASH: OnceLock<String> = OnceLock::new();
59 +
    HASH.get_or_init(|| crate::home::hash_file(Path::new("/proc/self/exe")).unwrap_or_else(|_| "unknown".into()))
60 +
}
61 +
59 62
fn kernel_release() -> String {
60 -
    crate::facts::kernel().unwrap_or_else(|| "unknown".into())
63 +
    std::fs::read_to_string("/proc/sys/kernel/osrelease").map(|s| s.trim().to_string()).unwrap_or_else(|_| "unknown".into())
61 64
}
62 65
63 66
/// Whether this machine can run the tier now: a cgroup this runner may
…
33 unchanged lines
97 100
            return Err("Landlock is not enabled in this kernel (it must be in the lsm= list)".into());
98 101
        }
99 102
        no_new_privs()?;
100 -
        seccomp(&[], families(false)).map_err(|e| format!("seccomp filters are not available: {e}"))
103 +
        seccomp(&[]).map_err(|e| format!("seccomp filters are not available: {e}"))
101 104
    })();
102 105
    match r {
103 106
        Ok(()) => 0,
…
179 unchanged lines
283 286
        timed_out,
284 287
        cancelled,
285 288
        peak_memory_mib,
286 -
        pruned: Vec::new(),
287 289
    })
288 290
}
289 291
292 +
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
293 +
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
294 +
    scratch.join(format!("mount-{n}"))
295 +
}
296 +
297 +
/// How the helper says the job never started, on its stderr.
298 +
const FAILED: &str = "\u{1e}F ";
299 +
300 +
fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
301 +
    let mut r = BufReader::new(r);
302 +
    let mut buf = Vec::new();
303 +
    loop {
304 +
        buf.clear();
305 +
        // A long line is cut at 16 KiB, as flow-init cuts it.
306 +
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
307 +
            Ok(0) | Err(_) => break,
308 +
            Ok(_) => {
309 +
                if buf.last() == Some(&b'\n') {
310 +
                    buf.pop();
311 +
                }
312 +
                if tx.send((stdout, buf.clone())).is_err() {
313 +
                    break;
314 +
                }
315 +
            }
316 +
        }
317 +
    }
318 +
}
319 +
290 320
/// The image's tree, unpacked from its ext4 file once per hash with
291 321
/// debugfs (no mount rights needed). Files belong to the runner; set-id
292 322
/// bits and device nodes are dropped, which a sandboxed job cannot use
…
323 unchanged lines
616 646
            }
617 647
            no_new_privs()?;
618 648
            landlock(&writable)?;
619 -
            seccomp(DENIED, families(spec.loopback))
649 +
            seccomp(DENIED)
620 650
        })();
621 651
        unsafe {
622 652
            if let Err(e) = confined {
…
174 unchanged lines
797 827
    libc::SYS_io_uring_enter,
798 828
    libc::SYS_io_uring_register,
799 829
];
800 -
801 -
/// Classic BPF opcodes (linux/bpf_common.h), fixed by the kernel's ABI;
802 -
/// spelled out because libc does not export them for every target
803 -
/// (Android, where the runner is built for the WASM tier only).
804 -
const BPF_LD: u16 = 0x00;
805 -
const BPF_W: u16 = 0x00;
806 -
const BPF_ABS: u16 = 0x20;
807 -
const BPF_JMP: u16 = 0x05;
808 -
const BPF_JEQ: u16 = 0x10;
809 -
const BPF_JGE: u16 = 0x30;
810 -
const BPF_K: u16 = 0x00;
811 -
const BPF_RET: u16 = 0x06;
812 -
813 -
/// Socket families a job may open: `AF_UNIX` always; with `network
814 -
/// "loopback"` also IPv4 and IPv6 (oxygenflow#73), which in the job's own
815 -
/// network namespace reach only its `lo`.
816 -
fn families(loopback: bool) -> &'static [libc::c_int] {
817 -
    if loopback {
818 -
        &[libc::AF_UNIX, libc::AF_INET, libc::AF_INET6]
819 -
    } else {
820 -
        &[libc::AF_UNIX]
821 -
    }
822 -
}
823 830
824 831
/// Install a filter that fails each of `denied` with EPERM and `socket`
825 -
/// for a family not in `allowed` with EAFNOSUPPORT (an empty `denied`
832 +
/// for any family but `AF_UNIX` with EAFNOSUPPORT (an empty `denied`
826 833
/// still checks sockets). Another architecture's calls kill the process.
827 -
fn seccomp(denied: &[libc::c_long], allowed: &[libc::c_int]) -> std::result::Result<(), String> {
828 -
    const LD: u16 = BPF_LD | BPF_W | BPF_ABS;
829 -
    const JEQ: u16 = BPF_JMP | BPF_JEQ | BPF_K;
830 -
    const RET: u16 = BPF_RET | BPF_K;
834 +
fn seccomp(denied: &[libc::c_long]) -> std::result::Result<(), String> {
835 +
    const LD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
836 +
    const JEQ: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
837 +
    const RET: u16 = (libc::BPF_RET | libc::BPF_K) as u16;
831 838
    let op = |code: u16, k: u32, jt: u8, jf: u8| libc::sock_filter { code, jt, jf, k };
832 839
    let errno = |e: libc::c_int| libc::SECCOMP_RET_ERRNO | (e as u32 & libc::SECCOMP_RET_DATA);
833 840
    // struct seccomp_data: nr at 0, arch at 4, args[0] at 16 (its low
…
2 unchanged lines
836 843
    #[cfg(target_arch = "x86_64")]
837 844
    {
838 845
        // x32 calls carry this bit; there are none in a 64-bit job.
839 -
        const JGE: u16 = BPF_JMP | BPF_JGE | BPF_K;
846 +
        const JGE: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16;
840 847
        f.push(op(JGE, 0x4000_0000, 0, 1));
841 848
        f.push(op(RET, errno(libc::EPERM), 0, 0));
842 849
    }
…
1 unchanged lines
844 851
        f.push(op(JEQ, *nr as u32, 0, 1));
845 852
        f.push(op(RET, errno(libc::EPERM), 0, 0));
846 853
    }
847 -
    // socket(): past the family checks and the refusal to the allow when
848 -
    // it is another call; each allowed family jumps over the rest to it.
849 -
    let n = allowed.len() as u8;
850 -
    f.push(op(JEQ, libc::SYS_socket as u32, 0, n + 2));
854 +
    f.push(op(JEQ, libc::SYS_socket as u32, 0, 3));
851 855
    f.push(op(LD, 16, 0, 0));
852 -
    for (i, fam) in allowed.iter().enumerate() {
853 -
        f.push(op(JEQ, *fam as u32, n - i as u8, 0));
854 -
    }
856 +
    f.push(op(JEQ, libc::AF_UNIX as u32, 1, 0));
855 857
    f.push(op(RET, errno(libc::EAFNOSUPPORT), 0, 0));
856 858
    f.push(op(RET, libc::SECCOMP_RET_ALLOW, 0, 0));
857 859
    let prog = libc::sock_fprog { len: f.len() as u16, filter: f.as_mut_ptr() };
…
1 unchanged lines
859 861
        return Err(format!("seccomp: {}", std::io::Error::last_os_error()));
860 862
    }
861 863
    Ok(())
862 -
}
863 -
864 -
#[cfg(all(test, not(target_os = "android")))]
865 -
mod tests {
866 -
    /// The opcodes written out above are the ABI's, as glibc's libc has them.
867 -
    #[test]
868 -
    fn bpf_opcodes_are_the_kernels() {
869 -
        use super::*;
870 -
        assert_eq!(
871 -
            [BPF_LD, BPF_W, BPF_ABS, BPF_JMP, BPF_JEQ, BPF_JGE, BPF_K, BPF_RET],
872 -
            [libc::BPF_LD, libc::BPF_W, libc::BPF_ABS, libc::BPF_JMP, libc::BPF_JEQ, libc::BPF_JGE, libc::BPF_K, libc::BPF_RET]
873 -
                .map(|v| v as u16)
874 -
        );
875 -
    }
876 864
}
removed crates/flow/src/syscall_none.rs +0 −46
1 -
//! The syscall tier where it cannot be: it is built from Linux's user
2 -
//! namespaces, Landlock, seccomp and cgroups, so a runner elsewhere (a
3 -
//! Windows runner, oxygenflow#83; Haiku and FreeBSD, #91, #99) never
4 -
//! offers it, and these say why.
5 -
6 -
use crate::home::Home;
7 -
use crate::microvm::{Outcome, Sink};
8 -
use anyhow::{bail, Result};
9 -
use flow_core::api::SecretInput;
10 -
use flow_core::{Job, Sandbox};
11 -
use std::path::Path;
12 -
use std::time::Duration;
13 -
14 -
pub use crate::tier::disk_dir;
15 -
pub(crate) use crate::tier::{lines, self_hash, FAILED};
16 -
17 -
const WHY: &str = "the syscall tier runs only on Linux";
18 -
19 -
pub fn available() -> std::result::Result<(), String> {
20 -
    Err(WHY.into())
21 -
}
22 -
23 -
pub fn probe_main() -> i32 {
24 -
    eprintln!("{WHY}");
25 -
    1
26 -
}
27 -
28 -
pub fn sandbox_main(_spec_path: &Path) -> i32 {
29 -
    eprintln!("{WHY}");
30 -
    125
31 -
}
32 -
33 -
#[allow(clippy::too_many_arguments)]
34 -
pub fn run(
35 -
    _home: &Home,
36 -
    _sandbox: &Sandbox,
37 -
    _job: &Job,
38 -
    _secrets: &[SecretInput],
39 -
    _workspace: &Path,
40 -
    _scratch: &Path,
41 -
    _timeout: Duration,
42 -
    _sink: &mut dyn Sink,
43 -
    _name: &str,
44 -
) -> Result<Outcome> {
45 -
    bail!("{WHY}")
46 -
}
removed crates/flow/src/tier.rs +0 −52
1 -
//! What the sandbox tiers share, on every system a runner runs on: where a
2 -
//! job's disks are kept, how a helper says a job never started, reading a
3 -
//! job's output by lines, and this binary's hash for receipts.
4 -
5 -
use std::io::{BufRead, BufReader, Read};
6 -
use std::path::{Path, PathBuf};
7 -
use std::sync::{mpsc, OnceLock};
8 -
9 -
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
10 -
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
11 -
    scratch.join(format!("mount-{n}"))
12 -
}
13 -
14 -
/// How the helper says the job never started, on its stderr.
15 -
pub(crate) const FAILED: &str = "\u{1e}F ";
16 -
17 -
pub(crate) fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
18 -
    let mut r = BufReader::new(r);
19 -
    let mut buf = Vec::new();
20 -
    loop {
21 -
        buf.clear();
22 -
        // A long line is cut at 16 KiB, as flow-init cuts it.
23 -
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
24 -
            Ok(0) | Err(_) => break,
25 -
            Ok(_) => {
26 -
                if buf.last() == Some(&b'\n') {
27 -
                    buf.pop();
28 -
                }
29 -
                if tx.send((stdout, buf.clone())).is_err() {
30 -
                    break;
31 -
                }
32 -
            }
33 -
        }
34 -
    }
35 -
}
36 -
37 -
/// This binary, to start helpers from and hash: `/proc/self/exe` on Linux
38 -
/// and Android (the running file, even after an update replaced its name),
39 -
/// the path it was started from elsewhere (oxygenflow#83).
40 -
pub fn self_exe() -> PathBuf {
41 -
    if cfg!(target_os = "linux") || cfg!(target_os = "android") {
42 -
        PathBuf::from("/proc/self/exe")
43 -
    } else {
44 -
        std::env::current_exe().unwrap_or_default()
45 -
    }
46 -
}
47 -
48 -
/// This binary's hash, which receipts name as the sandbox.
49 -
pub(crate) fn self_hash() -> &'static str {
50 -
    static HASH: OnceLock<String> = OnceLock::new();
51 -
    HASH.get_or_init(|| crate::home::hash_file(&self_exe()).unwrap_or_else(|_| "unknown".into()))
52 -
}
removed crates/flow/src/update.rs +0 −593
1 -
//! Runner updates, the runner's side (docs/jetstream.md §8.2, #85): check
2 -
//! a new `flow` build, stage it beside the running one, swap it in keeping
3 -
//! the old one as `flow.prev`, and have the running runner leave with
4 -
//! notice so its unit starts the new build. The new build proves itself by
5 -
//! reaching the hub; three failed starts, or ten minutes without the hub,
6 -
//! put `flow.prev` back.
7 -
8 -
use anyhow::{bail, Context, Result};
9 -
use serde::{Deserialize, Serialize};
10 -
use std::path::{Path, PathBuf};
11 -
use std::time::Duration;
12 -
13 -
/// The staged build, beside the installed one.
14 -
pub const NEW: &str = "flow.new";
15 -
/// The build an update replaced, kept to go back to.
16 -
pub const PREV: &str = "flow.prev";
17 -
/// In the runner's home: the update not yet proven.
18 -
const PENDING: &str = "update.json";
19 -
/// In the runner's home: every update and rollback, a JSON line each.
20 -
const LOG: &str = "updates.log";
21 -
/// In the runner's home: the running runner's process id.
22 -
const PID: &str = "runner.pid";
23 -
/// In the runner's home: a new build is in place, leave for it. How a
24 -
/// runner is told on Windows, which has no SIGUSR1.
25 -
const REQUEST: &str = "update.request";
26 -
/// Starts of an unproven build after which the previous one comes back.
27 -
pub const MAX_STARTS: u32 = 3;
28 -
/// How long a new build has to reach the hub.
29 -
pub const PROVE_WITHIN: Duration = Duration::from_secs(10 * 60);
30 -
31 -
/// An update swapped in and not yet proven.
32 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33 -
pub struct Pending {
34 -
    pub from: String,
35 -
    pub to: String,
36 -
    /// The installed binary the update replaced.
37 -
    pub exe: PathBuf,
38 -
    pub at_ms: u64,
39 -
    /// How long the running runner had to finish what it held.
40 -
    pub notice_secs: u64,
41 -
    /// Starts of the new build so far.
42 -
    pub starts: u32,
43 -
}
44 -
45 -
/// What [`install`] did, or would do.
46 -
#[derive(Debug, PartialEq, Eq)]
47 -
pub struct Installed {
48 -
    pub from: String,
49 -
    pub to: String,
50 -
    pub exe: PathBuf,
51 -
    /// The running runner told to leave, by process id.
52 -
    pub told: Option<u32>,
53 -
}
54 -
55 -
/// The version a `flow --version` line names: `flow 0.3.1 (build)` → `0.3.1`.
56 -
pub fn parse_version(line: &str) -> Option<String> {
57 -
    let v = line.trim().strip_prefix("flow ")?.split_whitespace().next()?;
58 -
    v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string())
59 -
}
60 -
61 -
/// Whether version `a` is older than `b`, part by part.
62 -
pub fn older(a: &str, b: &str) -> bool {
63 -
    let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
64 -
    parts(a) < parts(b)
65 -
}
66 -
67 -
/// `exe`'s sibling named `stem`, with `exe`'s extension (`flow.prev.exe`
68 -
/// beside `flow.exe` on Windows, `flow.prev` beside `flow` elsewhere).
69 -
pub fn beside(exe: &Path, stem: &str) -> PathBuf {
70 -
    match exe.extension() {
71 -
        Some(e) => exe.with_file_name(format!("{stem}.{}", e.to_string_lossy())),
72 -
        None => exe.with_file_name(stem),
73 -
    }
74 -
}
75 -
76 -
/// The PE machine number of this build's architecture (Windows).
77 -
fn pe_machine_here() -> Option<u16> {
78 -
    Some(match std::env::consts::ARCH {
79 -
        "x86_64" => 0x8664,
80 -
        "aarch64" => 0xAA64,
81 -
        _ => return None,
82 -
    })
83 -
}
84 -
85 -
/// Refuse a file that is not a PE executable for this machine (Windows).
86 -
pub fn check_pe(bytes: &[u8]) -> Result<()> {
87 -
    if bytes.len() < 0x40 || &bytes[..2] != b"MZ" {
88 -
        bail!("not a Windows executable");
89 -
    }
90 -
    let at = u32::from_le_bytes([bytes[0x3c], bytes[0x3d], bytes[0x3e], bytes[0x3f]]) as usize;
91 -
    if bytes.len() < at + 6 || &bytes[at..at + 4] != b"PE\0\0" {
92 -
        bail!("not a Windows executable (no PE header in its first {} bytes)", bytes.len());
93 -
    }
94 -
    let machine = u16::from_le_bytes([bytes[at + 4], bytes[at + 5]]);
95 -
    match pe_machine_here() {
96 -
        Some(m) if m == machine => Ok(()),
97 -
        Some(_) => bail!("a build for another machine (PE machine {machine:#x}); this runner is {}", std::env::consts::ARCH),
98 -
        None => bail!("updates are not supported on {}", std::env::consts::ARCH),
99 -
    }
100 -
}
101 -
102 -
/// The ELF machine number of this build's architecture.
103 -
fn machine_here() -> Option<u16> {
104 -
    Some(match std::env::consts::ARCH {
105 -
        "x86_64" => 62,
106 -
        "aarch64" => 183,
107 -
        "riscv64" => 243,
108 -
        _ => return None,
109 -
    })
110 -
}
111 -
112 -
/// Refuse a file that is not a 64-bit little-endian ELF executable for this
113 -
/// machine: a build for another target would not start, and the rollback
114 -
/// would only notice after three tries.
115 -
pub fn check_target(bytes: &[u8]) -> Result<()> {
116 -
    if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" {
117 -
        bail!("not an ELF executable");
118 -
    }
119 -
    if bytes[4] != 2 || bytes[5] != 1 {
120 -
        bail!("not a 64-bit little-endian build");
121 -
    }
122 -
    let machine = u16::from_le_bytes([bytes[18], bytes[19]]);
123 -
    match machine_here() {
124 -
        Some(m) if m == machine => Ok(()),
125 -
        Some(_) => bail!("a build for another machine (ELF machine {machine}); this runner is {}", std::env::consts::ARCH),
126 -
        None => bail!("updates are not supported on {}", std::env::consts::ARCH),
127 -
    }
128 -
}
129 -
130 -
/// Run `file args…` with a time limit, returning its standard output.
131 -
fn run(file: &Path, args: &[&str], limit: Duration) -> Result<String> {
132 -
    let mut child = std::process::Command::new(file)
133 -
        .args(args)
134 -
        .stdin(std::process::Stdio::null())
135 -
        .stdout(std::process::Stdio::piped())
136 -
        .stderr(std::process::Stdio::piped())
137 -
        .spawn()
138 -
        .with_context(|| format!("starting {}", file.display()))?;
139 -
    let start = std::time::Instant::now();
140 -
    loop {
141 -
        if child.try_wait()?.is_some() {
142 -
            break;
143 -
        }
144 -
        if start.elapsed() > limit {
145 -
            let _ = child.kill();
146 -
            let _ = child.wait();
147 -
            bail!("`{} {}` did not finish within {} s", file.display(), args.join(" "), limit.as_secs());
148 -
        }
149 -
        std::thread::sleep(Duration::from_millis(50));
150 -
    }
151 -
    let out = child.wait_with_output()?;
152 -
    if !out.status.success() {
153 -
        bail!("`{} {}` failed ({}): {}", file.display(), args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim());
154 -
    }
155 -
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
156 -
}
157 -
158 -
/// Copy `from` to `to` if it is `size` bytes with BLAKE3 `blake3`, as the
159 -
/// hub attests a build: a part file renamed into place, so `to` is either
160 -
/// the whole checked build or absent.
161 -
pub fn copy_checked(from: &Path, to: &Path, size: u64, blake3: &str) -> Result<()> {
162 -
    let bytes = std::fs::read(from).with_context(|| format!("reading {}", from.display()))?;
163 -
    let got = blake3::hash(&bytes).to_hex().to_string();
164 -
    if bytes.len() as u64 != size || got != blake3 {
165 -
        bail!("{} is not the build the hub attests ({} bytes, BLAKE3 {}…)", from.display(), bytes.len(), &got[..12]);
166 -
    }
167 -
    let part = to.with_extension("partial");
168 -
    std::fs::write(&part, &bytes).with_context(|| format!("writing {}", part.display()))?;
169 -
    std::fs::rename(&part, to)?;
170 -
    Ok(())
171 -
}
172 -
173 -
/// Check a build as the runner would run it: this machine's target, it
174 -
/// starts and names its version, and it finds the tiers this home offers.
175 -
/// Returns its version.
176 -
pub fn check(file: &Path, home: &Path) -> Result<String> {
177 -
    let mut head = vec![0u8; 4096];
178 -
    let n = std::io::Read::read(&mut std::fs::File::open(file).with_context(|| format!("opening {}", file.display()))?, &mut head)?;
179 -
    if cfg!(windows) {
180 -
        check_pe(&head[..n])?;
181 -
    } else {
182 -
        check_target(&head[..n])?;
183 -
    }
184 -
    let limit = Duration::from_secs(30);
185 -
    let line = run(file, &["--version"], limit)?;
186 -
    let version = parse_version(&line).with_context(|| format!("{} does not say it is flow: {:?}", file.display(), line.trim()))?;
187 -
    let home_s = home.to_string_lossy();
188 -
    let tiers = run(file, &["--home", &home_s, "tiers"], limit)?;
189 -
    if !tiers.lines().any(|l| l.ends_with(": available")) {
190 -
        bail!("flow {version} finds no tier it can run here:\n{}", tiers.trim());
191 -
    }
192 -
    Ok(version)
193 -
}
194 -
195 -
fn now_ms() -> u64 {
196 -
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
197 -
}
198 -
199 -
fn log(home: &Path, event: &str, from: &str, to: &str, why: &str) {
200 -
    use std::io::Write;
201 -
    let line = serde_json::json!({"at_ms": now_ms(), "event": event, "from": from, "to": to, "why": why});
202 -
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(home.join(LOG)) {
203 -
        let _ = writeln!(f, "{line}");
204 -
    }
205 -
}
206 -
207 -
/// The update not yet proven, if any.
208 -
pub fn pending(home: &Path) -> Option<Pending> {
209 -
    serde_json::from_slice(&std::fs::read(home.join(PENDING)).ok()?).ok()
210 -
}
211 -
212 -
fn save(home: &Path, p: &Pending) -> Result<()> {
213 -
    let tmp = home.join(format!("{PENDING}.tmp"));
214 -
    std::fs::write(&tmp, serde_json::to_vec_pretty(p)?)?;
215 -
    std::fs::rename(&tmp, home.join(PENDING))?;
216 -
    Ok(())
217 -
}
218 -
219 -
/// Whether `p` is the program running now.
220 -
fn is_self(p: &Path) -> bool {
221 -
    let canon = |p: &Path| std::fs::canonicalize(p).ok();
222 -
    std::env::current_exe().ok().and_then(|me| canon(&me)).is_some_and(|me| Some(me) == canon(p))
223 -
}
224 -
225 -
/// Rename program `from` to `to`. On Windows a running program cannot
226 -
/// rename its own file (other programs can), so a copy of it, run as
227 -
/// `flow __rename`, does it then.
228 -
fn rename_exe(from: &Path, to: &Path) -> Result<()> {
229 -
    if cfg!(windows) && is_self(from) {
230 -
        let helper = beside(from, "flow.rename");
231 -
        std::fs::copy(from, &helper).with_context(|| format!("copying {} to rename it", from.display()))?;
232 -
        let out = std::process::Command::new(&helper).arg("__rename").arg(from).arg(to).output();
233 -
        let _ = std::fs::remove_file(&helper);
234 -
        let out = out.with_context(|| format!("starting {}", helper.display()))?;
235 -
        if !out.status.success() {
236 -
            bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
237 -
        }
238 -
        return Ok(());
239 -
    }
240 -
    Ok(crate::portable::rename(from, to)?)
241 -
}
242 -
243 -
/// Put `staged` in place of `exe`, keeping `exe` as [`PREV`] beside it.
244 -
/// `exe` is never missing: it is copied aside, then replaced in one rename.
245 -
/// On Windows, where a running program cannot be replaced but can be
246 -
/// renamed, `exe` is renamed aside and `staged` into its place.
247 -
pub fn swap(exe: &Path, staged: &Path) -> Result<()> {
248 -
    let prev = beside(exe, PREV);
249 -
    if cfg!(windows) {
250 -
        let _ = std::fs::remove_file(&prev);
251 -
        rename_exe(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?;
252 -
        if let Err(e) = crate::portable::rename(staged, exe) {
253 -
            let _ = crate::portable::rename(&prev, exe);
254 -
            return Err(e).with_context(|| format!("putting {} in place of {}", staged.display(), exe.display()));
255 -
        }
256 -
        return Ok(());
257 -
    }
258 -
    let _ = std::fs::remove_file(&prev);
259 -
    if std::fs::hard_link(exe, &prev).is_err() {
260 -
        std::fs::copy(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?;
261 -
    }
262 -
    std::fs::rename(staged, exe).with_context(|| format!("putting {} in place of {}", staged.display(), exe.display()))?;
263 -
    Ok(())
264 -
}
265 -
266 -
/// Put [`PREV`] back in place of `exe`.
267 -
pub fn restore(exe: &Path) -> Result<()> {
268 -
    let prev = beside(exe, PREV);
269 -
    if !prev.is_file() {
270 -
        bail!("{} is not there to go back to", prev.display());
271 -
    }
272 -
    if cfg!(windows) {
273 -
        // The failed build may be the one running: rename it aside first.
274 -
        let failed = beside(exe, "flow.failed");
275 -
        let _ = std::fs::remove_file(&failed);
276 -
        rename_exe(exe, &failed).with_context(|| format!("moving {} aside", exe.display()))?;
277 -
        return crate::portable::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()));
278 -
    }
279 -
    std::fs::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()))?;
280 -
    Ok(())
281 -
}
282 -
283 -
/// Check `file`, stage it beside `exe`, and unless `dry_run` swap it in and
284 -
/// tell the running runner (if any) to leave within `notice`, finishing
285 -
/// what fits and handing back the rest, so its unit starts the new build.
286 -
pub fn install(home: &crate::home::Home, exe: &Path, file: &Path, notice: Duration, dry_run: bool, allow_older: bool) -> Result<Installed> {
287 -
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
288 -
        bail!("flow.toml says [runner.update] allow = false: this runner takes no updates");
289 -
    }
290 -
    if let Some(p) = pending(&home.root) {
291 -
        bail!("the update from {} to {} is not proven yet; wait for it (or for its rollback)", p.from, p.to);
292 -
    }
293 -
    let from = env!("CARGO_PKG_VERSION").to_string();
294 -
    let staged = beside(exe, NEW);
295 -
    let _ = std::fs::remove_file(&staged);
296 -
    std::fs::copy(file, &staged).with_context(|| format!("staging {} as {}", file.display(), staged.display()))?;
297 -
    crate::portable::set_mode(&staged, 0o755)?;
298 -
    let checked = check(&staged, &home.root).and_then(|to| {
299 -
        if older(&to, &from) && !allow_older {
300 -
            bail!("flow {to} is older than this runner's {from} (--allow-older to go back)");
301 -
        }
302 -
        Ok(to)
303 -
    });
304 -
    let to = match checked {
305 -
        Ok(v) => v,
306 -
        Err(e) => {
307 -
            let _ = std::fs::remove_file(&staged);
308 -
            return Err(e);
309 -
        }
310 -
    };
311 -
    if dry_run {
312 -
        let _ = std::fs::remove_file(&staged);
313 -
        return Ok(Installed { from, to, exe: exe.to_path_buf(), told: None });
314 -
    }
315 -
    if let Err(e) = swap(exe, &staged) {
316 -
        let _ = std::fs::remove_file(&staged);
317 -
        return Err(e);
318 -
    }
319 -
    let p =
320 -
        Pending { from: from.clone(), to: to.clone(), exe: exe.to_path_buf(), at_ms: now_ms(), notice_secs: notice.as_secs(), starts: 0 };
321 -
    save(&home.root, &p)?;
322 -
    log(&home.root, "installed", &from, &to, "flow runner update --from");
323 -
    let told = running(&home.root).filter(|pid| cfg!(not(unix)) || tell(*pid));
324 -
    Ok(Installed { from, to, exe: exe.to_path_buf(), told })
325 -
}
326 -
327 -
/// Ask runner `pid` to leave for the new build (SIGUSR1).
328 -
#[cfg(unix)]
329 -
fn tell(pid: u32) -> bool {
330 -
    unsafe { libc::kill(pid as libc::pid_t, libc::SIGUSR1) == 0 }
331 -
}
332 -
333 -
/// On Windows: a request in the runner's home, which its loop reads.
334 -
#[cfg(not(unix))]
335 -
fn tell(_pid: u32) -> bool {
336 -
    false
337 -
}
338 -
339 -
/// In the runner's home: drain (claim nothing more, finish what it holds,
340 -
/// then stop), as SIGTERM asks on Linux: `flow runner drain`, for Windows.
341 -
const DRAIN: &str = "drain.request";
342 -
343 -
/// Ask the runner of `home` to drain; whether one is running to ask.
344 -
pub fn ask_to_drain(home: &Path) -> std::io::Result<bool> {
345 -
    std::fs::write(home.join(DRAIN), "")?;
346 -
    Ok(home.join(PID).is_file())
347 -
}
348 -
349 -
/// Whether the runner has been asked, by file, to drain (once).
350 -
pub fn drain_requested(home: &Path) -> bool {
351 -
    std::fs::remove_file(home.join(DRAIN)).is_ok()
352 -
}
353 -
354 -
/// Whether the runner has been asked, by file, to leave for a new build
355 -
/// (and forget the asking).
356 -
pub fn requested(home: &Path) -> bool {
357 -
    std::fs::remove_file(home.join(REQUEST)).is_ok()
358 -
}
359 -
360 -
/// The running runner's process id, from its pid file, if that process is
361 -
/// still a `flow runner`: a stale file's pid may be another program's now,
362 -
/// and SIGUSR1 would end it. Builds before #85 write no pid file (and do
363 -
/// not listen for SIGUSR1), so they are never told.
364 -
#[cfg(unix)]
365 -
fn running(home: &Path) -> Option<u32> {
366 -
    let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?;
367 -
    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
368 -
    is_runner(&cmdline).then_some(pid)
369 -
}
370 -
371 -
/// On Windows: a runner of this home has a pid file, and is asked by a
372 -
/// request file it reads (a stale one is cleared when a runner starts).
373 -
#[cfg(not(unix))]
374 -
fn running(home: &Path) -> Option<u32> {
375 -
    let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?;
376 -
    std::fs::write(home.join(REQUEST), pid.to_string()).ok()?;
377 -
    Some(pid)
378 -
}
379 -
380 -
/// Whether a NUL-separated command line is `flow … runner` with no subcommand.
381 -
#[cfg_attr(not(unix), allow(dead_code))]
382 -
fn is_runner(cmdline: &[u8]) -> bool {
383 -
    let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
384 -
    let Some((argv0, rest)) = args.split_first() else { return false };
385 -
    let name = argv0.rsplit(|b| *b == b'/').next().unwrap_or(argv0);
386 -
    name.starts_with(b"flow") && rest.last() == Some(&&b"runner"[..])
387 -
}
388 -
389 -
/// Note this process as the home's runner, for `flow runner update` to tell.
390 -
pub fn note_runner(home: &Path) {
391 -
    let _ = std::fs::remove_file(home.join(REQUEST));
392 -
    let _ = std::fs::remove_file(home.join(DRAIN));
393 -
    let _ = std::fs::write(home.join(PID), std::process::id().to_string());
394 -
}
395 -
396 -
/// The runner stopped: forget its pid.
397 -
pub fn forget_runner(home: &Path) {
398 -
    if std::fs::read_to_string(home.join(PID)).is_ok_and(|p| p.trim() == std::process::id().to_string()) {
399 -
        let _ = std::fs::remove_file(home.join(PID));
400 -
    }
401 -
}
402 -
403 -
/// What a runner starting up should do about an update.
404 -
#[derive(Debug, PartialEq, Eq)]
405 -
pub enum Start {
406 -
    /// No update waits to be proven.
407 -
    Settled,
408 -
    /// The new build is on trial: prove it within [`PROVE_WITHIN`].
409 -
    Trial(Pending),
410 -
    /// It failed too often; the previous build is back and should be run.
411 -
    RolledBack(Pending),
412 -
}
413 -
414 -
/// Count this start of an unproven build; after [`MAX_STARTS`], put the
415 -
/// previous build back.
416 -
pub fn on_start(home: &Path) -> Result<Start> {
417 -
    let Some(mut p) = pending(home) else { return Ok(Start::Settled) };
418 -
    p.starts += 1;
419 -
    if p.starts > MAX_STARTS {
420 -
        roll_back(home, &p, &format!("{MAX_STARTS} starts without reaching the hub"))?;
421 -
        return Ok(Start::RolledBack(p));
422 -
    }
423 -
    save(home, &p)?;
424 -
    Ok(Start::Trial(p))
425 -
}
426 -
427 -
/// In the runner's home: what to tell the hub once it answers.
428 -
const NOTE: &str = "update.note";
429 -
430 -
/// Keep `note` to tell the hub (a proof, a rollback) once it answers.
431 -
fn note(home: &Path, note: &str) {
432 -
    let _ = std::fs::write(home.join(NOTE), note);
433 -
}
434 -
435 -
/// What there is to tell the hub, once.
436 -
pub fn take_note(home: &Path) -> Option<String> {
437 -
    let n = std::fs::read_to_string(home.join(NOTE)).ok()?;
438 -
    let _ = std::fs::remove_file(home.join(NOTE));
439 -
    Some(n).filter(|n| !n.trim().is_empty())
440 -
}
441 -
442 -
/// The new build reached the hub: it stays.
443 -
pub fn proven(home: &Path) {
444 -
    if let Some(p) = pending(home) {
445 -
        let _ = std::fs::remove_file(home.join(PENDING));
446 -
        log(home, "proven", &p.from, &p.to, "reached the hub");
447 -
        note(home, &format!("runs flow {} (was {}); proven", p.to, p.from));
448 -
        eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {}", p.to, p.from, beside(&p.exe, PREV).display());
449 -
    }
450 -
}
451 -
452 -
/// Put the previous build back and forget the update.
453 -
pub fn roll_back(home: &Path, p: &Pending, why: &str) -> Result<()> {
454 -
    restore(&p.exe)?;
455 -
    let _ = std::fs::remove_file(home.join(PENDING));
456 -
    log(home, "rolled back", &p.to, &p.from, why);
457 -
    note(home, &format!("rolled back from flow {} to {}: {why}", p.to, p.from));
458 -
    eprintln!("flow runner: rolled back from flow {} to {} ({why})", p.to, p.from);
459 -
    Ok(())
460 -
}
461 -
462 -
#[cfg(test)]
463 -
mod tests {
464 -
465 -
    #[test]
466 -
    fn a_build_from_the_swarm_is_copied_only_when_it_is_the_one_attested() {
467 -
        let dir = tempfile::tempdir().unwrap();
468 -
        let (from, to) = (dir.path().join("cas.ext4"), dir.path().join("flow-0.5.2"));
469 -
        std::fs::write(&from, b"flow build").unwrap();
470 -
        let hash = blake3::hash(b"flow build").to_hex().to_string();
471 -
        assert!(copy_checked(&from, &to, 9, &hash).is_err(), "the wrong size");
472 -
        assert!(copy_checked(&from, &to, 10, &"0".repeat(64)).is_err(), "the wrong hash");
473 -
        assert!(!to.exists() && !to.with_extension("partial").exists(), "nothing left behind");
474 -
        copy_checked(&from, &to, 10, &hash).unwrap();
475 -
        assert_eq!(std::fs::read(&to).unwrap(), b"flow build");
476 -
    }
477 -
    use super::*;
478 -
479 -
    #[test]
480 -
    fn versions_are_read_and_compared() {
481 -
        assert_eq!(parse_version("flow 0.3.1 (local build)\n").as_deref(), Some("0.3.1"));
482 -
        assert_eq!(parse_version("flow 0.10.0"), Some("0.10.0".into()));
483 -
        assert_eq!(parse_version("oxy 0.6.0"), None);
484 -
        assert_eq!(parse_version("flow banana"), None);
485 -
        assert!(older("0.3.1", "0.10.0"), "by number, not by text");
486 -
        assert!(!older("0.3.1", "0.3.1"));
487 -
        assert!(older("0.3", "0.3.1"));
488 -
    }
489 -
490 -
    #[test]
491 -
    fn only_a_build_for_this_machine_passes() {
492 -
        let me = std::fs::read(std::env::current_exe().unwrap()).unwrap();
493 -
        check_target(&me[..64]).unwrap();
494 -
        assert!(check_target(b"#!/bin/sh\necho flow 9.9.9\n").unwrap_err().to_string().contains("not an ELF"));
495 -
        let mut other = me[..64].to_vec();
496 -
        other[18..20].copy_from_slice(&(if std::env::consts::ARCH == "riscv64" { 62u16 } else { 243u16 }).to_le_bytes());
497 -
        assert!(check_target(&other).unwrap_err().to_string().contains("another machine"));
498 -
    }
499 -
500 -
    #[test]
501 -
    fn a_swap_keeps_the_old_build_and_three_failed_starts_bring_it_back() {
502 -
        let dir = tempfile::tempdir().unwrap();
503 -
        let (bin, home) = (dir.path().join("bin"), dir.path().join("home"));
504 -
        std::fs::create_dir_all(&bin).unwrap();
505 -
        std::fs::create_dir_all(&home).unwrap();
506 -
        let exe = bin.join("flow");
507 -
        std::fs::write(&exe, "old").unwrap();
508 -
        std::fs::write(bin.join(NEW), "new").unwrap();
509 -
        swap(&exe, &bin.join(NEW)).unwrap();
510 -
        assert_eq!(std::fs::read_to_string(&exe).unwrap(), "new");
511 -
        assert_eq!(std::fs::read_to_string(bin.join(PREV)).unwrap(), "old");
512 -
        assert!(!bin.join(NEW).exists());
513 -
514 -
        assert_eq!(on_start(&home).unwrap(), Start::Settled, "no update, nothing to do");
515 -
        let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 600, starts: 0 };
516 -
        save(&home, &p).unwrap();
517 -
        for n in 1..=MAX_STARTS {
518 -
            assert_eq!(on_start(&home).unwrap(), Start::Trial(Pending { starts: n, ..p.clone() }));
519 -
        }
520 -
        assert!(matches!(on_start(&home).unwrap(), Start::RolledBack(_)));
521 -
        assert_eq!(std::fs::read_to_string(&exe).unwrap(), "old", "the previous build is back");
522 -
        assert!(pending(&home).is_none());
523 -
        let log = std::fs::read_to_string(home.join(LOG)).unwrap();
524 -
        assert!(log.contains("rolled back") && log.contains("0.3.2"), "{log}");
525 -
    }
526 -
527 -
    #[test]
528 -
    fn a_windows_build_is_checked_as_pe_and_named_beside_with_its_extension() {
529 -
        let mut pe = vec![0u8; 0x100];
530 -
        pe[..2].copy_from_slice(b"MZ");
531 -
        pe[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes());
532 -
        pe[0x80..0x84].copy_from_slice(b"PE\0\0");
533 -
        pe[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes());
534 -
        if std::env::consts::ARCH == "x86_64" {
535 -
            check_pe(&pe).unwrap();
536 -
        }
537 -
        pe[0x84..0x86].copy_from_slice(&0x01c4u16.to_le_bytes());
538 -
        assert!(check_pe(&pe).is_err(), "another machine");
539 -
        assert!(check_pe(b"\x7fELF and more bytes than enough for a header, really, quite a few more")
540 -
            .unwrap_err()
541 -
            .to_string()
542 -
            .contains("not a Windows"));
543 -
        assert_eq!(beside(Path::new("C:/flow/flow.exe"), PREV), Path::new("C:/flow/flow.prev.exe"));
544 -
        assert_eq!(beside(Path::new("/usr/local/bin/flow"), NEW), Path::new("/usr/local/bin/flow.new"));
545 -
    }
546 -
547 -
    #[test]
548 -
    fn a_drain_is_asked_by_file_once() {
549 -
        let dir = tempfile::tempdir().unwrap();
550 -
        assert!(!ask_to_drain(dir.path()).unwrap(), "no runner running");
551 -
        assert!(drain_requested(dir.path()) && !drain_requested(dir.path()));
552 -
        note_runner(dir.path());
553 -
        assert!(ask_to_drain(dir.path()).unwrap());
554 -
        note_runner(dir.path());
555 -
        assert!(!drain_requested(dir.path()), "a starting runner clears a stale request");
556 -
    }
557 -
558 -
    #[test]
559 -
    fn a_request_file_asks_once() {
560 -
        let dir = tempfile::tempdir().unwrap();
561 -
        assert!(!requested(dir.path()));
562 -
        std::fs::write(dir.path().join(REQUEST), "1").unwrap();
563 -
        assert!(requested(dir.path()) && !requested(dir.path()), "read once");
564 -
        std::fs::write(dir.path().join(REQUEST), "1").unwrap();
565 -
        note_runner(dir.path());
566 -
        assert!(!requested(dir.path()), "a starting runner clears a stale request");
567 -
    }
568 -
569 -
    #[test]
570 -
    fn only_a_running_runner_is_told() {
571 -
        assert!(is_runner(b"/usr/local/bin/flow\0runner\0"));
572 -
        assert!(is_runner(b"flow\0--home\0/srv/flow\0runner\0"));
573 -
        assert!(!is_runner(b"flow\0runner\0update\0--from\0x\0"), "not the update command itself");
574 -
        assert!(!is_runner(b"/usr/bin/vim\0runner\0"));
575 -
        assert!(!is_runner(b""));
576 -
        let dir = tempfile::tempdir().unwrap();
577 -
        std::fs::write(dir.path().join(PID), std::process::id().to_string()).unwrap();
578 -
        assert_eq!(running(dir.path()), None, "this test is not a flow runner");
579 -
    }
580 -
581 -
    #[test]
582 -
    fn a_proven_build_stays() {
583 -
        let dir = tempfile::tempdir().unwrap();
584 -
        let exe = dir.path().join("flow");
585 -
        std::fs::write(&exe, "new").unwrap();
586 -
        let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 0, starts: 1 };
587 -
        save(dir.path(), &p).unwrap();
588 -
        proven(dir.path());
589 -
        assert!(pending(dir.path()).is_none());
590 -
        assert_eq!(on_start(dir.path()).unwrap(), Start::Settled);
591 -
        assert!(std::fs::read_to_string(dir.path().join(LOG)).unwrap().contains("proven"));
592 -
    }
593 -
}
removed crates/flow/src/wasm.rs +0 −598
1 -
//! The WASM tier (spec §4.1, oxygenflow#75): a job's `exec` names a WASI
2 -
//! preview 1 module, or a glob of modules run one after another, which
3 -
//! wasmtime runs with the job's workspace at `/src` and nothing else: no
4 -
//! network, no other file of the runner. Every runner can offer it, and it
5 -
//! is the tier phones and browsers run (docs/handover.md).
6 -
//!
7 -
//! As in the syscall tier the module runs in a child of the runner
8 -
//! (`flow __wasm SPEC`), so the runner streams its output as it comes,
9 -
//! stops it at the time limit or when the run is cancelled, and measures
10 -
//! its memory the same way; wasmtime itself also holds it to the sandbox's
11 -
//! memory limit.
12 -
13 -
use crate::microvm::{Outcome, Sink};
14 -
use crate::syscall::{lines, self_hash, FAILED};
15 -
use anyhow::{bail, Context, Result};
16 -
use flow_core::api::Record;
17 -
use flow_core::{Environment, Job, MountSource, Sandbox};
18 -
use serde::{Deserialize, Serialize};
19 -
use std::path::{Path, PathBuf};
20 -
use std::sync::mpsc;
21 -
use std::time::{Duration, Instant};
22 -
23 -
/// The runtime, named in every receipt this tier writes.
24 -
pub const WASMTIME: &str = "49.0.4";
25 -
26 -
/// Whether this build has the tier: a build for a system wasmtime does not
27 -
/// run on (Haiku, oxygenflow#91) leaves it out with the `wasm` feature, and
28 -
/// its runner does not offer it.
29 -
pub const AVAILABLE: bool = cfg!(feature = "wasm");
30 -
31 -
/// What a build without the tier says when asked to run a job in it.
32 -
const WITHOUT: &str = "this flow was built without the WASM tier (the `wasm` feature): it runs on systems wasmtime supports";
33 -
34 -
/// A line the helper writes to say something about the job itself (which
35 -
/// module starts), shown as the runner's own note rather than the job's.
36 -
const NOTE: &str = "\u{1e}N ";
37 -
38 -
/// The exit code for a module that trapped (crashed) rather than exiting.
39 -
#[cfg(feature = "wasm")]
40 -
const TRAPPED: i32 = 134;
41 -
42 -
/// What the helper runs, written by the runner for `flow __wasm`.
43 -
#[derive(Serialize, Deserialize)]
44 -
struct Spec {
45 -
    /// The job's files, preopened as `/src` (and as the current directory).
46 -
    workspace: PathBuf,
47 -
    /// The modules to run in turn, relative to the workspace.
48 -
    modules: Vec<String>,
49 -
    /// The arguments after the module.
50 -
    args: Vec<String>,
51 -
    env: Vec<(String, String)>,
52 -
    memory_mib: u32,
53 -
    /// The job's mounts: a directory of the runner and where the module sees it.
54 -
    preopens: Vec<(PathBuf, String)>,
55 -
}
56 -
57 -
/// The receipt environment of a job in this tier: the runtime and its
58 -
/// version, this runner's binary, the memory allowed, and every module run
59 -
/// with its hash, so the receipt says exactly what code ran.
60 -
pub fn environment(sandbox: &Sandbox, job: &Job, modules: &[(String, String)]) -> Environment {
61 -
    Environment {
62 -
        runtime: "wasm".into(),
63 -
        parts: vec![
64 -
            ("wasmtime".into(), WASMTIME.into()),
65 -
            ("flow".into(), self_hash().to_string()),
66 -
            ("memory_mib".into(), sandbox.memory_mib.to_string()),
67 -
        ]
68 -
        .into_iter()
69 -
        .chain(modules.iter().map(|(path, hash)| (format!("module {path}"), hash.clone())))
70 -
        .collect(),
71 -
        rootfs: String::new(),
72 -
        argv: job.exec.clone(),
73 -
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
74 -
    }
75 -
}
76 -
77 -
/// The modules `program` names in `workspace`: one file, or the files a
78 -
/// glob matches (`*` in its last part only), sorted. A path may start with
79 -
/// `/src/`, where the module sees the workspace.
80 -
pub fn modules(workspace: &Path, program: &str) -> Result<Vec<String>> {
81 -
    let rel = program.strip_prefix("/src/").unwrap_or(program).trim_start_matches("./");
82 -
    if rel.is_empty() || rel.starts_with('/') || rel.split('/').any(|p| p == "..") {
83 -
        bail!("module {program:?}: give a path inside the job's files, such as target/wasm32-wasip1/debug/app.wasm");
84 -
    }
85 -
    let (dir, last) = rel.rsplit_once('/').unwrap_or(("", rel));
86 -
    if dir.contains('*') {
87 -
        bail!("module {program:?}: only the file name may hold `*`");
88 -
    }
89 -
    if !last.contains('*') {
90 -
        if !workspace.join(rel).is_file() {
91 -
            bail!("module {rel} is not among the job's files (its scope or an earlier job's artifact)");
92 -
        }
93 -
        return Ok(vec![rel.to_string()]);
94 -
    }
95 -
    let mut found: Vec<String> = std::fs::read_dir(workspace.join(dir))
96 -
        .with_context(|| format!("module {program:?}: the directory {dir} is not among the job's files"))?
97 -
        .filter_map(|e| e.ok())
98 -
        .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
99 -
        .filter_map(|e| e.file_name().into_string().ok())
100 -
        .filter(|name| wildcard(last, name))
101 -
        .map(|name| if dir.is_empty() { name } else { format!("{dir}/{name}") })
102 -
        .collect();
103 -
    found.sort();
104 -
    if found.is_empty() {
105 -
        bail!("no module matches {program:?}");
106 -
    }
107 -
    Ok(found)
108 -
}
109 -
110 -
/// Whether `name` matches `pattern`, where `*` stands for any run of characters.
111 -
fn wildcard(pattern: &str, name: &str) -> bool {
112 -
    let parts: Vec<&str> = pattern.split('*').collect();
113 -
    let (first, last) = (parts[0], parts[parts.len() - 1]);
114 -
    if parts.len() == 1 {
115 -
        return pattern == name;
116 -
    }
117 -
    if !name.starts_with(first) || !name[first.len()..].ends_with(last) || name.len() < first.len() + last.len() {
118 -
        return false;
119 -
    }
120 -
    let mut rest = &name[first.len()..name.len() - last.len()];
121 -
    for middle in &parts[1..parts.len() - 1] {
122 -
        match rest.find(middle) {
123 -
            Some(i) => rest = &rest[i + middle.len()..],
124 -
            None => return false,
125 -
        }
126 -
    }
127 -
    true
128 -
}
129 -
130 -
/// Run `job` in this tier with its files in `workspace` (as the syscall
131 -
/// tier lays them out); `scratch` holds the helper's spec and the job's
132 -
/// mounts.
133 -
pub fn run(sandbox: &Sandbox, job: &Job, workspace: &Path, scratch: &Path, timeout: Duration, sink: &mut dyn Sink) -> Result<Outcome> {
134 -
    if !AVAILABLE {
135 -
        bail!("job {}: {WITHOUT}", job.name);
136 -
    }
137 -
    if !job.secrets.is_empty() || !job.identities.is_empty() {
138 -
        bail!("job {}: the WASM tier takes no secrets or identities yet; run it in a microvm or syscall sandbox", job.name);
139 -
    }
140 -
    let Some(program) = job.exec.first() else { bail!("job {}: exec names no module", job.name) };
141 -
    let modules = modules(workspace, program)?;
142 -
    let hashed: Vec<(String, String)> =
143 -
        modules.iter().map(|m| Ok((m.clone(), crate::home::hash_file(&workspace.join(m))?))).collect::<Result<_>>()?;
144 -
    let environment = environment(sandbox, job, &hashed);
145 -
    let mut preopens = Vec::new();
146 -
    let mut disks = 0;
147 -
    for (i, m) in job.mounts.iter().enumerate() {
148 -
        let dir = match m.source {
149 -
            MountSource::Disk => {
150 -
                disks += 1;
151 -
                crate::syscall::disk_dir(scratch, disks - 1)
152 -
            }
153 -
            MountSource::Tmpfs => scratch.join(format!("tmpfs-{i}")),
154 -
        };
155 -
        std::fs::create_dir_all(&dir)?;
156 -
        preopens.push((dir, m.target.clone()));
157 -
    }
158 -
    let spec = Spec {
159 -
        workspace: workspace.to_path_buf(),
160 -
        modules,
161 -
        args: job.exec[1..].to_vec(),
162 -
        env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
163 -
        memory_mib: sandbox.memory_mib,
164 -
        preopens,
165 -
    };
166 -
    if in_process() {
167 -
        return run_in_thread(&spec, environment, timeout, sink);
168 -
    }
169 -
    let spec_path = scratch.join("wasm.json");
170 -
    std::fs::write(&spec_path, serde_json::to_vec(&spec)?)?;
171 -
172 -
    let started = Instant::now();
173 -
    let mut child = std::process::Command::new(crate::tier::self_exe())
174 -
        .arg("__wasm")
175 -
        .arg(&spec_path)
176 -
        .env_clear()
177 -
        .current_dir(scratch)
178 -
        .stdin(std::process::Stdio::null())
179 -
        .stdout(std::process::Stdio::piped())
180 -
        .stderr(std::process::Stdio::piped())
181 -
        .spawn()
182 -
        .context("start the WASM helper")?;
183 -
    let (tx, rx) = mpsc::channel::<(bool, Vec<u8>)>();
184 -
    let out = child.stdout.take().unwrap();
185 -
    let err = child.stderr.take().unwrap();
186 -
    let tx2 = tx.clone();
187 -
    let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))];
188 -
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
189 -
    let (mut timed_out, mut cancelled, mut failed_to_start) = (false, false, false);
190 -
    let mut peak_memory_mib = None;
191 -
    let deadline = started + timeout;
192 -
    loop {
193 -
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
194 -
            peak_memory_mib = Some(m);
195 -
        }
196 -
        match rx.recv_timeout(Duration::from_millis(200)) {
197 -
            Ok((stdout, line)) => {
198 -
                let text = String::from_utf8_lossy(&line).into_owned();
199 -
                let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() };
200 -
                if let Some(note) = text.strip_prefix(NOTE).filter(|_| !stdout) {
201 -
                    sink.record(rec("flow", note));
202 -
                } else if let Some(why) = text.strip_prefix(FAILED).filter(|_| !stdout) {
203 -
                    failed_to_start = true;
204 -
                    sink.record(rec("flow", &format!("the WASM tier could not run the job: {why}")));
205 -
                } else {
206 -
                    sink.record(rec(if stdout { "out" } else { "err" }, &text));
207 -
                }
208 -
            }
209 -
            Err(mpsc::RecvTimeoutError::Timeout) => {}
210 -
            Err(mpsc::RecvTimeoutError::Disconnected) => break,
211 -
        }
212 -
        if !timed_out && !cancelled && sink.cancelled() {
213 -
            cancelled = true;
214 -
            let _ = child.kill();
215 -
            sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
216 -
        }
217 -
        if Instant::now() >= deadline && !timed_out && !cancelled {
218 -
            timed_out = true;
219 -
            let _ = child.kill();
220 -
            sink.record(Record {
221 -
                t: t(&started),
222 -
                stream: "flow".into(),
223 -
                text: format!("stopped at the time limit of {} s", timeout.as_secs()),
224 -
            });
225 -
        }
226 -
    }
227 -
    let status = child.wait()?;
228 -
    for r in readers {
229 -
        let _ = r.join();
230 -
    }
231 -
    let _ = std::fs::remove_file(&spec_path);
232 -
    let exit_code = status.code();
233 -
    if exit_code.is_none() && !timed_out && !cancelled && !failed_to_start {
234 -
        sink.record(Record {
235 -
            t: t(&started),
236 -
            stream: "flow".into(),
237 -
            text: format!("the WASM helper ended without an exit code ({status})"),
238 -
        });
239 -
    }
240 -
    Ok(Outcome {
241 -
        exit_code: if timed_out || cancelled { None } else { exit_code },
242 -
        duration_ms: t(&started),
243 -
        environment,
244 -
        timed_out,
245 -
        cancelled,
246 -
        peak_memory_mib,
247 -
        pruned: Vec::new(),
248 -
    })
249 -
}
250 -
251 -
/// Whether modules run in a thread of this process rather than in a child
252 -
/// `flow __wasm`: on Android, where the runner is a library inside an app
253 -
/// that cannot start itself as a command (oxygenhub-android#8), and
254 -
/// anywhere `FLOW_WASM_IN_PROCESS` is set (how the tests check this way).
255 -
pub fn in_process() -> bool {
256 -
    cfg!(target_os = "android") || std::env::var_os("FLOW_WASM_IN_PROCESS").is_some()
257 -
}
258 -
259 -
#[cfg(feature = "wasm")]
260 -
/// The most of a module's output kept before it is read, per stream.
261 -
const PIPE_BYTES: usize = 64 << 20;
262 -
263 -
#[cfg(feature = "wasm")]
264 -
/// Run the spec's modules in a thread of this process, streaming their
265 -
/// output as it comes: the pipes are read every 100 ms, and a time limit
266 -
/// or a cancelled run interrupts the module through wasmtime's epoch.
267 -
fn run_in_thread(spec: &Spec, environment: Environment, timeout: Duration, sink: &mut dyn Sink) -> Result<Outcome> {
268 -
    use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
269 -
    let mut config = wasmtime::Config::new();
270 -
    config.epoch_interruption(true);
271 -
    let engine = ww(wasmtime::Engine::new(&config), || "start wasmtime".into())?;
272 -
    let (stdout, stderr) = (MemoryOutputPipe::new(PIPE_BYTES), MemoryOutputPipe::new(PIPE_BYTES));
273 -
    let (notes_tx, notes) = mpsc::channel::<String>();
274 -
    let io = Io::Pipes { stdout: stdout.clone(), stderr: stderr.clone(), notes: notes_tx };
275 -
    let started = Instant::now();
276 -
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
277 -
    let deadline = started + timeout;
278 -
    let (mut timed_out, mut cancelled) = (false, false);
279 -
    // Bytes of each stream already passed on, and the part line not ended yet.
280 -
    let mut said = [(0usize, Vec::<u8>::new()), (0usize, Vec::<u8>::new())];
281 -
    let mut pass_on = |sink: &mut dyn Sink, last: bool| {
282 -
        for (i, pipe) in [&stdout, &stderr].into_iter().enumerate() {
283 -
            let all = pipe.contents();
284 -
            let (offset, partial) = &mut said[i];
285 -
            partial.extend_from_slice(&all[*offset..]);
286 -
            *offset = all.len();
287 -
            while let Some(n) = partial.iter().position(|b| *b == b'\n') {
288 -
                let line: Vec<u8> = partial.drain(..=n).collect();
289 -
                let text = String::from_utf8_lossy(&line[..line.len() - 1]).into_owned();
290 -
                sink.record(Record { t: t(&started), stream: if i == 0 { "out" } else { "err" }.into(), text });
291 -
            }
292 -
            if last && !partial.is_empty() {
293 -
                let text = String::from_utf8_lossy(partial).into_owned();
294 -
                partial.clear();
295 -
                sink.record(Record { t: t(&started), stream: if i == 0 { "out" } else { "err" }.into(), text });
296 -
            }
297 -
        }
298 -
        while let Ok(note) = notes.try_recv() {
299 -
            sink.record(Record { t: t(&started), stream: "flow".into(), text: note });
300 -
        }
301 -
    };
302 -
    let ran = std::thread::scope(|scope| {
303 -
        let worker = scope.spawn(|| run_modules(&engine, spec, &io));
304 -
        while !worker.is_finished() {
305 -
            std::thread::sleep(Duration::from_millis(100));
306 -
            pass_on(sink, false);
307 -
            // Once stopped, keep moving the epoch: a store made after the
308 -
            // first increment (its module still compiling when the time
309 -
            // limit or the cancel came) sets its deadline one epoch past
310 -
            // that, and a single increment would never reach it (#93).
311 -
            if timed_out || cancelled {
312 -
                engine.increment_epoch();
313 -
            }
314 -
            if !timed_out && !cancelled && sink.cancelled() {
315 -
                cancelled = true;
316 -
                engine.increment_epoch();
317 -
                sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() });
318 -
            }
319 -
            if Instant::now() >= deadline && !timed_out && !cancelled {
320 -
                timed_out = true;
321 -
                engine.increment_epoch();
322 -
                sink.record(Record {
323 -
                    t: t(&started),
324 -
                    stream: "flow".into(),
325 -
                    text: format!("stopped at the time limit of {} s", timeout.as_secs()),
326 -
                });
327 -
            }
328 -
        }
329 -
        worker.join().unwrap_or_else(|_| Err(anyhow::anyhow!("the WASM worker panicked")))
330 -
    });
331 -
    pass_on(sink, true);
332 -
    let (exit_code, peak) = match ran {
333 -
        Ok((code, peak)) => (Some(code), Some(peak)),
334 -
        Err(e) => {
335 -
            sink.record(Record { t: t(&started), stream: "flow".into(), text: format!("the WASM tier could not run the job: {e:#}") });
336 -
            (Some(125), None)
337 -
        }
338 -
    };
339 -
    Ok(Outcome {
340 -
        exit_code: if timed_out || cancelled { None } else { exit_code },
341 -
        duration_ms: t(&started),
342 -
        environment,
343 -
        timed_out,
344 -
        cancelled,
345 -
        peak_memory_mib: peak.map(|b| (b as u64).div_ceil(1 << 20)),
346 -
        pruned: Vec::new(),
347 -
    })
348 -
}
349 -
350 -
#[cfg(feature = "wasm")]
351 -
/// `flow __wasm SPEC`: run the spec's modules in turn, each in a fresh
352 -
/// instance; the exit code is the first non-zero one, after all have run,
353 -
/// so one failing module does not hide another's results.
354 -
pub fn wasm_main(spec_path: &Path) -> i32 {
355 -
    let ran = (|| {
356 -
        let spec: Spec = serde_json::from_slice(&std::fs::read(spec_path)?).context("read the spec")?;
357 -
        let engine = ww(wasmtime::Engine::new(&wasmtime::Config::new()), || "start wasmtime".into())?;
358 -
        run_modules(&engine, &spec, &Io::Inherit)
359 -
    })();
360 -
    match ran {
361 -
        Ok((code, _)) => code,
362 -
        Err(e) => {
363 -
            eprintln!("{FAILED}{e:#}");
364 -
            125
365 -
        }
366 -
    }
367 -
}
368 -
369 -
/// `flow __wasm SPEC` in a build without the tier: says so.
370 -
#[cfg(not(feature = "wasm"))]
371 -
pub fn wasm_main(_spec_path: &Path) -> i32 {
372 -
    eprintln!("{FAILED}{WITHOUT}");
373 -
    125
374 -
}
375 -
376 -
#[cfg(not(feature = "wasm"))]
377 -
fn run_in_thread(_spec: &Spec, _environment: Environment, _timeout: Duration, _sink: &mut dyn Sink) -> Result<Outcome> {
378 -
    bail!(WITHOUT)
379 -
}
380 -
381 -
#[cfg(feature = "wasm")]
382 -
/// wasmtime's own error, as this crate's: with its whole chain of causes.
383 -
fn ww<T>(r: wasmtime::Result<T>, what: impl FnOnce() -> String) -> Result<T> {
384 -
    r.map_err(|e| anyhow::anyhow!("{}: {e:#}", what()))
385 -
}
386 -
387 -
#[cfg(feature = "wasm")]
388 -
/// Where a module's output and the tier's notes go: inherited by the
389 -
/// helper process (notes on stderr, marked), or pipes the runner reads.
390 -
enum Io {
391 -
    Inherit,
392 -
    Pipes {
393 -
        stdout: wasmtime_wasi::p2::pipe::MemoryOutputPipe,
394 -
        stderr: wasmtime_wasi::p2::pipe::MemoryOutputPipe,
395 -
        notes: mpsc::Sender<String>,
396 -
    },
397 -
}
398 -
399 -
#[cfg(feature = "wasm")]
400 -
impl Io {
401 -
    fn note(&self, text: String) {
402 -
        match self {
403 -
            Io::Inherit => eprintln!("{NOTE}{text}"),
404 -
            Io::Pipes { notes, .. } => {
405 -
                let _ = notes.send(text);
406 -
            }
407 -
        }
408 -
    }
409 -
}
410 -
411 -
#[cfg(feature = "wasm")]
412 -
/// The sandbox's memory limit, and the most a module asked for within it.
413 -
struct Limits {
414 -
    memory: usize,
415 -
    peak: usize,
416 -
}
417 -
418 -
#[cfg(feature = "wasm")]
419 -
impl wasmtime::ResourceLimiter for Limits {
420 -
    fn memory_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
421 -
        let ok = desired <= self.memory && maximum.is_none_or(|m| desired <= m);
422 -
        if ok {
423 -
            self.peak = self.peak.max(desired);
424 -
        }
425 -
        Ok(ok)
426 -
    }
427 -
428 -
    fn table_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
429 -
        Ok(maximum.is_none_or(|m| desired <= m))
430 -
    }
431 -
}
432 -
433 -
#[cfg(feature = "wasm")]
434 -
struct State {
435 -
    wasi: wasmtime_wasi::p1::WasiP1Ctx,
436 -
    limits: Limits,
437 -
}
438 -
439 -
#[cfg(feature = "wasm")]
440 -
/// Run each module in turn: the first non-zero exit code after all ran,
441 -
/// and the most memory any of them held, in bytes.
442 -
fn run_modules(engine: &wasmtime::Engine, spec: &Spec, io: &Io) -> Result<(i32, usize)> {
443 -
    let mut linker: wasmtime::Linker<State> = wasmtime::Linker::new(engine);
444 -
    ww(wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s: &mut State| &mut s.wasi), || "link WASI".into())?;
445 -
    let (mut first_failure, mut peak) = (0, 0);
446 -
    for path in &spec.modules {
447 -
        if spec.modules.len() > 1 {
448 -
            io.note(format!("running {path}"));
449 -
        }
450 -
        let (code, held) = run_module(engine, &linker, spec, path, io)?;
451 -
        peak = peak.max(held);
452 -
        if spec.modules.len() > 1 {
453 -
            io.note(format!("{path}: exit {code}"));
454 -
        }
455 -
        if first_failure == 0 && code != 0 {
456 -
            first_failure = code;
457 -
        }
458 -
    }
459 -
    Ok((first_failure, peak))
460 -
}
461 -
462 -
#[cfg(feature = "wasm")]
463 -
fn run_module(engine: &wasmtime::Engine, linker: &wasmtime::Linker<State>, spec: &Spec, path: &str, io: &Io) -> Result<(i32, usize)> {
464 -
    use wasmtime_wasi::{FsPerms, WasiCtxBuilder};
465 -
    let module = ww(wasmtime::Module::from_file(engine, spec.workspace.join(path)), || format!("module {path} does not load"))?;
466 -
    let name = path.rsplit('/').next().unwrap_or(path);
467 -
    let mut argv = vec![name.to_string()];
468 -
    argv.extend(spec.args.iter().cloned());
469 -
    let mut wasi = WasiCtxBuilder::new();
470 -
    match io {
471 -
        Io::Inherit => {
472 -
            wasi.inherit_stdout().inherit_stderr();
473 -
        }
474 -
        Io::Pipes { stdout, stderr, .. } => {
475 -
            wasi.stdout(stdout.clone()).stderr(stderr.clone());
476 -
        }
477 -
    }
478 -
    wasi.args(&argv).envs(&spec.env).env("PWD", "/src").allow_blocking_current_thread(true);
479 -
    let open = |wasi: &mut WasiCtxBuilder, dir: &Path, at: &str| {
480 -
        ww(wasi.preopened_dir(dir, at, FsPerms::ReadWrite).map(|_| ()), || format!("open {} as {at}", dir.display()))
481 -
    };
482 -
    open(&mut wasi, &spec.workspace, "/src")?;
483 -
    // Relative paths resolve against the workspace too.
484 -
    open(&mut wasi, &spec.workspace, ".")?;
485 -
    for (dir, target) in &spec.preopens {
486 -
        open(&mut wasi, dir, target)?;
487 -
    }
488 -
    let limits = Limits { memory: spec.memory_mib as usize * 1024 * 1024, peak: 0 };
489 -
    let mut store = wasmtime::Store::new(engine, State { wasi: wasi.build_p1(), limits });
490 -
    store.limiter(|s| &mut s.limits);
491 -
    // With epoch interruption on (in process), one tick from now traps:
492 -
    // the runner ticks only to stop the module.
493 -
    store.set_epoch_deadline(1);
494 -
    let instance = ww(linker.instantiate(&mut store, &module), || format!("module {path} cannot start here"))?;
495 -
    let start =
496 -
        ww(instance.get_typed_func::<(), ()>(&mut store, "_start"), || format!("module {path} has no _start: is it a WASI command?"))?;
497 -
    let code = match start.call(&mut store, ()) {
498 -
        Ok(()) => 0,
499 -
        Err(e) => match e.downcast_ref::<wasmtime_wasi::I32Exit>() {
500 -
            Some(exit) => exit.0,
501 -
            None => {
502 -
                io.note(format!("{path} trapped: {e:#}"));
503 -
                TRAPPED
504 -
            }
505 -
        },
506 -
    };
507 -
    Ok((code, store.data().limits.peak))
508 -
}
509 -
510 -
#[cfg(all(test, feature = "wasm"))]
511 -
mod tests {
512 -
    use super::*;
513 -
514 -
    /// A spec running `text` (WAT) as the only module, with 64 MiB.
515 -
    fn spec_of(dir: &Path, text: &str) -> Spec {
516 -
        std::fs::write(dir.join("m.wasm"), wat::parse_str(text).unwrap()).unwrap();
517 -
        Spec { workspace: dir.to_path_buf(), modules: vec!["m.wasm".into()], args: vec![], env: vec![], memory_mib: 64, preopens: vec![] }
518 -
    }
519 -
520 -
    fn env() -> Environment {
521 -
        Environment { runtime: "wasm".into(), parts: vec![], rootfs: String::new(), argv: vec![], env: vec![] }
522 -
    }
523 -
524 -
    /// Records, and whether to say the run was cancelled.
525 -
    struct Collect(Vec<Record>, bool);
526 -
    impl Sink for Collect {
527 -
        fn record(&mut self, r: Record) {
528 -
            self.0.push(r);
529 -
        }
530 -
        fn cancelled(&mut self) -> bool {
531 -
            self.1
532 -
        }
533 -
    }
534 -
535 -
    const PRINT: &str = r#"(module
536 -
      (import "wasi_snapshot_preview1" "fd_write" (func $w (param i32 i32 i32 i32) (result i32)))
537 -
      (memory (export "memory") 2)
538 -
      (data (i32.const 16) "line one\nline two")
539 -
      (func (export "_start")
540 -
        (i32.store (i32.const 0) (i32.const 16)) (i32.store (i32.const 4) (i32.const 17))
541 -
        (drop (call $w (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#;
542 -
543 -
    const SPIN: &str = r#"(module (func (export "_start") (loop $l (br $l))))"#;
544 -
545 -
    #[test]
546 -
    fn in_process_output_streams_as_lines() {
547 -
        let dir = tempfile::tempdir().unwrap();
548 -
        let mut sink = Collect(vec![], false);
549 -
        let o = run_in_thread(&spec_of(dir.path(), PRINT), env(), Duration::from_secs(30), &mut sink).unwrap();
550 -
        assert_eq!(o.exit_code, Some(0));
551 -
        let out: Vec<&str> = sink.0.iter().filter(|r| r.stream == "out").map(|r| r.text.as_str()).collect();
552 -
        assert_eq!(out, ["line one", "line two"], "a last line without its newline still comes");
553 -
        assert_eq!(o.peak_memory_mib, Some(1), "two 64 KiB pages, in whole MiB");
554 -
    }
555 -
556 -
    #[test]
557 -
    fn in_process_stops_at_the_time_limit_and_when_cancelled() {
558 -
        let dir = tempfile::tempdir().unwrap();
559 -
        let mut sink = Collect(vec![], false);
560 -
        let started = Instant::now();
561 -
        let o = run_in_thread(&spec_of(dir.path(), SPIN), env(), Duration::from_millis(300), &mut sink).unwrap();
562 -
        assert!(o.timed_out && o.exit_code.is_none(), "{:?}", sink.0);
563 -
        assert!(started.elapsed() < Duration::from_secs(5), "the endless loop was interrupted");
564 -
        let mut sink = Collect(vec![], true);
565 -
        let o = run_in_thread(&spec_of(dir.path(), SPIN), env(), Duration::from_secs(30), &mut sink).unwrap();
566 -
        assert!(o.cancelled && o.exit_code.is_none());
567 -
        assert!(sink.0.iter().any(|r| r.text == "stopped: the run was cancelled"));
568 -
    }
569 -
570 -
    #[test]
571 -
    fn globs_match_names_with_stars() {
572 -
        assert!(wildcard("*.wasm", "oxy_core-1a2b.wasm"));
573 -
        assert!(wildcard("oxy_core-*.wasm", "oxy_core-1a2b.wasm"));
574 -
        assert!(!wildcard("oxy_core-*.wasm", "oxy_core-1a2b.d"));
575 -
        assert!(wildcard("a*b*c", "aXbYc") && !wildcard("a*b*c", "aXcYb"));
576 -
        assert!(wildcard("plain.wasm", "plain.wasm") && !wildcard("plain.wasm", "other.wasm"));
577 -
    }
578 -
579 -
    #[test]
580 -
    fn modules_are_files_of_the_job() {
581 -
        let dir = tempfile::tempdir().unwrap();
582 -
        let deps = dir.path().join("target/wasm32-wasip1/debug/deps");
583 -
        std::fs::create_dir_all(&deps).unwrap();
584 -
        for f in ["b-2.wasm", "a-1.wasm", "a-1.d"] {
585 -
            std::fs::write(deps.join(f), b"").unwrap();
586 -
        }
587 -
        let ws = dir.path();
588 -
        assert_eq!(
589 -
            modules(ws, "/src/target/wasm32-wasip1/debug/deps/*.wasm").unwrap(),
590 -
            ["target/wasm32-wasip1/debug/deps/a-1.wasm", "target/wasm32-wasip1/debug/deps/b-2.wasm"]
591 -
        );
592 -
        assert_eq!(modules(ws, "./target/wasm32-wasip1/debug/deps/b-2.wasm").unwrap(), ["target/wasm32-wasip1/debug/deps/b-2.wasm"]);
593 -
        assert!(modules(ws, "target/wasm32-wasip1/debug/deps/*.so").unwrap_err().to_string().contains("no module matches"));
594 -
        assert!(modules(ws, "../etc/passwd").is_err());
595 -
        assert!(modules(ws, "/etc/x.wasm").is_err());
596 -
        assert!(modules(ws, "target/*/debug/a.wasm").unwrap_err().to_string().contains("only the file name"));
597 -
    }
598 -
}
modified crates/flow/src/workspace.rs +8 −171
…
37 unchanged lines
38 38
                }
39 39
                if *kind == NodeKind::Symlink {
40 40
                    let to = String::from_utf8_lossy(&bytes).into_owned();
41 -
                    crate::portable::symlink(Path::new(&to), &target).with_context(|| format!("symlink {path}"))?;
41 +
                    std::os::unix::fs::symlink(&to, &target).with_context(|| format!("symlink {path}"))?;
42 42
                } else {
43 43
                    std::fs::write(&target, &bytes).with_context(|| format!("write {path}"))?;
44 44
                    if f.mode.exec {
45 -
                        crate::portable::set_mode(&target, 0o755)?;
45 +
                        use std::os::unix::fs::PermissionsExt;
46 +
                        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
46 47
                    }
47 48
                }
48 49
                files.push((path.clone(), *blake3::hash(&bytes).as_bytes()));
…
7 unchanged lines
56 57
/// `.gitignore` exclude) under `dir`, for `flow run`.
57 58
pub fn copy_tree(src: &Path, dir: &Path) -> Result<Files> {
58 59
    let mut files = Vec::new();
59 -
    // A file at the top would otherwise find no directory to go in.
60 -
    std::fs::create_dir_all(dir)?;
61 60
    let walk = ignore::WalkBuilder::new(src)
62 61
        .hidden(false)
63 62
        .git_ignore(true)
…
14 unchanged lines
78 77
            std::fs::create_dir_all(&target)?;
79 78
        } else if ft.is_symlink() {
80 79
            let to = std::fs::read_link(entry.path())?;
81 -
            crate::portable::symlink(&to, &target)?;
80 +
            std::os::unix::fs::symlink(&to, &target)?;
82 81
            files.push((rel_s, *blake3::hash(to.to_string_lossy().as_bytes()).as_bytes()));
83 82
        } else if ft.is_file() {
84 83
            std::fs::copy(entry.path(), &target)?;
…
16 unchanged lines
101 100
        .map(|m| m.len().max(4096))
102 101
        .sum();
103 102
    let mib = used * 12 / 10 / (1 << 20) + 512;
104 -
    let (uid, gid) = owner();
103 +
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
105 104
    let status = std::process::Command::new("mkfs.ext4")
106 105
        .args(["-q", "-F", "-L", "workspace", "-E"])
107 106
        .arg(format!("root_owner={uid}:{gid}"))
…
7 unchanged lines
115 114
        bail!("mkfs.ext4 failed for the workspace image");
116 115
    }
117 116
    Ok(())
118 -
}
119 -
120 -
/// Give the files of `dir` whose content a cached disk was built from
121 -
/// the mtime they had then (oxygenflow#52), and describe this checkout's
122 -
/// sources for the disk to keep: unchanged files are fresh to a build
123 -
/// tool that judges by time, changed ones newer than anything it built.
124 -
/// Whole seconds, as the workspace image keeps them. Returns the sources
125 -
/// and how many files got their time back.
126 -
pub fn restore_times(dir: &Path, files: &Files, before: Option<&crate::cache::Sources>) -> (crate::cache::Sources, usize) {
127 -
    let mut out = crate::cache::Sources::new();
128 -
    let mut restored = 0;
129 -
    for (path, hash) in files {
130 -
        let p = dir.join(path);
131 -
        let Ok(md) = std::fs::symlink_metadata(&p) else { continue };
132 -
        if !md.is_file() {
133 -
            continue;
134 -
        }
135 -
        let hex = hex::encode(hash);
136 -
        let kept = before.and_then(|b| b.get(path)).filter(|(h, _)| *h == hex).map(|(_, t)| *t);
137 -
        let t = match kept {
138 -
            Some(t) => {
139 -
                let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.max(0) as u64);
140 -
                if std::fs::File::options().write(true).open(&p).and_then(|f| f.set_modified(at)).is_err() {
141 -
                    continue;
142 -
                }
143 -
                restored += 1;
144 -
                t
145 -
            }
146 -
            None => md.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64).unwrap_or(0),
147 -
        };
148 -
        out.insert(path.clone(), (hex, t));
149 -
    }
150 -
    // Directories too, after their files (#52): a build script's
151 -
    // `rerun-if-changed` on a directory looks at its time as well. A
152 -
    // directory whose listing is unchanged gets its old time back; one that
153 -
    // gained or lost an entry keeps its checkout's, so the change shows.
154 -
    restore_dir_times(dir, Path::new(""), before, &mut out, &mut restored);
155 -
    (out, restored)
156 -
}
157 -
158 -
/// The key a directory's entry has in the sources: its path and a slash.
159 -
fn dir_key(rel: &Path) -> String {
160 -
    format!("{}/", rel.display())
161 117
}
162 118
163 -
fn restore_dir_times(
164 -
    root: &Path,
165 -
    rel: &Path,
166 -
    before: Option<&crate::cache::Sources>,
167 -
    out: &mut crate::cache::Sources,
168 -
    restored: &mut usize,
169 -
) {
170 -
    let here = root.join(rel);
171 -
    let Ok(entries) = std::fs::read_dir(&here) else { return };
172 -
    let mut names: Vec<(String, bool)> = Vec::new();
173 -
    for e in entries.flatten() {
174 -
        let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
175 -
        names.push((e.file_name().to_string_lossy().into_owned(), is_dir));
176 -
    }
177 -
    names.sort();
178 -
    for (name, is_dir) in &names {
179 -
        if *is_dir {
180 -
            restore_dir_times(root, &rel.join(name), before, out, restored);
181 -
        }
182 -
    }
183 -
    let mut h = blake3::Hasher::new();
184 -
    for (name, is_dir) in &names {
185 -
        h.update(name.as_bytes());
186 -
        h.update(if *is_dir { b"/\n".as_slice() } else { b"\n".as_slice() });
187 -
    }
188 -
    let hex = h.finalize().to_hex().to_string();
189 -
    let key = dir_key(rel);
190 -
    let kept = before.and_then(|b| b.get(&key)).filter(|(h, _)| *h == hex).map(|(_, t)| *t);
191 -
    let t = match kept {
192 -
        Some(t) => {
193 -
            let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.max(0) as u64);
194 -
            if std::fs::File::open(&here).and_then(|f| f.set_modified(at)).is_err() {
195 -
                return;
196 -
            }
197 -
            *restored += 1;
198 -
            t
199 -
        }
200 -
        None => std::fs::metadata(&here)
201 -
            .ok()
202 -
            .and_then(|m| m.modified().ok())
203 -
            .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
204 -
            .map(|d| d.as_secs() as i64)
205 -
            .unwrap_or(0),
206 -
    };
207 -
    out.insert(key, (hex, t));
208 -
}
209 -
210 119
/// An empty ext4 filesystem of `mib` MiB at `out`, owned by this user like
211 120
/// the workspace. mkfs.ext4 creates the file sparse, so only what the job
212 121
/// writes takes space on the runner.
213 122
pub fn empty_ext4(out: &Path, mib: u32, label: &str) -> Result<()> {
214 -
    let (uid, gid) = owner();
123 +
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
215 124
    let status = std::process::Command::new("mkfs.ext4")
216 125
        .args(["-q", "-F", "-L", label, "-E"])
217 126
        .arg(format!("root_owner={uid}:{gid}"))
…
20 unchanged lines
238 147
    }
239 148
}
240 149
241 -
/// The runner's user and group, which own a workspace image's root.
242 -
fn owner() -> (u32, u32) {
243 -
    #[cfg(unix)]
244 -
    {
245 -
        unsafe { (libc::getuid(), libc::getgid()) }
246 -
    }
247 -
    #[cfg(not(unix))]
248 -
    {
249 -
        (0, 0)
250 -
    }
251 -
}
252 -
253 150
fn open_up(dir: &Path) {
151 +
    use std::os::unix::fs::PermissionsExt;
254 152
    let Ok(meta) = std::fs::symlink_metadata(dir) else { return };
255 153
    if !meta.is_dir() {
256 154
        return;
257 155
    }
258 -
    let _ = crate::portable::set_mode(dir, 0o700);
156 +
    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
259 157
    if let Ok(entries) = std::fs::read_dir(dir) {
260 158
        for e in entries.flatten() {
261 159
            open_up(&e.path());
262 160
        }
263 -
    }
264 -
}
265 -
266 -
#[cfg(test)]
267 -
mod tests {
268 -
    use super::*;
269 -
    use std::time::UNIX_EPOCH;
270 -
271 -
    fn secs(p: &Path) -> i64 {
272 -
        std::fs::metadata(p).unwrap().modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
273 -
    }
274 -
275 -
    /// oxygenflow#52: an unchanged file gets the time it had when the cached
276 -
    /// disk was built; a changed or new one keeps its checkout's, newer.
277 -
    #[test]
278 -
    fn unchanged_sources_get_their_cached_times_back() {
279 -
        let dir = tempfile::tempdir().unwrap();
280 -
        for (name, text) in [("same.rs", "a"), ("changed.rs", "b2"), ("new.rs", "c")] {
281 -
            std::fs::write(dir.path().join(name), text).unwrap();
282 -
        }
283 -
        std::os::unix::fs::symlink("same.rs", dir.path().join("link.rs")).unwrap();
284 -
        let h = |t: &str| *blake3::hash(t.as_bytes()).as_bytes();
285 -
        let files: Files =
286 -
            vec![("same.rs".into(), h("a")), ("changed.rs".into(), h("b2")), ("new.rs".into(), h("c")), ("link.rs".into(), h("same.rs"))];
287 -
        let now = secs(&dir.path().join("same.rs"));
288 -
        let before: crate::cache::Sources =
289 -
            [("same.rs".to_string(), (hex::encode(h("a")), 1_000_000)), ("changed.rs".to_string(), (hex::encode(h("b1")), 1_000_000))]
290 -
                .into();
291 -
292 -
        let (sources, restored) = restore_times(dir.path(), &files, Some(&before));
293 -
        assert_eq!(restored, 1);
294 -
        assert_eq!(secs(&dir.path().join("same.rs")), 1_000_000, "unchanged: fresh to cargo");
295 -
        assert!(secs(&dir.path().join("changed.rs")) >= now, "changed: newer than the cached build");
296 -
        assert_eq!(sources["same.rs"], (hex::encode(h("a")), 1_000_000));
297 -
        assert_eq!(sources["changed.rs"].0, hex::encode(h("b2")), "kept with its new content");
298 -
        assert!(sources.contains_key("new.rs") && !sources.contains_key("link.rs"), "files only");
299 -
300 -
        // Without a cached build nothing moves, and the sources are listed:
301 -
        // three files and the directory holding them.
302 -
        let (again, restored) = restore_times(dir.path(), &files, None);
303 -
        assert_eq!((restored, again.len()), (0, 4));
304 -
        assert!(again.contains_key("/"));
305 -
306 -
        // A directory whose listing is unchanged gets its time back; one
307 -
        // that gained an entry keeps its new time, so a build script's
308 -
        // `rerun-if-changed` on it sees the change.
309 -
        std::fs::create_dir(dir.path().join("migrations")).unwrap();
310 -
        std::fs::write(dir.path().join("migrations/0001.sql"), "x").unwrap();
311 -
        let mut files = files;
312 -
        files.push(("migrations/0001.sql".into(), h("x")));
313 -
        let (mut kept, _) = restore_times(dir.path(), &files, None);
314 -
        kept.get_mut("migrations/").unwrap().1 = 2_000_000;
315 -
        kept.get_mut("migrations/0001.sql").unwrap().1 = 2_000_000;
316 -
        let (_, restored) = restore_times(dir.path(), &files, Some(&kept));
317 -
        assert_eq!(secs(&dir.path().join("migrations")), 2_000_000, "unchanged listing: its old time");
318 -
        assert!(restored >= 2);
319 -
        std::fs::write(dir.path().join("migrations/0002.sql"), "y").unwrap();
320 -
        files.push(("migrations/0002.sql".into(), h("y")));
321 -
        let (now_sources, _) = restore_times(dir.path(), &files, Some(&kept));
322 -
        assert!(secs(&dir.path().join("migrations")) > 2_000_000, "a new entry: it looks changed");
323 -
        assert_ne!(now_sources["migrations/"].0, kept["migrations/"].0);
324 161
    }
325 162
}
modified crates/flow/tests/syscall.rs +1 −17
…
9 unchanged lines
10 10
11 11
const APPLETS: &[&str] = &[
12 12
    "sh", "echo", "cat", "id", "touch", "wget", "tail", "cut", "tr", "true", "false", "ls", "grep", "wc", "mount", "hostname", "head",
13 -
    "sed", "httpd", "mkdir", "sleep",
13 +
    "sed",
14 14
];
15 15
16 16
/// `flow`, in a delegated cgroup scope of its own when systemd's user
…
51 unchanged lines
68 68
    runtime "syscall"
69 69
    rootfs "cas://blob/{rootfs}"
70 70
    memory-limit "256MiB"
71 -
}}
72 -
sandbox "bb-lo" {{
73 -
    runtime "syscall"
74 -
    rootfs "cas://blob/{rootfs}"
75 -
    memory-limit "256MiB"
76 -
    network "loopback"
77 -
}}
78 -
job "loopback" {{
79 -
    sandbox "bb-lo"
80 -
    exec "sh" "-c" "mkdir -p /tmp/www && echo SERVED > /tmp/www/x && httpd -p 127.0.0.1:8080 -h /tmp/www && sleep 1 && wget -q -O - http://127.0.0.1:8080/x; wget -q -T 2 -O /dev/null http://1.1.1.1 && echo LO-NETWORK || echo lo-only"
81 71
}}
82 72
job "literal" {{
83 73
    sandbox "bb"
…
41 unchanged lines
125 115
    assert!(stdout.contains("   flow\n"), "its own host name: {all}");
126 116
    assert!(!stdout.contains("   MOUNTED\n") && stdout.contains("   done\n"), "no mounts: {all}");
127 117
    assert!(stderr.contains("Address family not supported"), "sockets other than AF_UNIX are refused: {all}");
128 -
    // oxygenflow#73: with network "loopback", a server on 127.0.0.1 answers
129 -
    // its own job, and nothing past lo is reached.
130 -
    assert!(
131 -
        stdout.contains("   SERVED\n") && stdout.contains("   lo-only\n") && !stdout.contains("   LO-NETWORK\n"),
132 -
        "loopback TCP: {all}"
133 -
    );
134 118
    assert!(!work.join("w").exists(), "jobs write to their own copy: {all}");
135 119
    let procs: u32 = stdout.lines().find_map(|l| l.trim().strip_prefix("procs ")).and_then(|n| n.parse().ok()).expect(&all);
136 120
    assert!(procs < 10, "only the sandbox's processes are visible: {all}");
…
7 unchanged lines
removed crates/flow/tests/wasm.rs +0 −150
1 -
//! The WASM tier (oxygenflow#75): modules written as text, run by `flow
2 -
//! run` through wasmtime. Every machine offers the tier, so nothing is
3 -
//! skipped.
4 -
5 -
use std::path::Path;
6 -
use std::process::Command;
7 -
8 -
const IMPORTS: &str = r#"
9 -
  (import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
10 -
  (import "wasi_snapshot_preview1" "proc_exit" (func $proc_exit (param i32)))
11 -
  (import "wasi_snapshot_preview1" "path_open"
12 -
    (func $path_open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
13 -
  (import "wasi_snapshot_preview1" "args_sizes_get" (func $args_sizes_get (param i32 i32) (result i32)))
14 -
  (memory (export "memory") 1)
15 -
"#;
16 -
17 -
/// A module whose `_start` is `body`, with the WASI calls above.
18 -
fn module(dir: &Path, path: &str, data: &str, body: &str) {
19 -
    let text = format!("(module {IMPORTS} {data} (func (export \"_start\") (local $e i32) {body}))");
20 -
    let bytes = wat::parse_str(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
21 -
    let file = dir.join(path);
22 -
    std::fs::create_dir_all(file.parent().unwrap()).unwrap();
23 -
    std::fs::write(file, bytes).unwrap();
24 -
}
25 -
26 -
/// Print the `len` bytes at 16 to fd `fd`.
27 -
fn print(fd: i32, len: i32) -> String {
28 -
    format!("(i32.store (i32.const 0) (i32.const 16)) (i32.store (i32.const 4) (i32.const {len})) (drop (call $fd_write (i32.const {fd}) (i32.const 0) (i32.const 1) (i32.const 8)))")
29 -
}
30 -
31 -
#[test]
32 -
fn wasi_modules_run_confined_in_the_wasm_tier() {
33 -
    confined(false);
34 -
}
35 -
36 -
/// The same in a thread of the runner, as on Android, where the runner is
37 -
/// a library in an app that cannot start itself as a command.
38 -
#[test]
39 -
fn wasi_modules_run_confined_in_process() {
40 -
    confined(true);
41 -
}
42 -
43 -
fn confined(in_process: bool) {
44 -
    let dir = tempfile::tempdir().unwrap();
45 -
    let home = dir.path().join("home");
46 -
    let work = dir.path().join("work");
47 -
    let tiers = Command::new(env!("CARGO_BIN_EXE_flow")).arg("--home").arg(&home).arg("tiers").output().unwrap();
48 -
    assert!(String::from_utf8_lossy(&tiers.stdout).contains("wasm"), "{}", String::from_utf8_lossy(&tiers.stdout));
49 -
50 -
    // Prints, and says how many arguments it had (2: its name and one).
51 -
    module(
52 -
        &work,
53 -
        "mods/hello.wasm",
54 -
        r#"(data (i32.const 16) "hello from wasm\n")"#,
55 -
        &format!(
56 -
            "{} (drop (call $args_sizes_get (i32.const 100) (i32.const 104))) (if (i32.ne (i32.load (i32.const 100)) (i32.const 2)) (then (call $proc_exit (i32.const 7))))",
57 -
            print(1, 16)
58 -
        ),
59 -
    );
60 -
    // Two modules under one glob: the first passes, the second exits 3.
61 -
    module(&work, "mods/each/a.wasm", r#"(data (i32.const 16) "module a\n")"#, &print(1, 9));
62 -
    module(&work, "mods/each/b.wasm", r#"(data (i32.const 16) "module b\n")"#, &format!("{} (call $proc_exit (i32.const 3))", print(2, 9)));
63 -
    // Creates a file in the workspace (preopen 3 is /src).
64 -
    module(
65 -
        &work,
66 -
        "mods/writer.wasm",
67 -
        r#"(data (i32.const 64) "made.txt") (data (i32.const 16) "made in wasm\n")"#,
68 -
        "(local.set $e (call $path_open (i32.const 3) (i32.const 0) (i32.const 64) (i32.const 8) (i32.const 9) (i64.const 0x1fffffff) (i64.const 0x1fffffff) (i32.const 0) (i32.const 200)))
69 -
         (if (local.get $e) (then (call $proc_exit (i32.const 10))))
70 -
         (i32.store (i32.const 0) (i32.const 16)) (i32.store (i32.const 4) (i32.const 13))
71 -
         (drop (call $fd_write (i32.load (i32.const 200)) (i32.const 0) (i32.const 1) (i32.const 8)))",
72 -
    );
73 -
    // Tries to create a file outside the workspace: refused, so it exits 0.
74 -
    module(
75 -
        &work,
76 -
        "mods/escape.wasm",
77 -
        r#"(data (i32.const 64) "../escaped")"#,
78 -
        "(local.set $e (call $path_open (i32.const 3) (i32.const 0) (i32.const 64) (i32.const 10) (i32.const 9) (i64.const 0x1fffffff) (i64.const 0x1fffffff) (i32.const 0) (i32.const 200)))
79 -
         (if (i32.eqz (local.get $e)) (then (call $proc_exit (i32.const 9))))",
80 -
    );
81 -
    // Asks for 512 MiB in a 256 MiB sandbox: refused, so it exits 0.
82 -
    module(
83 -
        &work,
84 -
        "mods/greedy.wasm",
85 -
        "",
86 -
        "(if (i32.ne (memory.grow (i32.const 8192)) (i32.const -1)) (then (call $proc_exit (i32.const 9))))",
87 -
    );
88 -
    module(&work, "mods/trap.wasm", "", "unreachable");
89 -
90 -
    // A file at the top of the workspace, beside the directories.
91 -
    std::fs::write(work.join("README"), "top\n").unwrap();
92 -
    std::fs::create_dir_all(work.join(".flow")).unwrap();
93 -
    std::fs::write(
94 -
        work.join(".flow/pipeline.kdl"),
95 -
        r#"schema-version 1
96 -
sandbox "w" {
97 -
    runtime "wasm"
98 -
    memory-limit "256MiB"
99 -
}
100 -
job "hello" {
101 -
    sandbox "w"
102 -
    exec "/src/mods/hello.wasm" "one"
103 -
}
104 -
job "each" {
105 -
    sandbox "w"
106 -
    exec "mods/each/*.wasm"
107 -
}
108 -
job "writer" {
109 -
    sandbox "w"
110 -
    exec "mods/writer.wasm"
111 -
}
112 -
job "escape" {
113 -
    sandbox "w"
114 -
    exec "mods/escape.wasm"
115 -
}
116 -
job "greedy" {
117 -
    sandbox "w"
118 -
    exec "mods/greedy.wasm"
119 -
}
120 -
job "trap" {
121 -
    sandbox "w"
122 -
    exec "mods/trap.wasm"
123 -
}
124 -
"#,
125 -
    )
126 -
    .unwrap();
127 -
    let mut flow = Command::new(env!("CARGO_BIN_EXE_flow"));
128 -
    if in_process {
129 -
        flow.env("FLOW_WASM_IN_PROCESS", "1");
130 -
    }
131 -
    let run = flow.arg("--home").arg(&home).arg("run").current_dir(&work).output().unwrap();
132 -
    let out = String::from_utf8_lossy(&run.stdout).to_string() + &String::from_utf8_lossy(&run.stderr);
133 -
    for want in [
134 -
        "hello from wasm",
135 -
        "── hello passed",
136 -
        "running mods/each/a.wasm",
137 -
        "module a",
138 -
        "mods/each/b.wasm: exit 3",
139 -
        "── each failed (exit 3)",
140 -
        "── writer passed",
141 -
        "── escape passed",
142 -
        "── greedy passed",
143 -
        "mods/trap.wasm trapped",
144 -
        "── trap failed (exit 134)",
145 -
    ] {
146 -
        assert!(out.contains(want), "{want:?} not in:\n{out}");
147 -
    }
148 -
    assert!(!dir.path().join("escaped").exists() && !work.join("escaped").exists(), "a module wrote outside the workspace");
149 -
    assert_eq!(run.status.code(), Some(1), "two jobs failed:\n{out}");
150 -
}
modified crates/jet/src/lib.rs +1 −4
…
1 unchanged lines
2 2
//! its own. Flow says what to run; Jet carries it to where it should run.
3 3
//!
4 4
//! It starts with images by hash (oxygenflow#39): a rootfs image is kept by
5 -
//! a hub as content-defined chunks, sent and fetched by what is missing,
6 -
//! and shared between runners in a swarm (oxygenflow#47).
5 +
//! a hub as content-defined chunks, sent and fetched by what is missing.
7 6
8 7
pub mod images;
9 -
pub mod rootfs;
10 -
pub mod swarm;
removed crates/jet/src/rootfs.rs +0 −383
1 -
//! What a rootfs image is for, read from its own files (OxygenFlow spec
2 -
//! §11.2): the ELF header of its shell. An image is an ext4 filesystem,
3 -
//! as `flow rootfs import` makes it; this reads just enough of one to
4 -
//! follow a path, symlinks included, to a file's first bytes. It needs no
5 -
//! mount and no privileges, so a runner can check an image it fetched and
6 -
//! a hub an image it keeps as chunks.
7 -
//!
8 -
//! Read only, and defensive: a malformed image answers `None`, never a
9 -
//! panic or an unbounded read.
10 -
11 -
use std::io::{Read, Seek, SeekFrom};
12 -
use std::path::Path;
13 -
14 -
/// The files whose architecture is the image's, in order: the shell's,
15 -
/// else the first program found.
16 -
const PROGRAMS: [&str; 5] = ["bin/sh", "bin/busybox", "usr/bin/env", "bin/bash", "usr/bin/sh"];
17 -
18 -
const MAGIC: u16 = 0xEF53;
19 -
const ROOT_INODE: u32 = 2;
20 -
const INCOMPAT_64BIT: u32 = 0x80;
21 -
const EXTENTS_FL: u32 = 0x80000;
22 -
const INLINE_DATA_FL: u32 = 0x1000_0000;
23 -
const EXTENT_MAGIC: u16 = 0xF30A;
24 -
/// More symlinks than this on one path is a loop.
25 -
const MAX_LINKS: u32 = 40;
26 -
/// The largest directory read, in bytes.
27 -
const MAX_DIR: u64 = 64 << 20;
28 -
29 -
/// The architecture an ELF header names (`x86_64`, `aarch64`, `riscv64`,
30 -
/// …), as `std::env::consts::ARCH` spells it; `None` for anything else.
31 -
pub fn elf_arch(header: &[u8]) -> Option<&'static str> {
32 -
    if header.len() < 20 || &header[..4] != b"\x7fELF" {
33 -
        return None;
34 -
    }
35 -
    let wide = header[4] == 2;
36 -
    let machine = if header[5] == 1 { u16::from_le_bytes([header[18], header[19]]) } else { u16::from_be_bytes([header[18], header[19]]) };
37 -
    Some(match machine {
38 -
        0x3E => "x86_64",
39 -
        0xB7 => "aarch64",
40 -
        0xF3 if wide => "riscv64",
41 -
        0xF3 => "riscv32",
42 -
        0x03 => "x86",
43 -
        0x28 => "arm",
44 -
        0x15 => "powerpc64",
45 -
        0x16 => "s390x",
46 -
        _ => return None,
47 -
    })
48 -
}
49 -
50 -
/// The architecture of the ext4 image read through `read` (fill the
51 -
/// buffer from that byte offset): its shell's, else the first program of
52 -
/// [`PROGRAMS`] it has. `None` when it is not ext4 or has none of them.
53 -
pub fn arch(read: &mut dyn FnMut(u64, &mut [u8]) -> std::io::Result<()>) -> Option<&'static str> {
54 -
    let fs = Fs::open(read)?;
55 -
    PROGRAMS.iter().find_map(|p| {
56 -
        let inode = fs.resolve(read, p)?;
57 -
        if inode.mode & 0xF000 != 0x8000 {
58 -
            return None;
59 -
        }
60 -
        let mut head = [0u8; 20];
61 -
        fs.read_file(read, &inode, &mut head)?;
62 -
        elf_arch(&head)
63 -
    })
64 -
}
65 -
66 -
/// The architecture of the ext4 image at `path` ([`arch`]).
67 -
pub fn arch_of_file(path: &Path) -> Option<&'static str> {
68 -
    let mut f = std::fs::File::open(path).ok()?;
69 -
    arch(&mut |at: u64, buf: &mut [u8]| {
70 -
        f.seek(SeekFrom::Start(at))?;
71 -
        f.read_exact(buf)
72 -
    })
73 -
}
74 -
75 -
struct Fs {
76 -
    block: u64,
77 -
    first_data_block: u64,
78 -
    inodes_per_group: u32,
79 -
    inode_size: u64,
80 -
    desc_size: u64,
81 -
    wide: bool,
82 -
    groups: u32,
83 -
}
84 -
85 -
struct Inode {
86 -
    mode: u16,
87 -
    size: u64,
88 -
    flags: u32,
89 -
    block: [u8; 60],
90 -
}
91 -
92 -
fn u16_at(b: &[u8], at: usize) -> u16 {
93 -
    u16::from_le_bytes([b[at], b[at + 1]])
94 -
}
95 -
96 -
fn u32_at(b: &[u8], at: usize) -> u32 {
97 -
    u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
98 -
}
99 -
100 -
type ReadFn<'a> = dyn FnMut(u64, &mut [u8]) -> std::io::Result<()> + 'a;
101 -
102 -
impl Fs {
103 -
    fn open(read: &mut ReadFn) -> Option<Fs> {
104 -
        let mut sb = [0u8; 1024];
105 -
        read(1024, &mut sb).ok()?;
106 -
        if u16_at(&sb, 0x38) != MAGIC {
107 -
            return None;
108 -
        }
109 -
        let log = u32_at(&sb, 0x18);
110 -
        if log > 6 {
111 -
            return None;
112 -
        }
113 -
        let block = 1024u64 << log;
114 -
        let inode_size = if u32_at(&sb, 0x4C) == 0 { 128 } else { u16_at(&sb, 0x58) as u64 };
115 -
        let wide = u32_at(&sb, 0x60) & INCOMPAT_64BIT != 0;
116 -
        let desc_size = if wide { (u16_at(&sb, 0xFE) as u64).max(64) } else { 32 };
117 -
        let inodes_per_group = u32_at(&sb, 0x28);
118 -
        let inodes = u32_at(&sb, 0);
119 -
        if !(128..=1024).contains(&inode_size) || desc_size > 1024 || inodes_per_group == 0 {
120 -
            return None;
121 -
        }
122 -
        Some(Fs {
123 -
            block,
124 -
            first_data_block: u32_at(&sb, 0x14) as u64,
125 -
            inodes_per_group,
126 -
            inode_size,
127 -
            desc_size,
128 -
            wide,
129 -
            groups: inodes.div_ceil(inodes_per_group),
130 -
        })
131 -
    }
132 -
133 -
    fn inode(&self, read: &mut ReadFn, n: u32) -> Option<Inode> {
134 -
        let (group, index) = ((n.checked_sub(1)?) / self.inodes_per_group, (n - 1) % self.inodes_per_group);
135 -
        if group >= self.groups {
136 -
            return None;
137 -
        }
138 -
        let mut desc = [0u8; 64];
139 -
        let at = (self.first_data_block + 1) * self.block + group as u64 * self.desc_size;
140 -
        let len = if self.wide { 64 } else { 32 };
141 -
        read(at, &mut desc[..len]).ok()?;
142 -
        let hi = if self.wide { u32_at(&desc, 0x28) as u64 } else { 0 };
143 -
        let table = (hi << 32) | u32_at(&desc, 8) as u64;
144 -
        let mut raw = [0u8; 160];
145 -
        read(table * self.block + index as u64 * self.inode_size, &mut raw[..128]).ok()?;
146 -
        let mut block = [0u8; 60];
147 -
        block.copy_from_slice(&raw[0x28..0x28 + 60]);
148 -
        Some(Inode {
149 -
            mode: u16_at(&raw, 0),
150 -
            size: ((u32_at(&raw, 0x6C) as u64) << 32) | u32_at(&raw, 4) as u64,
151 -
            flags: u32_at(&raw, 0x20),
152 -
            block,
153 -
        })
154 -
    }
155 -
156 -
    /// The physical block holding logical block `n` of `inode`; `None` for
157 -
    /// a hole or an extent not yet written.
158 -
    fn map(&self, read: &mut ReadFn, inode: &Inode, n: u64) -> Option<u64> {
159 -
        if inode.flags & EXTENTS_FL != 0 {
160 -
            let mut node = inode.block.to_vec();
161 -
            for _ in 0..6 {
162 -
                if u16_at(&node, 0) != EXTENT_MAGIC {
163 -
                    return None;
164 -
                }
165 -
                let entries = (u16_at(&node, 2) as usize).min((node.len() - 12) / 12);
166 -
                let depth = u16_at(&node, 6);
167 -
                let entry = |i: usize| &node[12 + 12 * i..24 + 12 * i];
168 -
                if depth == 0 {
169 -
                    return (0..entries).map(entry).find_map(|e| {
170 -
                        let (first, len) = (u32_at(e, 0) as u64, u16_at(e, 4) as u64);
171 -
                        let start = ((u16_at(e, 6) as u64) << 32) | u32_at(e, 8) as u64;
172 -
                        (len <= 32768 && n >= first && n < first + len).then_some(start + (n - first))
173 -
                    });
174 -
                }
175 -
                let next = (0..entries).map(entry).take_while(|e| u32_at(e, 0) as u64 <= n).last()?;
176 -
                let child = ((u16_at(next, 8) as u64) << 32) | u32_at(next, 4) as u64;
177 -
                let mut buf = vec![0u8; self.block as usize];
178 -
                read(child * self.block, &mut buf).ok()?;
179 -
                node = buf;
180 -
            }
181 -
            return None;
182 -
        }
183 -
        // The old block map: twelve direct blocks, then one and two levels
184 -
        // of indirection.
185 -
        let per = self.block / 4;
186 -
        let ptr = |i: usize| u32_at(&inode.block, i * 4) as u64;
187 -
        let mut indirect = |table: u64, i: u64| -> Option<u64> {
188 -
            let mut b = [0u8; 4];
189 -
            read(table * self.block + i * 4, &mut b).ok()?;
190 -
            Some(u32::from_le_bytes(b) as u64).filter(|&p| p != 0)
191 -
        };
192 -
        let p = if n < 12 {
193 -
            ptr(n as usize)
194 -
        } else if n < 12 + per {
195 -
            indirect(ptr(12), n - 12)?
196 -
        } else if n < 12 + per + per * per {
197 -
            let k = n - 12 - per;
198 -
            let mid = indirect(ptr(13), k / per)?;
199 -
            indirect(mid, k % per)?
200 -
        } else {
201 -
            return None;
202 -
        };
203 -
        (p != 0).then_some(p)
204 -
    }
205 -
206 -
    /// Fill `buf` from the start of `inode`'s data.
207 -
    fn read_file(&self, read: &mut ReadFn, inode: &Inode, buf: &mut [u8]) -> Option<()> {
208 -
        if inode.flags & INLINE_DATA_FL != 0 || (buf.len() as u64) > inode.size.min(self.block) {
209 -
            return None;
210 -
        }
211 -
        let at = self.map(read, inode, 0)?;
212 -
        read(at * self.block, buf).ok()
213 -
    }
214 -
215 -
    /// Where a symlink points.
216 -
    fn link_target(&self, read: &mut ReadFn, inode: &Inode) -> Option<String> {
217 -
        if inode.size == 0 || inode.size > 4096 {
218 -
            return None;
219 -
        }
220 -
        let bytes = if inode.flags & (EXTENTS_FL | INLINE_DATA_FL) == 0 && inode.size < 60 {
221 -
            inode.block[..inode.size as usize].to_vec()
222 -
        } else {
223 -
            let mut b = vec![0u8; inode.size as usize];
224 -
            self.read_file(read, inode, &mut b)?;
225 -
            b
226 -
        };
227 -
        String::from_utf8(bytes).ok()
228 -
    }
229 -
230 -
    /// The inode named `name` in directory `dir`.
231 -
    fn lookup(&self, read: &mut ReadFn, dir: &Inode, name: &str) -> Option<u32> {
232 -
        if dir.mode & 0xF000 != 0x4000 || dir.flags & INLINE_DATA_FL != 0 || dir.size > MAX_DIR {
233 -
            return None;
234 -
        }
235 -
        let mut buf = vec![0u8; self.block as usize];
236 -
        for n in 0..dir.size.div_ceil(self.block) {
237 -
            let Some(at) = self.map(read, dir, n) else { continue };
238 -
            read(at * self.block, &mut buf).ok()?;
239 -
            let mut off = 0usize;
240 -
            while off + 8 <= buf.len() {
241 -
                let (ino, rec, len) = (u32_at(&buf, off), u16_at(&buf, off + 4) as usize, buf[off + 6] as usize);
242 -
                if rec < 8 || off + rec > buf.len() {
243 -
                    break;
244 -
                }
245 -
                if ino != 0 && off + 8 + len <= buf.len() && &buf[off + 8..off + 8 + len] == name.as_bytes() {
246 -
                    return Some(ino);
247 -
                }
248 -
                off += rec;
249 -
            }
250 -
        }
251 -
        None
252 -
    }
253 -
254 -
    /// The inode at `path` from the root, following every symlink.
255 -
    fn resolve(&self, read: &mut ReadFn, path: &str) -> Option<Inode> {
256 -
        let mut todo: Vec<String> = path.split('/').filter(|p| !p.is_empty()).rev().map(String::from).collect();
257 -
        let mut dirs = vec![ROOT_INODE];
258 -
        let mut links = 0;
259 -
        while let Some(name) = todo.pop() {
260 -
            match name.as_str() {
261 -
                "." => continue,
262 -
                ".." => {
263 -
                    if dirs.len() > 1 {
264 -
                        dirs.pop();
265 -
                    }
266 -
                    continue;
267 -
                }
268 -
                _ => {}
269 -
            }
270 -
            let dir = self.inode(read, *dirs.last()?)?;
271 -
            let n = self.lookup(read, &dir, &name)?;
272 -
            let inode = self.inode(read, n)?;
273 -
            if inode.mode & 0xF000 == 0xA000 {
274 -
                links += 1;
275 -
                if links > MAX_LINKS {
276 -
                    return None;
277 -
                }
278 -
                let target = self.link_target(read, &inode)?;
279 -
                if target.starts_with('/') {
280 -
                    dirs.truncate(1);
281 -
                }
282 -
                todo.extend(target.split('/').filter(|p| !p.is_empty()).rev().map(String::from));
283 -
                continue;
284 -
            }
285 -
            dirs.push(n);
286 -
        }
287 -
        self.inode(read, *dirs.last()?)
288 -
    }
289 -
}
290 -
291 -
#[cfg(test)]
292 -
mod tests {
293 -
    use super::*;
294 -
    use std::process::Command;
295 -
296 -
    /// The first 64 bytes of an ELF program for `machine`, 64-bit little
297 -
    /// endian.
298 -
    fn elf(machine: u16) -> Vec<u8> {
299 -
        let mut h = vec![0u8; 64];
300 -
        h[..4].copy_from_slice(b"\x7fELF");
301 -
        h[4] = 2;
302 -
        h[5] = 1;
303 -
        h[6] = 1;
304 -
        h[16] = 3;
305 -
        h[18..20].copy_from_slice(&machine.to_le_bytes());
306 -
        h
307 -
    }
308 -
309 -
    /// An ext4 image of `tree`, as `flow rootfs import` makes one; `None`
310 -
    /// where mkfs.ext4 is missing.
311 -
    fn image(tree: &Path, out: &Path, size: &str, extra: &[&str]) -> Option<()> {
312 -
        let status = Command::new("mkfs.ext4")
313 -
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0"])
314 -
            .args(extra)
315 -
            .arg("-d")
316 -
            .arg(tree)
317 -
            .arg(out)
318 -
            .arg(size)
319 -
            .status()
320 -
            .ok()?;
321 -
        assert!(status.success(), "mkfs.ext4 failed");
322 -
        Some(())
323 -
    }
324 -
325 -
    #[test]
326 -
    fn elf_headers_name_their_architecture() {
327 -
        assert_eq!(elf_arch(&elf(0x3E)), Some("x86_64"));
328 -
        assert_eq!(elf_arch(&elf(0xF3)), Some("riscv64"));
329 -
        assert_eq!(elf_arch(&elf(0xB7)), Some("aarch64"));
330 -
        assert_eq!(elf_arch(b"#!/bin/sh\necho hello\n"), None);
331 -
        assert_eq!(elf_arch(&elf(0x9999)), None);
332 -
    }
333 -
334 -
    #[test]
335 -
    fn an_images_shell_names_its_architecture_through_symlinks() {
336 -
        let dir = tempfile::tempdir().unwrap();
337 -
        // A merged /usr, as Debian and Ubuntu have it: bin -> usr/bin, and
338 -
        // sh -> dash; a riscv64 shell.
339 -
        let tree = dir.path().join("tree");
340 -
        std::fs::create_dir_all(tree.join("usr/bin")).unwrap();
341 -
        std::fs::write(tree.join("usr/bin/dash"), elf(0xF3)).unwrap();
342 -
        std::os::unix::fs::symlink("dash", tree.join("usr/bin/sh")).unwrap();
343 -
        std::os::unix::fs::symlink("usr/bin", tree.join("bin")).unwrap();
344 -
        // A full directory, so lookups cross blocks.
345 -
        for i in 0..400 {
346 -
            std::fs::write(tree.join(format!("usr/bin/tool-with-a-long-name-{i:04}")), b"#!/bin/sh\n").unwrap();
347 -
        }
348 -
        let img = dir.path().join("rootfs.ext4");
349 -
        if image(&tree, &img, "8M", &[]).is_none() {
350 -
            eprintln!("mkfs.ext4 not found: skipped");
351 -
            return;
352 -
        }
353 -
        assert_eq!(arch_of_file(&img), Some("riscv64"));
354 -
        // 1 KiB blocks, and an absolute link through a deeper directory.
355 -
        let tree2 = dir.path().join("tree2");
356 -
        std::fs::create_dir_all(tree2.join("bin")).unwrap();
357 -
        std::fs::create_dir_all(tree2.join("opt/busybox/bin")).unwrap();
358 -
        std::fs::write(tree2.join("opt/busybox/bin/busybox"), elf(0x3E)).unwrap();
359 -
        std::os::unix::fs::symlink("/opt/busybox/bin/../bin/busybox", tree2.join("bin/busybox")).unwrap();
360 -
        let img2 = dir.path().join("small.ext4");
361 -
        image(&tree2, &img2, "8M", &["-b", "1024"]).unwrap();
362 -
        assert_eq!(arch_of_file(&img2), Some("x86_64"), "no sh: busybox decides");
363 -
    }
364 -
365 -
    #[test]
366 -
    fn what_is_not_an_image_or_has_no_shell_answers_nothing() {
367 -
        let dir = tempfile::tempdir().unwrap();
368 -
        let junk = dir.path().join("junk");
369 -
        std::fs::write(&junk, vec![0x5Au8; 64 << 10]).unwrap();
370 -
        assert_eq!(arch_of_file(&junk), None);
371 -
        assert_eq!(arch_of_file(&dir.path().join("missing")), None);
372 -
        let tree = dir.path().join("tree");
373 -
        std::fs::create_dir_all(tree.join("bin")).unwrap();
374 -
        std::fs::write(tree.join("bin/sh"), b"#!/not/an/elf\n").unwrap();
375 -
        std::os::unix::fs::symlink("loop", tree.join("bin/loop")).unwrap();
376 -
        std::os::unix::fs::symlink("loop", tree.join("bin/busybox")).unwrap();
377 -
        let img = dir.path().join("rootfs.ext4");
378 -
        if image(&tree, &img, "8M", &[]).is_none() {
379 -
            return;
380 -
        }
381 -
        assert_eq!(arch_of_file(&img), None, "a script shell and a symlink loop name nothing");
382 -
    }
383 -
}
removed crates/jet/src/swarm.rs +0 −500
1 -
//! The swarm (oxygenflow#47, docs/jetstream.md §8.1): runners fetch image
2 -
//! chunks from each other as well as from the hub.
3 -
//!
4 -
//! This module holds what both ends agree on and what can be tested
5 -
//! without a network: the have-map a peer offers ([`Bits`]), the ticket the
6 -
//! hub signs to admit a runner to its peers ([`Ticket`]), and the order in
7 -
//! which a fetcher asks its sources for chunks ([`Picker`]): rarest first,
8 -
//! the hub only for what no peer has, and an endgame at the close.
9 -
10 -
use oxy_core::hash::KeyId;
11 -
use oxy_core::identity::{verify_challenge, Identity, SecretKey, Signature};
12 -
use serde::{Deserialize, Serialize};
13 -
use std::collections::HashMap;
14 -
use std::time::{Duration, Instant};
15 -
16 -
/// How long a ticket admits a runner to its peers.
17 -
pub const TICKET_LIFE_MS: u64 = 15 * 60 * 1000;
18 -
/// Peers make no progress for this long and the hub is asked for anything.
19 -
pub const STALL: Duration = Duration::from_secs(2);
20 -
/// The most neighbouring chunks a fetcher takes in order where rarity does
21 -
/// not decide (about 5.6 MB of an image's 88 KB chunks); a small image's
22 -
/// runs are shorter, so it still has a few hundred to spread runners over.
23 -
const RUN: usize = 64;
24 -
/// Most sources asked for the same chunk in the endgame.
25 -
const ENDGAME_COPIES: u8 = 2;
26 -
27 -
/// One bit per chunk of a manifest's needed list, in its order.
28 -
#[derive(Clone, Debug, Default, PartialEq, Eq)]
29 -
pub struct Bits {
30 -
    words: Vec<u64>,
31 -
    len: usize,
32 -
}
33 -
34 -
impl Bits {
35 -
    pub fn new(len: usize) -> Bits {
36 -
        Bits { words: vec![0; len.div_ceil(64)], len }
37 -
    }
38 -
39 -
    /// Every bit set: a peer that holds the whole image.
40 -
    pub fn full(len: usize) -> Bits {
41 -
        let mut b = Bits::new(len);
42 -
        (0..len).for_each(|i| b.set(i));
43 -
        b
44 -
    }
45 -
46 -
    pub fn len(&self) -> usize {
47 -
        self.len
48 -
    }
49 -
50 -
    pub fn is_empty(&self) -> bool {
51 -
        self.len == 0
52 -
    }
53 -
54 -
    pub fn get(&self, i: usize) -> bool {
55 -
        i < self.len && self.words[i / 64] & (1 << (i % 64)) != 0
56 -
    }
57 -
58 -
    pub fn set(&mut self, i: usize) {
59 -
        if i < self.len {
60 -
            self.words[i / 64] |= 1 << (i % 64);
61 -
        }
62 -
    }
63 -
64 -
    pub fn clear(&mut self, i: usize) {
65 -
        if i < self.len {
66 -
            self.words[i / 64] &= !(1 << (i % 64));
67 -
        }
68 -
    }
69 -
70 -
    pub fn count(&self) -> usize {
71 -
        self.words.iter().map(|w| w.count_ones() as usize).sum()
72 -
    }
73 -
74 -
    /// Little-endian bytes, as `GET …/have` sends them.
75 -
    pub fn to_bytes(&self) -> Vec<u8> {
76 -
        let mut out: Vec<u8> = self.words.iter().flat_map(|w| w.to_le_bytes()).collect();
77 -
        out.truncate(self.len.div_ceil(8));
78 -
        out
79 -
    }
80 -
81 -
    /// The bits of a `have` answer for a list of `len` chunks; bits past
82 -
    /// `len` are ignored, and a short answer means the rest are unset.
83 -
    pub fn from_bytes(len: usize, bytes: &[u8]) -> Bits {
84 -
        let mut b = Bits::new(len);
85 -
        for (i, byte) in bytes.iter().enumerate().take(len.div_ceil(8)) {
86 -
            b.words[i / 8] |= (*byte as u64) << (8 * (i % 8));
87 -
        }
88 -
        if let Some(last) = b.words.last_mut() {
89 -
            if !len.is_multiple_of(64) {
90 -
                *last &= (1u64 << (len % 64)) - 1;
91 -
            }
92 -
        }
93 -
        b
94 -
    }
95 -
}
96 -
97 -
/// What the hub signs to admit a runner to its peers.
98 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99 -
pub struct Ticket {
100 -
    /// The hub's key, hex: a peer accepts only its own hub's tickets.
101 -
    pub hub: String,
102 -
    pub runner: i64,
103 -
    pub name: String,
104 -
    pub site: String,
105 -
    pub expires_ms: u64,
106 -
}
107 -
108 -
impl Ticket {
109 -
    /// The ticket as text: its JSON and the hub's signature over it, hex,
110 -
    /// joined by a dot.
111 -
    pub fn seal(&self, key: &KeyId, secret: &SecretKey) -> String {
112 -
        let body = serde_json::to_vec(self).expect("a ticket serialises");
113 -
        let sig = secret.sign_challenge(key, &body).expect("a ticket is longer than 16 bytes");
114 -
        format!("{}.{}", hex::encode(&body), hex::encode(Signature::encode_list(&[sig])))
115 -
    }
116 -
117 -
    /// Read and check a ticket: signed by `hub`, not expired at `now_ms`.
118 -
    pub fn open(text: &str, hub: &Identity, now_ms: u64) -> Result<Ticket, String> {
119 -
        let (body, sig) = text.trim().split_once('.').ok_or("not a ticket")?;
120 -
        let body = hex::decode(body).map_err(|_| "not a ticket")?;
121 -
        let sigs = hex::decode(sig).ok().and_then(|b| Signature::decode_list(&b).ok()).ok_or("not a ticket's signature")?;
122 -
        let sig = sigs.first().ok_or("an unsigned ticket")?;
123 -
        verify_challenge(hub, sig, &body).map_err(|_| "the ticket is not signed by this runner's hub")?;
124 -
        let t: Ticket = serde_json::from_slice(&body).map_err(|_| "not a ticket")?;
125 -
        if t.hub != hub.hash().to_hex() {
126 -
            return Err("the ticket names another hub".into());
127 -
        }
128 -
        if t.expires_ms <= now_ms {
129 -
            return Err("the ticket has expired".into());
130 -
        }
131 -
        Ok(t)
132 -
    }
133 -
}
134 -
135 -
/// Where a chunk comes from: the hub, or a peer by its index.
136 -
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
137 -
pub enum Source {
138 -
    Hub,
139 -
    Peer(usize),
140 -
}
141 -
142 -
/// What a source should do next.
143 -
#[derive(Debug, PartialEq, Eq)]
144 -
pub enum Pick {
145 -
    /// Ask for this chunk (an index into the needed list).
146 -
    Chunk(usize),
147 -
    /// Nothing for this source now; ask again shortly.
148 -
    Wait,
149 -
    /// Every chunk is here.
150 -
    Done,
151 -
}
152 -
153 -
/// The order a fetcher asks its sources for chunks (docs/jetstream.md
154 -
/// §8.1): the rarest chunk a peer holds first; the hub for chunks no peer
155 -
/// holds, or for anything once the peers stall; and in the endgame, a
156 -
/// second source for a chunk already asked for.
157 -
pub struct Picker {
158 -
    have: Bits,
159 -
    asked: Vec<u8>,
160 -
    /// The source first asked for each chunk in flight: the endgame asks
161 -
    /// a different one.
162 -
    first: Vec<Option<Source>>,
163 -
    /// Each peer's map: what it has, and what it is fetching now.
164 -
    peers: HashMap<usize, (Bits, Bits)>,
165 -
    /// Missing chunks, rarest first; rebuilt when the peers' maps change.
166 -
    order: Vec<usize>,
167 -
    /// Where each chunk sits in `order`.
168 -
    pos: Vec<usize>,
169 -
    /// No chunk before this place in `order` can be picked fresh: each is
170 -
    /// here or asked for. Moved back when one fails.
171 -
    skip: usize,
172 -
    /// How many peers hold each chunk, as of the last rebuild.
173 -
    holders: Vec<u16>,
174 -
    /// Whether some peer is fetching each chunk now: the hub is not asked
175 -
    /// for it, the peer will have it shortly.
176 -
    coming: Vec<bool>,
177 -
    progress: Instant,
178 -
    seed: u64,
179 -
}
180 -
181 -
impl Picker {
182 -
    /// A picker for `len` chunks, of which `have` are already here.
183 -
    pub fn new(len: usize, have: Bits, seed: u64) -> Picker {
184 -
        let mut p = Picker {
185 -
            have,
186 -
            asked: vec![0; len],
187 -
            first: vec![None; len],
188 -
            peers: HashMap::new(),
189 -
            order: Vec::new(),
190 -
            pos: vec![0; len],
191 -
            skip: 0,
192 -
            holders: vec![0; len],
193 -
            coming: vec![false; len],
194 -
            progress: Instant::now(),
195 -
            seed: seed | 1,
196 -
        };
197 -
        p.reorder();
198 -
        p
199 -
    }
200 -
201 -
    pub fn len(&self) -> usize {
202 -
        self.asked.len()
203 -
    }
204 -
205 -
    pub fn is_empty(&self) -> bool {
206 -
        self.asked.is_empty()
207 -
    }
208 -
209 -
    pub fn missing(&self) -> usize {
210 -
        self.len() - self.have.count()
211 -
    }
212 -
213 -
    pub fn have(&self) -> &Bits {
214 -
        &self.have
215 -
    }
216 -
217 -
    /// A peer's have-map, new or refreshed.
218 -
    pub fn peer(&mut self, peer: usize, bits: Bits) {
219 -
        let none = Bits::new(bits.len());
220 -
        self.peer_fetching(peer, bits, none);
221 -
    }
222 -
223 -
    /// A peer's have-map and the chunks it is fetching now.
224 -
    pub fn peer_fetching(&mut self, peer: usize, have: Bits, fetching: Bits) {
225 -
        self.peers.insert(peer, (have, fetching));
226 -
        self.reorder();
227 -
    }
228 -
229 -
    /// A peer that failed: it is asked for nothing more.
230 -
    pub fn drop_peer(&mut self, peer: usize) {
231 -
        self.peers.remove(&peer);
232 -
        self.reorder();
233 -
    }
234 -
235 -
    fn next_rand(&mut self) -> u64 {
236 -
        // xorshift64: enough to spread runners that start together.
237 -
        self.seed ^= self.seed << 13;
238 -
        self.seed ^= self.seed >> 7;
239 -
        self.seed ^= self.seed << 17;
240 -
        self.seed
241 -
    }
242 -
243 -
    /// Rarest first; among chunks as rare as each other, runs of up to
244 -
    /// [`RUN`] neighbours in order, the runs shuffled. A peer reads them out of a
245 -
    /// whole image, and on a spinning disk scattered reads cost a seek each
246 -
    /// (Earth sent 2.3 GiB in 20 minutes, picked one chunk at a time at
247 -
    /// random); runners that start together still spread over the runs.
248 -
    fn reorder(&mut self) {
249 -
        let run = (self.len() / 256).clamp(1, RUN);
250 -
        let shuffle: Vec<u64> = (0..self.len().div_ceil(run)).map(|_| self.next_rand()).collect();
251 -
        let mut keyed: Vec<(u16, u64, usize)> = Vec::new();
252 -
        for i in 0..self.len() {
253 -
            self.holders[i] = self.peers.values().filter(|(b, _)| b.get(i)).count().min(u16::MAX as usize) as u16;
254 -
            self.coming[i] = self.peers.values().any(|(_, f)| f.get(i));
255 -
            if !self.have.get(i) {
256 -
                keyed.push((self.holders[i], shuffle[i / run], i));
257 -
            }
258 -
        }
259 -
        keyed.sort_unstable();
260 -
        self.order = keyed.into_iter().map(|(_, _, i)| i).collect();
261 -
        for (at, &i) in self.order.iter().enumerate() {
262 -
            self.pos[i] = at;
263 -
        }
264 -
        self.skip = 0;
265 -
    }
266 -
267 -
    /// The next chunk for `src` to fetch.
268 -
    pub fn pick(&mut self, src: Source) -> Pick {
269 -
        if self.missing() == 0 {
270 -
            return Pick::Done;
271 -
        }
272 -
        let holds = |i: usize| match src {
273 -
            Source::Hub => true,
274 -
            Source::Peer(p) => self.peers.get(&p).is_some_and(|(b, _)| b.get(i)),
275 -
        };
276 -
        let stalled = self.progress.elapsed() >= STALL;
277 -
        // Not yet asked for: a peer takes the rarest it holds; the hub
278 -
        // takes what no peer holds, or anything once the peers stall.
279 -
        while self.skip < self.order.len() && (self.have.get(self.order[self.skip]) || self.asked[self.order[self.skip]] > 0) {
280 -
            self.skip += 1;
281 -
        }
282 -
        let fresh = self.order[self.skip..].iter().copied().find(|&i| {
283 -
            !self.have.get(i)
284 -
                && self.asked[i] == 0
285 -
                && holds(i)
286 -
                && (src != Source::Hub || stalled || self.peers.is_empty() || (self.holders[i] == 0 && !self.coming[i]))
287 -
        });
288 -
        if let Some(i) = fresh {
289 -
            self.asked[i] += 1;
290 -
            self.first[i] = Some(src);
291 -
            return Pick::Chunk(i);
292 -
        }
293 -
        // The endgame: everything missing is asked for already. The hub
294 -
        // joins it only once the peers stall.
295 -
        if src == Source::Hub && !stalled && !self.peers.is_empty() {
296 -
            return Pick::Wait;
297 -
        }
298 -
        let mut left = self.order.iter().copied().filter(|&i| !self.have.get(i));
299 -
        if left.clone().all(|i| self.asked[i] > 0) {
300 -
            if let Some(i) = left.find(|&i| self.asked[i] < ENDGAME_COPIES && self.first[i] != Some(src) && holds(i)) {
301 -
                self.asked[i] += 1;
302 -
                return Pick::Chunk(i);
303 -
            }
304 -
        }
305 -
        Pick::Wait
306 -
    }
307 -
308 -
    /// Chunk `i` arrived and was checked.
309 -
    pub fn got(&mut self, i: usize) {
310 -
        self.have.set(i);
311 -
        self.asked[i] = self.asked[i].saturating_sub(1);
312 -
        self.first[i] = None;
313 -
        self.progress = Instant::now();
314 -
    }
315 -
316 -
    /// Chunk `i` did not come: it can be asked for again.
317 -
    pub fn failed(&mut self, i: usize) {
318 -
        self.asked[i] = self.asked[i].saturating_sub(1);
319 -
        if self.asked[i] == 0 {
320 -
            self.first[i] = None;
321 -
            if !self.have.get(i) {
322 -
                self.skip = self.skip.min(self.pos[i]);
323 -
            }
324 -
        }
325 -
    }
326 -
}
327 -
328 -
#[cfg(test)]
329 -
mod tests {
330 -
    #[test]
331 -
    fn from_one_peer_neighbouring_chunks_come_in_order() {
332 -
        let n = RUN * 256;
333 -
        let mut p = Picker::new(n, Bits::new(n), 3);
334 -
        p.peer(0, Bits::full(n));
335 -
        let first: Vec<usize> = (0..RUN)
336 -
            .map(|_| match p.pick(Source::Peer(0)) {
337 -
                Pick::Chunk(i) => i,
338 -
                other => panic!("{other:?}"),
339 -
            })
340 -
            .collect();
341 -
        assert_eq!(first[0] % RUN, 0, "a run starts at its first chunk: {first:?}");
342 -
        assert!(first.windows(2).all(|w| w[1] == w[0] + 1), "one run, in order: {first:?}");
343 -
        // A failed chunk is asked for again before going further.
344 -
        p.failed(first[3]);
345 -
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(first[3]));
346 -
    }
347 -
348 -
    /// A fetch the size of the 14 GB Rust image (142,244 chunks) from one
349 -
    /// peer that holds them all: how long picking takes.
350 -
    #[test]
351 -
    #[ignore]
352 -
    fn picking_a_big_image_from_one_peer_is_quick() {
353 -
        let n = 142_244;
354 -
        let mut p = Picker::new(n, Bits::new(n), 7);
355 -
        p.peer(0, Bits::full(n));
356 -
        let started = std::time::Instant::now();
357 -
        let mut in_flight = std::collections::VecDeque::new();
358 -
        loop {
359 -
            match p.pick(Source::Peer(0)) {
360 -
                Pick::Chunk(i) => {
361 -
                    in_flight.push_back(i);
362 -
                    if in_flight.len() >= 4 {
363 -
                        p.got(in_flight.pop_front().unwrap());
364 -
                    }
365 -
                }
366 -
                Pick::Wait => p.got(in_flight.pop_front().unwrap()),
367 -
                Pick::Done => break,
368 -
            }
369 -
        }
370 -
        eprintln!("picked {n} chunks in {:?}", started.elapsed());
371 -
    }
372 -
373 -
    use super::*;
374 -
375 -
    #[test]
376 -
    fn bits_travel_as_bytes() {
377 -
        let mut b = Bits::new(70);
378 -
        [0, 9, 63, 64, 69].iter().for_each(|&i| b.set(i));
379 -
        let back = Bits::from_bytes(70, &b.to_bytes());
380 -
        assert_eq!(back, b);
381 -
        assert_eq!(b.to_bytes().len(), 9);
382 -
        assert_eq!(Bits::from_bytes(10, &[0xff, 0xff, 0xff]).count(), 10, "bits past the list are ignored");
383 -
        assert_eq!(Bits::from_bytes(10, &[]).count(), 0, "a short answer leaves the rest unset");
384 -
        assert_eq!(Bits::full(130).count(), 130);
385 -
    }
386 -
387 -
    #[test]
388 -
    fn a_ticket_opens_only_with_its_hub_and_in_time() {
389 -
        let sk = SecretKey::generate();
390 -
        let hub = sk.identity("hub", "hub@example", 1);
391 -
        let t = Ticket { hub: hub.hash().to_hex(), runner: 7, name: "earth-1".into(), site: "home".into(), expires_ms: 10_000 };
392 -
        let text = t.seal(&hub.hash(), &sk);
393 -
        assert_eq!(Ticket::open(&text, &hub, 9_999).unwrap(), t);
394 -
        assert!(Ticket::open(&text, &hub, 10_000).unwrap_err().contains("expired"));
395 -
        let other_sk = SecretKey::generate();
396 -
        let other = other_sk.identity("other", "o@example", 1);
397 -
        assert!(Ticket::open(&text, &other, 1).is_err(), "another hub's key");
398 -
        let mut forged = text.clone();
399 -
        forged.replace_range(2..4, "ff");
400 -
        assert!(Ticket::open(&forged, &hub, 1).is_err(), "a changed body");
401 -
        assert!(Ticket::open("nonsense", &hub, 1).is_err());
402 -
    }
403 -
404 -
    #[test]
405 -
    fn peers_are_asked_for_the_rarest_chunk_they_hold() {
406 -
        let mut p = Picker::new(4, Bits::new(4), 42);
407 -
        let mut a = Bits::new(4);
408 -
        [0, 1, 2].iter().for_each(|&i| a.set(i));
409 -
        let mut b = Bits::new(4);
410 -
        [0, 1].iter().for_each(|&i| b.set(i));
411 -
        p.peer(0, a);
412 -
        p.peer(1, b);
413 -
        // Chunk 2 only peer 0 holds; chunk 3 nobody does.
414 -
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(2));
415 -
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(3), "the hub seeds what no peer holds");
416 -
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "and leaves the rest to the peers");
417 -
        let first = p.pick(Source::Peer(1));
418 -
        assert!(matches!(first, Pick::Chunk(0) | Pick::Chunk(1)));
419 -
    }
420 -
421 -
    #[test]
422 -
    fn the_hub_is_not_asked_for_what_a_peer_is_fetching() {
423 -
        let mut p = Picker::new(2, Bits::new(2), 4);
424 -
        let mut fetching = Bits::new(2);
425 -
        fetching.set(0);
426 -
        p.peer_fetching(0, Bits::new(2), fetching);
427 -
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(1));
428 -
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "chunk 0 is coming from the peer");
429 -
        let mut have = Bits::new(2);
430 -
        have.set(0);
431 -
        p.peer(0, have);
432 -
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0));
433 -
    }
434 -
435 -
    #[test]
436 -
    fn without_peers_the_hub_serves_everything() {
437 -
        let mut p = Picker::new(3, Bits::new(3), 1);
438 -
        let mut seen = Vec::new();
439 -
        while let Pick::Chunk(i) = p.pick(Source::Hub) {
440 -
            seen.push(i);
441 -
            p.got(i);
442 -
        }
443 -
        seen.sort();
444 -
        assert_eq!(seen, vec![0, 1, 2]);
445 -
        assert_eq!(p.pick(Source::Hub), Pick::Done);
446 -
    }
447 -
448 -
    #[test]
449 -
    fn the_endgame_never_asks_the_same_source_twice() {
450 -
        let mut p = Picker::new(1, Bits::new(1), 9);
451 -
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(0));
452 -
        assert_eq!(p.pick(Source::Hub), Pick::Wait);
453 -
        p.peer(0, Bits::full(1));
454 -
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0), "a peer may race the hub for it");
455 -
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "but not the hub a peer, while the peers make progress");
456 -
    }
457 -
458 -
    #[test]
459 -
    fn the_endgame_asks_a_second_source_and_a_failure_frees_the_chunk() {
460 -
        let mut p = Picker::new(1, Bits::new(1), 3);
461 -
        p.peer(0, Bits::full(1));
462 -
        p.peer(1, Bits::full(1));
463 -
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0));
464 -
        assert_eq!(p.pick(Source::Peer(1)), Pick::Chunk(0), "the endgame");
465 -
        assert_eq!(p.pick(Source::Peer(1)), Pick::Wait, "at most two copies");
466 -
        p.failed(0);
467 -
        p.failed(0);
468 -
        p.drop_peer(0);
469 -
        assert_eq!(p.pick(Source::Peer(1)), Pick::Chunk(0));
470 -
        p.got(0);
471 -
        assert_eq!(p.pick(Source::Peer(1)), Pick::Done);
472 -
    }
473 -
474 -
    #[test]
475 -
    fn chunks_already_here_are_never_asked_for() {
476 -
        let mut have = Bits::new(3);
477 -
        have.set(1);
478 -
        let mut p = Picker::new(3, have, 5);
479 -
        assert_eq!(p.missing(), 2);
480 -
        let mut seen = Vec::new();
481 -
        while let Pick::Chunk(i) = p.pick(Source::Hub) {
482 -
            seen.push(i);
483 -
            p.got(i);
484 -
        }
485 -
        assert!(!seen.contains(&1));
486 -
    }
487 -
488 -
    #[test]
489 -
    fn runners_starting_together_ask_the_hub_for_different_chunks() {
490 -
        let a: Vec<_> = {
491 -
            let mut p = Picker::new(64, Bits::new(64), 11);
492 -
            (0..8).filter_map(|_| if let Pick::Chunk(i) = p.pick(Source::Hub) { Some(i) } else { None }).collect()
493 -
        };
494 -
        let b: Vec<_> = {
495 -
            let mut p = Picker::new(64, Bits::new(64), 12);
496 -
            (0..8).filter_map(|_| if let Pick::Chunk(i) = p.pick(Source::Hub) { Some(i) } else { None }).collect()
497 -
        };
498 -
        assert_ne!(a, b);
499 -
    }
500 -
}
removed docs/handover.md +0 −271
1 -
# Handover: jobs that outlive their runner
2 -
3 -
Proposed 2026-10-25, from a design conversation with Andy. Issues:
4 -
oxygenflow#75 (WASM tier), #76 (leases and drain), #77 (checkpoints),
5 -
#78 (hedging), #79 (trust classes), #80 (browser runners); the phone
6 -
runner is oxygenhub-android#8. Builds on Jetstream (docs/jetstream.md),
7 -
whose pools (hosts, Kubernetes, cloud) and requeue of a lost runner's job
8 -
this extends.
9 -
10 -
## 1. Why
11 -
12 -
Three situations, one problem: a job is tied to the runner that started
13 -
it.
14 -
15 -
- **The runner goes away.** A phone leaves the office Wi-Fi or comes off
16 -
  charge; a laptop sleeps; a spot VM is reclaimed with 30 seconds' notice;
17 -
  a Kubernetes node is drained. Today the job starts again from nothing
18 -
  elsewhere, or fails.
19 -
- **A better runner frees up.** A job went to the small runner B because
20 -
  the big runner A was busy. A finishes its own job when B is 10% through,
21 -
  then sits idle while B grinds through the other 90%.
22 -
- **The same work runs twice.** A requeued job redoes everything; two
23 -
  pipelines that build the same thing both build it.
24 -
25 -
Solving the first makes unreliable runners usable (phones, browsers,
26 -
spare machines, spot instances). Solving the second keeps a mixed fleet
27 -
busy. Together they are the case a company makes for running CI on the
28 -
hardware it already has: more use from it, and less waiting for
29 -
developers and agents.
30 -
31 -
## 2. What Flow already has
32 -
33 -
Most of the parts exist:
34 -
35 -
- **Jobs are the packets.** A job runs one `exec`; jobs form a graph and
36 -
  pass artifacts (§5 of the spec, oxygenflow#58). A run is already spread
37 -
  over runners: oxygenhub's run #439 built on earth-1, ran clippy on
38 -
  plex-1, dist on plex-1 and riscv64 on narvi-1.
39 -
- **Receipts already chain.** Each job's receipt (spec §6.1) is signed by
40 -
  its runner and names its inputs, which are other jobs' outputs, whose
41 -
  receipts name *their* runners. A run's provenance is already a graph of
42 -
  signed receipts from several runners.
43 -
- **A lost runner costs only its job.** Jetstream requeues it after a
44 -
  grace, and a spot reclaim notice requeues it at once (jetstream.md §11).
45 -
- **Placement can wait for a better runner** ("held for a better
46 -
  runner"), and the hub learns each job's duration per speed class
47 -
  (spec §11).
48 -
49 -
What is missing: a job that is *partly done* is lost when it moves; there
50 -
is no graceful way to leave; a better runner cannot take over; nothing
51 -
says how far to trust a runner; and phones and browsers cannot run jobs
52 -
at all (no WASM tier).
53 -
54 -
## 3. Decision: move progress, not processes
55 -
56 -
**No live migration of running VMs** (oxygenflow#33 suggested Firecracker
57 -
snapshots). A snapshot fixes the VM's shape: a job started with 2 vCPUs
58 -
on a small runner is restored with 2 vCPUs on the big one, since
59 -
Firecracker cannot add vCPUs to a running VM, so it gets no faster. It
60 -
moves gigabytes of memory, works only between hosts of the same
61 -
architecture and CPU features, and cannot help when the runner simply
62 -
disappears, because the snapshot is on the runner that is gone.
63 -
64 -
Instead a job's **progress** moves: the outputs of finished jobs (as
65 -
today) and, within a job, the directories it declares as checkpoints
66 -
(§7). Everything is content-addressed, so the new runner fetches only what
67 -
it lacks, and every piece comes with a receipt saying who made it.
68 -
69 -
## 4. Runners
70 -
71 -
| Kind | Tiers | Typical trust (§9) | Leaves by |
72 -
|---|---|---|---|
73 -
| Hosts (earth-1, plex-1, mars-1, narvi-1) | microvm, syscall, wasm | owned | restart or update (drain) |
74 -
| Kubernetes pods | microvm (KVM nodes), syscall, wasm | owned or attested | node drain, eviction, scale-down (SIGTERM) |
75 -
| Cloud VMs, spot or on demand | microvm, syscall, wasm | attested | reclaim notice, scale-down |
76 -
| Phone, the easy way (oxygenhub-android#8) | wasm | device | off charge, off the allowed network, too hot, owner stops it |
77 -
| Phone, full Linux (oxygenhub-android#9, later) | syscall in Android's Linux VM | device | as above |
78 -
| Browser tab (#80) | wasm | anonymous, or device when a member signs in | tab closed or hidden |
79 -
80 -
Cloud and Kubernetes are where handover pays most: spot capacity costs
81 -
60 to 90% less, and handover is what makes it safe to run CI on it.
82 -
Phones and browsers are where it shows best in a demonstration.
83 -
84 -
**Architecture still matters.** Native outputs and checkpoints only move
85 -
between runners of the same image and target (x86_64 to x86_64, arm64 to
86 -
arm64). WASM work moves anywhere.
87 -
88 -
## 5. The WASM tier (oxygenflow#75)
89 -
90 -
Spec §4.1 names it; built 2026-10-25 (wasmtime 49, WASI preview 1).
91 -
92 -
- wasmtime, WASI preview 1 first (preview 2 components later).
93 -
- A virtual file system holding the job's scope and inputs; outputs
94 -
  collected from it as for the other tiers.
95 -
- No network. Memory from the sandbox's limit; CPU time bounded by fuel
96 -
  and the job's time limit.
97 -
- The receipt's environment names the runtime, its version and the
98 -
  module's hash.
99 -
- Every Linux runner offers it (`tiers` gains `wasm`); phones and
100 -
  browsers offer only it.
101 -
102 -
What runs there is what compiles to WASM: linters, formatters, policy
103 -
checks, generated docs, and **test suites built for `wasm32-wasip1`**.
104 -
That last one makes the first demonstration (§11): a native job builds
105 -
the test modules, a phone runs them.
106 -
107 -
## 6. Leases and drain (oxygenflow#76)
108 -
109 -
**Lease.** A runner holding a job holds a lease, renewed by its logs and
110 -
a heartbeat. When the lease lapses the job is requeued, as today.
111 -
112 -
**Drain** is one message in the runner contract (jetstream.md §4) for
113 -
every *planned* departure:
114 -
115 -
| Trigger | Notice |
116 -
|---|---|
117 -
| SIGTERM, `flow` restart or update | the runner's grace (75 min on Earth) |
118 -
| Kubernetes node drain or eviction | the pod's grace period |
119 -
| Spot reclaim | 30 s (GCP), 2 min (AWS) |
120 -
| Phone off charge, off the allowed network, too hot | the owner's policy (a minute or so) |
121 -
| Browser tab hidden or closing | a few seconds |
122 -
123 -
On drain the runner decides from the job's learned duration (spec §11):
124 -
125 -
1. the rest is shorter than the notice: finish the job, then leave;
126 -
2. otherwise: save its checkpoints (§7), upload them, release the lease
127 -
   with `drained`, and stop the job.
128 -
129 -
A drained job is requeued at once, **ahead of new work**, and resumes
130 -
from its checkpoints. A runner that drains a job is not penalised; one
131 -
that drops leases without draining loses placement priority.
132 -
133 -
## 7. Checkpoints (oxygenflow#77)
134 -
135 -
A job may declare directories that hold its progress:
136 -
137 -
```kdl
138 -
job "build" {
139 -
    sandbox "rust"
140 -
    exec "cargo" "build" "--release"
141 -
    checkpoint "/src/target"
142 -
}
143 -
```
144 -
145 -
- **Saved** by content (FastCDC chunks, deduplicated, kept for the run's
146 -
  life) on drain and every few minutes while it runs (a sandbox setting,
147 -
  default 5 minutes, only while the job has run at least that long).
148 -
- **Restored** before a resumed job runs the **same exec again**. Tools
149 -
  that skip finished work (cargo's fingerprints, gradle, make, ninja)
150 -
  then carry on where they stopped; a tool that does not just starts
151 -
  again, so it is never wrong, only slower.
152 -
- **Receipts.** The partial run ends in a receipt with `exit` "drained",
153 -
  naming the checkpoint's hash as an output and signed by its runner. The
154 -
  resumed job's receipt names that checkpoint among its inputs. So
155 -
  provenance says that plex built the first part and mars finished it,
156 -
  and both are signed.
157 -
- **Cheap to move inside a cloud:** checkpoints live in the run's store
158 -
  (a bucket under Strata for cloud pools), so a new pod in the same region
159 -
  restores them quickly. From a phone, the policy (charger, office
160 -
  network) keeps uploads cheap.
161 -
- **Checkpoints are code.** A tampered `.rlib` in `target/` ends up in
162 -
  the binary. So a job accepts a checkpoint only from a runner of at
163 -
  least its trust class (§9), and its receipt says that it used one.
164 -
165 -
## 8. Hedging (oxygenflow#78)
166 -
167 -
When a markedly faster runner frees up while a job runs on a slow one,
168 -
and the learned durations say it would finish sooner (counting the time
169 -
to restore the latest checkpoint), start the job there too. The first to
170 -
finish wins; the other is cancelled.
171 -
172 -
- **Bounded:** one hedge per job, and only where a policy allows it (the
173 -
  critical path of a run, or always on owned runners). Never for jobs
174 -
  with side effects (deployments, publishing).
175 -
- **No tricky handover:** the slow runner is never interrupted. If the
176 -
  fast one fails, nothing is lost.
177 -
- **A free determinism check:** when both finish (a race), their outputs
178 -
  are compared; a difference is reported as a non-hermetic job.
179 -
- **Cost-aware in clouds:** starting a bigger instance costs money, so a
180 -
  pool may forbid hedging, or allow it only for jobs a person is waiting
181 -
  on.
182 -
183 -
Hedging plus holding briefly for a better runner (which exists) gets most
184 -
of the benefit of a full handover, for a fraction of the work. Build it
185 -
before anything finer (§12).
186 -
187 -
## 9. Trust classes (oxygenflow#79)
188 -
189 -
How far to believe a receipt depends on who signed it:
190 -
191 -
| Class | Who | Key |
192 -
|---|---|---|
193 -
| `attested` | cloud confidential VMs, vTPM-measured hosts | keyless workload identity (oxygenhub#122) |
194 -
| `owned` | the organisation's registered hosts | the runner's identity, or keyless |
195 -
| `device` | members' phones and laptops | the device's Keystore key, under the member's grant |
196 -
| `anonymous` | browsers not signed in | a key held non-extractable by WebCrypto |
197 -
198 -
- A sandbox or job may require a minimum (`trust "owned"`). Release and
199 -
  publish jobs default to `owned`.
200 -
- A job resumes only from checkpoints made at its class or above.
201 -
- Receipts and provenance record the class of every runner that
202 -
  contributed. The trust page shows a run's weakest link.
203 -
- **Spot checks:** a sample of jobs run by `device` and `anonymous`
204 -
  runners is run again elsewhere and the outputs compared (with
205 -
  oxygenhub#298's independent rebuilds). A runner whose results disagree
206 -
  is held for its owner to look at.
207 -
208 -
## 10. Phones and browsers
209 -
210 -
**Phones** (oxygenhub-android#8): one tap registers the phone as a
211 -
WASM-tier runner, as a foreground service, scoped like any runner. The
212 -
owner's policy says when it may run: on charge, on these networks (the
213 -
office Wi-Fi), under a temperature, within a storage budget. Leaving
214 -
those conditions drains it (§6). The runner itself is Flow's runner code
215 -
built for Android through the app's Rust core (rust-core, uniffi), with
216 -
wasmtime for the tier. The full Linux runner in Android's Linux terminal
217 -
(#9) comes later.
218 -
219 -
**Browsers** (#80): a page on the hub turns a tab into a WASM-tier runner
220 -
in a Web Worker: WASI through a shim, OPFS (the browser's private file
221 -
storage) for files, uploads as for any runner. Limits: about 4 GB of
222 -
memory, threads only on a cross-origin-isolated page, throttled or
223 -
stopped in a background tab (a drain). This is mostly a demonstration of
224 -
handover and spot checks on the least reliable runner there is, not
225 -
capacity.
226 -
227 -
## 11. The first demonstration
228 -
229 -
A pipeline over oxy-core:
230 -
231 -
1. `build-wasm-tests` (microvm, any x86_64 host): `cargo test
232 -
   --target wasm32-wasip1 --no-run`, outputs the test modules.
233 -
2. `test-wasm` (wasm, `runs-on "android"`, depends on 1): runs each
234 -
   module under the WASM tier, on the phone.
235 -
236 -
The script: start the run; the phone takes `test-wasm`; switch the
237 -
phone's Wi-Fi off halfway (a drain under its policy); the job goes back
238 -
to the queue and plex-1 takes it in its WASM tier; the run page shows
239 -
which runner did what, with the receipts and their trust classes. Then
240 -
the same with a pipeline that has a checkpoint, to show a job resuming
241 -
rather than restarting.
242 -
243 -
## 12. Order of work
244 -
245 -
1. **WASM tier** (#75), in `flow` on Linux runners.
246 -
2. **Phone runner** (oxygenhub-android#8) on that tier, with its policy.
247 -
3. **Leases and drain** (#76), with requeue ahead of new work.
248 -
4. The demonstration (§11), job-level: drain and requeue.
249 -
5. **Checkpoints** (#77): resume within a job; receipts that say so.
250 -
6. **Trust classes** (#79), before anything other than our own runners
251 -
   joins.
252 -
7. **Hedging** (#78), then measure on our own CI: queue and run times
253 -
   with and without it.
254 -
8. **Browser runners** (#80).
255 -
9. **Finer pieces,** only if the measurements call for them: test
256 -
   sharding and per-crate builds (#29), so that a long job is many short
257 -
   ones a better runner can take over between.
258 -
259 -
Cloud and Kubernetes pools come through Jetstream (jetstream.md §9,
260 -
oxygenflow#33), and get drain and checkpoints as they arrive.
261 -
262 -
## 13. Open questions
263 -
264 -
- Checkpoint retention: the run's life, or kept as a cache for the next
265 -
  run of the same job (a remote cache, which would then need the same
266 -
  trust rules)?
267 -
- How long may a drained job wait for a runner of its trust class before
268 -
  it falls back to restarting from nothing on any runner?
269 -
- Hedging costs compute: a per-pool budget, or a per-organisation one?
270 -
- A phone's battery and heat: are the owner's policy limits enough, or
271 -
  should the runner also cap its own CPU use?
modified docs/jetstream.md +0 −178
…
238 unchanged lines
239 239
- **Architecture recorded at build**, so an x86 image never reaches a
240 240
  RISC-V runner.
241 241
242 -
### 8.1 The swarm: runners share images
243 -
244 -
Runners that are configured for it fetch image chunks from each other as
245 -
well as from the hub (oxygenflow #47, oxygenhub #144). Fifty fresh
246 -
runners asking for the same 2 GiB image then cost the hub about one copy,
247 -
not fifty, and a site behind a thin link pulls each chunk across it once.
248 -
249 -
- **Trust comes from the hashes.** The manifest comes from the hub, and
250 -
  every chunk is checked against its name before it is kept. A peer can
251 -
  fail to help; it can never change what a job runs.
252 -
- **The hub is the tracker.** A runner reports its swarm address, its site
253 -
  and its certificate's fingerprint with every claim. `POST /api/v1/flow/swarm/announce {image, state}`
254 -
  (`fetching`, `done` or `stopped`) returns the peers in the same site that
255 -
  hold the image or are fetching it, and a **ticket**.
256 -
- **Tickets admit peers.** A ticket names the runner, its site and an
257 -
  expiry (15 minutes), signed with the hub key that `/.well-known/oxyhub-key`
258 -
  publishes. A runner shows its ticket once to each peer
259 -
  (`POST /swarm/v1/hello`) and gets a session for the ticket's life; no
260 -
  other runner and no account can take chunks from a peer.
261 -
- **Encrypted, and pinned by the hub.** Each runner makes its own TLS
262 -
  certificate on first use (`swarm/cert.der`, its key readable by the
263 -
  runner alone) and keeps it, so its fingerprint (BLAKE3 of the
264 -
  certificate) holds across restarts. The tracker hands each peer out with
265 -
  that fingerprint, and a fetcher talks only to a peer showing that
266 -
  certificate and proving its key in the handshake; no CA is involved. A
267 -
  peer at another address, or a stranger at a peer's, gets nothing and
268 -
  sees nothing: chunks, tickets and sessions all travel inside TLS.
269 -
- **The peer protocol** is HTTPS on the runner's swarm address:
270 -
  - `POST /swarm/v1/hello` with the ticket: a session token.
271 -
  - `GET /swarm/v1/images/{image}/have`: `complete`, or two bit maps over
272 -
    the manifest's needed list (`Manifest::needed`), in its order: the
273 -
    chunks it has, then the chunks it is fetching now.
274 -
  - `GET /swarm/v1/chunks/{chunk}`: the chunk's bytes, from the chunk
275 -
    cache or read out of a whole image; `503` when the peer is at its
276 -
    connection limit, and the fetcher asks it again shortly.
277 -
- **Rarest first.** The fetcher refreshes every peer's maps twice a
278 -
  second and asks each peer, four requests at a time, for the chunk the
279 -
  fewest peers hold. The hub is a seed that holds everything: it is asked
280 -
  only for chunks no peer has or is fetching, or any chunk once the peers
281 -
  have made no progress for two seconds. Runners that start together so
282 -
  split the hub's chunks between them rather than each taking them all. When every missing chunk is already asked
283 -
  for, idle workers ask a second source for it (the endgame; the hub joins
284 -
  it only once the peers stall). A peer that
285 -
  fails is dropped and its chunks go back to the pool.
286 -
- **Serving while fetching.** A chunk is offered to others as soon as it
287 -
  is checked and kept, so runners starting together share the load.
288 -
- **Bounded.** A runner serves at most 16 connections and `upload_mib_s`
289 -
  (default 50 MiB/s), so serving never starves its own jobs.
290 -
- **Configured, never assumed.** Without a `[runner.swarm]` section a
291 -
  runner neither listens nor asks peers. Peers are not offered across
292 -
  sites, so a site is the network its runners can reach each other on.
293 -
294 -
In flow.toml (or as `[swarm]` in runner.toml, which stands in for the
295 -
`[runner]` section):
296 -
297 -
```toml
298 -
[runner.swarm]
299 -
listen = "0.0.0.0:7401"        # where peers reach this runner
300 -
advertise = "10.0.0.5:7401"    # the address peers are given (default: listen)
301 -
site = "home"                  # peers are only ever in the same site
302 -
upload_mib_s = 50
303 -
```
304 -
305 -
#### 8.1.1 Images only runners hold (oxygenflow#94, oxygenhub#342)
306 -
307 -
Until now an image had to be sent to the hub (`flow rootfs push`) before any
308 -
runner but the one that imported it could take its jobs: the hub hands out
309 -
the manifest, and placement offers a job to a runner that fetches only when
310 -
the hub holds its image. On 2026-10-25 the Rust image was re-pinned and not
311 -
pushed, and for a day every Rust job queued on the one runner that had
312 -
imported it while the others sat idle.
313 -
314 -
- **Runners say what they hold.** At start, every ten minutes and after an
315 -
  import, a runner sends the hashes of the images it holds
316 -
  (`POST /flow/images/held {images}`); the hub answers with those it keeps
317 -
  no manifest for (`{wanted}`), and the runner sends each manifest alone
318 -
  (`PUT /flow/images/{hash}/held`, a few MB even for a 14 GB image): no
319 -
  chunks. (Not in the claim: an idle runner's claim gets no answer at all.)
320 -
- **The hub keeps it as peer-held.** It checks the manifest is well formed
321 -
  and names the hash it is filed under, and records the image as held by
322 -
  runners, not by itself: it cannot read the image to check it. A pushed
323 -
  image (`flow rootfs push`, verified whole) replaces a peer-held record.
324 -
- **Trust still comes from the hashes.** The pipeline pins the image's hash;
325 -
  a fetcher asks for the manifest by that hash, checks every chunk against
326 -
  its name, and checks the assembled image against the pin before it keeps
327 -
  it (`jet::images::assemble`). A wrong manifest can make a fetch fail; it
328 -
  cannot change what a job runs. Only runner tokens may send one.
329 -
- **Placement counts peers.** A job whose image the hub holds only as
330 -
  peer-held goes to a runner that fetches only when a runner of the same
331 -
  swarm site holds the image, and says so when none does ("held only by
332 -
  earth-1, in another site").
333 -
- **The hub is no seed for it.** The announce answer says whether the hub
334 -
  holds the chunks (`seed`); when it does not, the fetcher asks peers only,
335 -
  and a fetch with no peer left fails with that reason rather than after
336 -
  the hub refused chunk after chunk.
337 -
338 -
### 8.2 Runners update themselves
339 -
340 -
Proposed 2026-10-25 from Andy's experience: keeping a large fleet's
341 -
runners up to date by hand is one of the biggest costs of running CI.
342 -
Runners fetch new runner builds the way they fetch images, from each other
343 -
through the swarm, check them as strictly as a release, and swap
344 -
themselves in without losing work, under a policy the organisation sets,
345 -
testing on a few runners before the rest.
346 -
347 -
**A runner build** is a `flow` binary from an OxygenFlow release, one per
348 -
target (linux-x86_64, linux-arm64, linux-riscv64; Windows and macOS when
349 -
those runners exist), built by Flow from the tagged state with a signed
350 -
receipt, as oxy's and OxygenHub's releases are. The hub serves it as a
351 -
blob with a chunk manifest, so the swarm (§8.1) carries it unchanged: one
352 -
runner of a site fetches each chunk from the hub and the rest from it.
353 -
354 -
**Off unless chosen, nearest wins.** The policy is set for the site, an
355 -
organisation, a test group and a runner; the nearest setting wins:
356 -
357 -
- `updates`: `off` (the default) or `on`;
358 -
- `channel`: `stable`, `canary`, or a pinned version (`0.3.4`);
359 -
- `window`: hours in which a runner may update (outside them it waits);
360 -
- `dry_run`: fetch and check, say what would happen, do not swap.
361 -
362 -
A runner's own `flow.toml` has the last word: `[runner.update] allow =
363 -
false` refuses every update whatever the hub says, so a site can hold a
364 -
machine back while it tests.
365 -
366 -
**Test groups and waves.** A test group is a named set of runners: chosen
367 -
by name, by label or by pool. A rollout is a release and its waves, in
368 -
order; each wave is a group and its share of it, **a percentage or chosen
369 -
runners**. A percentage is taken by a stable hash of each runner's id, so
370 -
"10% of build-farm" is the same machines every rollout, the ones the
371 -
organisation watches. A wave starts when the one before has been healthy
372 -
for its soak time (say 24 hours) or when an admin promotes it. For
373 -
example: canary (two chosen runners), then build-farm at 10%, then
374 -
build-farm at 100%, then everyone.
375 -
376 -
**Healthy** means the updated runners came back on the new version within
377 -
ten minutes, claim jobs, and their jobs' infrastructure errors (`lost`,
378 -
`error`, declines) over the soak are no worse than the same runners' on
379 -
the version before, over enough jobs to tell. A wave that is not healthy
380 -
halts the rollout, and its runners roll back. Job failures from the
381 -
pipelines themselves do not count.
382 -
383 -
**On the runner,** in order:
384 -
385 -
1. The hub's answer to a claim or heartbeat names an update when the
386 -
   policy says so: the version, its blob and manifest, its receipt.
387 -
2. Fetch the blob through the swarm (hub as seed); every chunk checked
388 -
   against the manifest from the hub.
389 -
3. Check it as a release: its BLAKE3 matches the release asset the hub
390 -
   attests; the receipt is signed by an `owned` build runner (trust
391 -
   classes, #79) and names the tagged state; the target is this machine's.
392 -
   A peer can only serve bytes; it cannot change what is installed.
393 -
4. Stage it (`bin/flow.new`) and run its self-check (`flow.new tiers`,
394 -
   `--version`).
395 -
5. Drain with notice (`leave_within`, §11.5.2 of the spec): finish what
396 -
   fits, hand back the rest with its checkpoints.
397 -
6. Swap: `flow` → `flow.prev`, `flow.new` → `flow`, and restart.
398 -
7. Prove it: the first claim reports the new version. A runner that will
399 -
   not start, or that the hub does not hear from within ten minutes, goes
400 -
   back to `flow.prev` (a start counter in the runner's home: three
401 -
   failed starts after an update restore the previous build).
402 -
8. The runner's page shows its version, channel, group, last update and
403 -
   why; the audit log records every update and rollback.
404 -
405 -
**Phones** take runner updates with the app's releases (Android updates an
406 -
app only through its store or an install), so they follow the app.
407 -
408 -
**Order of work:** (1) the runner side, with a local source (`flow runner
409 -
update --from FILE`): check, stage, drain, swap, prove, roll back (done,
410 -
#85: spec §11.5.3, Linux and Windows); runners asking the hub and taking
411 -
its offer (done); (2) the
412 -
policy on the hub (opt in and out, channels, windows, dry run) and the
413 -
update a runner is offered (done: oxygenhub#322, for the site and for each
414 -
organisation's own runners, `GET /api/v1/flow/runner/update`); (3) test groups (done: chosen or a percentage), waves and health (a
415 -
build that rolls back on one runner is held for every other, oxygenhub#326;
416 -
waves that advance by themselves after a soak are not built yet); (4) the
417 -
swarm as transport; (5) Admin › Runners › Updates (done) and `oh runner
418 -
update`.
419 -
420 242
## 9. Pools
421 243
422 244
A **pool** is one place runners come from. All pools report the same
…
165 unchanged lines
modified docs/m0.md +6 −14
…
146 unchanged lines
147 147
   variable the pipeline sets keeps its value. They are part of the job's
148 148
   environment, so of its receipt.
149 149
5. `flow-init` frames the child's output on the serial console as
150 -
   `\x1eO <line>` (stdout) and `\x1eE <line>` (stderr). After a passing
151 -
   job it prunes each build cache disk (mounted `strictatime`) of the
152 -
   files the job did not use when it is over the runner's
153 -
   `cache_prune_percent` of its size, and reports each as
154 -
   `\x1eC <json>` (spec §5.4, oxygenflow#72); then `\x1eX <code>`, and
155 -
   it powers off. The kernel runs `quiet` so the host sees
150 +
   `\x1eO <line>` (stdout) and `\x1eE <line>` (stderr), then
151 +
   `\x1eX <code>`, and powers off. The kernel runs `quiet` so the host sees
156 152
   only frames; anything else is kept as `console` records.
157 153
6. The host reads frames, streams them as log records, and kills the VM
158 154
   at the timeout (the guest cannot extend it). Scratch files are deleted
…
9 unchanged lines
168 164
UTS namespaces (no interfaces but loopback), the rootfs image's tree
169 165
(unpacked once per hash, read-only) as `/`, the workspace directory at
170 166
`/src`, tmpfs at `/tmp`; Landlock limits writes to `/src` and `/tmp`; a
171 -
seccomp filter refuses `socket` for anything but `AF_UNIX` (and, with
172 -
`network "loopback"`, `AF_INET` and `AF_INET6`, which reach only the
173 -
job's own `lo`: oxygenflow#73), plus
167 +
seccomp filter refuses `socket` for anything but `AF_UNIX`, plus
174 168
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls;
175 169
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the
176 170
runner has a delegated cgroup, and the job fails to start otherwise
…
1 unchanged lines
178 172
179 173
**How it runs.**
180 174
- **Helper:** for each job the runner starts `flow __sandbox <spec>` (its own binary, through `/proc/self/exe`). The helper joins the job's cgroup, makes the namespaces with the job's user (uid and gid 1000) mapped to the runner's, and forks the sandbox's PID 1.
181 -
- **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"` and `"egress"` (with the vsock relay of spec §4.3.1 for the latter), pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach.
175 +
- **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"`, pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach.
182 176
- **Job:** the job gets no new privileges, then Landlock (read and execute everywhere, write beneath `/src`, `/tmp`, `/dev` and its mounts), then the seccomp filter, then `execve` with exactly the job's environment, found the way `flow-init` finds it (the two share that code). The filter also refuses io_uring (which could open sockets past it), the new mount calls, kexec, swap and reboot.
183 177
- **Unpacking:** `debugfs rdump` unpacks the image into `trees/<hash>` in the home once, without mount rights. Set-id bits and device nodes are dropped.
184 -
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier. One that names a `cache` starts from a directory a passing job of the slot kept, and goes back into the runner's cache pool when the job passes (spec §5.4, oxygenflow#70), pruned first by the runner of the files the job did not use when over the cap (oxygenflow#72).
178 +
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier.
185 179
- **Artifacts:** they are read from the workspace and mount directories without following links.
186 180
- **Receipts:** the environment's runtime is `syscall`. Its parts are the `flow` binary's BLAKE3, the host kernel's release, memory, CPUs and mounts.
187 181
- **Limits:** the job's cgroup gets `memory.max`, `memory.swap.max 0` and `cpu.max`. A job killed for memory is told so in its log. At the end the group is killed and removed.
…
61 unchanged lines
249 243
`kernel/vmlinux` (pinned), `cas/` (rootfs images), `work/` (scratch), and
250 244
`runner.toml`. `flow runner` runs as the `flow-runner` systemd service
251 245
under the `claude` user (member of `kvm`), polling the hub at
252 -
`https://earth.andymccall.xyz:7343`. The unit has `KillMode=mixed` and
253 -
`TimeoutStopSec=75min`, so a restart drains the runner instead of killing
254 -
its job (spec §11.5.1).
246 +
`https://earth.andymccall.xyz:7343`.
255 247
256 248
## 7. Not in M0
257 249
…
4 unchanged lines
modified docs/spec.md +7 −382
…
90 unchanged lines
91 91
    rootfs "cas://blob/9b5c2a1e8f3d4e7a6b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
92 92
    memory-limit "4GiB"
93 93
    cpu-limit 4
94 -
    network "isolated"      // Options: "isolated" | "loopback" | "egress" { allow "host"... } (§4.3.1)
94 +
    network "isolated"      // Options: "isolated" | "loopback" | "egress"
95 95
}
96 96
97 97
// Job evaluated based on Oxygen change closure
…
80 unchanged lines
178 178
```
179 179
180 180
**Time limits.** A job may say how long it may run with `timeout "90m"` (hours, minutes and seconds, as `"2h"`, `"1h30m"` or `"45s"`, from a minute to a day); a sandbox's `timeout` is the default for its jobs. Without one, the hub's default applies, and the hub caps every job at the site's maximum (both set by an administrator on Admin → Runners). The runner stops a job at its limit, and the hub ends one its runner stopped reporting a few minutes after it. A timeout is part of the compiled job, so a job without one compiles as before.
181 -
182 -
**Priority.** A pipeline may lower its jobs' place in the hub's queue, never raise it (oxygenhub#244): `priority "low"` (or `"normal"`) on a job, or at the top of the file for every job that does not say. A job's level is then the lower of its own and its repository's (the repository's, organisation's or site's, set on the hub), so nightly or bulk jobs can stand back for everyday ones; `high` and `urgent` are refused here, being the hub's to give, and a run a person expedites stays urgent whatever its jobs say. A priority is part of the compiled job; a job without one compiles as before.
183 181
184 182
**Runner labels.** A sandbox or a job may say `runs-on "LABEL" ...`: lower-case letters, digits, `-`, `_` and `.`, at most 64 bytes each, given on one or more lines. A job needs a runner that has every label its sandbox and it name. A runner reports its labels and architecture when it registers and on each claim (`flow runner register --label`). The hub hands a job only to a runner with all of them, within the runner's scope (OxygenHub spec 13.2), and a job no runner can take waits, saying so on the queue. Labels are part of the compiled job, so a job that asks for none compiles as before.
185 183
…
7 unchanged lines
193 191
OxygenFlow implements three distinct isolation tiers depending on the workload:
194 192
195 193
### 4.1 Level 0: WASM Component Sandbox
196 -
- Intended for static linters, formatters, doc generators, policy checks, and
197 -
  test suites built for `wasm32-wasip1`; the tier phones and browsers run
198 -
  (docs/handover.md).
199 -
- Built (oxygenflow#75): wasmtime 49 runs WASI preview 1 modules, each in a
200 -
  fresh instance, compiled by Cranelift (tens of milliseconds a module).
201 -
  Preview 2 components later.
202 -
- `runtime "wasm"`; no `rootfs` and no network (`network` absent or
203 -
  `"isolated"`); `memory-limit` holds the module's linear memory.
204 -
- `exec` names the module, a path among the job's files (its scope or an
205 -
  earlier job's artifact), optionally under `/src/`, or a glob with `*` in
206 -
  its file name: each match runs in turn, and the job's exit code is the
207 -
  first non-zero one after all have run. The rest of `exec` are the
208 -
  module's arguments. A module that traps exits 134.
209 -
- The job's files are the only host files it sees, preopened as `/src` (and
210 -
  as the current directory, for relative paths); each `mount` is preopened at
211 -
  its target. Secrets and identities are not offered yet.
212 -
- The receipt's environment names `wasmtime` and its version, the runner
213 -
  binary, the memory, and every module run with its BLAKE3.
194 +
- Intended for static linters, formatters, doc generators, and policy checks.
195 +
- Runs on a standard WebAssembly System Interface (WASI) runtime.
196 +
- Startup latency: < 5 ms.
197 +
- Zero host I/O; virtualized file system presented in-memory.
214 198
215 199
### 4.2 Level 1: Intercepted Syscall Isolation (gVisor / Landlock + seccomp-bpf)
216 200
- Intended for standard compilation and hermetic test suites.
…
8 unchanged lines
225 209
- The root filesystem is presented as a read-only virtio-block device mapped
226 210
  directly to FastCDC chunks stored in Oxygen's CAS.
227 211
- Guest VM memory is cleared upon termination.
228 -
229 -
#### 4.3.1 Allow-listed egress (oxygenflow#55)
230 -
231 -
A sandbox reaches the network only by naming its hosts:
232 -
233 -
```kdl
234 -
sandbox "deploy" {
235 -
    runtime "microvm"
236 -
    network "egress" { allow "*.googleapis.com" "registry.opentofu.org" }
237 -
}
238 -
```
239 -
240 -
The VM still has no network device. `flow-init` serves a proxy on the
241 -
guest's `127.0.0.1:3128` (the job gets `HTTPS_PROXY`, `HTTP_PROXY` and
242 -
their lower-case forms, unless it sets them) and relays each connection,
243 -
unread, over Firecracker's vsock to the runner. The runner accepts only
244 -
`CONNECT host:443` for a host the list allows (a name exactly, or any
245 -
name under a `*.` domain, never the domain itself; never an address),
246 -
resolves it itself (the guest does not resolve), refuses any name that
247 -
resolves to an address that is not public, and relays the bytes without
248 -
opening the TLS. Every decision, allowed or refused, is a `flow` line in
249 -
the job's log. Patterns are lower-case host names of two or more labels,
250 -
at most 32 per sandbox, and part of the sandbox's hash.
251 212
252 -
Egress is off unless a runner turns it on (`egress = true` under
253 -
`[runner]` in `flow.toml`); such a runner offers the `egress` label, and
254 -
an egress sandbox needs it, so the hub hands its jobs only to runners
255 -
that allow it, and a runner without it refuses one anyway. The syscall
256 -
runtime has no egress yet.
257 -
258 -
**Keyless receipts** (oxygenhub#241). With `keyless = true` under
259 -
`[runner]` (or in `runner.toml`), a runner signs each job's receipt with a
260 -
fresh key instead of its own: just before signing, it asks the hub to
261 -
certify the key (`POST /api/v1/keyless/certify` with the job's token and
262 -
the key's proof of possession, scope `seal`), checks the certificate is
263 -
for that key and signed by the key the hub publishes at
264 -
`/.well-known/oxyhub-key`, signs, attaches the certificate and the hub's
265 -
identity to the receipt (Oxygen spec §14.6), and drops the key. When the
266 -
hub cannot certify, the runner's own key signs and the job's log says
267 -
why. The runner's key then only authenticates it to the hub.
268 -
269 -
**Metrics** (oxygenflow#35). With `metrics = "127.0.0.1:9464"` under
270 -
`[runner]` (off unless set), a runner serves Prometheus text at
271 -
`/metrics` on that address, from a thread of its own: its version and
272 -
uptime, jobs claimed and declined, finished jobs by result (`passed`,
273 -
`failed`, `error`, `cancelled`), the seconds they ran together, and the
274 -
jobs running now, each labelled with the runner's name. With
275 -
`metrics_token`, a scrape must send it as a bearer token; without one,
276 -
bind it to an address only the scraper reaches. The hub's own `/metrics`
277 -
has the queue and run times of every runner's jobs.
278 -
279 213
## 5. Content-Addressable Storage (CAS) & Zero-Copy Mounting
280 214
281 215
OxygenFlow runners bypass traditional clone operations. Repositories are accessed
…
66 unchanged lines
348 282
    report "frame-timing" {
349 283
        path "/src/out/report"
350 284
        when "always"
351 -
    }
352 -
}
353 -
```
354 -
355 -
### 5.3.1 Artifacts between jobs (oxygenflow#58)
356 -
357 -
A job takes an artifact of a job it depends on:
358 -
359 -
```kdl
360 -
job "apply" {
361 -
    depends-on "plan"
362 -
    input {
363 -
        artifact "tfplan" from "plan"
364 -
        path "/src/tfplan"
365 285
    }
366 286
}
367 287
```
368 288
369 -
The producer must be named in `depends-on` and declare that name as an
370 -
artifact (not a report); paths are clean absolute paths under `/src`, at
371 -
most 8 inputs, and they are part of the job's hash (`[artifact, from,
372 -
path]`, field 10). Since the producer has passed when the job is handed
373 -
out, the hub puts each input in the claim with the blob the producer's
374 -
receipt names (a passing job's receipt names exactly the artifacts it
375 -
uploaded). Before the job starts, the runner fetches each from
376 -
`GET /api/v1/flow/jobs/{job}/inputs/{index}`, checks its Oxygen blob
377 -
reference against that blob, refuses the job if they differ, and writes it
378 -
at its path in the workspace (not part of the scope hash: it belongs to the
379 -
run, not the repository). The job's receipt lists the blobs as its
380 -
`inputs`, so an apply job's receipt proves which plan it applied.
381 -
382 -
### 5.3.2 Approval gates (oxygenflow#57)
383 -
384 -
A job can wait for a person:
385 -
386 -
```kdl
387 -
job "apply" {
388 -
    depends-on "plan"
389 -
    approval {
390 -
        by "maintain"        // the least role: "write", "maintain" (default) or "admin"
391 -
        others-only #true    // not the person whose push started the run
392 -
        expires "24h"        // from when the jobs it depends on are done; up to 720h
393 -
        sealed #true         // signed with the approver's key; default: on with an environment
394 -
    }
395 -
}
396 -
```
397 -
398 -
The hub hands such a job to no runner until someone with that role on the
399 -
repository approves it, on the run page or through the API; others-only
400 -
leaves out whoever pushed. Approving or rejecting is recorded on the job
401 -
(who, when) and in the audit log. A rejected or expired job ends as
402 -
cancelled and the jobs after it are skipped; a newer push to the
403 -
bookmark cancels the run as before, so a stale plan is never applied. The
404 -
approval is part of the job's hash (field 11).
405 -
406 -
**Sealed approvals (oxygenhub#235).** With `sealed` (the default for a job
407 -
that names an environment, §5.3.3), pressing Approve is not enough: the
408 -
approver signs an Oxygen `approval` attestation with their own key
409 -
(`oxy seal --flow URL --job NAME`; Oxygen spec §14.4) covering the run, the
410 -
job's hash, the state, the input blobs (the reviewed plan) and the
411 -
environment. The hub starts the job only once it verifies, the key is
412 -
linked to an account with the role, and it is not a delegated key; it keeps
413 -
the approval in the repository, and the job's identity tokens name the
414 -
approver and the approval's hash. Rejecting needs no seal.
415 -
416 -
**Agents (oxygenhub#229, #258).** A gate is there so a person decides. A
417 -
decision made through an agent's token (an agent-marked token, or a keyless
418 -
agent's grant) is refused unless the repository lets agents decide its
419 -
gates (its Settings › OxygenFlow, for maintainers; off by default, audited
420 -
when changed). Where allowed, it says so on the job, the run and the audit
421 -
log ("approved by carol via claude-code"). A sealed approval is a person's
422 -
own key, whatever carried it, and is not affected.
423 -
424 -
### 5.3.3 Environments (oxygenflow#59)
425 -
426 -
`environment "prod"` on a job says where it deploys (1 to 32 of `a-z`,
427 -
`0-9`, `-`; part of the job's hash, field 12). The hub keeps what each
428 -
environment of a repository runs, from the jobs that passed for it (state,
429 -
run, when, who approved), on the repository's Deployments page. The job's
430 -
identity tokens carry an `environment` claim only when the job was
431 -
approved (§5.3.2): a job with no approval block, or one not approved, gets
432 -
tokens without it. So a cloud's trust condition can require
433 -
`environment == "prod"`, and nothing unapproved deploys there, even from
434 -
the deploy bookmark. The approval block is the environment's protection
435 -
rule for now.
436 -
437 -
### 5.4 The Build Cache (oxygenflow#28)
438 -
439 -
An `ephemeral://disk` mount may name a `cache`, and the files that key it:
440 -
441 -
```kdl
442 -
mount {
443 -
    source "ephemeral://disk"
444 -
    target "/src/target"
445 -
    size "24GiB"
446 -
    cache "target"
447 -
    key "Cargo.lock" "rust-toolchain.toml"
448 -
}
449 -
```
450 -
451 -
Such a disk is not always empty at the start. Each runner keeps a pool of
452 -
the disks of passing jobs, by repository, job, cache name and bookmark,
453 -
named by the **key**: the BLAKE3 of the sandbox image, the job's name, the
454 -
cache name, and each key file's content in the job's state. A job's disk
455 -
starts as, in order: its bookmark's disk with the same key (moved), its
456 -
bookmark's newest disk (moved), another bookmark's with the same key and
457 -
then another's newest (copied, so a proposal never takes the default
458 -
bookmark's), and otherwise an empty disk. A microVM's disk must be the
459 -
size the mount asks for.
460 -
461 -
- **Only passing jobs keep a disk.** When the job and its outputs pass,
462 -
  its cached disks go back into the pool under their key; otherwise they
463 -
  are discarded with the scratch directory, so nothing a failing job wrote
464 -
  reaches a later one.
465 -
- **The receipt says so.** The job's environment gains `cache TARGET` with
466 -
  the key and what the disk started from, so the receipt's environment
467 -
  hash differs from a cold build's.
468 -
- **Bounded.** The pool keeps under `cache_gb` in the runner's config
469 -
  (default 64 GiB) by dropping the disks used longest ago.
470 -
- **Pruned before it is kept** (oxygenflow#72). A kept disk would
471 -
  otherwise carry every build's files (each crate under every hash a
472 -
  lockfile or feature set gave it) until the job's disk is full. When a
473 -
  passing job's cache holds more than `cache_prune_percent` of its
474 -
  mount's `size` (runner config, default 60), the files the job did not
475 -
  use are removed, used longest ago first, until it is under that cap; one
476 -
  still over it is not kept, so the next run starts empty. A file was
477 -
  used if it was read, written or changed since the job started, or it
478 -
  shares a unit hash with one that was (a 16-digit hex word after a `-`
479 -
  in its path, as cargo names a unit's fingerprint, rlibs, binaries and
480 -
  build-script output: a crate cargo found fresh is only looked at, but
481 -
  its fingerprint is read), or is another link of one, or lies under one
482 -
  of the job's outputs. A microVM's guest (`flow-init`) prunes its disks
483 -
  before it powers off, having mounted them `strictatime` so every read
484 -
  is seen, and tells the runner what it kept (`\x1eC` frames); without
485 -
  word from it the runner only measures the image (`dumpe2fs`) and keeps
486 -
  it if it is under the cap. The syscall tier's runner prunes a directory
487 -
  itself, having set each file's atime to the epoch when the job took it,
488 -
  so even a `relatime` file system records the job's first read of each;
489 -
  on a `noatime` one nothing is pruned and only the size decides. The
490 -
  job's log says what happened: `cache target: kept 12.3 GiB of 23.1 GiB
491 -
  (pruned 10.8 GiB in 4113 files this run did not use; the cap is 60% of
492 -
  its 24.0 GiB disk)`, or `not kept:` and why.
493 -
- **Unchanged sources keep their times** (oxygenflow#52). Beside each kept
494 -
  disk the pool keeps its sources: each workspace file's content hash and
495 -
  the mtime it had, in whole seconds (what the workspace image keeps). A
496 -
  job that takes the disk gives each file whose content is unchanged that
497 -
  mtime back before the workspace is imaged; a changed or new file keeps
498 -
  the time of its checkout, newer than anything on the disk. A build tool
499 -
  that judges freshness by time, such as cargo, then rebuilds exactly the
500 -
  crates whose sources changed, where a fresh checkout would make every
501 -
  path crate look changed. Directories are kept the same way, by their
502 -
  listing (the names in them): an unchanged one gets its old time back,
503 -
  one that gained or lost an entry keeps its new time, so a build
504 -
  script's `rerun-if-changed` on a directory sees exactly what changed.
505 -
  The log says how many files and directories kept their times. A build
506 -
  script that reads a per-state value (the Oxygen crates' `FLOW_HEADS`)
507 -
  reruns on every state; those crates read it only when `OXY_BUILD` is
508 -
  unset, so a pipeline that sets `OXY_BUILD` keeps them fresh.
509 -
- **Jobs go where their cache is** (oxygenflow#51). Each claim lists the
510 -
  caches the runner holds a disk of (`caches`, each
511 -
  `flow_core::api::cache_slot(repo, job)`), and the hub ranks a runner
512 -
  holding a job's cache above one holding its image (§11.4): a job another
513 -
  free runner holds the cache for is held for it for up to 15 seconds, and
514 -
  `placed` says "holds its build cache". When several hold it, the
515 -
  freshest copy wins (oxygenflow#54): claims also say when each slot last
516 -
  kept a disk (`cache_times`, Unix ms), and a copy kept at least a minute
517 -
  later ranks above an older one, before the image and speed are
518 -
  weighed; `placed` says how old the copy is.
519 -
- **The syscall tier keeps directories** (oxygenflow#70). Its
520 -
  `ephemeral://disk` mount is a host directory bound at the target, so
521 -
  its pool holds directories (`<key>.tree`) beside the microVM's images
522 -
  (`<key>.ext4`), taken and kept the same way: moved out of the pool for a
523 -
  job of the same bookmark (so two jobs of one slot running at once never
524 -
  take the same one), copied with their modes and times from another
525 -
  bookmark's, kept only when the job and its outputs pass and otherwise
526 -
  thrown away, and counted by the blocks of everything in them toward
527 -
  `cache_gb`. A directory has no size, so `size` does not choose between
528 -
  them. Each tier takes only its own form: a syscall job never starts from
529 -
  a microVM's image, nor a microVM from a directory. The job runs as the
530 -
  runner's user, so what it wrote is the runner's; before a directory goes
531 -
  into the pool nothing in it is left writable by group or others, or
532 -
  set-id, and the pool is readable by the runner alone. Claims report the
533 -
  slots of both forms in `caches` and `cache_times`, so jobs go where
534 -
  their cache is whichever tier they run in.
535 -
- A cache is a speed-up, never a source of truth: jobs must build
536 -
  correctly from an empty disk, which a key change or an eviction gives
537 -
  them at any time.
538 -
539 289
## 6. Commutative Execution & Change Caching
540 290
541 291
In Oxygen, changes that do not share scopes (referenced atoms, placements, or
…
222 unchanged lines
764 514
`flow rootfs import` reads the ELF header of the image's `/bin/sh` and
765 515
records its architecture in the CAS manifest beside the hash. A runner
766 516
refuses to import an image for another architecture (there is no
767 -
emulation tier).
768 -
769 -
An image whose manifest records no architecture (imported before this
770 -
section, or fetched from a hub that did not know it) is read, not
771 -
guessed: `jet::rootfs` follows `/bin/sh` (else busybox, env or bash)
772 -
through the image's ext4, symlinks included, to its ELF header, with no
773 -
mount and no privileges. The runner records the answer in the manifest,
774 -
so each image is read once, reports it with its claims, and declines a
775 -
job whose image is for another architecture instead of running it and
776 -
failing (§11.5). The hub reads the images it keeps the same way
777 -
(OxygenHub, oxygenhub#173), and an image nobody can read is offered only
778 -
to the architecture its jobs have passed on before.
517 +
emulation tier). An image imported before this section has no recorded
518 +
architecture and is treated as the importing runner's.
779 519
780 520
### 11.3 Job Requirements
781 521
…
44 unchanged lines
826 566
with status `error` and an error beginning `unplaceable:`. The hub
827 567
works out why each ready job waits every 15 seconds, and the run page and
828 568
the API (`waiting`) show it.
829 -
830 -
### 11.5.1 Restarts (oxygenflow#49, #50)
831 -
832 -
Neither a hub restart nor a runner restart costs a job anything.
833 -
834 -
- **The hub away.** A job's uploads (artifacts, report files) and declines
835 -
  are tried again through refused connections and server errors (5xx,
836 -
  as a proxy answers while the hub restarts) for three minutes, waiting
837 -
  1, 2, 4, 8 and then 15 seconds; a refusal (4xx) is final. Log lines
838 -
  the hub could not take are kept and sent with the next batch, 200 lines
839 -
  at a time, up to 50,000 (beyond that the oldest go, and the log says how
840 -
  many). A job's result is kept on disk and offered for two hours, as
841 -
  before. Claims are not retried through a lost connection, so a job is
842 -
  never claimed twice; the runner simply asks again.
843 -
- **The runner restarted.** The first SIGTERM or SIGINT drains the runner:
844 -
  it claims nothing more and stops once every job it holds has ended and
845 -
  been reported. A second signal stops it at once. Its systemd unit says
846 -
  `KillMode=mixed`, so only the runner is signalled and its microVMs run
847 -
  on, and `TimeoutStopSec` longer than the longest job, after which
848 -
  systemd kills what is left. A new binary is installed by replacing it
849 -
  and restarting the unit (`systemctl restart --no-block`): the old runner
850 -
  finishes its job, and `Restart=always` starts the new one.
851 -
  `flow runner drain` asks the same by a `drain.request` file in the
852 -
  runner's home, which its loop reads: the way on Windows, which has no
853 -
  SIGTERM (oxygenflow#83).
854 -
855 -
### 11.5.2 Handing back (oxygenflow#76)
856 -
857 -
A runner that leaves while it holds a job, with no time to finish it,
858 -
hands the job back: `POST /api/v1/flow/jobs/{id}/release` with
859 -
`{"reason": …}` (a phone off its allowed network, docs/handover.md §6).
860 -
The hub queues the job again **ahead of new work**, whatever its
861 -
priority, adds a line to its log saying who handed it back and why, and
862 -
lets the next attempt's log follow on. It is not a decline: the runner is
863 -
not held from the job and it does not count towards unplaceable.
864 -
865 -
**Leases.** Holding a job is a lease the runner keeps by being heard from:
866 -
claims, a heartbeat every 30 seconds while it claims nothing, its jobs'
867 -
logs. When the hub has not heard from a runner for 3 minutes, its jobs
868 -
are handed back the same way, so a runner that vanishes (out of every
869 -
network, crashed) costs a restart of its job, not the job. A job handed
870 -
back more than 5 times ends as an error saying so.
871 -
872 -
**Notice.** A departure may come with notice (a phone taken off charge
873 -
has a minute; a spot reclaim 30 seconds to 2 minutes). A claim carries
874 -
`expected_ms`, how long the job is expected to take, when the hub has
875 -
learned it: the median of the job's last five passing runs in that
876 -
repository (reused passes left out). A leaving runner finishes and
877 -
reports a job whose expected rest fits within the notice, and hands back
878 -
the others at once, and any job still running when the notice is up. A
879 -
job with no expected time is handed back at once.
880 -
881 -
**Checkpoints (#77).** A job may name directories that hold its progress:
882 -
`checkpoint "/src/target"` (one or more, under /src, not /src itself).
883 -
When its runner hands it back, the runner packs them (files with their
884 -
modes and modification times, which cargo's fingerprints read; at most
885 -
512 MiB) and keeps them with `POST /api/v1/flow/jobs/{id}/checkpoint`
886 -
before the release; the claim of the next attempt names the blob
887 -
(`checkpoint`), and that runner fetches it (`GET` on the same path),
888 -
checks it, and restores it over the job's cache before running the same
889 -
exec, so a tool that skips finished work carries on. The receipt lists
890 -
the checkpoint among the job's inputs. As built: the WASM and syscall
891 -
tiers save and restore checkpoints, under a disk mount or in the tree;
892 -
the MicroVM tier, whose files are in an image, starts again for now.
893 -
894 -
As built (flow 0.3.2, OxygenHub 0.9.4): the runner as a library has
895 -
`runner::leave(reason)` (no notice) and `runner::leave_within(reason,
896 -
notice)` beside `runner::stop()`: each claims nothing more and stops the
897 -
jobs it does not finish (their logs sent first), releasing them; if the
898 -
release cannot be sent, the lease does it. A host's SIGTERM still drains
899 -
by finishing what it holds. Checkpoints (#77), so the next attempt
900 -
resumes rather than restarts, come next.
901 -
902 -
### 11.5.3 Updating a runner (oxygenflow#85)
903 -
904 -
`flow runner update --from FILE` installs a new `flow` build in place of
905 -
the one it runs as (`--exe` names another), the runner's side of
906 -
self-updating runners (docs/jetstream.md §8.2):
907 -
908 -
1. **Refused** when the home's `flow.toml` says `[runner.update] allow =
909 -
   false`, when an earlier update is not yet proven, or when the build is
910 -
   older than the installed one (unless `--allow-older`).
911 -
2. **Checked:** a 64-bit little-endian ELF for this machine; it names
912 -
   itself (`--version`, `flow X.Y.Z …`) and finds a tier it can run here
913 -
   (`tiers` against the runner's home), each within 30 seconds.
914 -
3. **Staged and swapped:** copied beside the installed binary as
915 -
   `flow.new` and checked there; the installed one is kept as
916 -
   `flow.prev` (a hard link, else a copy), then `flow.new` replaces it in
917 -
   one rename, so `flow` is never missing. `--dry-run` stops before the
918 -
   swap and changes nothing.
919 -
4. **The running runner leaves:** a runner writes its pid to
920 -
   `runner.pid` in its home, and the update sends it SIGUSR1 (after
921 -
   checking that pid is still a `flow … runner`). It then leaves with
922 -
   `--notice` (default 600 s, §11.5.2): jobs expected to finish in time
923 -
   finish, the rest are handed back with their checkpoints, and it exits
924 -
   0, so its unit's `Restart=always` starts the new build. A runner from
925 -
   before #85 is not told; restarting its unit drains it as before.
926 -
5. **Proven or rolled back:** the update waits in `update.json` until the
927 -
   new build's first claim gets an answer from the hub. Each start counts;
928 -
   on the fourth start without that answer, or after ten minutes running
929 -
   without it, `flow.prev` is put back (and on a start, run in its place).
930 -
   `updates.log` in the home records every install, proof and rollback.
931 -
   The runner tells the hub at its next answer (`POST /api/v1/flow/runner/update`):
932 -
   `runs flow X (was Y); proven`, or `rolled back from flow X to Y: why`,
933 -
   on which the hub holds X back from every other runner until an
934 -
   administrator lifts the hold (oxygenhub#326).
935 -
6. **On Windows** (oxygenflow#83) the same steps run with Windows' means:
936 -
   the build is checked as a PE executable for this machine; `flow.exe`
937 -
   is renamed to `flow.prev.exe` and `flow.new.exe` into its place (a
938 -
   running program can be renamed but not replaced, and cannot rename
939 -
   its own file, so a copy of it, `flow __rename`, does that); the runner
940 -
   is told by an `update.request` file in its home, which it reads in its
941 -
   loop, there being no SIGUSR1; and having left, it exits 75, so a Task
942 -
   Scheduler task set to restart on failure starts the new build. A
943 -
   rollback runs the restored build in its place and ends with it.
944 569
945 570
### 11.6 Conformance
946 571
…
11 unchanged lines
removed docs/workspaces.md +0 −73
1 -
# Workspaces: VS Code in a microVM, the runner side
2 -
3 -
**Status: paused** (Andy, 2026-10-17): too large to take on, and to keep up,
4 -
before the work already promised is delivered. Nothing below is built; the
5 -
plan stands for when it is picked up again.
6 -
7 -
oxygenhub#69, hub spec §13.3. Written 2026-10-17 with Andy's decisions:
8 -
build it, **runner side first**; files **lazy from the start**; editor
9 -
traffic over a **WebSocket with multiplexing**. This is the plan for the
10 -
runner; the hub's part (the workspaces table, the button, the proxy,
11 -
commits) follows it.
12 -
13 -
## What is there today
14 -
15 -
- Jobs run to their exit under a hard time limit, with a job token that
16 -
  lasts at most two hours (hub `JOB_TOKEN_MAX_AGE_MS`).
17 -
- A microVM has no network device. Its only traffic is guest-initiated:
18 -
  the egress proxy over vsock (`flow-init/src/egress.rs`).
19 -
- The runner speaks to the hub in short HTTPS calls; nothing long-lived.
20 -
- flow-casfs mounts a state lazily with FUSE, on the host. Firecracker has
21 -
  no virtio-fs, so a FUSE mount on the host cannot be handed to a guest.
22 -
- The pinned guest kernel (6.1.155) has overlayfs and vsock, but no FUSE.
23 -
24 -
## The shape
25 -
26 -
```
27 -
browser ──HTTPS/WS──► hub /{owner}/{name}/workspace/{id}/ ──┐
28 -
                                                            │ one WebSocket per workspace,
29 -
runner ◄────────────────────────────────────────────────────┘ opened by the runner (yamux streams)
30 -
  │ vsock (host connects in)
31 -
  ▼
32 -
microVM: flow-init ─► openvscode-server on 127.0.0.1:3000
33 -
         /workspace = overlay(lower: casfs over vsock, upper: scratch disk)
34 -
```
35 -
36 -
## Steps
37 -
38 -
Each is an issue of its own, and each lands working and tested.
39 -
40 -
1. **A guest kernel with FUSE.** The same 6.1 kernel and Firecracker's
41 -
   guest config, with `CONFIG_FUSE_FS=y`; pinned by BLAKE3 in `flow.toml`
42 -
   on earth-1, plex-1 and mars-1. Pipeline jobs are unaffected.
43 -
2. **casfs over vsock.** flow-casfs gains a server half: on the host it
44 -
   answers lookups, listings and reads for one state over a vsock port of
45 -
   the workspace's VM, from the same graph-key cache it uses today. The
46 -
   guest half is a small FUSE client in the workspace rootfs. `/workspace`
47 -
   is an overlay: the state read-only below, the VM's scratch disk above,
48 -
   so edits are plain files. Opening a workspace costs a listing, not an
49 -
   export; the target is under a second on the Linux tree.
50 -
3. **The workspace rootfs.** openvscode-server (pinned release, its
51 -
   checksum checked), `oxy`, the Oxygen VCS extension, a non-root user;
52 -
   built and imported like the rust image (`images/workspace`).
53 -
4. **The `workspace` job kind.** `Claim` says `kind: "workspace"`. The
54 -
   runner keeps the VM until it is told to stop, or the hub's idle limit
55 -
   passes, instead of waiting for an exit; it reports liveness and open
56 -
   connections every 30 s, and the hub renews the job token while the
57 -
   workspace lives. A runner advertises whether it takes workspaces.
58 -
5. **The tunnel.** flow-init accepts host connections on a vsock port and
59 -
   forwards each to openvscode-server. The runner opens one WebSocket to
60 -
   the hub per workspace (`/api/v1/flow/workspaces/{id}/tunnel`, its
61 -
   runner token) and carries each browser connection as a yamux stream to
62 -
   that port. Works for runners behind NAT and through Cloudflare.
63 -
64 -
Then the hub (hub spec §13.3): the `workspaces` table and limits, "Open in
65 -
workspace" behind a site setting (off), the owner-only reverse proxy for
66 -
HTTP and WebSocket into the tunnel, and commits pushed to `ws/{account}/{id}`
67 -
and signed by the hub as the account's delegate.
68 -
69 -
## Downloads this needs
70 -
71 -
Linux 6.1 sources (kernel.org) and Firecracker's guest config for step 1,
72 -
and an openvscode-server release (GitHub, Gitpod) for step 3: each pinned
73 -
and checked against its published checksum, as cosign was.
removed images/android/Dockerfile +0 −58
1 -
# The OxygenFlow Android image (oxygenflow#19): a JDK, the Android SDK and
2 -
# NDK, Gradle, cargo-ndk, the crates and Gradle dependencies of the
3 -
# OxygenHub app, so its build and tests run in a job with no network.
4 -
# Built by ./build.sh, which fetches and checks the downloads first.
5 -
FROM rust:1.97.1-slim-bookworm
6 -
7 -
RUN apt-get update \
8 -
 && apt-get install -y --no-install-recommends ca-certificates openjdk-17-jdk-headless unzip make git pkg-config \
9 -
 && rm -rf /var/lib/apt/lists/*
10 -
11 -
# The guest's PATH is fixed and has no /usr/local/cargo/bin, and a rustup
12 -
# proxy would need RUSTUP_HOME: link the toolchain's own binaries instead.
13 -
RUN rustup target add aarch64-linux-android x86_64-linux-android \
14 -
 && cargo install --locked --version 4.1.2 --root /opt/cargo-ndk cargo-ndk \
15 -
 && rm -rf /usr/local/cargo/registry /usr/local/cargo/git \
16 -
 && tc=$(ls -d /usr/local/rustup/toolchains/*/ | head -1) \
17 -
 && for b in cargo rustc rustdoc; do ln -sf "$tc/bin/$b" /usr/local/bin/$b; done \
18 -
 && ln -sf /opt/cargo-ndk/bin/cargo-ndk /usr/local/bin/cargo-ndk
19 -
20 -
# The SDK: Google's command-line tools (checked by build.sh), then the
21 -
# platform, build tools and NDK the app names, licences accepted here.
22 -
ENV ANDROID_HOME=/opt/android-sdk JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
23 -
COPY dl/cmdline-tools.zip /tmp/cmdline-tools.zip
24 -
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
25 -
 && unzip -q /tmp/cmdline-tools.zip -d /tmp/ct \
26 -
 && mv /tmp/ct/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" \
27 -
 && rm -rf /tmp/ct /tmp/cmdline-tools.zip \
28 -
 && yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null \
29 -
 && "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \
30 -
      "platforms;android-37.0" "build-tools;36.0.0" "ndk;30.0.16248370" >/dev/null
31 -
32 -
# Gradle itself (checked by build.sh): jobs run `gradle`, not the wrapper,
33 -
# which would download its distribution.
34 -
ARG GRADLE_VERSION
35 -
COPY dl/gradle.zip /tmp/gradle.zip
36 -
RUN unzip -q /tmp/gradle.zip -d /opt && rm /tmp/gradle.zip \
37 -
 && ln -sf /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle
38 -
39 -
# The app's crates, vendored: cargo reads /.cargo/config.toml for any
40 -
# directory under /, so rust-core builds offline.
41 -
COPY vendor /opt/vendor
42 -
COPY cargo-config.toml /.cargo/config.toml
43 -
44 -
# Gradle's dependencies: build the app once here, with the network, and
45 -
# keep what Gradle fetched as a read-only cache (GRADLE_RO_DEP_CACHE) that
46 -
# a job's own, writable GRADLE_USER_HOME reads first. A failing test does
47 -
# not matter here; only the downloads do.
48 -
COPY app /tmp/app
49 -
RUN cd /tmp/app \
50 -
 && printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties \
51 -
 && (GRADLE_USER_HOME=/opt/gradle-home gradle --no-daemon -q --continue \
52 -
      :app:assembleDebug :app:createDebugUnitTestCoverageReport :app:lintDebug :app:validateDebugScreenshotTest || true) \
53 -
 && test -d /opt/gradle-home/caches/modules-2 \
54 -
 && cd / && rm -rf /tmp/app /opt/gradle-home/daemon /opt/gradle-home/native /opt/gradle-home/wrapper \
55 -
      /opt/gradle-home/caches/[0-9]* /opt/gradle-home/caches/transforms-* /opt/gradle-home/caches/build-cache-* \
56 -
      /opt/gradle-home/caches/journal-* /opt/gradle-home/caches/jars-* \
57 -
 && rm -f /opt/gradle-home/caches/modules-2/*.lock /opt/gradle-home/caches/modules-2/gc.properties \
58 -
 && chmod -R a+rX /opt/gradle-home "$ANDROID_HOME" /opt/vendor
removed images/android/README.md +0 −49
1 -
# The OxygenFlow Android image
2 -
3 -
A root file system for building and testing the OxygenHub Android app (`oxygenhub-android`) in a job with no network (oxygenflow#19):
4 -
5 -
- OpenJDK 17 (`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`).
6 -
- The Android SDK in `/opt/android-sdk` (`ANDROID_HOME`): command-line tools 23.0 (checked against Google's published SHA-1), `platforms;android-37.0`, `build-tools;36.0.0` and `ndk;30.0.16248370`, licences accepted.
7 -
- Gradle 9.6.0 as `gradle` (checked against its published SHA-256). Jobs run `gradle --offline`, not `./gradlew`, which would download its distribution.
8 -
- Rust 1.97.1 with the `aarch64-linux-android` and `x86_64-linux-android` targets and cargo-ndk 4.1.2, for the app's `rust-core`.
9 -
- `rust-core`'s crates, vendored in `/opt/vendor`; `/.cargo/config.toml` points cargo at them, as in the rust image.
10 -
- The app's Gradle dependencies in `/opt/gradle-home/caches`, fetched by building the app once while the image is built.
11 -
12 -
The Gradle cache is read-only in the guest. A job points `GRADLE_RO_DEP_CACHE` at it and gives Gradle its own writable `GRADLE_USER_HOME`, so Gradle reads dependencies from the image and writes everything else under `/tmp`.
13 -
14 -
## Building and importing
15 -
16 -
```
17 -
images/android/build.sh /path/to/oxygenflow-android.tar
18 -
scp /path/to/oxygenflow-android.tar claude@earth:/home/claude/flow/dl/
19 -
ssh claude@earth 'cd ~/flow && FLOW_HOME=~/flow bin/flow rootfs import dl/oxygenflow-android.tar'
20 -
```
21 -
22 -
`build.sh` expects the app next to this repository (`../oxygenhub-android`, subrepos checked out). It downloads and checks the command-line tools and Gradle into `images/android/dl/`, copies the app without its build output, vendors `rust-core`'s crates, then builds the image with Docker and exports it as a plain tarball. The tarball is about 6 GB, so build it where the disk has room (Earth) and delete it once imported.
23 -
24 -
Rebuild and re-import when the app changes its SDK, NDK, Gradle or dependency versions, or `rust-core/Cargo.lock` changes, then pin the new hash in the app's pipeline. A dependency missing from the image fails the job with Gradle's "No cached version … available for offline mode", or cargo's "no matching package".
25 -
26 -
## Using it
27 -
28 -
```kdl
29 -
sandbox "android" {
30 -
    runtime "microvm"
31 -
    rootfs "cas://blob/<hash printed by flow rootfs import>"
32 -
    memory-limit "8GiB"
33 -
    cpu-limit 4
34 -
    network "loopback"
35 -
}
36 -
37 -
job "test" {
38 -
    sandbox "android"
39 -
    env {
40 -
        ANDROID_HOME "/opt/android-sdk"
41 -
        JAVA_HOME "/usr/lib/jvm/java-17-openjdk-amd64"
42 -
        GRADLE_RO_DEP_CACHE "/opt/gradle-home/caches"
43 -
        GRADLE_USER_HOME "/tmp/gradle"
44 -
    }
45 -
    exec "gradle" "--offline" "--no-daemon" ":app:testDebugUnitTest"
46 -
}
47 -
```
48 -
49 -
Use `network "loopback"`, not `"isolated"`: with no interface up, Gradle stops with "Could not determine a usable wildcard IP for this machine". Loopback keeps the job offline.
removed images/android/build.sh +0 −41
1 -
#!/bin/sh
2 -
# Build the OxygenFlow Android image and write it as a root file system
3 -
# tarball for `flow rootfs import` (oxygenflow#19). Run from any directory;
4 -
# the app is expected next to this repository (../oxygenhub-android), with
5 -
# its subrepos checked out. Rebuild when the app's SDK, NDK, Gradle or
6 -
# dependency versions change, or rust-core's Cargo.lock does.
7 -
set -eu
8 -
here=$(cd "$(dirname "$0")" && pwd)
9 -
dev=$(cd "$here/../../.." && pwd)
10 -
app="$dev/oxygenhub-android"
11 -
out=${1:-$here/oxygenflow-android.tar}
12 -
# Command-line tools 23.0, as Google's repository2-3.xml lists them.
13 -
CMDLINE_TOOLS=commandlinetools-linux-16111833_latest.zip
14 -
CMDLINE_TOOLS_SHA1=e025545c62a8e64c7559119566a569fb1dec5f60
15 -
GRADLE_VERSION=9.6.0
16 -
17 -
mkdir -p "$here/dl"
18 -
if [ ! -f "$here/dl/cmdline-tools.zip" ]; then
19 -
    curl -fsSL "https://dl.google.com/android/repository/$CMDLINE_TOOLS" -o "$here/dl/cmdline-tools.zip"
20 -
fi
21 -
echo "$CMDLINE_TOOLS_SHA1  $here/dl/cmdline-tools.zip" | sha1sum -c - >/dev/null
22 -
if [ ! -f "$here/dl/gradle.zip" ]; then
23 -
    curl -fsSL "https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip" -o "$here/dl/gradle.zip"
24 -
fi
25 -
# Gradle publishes each distribution's SHA-256 beside it.
26 -
want=$(curl -fsSL "https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip.sha256")
27 -
echo "$want  $here/dl/gradle.zip" | sha256sum -c - >/dev/null
28 -
29 -
# The app, without build output, and rust-core's crates vendored.
30 -
rm -rf "$here/app" "$here/vendor"
31 -
mkdir -p "$here/app"
32 -
tar -C "$app" --exclude='./build' --exclude='./app/build' --exclude='./rust-core/target' \
33 -
    --exclude='./.gradle' --exclude='./.oxy' --exclude='./local.properties' -cf - . | tar -C "$here/app" -xf -
34 -
cargo vendor --locked --versioned-dirs --manifest-path "$app/rust-core/Cargo.toml" "$here/vendor" >/dev/null
35 -
chmod -R a+rX "$here/vendor" "$here/app"
36 -
docker build -q -t oxygenflow-android --build-arg GRADLE_VERSION=$GRADLE_VERSION "$here"
37 -
id=$(docker create oxygenflow-android)
38 -
docker export "$id" -o "$out"
39 -
docker rm "$id" >/dev/null
40 -
rm -rf "${here:?}/app" "${here:?}/vendor"
41 -
echo "$out"
removed images/android/cargo-config.toml +0 −9
1 -
# Crates come from the image, never the network (OxygenFlow jobs are isolated).
2 -
[source.crates-io]
3 -
replace-with = "vendored"
4 -
5 -
[source.vendored]
6 -
directory = "/opt/vendor"
7 -
8 -
[net]
9 -
offline = true
modified images/rust/Dockerfile +2 −23
…
16 unchanged lines
17 17
# Release builds (oxygen#59, oxygenhub#85): each project's scripts/dist.sh
18 18
# cross-builds Linux x86_64, arm64 and riscv64, macOS and Windows, with
19 19
# cargo-zigbuild and zig (no Apple SDK needed) and MinGW, and packs .deb,
20 -
# .tar.gz and .zip; wasm32-wasip1 rebuilds the cheap checks' WASI module
21 -
# (scripts/checks-wasm.sh --check, #96). The rustup targets' standard libraries, zig checked
20 +
# .tar.gz and .zip. The rustup targets' standard libraries, zig checked
22 21
# against its published SHA-256, and cargo-zigbuild, all linked onto the
23 22
# guest's fixed PATH.
24 23
ARG ZIG_VERSION
25 24
ARG ZIG_SHA256
26 -
RUN rustup target add x86_64-pc-windows-gnu aarch64-unknown-linux-gnu riscv64gc-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin wasm32-wasip1 \
25 +
RUN rustup target add x86_64-pc-windows-gnu aarch64-unknown-linux-gnu riscv64gc-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin \
27 26
 && apt-get update \
28 27
 && apt-get install -y --no-install-recommends gcc-mingw-w64-x86-64 zip \
29 28
 && rm -rf /var/lib/apt/lists/*
…
14 unchanged lines
44 43
 && ln -sf /opt/cargo-llvm-cov/bin/cargo-llvm-cov /usr/local/bin/cargo-llvm-cov \
45 44
 && rm -rf /usr/local/cargo/registry /usr/local/cargo/git
46 45
47 -
# Test reports (oxygenflow#81): cargo-nextest writes JUnit XML, which the
48 -
# hub reads as a job's `junit` artifact; `cargo llvm-cov nextest` gives the
49 -
# LCOV coverage and the JUnit report from one run of the tests.
50 -
RUN cargo install --locked --version 0.9.148 --root /opt/cargo-nextest cargo-nextest \
51 -
 && ln -sf /opt/cargo-nextest/bin/cargo-nextest /usr/local/bin/cargo-nextest \
52 -
 && rm -rf /usr/local/cargo/registry /usr/local/cargo/git
53 -
54 46
ARG NODE_VERSION
55 47
ARG NODE_SHA256
56 48
ADD https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /tmp/node.tar.xz
…
1 unchanged lines
58 50
 && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --exclude='*/include' --exclude='*/share/doc' \
59 51
 && rm /tmp/node.tar.xz
60 52
61 -
# PostgreSQL (oxygenhub#211): OxygenHub's tests on Postgres start a server
62 -
# of their own on loopback inside the job (.flow/test-postgres.sh). Debian's
63 -
# server, its programs linked onto the guest's fixed PATH, and nss_wrapper:
64 -
# a job runs as the runner's builder, a user this image's /etc/passwd does
65 -
# not name, and initdb must look its user up.
66 -
RUN apt-get update \
67 -
 && apt-get install -y --no-install-recommends postgresql libnss-wrapper \
68 -
 && rm -rf /var/lib/apt/lists/* \
69 -
 && for b in initdb pg_ctl postgres; do ln -sf /usr/lib/postgresql/*/bin/$b /usr/local/bin/$b; done \
70 -
 && initdb --version
71 -
72 53
# npm's cache for the Oxygen extensions' lock files: `npm ci --offline
73 54
# --cache /opt/npm-cache` installs from it, read-only as it is.
74 55
COPY npm-cache /opt/npm-cache
…
1 unchanged lines
76 57
# Cargo reads /.cargo/config.toml for anything under /, so /src uses the
77 58
# vendored crates; CARGO_HOME stays the default $HOME/.cargo (/tmp).
78 59
COPY vendor /opt/vendor
79 -
# What it vendors, `NAME VERSION` a line: `flow deps check --list` (#101).
80 -
COPY crates.txt /opt/crates.txt
81 60
COPY cargo-config.toml /.cargo/config.toml
82 61
83 62
# Dependencies prebuilt (backlog F6b2): each workspace's recipe (its
…
21 unchanged lines
modified images/rust/README.md +0 −2
…
5 unchanged lines
6 6
- Node 24.21.0, checked against its published SHA-256.
7 7
- git, make and pkg-config.
8 8
- Coverage: llvm-tools and cargo-llvm-cov 0.9.1, so a job can write an LCOV report for the hub's Coverage tab.
9 -
- Test reports: cargo-nextest 0.9.148, so a job can write JUnit XML for the hub (with `cargo llvm-cov nextest`, both from one run).
10 9
- Release cross-building: the Windows, Linux arm64 and riscv64, and macOS targets, zig 0.16.0 (checked against its SHA-256), cargo-zigbuild and MinGW, with zip, so `scripts/dist.sh` runs in a job.
11 -
- WASI: the wasm32-wasip1 target, so the fmt job can check that `.flow/checks.wasm` is what `crates/flow-checks` builds (#96).
12 10
- Every crate that oxygen, oxygenhub and oxygenflow use, vendored in `/opt/vendor`.
13 11
14 12
OxygenFlow jobs have no network (`network "isolated"`). So cargo is pointed at the vendored crates by `/.cargo/config.toml`, which cargo reads for any directory under `/`, including `/src`. `CARGO_HOME` stays at its default, `$HOME/.cargo`, which is under `/tmp` and writable. The toolchain's binaries are linked into `/usr/local/bin`, because the guest's `PATH` is fixed and nothing at run time needs rustup.
…
41 unchanged lines
modified images/rust/build.sh +2 −8
…
17 unchanged lines
18 18
    --sync "$dev/oxygenhub/Cargo.toml" \
19 19
    --sync "$dev/oxygenflow/Cargo.toml" \
20 20
    "$here/vendor" >/dev/null
21 -
# The image's crates as `NAME VERSION` lines (#101): at /opt/crates.txt in
22 -
# the image and beside the tarball, for `flow deps check --list` to read.
23 -
for m in "$here"/vendor/*/Cargo.toml; do
24 -
    awk -F'"' '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^name = /{n=$2} p&&/^version = /{v=$2} END{print n" "v}' "$m"
25 -
done | sort > "$here/crates.txt"
26 21
# npm packages of the two extensions, into a cache `npm ci --offline` reads.
27 22
rm -rf "${here:?}/npm-cache"
28 23
for e in oxygen-vscode oxygenhub-vscode; do
…
12 unchanged lines
41 36
# Readable by any user: a job runs as its workspace's owner on the runner
42 37
# host, which differs between runners (oxygen#59: 0640 files from Earth's
43 38
# umask failed plex's dist job).
44 -
chmod -R a+rX "$here/vendor" "$here/npm-cache" "$here/warm" "$here/crates.txt"
39 +
chmod -R a+rX "$here/vendor" "$here/npm-cache" "$here/warm"
45 40
docker build -q -t oxygenflow-build \
46 41
    --build-arg NODE_VERSION=$NODE_VERSION --build-arg NODE_SHA256=$NODE_SHA256 \
47 42
    --build-arg ZIG_VERSION=$ZIG_VERSION --build-arg ZIG_SHA256=$ZIG_SHA256 "$here"
48 43
id=$(docker create oxygenflow-build)
49 44
docker export "$id" -o "$out"
50 45
docker rm "$id" >/dev/null
51 -
cp "$here/crates.txt" "${out%.tar}.crates.txt"
52 -
rm -rf "${here:?}/vendor" "${here:?}/npm-cache" "${here:?}/warm" "${here:?}/crates.txt"
46 +
rm -rf "${here:?}/vendor" "${here:?}/npm-cache" "${here:?}/warm"
53 47
echo "$out"
removed images/tofu/Dockerfile +0 −56
1 -
# The OxygenFlow OpenTofu image (oxygenflow#56): OpenTofu and the providers
2 -
# the deploy pipelines use, mirrored inside, so `tofu init` needs no
3 -
# registry. A job reaches a cloud's API only through allow-listed egress
4 -
# (#55); formatting and validation need no network at all.
5 -
6 -
# Download and check OpenTofu, and mirror the providers, with the tools
7 -
# only this stage needs.
8 -
FROM debian:bookworm-slim AS fetch
9 -
ARG TOFU_VERSION
10 -
ARG TOFU_SHA256
11 -
RUN apt-get update \
12 -
 && apt-get install -y --no-install-recommends ca-certificates curl unzip \
13 -
 && rm -rf /var/lib/apt/lists/*
14 -
RUN curl -fsSL -o /tmp/tofu.zip \
15 -
      "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" \
16 -
 && echo "${TOFU_SHA256}  /tmp/tofu.zip" | sha256sum -c - \
17 -
 && unzip -q /tmp/tofu.zip tofu -d /usr/local/bin \
18 -
 && rm /tmp/tofu.zip
19 -
# The exact provider versions of the pipelines' lock files; `tofu
20 -
# providers mirror` checks each package against the registry's signed
21 -
# checksums as it downloads it.
22 -
COPY mirror.tf /tmp/mirror/
23 -
RUN cd /tmp/mirror && tofu providers mirror -platform=linux_amd64 /opt/tofu/mirror
24 -
# TFLint and its Google ruleset (HCL linting), each checked against its
25 -
# release's SHA-256; the ruleset where TFLint looks for plugins, so
26 -
# linting needs no `tflint --init` and no network.
27 -
ARG TFLINT_VERSION
28 -
ARG TFLINT_SHA256
29 -
ARG TFLINT_GOOGLE_VERSION
30 -
ARG TFLINT_GOOGLE_SHA256
31 -
RUN curl -fsSL -o /tmp/tflint.zip \
32 -
      "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip" \
33 -
 && echo "${TFLINT_SHA256}  /tmp/tflint.zip" | sha256sum -c - \
34 -
 && unzip -q /tmp/tflint.zip tflint -d /usr/local/bin \
35 -
 && curl -fsSL -o /tmp/google.zip \
36 -
      "https://github.com/terraform-linters/tflint-ruleset-google/releases/download/v${TFLINT_GOOGLE_VERSION}/tflint-ruleset-google_linux_amd64.zip" \
37 -
 && echo "${TFLINT_GOOGLE_SHA256}  /tmp/google.zip" | sha256sum -c - \
38 -
 && d=/opt/tflint/plugins/github.com/terraform-linters/tflint-ruleset-google/${TFLINT_GOOGLE_VERSION} \
39 -
 && mkdir -p "$d" && unzip -q /tmp/google.zip -d "$d" \
40 -
 && rm /tmp/tflint.zip /tmp/google.zip
41 -
42 -
FROM debian:bookworm-slim
43 -
# curl, jq, xz, sfdisk and e2fsprogs' debugfs: oxygenhub-gcp's bake job
44 -
# builds the hub's VM image by writing files into Debian's cloud disk image,
45 -
# no root needed, and publishes it through Google's APIs (oxygenhub-gcp #7).
46 -
RUN apt-get update \
47 -
 && apt-get install -y --no-install-recommends ca-certificates curl jq xz-utils fdisk e2fsprogs \
48 -
 && rm -rf /var/lib/apt/lists/*
49 -
COPY --from=fetch /usr/local/bin/tofu /usr/local/bin/tofu
50 -
COPY --from=fetch /opt/tofu/mirror /opt/tofu/mirror
51 -
COPY --from=fetch /usr/local/bin/tflint /usr/local/bin/tflint
52 -
COPY --from=fetch /opt/tflint/plugins /opt/tflint/plugins
53 -
# Providers come only from the mirror: a job never reaches a registry.
54 -
COPY cli.tfrc /etc/tofu/cli.tfrc
55 -
ENV TF_CLI_CONFIG_FILE=/etc/tofu/cli.tfrc \
56 -
    TFLINT_PLUGIN_DIR=/opt/tflint/plugins
removed images/tofu/README.md +0 −54
1 -
# The OxygenFlow OpenTofu image
2 -
3 -
A root file system for deploy jobs (oxygenflow#56):
4 -
5 -
- Debian bookworm, slim, with CA certificates.
6 -
- OpenTofu 1.13.1, checked against its published SHA-256.
7 -
- A provider mirror in `/opt/tofu/mirror`: `hashicorp/google` 8.7.0,
8 -
  `hashicorp/google-beta` 8.7.0 (Firebase) and `cloudflare/cloudflare`
9 -
  5.28.0, checked by `tofu providers mirror` against the registry's signed
10 -
  checksums when the image is built.
11 -
- `/etc/tofu/cli.tfrc` (set by `TF_CLI_CONFIG_FILE`) installs providers
12 -
  from the mirror only.
13 -
- TFLint 0.64.0 and its Google ruleset 0.40.0, each checked against its
14 -
  release's SHA-256, the ruleset in `/opt/tflint/plugins`
15 -
  (`TFLINT_PLUGIN_DIR`): a repository's `.tflint.hcl` enables it with
16 -
  `plugin "google" { enabled = true  version = "0.40.0"  source =
17 -
  "github.com/terraform-linters/tflint-ruleset-google" }`, and linting
18 -
  needs no `tflint --init`.
19 -
20 -
So `tofu init`, `fmt` and `validate` need no network; `plan` and `apply`
21 -
reach the cloud's APIs through allow-listed egress (#55). The guest's
22 -
environment is the pipeline's, so a job sets `TF_CLI_CONFIG_FILE` itself
23 -
(below).
24 -
25 -
## Building and importing
26 -
27 -
```
28 -
images/tofu/build.sh /path/to/oxygenflow-tofu.tar
29 -
scp /path/to/oxygenflow-tofu.tar claude@earth:/home/claude/flow/dl/
30 -
ssh claude@earth 'cd ~/flow && FLOW_HOME=~/flow bin/flow rootfs import dl/oxygenflow-tofu.tar'
31 -
```
32 -
33 -
Pin the printed hash in the pipelines that use it, and push it to the hub
34 -
(`flow rootfs push HASH`) so every runner can fetch it.
35 -
36 -
A provider version in a pipeline's lock file must be in the mirror: bump
37 -
`mirror.tf`, rebuild and re-pin when a lock file changes.
38 -
39 -
## Using it
40 -
41 -
```kdl
42 -
sandbox "tofu" {
43 -
    runtime "microvm"
44 -
    rootfs "cas://blob/<hash printed by flow rootfs import>"
45 -
    memory-limit "2GiB"
46 -
    cpu-limit 2
47 -
    network "isolated"
48 -
    env {
49 -
        TF_CLI_CONFIG_FILE "/etc/tofu/cli.tfrc"
50 -
        TF_IN_AUTOMATION "1"
51 -
        TFLINT_PLUGIN_DIR "/opt/tflint/plugins"
52 -
    }
53 -
}
54 -
```
removed images/tofu/build.sh +0 −23
1 -
#!/bin/sh
2 -
# Build the OxygenFlow OpenTofu image and write it as a root file system
3 -
# tarball for `flow rootfs import`. Rebuild when OpenTofu or a pinned
4 -
# provider version, TFLint or its Google ruleset changes.
5 -
set -eu
6 -
here=$(cd "$(dirname "$0")" && pwd)
7 -
out=${1:-$here/oxygenflow-tofu.tar}
8 -
TOFU_VERSION=1.13.1
9 -
TOFU_SHA256=8ccbc6f8ee21d2827715f3c6e08a9b3e0209b1e62057c05067ef117e047c1a80
10 -
TFLINT_VERSION=0.64.0
11 -
TFLINT_SHA256=cca9d13e2e1d7a2c627af60ff899a3c9b74212899416aeb96ec764d2ef954537
12 -
TFLINT_GOOGLE_VERSION=0.40.0
13 -
TFLINT_GOOGLE_SHA256=a5c02b3de937456de2521155831eaf1cc876014a21445bf7dd1621654511fe28
14 -
15 -
docker build -q -t oxygenflow-tofu \
16 -
    --build-arg TOFU_VERSION=$TOFU_VERSION --build-arg TOFU_SHA256=$TOFU_SHA256 \
17 -
    --build-arg TFLINT_VERSION=$TFLINT_VERSION --build-arg TFLINT_SHA256=$TFLINT_SHA256 \
18 -
    --build-arg TFLINT_GOOGLE_VERSION=$TFLINT_GOOGLE_VERSION --build-arg TFLINT_GOOGLE_SHA256=$TFLINT_GOOGLE_SHA256 \
19 -
    "$here"
20 -
id=$(docker create oxygenflow-tofu)
21 -
docker export "$id" -o "$out"
22 -
docker rm "$id" >/dev/null
23 -
echo "$out"
removed images/tofu/cli.tfrc +0 −8
1 -
provider_installation {
2 -
  filesystem_mirror {
3 -
    path    = "/opt/tofu/mirror"
4 -
    include = ["registry.opentofu.org/*/*"]
5 -
  }
6 -
  # Nothing is fetched from a registry: a provider missing from the
7 -
  # mirror fails `tofu init` instead of reaching out.
8 -
}
removed images/tofu/mirror.tf +0 −20
1 -
# The providers the image carries, at the versions the deploy pipelines
2 -
# lock. Bump here, rebuild, re-pin (README.md).
3 -
terraform {
4 -
  required_providers {
5 -
    google = {
6 -
      source  = "hashicorp/google"
7 -
      version = "8.7.0"
8 -
    }
9 -
    # Firebase (oxygenhub-gcp decision 0008): its project and apps are
10 -
    # google-beta resources.
11 -
    google-beta = {
12 -
      source  = "hashicorp/google-beta"
13 -
      version = "8.7.0"
14 -
    }
15 -
    cloudflare = {
16 -
      source  = "cloudflare/cloudflare"
17 -
      version = "5.28.0"
18 -
    }
19 -
  }
20 -
}
removed scripts/changelog-check.sh +0 −27
1 -
#!/bin/sh
2 -
# The changelog check (2026-10-19): a commit that changes what users get
3 -
# adds a line under "## Unreleased" in CHANGELOG.md, so a release's notes
4 -
# are written as the work is done and versions say what changed.
5 -
#
6 -
#   scripts/changelog-check.sh [PATH...]
7 -
#
8 -
# PATHs are what the commit will take (as given to `oxy commit`); without
9 -
# any, the working copy's changes. Code is anything under src/, crates/,
10 -
# templates/, assets/, packaging/, images/ or scripts/, except tests.
11 -
# Docs, pipelines, pins (deps/) and lock files need no line. For a code
12 -
# change users won't notice (a refactor, a test helper), set NO_CHANGELOG=1.
13 -
set -eu
14 -
[ "${NO_CHANGELOG:-}" = 1 ] && exit 0
15 -
cd "$(dirname "$0")/.."
16 -
if [ "$#" -gt 0 ]; then
17 -
    paths=$(printf '%s\n' "$@")
18 -
else
19 -
    paths=$(oxy --plain status --json | python3 -c 'import json,sys; print("\n".join(p["path"] for p in json.load(sys.stdin)["paths"]))')
20 -
fi
21 -
code=$(printf '%s\n' "$paths" | grep -E '^(src|crates|templates|assets|packaging|images|scripts)/' | grep -vE '(^|/)tests?/|_test\.rs$' || true)
22 -
[ -z "$code" ] && exit 0
23 -
printf '%s\n' "$paths" | grep -qx 'CHANGELOG.md' && exit 0
24 -
echo "changelog: these changes touch code but CHANGELOG.md has no new line under ## Unreleased:" >&2
25 -
printf '%s\n' "$code" | sed 's/^/  /' >&2
26 -
echo "add one (Added / Changed / Fixed / Security, with the issue number), or set NO_CHANGELOG=1 if users won't notice" >&2
27 -
exit 1
removed scripts/checks-wasm.sh +0 −36
1 -
#!/bin/sh
2 -
# Build the cheap checks (crates/flow-checks, #96) as a WASI module and keep
3 -
# it at .flow/checks.wasm, where the pipeline's WASM jobs run it from.
4 -
#
5 -
#   scripts/checks-wasm.sh          rebuild .flow/checks.wasm
6 -
#   scripts/checks-wasm.sh --check  fail unless .flow/checks.wasm is what
7 -
#                                   the source builds (the fmt job runs this)
8 -
#
9 -
# The build is the same on any machine with the same toolchain (the build
10 -
# image's, images/rust): the crate is std only, the workspace path is
11 -
# remapped to /src and rust-src's to /rustc/COMMIT, and the module carries
12 -
# no debug info.
13 -
# Needs: rustup target wasm32-wasip1.
14 -
set -eu
15 -
cd "$(dirname "$0")/.."
16 -
target=$(mktemp -d)
17 -
trap 'rm -rf "$target"' EXIT
18 -
# Where rust-src is installed, the standard library's panic locations name
19 -
# it; map it to /rustc/COMMIT, as a toolchain without rust-src has them.
20 -
src="$(rustc --print sysroot)/lib/rustlib/src/rust"
21 -
commit=$(rustc -vV | sed -n 's/^commit-hash: //p')
22 -
RUSTFLAGS="--remap-path-prefix=$(pwd)=/src --remap-path-prefix=$src=/rustc/$commit" \
23 -
CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_STRIP=true CARGO_INCREMENTAL=0 \
24 -
    cargo build -q --release --locked --target wasm32-wasip1 -p flow-checks --target-dir "$target"
25 -
built="$target/wasm32-wasip1/release/flow-checks.wasm"
26 -
if [ "${1:-}" = --check ]; then
27 -
    if cmp -s "$built" .flow/checks.wasm; then
28 -
        echo ".flow/checks.wasm is what crates/flow-checks builds ($(rustc --version))"
29 -
    else
30 -
        echo ".flow/checks.wasm differs from what crates/flow-checks builds: run scripts/checks-wasm.sh and commit it" >&2
31 -
        exit 1
32 -
    fi
33 -
else
34 -
    cp "$built" .flow/checks.wasm
35 -
    echo "built .flow/checks.wasm ($(wc -c < .flow/checks.wasm) bytes, $(rustc --version))"
36 -
fi
removed scripts/dist.sh +0 −41
1 -
#!/usr/bin/env bash
2 -
# Build the runner, `flow`, for every Linux machine a runner runs on
3 -
# (glibc 2.28 and later), as release assets runners update themselves from
4 -
# (docs/jetstream.md §8.2, oxygenhub#322): target/dist-flat/flow-linux-ARCH,
5 -
# named by the architecture a runner reports (x86_64, aarch64, riscv64),
6 -
# plain stripped binaries; flow-windows-x86_64.exe (the WASM tier only);
7 -
# and SHA256SUMS of them.
8 -
#
9 -
#   scripts/dist.sh [VERSION]    (default: the package version)
10 -
#   ONLY=x86_64 scripts/dist.sh  builds one architecture (a glob)
11 -
#
12 -
# Needs: rustup targets aarch64-unknown-linux-gnu and
13 -
# riscv64gc-unknown-linux-gnu and x86_64-pc-windows-gnu; cargo-zigbuild
14 -
# and zig on PATH; the MinGW-w64 gcc.
15 -
set -euo pipefail
16 -
17 -
cd "$(dirname "$0")/.."
18 -
VERSION="${1:-$(cargo pkgid -p flow | sed 's/.*[#@]//')}"
19 -
VERSION="${VERSION#v}"
20 -
OUT="${DIST_FLAT:-target/dist-flat}"
21 -
rm -rf "$OUT" && mkdir -p "$OUT"
22 -
export PATH="$HOME/.local/opt/zig:$PATH"
23 -
# Stripped, with no debug info: about a tenth of the size.
24 -
export CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_STRIP=true CARGO_INCREMENTAL=0
25 -
# The version the binary reports, which the hub compares with its releases.
26 -
export OXY_BUILD="v$VERSION"
27 -
28 -
for p in "x86_64-unknown-linux-gnu|x86_64" "aarch64-unknown-linux-gnu|aarch64" "riscv64gc-unknown-linux-gnu|riscv64"; do
29 -
  IFS='|' read -r triple arch <<<"$p"
30 -
  if [ -n "${ONLY:-}" ] && [[ "$arch" != $ONLY ]]; then continue; fi
31 -
  echo "== linux-$arch ($triple)"
32 -
  cargo zigbuild -q --release --locked --target "$triple.2.28" -p flow --bin flow
33 -
  install -m 0755 "target/$triple/release/flow" "$OUT/flow-linux-$arch"
34 -
done
35 -
# Windows (oxygenflow#83): the WASM tier only, with the MinGW-w64 toolchain.
36 -
if [ -z "${ONLY:-}" ] || [[ windows-x86_64 == $ONLY ]]; then
37 -
  echo "== windows-x86_64 (x86_64-pc-windows-gnu)"
38 -
  cargo build -q --release --locked --target x86_64-pc-windows-gnu -p flow --bin flow
39 -
  install -m 0755 target/x86_64-pc-windows-gnu/release/flow.exe "$OUT/flow-windows-x86_64.exe"
40 -
fi
41 -
(cd "$OUT" && sha256sum flow-* > SHA256SUMS && cat SHA256SUMS)