Shuttle Per-Project Resources¶
Shuttle creates and manages K8s resources grouped by project. One Kubernetes namespace per
project, with environments (production, staging, dev, preview-pr-123) coexisting inside the
namespace as first-class labels on every resource. Environment isolation is enforced via
label-selector NetworkPolicies, Starform-layer RBAC (§15),
and Shuttle-applied labels audited in CI. (Design record:
specs/2026-07-04-placement-model-and-correctness-batch-design.md.)
§20.1 Namespace Naming & Placement¶
Namespace name format: proj-<project_uuid> — the project UUID hyphen-stripped to 32 hex
characters, the same convention as HTTPRoute names (§20.2).
- Identical on every cluster, in every region — no collision logic, no per-cluster name records, and immune to project renames (K8s namespaces can't be renamed; slug-derived names drift). Identity lives in names and labels; human context lives in labels only.
- Readability via labels, not the name: the namespace carries
starform.io/project-slug(§24.4) —kubectl get ns -L starform.io/project-slugshows the readable column;kubectl get pods -n $(kubectl get ns -l starform.io/project-slug=acme-api -o name | cut -d/ -f2)or the admin tooling resolves a slug to its namespace.
Placement — per service, per environment (FR-077):
region/cluster is chosen on the service-per-environment record (cluster_id), like Railway
and Render — never gated by billing plan. Each project has a default region; every service
lands there unless the customer deliberately moves it. Each cluster's desired-state payload
contains exactly the entities placed on that cluster; Shuttle never sees or reasons about other
clusters.
- The namespace exists per cluster, on demand: created on the first entity placed there, tombstoned in that cluster's payload only when the last entity leaves (§20.3 deletion rules apply — absence never deletes).
- No private networking across regions (FR-078): customer regional VPCs are not peered to each other, so cross-region private traffic has no path — by declaration and by physics. Cross-region service-to-service calls use public hostnames through the edge; the dashboard discloses this. Databases are region-local — the UI warns when a service is placed away from its environment's databases (Managed Databases).
- Preview (
is_ephemeral) environments place all entities in one cluster (their base environment's region by default).
Why not namespace-per-environment: at projected scale (250 projects × 4+ environments per cluster, growing to 10+ envs with preview environments), namespace-per-environment produces 2,500+ namespaces per cluster — operationally heavier without proportionate isolation benefit. Environment isolation via labels is functionally equivalent when Starbase is the only writer to K8s.
§20.2 Resources Shuttle Creates¶
| Resource | Scope | Purpose |
|---|---|---|
| Namespace | One per project per cluster (proj-<project_uuid>, identical everywhere) |
Project isolation boundary; exists on a cluster while ≥1 entity is placed there (§20.1) |
| ServiceAccount | One per namespace | Identity for workload pods; references registry-<name> in imagePullSecrets — custom SAs don't inherit the default SA's pull secrets (§26.3) |
| Role + RoleBinding | One per namespace | Minimal RBAC (no K8s API access from customer pods) |
| NetworkPolicy (default-deny) | One per namespace | Baseline deny-all between projects |
| NetworkPolicy (env-scoped) | One per (namespace, environment) pair | Same-environment pod-to-pod allow; cross-environment deny |
| ResourceQuota | One per namespace | Project-wide limits on pods, CPU, memory (aggregated) |
| LimitRange | One per namespace | Default pod resource requests and limits |
| Deployment | One per (service, environment) | Customer workload — Shuttle stamps runtimeClassName: gvisor on web/worker pods (runc for DB/platform) and the hardened securityContext on every customer pod (FR-082 / FR-083, Kubernetes Runtime) |
| Service (ClusterIP) | One per web (service, environment) | Internal networking + the internal_host name; environment-scoped via labels. Workers/cron get none (Services › Networking) |
| Secret (Var Group) | One per (var_group, environment) attached | Environment variables and secrets (see Section 38) |
| Secret (system) | One per (service, environment) | Starform-injected env vars (DB URL, bucket creds, etc.) |
| ConfigMap | One per (service, environment), optional | Non-sensitive config |
| PodDisruptionBudget | One per (service, environment) with replicas > 1 | Protect against simultaneous eviction |
| HorizontalPodAutoscaler | One per (service, environment) with autoscaling enabled | CPU/memory autoscaling (autoscaling/v2; metrics-server-backed, FR-068) |
| Database Deployment + Service | One per (Mininova database, environment) | Containerized throwaway DB/cache (Managed Databases) |
| PersistentVolumeClaim (Mininova DB) | One per throwaway Postgres instance | 1 GiB data volume — internal template only; customer-facing volumes remain post-MVP |
| NetworkPolicy (DB egress allowlist) | One per (environment, dedicated DB instance) | Only the owning environment's pods may reach the dedicated endpoint (FR-075) |
| Job (pre-deploy) | One per deployment with pre_deploy set |
Runs the migration/command once, gating the rollout (§20.3) |
| HTTPRoute | One per public web (service, environment) — public=true |
Envoy Gateway external routing; skipped when public=false (Services › Networking) |
| PersistentVolumeClaim | One per (service, environment) with volumes | Persistent storage (post-MVP) |
| CronJob | One per (cron service, environment) | Scheduled jobs (post-MVP) |
| SecurityPolicy | One per (service, environment) with auth enabled | JWT/JWKS auth at gateway (post-MVP) |
Load-bearing — HTTPRoute name encoding for metrics attribution
The HTTPRoute name format is load-bearing for metrics attribution. This is the canonical home (CLAUDE.md §7); the store-side split is defined in Observability v2 · Store. Identity is read from Envoy's route metadata via the access-log CEL operator — confirm the CEL path on every Envoy Gateway bump (v2 Collect).
HTTPRoute name format (load-bearing for metrics attribution): the L7 request log is written by
Envoy, not a customer pod, so it cannot be label-stamped — the only customer identity available
on each request row is the HTTPRoute's name/namespace, which Envoy Gateway exposes as route
metadata. Because environments share a single project namespace (§20.1), the
namespace cannot distinguish production from staging — so the route name must carry full
identity. Shuttle names each HTTPRoute:
- Both UUIDs are hyphen-stripped to 32 hex characters (a raw UUID and environment names like
preview-pr-123both contain hyphens, which makes a hyphen-delimited scheme ambiguous). Fixed-width UUIDs make the parse positional and unambiguous. - Parse rule (applied once, at insert, in the store's derived columns —
v2 Store):
chars[0:32]=project_id,chars[32:64]=service_id, the segment after the separating hyphen =environment. - Environment-name rule (load-bearing): the environment segment is a customer-chosen string,
but because it lands in this DNS-1123 route name and in a K8s label value
(§24.1), it is not free-form. Validate at environment creation as
an RFC 1123 label: lowercase
[a-z0-9-], must start/end alphanumeric, ≤30 chars. The positional parse tolerates hyphens inside the name (the first 64 chars are fixed-width hex), somy-feature-xparses fine;My_Env!is rejected. There is no fixeddev/staging/prodenum — those are only examples. ("Preview" environments are identified by a structuralis_ephemeralflag, not by name-matching — §39.1.) - Total length ≤ 95 chars (64 hex +
-+ ≤30-char environment), well under the K8s 253-char DNS-1123 name limit. - Why fold
project_idinto the route name when it is also recoverable from the namespace (proj-<project_uuid>): carrying all three IDs in one string lets the store derive the full tenant key from a single field — no join against pod or namespace metadata, no kube-state-metrics dependency for L7 attribution. The redundancy with the namespace name is intentional and free. - Upgrade sensitivity: identity is read from the HTTPRoute's route metadata (
name/namespace) via the access-log CEL operator — a stable Gateway-API field, not Envoy's internal cluster-name string (v1's per-scrapeenvoy_cluster_nameregex is retired). Pin the EG version and confirm the CEL path on every bump — a 5-minute one-request test (v2 Collect). If EG ever propagates arbitrary labels onto route metadata (GH #2488), stampstarform.io/*directly and retire the name encoding.
§20.3 Apply Execution Order¶
- Ensure Namespace exists with correct labels and annotations (create if missing)
- Ensure ServiceAccount, Role, RoleBinding exist
- Ensure default-deny NetworkPolicy, LimitRange, ResourceQuota exist
- For each distinct environment in desired state: ensure env-scoped NetworkPolicy exists
- For each attached Var Group: ensure K8s Secret matches desired spec (§38)
- For each system Secret (DB creds, bucket creds): ensure spec matches
- Ensure ConfigMap matches desired spec (if present)
- Run the pre-deploy Job when required — service entry carries
pre_deployand itsdeployment_iddiffers from the last applied one; step 9 for that service is gated on Job success (rules below) - Ensure Deployment matches desired spec (triggers rolling update if pod template hash changed)
- Ensure Service matches desired spec
- Ensure HTTPRoute matches desired spec
- Ensure PodDisruptionBudget matches desired spec (if replicas > 1)
- Ensure database resources match desired spec — Mininova entries: Deployment + Service + PVC + system Secret; dedicated entries: system Secret + per-environment egress-allowlist NetworkPolicy (Managed Databases)
- Ensure HorizontalPodAutoscaler matches desired spec (if
autoscaling.enabled; delete it when disabled — sub-resource rule below) - Execute tombstoned deletions — entities carrying
deleted: truein the payload (§25.1) — subject to the delete breaker below, and confirm each via §25.4 (deletion_confirmed). Reconcile sub-resources of present entries (a present service entry is authoritative for its full resource set — a dropped ConfigMap/PDB/HTTPRoute is removed). Never delete on absence: astarform.io/managed-by=shuttleresource with no corresponding payload entry is reported as an orphan via §25.4 and left running (FR-073)
Deletion safety (tombstones + the delete breaker).
- If entity tombstones executable in one tick exceed
max(STARFORM_GC_MAX_DELETES, STARFORM_GC_MAX_DELETE_FRACTION × managed-service-count)— defaultsmax(10, 20%), sized so a multi-PR preview-environment teardown doesn't false-trip — Shuttle skips all deletions that tick, keeps applying creates/updates, raisesstarform_gc_breaker_tripped(§26.1, alerting via §35.5), and reports the trip in §25.4. - Override: Starbase sets top-level
force_gc: truein the payload (§25.1) after an explicit operator action. - Together with the §25.1
sequenceregression guard (§27), a control-plane bug can no longer mass-delete customer workloads.
Rollout settings & health checks.
- Every Deployment renders
strategy: RollingUpdatewithmaxSurge: 1, maxUnavailable: 0— a new pod must be ready before an old one dies (true zero-downtime, FR-018), uniform for web and worker. - The §25.1
health_checkinput renders as all three probes (FR-020):
| Probe | Check | Settings derived from the one input |
|---|---|---|
| startup | the configured check | period 10 s · failureThreshold = ceil(timeout_seconds / 10) (default 300 s → 30) |
| readiness | the configured check | period 10 s · threshold 3 |
| liveness | the configured check | period 15 s · threshold 3 (only runs after startup succeeds) |
- The check itself:
pathset ⇒httpGetonport; unset on a web service ⇒tcpSocketon the first container port (ready = accepting connections); worker ⇒ no probes (running = ready).
Pre-deploy Job (apply-order step 8).
- Rendered once per new
deployment_idwhenpre_deploy.commandis set: the new image, the command undersh -c, the sameenvFrom(Var Group + system Secrets) as the workload, the gVisor RuntimeClass + hardened spec + standard labels,backoffLimit: 0,activeDeadlineSeconds: 600,ttlSecondsAfterFinished: 3600. - Success ⇒ proceed to the Deployment update. Failure or timeout ⇒ do not touch the Deployment
— the previous ReplicaSet keeps serving — and report §25.4
rollout: "failed", reasonPreDeployFailed. - A superseding deployment (newer
deployment_idarrives mid-run) deletes the running Job before starting its own. - The Job's pod carries the standard label set, so its output lands in the customer's log view automatically.
Replicas ownership under autoscaling.
- When
autoscaling.enabledis true, the HPA ownsDeployment.spec.replicas: Shuttle setsreplicasonly at Deployment creation (tomin_replicas) and thereafter excludes the field from its diff/apply, so the 30s level-driven loop never fights the HPA. - When autoscaling is disabled or removed, Shuttle deletes the HPA and resumes enforcing
replicasfrom the payload. - Scaling behavior (written down, not implied): the HPA
behaviorblock is rendered explicitly — scale-up immediate (K8s default),scaleDown.stabilizationWindowSeconds: 300(the documented K8s default); cooldowns are not customer-exposed at MVP. - Bounds (validated Starbase-side at PUT):
1 ≤ min_replicas ≤ max_replicas ≤ cap, wherecapderives from the project's plan-tier ResourceQuota (§20.6) — structural, not a magic number — and30 ≤ target_cpu_percent ≤ 90. RPS/latency targets stay deferred (§39.1 #9). - When
suspended: true(billing suspension, §25.1 / §36), Shuttle scales to 0 and removes any HPA regardless of the autoscaling config — so suspension halts autoscaled services; clearing the flag restores the HPA. - Shuttle also sets
progressDeadlineSeconds: 600on every Deployment it renders — the §25.4rollout: "failed"signal depends on it.
§20.4 Environment Isolation via Labels¶
K8s NetworkPolicies support podSelector.matchLabels — policies reference pods by label rather than
by namespace. For each environment, Shuttle creates a policy in the project namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: env-isolation-production
namespace: proj-1f2e3d4c5b6a79880011223344556677
spec:
podSelector:
matchLabels:
starform.io/environment: production
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels:
starform.io/environment: production # same env only
- from:
- namespaceSelector:
matchLabels:
starform.io/namespace-role: gateway # Envoy Gateway namespace
egress:
- to: # same env only
- podSelector:
matchLabels:
starform.io/environment: production
- to: # DNS — without this nothing resolves
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to: # the internet — every port, minus the private estate
- ipBlock:
cidr: 0.0.0.0/0
except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254/32]
- to: # THIS environment's own dedicated DBs only (§25.1 databases)
- ipBlock: { cidr: 10.110.0.7/32 }
ports:
- { protocol: TCP, port: 25060 }
The posture:
- Allow all external egress on any port (apps call arbitrary APIs — SMTP, webhooks, external databases).
- Deny the entire private estate by default — the single
except 10.0.0.0/8covers every managed DB, telemetry droplet, and node in the region (the real VPC infra lives in10/8; pod overlay CIDRs never appear on the wire, §4.4). - Allow back only this environment's own DB endpoints as per-
/32rules rendered from the §25.1databasesarray (FR-075). Nothing is ever "blocked by name": other tenants' databases simply match no allow rule. - This enforces production pods can only reach production pods — and note the precise meaning: same environment, same cluster. Pods on another cluster are unreachable privately regardless of labels (FR-078).
Cross-environment networking (opt-in,
FR-079): a per-project
allow_cross_env_networking flag in desired state (§25.1) relaxes the first ingress/egress rules
from same-environment to same-namespace scope — a NetworkPolicy template change only, nothing
else moves. Default off; enabling it while any environment in the project is protected (§15.5)
triggers a dashboard warning, since it lets dev pods reach production pods.
Failure mode to design around: if Shuttle fails to apply the correct starform.io/environment
label on a pod, that pod loses its NetworkPolicy protection. Mitigation: a Kyverno admission policy
(post-MVP) rejects any pod without the required label set, turning a silent failure into a loud one.
§20.5 Namespace Lifecycle¶
- Create: on first deploy of any service in the project, Shuttle creates the namespace and baseline isolation resources (quotas, RBAC, default NetworkPolicy)
- Update: on tier change or customer plan change, Shuttle updates ResourceQuota and LimitRange
- Delete: on project soft-delete flag in desired state, Shuttle deletes the entire namespace (cascading delete removes all child resources)
- Cluster migration (post-MVP): a project moving between clusters requires coordinated drain on source and provision on target; out of scope for MVP
§20.6 Project-Level Resource Quotas¶
apiVersion: v1
kind: ResourceQuota
metadata:
name: project-quota
namespace: proj-1f2e3d4c5b6a79880011223344556677
spec:
hard:
requests.cpu: "<plan-tier-limit>"
requests.memory: "<plan-tier-limit>"
limits.cpu: "<plan-tier-limit>"
limits.memory: "<plan-tier-limit>"
pods: "<plan-tier-limit>"
persistentvolumeclaims: "<plan-tier-limit>"
Values are derived from the project's plan tier (Hobby, Pro, Enterprise) multiplied by a headroom factor. Starbase computes the quota, Shuttle applies it. Per-cluster: since a project may span clusters, Starbase computes each cluster's quota from the entities placed on that cluster — the ResourceQuota is a per-namespace-per-cluster object, and naive replication would silently multiply a project's headroom by its cluster count.
Environment-level quota enforcement (e.g., "staging cannot exceed 50% of total project quota") happens at Starbase API layer, not K8s layer. Starbase rejects deployments that would exceed env-level budgets before writing to desired state.