pl up demo: the end-to-end vertical slice
npm run test:pl-up-demo (scripts/pl-up-demo-e2e.sh, also in test:all) runs the complete path from an ordinary web-only checkout to a URL and back down, with the real binaries. It is critical-path step 5 from docs/claude-handoff.md.
How to run it
export CARGO_BUILD_JOBS=4
nice -n 15 bash scripts/pl-up-demo-e2e.sh
It needs Docker, the dev PostgreSQL (compose.dev.yaml, 127.0.0.1:55432), jq, htpasswd, openssl, python3, git and network access to Docker Hub for the pinned base images. Every name is per run: a disposable database pushleft_plup_<run>, containers plup-<run>-registry/-caddy/-acme, a DNS stub process, networks labelled com.pushleft.e2e=<run>, and free loopback ports. PUSHLEFT_PLUP_E2E_REGISTRY_PORT, PUSHLEFT_PLUP_E2E_HTTP_PORT and PUSHLEFT_PLUP_E2E_HTTPS_PORT override the registry and Caddy ports. On failure the script prints the last log lines, the jobs, the instances and the events, and keeps its temporary directory.
Wall clock on the development Mac (Docker Desktop, warm Cargo and image caches): about 6 minutes (369 s and 366 s on two consecutive runs). Most of it is the BuildKit builds (including the five attempts the broken build gets) and the two 60-second health deadlines of the failed start and failed health cases.
What runs
| Component | How |
|---|---|
| PostgreSQL | Fresh database; bootstrap-owner and the API apply the migrations. |
| API | PUSHLEFT_AUTH_MODE=production, behind scripts/fixtures/forwarded-https-proxy.py (trusted proxy 127.0.0.1/32). The proxy now relays text/event-stream responses chunk by chunk, so pl up can follow events through it. |
| Registry | registry:2.8.3, htpasswd auth, deletes enabled, fixed loopback port. |
| Scheduler | PUSHLEFT_PREVIEW_BASE_DOMAIN=pl<run>.localhost. Retention is not configured (it has its own suite). |
| Agent gateway | mTLS with a per-run CA, source root and registry push credential. |
| Build agent | Node in the build pool with its own certificate; rootless BuildKit (moby/buildkit v0.25.1-rootless, digest-pinned). |
| Application agent | Node in the application pool with its own certificate; Docker runtime, --caddy-admin to the ingress Caddy, the lock-file probe image. It pulls with an operator-provisioned Docker credential (DOCKER_CONFIG), never the build lease’s push credential. |
| Ingress | Digest-pinned Caddy running the HTTPS-only config from pushleft-agent ingress-caddy-config (bucket, terminal 404, port 80 only redirects and answers ACME). The application agent loads a per-run wildcard *.pl<run>.localhost (--ingress-wildcard-cert-file/-key-file); the scheduler runs with PUSHLEFT_INGRESS_HTTPS=1 and PUSHLEFT_DOMAIN_VERIFY_NAMESERVER pointing at scripts/fixtures/dns-stub.py. A second pinned Caddy runs acme_server as a local ACME CA, rooted in the same per-run CA, on a Docker network where the custom domain resolves to the ingress Caddy. |
| CLI | target/debug/pl, with credentials stored by pl login. |
A node has one pool, so the demo uses two agent processes. Both nodes are shared (no owner_org_id), so the second organization is placed on the same hosts.
What is stubbed, and what is not proven
- Network admission. A background loop writes, for every environment segment, the same evidence
scripts/application-lifecycle-e2e.shwrites: a ready coordinator on a mesh-pool node, and fresh gateway, firewall, route and mesh-route rows for the application node. It also creates an internal Docker network named like the environment bridge (pl_<segment>) and connects Caddy to it. No Headscale, Tailscale gateway, nftables or host routes run. Nothing else is stubbed: the build, the registry push and pull, placement, health, ingress, supersession and teardown (including the network-lane teardown job, which finds nothing it owns to remove) all go through the real code. - The second organization is inserted in SQL with a hashed token, because there is no org-creation endpoint (only
bootstrap-owner, which creates the first one). - Docker Desktop, not native Linux. Containers run in Docker Desktop’s VM. This says nothing about host-kernel hardening, rootless BuildKit on a Linux host, or nftables.
- Local CA and DNS stub, not the internet. The URL is
https://<branch>--<project>.pl<run>.localhost/, served with a per-run wildcard and reached withcurl --cacert <run CA> --resolveon the Caddy HTTPS port. The custom domain’s certificate comes from a real ACME HTTP-01 exchange, but against a local ACME CA, and its ownership TXT record comes from a DNS stub. Public DNS, Let’s Encrypt/ZeroSSL and internet-reachable 80/443 are not exercised. - Single host. One build node and one application node on one machine. Multi-host placement, packet transport and failover are not exercised.
- One database role. Every service connects as the
pushleftsuperuser, so missing grants for separate service roles would not show up here.
Evidence, step by step
-
pl login.bootstrap-ownerprints a token;pl loginstores it and printsowner@plup.test · Demo org. -
pl up, following events. The checkout isscripts/fixtures/web-onlyin a git repo on branchdemo, with no.pl.yaml. Inference gives onewebrole on port 8080 with health/healthz. The CLI printsbuild_queued,source_verified,build_completed,application_ready,ingress_publishing,ingress_route_activeanddeployment_url_ready, ends withlive https://demo--demo-app.pl<run>.localhost/, and exits 0. The hostname returned 404 before the deployment. -
The URL serves the build over HTTPS.
curl --cacertthrough Caddy returnspushleft build fixture, so the handshake verified the wildcard for that name. Another name under the base domain returns 404, and plain HTTP answers only308to thehttps://URL. The artifact is<registry>/organizations/<org>/projects/<project>@sha256:…, built by the build node. The container runs that digest read-only, withCapDrop ALL, no mounts and no published ports. -
pl listshows the deploymentlivewith the URL. -
Restart replay. The application agent and the scheduler are killed with SIGKILL. The route keeps serving while they are down. After the restart the deployment is still
live, the container has the same ID and start time, and no job was added for the deployment. -
Second release, with a crash mid-rollout. The checkout changes and is committed. The application agent is down while the release builds and is targeted. Once restarted, it is killed again as soon as it has started the new container, before it reported health (the instance is still
deploying), and the scheduler is killed with it. The old release keeps serving. After the restart,ensure_applicationis leased again (attempt 2) and adopts the same container (same ID).pl upexits 0 with the same URL, which now returnspushleft demo release 2. The old route isremoved, Caddy holds onlypl-ingress-<host>-g2, the old deployment issupersededand its container stopped but kept, and its artifact row and registry manifest remain.pl listshows the new deploymentlivewith the URL and the old onesupersededwithout one. -
Tenant isolation. A second organization’s owner token gets
404for the first organization’s deployment on GET, the SSE stream, source upload and teardown.pl down <id>fails withnot_found, andpl listshows nothing. The deployment stayslive, noteardown_requestedevent exists, and the URL still serves. -
Viewer and revocation. A viewer onboarded with a login code cannot
pl up(forbidden), and no deployment row is created. A token that worked forpl listfails on the next call afterDELETE /v1/auth/tokens/{id}(revoked). -
Failed build. A Dockerfile whose
RUNexits 3 endspl upnon-zero atbuild_failed(after the five attempts the job allows), printing the log tail with the failing step’s output. The deployment isfailed, with no artifact and no route row. -
Failed start. An image whose process exits at once ends
pl upatapplication_failed. The deployment isfailedand has no route row. -
Failed health. An image whose
/healthzreturns 500 endspl upatapplication_failed, with the same result. -
Route refused. The second organization deploys a checkout that derives the same hostname.
pl upends atingress_rejected, the deployment isfailed, and the hostname stays owned by the first organization. -
Tampered source. With the build agent stopped, a deployment is queued, and one byte of its verified source object is changed on disk. When the build agent comes back, the gateway refuses the download (
500, integrity check), every attempt fails, the build ends with a terminalbuild_failed, and no artifact exists.In steps 9 to 12 the failing release goes into the live environment, and each time the script checks that the live deployment is still
live, that Caddy’s routes did not change, and that the URL still returns the second release. -
Custom domain. A viewer’s
pl domain addisforbidden. The owner’spl domain add www.demo-<run>.testprints the claim with the TXT_pushleft-challenge.…token and the CNAME target (the preview hostname). A duplicate claim is refused, and the other organization’spl domain listis empty. With no DNS record the scheduler records a failed check, the claim stayspending_verification, no route row exists, Caddy’sautomatelist is empty and the name gets no TLS certificate. Once the stub serves the TXT record, the claim becomesverified, is attached to the live release and becomesactive. Caddy obtains the certificate by HTTP-01 from the local ACME CA, andcurl --cacertreturnspushleft demo release 2on the custom name. The release staysliveon its preview URL.scripts/ops-alerts.shprobes both names through the ingress: it reports the 12-hour ACME leaf as expiring under a 1-day threshold, and neither name as invalid.pl domain showlistscustom_domain_added,_check_failed,_verified,_routingand_route_active.pl domain removedrains the route, Caddy is back to the single preview route, andautomateis empty. -
pl down.pl downprintsTeardown queued. Then the routes areremoved, Caddy has no routes, the URL returns Caddy’s 404, no container of the deployment exists, the instance isremoved, and itsdeployment_roleallocation is released. The artifact, the snapshot and the events (includingapplication_removed) remain. No other deployment of the environment is still active (release 1 issuperseded, the failing releases arefailed), so the scheduler queues oneremove_environment_gatewayjob on the app node’s network lane. Its receipt releases the attachment (gateway_status='removed',released_at, evidencegateway: absent,bridge: foreign_retainedbecause the stub bridge is not agent-owned and Caddy is attached to it,routes/firewall: not_applicableon macOS), releases the membership (the subnet slot stays burned) and the gateway capacity, marks the segmentdestroyed, and the deploymentdestroyedwith adeployment_destroyedevent.pl listshows itdestroyed. -
pl upagain into the torn-down environment. The API creates a new segment for the environment; placement gets a new membership with a higher subnet slot; the URL serves release 2 again.
Integration bugs this demo found
- Build retries reused the previous attempt’s workspace. The name was
pl-build-<job>-<first 8 hex of the lease token>. Lease tokens are UUIDv7, so those 8 hex digits are the millisecond timestamp and stay the same for about 65 seconds. A retry inside that window recreated the same path, and Docker Desktop refused the bind mount (bind source path does not exist). The first real failure was then hidden behind four infrastructure failures, and the CLI printed the wrong log tail. The suffix now uses the random tail of the token (crates/pushleft-agent/src/build.rs,workspace_name; testretries_get_a_fresh_workspace_name). - A failed health check was retried five times. The agent reported an image that never became healthy as a retryable failure, so the gateway re-leased it four more times with a 60-second deadline each.
pl upwaited about six minutes for a result that could not change. A missed health deadline is now a permanent failure with codeunhealthy. Docker and pull errors stay retryable (crates/pushleft-agent/src/application.rs,unhealthy; testa_failed_health_deadline_is_a_permanent_failure). - The test proxy buffered SSE.
forwarded-https-proxy.pyread the whole upstream response, sopl upcould never follow events in production mode. It now streamstext/event-stream. - The web-only fixture served nothing. Its image had no command (busybox
shexits at once), so it could build but never pass health. It now serves/wwwwith busyboxhttpdon 8080, including/healthz.
Remaining before this is production-proven
- Native Linux run of the same flow: rootless BuildKit on a Linux host, the host Caddy reaching the bridge address, nftables and the environment firewall.
- Real network admission (Headscale, Tailscale gateways, firewall and route evidence) instead of the SQL stub, and multi-host placement, transport and failover.
- Public wildcard DNS (
*.<base>record), a public CA (Let’s Encrypt/ZeroSSL) for custom domains with 80/443 reachable from the internet, and DNS-01 for the wildcard (needs a Caddy build with a DNS provider module; the pinned image has none). - Separate database roles per service with least-privilege grants.
- Scoped, short-lived registry credentials: build nodes share one push credential, and application nodes need an operator-provisioned pull credential.
- Org creation, so that tenant tests do not need SQL.
- Environment network teardown on a real host: the demo bridge is a stub the agent correctly refuses to remove, and no Tailscale gateway, nftables table or host route exists to remove.
npm run test:application-lifecycleremoves a labelled gateway stand-in container and the bridge; nftables and route removal only run on Linux and are not exercised by any suite yet. - Garbage collection of retained stopped containers, and of the role capacity still held by
faileddeployments’ instances.