Skip to content

Add Porter sandbox backend and deploy provider - #876

Open
16francej wants to merge 18 commits into
mainfrom
porter-provider-ci
Open

Add Porter sandbox backend and deploy provider#876
16francej wants to merge 18 commits into
mainfrom
porter-provider-ci

Conversation

@16francej

@16francej 16francej commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Revives #702 (by @GKestenberg) with CI fixed:

  • fake-porter's exec remap replaced guest /tmp/ after host-path substitution, corrupting paths on Linux runners where tmpdir() is under /tmp (passed on macOS, where it's /var/folders) — remap guest /tmp first
  • removed the unknown-DEPLOY_PROVIDER-falls-back-to-docker test, which contradicted the PR's own and main's throw-on-unknown validation
  • Prettier fix in test/secret-schema-drift.test.ts, merged latest main

Original PR description: adds Porter as a sandbox backend (SANDBOX_BACKEND=porter) and deploy provider (DEPLOY_PROVIDER=porter) via a small Porter API client, plus a Helm chart under deploy/helm/. Covered by test/porter-sandbox.test.ts and test/porter-deploy-provider.test.ts against a fake Porter API.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

GKestenberg and others added 18 commits August 27, 2026 18:24
The fake Porter's exec remap replaced guest /tmp/ after substituting
host paths, so on Linux (where tmpdir() lives under /tmp) the
substituted home/app/volume paths were corrupted and volume
persistence, restart, and abort tests failed. Remap guest /tmp before
the host-path substitutions instead.

Also drop the unknown-DEPLOY_PROVIDER fallback test that contradicted
the branch's own (and main's) throw-on-unknown validation, and run
Prettier on test/secret-schema-drift.test.ts.
- knip: drop the unused PorterClientLike/PorterSandboxLike re-export
  from porter-sandbox.ts (consumers import from porter-client.ts)
- abort test: the fake Porter's exec used spawnSync, which blocks the
  event loop for the exec's full duration, so the abort timer could
  never fire and the kill exec could never run concurrently; switch to
  async spawn (the macOS pass was accidental — setsid is missing there,
  so the killable wrapper failed instantly)
- redeploy flakes: under concurrent sharded test load a freshly forked
  node can take several seconds to start, so widen the test's
  readyWindowSec from 3 to 10, and have the test server send
  Connection: close so readiness-probe keep-alive sockets to a retired
  body are never reused
Deployed apps now get private visibility unless PORTER_DEPLOY_VISIBILITY
opts into public, matching the fly provider's refusal of public ingress
by default.

A provision without an egress token no longer rotates a live proxy-mode
body to an open one — it reuses the pinned body without injecting proxy
env, matching the sprites backend's one-way in-place policy. Rotating
killed the running computer whenever a token-less caller (e.g. the
monitor poller) provisioned mid-turn, and silently downgraded egress
from proxy to open. Upgrading an open body to proxy still rotates,
since Porter pins allowed destinations only at creation.
--sandbox porter validates the Porter token/project/cluster env,
passes the PORTER_* config through to core, and opens the same
self-API tunnel as the other cloud backends. Verified live against a
real Porter EKS cluster: a dev-instance agent turn executed on a
Porter gVisor sandbox.
porter apply is the default path: Porter builds the six surfaces from
their Dockerfiles and assigns each web service a hostname, so a new
operator needs no DNS record, TLS certificate, or ingress controller.
An apps domain is the upgrade step that gives published apps stable
hostnames, and is what the Helm chart expects.

Core now warns at config load when DEPLOY_PROVIDER=porter has no
PORTER_DEPLOY_APPS_DOMAIN, so the missing address surfaces at startup
rather than at the first agent deploy.
A fresh-context agent deployed the full stack onto a real Porter EKS
cluster following docs/porter.md; every fix here is a wall it hit.

- porter apply reads one app per invocation and silently ignores the
  rest of a multi-document file, so porter/porter.yaml becomes one
  file per app under porter/apps/
- auth, web-ui, and admin carry private: true — Porter was minting a
  public onporter.run ingress for every service, contradicting the
  portal-only topology in deploy/README.md
- the Helm chart's image reference could never name the published
  ghcr.io/yc-software/qm/<service> images (wrong org, dash joiner);
  the chart now also wires CORE_API_URL, the portal upstreams, and
  the public-URL variables that had to be hand-assembled
- a publish rejected because the cluster has no sandbox ingress now
  says so and names the dashboard setting, instead of surfacing a
  bare HTTP 400
- docs/porter.md gains the per-service wiring table and the traps the
  deploy hit: arm64 builds, env-group resolution, the two-apply
  hostname dance, RDS force_ssl, image vintage, sandbox ingress
A second fresh-context deploy proved the earlier fixes hold (~35 min,
five old walls now documented away) and surfaced one real bug: with
DEPLOY_PROVIDER=porter and no PORTER_DEPLOY_APPS_DOMAIN, apply created
a body, timed out opaquely, and leaked a running sandbox per attempt.
apply now refuses before creating anything, and the startup warning
says publishes will fail rather than "may have no address".

Also from that run: core gets private: true like the other non-portal
surfaces (a literal doc-follower was putting core on a public
onporter.run hostname), and docs/porter.md completes the wiring table
(secret-schema.ts named as the source of truth), replaces the gated
--remote suggestion with pushing to the project registry, and says how
to read the assigned hostname.
A third end-to-end pass installed the chart on a real cluster and drove
the deploy and egress paths against it.

deploy/helm actually installs now: the chart enabled an egress-proxy
image the release workflow never built (guaranteed ImagePullBackOff,
so the workflow now publishes it), emitted none of the OIDC_*/AUTH_*
broker wiring that cli/src/services.ts derives (portal and auth
crash-looped on OIDC_JWKS_URI and AUTH_ISSUER), and scripts/deploy-helm.sh
built <repo>-<svc> while deploying <repo>/<svc>, so it always shipped
images it had not built.

Porter refuses to create a sandbox at all when a cluster has egress
restriction turned off — the agent gets no computer rather than an
unenforced one — so that rejection now says which cluster setting to
turn on. porter-sandbox drops a byte-identical copy of
forceThroughProxyEnv in favour of the shared helper, and the deploy
provider's remediation points at the API path rather than the
dashboard.

docs/porter.md gains what the run proved: cluster creation and sandbox
ingress are both API-able (contract routes), the printed kubeconfig is
read-only so Helm needs an EKS access entry, Porter's sandbox
NetworkPolicy blocks RFC1918 so an in-cluster egress proxy is
unreachable, and the sandbox wildcard cert needs a Route53-capable
cert-manager the standard grant does not provide.
Enabling sandbox ingress on a live cluster stranded ours: a revision
submitted while another was reconciling superseded it, the interrupted
one stopped with CONCURRENT_UPDATE, and the cluster stayed UPDATING with
nothing running. Porter then refuses both a new contract and a delete,
each citing that status, so only support can clear it. Declare the
sandbox load balancer at cluster creation and revise one at a time.
A stranded cluster is not idle. Deleting its EKS cluster and nodes by
hand had them rebuilt within ten minutes by Porter's Cluster API
controllers, assuming porter-manager from Porter's own account —
CloudTrail names the caller and the aws.cluster.x-k8s.io user agent.
The teardown only holds once the CAPIManagement trust statements come
off porter-manager and porter-access-manager, so for a wedged cluster
the IAM revoke comes first rather than last, inverting the usual order.

Also: cluster DELETE needs an admin-role Porter token, which is worth
checking before provisioning anything, and the status gate runs before
protojson parsing, so a wedged cluster cannot be used to probe whether
a contract field exists.
A live cluster settled how addressing actually works: declare the
sandbox load balancer with a root domain in the creation contract, and
Porter names every published app `<app>.<root domain>` on a wildcard
Let's Encrypt certificate it obtains itself — creating the delegated
hosted zone, the parent-zone NS record, and a per-cluster
porter-cert-manager-route53-<id> role scoped to that zone. A probe with
no `domains` key came back with a real hostname, so the up-front refusal
this branch added was blocking the turnkey path: PORTER_DEPLOY_APPS_DOMAIN
is for choosing a different name, not for having an address at all.

apply() now proceeds without it and uses whatever host the cluster names,
failing only when the cluster names none — and retiring the body on that
path, which is what the refusal was really protecting against.

The docs' claim that no porter-* role ever gets Route53 actions was
wrong, and the manual wildcard-record instructions were unnecessary;
both are corrected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants