diff --git a/knot2/worker/Cargo.lock b/knot2/worker/Cargo.lock index d2eaf32ff..78414822f 100644 --- a/knot2/worker/Cargo.lock +++ b/knot2/worker/Cargo.lock @@ -768,6 +768,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -4124,6 +4125,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/knot2/worker/Cargo.toml b/knot2/worker/Cargo.toml index d13170e76..dc38a4e63 100644 --- a/knot2/worker/Cargo.toml +++ b/knot2/worker/Cargo.toml @@ -41,7 +41,8 @@ http = "1" url = "2" bytes = "1" futures = "0.3" -flate2 = "1" +# native resolves flate2 onto zlib-rs; binary patch literals must deflate the same +flate2 = { version = "1", features = ["zlib-rs"] } sha2 = "0.11" base64 = "0.22" k256 = { version = "0.13", features = ["ecdsa"] } diff --git a/knot2/worker/parity/compose.yml b/knot2/worker/parity/compose.yml index 46d27bba9..55cbd5ccd 100644 --- a/knot2/worker/parity/compose.yml +++ b/knot2/worker/parity/compose.yml @@ -9,8 +9,11 @@ services: dns: [11.0.0.254] build: context: ../../.. - dockerfile: knot2/Containerfile + dockerfile: knot2/worker/parity/knot2.Containerfile restart: unless-stopped + # knot2 sizes its ingest budget off the cgroup limit; without one it takes + # the whole vm and refuses pushes while the rest of localinfra is running + mem_limit: 2g environment: KNOT_HOSTNAME: knot2-parity.tngl.boltless.dev KNOT_ADMINS: ${PARITY_ADMIN_DID:?the did allowed to create repos} @@ -24,6 +27,11 @@ services: KNOT_GIT_OBJECT_FORMAT: sha1 KNOT_TLS_HTTP3: "false" KNOT_LISTEN_REQUEST_TIMEOUT_MS: "600000" + # the harness fires its whole request matrix back to back + KNOT_LISTEN_RATE_LIMIT_PER_SECOND: "100000" + KNOT_LISTEN_RATE_LIMIT_BURST: "100000" + KNOT_XRPC_PREAUTH_BURST: "100000" + KNOT_XRPC_PREAUTH_REFILL_MS: "1" RUST_LOG: info ports: - "127.0.0.1:5556:5555" diff --git a/knot2/worker/parity/knot2.Containerfile b/knot2/worker/parity/knot2.Containerfile new file mode 100644 index 000000000..92fa0444c --- /dev/null +++ b/knot2/worker/parity/knot2.Containerfile @@ -0,0 +1,32 @@ +# knot2/Containerfile for parity runs: same sources, but without fat lto, whose +# final link doesn't fit next to a running localinfra stack, and with cargo +# caches so a rebuild after a change is quick. responses don't depend on either. +FROM docker.io/library/rust:1.96-slim-trixie AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates build-essential cmake perl pkg-config clang mold \ + && rm -rf /var/lib/apt/lists/* +ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" \ + CARGO_PROFILE_RELEASE_LTO=off \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +WORKDIR /src +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY crates ./crates +COPY bobbin/crates ./bobbin/crates +COPY shuttle ./shuttle +COPY lexicons ./lexicons +COPY knot2/lexicons ./knot2/lexicons +COPY knot2/crates ./knot2/crates +COPY knot2/third_party ./knot2/third_party +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target,id=knot2-parity-target \ + cargo build --release --package knot-server && \ + cp target/release/knot-server /usr/local/bin/knot-server + +# the pinned distroless digest is amd64-only +FROM docker.io/library/debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /usr/local/bin/knot-server /usr/local/bin/knot-server +EXPOSE 5555 2222 +ENTRYPOINT ["/usr/local/bin/knot-server"] +CMD ["/etc/knot/config.toml"] diff --git a/knot2/worker/parity/parity.mjs b/knot2/worker/parity/parity.mjs index 896e5595e..07c59f0cb 100644 --- a/knot2/worker/parity/parity.mjs +++ b/knot2/worker/parity/parity.mjs @@ -1,12 +1,15 @@ #!/usr/bin/env node // compares the worker knot against a native knot2, request by request. // -// node parity.mjs setup-native create the fixture repo on the native knot and push it +// node parity.mjs setup-native [did] create the fixture repo on the native knot (or reuse did) and push it // node parity.mjs register-worker point the local worker at the native repo over git // node parity.mjs create-worker create the repo on the worker (artifacts) and push it // node parity.mjs compare run the request matrix against both and diff // node parity.mjs set-worker record a worker repo created and pushed elsewhere // node parity.mjs events diff the ref updates each knot announced on /events +// node parity.mjs lifecycle create, push, set default branch and delete on both, diffing +// each response and the events; mints the worker push token +// with CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN // // NODE_EXTRA_CA_CERTS must name localinfra's root.crt so the pds is trusted. @@ -63,11 +66,17 @@ function git(...args) { return execFileSync(env.git, args, { cwd: fixture, encoding: "utf8" }).trim(); } -function pushTo(url) { - execFileSync(env.git, ["push", "--quiet", url, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"], { - cwd: fixture, - stdio: "inherit", - }); +function pushTo(url, bearer) { + try { + execFileSync( + env.git, + ["-c", `http.extraHeader=Authorization: Bearer ${bearer}`, "push", url, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"], + { cwd: fixture, stdio: ["ignore", "inherit", "inherit"] }, + ); + } catch (error) { + // the command line carries the token; say where the push failed and nothing more + throw new Error(`git push to ${url} exited ${error.status}`); + } } async function createRepo(base, aud, access) { @@ -79,16 +88,17 @@ async function createRepo(base, aud, access) { }); } +async function pushNative(repoDid, accessJwt) { + const push = await serviceAuth(accessJwt, env.nativeDid, "sh.tangled.repo.push", 300); + pushTo(`${env.native}/${repoDid}`, push); +} + async function setupNative() { const { accessJwt, did } = await session(); - const created = await createRepo(env.native, env.nativeDid, accessJwt); - const push = await serviceAuth(accessJwt, env.nativeDid, "sh.tangled.repo.push", 300); - const remote = new URL(`${env.native}/${created.repoDid}`); - remote.username = "x"; - remote.password = push; - pushTo(remote.toString()); - save({ ...load(), owner: did, native: created.repoDid }); - console.log(`native repo ${created.repoDid}`); + const repoDid = process.argv[3] ?? (await createRepo(env.native, env.nativeDid, accessJwt)).repoDid; + save({ ...load(), owner: did, native: repoDid }); + await pushNative(repoDid, accessJwt); + console.log(`native repo ${repoDid}`); } async function registerWorker() { @@ -133,20 +143,21 @@ function replay(base, wait = 5000) { }); } -async function events() { - const state = load(); - // each knot's stream also carries other repos; keep the parity repo's own updates - const mine = (frames, did) => frames.filter((frame) => frame.event?.repo === did); - const shape = (frame, did) => { - const event = structuredClone(frame.event); - event.repo = event.repo === did ? "" : event.repo; - const inputs = event.meta?.langBreakdown?.inputs; - if (inputs) inputs.sort((a, b) => a.lang.localeCompare(b.lang)); - return { nsid: frame.nsid, event, rkeyIsTid: /^[2-7a-z]{13}$/.test(frame.rkey), createdIsNanos: Number.isInteger(frame.created) && frame.created > 1e18 }; +function shapeEvent(frame, did) { + const event = structuredClone(frame.event); + event.repo = event.repo === did ? "" : event.repo; + const inputs = event.meta?.langBreakdown?.inputs; + if (inputs) inputs.sort((a, b) => a.lang.localeCompare(b.lang)); + return { + nsid: frame.nsid, + event, + rkeyIsTid: /^[2-7a-z]{13}$/.test(frame.rkey), + createdIsNanos: Number.isInteger(frame.created) && frame.created > 1e18, }; - const native = mine(await replay(env.native), state.native).map((frame) => shape(frame, state.native)); - const worker = mine(await replay(env.worker), state.worker).map((frame) => shape(frame, state.worker)); - console.log(`native ${native.length} updates, worker ${worker.length} updates`); +} + +function diffEvents(native, worker) { + console.log(`native ${native.length} events, worker ${worker.length} events`); let same = 0; const count = Math.max(native.length, worker.length); for (let i = 0; i < count; i++) { @@ -154,9 +165,19 @@ async function events() { if (found) console.log(`✗ ${found}`); else same++; } - console.log(`${same}/${count} identical`); + console.log(`${same}/${count} events identical`); + return same === count; +} + +async function events() { + const state = load(); + // each knot's stream also carries other repos; keep the parity repo's own updates + const mine = (frames, did) => frames.filter((frame) => frame.event?.repo === did); + const native = mine(await replay(env.native), state.native).map((frame) => shapeEvent(frame, state.native)); + const worker = mine(await replay(env.worker), state.worker).map((frame) => shapeEvent(frame, state.worker)); + const same = diffEvents(native, worker); writeFileSync(join(out, "events.json"), JSON.stringify({ native, worker }, null, 2)); - process.exitCode = same === count ? 0 : 1; + process.exitCode = same ? 0 : 1; } async function createWorker() { @@ -164,10 +185,7 @@ async function createWorker() { const created = await createRepo(env.worker, env.workerDid, accessJwt); const token = process.env.PARITY_ARTIFACTS_TOKEN; if (!token) throw new Error("set PARITY_ARTIFACTS_TOKEN to a write token for the new artifacts repo"); - const remote = new URL(`${env.worker}/${created.repoDid}`); - remote.username = "x"; - remote.password = token.split("?expires=")[0]; - pushTo(remote.toString()); + pushTo(`${env.worker}/${created.repoDid}`, token); save({ ...load(), owner: did, worker: created.repoDid }); console.log(`worker repo ${created.repoDid}`); } @@ -365,8 +383,98 @@ async function compare() { process.exitCode = differing.length ? 1 : 0; } +// a write token for the worker repo's artifacts remote, minted the way an +// operator would hand one out: through the cloudflare api, not the knot +async function artifactsWriteToken(repoDid) { + const { CLOUDFLARE_ACCOUNT_ID: account, CLOUDFLARE_API_TOKEN: api } = process.env; + if (!account || !api) throw new Error("set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to mint a push token"); + const namespace = process.env.PARITY_ARTIFACTS_NAMESPACE ?? "knot-parity"; + const repo = repoDid.replace(/^did:/, "").replace(/[^A-Za-z0-9._-]/g, "-"); + const { result } = await json( + `https://api.cloudflare.com/client/v4/accounts/${account}/artifacts/namespaces/${namespace}/tokens`, + { + method: "POST", + headers: { authorization: `Bearer ${api}`, "content-type": "application/json" }, + body: JSON.stringify({ repo, scope: "write", ttl: 900 }), + }, + ); + return result.plaintext; +} + +async function call(base, aud, accessJwt, nsid, body) { + const token = await serviceAuth(accessJwt, aud, nsid); + const response = await fetch(`${base}/xrpc/${nsid}`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + return { status: response.status, body: await response.text() }; +} + +// create, push, move the default branch, delete: the same steps on both knots, +// each response compared with the repo did masked, then the events each emitted +async function lifecycle() { + const { accessJwt } = await session(); + const rkey = process.env.PARITY_RKEY ?? `life-${Date.now().toString(36)}`; + const knots = { + native: { base: env.native, aud: env.nativeDid, push: async (did) => pushNative(did, accessJwt) }, + worker: { base: env.worker, aud: env.workerDid, push: async (did) => pushTo(`${env.worker}/${did}`, await artifactsWriteToken(did)) }, + }; + const steps = {}; + const step = (side, label, did, reply) => { + let body = reply.body.split(did).join(""); + try { + body = JSON.parse(body); + // each knot signs with its own key + if (typeof body.key === "string") body.key = body.key.startsWith("did:key:z") ? "" : body.key; + } catch {} + (steps[label] ??= {})[side] = { status: reply.status, body }; + }; + const dids = {}; + for (const [side, knot] of Object.entries(knots)) { + const post = (nsid, body) => call(knot.base, knot.aud, accessJwt, nsid, body); + const read = async (nsid, query) => { + const response = await fetch(`${knot.base}/xrpc/${nsid}?repo=${dids[side]}${query ? `&${query}` : ""}`); + return { status: response.status, body: await response.text() }; + }; + const created = await post("sh.tangled.repo.create", { rkey, name: rkey, defaultBranch: "main" }); + if (created.status !== 200) throw new Error(`${side} create: ${created.status} ${created.body}`); + const did = (dids[side] = JSON.parse(created.body).repoDid); + step(side, "create", did, created); + step(side, "create again", did, await post("sh.tangled.repo.create", { rkey, name: rkey, defaultBranch: "main" })); + step(side, "getDefaultBranch empty", did, await read("sh.tangled.repo.getDefaultBranch")); + step(side, "branches empty", did, await read("sh.tangled.repo.branches")); + await knot.push(did); + step(side, "getDefaultBranch pushed", did, await read("sh.tangled.repo.getDefaultBranch")); + step(side, "setDefaultBranch feature", did, await post("sh.tangled.repo.setDefaultBranch", { repo: did, defaultBranch: "feature" })); + step(side, "getDefaultBranch feature", did, await read("sh.tangled.repo.getDefaultBranch")); + step(side, "tree default", did, await read("sh.tangled.repo.tree")); + step(side, "setDefaultBranch missing", did, await post("sh.tangled.repo.setDefaultBranch", { repo: did, defaultBranch: "nope" })); + step(side, "setDefaultBranch main", did, await post("sh.tangled.repo.setDefaultBranch", { repo: did, defaultBranch: "main" })); + step(side, "delete", did, await post("sh.tangled.repo.delete", { repo: did })); + step(side, "tree deleted", did, await read("sh.tangled.repo.tree", "ref=main")); + step(side, "delete again", did, await post("sh.tangled.repo.delete", { repo: did })); + console.log(`${side} ${did}`); + } + let same = 0; + for (const [label, { native, worker }] of Object.entries(steps)) { + const found = firstDifference(native, worker, label); + if (found) console.log(`✗ ${found}`); + else same++; + } + console.log(`${same}/${Object.keys(steps).length} lifecycle steps identical`); + // the worker emits push events after answering; give it a moment + await new Promise((resolve) => setTimeout(resolve, 3000)); + const native = (await replay(env.native)).filter((f) => f.event?.repo === dids.native).map((f) => shapeEvent(f, dids.native)); + const worker = (await replay(env.worker)).filter((f) => f.event?.repo === dids.worker).map((f) => shapeEvent(f, dids.worker)); + const sameEvents = diffEvents(native, worker); + writeFileSync(join(out, "lifecycle.json"), JSON.stringify({ dids, steps, events: { native, worker } }, null, 2)); + process.exitCode = same === Object.keys(steps).length && sameEvents ? 0 : 1; +} + const commands = { "setup-native": setupNative, + lifecycle, "register-worker": registerWorker, "create-worker": createWorker, "set-worker": setWorker,