Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions clusters/gcp-mycluster-0/infrastructure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# GCP-only infrastructure for this cluster.
#
# Points at infrastructure/gcp-mycluster-0, NOT at infrastructure/base — that
# tree is AWS-shaped (Karpenter, aws-load-balancer-controller, EKS Pod
# Identities) and applying it here would fail on resources whose CRDs do not
# exist. The GCP tree is deliberately minimal until the design's slices 6-7
# settle which parts of the shared tree are genuinely cloud-neutral.
#
# It sources infra-artifact, which flux/artifact-generators/monorepo-split.yaml
# already builds from `@repo/infrastructure/**` — so the new directory needs no
# generator change, only this Kustomization.
#
# No dependsOn: crds. The only resource here is a ComputeClass, whose CRD is
# installed by GKE itself (`cloud.google.com/v1`, present on a fresh cluster)
# rather than by crds/base. Adding that edge would couple this to a Kustomization
# it does not need and delay it behind the whole upstream CRD set.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: infrastructure
namespace: flux-system
spec:
prune: true
interval: 1m0s
path: ./infrastructure/gcp-mycluster-0
sourceRef:
kind: ExternalArtifact
name: infra-artifact
dependsOn:
# Currently inert -- a ComputeClass is cluster-scoped and this path holds
# nothing namespaced. Kept as future-proofing: this is the GCP cluster's only
# infrastructure Kustomization, so the first namespaced resource added here
# would otherwise race its namespace, which is a failure that presents as a
# dependency cascade rather than as a missing namespace.
- name: namespaces
14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-08-23-gcp-foundation-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ flux reconcile source git flux-system
failure mode, `depends_on` gaps, and the `clusters/gcp-mycluster-0/` Flux wiring.
Two review agents produced nothing across repeated asks.

### GPU quota — blocks slice 4's last criterion

`GPUS_ALL_REGIONS` on project `ogenki-435905` is **0**, so no GPU node can be
created at any price. Criterion 15 ("a GPU pod with no `runtimeClassName` sees
the device via `nvidia-smi`") is therefore blocked rather than failed — the
`gpu-l4` ComputeClass is proven correct up to the point of provisioning.

Do **not** diagnose this from the regional quota: `NVIDIA_L4_GPUS` in
`europe-west4` shows a limit of 1, which looks fine and is meaningless while the
global cap is zero. Spot attempts fail with `GCE out of resources` and on-demand
with `GCE quota exceeded`; only the second points at the real cause.

Closing it needs a quota increase request to Google, not a retry.

### ClusterMesh prerequisites (recorded in ADR-0017, not implemented)

- `cluster.id` is unset on both clusters; ClusterMesh needs a unique 1–255 ID.
Expand Down
38 changes: 37 additions & 1 deletion docs/superpowers/specs/2026-08-18-gcp-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,16 +351,52 @@ Falsifiable, verified against a live cluster.
11. A written monthly run-rate estimate exists (cluster fee, static pool, Cloud NAT, Cloud DNS,
Tailscale instance) stating the zonal-vs-regional choice and its price delta.

**Slice 4 (autoscaling)**
**Slice 4 (autoscaling)** — *results recorded 2026-08-24, measured on gcp-mycluster-0.
Four PASS, one partial, one blocked on a GCP quota. Each is annotated below.*

*Per-class status: `general-purpose` and `io` are both VERIFIED by live scale-up —
`io` provisioned an `n2-standard-4` spot node with a 375 GiB Local SSD attached
(368 GiB ephemeral on a 50 GB boot disk). `gpu-l4` is proven up to the point of
provisioning and then blocked by `GPUS_ALL_REGIONS: 0` (see criterion 15).*

*A predicted second quota blocker did NOT materialise: `PREEMPTIBLE_LOCAL_SSD_GB`
reads 0 in `europe-west4`, which looked like it would stop `io` for the same
reason GPU is stopped. It does not — GKE-managed Local SSD on spot nodes
provisions regardless. Recorded so nobody re-derives the false alarm.*


12. A **freshly auto-created** node carries `node.cilium.io/agent-not-ready` at registration, and
Cilium clears it. *This is the criterion the slice exists to test.*
- **PASS.** Set via `spec.nodePoolConfig.taints`; after scale-up the nodes retained only GKE's
own `cloud.google.com/compute-class` taint. **Unanticipated finding:** every workload
targeting a ComputeClass must *tolerate* that taint or NOTHING SCALES UP — the autoscaler
judges an untolerating pod unplaceable on a node that will carry it. Ten minutes Pending,
no `TriggeredScaleUp` at all. See ADR-0006.
13. Across 5 scale-up cycles, 0 pods record `FailedCreatePodSandBox` referencing a missing CNI.
- **PARTIAL.** As worded it holds — nothing recorded a *missing* CNI. But tolerating pods can
land before the Cilium agent is up and log `plugin type="cilium-cni" failed (add) ... EOF`,
which is the CNI present with its agent starting. Self-heals (0 restarts);
`kube-system/metrics-server` hits the same on any fresh node. The toleration required by
criterion 12 is what reopens this window.
14. `imageType` on every auto-created pool matches the pinned value; general-purpose nodes are
spot with **zero** on-demand fallback.
- **PASS.** Auto-created nodes came up `e2-highcpu-4` with `spot=true` on `cos_containerd`.
NAP chose `highcpu` over `standard` unprompted — cheaper per vCPU.
15. A GPU pod with **no** `runtimeClassName` sees the device via `nvidia-smi`.
- **BLOCKED — not failed.** Untestable in this project: `GPUS_ALL_REGIONS` is **0**, so no GPU
node can be created at any price. The per-region `NVIDIA_L4_GPUS: 1` is meaningless beneath
it — checking only the regional quota would wrongly suggest retrying later.
Spot attempt gave `GCE out of resources`; on-demand gave `GCE quota exceeded`.
Everything up to the GPU is proven: the class is selected, NAP resolves it to a
`g2-standard-4-gpu1` pool and attempts creation — which also confirms
`cluster_autoscaling.gpu_resources` is load-bearing (left empty, no scale-up is even
attempted). **Needs a GPUS_ALL_REGIONS quota grant from Google to close.**
16. Cluster `resourceLimits` set; an oversized workload stays `Unschedulable` at the ceiling.
- **PASS.** A 64-vCPU pod against a 32-vCPU ceiling held `Pending` for 4 minutes, node count
never moved, autoscaler logged `NotTriggerScaleUp`. It refuses rather than grinding.
17. Empty auto-created pools are removed on scale-down.
- **PASS.** All four auto-created nodes were reaped after the probe was deleted; back to the
2 static nodes with no intervention. `OPTIMIZE_UTILIZATION` was chosen for this.

**Slice 5 (identity)**

Expand Down
56 changes: 56 additions & 0 deletions infrastructure/gcp-mycluster-0/computeclass/general-purpose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# General-purpose ComputeClass — the default shape for auto-provisioned nodes.
#
# The GCP counterpart to infrastructure/base/karpenter-nodepools/default-nodepool.yaml.
# ADR-0006 chose GKE node auto-provisioning over Karpenter here; this object is
# what turns that decision into running nodes, telling NAP which machine shapes
# to try and in what order.
apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
name: general-purpose
spec:
# Ordered fallback: NAP walks these in sequence and takes the first it can
# provision, so this is a preference order rather than a set.
#
# e2 first because it is what the static pool runs and it is the cheapest
# general-purpose family. n2 second as a spot-availability fallback: a GKE node
# pool takes ONE machine type, so a single family concentrates interruption
# risk — the breadth the static pool's own comment defers to ComputeClass.
priorities:
- machineFamily: e2
spot: true
- machineFamily: n2
spot: true

# No on-demand entry, deliberately (design criterion 14). On a test cluster an
# unnoticed fallback to on-demand is a silent cost regression, and these
# workloads are restartable. The honest consequence: when no spot capacity
# exists in either family, pods stay Pending rather than becoming expensive.
whenUnsatisfiable: DoNotScaleUp

nodePoolConfig:
# ┌──────────────────────────────────────────────────────────────────────┐
# │ WORKLOADS TARGETING THIS CLASS MUST TOLERATE THIS TAINT │
# └──────────────────────────────────────────────────────────────────────┘
#
# tolerations:
# - key: node.cilium.io/agent-not-ready
# operator: Exists
# effect: NoSchedule
#
# Not optional: WITHOUT it nothing scales up at all — the autoscaler judges
# an untolerating pod unplaceable on a node that will carry the taint and
# never provisions one. Measured 2026-08-24. Full rationale, the trade it
# accepts, and the AWS divergence are in ADR-0006 (Negative consequences);
# this file states the requirement, the ADR explains it.
taints:
- key: node.cilium.io/agent-not-ready
value: "true"
effect: NoSchedule

# Must match the static pool and the NAP default — see the image_type note in
# opentofu/gcp/gke/init/main.tf for why COS specifically.
imageType: cos_containerd

nodePoolAutoCreation:
enabled: true
99 changes: 99 additions & 0 deletions infrastructure/gcp-mycluster-0/computeclass/gpu-l4.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# GPU ComputeClass — the GCP counterpart to
# infrastructure/base/karpenter-nodepools-gpu/gpu-l4-nodepool.yaml.
#
# NVIDIA L4, matching the AWS pool's accelerator choice and the reason
# europe-west4 was picked in the first place: opentofu/config.tm.hcl records that
# nvidia-l4 exists in all three of its zones and NOT AT ALL in europe-west9,
# which would otherwise have been the geographic match for eu-west-3.
#
# NO runtimeClassName IS NEEDED HERE, and that is a real divergence from AWS
# rather than an omission. On AWS the Bottlerocket NVIDIA variant requires
# `runtimeClassName: nvidia` and a matching RuntimeClass
# (infrastructure/base/runtimeclass-nvidia/). GKE installs the driver through its
# own DaemonSet and wires the default runtime, so a GPU pod just requests
# `nvidia.com/gpu` and works -- which is exactly design criterion 15: "a GPU pod
# with NO runtimeClassName sees the device via nvidia-smi".
apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
name: gpu-l4
spec:
# g2 is the only family that carries L4, so there is no fallback tier to list:
# either L4 spot capacity exists in this zone or nothing is provisioned.
#
# count: 1 mirrors the AWS pool's instance-gpu-count ["1"] -- one accelerator
# per node keeps the failure domain small and the bill legible, and nothing on
# this platform yet needs multi-GPU nodes.
priorities:
- machineFamily: g2
spot: true
gpu:
type: nvidia-l4
count: 1
storage:
# REQUIRED, and the only reason this block exists. NAP's cluster-wide
# boot-disk default is `pd-standard` (opentofu/gcp/gke/init/main.tf), and
# the G2 series does NOT support it — Google's PD matrix allows only
# pd-ssd, pd-balanced and hyperdisk on G2. The CRD says as much:
# "available boot disk types depend on the machine family".
#
# Left unset, this class can never provision: NAP either filters g2 out
# as unsatisfiable or the pool create fails on disk type, and with
# `whenUnsatisfiable: DoNotScaleUp` no event names the disk. A GPU pod
# simply stays Pending — the silent mode the gpu_resources note warns
# about. `--dry-run=server` cannot catch it either: it never reaches VM
# creation.
#
# Overridden HERE rather than by changing the cluster-wide default,
# because e2 and n2 both support pd-standard and are the common case.
# Only GPU nodes pay for the dearer disk.
bootDiskType: pd-balanced

# Spot GPUs are markedly scarcer than spot CPU, so this class will sometimes
# provision nothing at all. That is the intended behaviour on a test cluster:
# an L4 falling back to on-demand is the single most expensive thing this
# repository could do by accident.
whenUnsatisfiable: DoNotScaleUp

nodePoolConfig:
# ┌──────────────────────────────────────────────────────────────────────┐
# │ WORKLOADS MUST TOLERATE **BOTH** TAINTS BELOW │
# └──────────────────────────────────────────────────────────────────────┘
#
# tolerations:
# - key: node.cilium.io/agent-not-ready
# operator: Exists
# effect: NoSchedule
# - key: nvidia.com/gpu
# operator: Exists
# effect: NoSchedule
#
# The Cilium taint is not optional — without it nothing scales up. See
# general-purpose.yaml and ADR-0006.
#
# `nvidia.com/gpu` is the conventional accelerator taint, identical to the
# AWS pool's, so a GPU workload's tolerations are portable between clouds
# even though everything under them differs.
#
# UNVERIFIED (no GPU node has existed — see the quota note in the design's
# criterion 15): GKE applies its own `nvidia.com/gpu=present:NoSchedule` to
# GPU nodes, and Kubernetes forbids two taints sharing key+effect. So either
# GKE's value wins and the `"true"` below is inaccurate, or the pool create
# errors. The toleration shown above uses `operator: Exists`, which survives
# both outcomes — use that form, NOT `operator: Equal` with a value. Confirm
# the node's real taints on the first successful GPU scale-up and correct
# this block.
taints:
- key: node.cilium.io/agent-not-ready
value: "true"
effect: NoSchedule
- key: nvidia.com/gpu
value: "true"
effect: NoSchedule

# Must match the static pool and the NAP default — see the image_type note in
# opentofu/gcp/gke/init/main.tf for why COS specifically.
imageType: cos_containerd

nodePoolAutoCreation:
enabled: true
85 changes: 85 additions & 0 deletions infrastructure/gcp-mycluster-0/computeclass/io.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# IO-optimised ComputeClass — the GCP counterpart to
# infrastructure/base/karpenter-nodepools/io-nodepool.yaml.
#
# For workloads that want local NVMe rather than network-attached disk. The
# `ogenki/io` taint key is kept IDENTICAL to the AWS NodePool's on purpose: it is
# a platform convention, not a cloud detail, so a workload's toleration is the
# one part of its manifest that does not have to change between clouds.
apiVersion: cloud.google.com/v1
kind: ComputeClass
metadata:
name: io
spec:
# machineType, NOT machineFamily — and that is forced, not stylistic. GKE's
# admission webhook rejects the family form outright:
#
# compute-class "io" doesn't support local ssd without machine type or
# GPU config
#
# Local SSD attachment is a per-machine-type property, so the class has to name
# the type. The cost is a narrower fallback list than general-purpose gets:
# two explicit sizes rather than "any n2".
#
# localSSDCount is what makes this class different from general-purpose. GCP
# attaches Local SSD as fixed 375 GiB devices, so the count is a device count,
# not a size.
#
# MEASURED 2026-08-24, because it was not obvious: the Local SSD does back POD
# EPHEMERAL STORAGE, it is not merely a raw block device. A provisioned node
# reported 385926528Ki (~368 GiB) of ephemeral-storage capacity on a 50 GB boot
# disk, which is the Local SSD and nothing else. So this class does give
# ephemeral-hungry pods what they need, and the parity with the AWS io pool
# (which uses `instanceStorePolicy: RAID0` to the same end) holds.
#
# Both entries are n2: the smaller first so a modest IO workload does not
# provision a large node, with the larger as the capacity fallback. Sticking to
# one family keeps this predictable; c3 would need its distinct `-lssd` machine
# types, which is a second thing to get right for no benefit on a test cluster.
priorities:
- machineType: n2-standard-4
spot: true
storage:
localSSDCount: 1
- machineType: n2-standard-8
spot: true
storage:
localSSDCount: 1

# Same rationale as general-purpose: criterion 14 forbids on-demand fallback,
# so exhausted spot leaves pods Pending rather than quietly costing more.
whenUnsatisfiable: DoNotScaleUp

nodePoolConfig:
# ┌──────────────────────────────────────────────────────────────────────┐
# │ WORKLOADS MUST TOLERATE **BOTH** TAINTS BELOW │
# └──────────────────────────────────────────────────────────────────────┘
#
# tolerations:
# - key: node.cilium.io/agent-not-ready
# operator: Exists
# effect: NoSchedule
# - key: ogenki/io
# operator: Exists
# effect: NoSchedule
#
# The Cilium taint is not optional — without it nothing scales up. See
# general-purpose.yaml and ADR-0006.
#
# `ogenki/io` is this class's own: it keeps general-purpose workloads off
# nodes provisioned for their local disk, which are fewer and dearer.
# Identical key and value to the AWS io NodePool, so a workload's
# tolerations are portable between clouds.
taints:
- key: node.cilium.io/agent-not-ready
value: "true"
effect: NoSchedule
- key: ogenki/io
value: "true"
effect: NoSchedule

# Must match the static pool and the NAP default — see the image_type note in
# opentofu/gcp/gke/init/main.tf for why COS specifically.
imageType: cos_containerd

nodePoolAutoCreation:
enabled: true
10 changes: 10 additions & 0 deletions infrastructure/gcp-mycluster-0/computeclass/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

# The three classes of design slice 4. general-purpose was proven first, on its
# own, because it carried the unknown the slice existed to settle (the Cilium
# taint); io and gpu-l4 follow the pattern it established.
resources:
- general-purpose.yaml
- io.yaml
- gpu-l4.yaml
16 changes: 16 additions & 0 deletions infrastructure/gcp-mycluster-0/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

# GCP-only infrastructure for gcp-mycluster-0.
#
# Separate from infrastructure/mycluster-0/ rather than an overlay on it: that
# tree is AWS-shaped (Karpenter NodePools, aws-load-balancer-controller,
# EKS Pod Identities) and shares no resources with this one. A ComputeClass has
# no AWS counterpart at all -- ADR-0006 chose NAP over Karpenter precisely
# because the two autoscalers do not share an API.
#
# This is the manifest-side counterpart to the opentofu/{aws,gcp,shared} split,
# and is deliberately minimal until the design's slices 6-7 settle which parts of
# the shared tree are genuinely cloud-neutral.
resources:
- computeclass
Loading
Loading