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.
§20.1 Namespace Naming & Cluster Affinity¶
Namespace name format: proj-<project_slug>
project_slugis a URL-safe identifier stored on the project record (2–63 chars, matches^[a-z0-9][a-z0-9-]*[a-z0-9]$)- Collision handling: if
proj-<slug>already exists in the target cluster (rare edge case from workspace boundaries), Shuttle falls back toproj-<workspace_slug>-<project_slug> - Slug, not UUID — improves debuggability (
kubectl get pods -n proj-acme-apiis human-readable)
Cluster affinity: a project is pinned to exactly one cluster at creation time. The assignment
lives in Postgres (projects.cluster_id). Shuttle in cluster X only sees projects assigned to
cluster X in its desired state pull. All environments of a project (production, staging, dev,
preview envs) schedule to the same cluster. This matches the unit-of-placement = unit-of-isolation
principle: projects are the scheduling unit, therefore projects are the namespace boundary.
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 | Project isolation boundary, cluster affinity anchor |
| ServiceAccount | One per namespace | Identity for workload pods |
| 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 |
| Service (ClusterIP) | One per (service, environment) with ports | Internal networking; environment-scoped via labels |
| 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 |
| HTTPRoute | One per (service, environment) with external access | Envoy Gateway external routing |
| 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 and its positional parse are load-bearing for metrics attribution.
This is the canonical home (CLAUDE.md §7); the same parse is restated in
Observability › Metrics. Re-verify the
envoy_cluster_name format on every Envoy Gateway bump.
HTTPRoute name format (load-bearing for metrics attribution): Envoy Gateway names one upstream
cluster per HTTPRoute rule, and the only customer identity that appears on Envoy's per-route metrics
is the cluster-name string (httproute/<namespace>/<route-name>/rule/N). 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 (used by vmagent relabeling,
§35.2):
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 label: vmagent relabeling cannot perform cross-metric joins, so derivingproject_idfrom a namespace label would force a PromQL/recording-rule join at query time and make every dashboard depend on kube-state-metrics being healthy. Carrying all three IDs in the route name lets vmagent parse identity directly from one string — no join, no kube-state-metrics dependency for Envoy attribution. The redundancy with the namespace name is intentional and free. - Upgrade sensitivity: the parse is coupled to Envoy Gateway's cluster-name format, which EG has
changed between versions. Pin the EG version, snapshot the exact
/stats/prometheusoutput in a test, and re-verify on every EG bump. Envoy's nativestats_tagsis not a more robust alternative — it runs the same parse inside Envoy via a raw config patch and breaks identically on a format change. If upstream label propagation (Envoy Gateway GH #2488) ever ships, stampstarform.io/*directly on the HTTPRoute and retire the parse.
§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)
- 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)
- Garbage collect resources no longer in desired state (by label selector
starform.io/managed-by=shuttleAND name not in desired set)
§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-acme-api
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:
- podSelector:
matchLabels:
starform.io/environment: production
- to: [] # all external traffic allowed (egress policing is at Gateway)
ports:
- protocol: TCP
port: 443
This enforces production pods can only reach production pods, and staging pods cannot reach production pods, within the same namespace.
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-acme-api
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.
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.