chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A deploy keeps the runner unless its build changed
crates/knotless-agent/src/main.rs+7−2
| @@ -9,8 +9,13 @@ fn main() -> ExitCode { | ||
return ExitCode::from(2); | ||
} | ||
}; | ||
let (version, commit) = (env!("CARGO_PKG_VERSION"), option_env!("KNOTLESS_COMMIT")); | ||
knotless::telemetry::init("knotless-agent", version, commit); | ||
// KNOTLESS_COMMIT is the commit the image was built from, set as it | ||
// runs (Dockerfile), so the binary is the same at every commit. | ||
let (version, commit) = ( | ||
env!("CARGO_PKG_VERSION"), | ||
std::env::var("KNOTLESS_COMMIT").ok(), | ||
); | ||
knotless::telemetry::init("knotless-agent", version, commit.as_deref()); | ||
let ctx = knotless::env::Ctx::new(knotless::env::Env::from_process()); | ||
let runtime = tokio::runtime::Runtime::new().expect("a runtime builds"); | ||
match runtime.block_on(knotless_agent::run(opts, ctx)) { | ||
crates/knotless-run/src/main.rs+7−3
| @@ -7,9 +7,13 @@ fn main() { | ||
std::process::exit(2); | ||
} | ||
}; | ||
// KNOTLESS_COMMIT is the commit the image was built from (Dockerfile). | ||
let (version, commit) = (env!("CARGO_PKG_VERSION"), option_env!("KNOTLESS_COMMIT")); | ||
knotless::telemetry::init("knotless-run", version, commit); | ||
// KNOTLESS_COMMIT is the commit the image was built from, set as it | ||
// runs (Dockerfile), so the binary is the same at every commit. | ||
let (version, commit) = ( | ||
env!("CARGO_PKG_VERSION"), | ||
std::env::var("KNOTLESS_COMMIT").ok(), | ||
); | ||
knotless::telemetry::init("knotless-run", version, commit.as_deref()); | ||
let ctx = knotless::env::Ctx::new(knotless::env::Env::from_process()); | ||
let runtime = tokio::runtime::Runtime::new().expect("a runtime builds"); | ||
if let Err(err) = runtime.block_on(knotless_run::run(opts, ctx)) { | ||
crates/knotless-serve/src/main.rs+7−3
| @@ -7,9 +7,13 @@ fn main() { | ||
std::process::exit(2); | ||
} | ||
}; | ||
// KNOTLESS_COMMIT is the commit the image was built from (Dockerfile). | ||
let (version, commit) = (env!("CARGO_PKG_VERSION"), option_env!("KNOTLESS_COMMIT")); | ||
knotless::telemetry::init("knotless-serve", version, commit); | ||
// KNOTLESS_COMMIT is the commit the image was built from, set as it | ||
// runs (Dockerfile), so the binary is the same at every commit. | ||
let (version, commit) = ( | ||
env!("CARGO_PKG_VERSION"), | ||
std::env::var("KNOTLESS_COMMIT").ok(), | ||
); | ||
knotless::telemetry::init("knotless-serve", version, commit.as_deref()); | ||
let ctx = knotless::env::Ctx::new(knotless::env::Env::from_process()); | ||
let runtime = tokio::runtime::Runtime::new().expect("a runtime builds"); | ||
if let Err(err) = runtime.block_on(knotless_serve::run(opts, ctx)) { | ||
crates/knotless-triage/src/main.rs+7−2
| @@ -10,8 +10,13 @@ fn main() -> ExitCode { | ||
return ExitCode::from(2); | ||
} | ||
}; | ||
let (version, commit) = (env!("CARGO_PKG_VERSION"), option_env!("KNOTLESS_COMMIT")); | ||
knotless::telemetry::init("knotless-triage", version, commit); | ||
// KNOTLESS_COMMIT is the commit the image was built from, set as it | ||
// runs (Dockerfile), so the binary is the same at every commit. | ||
let (version, commit) = ( | ||
env!("CARGO_PKG_VERSION"), | ||
std::env::var("KNOTLESS_COMMIT").ok(), | ||
); | ||
knotless::telemetry::init("knotless-triage", version, commit.as_deref()); | ||
let ctx = knotless::env::Ctx::new(knotless::env::Env::from_process()); | ||
let runtime = tokio::runtime::Runtime::new().expect("a runtime builds"); | ||
match runtime.block_on(knotless_triage::run(opts, ctx)) { | ||
deploy/deploy.sh+23−18
| @@ -49,9 +49,9 @@ | ||
# KNOTLESS_RUN_DATA (/var/lib/knotless-run), and the secrets it grants in | ||
# /etc/knotless/run-secrets.json, which starts empty. KNOTLESS_RUN_ARGS | ||
# adds to its command line: `--pulls`, say, to run open pulls' heads too. | ||
# A deploy restarts it only when what it runs changed: the sources it is | ||
# built from (deploy/run-unchanged.sh) or its settings; KNOTLESS_RUN_RESTART=1 | ||
# restarts it anyway. The deploy log says which, and why. | ||
# A deploy restarts it only when what it runs changed: its build, as the | ||
# image's /etc/knotless-run.fingerprint says (Dockerfile), or its settings; | ||
# KNOTLESS_RUN_RESTART=1 restarts it anyway. The deploy log says which, and why. | ||
# | ||
# knotless-triage too, if KNOTLESS_TRIAGE_REPOS names repositories to | ||
# triage, once /etc/knotless/triage.env is on the host: a deploy with | ||
| @@ -137,15 +137,10 @@ echo /system.slice/knotless-build | ||
EOF | ||
) | ||
fi | ||
# The commit the runner is built from, for the next deploy to compare its | ||
# own with (up): left empty, as unknown, when this checkout's runner | ||
# sources are not that commit's, uncommitted changes and all. | ||
local run_commit=$COMMIT | ||
deploy/run-unchanged.sh "$COMMIT" >/dev/null || run_commit= | ||
say "building $CANDIDATE on $DEST${cgroup:+, in $cgroup}" | ||
local began=$SECONDS | ||
DOCKER_HOST="ssh://$DEST" docker build ${cgroup:+--cgroup-parent "$cgroup"} \ | ||
--build-arg "KNOTLESS_COMMIT=$COMMIT" --label "dev.knotless.run-commit=$run_commit" \ | ||
--build-arg "KNOTLESS_COMMIT=$COMMIT" \ | ||
-t "$CANDIDATE" . | ||
say "built $CANDIDATE in $((SECONDS - began))s" | ||
# buildkit keeps every layer it ever built, with no cap of its own: it | ||
| @@ -267,9 +262,12 @@ scp -q -o StrictHostKeyChecking=accept-new deploy/docker-compose.yml deploy/roll | ||
# "A stop"). What it runs is two things, each of which must be known to be | ||
# unchanged, or it is restarted as before: | ||
# | ||
# - its build: the image's dev.knotless.run-commit label (build), read | ||
# from the image the running container was made from, against this | ||
# checkout (deploy/run-unchanged.sh); | ||
# - its build: the fingerprint the image keeps of what the runner runs | ||
# (Dockerfile), read from the running container, against the one in | ||
# knotless:current, the image just built. The commit is set as the | ||
# binaries run rather than built in, so a commit that changes nothing | ||
# the runner is made of leaves the same fingerprint, whatever else in | ||
# the image or the repository it changes; | ||
# - its settings: a hash of the `run` service as compose resolves it from | ||
# docker-compose.yml and the .env just written, and of run.env, against | ||
# /etc/knotless/run-deployed, "<container id> <hash>", which each restart | ||
| @@ -288,8 +286,8 @@ STATUS=$($SSH sh -s <<'EOF' | ||
id=$(docker compose ps -q --status running run 2>/dev/null | head -n 1) | ||
[ -n "$id" ] || exit 0 | ||
echo "container $id" | ||
image=$(docker inspect -f '{{.Image}}' "$id") || exit 0 | ||
echo "commit $(docker image inspect -f '{{index .Config.Labels "dev.knotless.run-commit"}}' "$image" 2>/dev/null)" | ||
echo "build $(docker exec "$id" cat /etc/knotless-run.fingerprint 2>/dev/null)" | ||
echo "candidate $(docker run --rm --entrypoint cat knotless:current /etc/knotless-run.fingerprint 2>/dev/null)" | ||
[ ! -f run-deployed ] || echo "record $(head -n 1 run-deployed)" | ||
} </dev/null | ||
EOF | ||
| @@ -297,7 +295,10 @@ EOF | ||
field() { printf '%s\n' "$STATUS" | awk -v k="$1" '$1 == k { $1 = ""; sub(/^ /, ""); print; exit }'; } | ||
SETTINGS=$(field settings) | ||
RUNNING=$(field container) | ||
RUN_COMMIT=$(field commit) | ||
RUN_BUILD=$(field build) | ||
NEW_BUILD=$(field candidate) | ||
[[ $RUN_BUILD =~ ^[0-9a-f]{64}$ ]] || RUN_BUILD="" | ||
[[ $NEW_BUILD =~ ^[0-9a-f]{64}$ ]] || NEW_BUILD="" | ||
[[ $SETTINGS =~ ^[0-9a-f]{64}$ ]] || SETTINGS="" | ||
RESTART_RUN="" | ||
if [ -z "$RUN_REPOS" ]; then | ||
| @@ -310,14 +311,18 @@ elif [ -z "$RUNNING" ]; then | ||
RESTART_RUN="it is not running" | ||
elif [ "$(field record)" != "$RUNNING $SETTINGS" ]; then | ||
RESTART_RUN="its settings changed, or were not recorded for the container running" | ||
elif ! why=$(deploy/run-unchanged.sh "$RUN_COMMIT"); then | ||
RESTART_RUN=${why:-its build may have changed} | ||
elif [ -z "$RUN_BUILD" ]; then | ||
RESTART_RUN="the one running keeps no fingerprint of its build" | ||
elif [ -z "$NEW_BUILD" ]; then | ||
RESTART_RUN="the new image's fingerprint could not be read" | ||
elif [ "$RUN_BUILD" != "$NEW_BUILD" ]; then | ||
RESTART_RUN="its build changed, ${RUN_BUILD:0:12} to ${NEW_BUILD:0:12}" | ||
fi | ||
if [ -n "$RESTART_RUN" ]; then | ||
[ -z "$RUN_REPOS" ] || say "run: restarting: $RESTART_RUN" | ||
RUN_MODE=restart | ||
else | ||
say "run: unchanged since ${RUN_COMMIT:0:12}, not restarted" | ||
say "run: its build, ${RUN_BUILD:0:12}, and settings are the same, not restarted" | ||
RUN_MODE=keep | ||
fi | ||
| ||
deploy/README.md+21−23
| @@ -8,7 +8,6 @@ One Docker host, a reverse proxy in front of it for TLS, and a domain. | ||
| `docker-compose.yml` | `/etc/knotless/docker-compose.yml`: `serve` (knotless-serve, on :2590), `pages` (the SvelteKit app, on :2591) and, if asked, `run` (knotless-run), `triage` (knotless-triage) and `review` (knotless-agent review) | | ||
| `deploy.sh` | builds the image on the host over SSH, writes the compose file and its `.env`, and restarts what changed; or one of those steps, `build`, `up` or `drop` | | ||
| `rollout.sh` | run on the host by `deploy.sh`: moves `pages` and `serve` to a new container each, with no gap | | ||
| `run-unchanged.sh` | run by `deploy.sh`: whether the runner built from this checkout is the one built from a given commit, so a deploy can leave `run` running | | ||
| `rollout-test.sh` | tries `rollout.sh` on a local docker behind a Caddy, counting failed requests | | ||
| `release.sh` | builds the client for macOS and Linux here and puts the builds on the host, for `/install.sh` | | ||
| `setup.sh` | makes a fresh Ubuntu 24.04 host ready for `deploy.sh`: docker, a Caddy of its own for the domain, a firewall, swap, upgrades and pruning | | ||
| @@ -520,21 +519,19 @@ three times in all. It reads every repository again every `--sweep` | ||
| ||
A restart costs the runs still waiting for a job, and one that cuts off | ||
a merge-queue batch still being checked makes the batch be checked again, so a | ||
deploy restarts `run` only when what it runs changed. The image cannot | ||
say so: it is rebuilt at every commit, with the commit in it. So | ||
`deploy.sh up` compares two things, and leaves `run` running only when | ||
it knows both are the same: | ||
| ||
- **Its build.** `deploy.sh build` labels the image | ||
`dev.knotless.run-commit=<commit>`, or leaves the label empty when the | ||
checkout's runner sources are not that commit's (uncommitted changes, | ||
or no git). `up` reads the label from the image the running `run` | ||
container was made from, which is the build it runs whatever | ||
`knotless:current` now is, and `run-unchanged.sh` compares that commit | ||
with this checkout: `crates/knotless` and `crates/knotless-run` (less | ||
their `tests/` and `examples/`), `Cargo.toml`, `Cargo.lock`, the | ||
`Dockerfile`, `.dockerignore` and a pinned toolchain, committed or not, | ||
untracked files included. | ||
deploy restarts `run` only when what it runs changed. `deploy.sh up` | ||
compares two things, and leaves `run` running only when it knows both | ||
are the same: | ||
| ||
- **Its build.** The image keeps a fingerprint of what the runner is | ||
made of, in `/etc/knotless-run.fingerprint`: the `knotless-run` | ||
binary, the docker client its steps run through, and the packages | ||
installed (`/var/lib/dpkg/status`: git and the libraries). `up` reads it | ||
from the running container and from `knotless:current`, the image just | ||
built. The commit the binaries log is set as they run (`ENV | ||
KNOTLESS_COMMIT`, the image's last layer) rather than built in, so a | ||
commit that changes only serve, the pages, another binary or the docs | ||
leaves the runner's binary, and so its fingerprint, as it was. | ||
- **Its settings.** A hash of the `run` service as `docker compose config | ||
run` resolves it from the compose file and the `.env` this deploy just | ||
wrote (its command line, `KNOTLESS_RUN_ARGS` and `KNOTLESS_RUN_REPOS` | ||
| @@ -544,23 +541,24 @@ it knows both are the same: | ||
hash, or whose running container, is not the one recorded restarts it. | ||
`run-secrets.json` is not in it: the runner reads that at each run. | ||
| ||
Anything it cannot tell, a container not running, no label, a commit the | ||
checkout lacks, a record missing, restarts `run` as every deploy once did; | ||
Anything it cannot tell, a container not running, no fingerprint, a | ||
record missing, restarts `run` as every deploy once did; | ||
so the first deploy with this does, and so does one while a restart is | ||
still under way. Either way the deploy log says which, and why: | ||
| ||
```text | ||
2026-10-27T04:02:11Z 1a2b3c4d5e6f → run: unchanged since c7252c6fe7d9, not restarted | ||
2026-10-27T05:13:40Z 7d8e9f0a1b2c → run: restarting: 2 file(s) it is built from changed since c7252c6fe7d9: crates/knotless-run/src/queue.rs crates/knotless/src/queue.rs | ||
2026-10-27T04:02:11Z 1a2b3c4d5e6f → run: its build, 5d41402abc4b, and settings are the same, not restarted | ||
2026-10-27T05:13:40Z 7d8e9f0a1b2c → run: restarting: its build changed, 5d41402abc4b to 7d793037a076 | ||
``` | ||
| ||
A runner left running keeps logging the commit it was built from at its | ||
start, as it should. To restart it anyway, deploy with | ||
`KNOTLESS_RUN_RESTART=1`, or on the host `cd /etc/knotless && docker | ||
compose up -d --force-recreate run`, after which the next deploy, finding | ||
no record for that container, restarts it once more. `deploy/run-unchanged.sh | ||
<commit>` asks the first question by hand: it exits 0 when the runner built | ||
from the checkout is that commit's, and otherwise says why not. | ||
no record for that container, restarts it once more. To ask the first | ||
question by hand, on the host: `docker exec <container> cat | ||
/etc/knotless-run.fingerprint` beside `docker run --rm --entrypoint cat | ||
knotless:current /etc/knotless-run.fingerprint`. | ||
| ||
## knotless-triage | ||
| ||
deploy/run-unchanged.shdeleted+0−45
| @@ -1,45 +0,0 @@ | ||
#!/usr/bin/env bash | ||
# Whether knotless-run built from this checkout would be the one built | ||
# from <commit>: whether a deploy can leave `run` running. | ||
# | ||
# deploy/run-unchanged.sh <commit> | ||
# | ||
# Exits 0, saying nothing, when no file the runner is built from differs | ||
# between <commit> and this checkout as it is now (HEAD and its working | ||
# tree, which the image is built from, uncommitted changes included). | ||
# Anything else, a change or anything it cannot tell, exits 1 and says why | ||
# on stdout: deploy.sh then restarts the runner, as every deploy once did. | ||
# | ||
# What the runner is built from: its crate and the knotless crate it | ||
# depends on, less their tests and examples; the workspace's Cargo.toml | ||
# and Cargo.lock; the Dockerfile and .dockerignore; a pinned toolchain. | ||
# Its settings (the `run` service in docker-compose.yml, the .env and | ||
# run.env it reads) are compared on the host by deploy.sh, as resolved. | ||
| ||
set -uo pipefail | ||
cd "$(dirname "$0")/.." || { echo "no checkout"; exit 1; } | ||
| ||
PATHS=( | ||
crates/knotless crates/knotless-run | ||
':(exclude)crates/knotless/tests' ':(exclude)crates/knotless/examples' | ||
':(exclude)crates/knotless-run/tests' | ||
Cargo.toml Cargo.lock Dockerfile .dockerignore rust-toolchain rust-toolchain.toml | ||
) | ||
# The deploy workflow's checkout is root's, in a container whose user may | ||
# not be: git would refuse it as another's. | ||
git() { command git -c safe.directory='*' "$@"; } | ||
| ||
old="${1:-}" | ||
[[ $old =~ ^[0-9a-f]{40}$ ]] || { echo "the running runner's commit is unknown (${old:-none})"; exit 1; } | ||
git cat-file -e "$old^{commit}" 2>/dev/null || { echo "${old:0:12}, which it runs, is not in this checkout"; exit 1; } | ||
# Between the commit and the working tree, staged or not; then files git | ||
# does not know yet, which the build copies in too. | ||
changed=$(git diff --name-only "$old" -- "${PATHS[@]}") || { echo "git diff failed"; exit 1; } | ||
untracked=$(git ls-files --others --exclude-standard -- "${PATHS[@]}") || { echo "git ls-files failed"; exit 1; } | ||
changed=$(printf '%s\n%s\n' "$changed" "$untracked" | sed '/^$/d') | ||
if [ -n "$changed" ]; then | ||
count=$(printf '%s\n' "$changed" | wc -l | tr -d ' ') | ||
echo "$count file(s) it is built from changed since ${old:0:12}: $(printf '%s\n' "$changed" | head -n 5 | paste -sd ' ' -)" | ||
exit 1 | ||
fi | ||
exit 0 | ||
docs/design/runner.md+5−3
| @@ -492,9 +492,11 @@ A stop never waits forever on a hung step. One did once, when a deploy's | ||
| ||
Since a stop costs the runs still waiting, and can cut off a merge-queue | ||
batch still being checked, which is then checked again, knotless.dev's | ||
deploys stop the runner only when what it runs changed: the sources it | ||
is built from, or its settings. Each deploy rebuilds the image, so the | ||
image alone cannot tell; when unsure, it restarts the runner | ||
deploys stop the runner only when what it runs changed: its binary and | ||
the tools it runs, or its settings. The image keeps a fingerprint of the | ||
first, and the commit is given to the binaries as they run rather than | ||
built in, so a commit that changes nothing the runner is made of leaves | ||
the fingerprint as it was. When unsure, it restarts the runner | ||
([When a deploy restarts the runner](../../deploy/README.md#when-a-deploy-restarts-the-runner)). | ||
| ||
A run that has not started its steps when the stop comes is dropped: its | ||
Dockerfile+11−3
| @@ -13,9 +13,6 @@ COPY . . | ||
# The cache mounts keep the registry and the build between builds on the | ||
# same machine, so a change rebuilds the crates it touches and not the rest. | ||
# The deploy profile (Cargo.toml) is release's, built for the build's time. | ||
# KNOTLESS_COMMIT, from deploy.sh, is the commit serve and the runner log | ||
# that they run: only their main.rs reads it, so it rebuilds nothing else. | ||
ARG KNOTLESS_COMMIT | ||
RUN --mount=type=cache,target=/usr/local/cargo/registry \ | ||
--mount=type=cache,target=/src/target \ | ||
cargo build --profile deploy --locked \ | ||
| @@ -80,6 +77,17 @@ COPY crates/knotless-agent/claude-review /usr/local/bin/knotless-claude-review | ||
COPY --from=docker:28-cli /usr/local/bin/docker /usr/local/bin/docker | ||
COPY --from=pages /web/build /app/build | ||
COPY --from=pages /web/package.json /app/package.json | ||
# What knotless-run is, for a deploy to leave a runner running whose is the | ||
# same (deploy/deploy.sh): its binary, the docker client its steps run | ||
# through, and the packages installed, git and the libraries among them. | ||
RUN sha256sum /usr/local/bin/knotless-run /usr/local/bin/docker /var/lib/dpkg/status \ | ||
| sha256sum | cut -d ' ' -f 1 > /etc/knotless-run.fingerprint | ||
USER node | ||
# The commit, from deploy.sh, that the binaries log they run. Set as they | ||
# run rather than built in, and last, it changes no binary and no layer | ||
# before it: a runner built at another commit from the same code is the | ||
# same runner. | ||
ARG KNOTLESS_COMMIT | ||
ENV KNOTLESS_COMMIT=$KNOTLESS_COMMIT | ||
ENTRYPOINT ["tini", "--"] | ||
CMD ["knotless-serve", "--listen", "0.0.0.0:2590"] | ||