Skip to content

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-slug shows 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:

<project_uuid><service_uuid>-<environment>
  • Both UUIDs are hyphen-stripped to 32 hex characters (a raw UUID and environment names like preview-pr-123 both 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), so my-feature-x parses fine; My_Env! is rejected. There is no fixed dev/staging/prod enum — those are only examples. ("Preview" environments are identified by a structural is_ephemeral flag, 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_id into 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-scrape envoy_cluster_name regex 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), stamp starform.io/* directly and retire the name encoding.

§20.3 Apply Execution Order

  1. Ensure Namespace exists with correct labels and annotations (create if missing)
  2. Ensure ServiceAccount, Role, RoleBinding exist
  3. Ensure default-deny NetworkPolicy, LimitRange, ResourceQuota exist
  4. For each distinct environment in desired state: ensure env-scoped NetworkPolicy exists
  5. For each attached Var Group: ensure K8s Secret matches desired spec (§38)
  6. For each system Secret (DB creds, bucket creds): ensure spec matches
  7. Ensure ConfigMap matches desired spec (if present)
  8. Run the pre-deploy Job when required — service entry carries pre_deploy and its deployment_id differs from the last applied one; step 9 for that service is gated on Job success (rules below)
  9. Ensure Deployment matches desired spec (triggers rolling update if pod template hash changed)
  10. Ensure Service matches desired spec
  11. Ensure HTTPRoute matches desired spec
  12. Ensure PodDisruptionBudget matches desired spec (if replicas > 1)
  13. Ensure database resources match desired spec — Mininova entries: Deployment + Service + PVC + system Secret; dedicated entries: system Secret + per-environment egress-allowlist NetworkPolicy (Managed Databases)
  14. Ensure HorizontalPodAutoscaler matches desired spec (if autoscaling.enabled; delete it when disabled — sub-resource rule below)
  15. Execute tombstoned deletions — entities carrying deleted: true in 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: a starform.io/managed-by=shuttle resource 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) — defaults max(10, 20%), sized so a multi-PR preview-environment teardown doesn't false-trip — Shuttle skips all deletions that tick, keeps applying creates/updates, raises starform_gc_breaker_tripped (§26.1, alerting via §35.5), and reports the trip in §25.4.
  • Override: Starbase sets top-level force_gc: true in the payload (§25.1) after an explicit operator action.
  • Together with the §25.1 sequence regression guard (§27), a control-plane bug can no longer mass-delete customer workloads.

Rollout settings & health checks.

  • Every Deployment renders strategy: RollingUpdate with maxSurge: 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_check input 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: path set ⇒ httpGet on port; unset on a web service ⇒ tcpSocket on the first container port (ready = accepting connections); worker ⇒ no probes (running = ready).

Pre-deploy Job (apply-order step 8).

  • Rendered once per new deployment_id when pre_deploy.command is set: the new image, the command under sh -c, the same envFrom (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", reason PreDeployFailed.
  • A superseding deployment (newer deployment_id arrives 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.enabled is true, the HPA owns Deployment.spec.replicas: Shuttle sets replicas only at Deployment creation (to min_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 replicas from the payload.
  • Scaling behavior (written down, not implied): the HPA behavior block 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, where cap derives from the project's plan-tier ResourceQuota (§20.6) — structural, not a magic number — and 30 ≤ 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: 600 on every Deployment it renders — the §25.4 rollout: "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:

Env-scoped NetworkPolicy · applied per (namespace, environment)
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/8 covers every managed DB, telemetry droplet, and node in the region (the real VPC infra lives in 10/8; pod overlay CIDRs never appear on the wire, §4.4).
  • Allow back only this environment's own DB endpoints as per-/32 rules rendered from the §25.1 databases array (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

Project-wide ResourceQuota · one per namespace
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.


Cross-references

Tenant key & label catalog → §24.1 · annotations on these resources → §24B · the desired-state payload that drives these applies → §25.1 · Var Group Secrets → §38 · store-side attribution split → v2 Store · Starform-layer RBAC → §15.