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.

§20.1 Namespace Naming & Cluster Affinity

Namespace name format: proj-<project_slug>

  • project_slug is 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 to proj-<workspace_slug>-<project_slug>
  • Slug, not UUID — improves debuggability (kubectl get pods -n proj-acme-api is 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:

<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 (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), 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 label: vmagent relabeling cannot perform cross-metric joins, so deriving project_id from 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/prometheus output in a test, and re-verify on every EG bump. Envoy's native stats_tags is 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, stamp starform.io/* directly on the HTTPRoute and retire the parse.

§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. Ensure Deployment matches desired spec (triggers rolling update if pod template hash changed)
  9. Ensure Service matches desired spec
  10. Ensure HTTPRoute matches desired spec
  11. Ensure PodDisruptionBudget matches desired spec (if replicas > 1)
  12. Garbage collect resources no longer in desired state (by label selector starform.io/managed-by=shuttle AND 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:

Env-scoped NetworkPolicy · applied per (namespace, environment)
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

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


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 · vmagent attribution parse → §35.2 · Starform-layer RBAC → §15.