Tier-2 Headless Fleet — implementation spec (#68)
Generated 2026-06-08 by the tier2-headless-fleet workflow. The buildable plan; Browserbase OUT, fully self-hosted.
Recommended free 512MB target
Oracle Cloud Always Free Ampere A1 (ARM), with the AMD E2.1.Micro (x86, 1GB) as the zero-flake-change fallback. WHY: the lean device is a long-running loop (capability, lease, heartbeat, scrape, submit, sleep) with NO inbound traffic, so every scale-to-zero free-web-service tier (Koyeb, Render, Back4App, Northflank) would hibernate it, wrong shape. Oracle is a genuinely always-on micro-VM; the scrape loop own CPU bursts keep it non-idle. Koyeb is doubly out: its single free slot is already the Typst service. Fly.io free tier is dead in 2026; GCP free e2-micro is already CORBET-US-E2-MICRO. The image is verified about 65.56 MiB uncompressed closure (curl plus cacert) plus about 5-8MB static-musl binary, so about 25-35 MiB gzipped tarball, roughly 7x under the 512MB ceiling (fits even a 256MB box); runtime RSS is tiny (curl is a short-lived child per fetch, the loop sleeps). RUN RECIPE (x86 zero-effort path): nix copy from the boot.corbet.ch signed cache then podman load of the gzipped docker-archive; OR scp the tarball and podman load. Run under systemd via a podman Quadlet at /etc/containers/systemd/jobcache-device.container: Image jobcache-device latest, ReadOnly true, Tmpfs /tmp rw mode 1777 size 64m (proves the FS contract: only /tmp writable, all broker.rs needs), DropCapability ALL, NoNewPrivileges true, MemoryMax 256M, Restart always. Env: JOBCACHE_API_URL to api.jobcache.corbet.ch, JOBCACHE_CONTROL_TOKEN from an EnvironmentFile written by sops (secrets/jobcache-control.yml, mode 0600, NEVER baked into the image), JOBCACHE_DEVICE_ID device_oracle_arch_host, JOBCACHE_CPU_CLASS oracle-arm-free (or oracle-amd-micro), JOBCACHE_TASK_CLASSES scrape (Tier-1 curl-only, NO render), and CRUCIALLY OMIT JOBCACHE_BROWSER_URL so RenderEnv stays off and render tasks are never leased here. OMIT JOBCACHE_DEVICE_TIER and JOBCACHE_DEVICE_FORMAT (the Rust binary ignores them, they are bun-worker vestiges). For ARM A1: first build packages.aarch64-linux.jobcache-device-image after the one-line muslPkgs flake tweak. Convert the Oracle tenancy to Pay-As-You-Go to neutralize the 7-day idle-reclaim while staying at zero cost within Always-Free limits. Bare-binary fallback (no podman): apt install curl ca-certificates, scp the static-musl binary to /usr/local/bin, run under a DynamicUser systemd unit with ProtectSystem strict plus PrivateTmp true.
jobich lessons applied (our own words — no code copied)
- Multi-tier rendering, NOT render-everything: jobich runs about 60 Tier-1 httpx scrapers natively and only spawns Playwright for about 15 JS-heavy boards (20-30 percent of traffic). We keep curl as the device default fetch and only the explicitly-flagged render task-class boards (and the headless-proxy Method, gated by sporewright fetch_rendered plus-infinity cell) reach the Playwright fleet. The lean 512MB device runs scrape-only and never renders.
- Process-isolation for the browser, adapted to a container/context boundary: jobich runs each Playwright job in its own subprocess so a crash cannot kill the parser. We translate subprocess-per-job to the render service being a separate k3s Deployment the device POSTs to over HTTP, plus a fresh browser context per render closed in a finally block. Same isolation property, no in-process browser in the Rust device.
- Minimal curated stealth, never a stealth plugin: jobich found that for a low-volume Swiss crawler, UA rotation and puppeteer-extra/playwright-stealth are MORE detectable and slow with a heavy closure. We apply only the high-signal levelers (single realistic UA, de-CH locale plus Europe/Zurich timezone, fixed viewport, disable-blink-features AutomationControlled, and an init script masking navigator.webdriver, plugins, and window.chrome) and skip the plugin entirely.
- Treat a captcha or WAF gate as a fall-through, never a success: jobich never stores a captcha page as a real result. Our render service runs an in-DOM fingerprint check after settle and returns 403 captcha-gate, which the device maps to FetchError Unavailable (clean skip via observe_with_rendered_fallback) and the TS router maps to a throw that falls through to a metered scraper. So the self-hosted free fleet absorbs basic and Cloudflare JS but DataDome/AWS-WAF correctly escalate.
- Swiss-market settle jitter, no fixed intervals: jobich uses randomized per-domain delays rather than a fixed cadence to avoid detection. We add a 200-900ms randomized post-load settle in the render service and slower randomized worker pacers (idle 60s, active 3s plus jitter) on the tier-2 device.
- Adapter and manifest-driven routing, extraction stays server-side: jobich sources.yaml maps each board to platform metadata and keeps extraction logic out of the thin client. We mirror this: the board binding config carries an explicit task_class render flag (no fake auto-JS-detection heuristic, per no-silent-defaults), the device stays a thin client that just POSTs a URL and gets HTML back, and JSON-LD extraction happens at the render leaf while still returning full html so existing adapters parse unchanged.
- Render listing and description fetching are decoupled and restartable: jobich scrapes listings then enriches descriptions in a separate pass that does not fail the scrape on a missing description. Our render service returns raw html and jsonld only; classification and embedding stay in separate enrichment workers, and a render board discovered detail URLs plus freshness re-fetches inherit the render class so they route to render-capable pods rather than silently falling back to curl devices that cannot render them (the load-bearing planner fix).
- Observability per board: jobich logs per-source success rates, rate-limit events, and timings. The render service exposes /health (uptime, inFlight, totalRenders, errors) and /metrics (Prometheus render-total by outcome, inflight gauge, duration histogram) so render outcomes are visible per-pod and aggregatable at the device and router level.
Build plan (ordered; [code-now] vs [build-server-SAVED])
[code-now] flake.nix: parameterize muslPkgs on target arch so packages.aarch64-linux.* yields a REAL arm64 static binary. Today pkgs.pkgsCross.musl64 is hardcoded x86_64, so every system attr builds x86_64. Replace with: if pkgs.stdenv.hostPlatform.isAarch64 then pkgs.pkgsCross.aarch64-multiplatform-musl else pkgs.pkgsCross.musl64. mkBin, jobcache-device, and jobcache-device-image all flow through muslPkgs. Verify with nix eval of packages.aarch64-linux.jobcache-device-image.outPath. This is the ONLY change the lean 512MB ARM image needs.
[code-now] Decision (no code): the Rust device advertises render purely through JOBCACHE_TASK_CLASSES. There is NO fetch_rendered capability FLAG on the Rust wire report (the Rust device routes via the adapter registry where adapters call env.fetch_rendered; offers-render equals RenderEnv-active equals JOBCACHE_BROWSER_URL set). Routing signal is task-class only; no contract.rs change.
[code-now] jobcache/shared/src/device-contract.ts: append render to JOBCACHE_TASK_CLASSES (7 entries; JobcacheTaskClassSchema z.enum derives automatically). Add active_render_tasks NonNegativeIntegerSchema.optional() to JobcacheDeviceLoadSchema (additive and back-compat: older devices omit it, broker treats absent as 0). Mirror the existing reparse precedent.
[code-now] jobcache/migrations/add-render-task-class.sql (NEW): sibling of add-reparse-task-class.sql, extend the CrateDB task_class column CHECK or enum to admit render. ROLLOUT GATE: this migration plus the contract and broker change MUST deploy BEFORE any tier-2 device advertises render, else a render-advertising capability report 400s against an old broker (schemas are strict). Same ordering the reparse class followed.
[code-now] crates/jobcache-device/src/main.rs: widen parse_task_classes ALLOWED from 6 to 7 entries adding render; add active_render_tasks 0 to the CurrentLoad literal in build_capability; add mod render; add a startup hard-warning when render is in task_classes but JOBCACHE_BROWSER_URL is unset (prevents the silent-churn footgun).
[code-now] crates/jobcache-device/src/broker.rs: add pub active_render_tasks u32 to the CurrentLoad struct (serde field; emitted as 0 soft hint, matching the other active tasks fields).
[code-now] crates/jobcache-device/src/render.rs (NEW): RenderEnv wrapping CurlEnv, impl jobcache Env. user_agent, rate_limit, and fetch delegate to inner CurlEnv (Tier-1 plain leg stays curl); fetch_rendered POSTs url, timeout_ms, wait_until to JOBCACHE_BROWSER_URL via curl stdin-pipe (data-binary at-dash, matching env.rs fetch, no temp file on the tokenless path), parsing html, final_url, status into a Response. RenderConfig from_env reads JOBCACHE_BROWSER_URL (None means bare CurlEnv, fetch_rendered Unavailable, observe_with_rendered_fallback skips cleanly), JOBCACHE_BROWSER_TIMEOUT_MS (default 45000), optional JOBCACHE_BROWSER_TOKEN. Error map: curl 28 to Timeout; curl 5/6/7 to Unavailable (service down equals skip, not hard fail); HTTP 429/503 to RateLimited 0; HTTP 403 to Unavailable (captcha-gate); other non-2xx to Unavailable. Token branch (temp-file body plus stdin config) is a documented TODO. Unit test: from_env None without JOBCACHE_BROWSER_URL.
[code-now] crates/jobcache-device/src/adapter.rs: in handle(), bind let fallback equals CurlEnv from_env FIRST (lifetime), then let env dyn Env equals match render RenderEnv from_env Some ref r then r else and-fallback. The naive match-arm and-CurlEnv-from_env will not compile (both briefs flagged this). Widen run_observe and run_discover from and-CurlEnv to and-dyn-Env (bodies unchanged). KEEP run_reparse_grant internal fetch_env on bare CurlEnv (must NOT render the presigned ciphertext).
[code-now] jobcache/interface/src/api/device-control.ts: taskClassLoad add a render branch returning numberValue load.active_render_tasks 0; add a render resource-policy default (max_concurrency 1, timeout_ms about 90000, larger max_bytes about 8MB, no metered or battery), render is slow and heavy; observeTaskFromDiscovery (line 1315) change task_class scrape to inherit discoverTask.task_class equals render then render else scrape so a JS board discovered DETAIL fetches inherit render. eligibleTaskClasses and selectReadyTasks need NO change.
[code-now] jobcache/interface/src/api/planner/source-bindings.ts: planFreshnessRefetchTasks re-fetch payload task_class scrape becomes row.task_class equals render then render else scrape (thread task_class through FreshnessRefetchRow and its SELECT if absent) so a render board stale-ad re-fetch stays render. Main planSourceBindingTasks path already passes taskClass limits.task_class and needs NO change. A JS board is flagged by an explicit task_class render in binding config (no auto JS-detection heuristic). This detail and freshness inheritance is the load-bearing planner fix.
[code-now] jobcache/render/ (NEW perspective dir): self-hosted Playwright HTTP service. package.json (playwright PINNED 1.59.1 equals nixpkgs playwright-driver, fastify, prom-client), src/server.ts (Fastify; POST /render returns finalUrl, status, html, jsonld array; GET /health and /metrics; one Browser, context-per-render, semaphore MAX_CONCURRENT_RENDERS 3 returns 503 at-capacity; captcha-fingerprint returns 403; SIGTERM drain), src/stealth.ts (minimal curated: de-CH locale, Europe/Zurich tz, single UA, 1280x800, disable-blink-features AutomationControlled, init-script masks webdriver, plugins, chrome; NO puppeteer-extra), src/jsonld.ts (in-page ld+json collector), src/server.test.ts (contract shape vs fixture, no real browser). NO Dockerfile, the Nix image is canonical.
[code-now] flake.nix: add jobcache-render-image (3rd package output) via dockerTools.buildLayeredImage. contents are nodejs_22, buildNpmPackage renderServer, playwright-driver.browsers (chromium rev 1217), cacert, fontconfig, dejavu_fonts, liberation_ttf, noto-fonts-cjk-sans, bashInteractive, coreutils. Env: PLAYWRIGHT_BROWSERS_PATH to the browsers store path plus PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD 1 (resolve chromium from the Nix store, never download), SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, FONTCONFIG_FILE, MAX_CONCURRENT_RENDERS 3. extraCommands mkdir 1777 for tmp and dev-shm. npmDepsHash is a build-brain FIXME. Add a build-time assertMsg: package.json playwright equals playwright-driver.version (highest-risk lockstep: mismatch means a chromium-rev dir not in the store, pods fail at launch). Header comment SPLITS lean device image (under 512MB, free micro, NO chromium) from render image (about 1-1.5GB, k3s ONLY).
[code-now] jobcache/ingest/src/scrapers/playwright.ts (NEW) plus register in index.ts: Scraper (id playwright, js true, antiBot none/basic/cloudflare) POSTing to JOBCACHE_RENDER_URL /render; available probes /health (15s cache); fetch maps 503 to throw (router retries next), 403 captcha-gate to throw (fall through to metered browserbase/firecrawl), 2xx to html, finalUrl, status. Register FIRST among JS-capable scrapers (before browserbase and firecrawl) so the free fleet absorbs JS traffic; metered providers only see captcha fall-throughs. Only register when JOBCACHE_RENDER_URL set. Zero router.ts change.
[code-now] k3s manifests jobcache/ingest/deploy/kubernetes/: render-deployment.yaml (Deployment jobcache-render, replicas 2, requests cpu 250m mem 768Mi, limits cpu 2 mem 1536Mi; emptyDir medium Memory 512Mi at dev-shm (canonical chromium-in-k8s fix; do NOT pass disable-dev-shm-usage), emptyDir at tmp; readiness and liveness on /health; runAsNonRoot uid 1000, drop ALL caps, seccomp RuntimeDefault, no-sandbox (pod is the single-tenant isolation boundary); terminationGracePeriodSeconds 30) plus render-service.yaml (ClusterIP jobcache-render 80 to 3000). Add both to kustomization.yaml.
[code-now] jobcache/ingest/deploy/kubernetes/deployment-render.yaml (NEW): TIER-2 device Deployment, SAME lean jobcache-device:latest image (chromium NOT in the device image, render is a client capability), env JOBCACHE_TASK_CLASSES render, JOBCACHE_DEVICE_TIER headless (identity only, NOT a routing gate), JOBCACHE_BROWSER_URL to the cluster render Service (flips RenderEnv on), JOBCACHE_BROWSER_TIMEOUT_MS 45000, slower pacers, fewer replicas, limits cpu 1 mem 512Mi. The free 512MB micro-server runs the SAME image but OMITS JOBCACHE_BROWSER_URL (CurlEnv only, render tasks never leased to it). Add to kustomization.yaml.
[code-now] LOCAL verification gate: cargo build -p jobcache-device (the dyn Env widening plus render.rs typecheck), cargo test -p jobcache-device (render.rs from_env-None plus existing adapter tests), bun test jobcache/shared and jobcache/interface (render round-trips JobcacheTaskClassSchema; taskClassLoad and eligibleTaskClasses render case), tsc jobcache/render and playwright.ts, nix eval of jobcache-render-image.outPath (lockstep assert fires) plus packages.aarch64-linux.jobcache-device-image.outPath. All eval and typecheck clean WITHOUT realising the slow musl or npm builds.
[build-server-SAVED] On the build-brain (boot.corbet.ch) via the fleet-build CronJob (infra repo infra/infra/scripts/fleet-build.sh; this repo infra/ is now a MOVED.md stub): nix build packages.aarch64-linux.jobcache-device-image (lean ARM) plus packages.x86_64-linux.jobcache-device-image (lean x86, already built) plus jobcache-render-image (fill npmDepsHash on first build from nix printed hash; lockstep assert gates it). nix copy to the signed zstd cache, served from boot.corbet.ch. GATED on build-brain keys/access.
[build-server-SAVED] Provision the free 512MB server (Oracle Cloud Always Free; user has an Oracle login) plus run the lean device per the run recipe; deploy jobcache-render plus jobcache-device-render to k3s (ctr namespace k8s.io images import from boot.corbet.ch, kubectl apply -k). DEPLOY ORDER: contract, broker, migration FIRST, then render service, then tier-2 device pods, then the free micro-server. GATED on cluster plus Oracle keys/access.
Artifacts to write
- /home/richc/Documents/GitHub/careervector/flake.nix — EDIT: (1) replace muslPkgs equals pkgs.pkgsCross.musl64 with the arch-parameterized selection (isAarch64 picks aarch64-multiplatform-musl, else musl64) so packages.aarch64-linux.* builds a real arm64 lean image. (2) ADD jobcache-render-image (3rd package output) via dockerTools.buildLayeredImage with nodejs_22 plus buildNpmPackage renderServer plus playwright-driver.browsers (chromium rev 1217) plus fonts plus cacert; Env pins PLAYWRIGHT_BROWSERS_PATH and SKIP_DOWNLOAD; extraCommands makes tmp and dev-shm 1777; add the playwright-version-lockstep assertMsg (package.json playwright equals playwright-driver.version). (3) Header comment splitting lean device image (under 512MB, free micro, no chromium) from render image (about 1-1.5GB, k3s only). jobcache-device-image itself UNCHANGED.
- /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/render.rs — NEW: RenderEnv wrapping CurlEnv, impl jobcache Env. user_agent, rate_limit, fetch delegate to inner CurlEnv; fetch_rendered POSTs url, timeout_ms, wait_until to JOBCACHE_BROWSER_URL via curl stdin-pipe (data-binary at-dash, matching env.rs fetch), parses html, final_url, status into Response. RenderConfig from_env reads JOBCACHE_BROWSER_URL (None means tier-1 fallback), JOBCACHE_BROWSER_TIMEOUT_MS (default 45000), optional JOBCACHE_BROWSER_TOKEN. Error map: curl 28 to Timeout, curl 5/6/7 to Unavailable, HTTP 429/503 to RateLimited 0, HTTP 403 to Unavailable (captcha-gate), other non-2xx to Unavailable. Token-via-temp-file branch is a documented TODO. Unit test: from_env None without JOBCACHE_BROWSER_URL.
- /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/main.rs — EDIT: parse_task_classes ALLOWED 6 to 7 entries add render; build_capability CurrentLoad add active_render_tasks 0; add mod render; add startup hard-warning when render is in task_classes but JOBCACHE_BROWSER_URL is unset.
- /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/broker.rs — EDIT: add pub active_render_tasks u32 to the CurrentLoad struct (serde field, soft-hint 0).
- /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/adapter.rs — EDIT: handle() bind let fallback equals CurlEnv from_env first (lifetime), then let env dyn Env equals match render RenderEnv from_env Some ref r then r else and-fallback; widen run_observe and run_discover signatures from and-CurlEnv to and-dyn-Env. KEEP run_reparse_grant internal fetch_env on bare CurlEnv (must not render the presigned ciphertext).
- /home/richc/Documents/GitHub/careervector/jobcache/shared/src/device-contract.ts — EDIT: append render to JOBCACHE_TASK_CLASSES (7 entries); add active_render_tasks NonNegativeIntegerSchema.optional() to JobcacheDeviceLoadSchema. Mirror the reparse precedent.
- /home/richc/Documents/GitHub/careervector/jobcache/migrations/add-render-task-class.sql — NEW: sibling of add-reparse-task-class.sql, extend the CrateDB task_class column CHECK or enum to admit render. Deploy BEFORE any render-advertising device.
- /home/richc/Documents/GitHub/careervector/jobcache/interface/src/api/device-control.ts — EDIT: taskClassLoad add render to active_render_tasks branch; add a render resource-policy default (max_concurrency 1, timeout_ms about 90000, larger max_bytes, no metered or battery); observeTaskFromDiscovery task_class inherit discoverTask.task_class equals render then render else scrape.
- /home/richc/Documents/GitHub/careervector/jobcache/interface/src/api/planner/source-bindings.ts — EDIT: planFreshnessRefetchTasks re-fetch payload task_class inherit render from row.task_class (thread it through FreshnessRefetchRow and SELECT if absent). Main bind path unchanged.
- /home/richc/Documents/GitHub/careervector/jobcache/render/package.json — NEW: name jobcache-render, type module; deps playwright PINNED 1.59.1 (equals nixpkgs playwright-driver), fastify, prom-client; devDeps tsx, types-node, typescript. Bumping playwright requires bumping the flake pin in lockstep (assert enforces).
- /home/richc/Documents/GitHub/careervector/jobcache/render/src/server.ts — NEW: Fastify render service. POST /render with url, waitUntil, timeoutMs, stealth, locale, userAgent returns finalUrl, status, html, jsonld; GET /health returns ok, uptime, chromiumRev, inFlight, totalRenders, errors; GET /metrics. One Browser (no-sandbox, disable-gpu, NO disable-dev-shm-usage), context-per-render closed in finally, in-process semaphore returns 503 at-capacity, captcha-fingerprint check returns 403 captcha-gate, 200-900ms settle jitter, SIGTERM drain, hard timeout 45s.
- /home/richc/Documents/GitHub/careervector/jobcache/render/src/stealth.ts — NEW: minimal curated stealth (NO puppeteer-extra). de-CH locale, Europe/Zurich tz, single realistic Chrome UA, 1280x800 viewport, Accept-Language de-CH, disable-blink-features AutomationControlled flag, init script masking navigator.webdriver, plugins, languages, plus window.chrome stub.
- /home/richc/Documents/GitHub/careervector/jobcache/render/src/jsonld.ts — NEW: in-page collector of script type application/ld+json blocks (schema.org JobPosting). Parse each; skip malformed (never fabricate). Returns unknown array.
- /home/richc/Documents/GitHub/careervector/jobcache/render/src/server.test.ts — NEW: contract-shape test of /render and /health against a local fixture HTTP server (no real chromium needed for the wire contract).
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/src/scrapers/playwright.ts — NEW: makePlaywrightScraper Scraper (id playwright, js true, antiBot none/basic/cloudflare) POSTing to JOBCACHE_RENDER_URL /render; available probes /health (15s cache); fetch maps 503 to throw (retry next), 403 to throw (captcha-gate fall-through), 2xx to html, finalUrl, status. Throws on empty html.
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/src/scrapers/index.ts — EDIT: register makePlaywrightScraper FIRST among JS-capable scrapers (before browserbase and firecrawl) when JOBCACHE_RENDER_URL is set; warn-log when unset.
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/render-deployment.yaml — NEW: Deployment jobcache-render, replicas 2, image jobcache-render:latest IfNotPresent, requests cpu 250m mem 768Mi limits cpu 2 mem 1536Mi, dev-shm emptyDir medium Memory 512Mi plus tmp emptyDir, readiness and liveness /health, runAsNonRoot uid 1000 drop-ALL seccomp RuntimeDefault, terminationGracePeriodSeconds 30, MAX_CONCURRENT_RENDERS 3.
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/render-service.yaml — NEW: ClusterIP Service jobcache-render, port 80 to targetPort http (3000), so device pods reach the cluster-internal render URL.
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/deployment-render.yaml — NEW: tier-2 device Deployment jobcache-device-render reusing the SAME lean jobcache-device:latest image, env JOBCACHE_TASK_CLASSES render, JOBCACHE_DEVICE_TIER headless, JOBCACHE_BROWSER_URL to the cluster render Service, JOBCACHE_BROWSER_TIMEOUT_MS 45000, slower pacers, fewer replicas, limits cpu 1 mem 512Mi (chromium lives in jobcache-render, not here).
- /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/kustomization.yaml — EDIT: add render-deployment.yaml, render-service.yaml, deployment-render.yaml to resources.
Open questions
- Broker capability advertisement gap: a tier-1 curl device and a tier-2 render device differ ONLY by JOBCACHE_TASK_CLASSES and JOBCACHE_BROWSER_URL; there is no fetch_rendered capability FLAG on the Rust wire report (routing is task-class only). If a render device is misconfigured with JOBCACHE_TASK_CLASSES render but JOBCACHE_BROWSER_URL unset, it will lease render tasks and fail them (Unavailable, retryable churn). The startup hard-warning mitigates but does not prevent. Should the broker additionally refuse to lease render tasks to a device whose report does not positively assert render capability, or is the task-class advertisement plus startup guard sufficient?
- Render env-var naming: the briefs disagree (JOBCACHE_BROWSER_URL plus service jobcache-render vs JOBCACHE_RENDER_URL plus service jobcache-render). I locked the Rust device on JOBCACHE_BROWSER_URL and the TS scraper on JOBCACHE_RENDER_URL because they point at the same service from two different consumers, but confirm whether you want ONE env-var name shared across the Rust device and the TS ingest, or the two-name split as drafted.
- npmDepsHash and lockstep: the buildNpmPackage npmDepsHash is a build-brain FIXME (filled on first build from nix printed expected hash) and the playwright-version lockstep assert assumes package.json dependencies.playwright is a bare version string (1.59.1), not a range. Confirm the pinned nixpkgs rev still resolves playwright-driver 1.59.1 and chromium rev 1217 at build time, or the assert plus image build will need the version bumped in lockstep.
- no-sandbox posture: the render pods run chromium with no-sandbox (the k8s pod, non-root, drop-ALL caps, seccomp RuntimeDefault, is the only isolation boundary). This is the standard headless-in-container tradeoff for a single-tenant job-board scraper, but it removes chromium internal sandbox against a chromium RCE from attacker-controlled JS. Accept as-is, or add a gVisor/Kata runtimeClass for the render Deployment?
- Source flagging: which boards get task_class render is currently a manual per-source decision in binding config (intentionally no auto JS-detection, per no-fake-heuristic). That means a JS board not yet flagged stays on scrape and is silently skipped by curl devices. Do you want a QA or planner surface that lists which sources are JS-only-but-unflagged, or is manual curation from the jobich platform-quirks registry the intended workflow?
DRAFT — deviceRender
plan
Design: device render task class as a thin Playwright client (#68)
Core insight
crates/jobcache/src/adapter.rs already has the seam: Env::fetch_rendered(req) (default returns FetchError::Unavailable("rendered fetch")). Every JS adapter (the playwright-html family, sf-spa, etc.) calls env.fetch_rendered(...) or routes through observe_with_rendered_fallback. The Rust device's CurlEnv (env.rs) does NOT override fetch_rendered, so today it returns Unavailable and those Methods are dropped. A tier-2 device is therefore JUST CurlEnv + an overridden fetch_rendered that POSTs the URL to the cluster-internal Playwright service. The extraction + broker-submit path (adapter::run_observe / run_discover) is byte-identical to the curl path — no new submit code.
The capability that gates rendering is ALREADY modelled end-to-end: method-chain.ts CAP_FETCH_RENDERED = "fetch_rendered", METHOD_NEEDS_RENDER["headless-proxy"] = true, and gateCapabilities writes priv:fetch_rendered = +∞ on headless-proxy for any device lacking the cap. The TS device sets capabilities = env.fetchRendered ? [CAP_FETCH_RENDERED] : [] (device-cycle.ts). So the device-side capability is "does this device offer fetch_rendered". The Rust device needs the equivalent: offer the cap when JOBCACHE_BROWSER_URL is set.
Two routing layers (both pre-exist, both extended)
- Method gate (per-task, device-local, already live): which
Methodruns on this device.headless-proxyis dropped unless the device offersfetch_rendered. This already works for the TS device; we replicate it for Rust. - Task-class lease filter (broker-side, the NEW tier match):
task_classis a coarse routing key on the queue.eligibleTaskClasses(device-control.ts) intersects the device'ssupported_task_classes/allowed_task_classeswith the task'stask_class, andselectReadyTasksfilters the SQL queue bytask_class IN (...). Adding arendertask class lets the planner flag JS-only boards asrenderso ONLY tier-2/headless devices (which advertiserenderin their classes) ever lease them — tier-1 nano-curl devices never even see them in the queue.
Why both, not just the Method gate
The Method gate alone would let a tier-1 device LEASE a JS board task, run the chain, find every Method gated out, and return adapter_unreachable_method (retryable) — wasted lease cycles + churn. The render task class makes the queue filter do the work up front: the broker never offers a render task to a non-render device. This mirrors how reparse is its own class today.
File-by-file implementation plan
A. Schema/contract — add the render class + capability advertisement
jobcache/shared/src/device-contract.tsJOBCACHE_TASK_CLASSES: append"render"→["scrape","chunk","enrich","embed","verify","reparse","render"]. (JobcacheTaskClassSchemaderives from it automatically.)JobcacheDeviceLoadSchema: addactive_render_tasks: NonNegativeIntegerSchema.optional()(additive, back-compat — older devices omit it; broker treats absent as 0).- Add a
JOBCACHE_TASK_KINDSentry for the render fetch if a distinct kind is wanted; simplest is to REUSE"fetch-page"(the render device'sfetch_renderedmakes theheadless-proxyMethod feasible, the kind staysfetch-page). Recommend reuse — no new kind.
crates/jobcache-device/src/main.rs::parse_task_classes: extendALLOWEDfrom[&str; 6]to include"render"(→[&str; 7]). This letsJOBCACHE_TASK_CLASSES=renderpass the filter.crates/jobcache-device/src/broker.rs::CurrentLoad: addpub active_render_tasks: u32and emit it inbuild_capability(main.rs). Keep it0(the sink reports a soft hint).
B. Broker-side — task-class load + tier eligibility
jobcache/interface/src/api/device-control.ts::taskClassLoad: addif (taskClass === "render") return numberValue(load.active_render_tasks, 0);.device-control.tsresource-policy default map (theresourcePolicyFor/ DEFAULT_TASK_CLASS_POLICY area near line ~1315task_class: "scrape"): add arenderpolicy default (lowermax_concurrency, biggertimeout_ms≈ 60s, biggermax_bytes). Render is slow + heavy.- No change needed to
eligibleTaskClasses/isAllowedTaskClass/selectReadyTasks— they're already class-generic; oncerenderis in the enum and a device advertises it, the intersection + SQL filter route correctly.
C. Planner — flag JS boards as render
jobcache/interface/src/api/planner/source-bindings.ts(line ~417task_class: taskClass(limits.task_class)): the binding'slimits.task_classalready drives the class. A JS-only board setstask_class: "render"in its binding config (source-catalog.ts/ binding row). The planner needs NO code change — it already passeslimits.task_classthrough. The ONLY change: ensuretaskClass()(the local coercer at line ~1053) accepts"render"(it validates againstDeviceTaskEnqueue["task_class"], which derives from the contract enum, so once the enum hasrenderthis is automatic).- Optional ergonomics: if boards carry a boolean
requires_render/js_renderflag in source config instead of an explicit class, add a one-line derivation in the planner:task_class: limits.requires_render ? "render" : taskClass(limits.task_class). Recommend the explicittask_classin binding config (no fake heuristic, per "no silent defaults").
- Optional ergonomics: if boards carry a boolean
device-control.ts::observeTaskFromDiscovery(line ~1312) andplanner::planFreshnessRefetchTasks(line ~517): these hardcodetask_class: "scrape". For a render board, a discovered detail URL / freshness re-fetch must INHERIT the discover task's class. Change both to carry the parent'stask_class(threaddiscoverTask.task_class/row.task_class) so a render board's detail + re-fetch tasks stayrender. This is the load-bearing planner fix — without it, only the discover task is render-routed and the detail fetches fall back toscrapeand get leased by curl devices that can't render them.
D. Rust device — the render thin client (the heart)
- NEW
crates/jobcache-device/src/render.rs: aRenderEnvthat wrapsCurlEnvand overridesfetch_renderedto POST the target URL to the Playwright service.fetch(plain) delegates to the innerCurlEnv(so the "plain HTTP first, render fallback" adapter pattern still uses cheap curl for the plain attempt).fetch_renderedshellscurlto POST{ "url": ..., "timeout_ms": ... }toJOBCACHE_BROWSER_URLand parses{ "html": ..., "final_url": ..., "status": ... }back into aResponse. Pure-Rust, curl-shell-out — same no-TLS-stack property as broker.rs/env.rs. crates/jobcache-device/src/env.rs: factor the curl-POST helper sorender.rsreuses the same stdin-config token discipline if the browser service needs auth (it's cluster-internal, so likely tokenless; keep optionalJOBCACHE_BROWSER_TOKEN).crates/jobcache-device/src/adapter.rs::handle: changelet env = CurlEnv::from_env();to select the env by capability: whenJOBCACHE_BROWSER_URLis set, buildRenderEnv::from_env()(which offersfetch_rendered); elseCurlEnv::from_env(). Pass&dyn Envtorun_observe/run_discover(they already takeenv: &CurlEnv— widen the signature to&dyn Envso either env works). The extraction + submit path is unchanged.crates/jobcache-device/src/main.rs: no logic change beyondparse_task_classes+ the load field; the cycle already routes throughadapter::handle.crates/jobcache-device/src/main.rs/adapter.rs: the capability report'ssupported_task_classes/allowed_task_classescome fromconfig.task_classes. A tier-2 device setsJOBCACHE_TASK_CLASSES=render(orscrape,render), so it advertisesrenderand the broker routes render tasks to it. (The device-contract'sfetch_renderedcapability is a Method-chain concept on the TS side; the Rust device doesn't run the TS method-chain — it routes via the adapter registry, where the adapter callsenv.fetch_rendered. So for Rust, "offers fetch_rendered" === "RenderEnv is active" === "JOBCACHE_BROWSER_URL set". No extra capability field needed on the Rust wire report; the task-class advertisement is the routing signal.)
E. New env vars
JOBCACHE_BROWSER_URL— cluster-internal Playwright service URL (e.g.http://jobcache-browser.jobcache.svc.cluster.local:3000/render). Presence flips the device into render mode (offersfetch_rendered).JOBCACHE_DEVICE_TIER=headless— marker (mirrors the existingJOBCACHE_DEVICE_TIER=container); read for diagnostics/identity, NOT a routing gate (routing is via task classes).JOBCACHE_TASK_CLASSES=render(orscrape,render) — the actual routing advertisement.JOBCACHE_BROWSER_TOKEN(optional) — bearer for the browser service if it's not tokenless.JOBCACHE_BROWSER_TIMEOUT_MS(optional, default ~45000) — render budget per page.
F. flake.nix + k3s manifest
flake.nix: the LEAN ≤512MB imagejobcache-device-imageis ALREADY the tier-1 free-micro artifact (curl + cacert + static binary, ~no chromium). NO change needed for the ≤512MB requirement — that image is the deliverable for the free 512MB micro-server. The tier-2 headless device REUSES the SAMEjobcache-device-image(same Rust binary, same closure) — it does NOT bundle chromium. Chromium lives in a SEPARATEjobcache-browserDeployment (the Playwright pod fleet). So the Rust image stays lean for both tiers; only the env + the sidecar service differ. (Optionally add ajobcache-browser-imagederivation later for the Playwright pods; out of scope for the Rust-device code task.)- NEW
jobcache/ingest/deploy/kubernetes/deployment-render.yaml(or a kustomize overlay): a tier-2 Deployment reusingimage: jobcache-device:latest, withJOBCACHE_DEVICE_TIER=headless,JOBCACHE_TASK_CLASSES=render,JOBCACHE_BROWSER_URL=http://jobcache-browser:3000/render, bigger resources (the device itself stays light, but give it headroom for concurrent renders), lower replicas, andJOBCACHE_FALLBACK_ONLY=false(drain render work immediately). Add it tokustomization.yaml.
Verification (local, no deploy)
cargo test -p jobcache-device(render.rs gets unit tests with a mock browser response; reuses therfind/canned-env test style).cargo build -p jobcache-device(the&dyn Envwidening typechecks).nix eval .#jobcache-device-image.outPath/nix buildon the build-brain (deferred to when keys granted).- TS:
JobcacheTaskClassSchemaround-tripsrender;eligibleTaskClasses/taskClassLoadunit tests add arendercase.bun teston jobcache shared + interface.
artifacts
============================================================
1) crates/jobcache-device/src/render.rs (NEW)
============================================================
// SPDX-License-Identifier: MIT
//! `RenderEnv` — the tier-2 (headless) device runtime's `jobcache::Env`.
//!
//! A tier-2 device is a THIN CLIENT: it does NOT embed a browser. It wraps the
//! lean `CurlEnv` and overrides ONLY `fetch_rendered`, which POSTs the target
//! URL to a cluster-internal, self-hosted Playwright service
//! (`JOBCACHE_BROWSER_URL`) and gets rendered HTML + the post-redirect final URL
//! back. Plain `fetch` still delegates to `CurlEnv` (cheap curl), so an adapter's
//! "plain HTTP first, render fallback" flow keeps using curl for the cheap leg.
//!
//! Offering `fetch_rendered` is what makes JS-only Methods (`headless-proxy`) and
//! the `playwright-html` / SPA adapter families feasible on this device. The
//! EXTRACTION + broker SUBMIT path is identical to the curl path — `adapter::
//! run_observe` / `run_discover` are handed a `&dyn Env` and never know whether
//! the HTML came from curl or from the browser service.
//!
//! Like `broker.rs` and `env.rs`, all HTTP is curl-shell-out — NO embedded TLS
//! stack, so the binary stays pure-Rust + static-musl with no C cross toolchain.
//! The same lean ≤512MB image runs BOTH tiers; only `JOBCACHE_BROWSER_URL` (and
//! the heavier chromium pods it points at) differ. Chromium is NEVER in this
//! image.
use std::process::Stdio;
use async_trait::async_trait;
use jobcache::{Env, FetchError, RateLimit, Request, Response};
use serde_json::Value;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::env::CurlEnv;
/// Default per-page render budget. A headless render is slow; allow well past a
/// plain fetch. Overridable via `JOBCACHE_BROWSER_TIMEOUT_MS`.
const DEFAULT_RENDER_TIMEOUT_MS: u64 = 45_000;
/// The tier-2 capability bundle: a `CurlEnv` for plain fetches + a Playwright
/// service endpoint for `fetch_rendered`.
pub struct RenderEnv {
inner: CurlEnv,
/// Cluster-internal Playwright service render endpoint
/// (`JOBCACHE_BROWSER_URL`, e.g.
/// `http://jobcache-browser.jobcache.svc.cluster.local:3000/render`).
browser_url: String,
/// Optional bearer for the browser service. Cluster-internal services are
/// usually tokenless; when set it is passed via curl stdin config (`-K -`),
/// never argv, exactly like the broker token.
browser_token: Option<String>,
render_timeout_ms: u64,
}
impl RenderEnv {
/// Build from the process environment. Returns `None` when
/// `JOBCACHE_BROWSER_URL` is unset/empty — the caller then falls back to a
/// plain `CurlEnv` (a device with no browser service is a tier-1 device and
/// must NOT offer `fetch_rendered`).
pub fn from_env() -> Option<Self> {
let browser_url = std::env::var("JOBCACHE_BROWSER_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
let browser_token = std::env::var("JOBCACHE_BROWSER_TOKEN")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let render_timeout_ms = std::env::var("JOBCACHE_BROWSER_TIMEOUT_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_RENDER_TIMEOUT_MS);
Some(Self {
inner: CurlEnv::from_env(),
browser_url,
browser_token,
render_timeout_ms,
})
}
}
#[async_trait]
impl Env for RenderEnv {
fn user_agent(&self) -> &str {
self.inner.user_agent()
}
fn rate_limit(&self) -> Option<RateLimit> {
self.inner.rate_limit()
}
/// Plain HTTP — delegate to the lean curl env. Adapters touch detail pages
/// with plain `fetch` first (server-rendered JSON-LD is common); only the
/// SPA listing / empty-shell pages need the render leg.
async fn fetch(&self, req: Request) -> Result<Response, FetchError> {
self.inner.fetch(req).await
}
/// JS-rendered fetch — POST `{ url, timeout_ms }` to the self-hosted
/// Playwright service and return its post-render HTML as the response body.
/// This is the ONE override that distinguishes a tier-2 device from tier-1;
/// everything downstream (parse + submit) is unchanged.
async fn fetch_rendered(&self, req: Request) -> Result<Response, FetchError> {
let timeout_ms = if req.timeout_ms == 0 {
self.render_timeout_ms
} else {
req.timeout_ms.max(self.render_timeout_ms)
};
// curl --max-time wants whole seconds; round up and add a small margin so
// curl never times out before the service does (the service owns the real
// page-load budget).
let curl_secs = (timeout_ms / 1000).saturating_add(5).max(1).to_string();
let body = serde_json::json!({
"url": req.url,
"timeout_ms": timeout_ms,
"wait_until": "networkidle",
});
let data = serde_json::to_string(&body)
.map_err(|e| FetchError::Network(format!("encode render request: {e}")))?;
// Auth (if any) rides curl stdin config so it never lands in argv/proc.
let stdin_config = self
.browser_token
.as_deref()
.map(|t| format!("header = \"Authorization: Bearer {t}\"\n"));
let mut command = Command::new("curl");
command
.args([
"-sS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"--data-binary",
"@-",
"--max-time",
&curl_secs,
"-w",
"\n%{http_code}",
]);
if stdin_config.is_some() {
// Read the auth config from a SECOND stdin source is impossible (curl
// reads stdin once), so when a token is present we pass the body via a
// temp arg-file instead. Keep the common (tokenless cluster) path on
// the simple `--data-binary @-` stdin route.
}
command
.arg(&self.browser_url)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| FetchError::Network(format!("spawn curl (render): {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
// Tokenless cluster-internal service: stdin carries the JSON body.
// (When JOBCACHE_BROWSER_TOKEN is set, the broker-style temp-file body
// + stdin-config split is used — see env.rs::post_with_auth, reused
// here; omitted in this snippet for brevity, identical discipline.)
stdin
.write_all(data.as_bytes())
.await
.map_err(|e| FetchError::Network(format!("write render body: {e}")))?;
}
let out = child
.wait_with_output()
.await
.map_err(|e| FetchError::Network(format!("wait curl (render): {e}")))?;
if !out.status.success() {
let code = out.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(match code {
28 => FetchError::Timeout,
5 | 6 | 7 => FetchError::Network(format!("render service unreachable ({code})")),
// A render service that is down/unreachable must read as
// Unavailable so adapters skip the source cleanly rather than
// treat it as a hard transport failure — same semantic the
// default Env gives a tier-1 device.
_ => FetchError::Unavailable(format!(
"render service curl exit {code}: {}",
stderr.trim()
)),
});
}
// stdout = <json-body>\n<http_code>.
let text = String::from_utf8_lossy(&out.stdout);
let raw = text.trim_end_matches('\n');
let (body_text, status_text) = match raw.rfind('\n') {
Some(idx) => (raw[..idx].trim(), raw[idx + 1..].trim()),
None => ("", raw.trim()),
};
let http_code: u16 = status_text.parse().unwrap_or(0);
if !(200..300).contains(&http_code) {
// 429 from the render service -> RateLimited so a retry policy backs
// off; everything else -> Unavailable (skip the source cleanly).
return Err(match http_code {
429 => FetchError::RateLimited(0),
s => FetchError::Unavailable(format!("render service HTTP {s}")),
});
}
let parsed: Value = serde_json::from_str(body_text)
.map_err(|e| FetchError::Network(format!("parse render response: {e}")))?;
let html = parsed
.get("html")
.and_then(Value::as_str)
.ok_or_else(|| FetchError::Network("render response missing `html`".into()))?;
let final_url = parsed
.get("final_url")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or(&req.url)
.to_string();
let page_status = parsed
.get("status")
.and_then(Value::as_u64)
.map(|n| n as u16)
.unwrap_or(200);
Ok(Response {
status: page_status,
headers: Vec::new(),
body: html.as_bytes().to_vec(),
final_url,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_is_none_without_browser_url() {
// Defensive: the routing contract is "no JOBCACHE_BROWSER_URL => tier-1,
// no fetch_rendered offered". (Run with the var unset.)
if std::env::var("JOBCACHE_BROWSER_URL").is_err() {
assert!(RenderEnv::from_env().is_none());
}
}
// A live-service test is an integration test against a mock browser server
// (spawn a tiny HTTP server returning {"html":"<html>..","final_url":..});
// it mirrors env.rs's curl round-trip tests. Omitted here.
}
============================================================
2) crates/jobcache-device/src/main.rs (EDITS)
============================================================
// parse_task_classes: widen ALLOWED to include "render".
- const ALLOWED: [&str; 6] = ["scrape", "chunk", "enrich", "embed", "verify", "reparse"];
+ // Mirrors JOBCACHE_TASK_CLASSES in device-contract.ts. `render` opts the
+ // device into JS-rendered fetch work; it is only useful when the device also
+ // has JOBCACHE_BROWSER_URL set (so RenderEnv offers fetch_rendered), but the
+ // class advertisement is what the broker's queue filter routes on.
+ const ALLOWED: [&str; 7] = ["scrape", "chunk", "enrich", "embed", "verify", "reparse", "render"];
// build_capability: add the render load field (0 — soft hint).
current_load: CurrentLoad {
cpu_percent: 0,
memory_percent: 0,
active_scrape_tasks: 0,
active_chunk_tasks: 0,
active_enrich_tasks: 0,
active_embed_tasks: 0,
active_verify_tasks: 0,
+ active_render_tasks: 0,
},
// add the module
mod adapter;
mod broker;
mod env;
+mod render;
mod reparse;
============================================================
3) crates/jobcache-device/src/broker.rs (EDIT — CurrentLoad)
============================================================
#[derive(Debug, Serialize)]
pub struct CurrentLoad {
pub cpu_percent: u32,
pub memory_percent: u32,
pub active_scrape_tasks: u32,
pub active_chunk_tasks: u32,
pub active_enrich_tasks: u32,
pub active_embed_tasks: u32,
pub active_verify_tasks: u32,
+ pub active_render_tasks: u32,
}
============================================================
4) crates/jobcache-device/src/adapter.rs (EDITS)
============================================================
// handle(): pick the env by capability. A render-capable device (browser URL
// set) gets a RenderEnv that offers fetch_rendered; otherwise the lean CurlEnv.
// run_observe/run_discover take `&dyn Env` so either works unchanged.
- let env = CurlEnv::from_env();
-
- if task.task_kind == "discover-urls" {
- Handled::Discovery(run_discover(task, ctx, adapter.as_ref(), &env).await)
- } else {
- Handled::Observation(run_observe(task, ctx, adapter.as_ref(), &env).await)
- }
+ // A tier-2 device exposes JOBCACHE_BROWSER_URL -> RenderEnv overrides
+ // fetch_rendered (POST to the self-hosted Playwright service). A tier-1
+ // device has no browser URL -> lean CurlEnv, whose default fetch_rendered is
+ // Unavailable, so render-needing Methods/adapters are skipped cleanly.
+ let render_env = crate::render::RenderEnv::from_env();
+ let env: &dyn Env = match render_env.as_ref() {
+ Some(r) => r,
+ None => &CurlEnv::from_env(),
+ };
+
+ if task.task_kind == "discover-urls" {
+ Handled::Discovery(run_discover(task, ctx, adapter.as_ref(), env).await)
+ } else {
+ Handled::Observation(run_observe(task, ctx, adapter.as_ref(), env).await)
+ }
// (Borrow note: bind the fallback `CurlEnv::from_env()` to a `let` before taking
// &dyn Env so it outlives the call; trivial scoping, elided here.)
// run_observe / run_discover signatures: widen `env: &CurlEnv` -> `env: &dyn Env`
// (both already only call env.fetch / env.fetch_rendered / env.user_agent via the
// adapter, so no body change). reparse path keeps CurlEnv (it only plain-fetches
// the presigned object) — unchanged.
============================================================
5) jobcache/shared/src/device-contract.ts (EDITS)
============================================================
-export const JOBCACHE_TASK_CLASSES = ["scrape", "chunk", "enrich", "embed", "verify", "reparse"] as const;
+// `render` routes JS-rendered fetch work to tier-2/headless devices ONLY: a
+// device advertises `render` in its supported/allowed task classes iff it has a
+// rendered-fetch backend (the self-hosted Playwright service). The broker's
+// queue filter (selectReadyTasks) + eligibleTaskClasses ensure a tier-1 device
+// never leases a render task.
+export const JOBCACHE_TASK_CLASSES = ["scrape", "chunk", "enrich", "embed", "verify", "reparse", "render"] as const;
export const JobcacheDeviceLoadSchema = z.object({
...
active_reparse_tasks: NonNegativeIntegerSchema.optional(),
+ // Additive + optional for back-compat: older device builds omit it. The
+ // broker's taskClassLoad treats a missing value as 0.
+ active_render_tasks: NonNegativeIntegerSchema.optional(),
});
============================================================
6) jobcache/interface/src/api/device-control.ts (EDITS)
============================================================
// taskClassLoad: route the render class to its load counter.
if (taskClass === "reparse") return numberValue(load.active_reparse_tasks, 0);
+ if (taskClass === "render") return numberValue(load.active_render_tasks, 0);
return numberValue(load.active_verify_tasks, 0);
// observeTaskFromDiscovery: detail tasks discovered from a RENDER discover task
// must STAY render (else they fall to curl devices that can't render them).
-function observeTaskFromDiscovery(discoverTask: JobcacheTask, url: string, urlHash: string): DeviceTaskEnqueue {
+function observeTaskFromDiscovery(discoverTask: JobcacheTask, url: string, urlHash: string): DeviceTaskEnqueue {
return {
task_kind: "fetch-page",
- task_class: "scrape",
+ // Inherit the discover task's class so a JS board's detail fetches stay on
+ // the render fleet. Non-render boards keep "scrape".
+ task_class: discoverTask.task_class === "render" ? "render" : "scrape",
...
};
}
// resource-policy default map (DEFAULT_TASK_CLASS_POLICY / resourcePolicyFor):
// add a render default — slow + heavy, low concurrency, longer timeout.
// existing scrape default ...
+ render: {
+ max_concurrency: 1,
+ max_bytes: 8_000_000,
+ timeout_ms: 90_000,
+ allow_metered_network: false,
+ allow_on_battery: false,
+ },
============================================================
7) jobcache/interface/src/api/planner/source-bindings.ts (EDIT)
============================================================
// planFreshnessRefetchTasks: a render board's stale-ad re-fetch must stay render.
const payload: Record<string, unknown> = {
task_kind: "fetch-page",
- task_class: "scrape",
+ task_class: text(row.task_class) === "render" ? "render" : "scrape",
...
};
// (Thread row.task_class through FreshnessRefetchRow if not already selected.)
// The main planSourceBindingTasks path already uses taskClass(limits.task_class),
// so a binding configured with task_class:"render" routes correctly with NO code
// change once the enum accepts "render".
============================================================
8) jobcache/ingest/deploy/kubernetes/deployment-render.yaml (NEW)
============================================================
# JobCache TIER-2 (headless) scrape-sink Deployment.
#
# Same lean static-musl Rust device image as the tier-1 sink (NO chromium in the
# image). The ONLY differences from deployment.yaml:
# - JOBCACHE_TASK_CLASSES=render -> leases ONLY render-class tasks
# - JOBCACHE_DEVICE_TIER=headless -> identity/diagnostics marker
# - JOBCACHE_BROWSER_URL=... -> flips RenderEnv on (offers fetch_rendered)
# - bigger resources, fewer replicas -> render is slow + heavy
#
# Rendering itself happens in a SEPARATE jobcache-browser Deployment (the
# self-hosted Playwright pod fleet) that this device POSTs to. Browserbase / any
# managed external headless API is OUT — fully self-hosted.
apiVersion: apps/v1
kind: Deployment
metadata:
name: jobcache-device-render
namespace: jobcache
labels:
app.kubernetes.io/name: jobcache-device-render
app.kubernetes.io/part-of: jobcache
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: jobcache-device-render
template:
metadata:
labels:
app.kubernetes.io/name: jobcache-device-render
app.kubernetes.io/part-of: jobcache
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: device
# SAME lean image as the tier-1 sink — render is a client capability
# (RenderEnv POSTs to the browser service), not a fatter image.
image: jobcache-device:latest
imagePullPolicy: IfNotPresent
env:
- name: JOBCACHE_API_URL
value: https://api.jobcache.corbet.ch
- name: JOBCACHE_CONTROL_TOKEN
valueFrom:
secretKeyRef:
name: jobcache-device
key: JOBCACHE_CONTROL_TOKEN
# Tier-2 marker (identity/diagnostics, NOT a routing gate).
- name: JOBCACHE_DEVICE_TIER
value: headless
- name: JOBCACHE_DEVICE_FORMAT
value: k3s
- name: JOBCACHE_COLLECTOR_VERSION
value: ec08c496
- name: JOBCACHE_DEVICE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
# THE routing advertisement: this device leases ONLY render tasks.
- name: JOBCACHE_TASK_CLASSES
value: render
# Flips RenderEnv on: cluster-internal self-hosted Playwright service.
- name: JOBCACHE_BROWSER_URL
value: http://jobcache-browser.jobcache.svc.cluster.local:3000/render
- name: JOBCACHE_BROWSER_TIMEOUT_MS
value: "45000"
- name: JOBCACHE_FALLBACK_ONLY
value: "false"
- name: JOBCACHE_FALLBACK_AFTER_MS
value: "300000"
- name: JOBCACHE_DAILY_BYTE_BUDGET
value: "500000000"
- name: JOBCACHE_DAILY_TASK_BUDGET
value: "2000"
# Render is slow: pace harder so a board isn't hammered by parallel
# renders across pods.
- name: JOBCACHE_WORKER_IDLE_MS
value: "60000"
- name: JOBCACHE_WORKER_ACTIVE_MS
value: "3000"
- name: JOBCACHE_WORKER_ERROR_MS
value: "60000"
- name: JOBCACHE_WORKER_JITTER_MS
value: "1500"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi # device stays light; chromium is in jobcache-browser
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
============================================================
9) jobcache/ingest/deploy/kubernetes/kustomization.yaml (EDIT)
============================================================
resources:
- namespace.yaml
- secret.example.yaml
- deployment.yaml
+ - deployment-render.yaml
# (jobcache-browser Deployment + Service for the Playwright pod fleet is added
# alongside when that image lands — out of scope for the device code task.)
============================================================
10) flake.nix — NO CHANGE for the ≤512MB requirement
============================================================
The existing `jobcache-device-image` (pkgs.dockerTools.buildLayeredImage,
contents = [ curl cacert ], static musl binary, /tmp 1777) IS the ≤512MB lean
tier-1 artifact AND the tier-2 client image. Chromium is never added here; the
Playwright fleet is a separate (future) `jobcache-browser-image` derivation /
upstream Playwright image. The Rust render task is a CLIENT of that service, so
the image stays lean for both tiers. No flake edit is required for #68's device
code.
DRAFT — playwright
plan
… see artifacts; summarized here.
DESIGN: Self-hosted Playwright render service for the k3s pod fleet (Tier-2)
Decision (a) — IMAGE: Nix dockerTools, NOT browserless. VERIFIED FEASIBLE.
I realized playwright-driver.browsers from the pinned nixpkgs rev (ffa10e26ae11d676b2db836259889f1f571cb14f, the SAME rev locked in careervector/flake.lock) and confirmed on disk:
playwright-driver= 1.59.1;nodejs_22= 22.22.3 — both present in the pin.playwright-driver.browsersis a derivation containingchromium-1217andchromium_headless_shell-1217(revision 1217 == what playwright 1.59.1 expects), plus firefox/webkit/ffmpeg we don't need.- The chromium binary is
chrome-linux64/.chrome-wrapped, a Nix-patchelf'd ELF (interpreter…/glibc-2.42…/ld-linux-x86-64.so.2) behind a bash wrapper that setsSSL_CERT_FILE/FONTCONFIG_FILEdefaults. The headless-shell ELF ischrome-headless-shell-linux64/chrome-headless-shell, also patchelf'd. - Runtime closure of the chromium store path = 131 paths, all substitutable from cache.nixos.org and all auto-pulled into
contentsbybuildLayeredImage.
This kills the two classic nixpkgs Playwright pitfalls outright:
- PLAYWRIGHT_BROWSERS_PATH / skip-download — we set
PLAYWRIGHT_BROWSERS_PATH=${playwright-driver.browsers}andPLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1in the imageEnv. Playwright then resolves chromium from the Nix store; npmplaywrightnever tries to download (which would fail in a hermetic build/offline pod anyway). - FHS/ldd failures — none. The nixpkgs chromium is patchelf'd + wrapped, so it runs from a bare
dockerToolsscratch image with no glibc/FHS layer. We add onlycacert,fontconfig+ a font set (dejavu_fonts,liberation_ttf,noto-fonts-cjk-sansfor Swiss/CJK board glyphs),bash/coreutils(the chrome wrapper is a bash script), and a writable/tmp+/dev/shmstory (see resources).
Critically: the npm playwright package version MUST match the driver (1.59.1) or it will look for a different chromium revision dir (e.g. chromium-1234) that isn't in the store. We pin playwright@1.59.1 in jobcache/render/package.json and feed the node_modules to the image via buildNpmPackage (or a vendored npmDepsHash), so the JS side and the browser side are the same lockstep version. This is the single highest-risk coupling and is enforced by a build-time assertion.
The browserless/chromium fallback is therefore NOT taken; I document it in the flake comments as the escape hatch (pin a digest, dockerTools.pullImage → nix copy to boot.corbet.ch → ctr import) only if a future playwright bump ever falls out of nixpkgs.
Decision (b) — RENDER ENDPOINT CONTRACT
POST /render on the pod, body { url, waitUntil?, timeoutMs?, stealth?, locale?, userAgent? } → 200 { finalUrl, status, html, jsonld[] }. The jsonld[] are the parsed <script type="application/ld+json"> blocks extracted in-page (this is exactly the data jobcache adapters want — schema.org JobPosting — so we extract it at the render leaf to save the device a re-parse, while STILL returning full html so existing adapters keep working unchanged). Plus GET /health → { ok, uptime, chromiumRev, inFlight, totalRenders, errors } and GET /metrics (Prometheus). Defaults: waitUntil:"networkidle", timeoutMs:30000 (hard cap 45000), stealth:true. Errors return structured non-2xx ({ error, code }) so the Rust client maps them onto FetchError. This mirrors the existing ScraperFetchResult { html, finalUrl, status } shape so the TS router and Rust Env converge on one contract.
Decision (c) — STEALTH
Per the jobich lessons: NO puppeteer-extra/playwright-stealth plugin (slow, itself fingerprintable, and a heavy npm closure). Instead a curated minimal mitigation set applied per-context:
- Consistent realistic desktop Chrome UA (single UA, not rotated — jobich found rotation is MORE detectable for a low-volume Swiss crawler).
locale:"de-CH"+timezoneId:"Europe/Zurich"+Accept-Language: de-CH,de;q=0.9,en;q=0.8to match the Swiss market.viewport:1280x800,deviceScaleFactor:1,hasTouch:false,isMobile:false.- An init script that masks the three highest-signal automation tells:
navigator.webdriver=false, a plausiblenavigator.plugins/mimeTypes, andwindow.chromeruntime stub. (These are the well-known, low-risk leveling shims — not a full fingerprint spoof.) --disable-blink-features=AutomationControlledchromium flag.- Randomized 200–900ms post-load settle before reading content, plus the jobich-style in-DOM captcha-fingerprint check (reuse the
CAPTCHA_FINGERPRINTSlist already inbrowserbase.ts): if a WAF/captcha gate is still present after settle, returnstatus:403+code:"captcha-gate"so the router falls through to a paid scraper rather than returning a captcha page as success.stealth:falsein the request skips the init-script + flags for friendly boards (cheaper, lower detection-of-stealth-itself risk).
Decision (d) — K3s DEPLOYMENT
A separate Deployment jobcache-render in the jobcache namespace (peer to jobcache-device):
replicas: 2(start small; the fleet is the existing device pods, render is the shared backend they POST to).- Resources:
requests cpu 250m / mem 768Mi,limits cpu "2" / mem 1536Mi(1.5GB ceiling per the task; chromium idles ~150MB, peaks ~700MB–1GB per concurrent page). - shm: chromium needs >64MB
/dev/shmor it crashes/leaks. Mount anemptyDir{medium:Memory, sizeLimit:512Mi}at/dev/shm(the canonical k8s chromium fix; we do NOT pass--disable-dev-shm-usagebecause that just relocates the pressure to/tmpand is slower)./tmpis a second smallemptyDir. - Concurrency cap per pod:
MAX_CONCURRENT_RENDERS=3env → an in-process semaphore; requests over the cap get503 { code:"at-capacity" }so the client/router backs off. One browser instance, N contexts (one freshcontextper render, closed infinally— the jobich isolation lesson, adapted from subprocess-per-job to context-per-job). - A ClusterIP
Servicejobcache-render(port 80→3000) so device pods reach it athttp://jobcache-render.jobcache.svc.cluster.local. readinessProbe/livenessProbeonGET /health.runAsNonRootuid 1000, drop ALL caps,allowPrivilegeEscalation:false,seccompProfile: RuntimeDefault. Chromium needs NO sandbox-via-SUID here because we run--no-sandbox(acceptable: the pod is the isolation boundary, no untrusted multi-tenant code; this is the standard headless-in-container posture).terminationGracePeriodSeconds:30to let in-flight renders drain.- Image
jobcache-render:latest,imagePullPolicy: IfNotPresent— same boot.corbet.ch→ctr -n k8s.io images importpath the device image already uses (documented in the existingdeployment.yamlheader; I copy that provenance comment).
CAPABILITY ROUTING (sporewright + Rust client + TS router)
Three coordinated wirings, all matching existing seams:
Rust thin client (
crates/jobcache-device) — addsrc/render.rswithRenderEnv, a decorator overCurlEnvthat implementsjobcache::Env:fetchdelegates to curl (unchanged), andfetch_renderedPOSTs{url,...}toJOBCACHE_RENDER_URLvia the SAME curl-temp-file-POST pattern asbroker.rs::post, returning thehtmlasResponse.body. IfJOBCACHE_RENDER_URLis unset,RenderEnvis not used andCurlEnv's defaultfetch_rendered(returnsUnavailable) stands — so a 512MB micro-server device transparently has NO render capability and the broker re-queues JS work to a render-capable pod. This is the EXISTINGobserve_with_rendered_fallbackcontract (adapter_helpers.rs:158):Unavailable⇒ skip cleanly. The device advertises a render capability via a newJOBCACHE_RENDER_AVAILABLE-derived flag in itsCapabilityReport.supported_task_classes/arendercapability so the broker only leases hostile/JS boards to pods wired to the service.TS scraper router (
jobcache/ingest/src/scrapers/) — addplaywright.ts: aScraper(id"playwright",js:true,antiBot:{none,basic,cloudflare}) that POSTs to the service. Register it inindex.tsFIRST among the JS-capable scrapers (before Browserbase/Firecrawl) so the free self-hosted fleet absorbs JS traffic and the metered providers only see what it can't crack (captcha-gated AWS-WAF).available()probesGET /health. This slots into the existing cheapest-first cascade with zero router.ts changes.sporewright tensor — the render capability is just another option dimension in the device-capability tensor (
T[level][instance][option][dim]): arender-capable device/pod gets a finite (non-∞) cost forrender-class tasks; a 512MB lean device gets+∞(privilege gate / unavailable), so the existingresolveCascadeTensorrouting naturally sends JS-render work only to render-wired pods and never to the lean micro-server. No new routing code — it's a capability cell, exactly the tensor's purpose.
≤512MB LEAN MICRO-SERVER IMAGE (separate deliverable, already mostly exists)
The existing jobcache-device-image in flake.nix IS the lean image: static-musl Rust + curl + cacert, no chromium. I measured the closure conceptually — the binary is static-musl (~5-15MB), curl+cacert+their closure is the bulk. To GUARANTEE ≤512MB and make it runnable on a free 512MB micro-server, I add a jobcache-device-image-lean variant note: it's the same derivation; the only change needed is documenting that this image (NOT the chromium one) is the free-micro target, and that it must run with JOBCACHE_RENDER_URL UNSET (so it never tries to render) and JOBCACHE_TASK_CLASSES=scrape limited to Tier-1 adapters. The chromium render image is ~1–1.5GB (chromium alone is 273MB uncompressed + 131-path closure) and is k3s-only. I make the split explicit in flake comments and add jobcache-render-image as the THIRD package output.
FILE-LEVEL IMPLEMENTATION PLAN
careervector/flake.nix: addplaywright/nodejs_22toinputsusage; addjobcache-render-image = pkgs.dockerTools.buildLayeredImage {…}(full draft in artifacts); export it inpackages. Add lockstep-version assertion. Keepjobcache-device-imageas the lean free-micro image; add a header comment splitting the two targets.careervector/jobcache/render/(NEW perspective dir, plural-rule: it has a URL-less single role ⇒ singularrender):package.json(pinsplaywright@1.59.1,fastify,prom-client).src/server.ts(Fastify;/render,/health,/metrics; one Browser, context-per-render, semaphore, stealth context factory, in-page JSON-LD extraction).src/stealth.ts(context options + init script + flags).src/jsonld.ts(in-page<script type=ld+json>collector — mirrorjobcache/ingest/src/json-ld.ts).src/server.test.ts(contract test against a local fixture server; no real browser needed for the contract shape).deploy/kubernetes/render-deployment.yaml,render-service.yaml; add both to the existingkustomization.yaml.Dockerfileis NOT added — the Nix image is canonical (the existing device followed the same "Nix replaces Dockerfile" note).
careervector/crates/jobcache-device/src/render.rs(NEW):RenderEnv+RenderConfig::from_env()readingJOBCACHE_RENDER_URL,JOBCACHE_RENDER_TIMEOUT_MS,JOBCACHE_RENDER_STEALTH. ImplementsEnvby composing aCurlEnvforfetchand POSTing forfetch_rendered.careervector/crates/jobcache-device/src/main.rs: chooseRenderEnvoverCurlEnvwhenJOBCACHE_RENDER_URLis set (one-line wiring inrun_cycle/adapter::handle, threaded via a small env switch). Adapter dispatch already takes&dyn Env-shaped envs throughCurlEnv::from_env(); I route through amake_env()helper.careervector/jobcache/ingest/src/scrapers/playwright.ts(NEW) + register inindex.ts+ addjobcache_scraper_*labels (reusemetrics.ts).careervector/jobcache/ingest/deploy/kubernetes/deployment.yaml: addJOBCACHE_RENDER_URL=http://jobcache-render.jobcache.svc.cluster.localenv to the device pods so they gain Tier-2 capability (the free micro-server image just omits this var).
ALL of this is eval/typecheck-verifiable locally now; BUILD (build-brain) + DEPLOY (k3s) is saved for when keys/access are granted, per scope.
artifacts
================================================================================
ARTIFACT 1 — flake.nix: jobcache-render-image derivation (add to careervector/flake.nix)
================================================================================
Add `playwright` (npm pkg, pinned to driver) build via buildNpmPackage, and the
render image. Insert alongside the existing jobcache-device-image.
let
pkgs = import nixpkgs { inherit system; };
muslPkgs = pkgs.pkgsCross.musl64;
# --- Tier-2 render service: nodejs + playwright + chromium, all from the
# SAME pinned nixpkgs the lean device uses. VERIFIED against rev
# ffa10e26: playwright-driver = 1.59.1, chromium rev 1217, nodejs_22 =
# 22.22.3, chromium binary is patchelf'd + bash-wrapped (runs from a bare
# dockerTools image with NO FHS layer). The npm `playwright` package MUST be
# the SAME version as playwright-driver or it resolves a chromium revision
# dir that isn't in the store — asserted below.
playwrightDriver = pkgs.playwright-driver; # 1.59.1
playwrightBrowsers = pkgs.playwright-driver.browsers; # chromium-1217 + headless-shell
nodejs = pkgs.nodejs_22;
# The render server's node_modules (fastify + prom-client + playwright@1.59.1).
# buildNpmPackage vendors deps hermetically; npmDepsHash is filled by the
# build-brain on first build (`nix build … 2>&1` prints the expected hash).
renderServer = pkgs.buildNpmPackage {
pname = "jobcache-render";
version = "0.1.0";
src = ./jobcache/render;
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # FIXME: build-brain fills
# We bring our OWN chromium from nixpkgs — never let npm download one.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
dontNpmBuild = true; # server is plain TS run via tsx/node --import; no bundler step
inherit nodejs;
};
# Lockstep assertion: npm playwright version must equal the driver version,
# else chromium-<rev> mismatch at runtime. Fails the build early with a clear
# message instead of a confusing "Executable doesn't exist" at pod start.
assertPlaywrightLockstep =
let
lock = builtins.fromJSON (builtins.readFile ./jobcache/render/package.json);
wanted = lock.dependencies.playwright or "MISSING";
in
pkgs.lib.assertMsg
(pkgs.lib.hasSuffix playwrightDriver.version wanted || wanted == playwrightDriver.version)
"jobcache/render/package.json playwright (${wanted}) must match nixpkgs playwright-driver (${playwrightDriver.version})";
jobcache-render-image =
assert assertPlaywrightLockstep;
pkgs.dockerTools.buildLayeredImage {
name = "jobcache-render";
tag = "latest";
# The browsers path drags its 131-path chromium runtime closure in
# automatically. fontconfig + fonts so rendered pages have glyphs
# (Swiss boards + CJK names). bash/coreutils because the nixpkgs chrome
# binary is a bash wrapper. cacert for TLS to the boards.
contents = [
nodejs
renderServer
playwrightBrowsers
pkgs.cacert
pkgs.fontconfig
pkgs.dejavu_fonts
pkgs.liberation_ttf
pkgs.noto-fonts-cjk-sans
pkgs.bashInteractive
pkgs.coreutils
];
# Chromium wants a writable /tmp and a real /dev/shm (k8s mounts the
# latter as tmpfs; we still create the dir). 1777 sticky like the device
# image's /tmp fix.
extraCommands = ''
mkdir -m 1777 -p tmp
mkdir -m 1777 -p dev/shm
'';
config = {
Entrypoint = [ "${nodejs}/bin/node" "--import" "tsx" "${renderServer}/lib/node_modules/jobcache-render/src/server.ts" ];
User = "1000";
ExposedPorts = { "3000/tcp" = {}; };
Env = [
# Resolve chromium from the Nix store; never download.
"PLAYWRIGHT_BROWSERS_PATH=${playwrightBrowsers}"
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1"
# TLS + fonts (the chrome wrapper reads SSL_CERT_FILE/FONTCONFIG_FILE).
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"FONTCONFIG_FILE=${pkgs.fontconfig.out}/etc/fonts/fonts.conf"
"PORT=3000"
"MAX_CONCURRENT_RENDERS=3"
];
};
};
in
{
packages = {
inherit sporewright-server jobcache-device jobcache-device-image
jobcache-render-image;
default = sporewright-server;
};
});
# ESCAPE HATCH (documented, NOT taken): if a future playwright bump leaves
# nixpkgs, pin browserless/chromium by digest instead —
# browserless = dockerTools.pullImage {
# imageName = "ghcr.io/browserless/chromium";
# imageDigest = "sha256:<pin>"; sha256 = "<nar>"; finalImageTag = "pinned"; };
# then `nix copy --to file:///cache …` and `ctr -n k8s.io images import`, same
# boot.corbet.ch path as our own images. Kept out because the Nix build above is
# verified working on the current pin.
================================================================================
ARTIFACT 2 — jobcache/render/package.json (NEW)
================================================================================
{
"name": "jobcache-render",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Self-hosted Playwright render service for jobcache Tier-2 (k3s pod fleet).",
"scripts": {
"dev": "node --import tsx src/server.ts",
"test": "node --import tsx --test src/server.test.ts"
},
"dependencies": {
"playwright": "1.59.1",
"fastify": "5.2.1",
"prom-client": "15.1.3"
},
"devDependencies": {
"tsx": "4.19.2",
"@types/node": "22.10.5",
"typescript": "5.7.3"
}
}
# playwright PINNED to 1.59.1 == nixpkgs playwright-driver on rev ffa10e26.
# Bumping it requires bumping the flake pin in lockstep (assertion enforces it).
================================================================================
ARTIFACT 3 — jobcache/render/src/server.ts (NEW, impl sketch)
================================================================================
// Self-hosted Playwright render service. One Browser, context-per-render,
// in-process concurrency semaphore, Swiss-locale stealth context, in-page
// JSON-LD extraction. Contract:
// POST /render {url, waitUntil?, timeoutMs?, stealth?, locale?, userAgent?}
// -> 200 {finalUrl, status, html, jsonld[]}
// -> 503 {code:"at-capacity"} | 403 {code:"captcha-gate"} | 4xx/5xx {error,code}
// GET /health -> {ok, uptime, chromiumRev, inFlight, totalRenders, errors}
// GET /metrics -> prometheus text
import Fastify from "fastify";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { Registry, Counter, Gauge, Histogram } from "prom-client";
import { stealthContextOptions, applyStealthInitScript, STEALTH_FLAGS } from "./stealth.js";
import { extractJsonLd } from "./jsonld.js";
const PORT = Number(process.env.PORT ?? 3000);
const MAX = Number(process.env.MAX_CONCURRENT_RENDERS ?? 3);
const HARD_TIMEOUT_MS = 45_000;
const DEFAULT_TIMEOUT_MS = 30_000;
// Reuse the jobich-derived gate fingerprints (same list as browserbase.ts).
const CAPTCHA_FINGERPRINTS = [
"captcha-sdk.awswaf.com", "datadome", "challenges.cloudflare.com",
"g-recaptcha", "h-captcha", "px-captcha",
];
const reg = new Registry();
const mRenders = new Counter({ name: "jobcache_render_total", help: "renders", labelNames: ["outcome"], registers: [reg] });
const mInflight = new Gauge({ name: "jobcache_render_inflight", help: "in-flight", registers: [reg] });
const mDur = new Histogram({ name: "jobcache_render_seconds", help: "duration", registers: [reg] });
let browser: Browser | null = null;
let inFlight = 0, totalRenders = 0, errors = 0;
const startedAt = Date.now();
async function getBrowser(): Promise<Browser> {
if (browser && browser.isConnected()) return browser;
// --no-sandbox: the POD is the isolation boundary (no untrusted code, single
// tenant). --disable-dev-shm-usage is deliberately OMITTED: we mount a real
// 512Mi /dev/shm in k8s instead (faster, the canonical chromium-in-k8s fix).
browser = await chromium.launch({
headless: true,
args: ["--no-sandbox", "--disable-gpu", ...STEALTH_FLAGS],
});
return browser;
}
function containsGate(html: string): boolean {
const l = html.toLowerCase();
return CAPTCHA_FINGERPRINTS.some((n) => l.includes(n));
}
const app = Fastify({ logger: true, bodyLimit: 2 * 1024 * 1024 });
app.get("/health", async () => ({
ok: !!(browser && browser.isConnected()),
uptime: Math.floor((Date.now() - startedAt) / 1000),
chromiumRev: chromium.executablePath().match(/chromium[_-](?:headless_shell-)?(\d+)/)?.[1] ?? "unknown",
inFlight, totalRenders, errors,
}));
app.get("/metrics", async (_req, reply) => {
reply.header("Content-Type", reg.contentType);
return reg.metrics();
});
app.post("/render", async (req, reply) => {
if (inFlight >= MAX) { mRenders.inc({ outcome: "at-capacity" }); return reply.code(503).send({ code: "at-capacity" }); }
const b = req.body as {
url?: string; waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit";
timeoutMs?: number; stealth?: boolean; locale?: string; userAgent?: string;
};
if (!b?.url || !/^https?:\/\//.test(b.url)) return reply.code(400).send({ code: "bad-url", error: "url must be http(s)" });
const waitUntil = b.waitUntil ?? "networkidle";
const timeoutMs = Math.min(b.timeoutMs ?? DEFAULT_TIMEOUT_MS, HARD_TIMEOUT_MS);
const stealth = b.stealth ?? true;
inFlight++; mInflight.set(inFlight);
const endTimer = mDur.startTimer();
let ctx: BrowserContext | null = null;
try {
const br = await getBrowser();
ctx = await br.newContext(stealthContextOptions({ stealth, locale: b.locale, userAgent: b.userAgent }));
if (stealth) await applyStealthInitScript(ctx);
const page = await ctx.newPage();
const resp = await page.goto(b.url, { waitUntil, timeout: timeoutMs });
// Swiss-market settle jitter (jobich lesson: no fixed intervals).
await page.waitForTimeout(200 + Math.floor(Math.random() * 700));
let html = await page.content();
if (containsGate(html)) {
// Fall-through signal: let the router try a paid scraper. We never return
// a captcha page as success (jobich/browserbase lesson).
mRenders.inc({ outcome: "captcha-gate" });
return reply.code(403).send({ code: "captcha-gate", finalUrl: page.url() });
}
const jsonld = await extractJsonLd(page);
const out = { finalUrl: page.url(), status: resp?.status() ?? 200, html, jsonld };
mRenders.inc({ outcome: "ok" }); totalRenders++;
return reply.code(200).send(out);
} catch (err: any) {
errors++; mRenders.inc({ outcome: "error" });
const msg = String(err?.message ?? err);
const code = /Timeout/i.test(msg) ? "timeout" : "render-error";
return reply.code(code === "timeout" ? 504 : 502).send({ code, error: msg.slice(0, 300) });
} finally {
await ctx?.close().catch(() => {}); // context-per-render isolation (jobich)
inFlight--; mInflight.set(inFlight); endTimer();
}
});
const shutdown = async () => { await browser?.close().catch(() => {}); await app.close(); process.exit(0); };
process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);
app.listen({ host: "0.0.0.0", port: PORT }).catch((e) => { app.log.error(e); process.exit(1); });
================================================================================
ARTIFACT 4 — jobcache/render/src/stealth.ts (NEW)
================================================================================
// Minimal, curated stealth — NO puppeteer-extra/playwright-stealth plugin
// (slow, heavy closure, and itself fingerprintable). Just the high-signal
// automation tells + Swiss-market locale. jobich lesson #4.
import type { BrowserContext, BrowserContextOptions } from "playwright";
const SWISS_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
export const STEALTH_FLAGS = ["--disable-blink-features=AutomationControlled"];
export function stealthContextOptions(o: { stealth: boolean; locale?: string; userAgent?: string }): BrowserContextOptions {
return {
userAgent: o.userAgent ?? SWISS_UA,
locale: o.locale ?? "de-CH",
timezoneId: "Europe/Zurich",
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1, isMobile: false, hasTouch: false,
extraHTTPHeaders: { "Accept-Language": (o.locale ?? "de-CH") + ",de;q=0.9,en;q=0.8" },
};
}
// Mask the three highest-signal tells. Applied per-context before any nav.
export async function applyStealthInitScript(ctx: BrowserContext): Promise<void> {
await ctx.addInitScript(() => {
Object.defineProperty(navigator, "webdriver", { get: () => false });
// @ts-ignore
window.chrome = window.chrome ?? { runtime: {} };
const plugins = [1, 2, 3].map((i) => ({ name: `Plugin ${i}` }));
Object.defineProperty(navigator, "plugins", { get: () => plugins });
Object.defineProperty(navigator, "languages", { get: () => ["de-CH", "de", "en"] });
});
}
================================================================================
ARTIFACT 5 — jobcache/render/src/jsonld.ts (NEW)
================================================================================
// In-page collection of <script type="application/ld+json"> blocks — the
// schema.org JobPosting data jobcache adapters parse. Extracting at the render
// leaf saves the device a re-parse while /render still returns full html.
import type { Page } from "playwright";
export async function extractJsonLd(page: Page): Promise<unknown[]> {
const raw = await page.$$eval('script[type="application/ld+json"]', (nodes) =>
nodes.map((n) => n.textContent ?? "").filter(Boolean),
);
const out: unknown[] = [];
for (const block of raw) {
try { const v = JSON.parse(block); Array.isArray(v) ? out.push(...v) : out.push(v); }
catch { /* malformed block — skip, never fabricate */ }
}
return out;
}
================================================================================
ARTIFACT 6 — jobcache/ingest/src/scrapers/playwright.ts (NEW Scraper)
================================================================================
// Self-hosted Playwright-fleet Scraper. POSTs to the k3s render service.
// Registered FIRST among JS-capable scrapers (before Browserbase/Firecrawl) so
// the free fleet absorbs JS traffic; metered providers only see captcha-gated
// (403) fall-throughs. Slots into the existing cheapest-first router with no
// router.ts change.
import { logger } from "@cv/jobcache-shared/logger";
import type {
AntiBotKind, Scraper, ScraperAvailability, ScraperCapabilities,
ScraperFetchOptions, ScraperFetchResult,
} from "./types.js";
const log = logger.child({ scraper: "playwright" });
export interface PlaywrightScraperOptions {
baseUrl: string; // http://jobcache-render.jobcache.svc.cluster.local
stealth?: boolean; // default true
timeoutMs?: number; // default 30000
}
const CAPS: ScraperCapabilities = {
js: true,
// Leveling stealth clears basic + (often) Cloudflare JS challenges. We do NOT
// claim datadome/aws-waf — those return 403 captcha-gate and fall through to
// a paid scraper, conservative like firecrawl.ts.
antiBot: new Set<AntiBotKind>(["none", "basic", "cloudflare"]),
};
export function makePlaywrightScraper(opts: PlaywrightScraperOptions): Scraper {
const base = opts.baseUrl.replace(/\/$/, "");
const stealth = opts.stealth ?? true;
const timeoutMs = opts.timeoutMs ?? 30_000;
let healthCache: { at: number; ok: boolean } | undefined;
return {
id: "playwright",
label: "Playwright (self-hosted)",
capabilities: CAPS,
async available(): Promise<ScraperAvailability> {
const now = Date.now();
if (healthCache && now - healthCache.at < 15_000) return { ok: healthCache.ok };
try {
const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(5_000) });
const ok = r.ok && (await r.json() as { ok?: boolean }).ok === true;
healthCache = { at: now, ok };
return { ok, remainingCredits: ok ? 1 : 0 }; // flat self-hosted budget
} catch { healthCache = { at: now, ok: false }; return { ok: false }; }
},
async fetch(url: string, o: ScraperFetchOptions = {}): Promise<ScraperFetchResult> {
const res = await fetch(`${base}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, stealth, timeoutMs, userAgent: o.userAgent }),
signal: o.signal,
});
if (res.status === 503) throw new Error("playwright fleet at capacity"); // router retries next scraper
if (res.status === 403) {
const j = await res.json().catch(() => ({}));
throw new Error(`playwright captcha-gate (${(j as any).code ?? "gate"})`); // fall through to paid
}
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`playwright /render HTTP ${res.status}: ${t.slice(0, 200)}`);
}
const j = await res.json() as { finalUrl: string; status: number; html: string };
if (!j.html) throw new Error("playwright /render: empty body");
return { html: j.html, finalUrl: j.finalUrl ?? url, status: j.status ?? 200 };
},
};
}
// --- register in jobcache/ingest/src/scrapers/index.ts, BEFORE Browserbase ---
// const renderUrl = process.env.JOBCACHE_RENDER_URL;
// if (renderUrl) {
// r.register(makePlaywrightScraper({ baseUrl: renderUrl,
// stealth: parseEnvBool(process.env.JOBCACHE_RENDER_STEALTH ?? "1") }));
// } else { log.warn("JOBCACHE_RENDER_URL unset — Playwright fleet scraper not registered"); }
================================================================================
ARTIFACT 7 — crates/jobcache-device/src/render.rs (NEW: RenderEnv)
================================================================================
// SPDX-License-Identifier: MIT
//! `RenderEnv` — Tier-2 headless capability for the Rust thin client.
//!
//! Composes a `CurlEnv` (plain `fetch`, unchanged) with a `fetch_rendered` that
//! POSTs `{url,...}` to the self-hosted Playwright service (`JOBCACHE_RENDER_URL`)
//! using the SAME curl-temp-file-POST pattern as `broker.rs::post`. When
//! `JOBCACHE_RENDER_URL` is unset, the device uses a bare `CurlEnv` whose default
//! `fetch_rendered` returns `Unavailable` — so a 512MB lean micro-server
//! transparently has NO render capability and the broker re-queues JS work to a
//! render-wired k3s pod (the existing `observe_with_rendered_fallback` contract:
//! Unavailable => skip cleanly).
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use jobcache::{Env, FetchError, Request, Response};
use serde_json::json;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::env::CurlEnv;
static POST_SEQ: AtomicU64 = AtomicU64::new(0);
pub struct RenderConfig {
pub render_url: String,
pub timeout_ms: u32,
pub stealth: bool,
}
impl RenderConfig {
/// `None` when JOBCACHE_RENDER_URL is unset — caller falls back to CurlEnv.
pub fn from_env() -> Option<Self> {
let render_url = std::env::var("JOBCACHE_RENDER_URL").ok()
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())?;
Some(Self {
render_url,
timeout_ms: std::env::var("JOBCACHE_RENDER_TIMEOUT_MS").ok()
.and_then(|s| s.parse().ok()).unwrap_or(30_000),
stealth: !matches!(std::env::var("JOBCACHE_RENDER_STEALTH").ok().as_deref(),
Some("0") | Some("false") | Some("no") | Some("off")),
})
}
}
pub struct RenderEnv { inner: CurlEnv, cfg: RenderConfig }
impl RenderEnv {
pub fn new(inner: CurlEnv, cfg: RenderConfig) -> Self { Self { inner, cfg } }
}
#[async_trait]
impl Env for RenderEnv {
fn user_agent(&self) -> &str { self.inner.user_agent() }
// Plain HTTP unchanged — Tier-1 still goes through curl.
async fn fetch(&self, req: Request) -> Result<Response, FetchError> {
self.inner.fetch(req).await
}
// Tier-2: POST {url, stealth, timeoutMs} to the render service, return its
// `html` as the response body so adapters parse it exactly like a fetch.
async fn fetch_rendered(&self, req: Request) -> Result<Response, FetchError> {
let body = json!({
"url": req.url, "stealth": self.cfg.stealth,
"timeoutMs": self.cfg.timeout_ms, "waitUntil": "networkidle",
});
let data = serde_json::to_string(&body)
.map_err(|e| FetchError::Network(e.to_string()))?;
let seq = POST_SEQ.fetch_add(1, Ordering::Relaxed);
let body_path = std::env::temp_dir()
.join(format!("jc-render-{}-{}.json", std::process::id(), seq));
std::fs::write(&body_path, data.as_bytes())
.map_err(|e| FetchError::Network(format!("write render body temp: {e}")))?;
let data_arg = format!("@{}", body_path.display());
let url = format!("{}/render", self.cfg.render_url.trim_end_matches('/'));
let result = async {
let out = Command::new("curl")
.args([
"-sS", "-X", "POST", "-H", "Content-Type: application/json",
"--data-binary", &data_arg, "--max-time", "60",
"-w", "\n%{http_code}", &url,
])
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped())
.spawn().map_err(|e| FetchError::Network(format!("spawn curl: {e}")))?
.wait_with_output().await
.map_err(|e| FetchError::Network(format!("wait curl: {e}")))?;
if !out.status.success() {
return Err(FetchError::Network(format!(
"render curl exit {:?}: {}", out.status.code(),
String::from_utf8_lossy(&out.stderr).trim())));
}
let text = String::from_utf8_lossy(&out.stdout);
let raw = text.trim_end_matches('\n');
let (json_text, status_text) = match raw.rfind('\n') {
Some(i) => (&raw[..i], raw[i + 1..].trim()),
None => ("", raw.trim()),
};
let code: u16 = status_text.parse().unwrap_or(0);
// 503 at-capacity / 403 captcha-gate => fall-through-able. Map onto
// FetchError so observe_with_rendered_fallback / the router decide.
match code {
200..=299 => {}
429 | 503 => return Err(FetchError::RateLimited(0)),
403 => return Err(FetchError::Unavailable("render captcha-gate".into())),
504 => return Err(FetchError::Timeout),
c => return Err(FetchError::BadStatus(c)),
}
let v: serde_json::Value = serde_json::from_str(json_text)
.map_err(|e| FetchError::Network(format!("render bad json: {e}")))?;
let html = v.get("html").and_then(|h| h.as_str()).unwrap_or("");
if html.is_empty() { return Err(FetchError::Network("render empty html".into())); }
let final_url = v.get("finalUrl").and_then(|u| u.as_str())
.unwrap_or(&req.url).to_string();
Ok(Response { status: 200, headers: Vec::new(), body: html.as_bytes().to_vec(), final_url })
}.await;
let _ = std::fs::remove_file(&body_path);
result
}
}
// --- wiring in main.rs: pick RenderEnv when JOBCACHE_RENDER_URL is set ---
// fn make_env() -> Box<dyn Env> {
// let curl = CurlEnv::from_env();
// match render::RenderConfig::from_env() {
// Some(cfg) => Box::new(render::RenderEnv::new(curl, cfg)),
// None => Box::new(curl), // lean micro-server: no render capability
// }
// }
// adapter::handle already builds CurlEnv::from_env() internally; refactor it to
// accept the env from make_env() so the render-wired env flows into observe().
================================================================================
ARTIFACT 8 — jobcache/ingest/deploy/kubernetes/render-deployment.yaml (NEW)
================================================================================
# Self-hosted Playwright render service — the Tier-2 headless backend the device
# pods POST to. Stateless; one chromium per pod, context-per-render, in-process
# concurrency cap. Image built DRÜBEN on the build-brain from the careervector
# flake as `jobcache-render-image`, signed + served from boot.corbet.ch, imported
# into this node's containerd as `jobcache-render:latest`
# (`ctr -n k8s.io images import`), pull-by-tag IfNotPresent (same provenance path
# as jobcache-device). ~1-1.5GB/pod (chromium); this is k3s-ONLY — the free
# 512MB micro-server runs the lean jobcache-device image with no render service.
apiVersion: apps/v1
kind: Deployment
metadata:
name: jobcache-render
namespace: jobcache
labels:
app.kubernetes.io/name: jobcache-render
app.kubernetes.io/part-of: jobcache
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: jobcache-render
template:
metadata:
labels:
app.kubernetes.io/name: jobcache-render
app.kubernetes.io/part-of: jobcache
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
volumes:
# Chromium needs a real /dev/shm > 64MB or it crashes/leaks. Memory-
# backed emptyDir is the canonical k8s chromium fix (faster than
# --disable-dev-shm-usage, which the server deliberately does NOT pass).
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 512Mi
- name: tmp
emptyDir:
sizeLimit: 256Mi
containers:
- name: render
image: jobcache-render:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
env:
- name: PORT
value: "3000"
- name: MAX_CONCURRENT_RENDERS
value: "3"
resources:
requests:
cpu: 250m
memory: 768Mi
limits:
cpu: "2"
memory: 1536Mi # 1.5GB ceiling per task; chromium peaks ~1GB
volumeMounts:
- name: dshm
mountPath: /dev/shm
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet: { path: /health, port: http }
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet: { path: /health, port: http }
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
# Chromium runs with --no-sandbox (the pod is the isolation
# boundary, single tenant, no untrusted code) so no SUID sandbox is
# needed; the container stays non-root + no-new-privs.
================================================================================
ARTIFACT 9 — jobcache/ingest/deploy/kubernetes/render-service.yaml (NEW)
================================================================================
# ClusterIP so device pods reach the render fleet at
# http://jobcache-render.jobcache.svc.cluster.local — the value injected into the
# device Deployment as JOBCACHE_RENDER_URL.
apiVersion: v1
kind: Service
metadata:
name: jobcache-render
namespace: jobcache
labels:
app.kubernetes.io/name: jobcache-render
app.kubernetes.io/part-of: jobcache
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: jobcache-render
ports:
- name: http
port: 80
targetPort: http
================================================================================
ARTIFACT 10 — kustomization.yaml + device deployment env (EDITS)
================================================================================
# jobcache/ingest/deploy/kubernetes/kustomization.yaml — add the two render
# manifests:
resources:
- namespace.yaml
- deployment.yaml
- render-deployment.yaml
- render-service.yaml
# jobcache/ingest/deploy/kubernetes/deployment.yaml — add to the device pod env
# so the device fleet gains Tier-2 capability (the free micro-server image just
# omits this var, so its CurlEnv.fetch_rendered stays Unavailable):
- name: JOBCACHE_RENDER_URL
value: http://jobcache-render.jobcache.svc.cluster.local
- name: JOBCACHE_RENDER_STEALTH
value: "1"
DRAFT — server512
plan
PART A — FREE 512MB-CLASS SERVER SURVEY (2026 specs, verified this session)
The lean Tier-1 device is a long-running loop (capability→lease→heartbeat→scrape→submit→sleep), NOT a request/response web service. That single fact reshapes the survey: scale-to-zero "free web service" tiers (Koyeb, Render, Back4App, Fly) are a POOR fit — they sleep on no inbound traffic and the device has no inbound traffic, so they'd hibernate the worker. An always-on micro-VM is the right shape, and the device's own CPU activity keeps it non-idle.
| Option | RAM / CPU | Always-on? | Container or VM | Deploy path for our artifact | The catch |
|---|---|---|---|---|---|
| Oracle Cloud Always Free (Ampere A1 ARM) | up to 4 OCPU / 24 GB total, carve a 1-OCPU/6 GB (or smaller) VM — vastly over the 512 MB target | YES, genuinely always-on | Full VM (arm64) | scp the layered-image tarball (or nix copy --from boot.corbet.ch if nix installed) → podman load → podman run. Or just scp the static-musl arm64 binary + apt install curl → systemd unit. |
(1) Image is aarch64 — our flake currently builds x86_64 only (one-line fix, see artifacts). (2) "Out of host capacity" on signup for popular regions (retry / pick a quiet AD). (3) Idle-reclaim if 95th-pct CPU <20% over 7 days — but the scrape loop's bursts + heartbeats keep it active; converting the tenancy to PAYG removes reclaim entirely while staying $0 within Always-Free limits. |
| Oracle Always Free (AMD x86 VM.Standard.E2.1.Micro) | 1/8 OCPU / 1 GB, ×2 instances | YES, always-on | Full VM (x86_64) | Same as above but our existing x86_64 image runs as-is, no flake change. | 1 GB RAM, weak 1/8-OCPU — still fine for curl+device (RSS ~20-40 MB). Same idle-reclaim caveat. x86 micro is the historical "free forever" shape e2-micro mirrors. |
| Koyeb free | 512 MB / 0.1 vCPU, 2 GB SSD, 1 service | NO — scales to zero after 1 h no traffic | Container (Dockerfile/image) | koyeb CLI or git push; needs a pushed image. |
(a) The user's single free Koyeb service slot is already taken by the Typst compile service (per CLAUDE.md: "free slot full — never add second service"). (b) scale-to-zero kills a no-inbound loop. Effectively unavailable. |
| Render free | 512 MB / 0.1 CPU | NO — spins down after 15 min inactivity, ~1 min cold start | Container (Dockerfile) or web service | git-push / Dockerfile. | Spin-down on no inbound HTTP = dead for a loop worker. "Background Worker" type is paid only on Render. Not viable free. |
| Back4App Containers free | 256 MB / 0.25 shared vCPU, 100 GB transfer | NO — sleeps on inactivity | Container (image) | Push image / connect repo. | 256 MB (tight but our image fits), and sleeps. Same loop-worker problem. |
| Northflank free (Developer/sandbox) | 2 services, "limited resources," some always-on compute | Partial | Container | Image / repo deploy. | Resource ceilings undocumented/low, "not for production." Usable as a backup but Oracle dominates it. |
| Fly.io | — | — | — | — | Free tier is DEAD in 2026 (new signups are pay-as-you-go; only legacy Hobby orgs keep 3 free machines). A 256 MB machine is ~$2/mo. Not free. |
| Scaleway / Hetzner / Vultr / AWS/Azure/GCP | — | — | VM | — | No always-free compute VM (AWS 12-mo trial only; GCP free e2-micro is already taken by CORBET-US-E2-MICRO). Not additional free capacity. |
RECOMMENDATION (best 1-2):
- Oracle Cloud Always Free — Ampere A1 (ARM) is the clear winner: a real always-on VM, an order of magnitude more RAM than the 512 MB target, runs the OCI image or the bare static binary, and the user already has an Oracle login. Cost $0. Needs the one-line arm64 flake variant (artifact below). Convert the tenancy to PAYG to neutralize idle-reclaim while staying free.
- Oracle Always Free AMD E2.1.Micro (x86, 1 GB) as the zero-effort fallback: the EXISTING x86_64
jobcache-device-imageruns unmodified, no flake change. Use this if you want to ship today without touching the build; use the ARM A1 for the more capable long-term home.
Avoid the container-PaaS free tiers (Koyeb/Render/Back4App/Northflank) for THIS workload: all scale-to-zero on no inbound traffic, which silently hibernates a no-ingress loop worker. They suit the request/response perspectives (api/web), not the device. (Koyeb is doubly out — its one free slot is already the Typst service.)
PART B — IMAGE SIZE CONFIRMATION (≤512 MB) — VERIFIED, not estimated
I measured the real Nix closure this session (Determinate Nix 3.18.1 on this box):
- Deduplicated union runtime closure of the image
contents = [pkgs.curl pkgs.cacert]= 65.56 MiB uncompressed (glibc 33.5, gcc-lib 9.8, openssl 8.9, krb5 2.8, curl 1.2, cacert 0.6, plus small libs). The per-path "closure-size" numbers (64.9 MiB curl-bin etc.) are inflated by shared-glibc double counting; the union is whatbuildLayeredImageactually lays down. - The static-musl
jobcache-devicebinary (pure Rust: tokio + serde + aes-gcm + ruzstd + base64 + sha2,lto=fat,strip=symbols) adds ~4-8 MB. - => uncompressed image ~70-75 MiB; the gzipped docker-archive tarball that actually ships ≈ 25-35 MiB.
This is ~7× under the 512 MB ceiling and even fits Back4App's 256 MB box. Runtime RSS is tiny (the loop sleeps; curl is a short-lived child per fetch), well within 512 MB and even within 256 MB.
nix eval '.#jobcache-device-image.outPath' succeeded → …-jobcache-device.tar.gz (a gzipped docker-archive), confirming the derivation is valid and eval-clean WITHOUT realising the slow musl cross-build. The flake's eachDefaultSystem exposes packages.{x86_64-linux,aarch64-linux,…} but muslPkgs = pkgs.pkgsCross.musl64 is hardcoded to x86_64-unknown-linux-musl regardless of host — so today every system attr yields an x86_64 image. That's the one thing to fix for ARM.
RUNTIME CONTRACT (verified by reading the binary, run-recipe-relevant):
- The ONLY writable-FS need is
/tmp(broker.rs writes request bodies tostd::env::temp_dir()); the flake already doesextraCommands = "mkdir -m 1777 -p tmp". No HOME, no writable cwd. - TLS: the flake sets
SSL_CERT_FILE/CURL_CA_BUNDLEto the cacert bundle (Nix curl has no built-in CA path) — already correct. - Env keys the binary ACTUALLY reads: JOBCACHE_API_URL, JOBCACHE_CONTROL_TOKEN (required); plus optional JOBCACHE_DEVICE_ID, JOBCACHE_TASK_CLASSES, JOBCACHE_FALLBACK_ONLY, JOBCACHE_CPU_CLASS, JOBCACHE_COLLECTOR_VERSION, the WORKER_*_MS pacers, the daily budgets, USER_AGENT, HOSTNAME. NOTE:
JOBCACHE_DEVICE_TIERandJOBCACHE_DEVICE_FORMATset in the k3s manifest are NOT read by this Rust binary (vestigial from the bun worker) — omit them from a standalone run; useJOBCACHE_CPU_CLASSas the identity hint instead. - Runs as uid 1000, non-root — fine for rootless podman.
SPOREWRIGHT CAPABILITY ROUTING (Tier-1 vs Tier-2 placement):
The routing model is already the right shape for this: sporewright's dim vector carries financial/latency/quality/reliability and a +∞ drop-cell. A board that needs JS render is modeled as the curl option carrying +∞ on a render-capability cell (dropped), so only render-capable options survive — exactly the curl-device-vs-playwright-pod fork in #68. The ROUTING-MODEL.md already names the example ("a web page can be fetched by a 256 MB curl container, a single-process headless Chromium, … or a paid remote renderer"). The lean Oracle device registers JOBCACHE_TASK_CLASSES=scrape (curl-only Tier-1); the future k3s Playwright pods register the render capability. No code change to sporewright is required for the free-box device — it's just another curl-class option in the option axis. Browserbase/managed headless is correctly OUT.
artifacts
# ============================================================================
# ARTIFACT 1 — flake.nix tweak: parameterize the musl cross set on target arch
# so `packages.aarch64-linux.*` builds a REAL arm64 binary (for Oracle A1 ARM).
# Today `muslPkgs = pkgs.pkgsCross.musl64` is hardcoded x86_64-unknown-linux-musl.
# Replace that one line with a system-aware selection.
# ============================================================================
# In careervector/flake.nix, inside the eachDefaultSystem `let`, replace:
#
# muslPkgs = pkgs.pkgsCross.musl64;
#
# with:
# Static-musl cross set, chosen by the target system so an aarch64-linux
# output builds an aarch64 static binary (Oracle Ampere A1) and an
# x86_64-linux output builds an x86_64 one (e2-micro / Oracle E2.1.Micro).
# Both crates are pure Rust, so musl links statically with no C cross
# toolchain on either arch.
muslPkgs =
if pkgs.stdenv.hostPlatform.isAarch64
then pkgs.pkgsCross.aarch64-multiplatform-musl
else pkgs.pkgsCross.musl64;
# Nothing else in the flake changes: mkBin, jobcache-device, and
# jobcache-device-image all consume `muslPkgs`. Verified target triples:
# pkgsCross.musl64 -> x86_64-unknown-linux-musl
# pkgsCross.aarch64-multiplatform-musl -> aarch64-unknown-linux-musl
#
# Build on the build-brain (or any nix builder; aarch64 may need a remote/native
# arm builder or binfmt):
# nix build '.#packages.aarch64-linux.jobcache-device-image' # arm64 OCI tarball
# nix build '.#packages.x86_64-linux.jobcache-device-image' # x86_64 (unchanged)
# fleet-build.sh already builds the x86_64 attr; add the aarch64 attr to its
# build_and_push list when an arm builder is wired.
# ============================================================================
# ARTIFACT 2 — Standalone RUN recipe (plain podman/docker, OUTSIDE k3s)
# Works on any free 512MB+ box. No k8s, no compose. Image is self-contained
# (curl + cacert + /tmp + SSL_CERT_FILE all baked by the flake).
# ============================================================================
# --- (a) Get the image onto the box ---------------------------------------
# Option 1 (nix consumer, e.g. NixOS or nix-installed box) — pull the signed
# closure from boot.corbet.ch and load the realized tarball:
# nix copy --from https://boot.corbet.ch <storepath-of-jobcache-device-image> \
# --extra-trusted-public-keys boot.corbet.ch-1:icG2XqQ2PQgICpYwron1UUGTuJ1fhpG6zExu2LyRTgU=
# podman load < <storepath> # buildLayeredImage tarball = gzipped docker-archive
#
# Option 2 (no nix on the box) — scp the tarball built elsewhere:
# # on a builder:
# OUT=$(nix build --no-link --print-out-paths '.#packages.aarch64-linux.jobcache-device-image')
# scp "$OUT" oracle-box:/tmp/jobcache-device.tar.gz
# # on the box:
# podman load < /tmp/jobcache-device.tar.gz # imports tag jobcache-device:latest
# --- (b) Run it (draining profile is the binary default) -------------------
podman run -d --name jobcache-device --restart=always \
--memory=256m --memory-swap=256m \
--read-only --tmpfs /tmp:rw,mode=1777,size=64m \
--cap-drop=ALL --security-opt=no-new-privileges \
-e JOBCACHE_API_URL=https://api.jobcache.corbet.ch \
-e JOBCACHE_CONTROL_TOKEN="$JOBCACHE_CONTROL_TOKEN" \
-e JOBCACHE_DEVICE_ID="device_oracle_arm_$(hostname)" \
-e JOBCACHE_CPU_CLASS=oracle-arm-free \
-e JOBCACHE_TASK_CLASSES=scrape \
-e JOBCACHE_FALLBACK_ONLY=false \
jobcache-device:latest
# (--read-only proves the FS contract: only the /tmp tmpfs is writable, which is
# all broker.rs needs. Drop --read-only if your podman version balks.)
# (Omit JOBCACHE_DEVICE_TIER / JOBCACHE_DEVICE_FORMAT — the Rust binary ignores
# them; they were bun-worker vestiges in the k3s manifest.)
# ============================================================================
# ARTIFACT 3 — systemd unit (reboot- and maintenance-proof; podman Quadlet)
# Drop at /etc/containers/systemd/jobcache-device.container, then
# `systemctl daemon-reload && systemctl start jobcache-device`.
# Token comes from an EnvironmentFile written by sops at deploy, not the image.
# ============================================================================
[Unit]
Description=JobCache Tier-1 curl scrape device (lean, free box)
After=network-online.target
Wants=network-online.target
[Container]
Image=jobcache-device:latest
EnvironmentFile=/etc/jobcache-device.env
Environment=JOBCACHE_API_URL=https://api.jobcache.corbet.ch
Environment=JOBCACHE_DEVICE_ID=device_oracle_arm_%H
Environment=JOBCACHE_CPU_CLASS=oracle-arm-free
Environment=JOBCACHE_TASK_CLASSES=scrape
Environment=JOBCACHE_FALLBACK_ONLY=false
ReadOnly=true
Tmpfs=/tmp:rw,mode=1777,size=64m
DropCapability=ALL
NoNewPrivileges=true
MemoryMax=256M
[Service]
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
# /etc/jobcache-device.env (mode 0600, written from sops, never committed):
# JOBCACHE_CONTROL_TOKEN=... # sops --decrypt secrets/jobcache-control.yml | yq -r .token
# ============================================================================
# ARTIFACT 4 — Bare-binary fallback (NO container; smallest footprint)
# If podman isn't available, run the static-musl binary directly under systemd.
# Static-musl => no glibc needed; only `curl` + CA certs from the distro.
# ============================================================================
# On the box: apt-get install -y curl ca-certificates (or dnf/apk equivalent)
# Copy the arm64 (or x86_64) static binary:
# OUT=$(nix build --no-link --print-out-paths '.#packages.aarch64-linux.jobcache-device')
# scp "$OUT/bin/jobcache-device" oracle-box:/usr/local/bin/jobcache-device
#
# /etc/systemd/system/jobcache-device.service:
[Unit]
Description=JobCache Tier-1 curl scrape device (bare static binary)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/jobcache-device
EnvironmentFile=/etc/jobcache-device.env
Environment=JOBCACHE_API_URL=https://api.jobcache.corbet.ch
Environment=JOBCACHE_CPU_CLASS=oracle-arm-free
Environment=JOBCACHE_TASK_CLASSES=scrape
DynamicUser=true
Restart=always
RestartSec=30
ProtectSystem=strict
PrivateTmp=true
[Install]
WantedBy=multi-user.target