{"openapi":"3.1.0","info":{"title":"ig1-factory","description":"IG1 KaaS factory — cluster-on-demand via kamaji + CAPO","version":"0.6.5"},"paths":{"/health/live":{"get":{"tags":["health"],"summary":"Liveness","operationId":"liveness_health_live_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/health/ready":{"get":{"tags":["health"],"summary":"Readiness","description":"Report upstream reachability; ALWAYS 200 while this pod can serve.\n\n2026-08-17 (gotcha 175, the same defect fixed in the api). This 503'd when\nthe k8s API *or* the events bus was unreachable, and\nk8s/gitops/addons/factory/deployment.yaml wires it as the kubelet's\nreadinessProbe (every 10 s) — so a slow upstream took factory out of its own\nService, the surviving replica took all the load, and its probes then failed\ntoo. Observed on this cloud the same afternoon as the api's:\n`Readiness probe failed: HTTP probe failed with statuscode: 503`.\n\nThe events gate was the worse half: it made ONE service's wobble evict a\nDIFFERENT service, so a Kafka hiccup could remove the cluster factory from\nthe mesh. A dependency problem belongs in the per-request answer, and this\nservice already has that — cluster operations surface their own errors.\n\nNothing is hidden: both probe results and `status: degraded` still ride in\nthe body, which is where /v1/status and the dashboards read them.","operationId":"readiness_health_ready_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/clusters":{"get":{"tags":["clusters"],"summary":"List Clusters","description":"The caller's clusters: CAPI Cluster phase merged with KCP/TCP\nversion+endpoint and MachineDeployment worker readiness.\n\nScoped to the caller's tenant by the ig1.tenant/project-id owner label\n(W9/B — this used to return every cluster in the shared managed\nnamespace to every authenticated caller). Admins see all, unlabeled\nlegacy clusters included.\n\nPhase 47: a customer lists their OWN namespace, so the owner-label filter\nbelow became a second line of defence rather than the only one. An admin\nstill crosses every namespace, which is where the (namespace, name)\nkeying below matters.","operationId":"list_clusters_v1_clusters_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterList"}}}}},"security":[{"HTTPBearer":[]}]},"post":{"tags":["clusters"],"summary":"Create Cluster","description":"Provision a tenant cluster: CAPI Cluster + KamajiControlPlane, wait\nfor the kamaji-hosted control plane to announce its endpoint (pinning a\nunique NodePort first — gotcha 36), then the CAPO worker plane.\n\nTier >= 1 (require_cluster_access). The cluster is stamped with the\ncaller's RESOLVED project id as its owner (W9/B) — never a body field:\nan owner a client can assert is not a boundary.\n\nADMISSION BEFORE ACTUATION (audit gap B3): the tenant's kaas.clusters\ncount is checked after the name checks and before anything is created or\nany lifecycle event is emitted — a refused cluster never existed. See\n_cluster_admission_or_raise for the fail-closed rules.\n\nPHASE 47 — the name checks below are scoped to the CALLER'S namespace.\nThey used to run against one shared namespace, which meant tenant B could\nnot create `prod` while tenant A held it, and the 409 saying so confirmed\nthat A's cluster existed — the exact disclosure tenancy.py's 404-not-403\nrule exists to prevent, reintroduced on the create path. Two tenants may\nnow both hold `prod`, and neither can learn anything about the other by\nasking.","operationId":"create_cluster_v1_clusters_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/clusters/versions":{"get":{"tags":["clusters"],"summary":"List Supported Versions","description":"The Kubernetes versions this platform will provision or upgrade to.\n\nPublished so a caller can offer a real choice instead of guessing and\nmeeting a 422: the console fills its dropdown from here, and the CLI and\nagent tools validate against it.","operationId":"list_supported_versions_v1_clusters_versions_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/clusters/{name}":{"get":{"tags":["clusters"],"summary":"Get Cluster","description":"One cluster of the CALLER'S tenant — another tenant's cluster answers\n404, exactly like a name that does not exist (W9/B).","operationId":"get_cluster_v1_clusters__name__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["clusters"],"summary":"Delete Cluster","description":"Deprovision: delete the CAPI Cluster (ownerRefs cascade the KCP,\nOpenStackCluster, templates and MachineDeployment) then the\nprovider-owned TenantControlPlane explicitly, in case GC lags.\n\nTier 2 and the OWNING tenant only. This is the path the 2026-08-08 audit\nfound wide open (§2.1): a by-name delete behind an identity-only gate,\nreachable with any customer token — including a tier-0 read-only agent\ncredential — on a service published on the customer VIP.","operationId":"delete_cluster_v1_clusters__name__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/scale":{"patch":{"tags":["clusters"],"summary":"Scale Cluster","description":"Scale the worker plane: patch the MachineDeployment's replicas.\n\nTier >= 1, and the OWNING tenant only — the ownership check runs BEFORE\nthe patch (W9/B): a mutation that authorizes afterwards has already\nhappened.","operationId":"scale_cluster_v1_clusters__name__scale_patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScaleRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/autoscaling":{"patch":{"tags":["clusters"],"summary":"Set Cluster Autoscaling","description":"Enable, retune or disable worker autoscaling for one cluster.\n\nTier >= 1 (a PATCH) and the OWNING tenant only — another tenant's cluster\nanswers 404, exactly like a name that does not exist. Enabling sets the\ntwo cluster-autoscaler annotations on the MachineDeployment; disabling\nclears them. THE ANNOTATIONS ARE THE STORE: reads (the cluster summary)\nand the autoscaler itself both consume them directly, so there is no\nsecond record to drift.\n\nADMISSION BEFORE ACTUATION. `max` is what the autoscaler may reach with\nno further API call, so `max` — not the current replica count — is what\nthe tenant's tier grant admits: the requested ceiling plus the worst case\nof the tenant's other pools (each charged at ITS ceiling when autoscaled,\nits desired count otherwise) must fit kaas.worker_nodes\n(config/quota-tiers.yml). A breach is a 409 stating all the numbers;\nan unreachable grant is a 503, never a pass (fail closed). Admin callers\nskip the check — they are not scoped to a tenant, and an admin widening a\nceiling is the operator making a capacity decision.\n\nmin=0 PARKS THE POOL: the autoscaler may drain every worker when nothing\nneeds them. Honest caveat, from the upstream README (checked 2026-08-13):\nscaling to/from zero additionally needs the node-group CAPACITY\nannotations because CAPO does not implement the opt-in from-zero\ncontract — wiring those from the flavor is a recorded production item,\nso today a pool parked at 0 restarts on the next manual range change.\n\nWhile autoscaling is enabled, PATCH /v1/clusters/{name}/scale is refused\n(two writers fighting over spec.replicas is the incident); the refusal\nnames this endpoint as the way out. The autoscaler INSTANCE for the\ncluster is deployment-side (one per workload cluster — upstream takes\nexactly one workload kubeconfig): k8s/gitops/addons/cluster-autoscaler/\nships the pilot's, ansible/playbooks/phase-41-cluster-autoscaler.yml\nstamps tenant instances from that same template.","operationId":"set_cluster_autoscaling_v1_clusters__name__autoscaling_patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutoscalingRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AutoscalingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/nodes":{"get":{"tags":["clusters"],"summary":"List Cluster Nodes","description":"The worker nodes of a cluster you own (tier 0).\n\nOne row per CAPI Machine: the Machine name, the Kubernetes Node it became,\nits phase, the Nova instance behind it (providerID), its Kubernetes\nversion and whether it is Ready.\n\nUntil now the platform reported worker counts and nothing else — a tenant\ncould see \"2/3 ready\" and had no way to learn WHICH node was not, so the\nonly next step was a kubeconfig download and kubectl. Ownership is checked\nthe same way every other cluster route checks it: another tenant's cluster\nanswers 404, exactly like a name that does not exist.","operationId":"list_cluster_nodes_v1_clusters__name__nodes_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeList"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/nodes/{machine}":{"delete":{"tags":["clusters"],"summary":"Recycle Cluster Node","description":"Recycle ONE worker node: delete its CAPI Machine.\n\nTHIS DOES NOT PERMANENTLY REMOVE CAPACITY. Deleting the Machine object is\nhow CAPI is ASKED to recycle a node: the MachineDeployment controller\ncordons and drains it, deletes the Nova instance, and then — because the\nMachineDeployment's replica count is unchanged — creates a REPLACEMENT to\nget back to the desired count. It is the supported repair for a node that\nis wedged, NotReady, or sitting on bad hardware. To actually shrink a\ncluster, PATCH /v1/clusters/{name}/scale instead; that is the endpoint\nthat changes how many workers exist.\n\nTier 2 (a DELETE) and the OWNING tenant only, checked before anything is\nremoved. Three refusals, each covering a different way this could destroy\nsomething the caller did not intend:\n\n  · DELETE PROTECTION (409) — a protected cluster refuses node recycling\n    too. Protection means \"do not take capacity away from this cluster\";\n    honouring it on the cluster delete but not here would leave the guard\n    with a hole exactly the width of one node at a time.\n  · THE LAST REPLICA (409) — with spec.replicas <= 1 the drain has nowhere\n    to move the workloads and the cluster is briefly empty of workers. The\n    caller asked to recycle a node, not to empty the cluster, so this is\n    refused and they are pointed at the scale endpoint, where emptying a\n    cluster is the stated intent rather than a side effect.\n  · A MACHINE THAT IS NOT THIS CLUSTER'S (404) — the Machine name comes\n    from the caller and every tenant's Machines share the managed\n    namespace. Without checking spec.clusterName, naming another tenant's\n    machine under YOUR cluster's path would delete their node: the\n    ownership check on {name} would pass and nothing else would look at\n    the machine. The mismatch answers 404 rather than 403 for the usual\n    reason — a 403 would confirm the machine exists.","operationId":"recycle_cluster_node_v1_clusters__name__nodes__machine__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"machine","in":"path","required":true,"schema":{"type":"string","title":"Machine"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeRecycled"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/upgrade":{"post":{"tags":["clusters"],"summary":"Upgrade Cluster","description":"Upgrade the control plane, then roll the workers.\n\nTier >= 1 and the OWNING tenant only, checked BEFORE anything is patched.\n\nThree refusals, each protecting against a different way to break a live\ncluster:\n\n  · a version this platform does not support (422) — CAPO needs a node\n    image per Kubernetes minor, so an arbitrary version produces workers\n    that never join;\n  · a downgrade (422) — kubelets do not go backwards, and etcd's storage\n    version may already have moved;\n  · a minor skip (422) — Kubernetes supports one minor at a time, and\n    skipping strands the control plane's API versions ahead of the\n    components that read them.\n\nThe order is control plane FIRST, then workers: a worker may run one minor\nBEHIND its control plane, never ahead. Doing it the other way round is a\nsupported-skew violation that surfaces as pods failing to schedule.","operationId":"upgrade_cluster_v1_clusters__name__upgrade_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpgradeRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClusterSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/protection":{"patch":{"tags":["clusters"],"summary":"Set Cluster Protection","description":"Turn delete protection on or off.\n\nEnabling is tier 1 (adding a safety catch should be easy). DISABLING is\nchecked here at tier 2, because removing the guard is the dangerous half:\na leaked tier-1 credential must not be able to unlock a cluster and then\ndelete it, which would make the protection theatre.","operationId":"set_cluster_protection_v1_clusters__name__protection_patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProtectionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/clusters/{name}/kubeconfig":{"get":{"tags":["clusters"],"summary":"Get Kubeconfig","description":"The kamaji-published admin kubeconfig for a cluster you own.\n\nRE-GATED 2026-08-12 from admin-only to owner-or-admin. Admin-only was not\na security posture, it was an accident with a security shape: customers\nwere left with NO first-class way to reach their own cluster, so both the\nCLI and the agent surface worked around it by reading the Kubernetes\nSecret through the raw /v1/kubernetes proxy — which is itself admin-gated\nby default. The result was a platform whose Kubernetes product could not\nbe used by the tenants who paid for it, and two client-side workarounds\nthat would have been the real problem the day the proxy opened.\n\nThe kubeconfig grants cluster-admin on the tenant's OWN control plane and\nnothing else, so scoping it to the owner gives away nothing the tenant did\nnot already own. Ownership is checked the same way every other mutation\nchecks it, and the content is never logged.","operationId":"get_kubeconfig_v1_clusters__name__kubeconfig_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/workload-identity":{"get":{"tags":["workload-identity"],"summary":"List Workload Identities","description":"Which of the caller's clusters can exchange pod tokens for IG1\ncredentials, and the issuer each one signs with.\n\nTier 0 (read). Scoped exactly like list_clusters — the owner label, and\nthe caller's own namespace unless they are an admin.","operationId":"list_workload_identities_v1_workload_identity_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkloadIdentityList"}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/clusters/{name}/workload-identity":{"get":{"tags":["workload-identity"],"summary":"Get Workload Identity","description":"One cluster's workload-identity state. Tier 0 (read).","operationId":"get_workload_identity_v1_clusters__name__workload_identity_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkloadIdentity"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["workload-identity"],"summary":"Enable Workload Identity","description":"Let this cluster's pods exchange service-account tokens for IG1\ncredentials, by pointing its apiserver at its own OIDC issuer.\n\nTier >= 1, owner only, and IDEMPOTENT: a cluster created since 60.4\nalready carries the flags, so this reports the state rather than\npatching it. Adding them triggers a rolling restart of the tenant\ncontrol plane, which is why doing it twice must be free.\n\nRefused when the control plane has no endpoint yet — the endpoint IS the\nissuer, so there is nothing to point at and a patch would write a flag\nthat names nothing.","operationId":"enable_workload_identity_v1_clusters__name__workload_identity_put","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkloadIdentity"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["workload-identity"],"summary":"Disable Workload Identity","description":"Stop this cluster's pods exchanging tokens for IG1 credentials.\n\nTier 2, owner only, and idempotent. Tokens ALREADY minted keep working\nuntil they expire — this removes the apiserver's issuer, so no NEW\nservice-account token can be verified, and it triggers a rolling restart\nof the tenant control plane.","operationId":"disable_workload_identity_v1_clusters__name__workload_identity_delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"project","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token.","title":"Project"},"description":"Platform admins only: the Keystone project owning the cluster. Required when the same cluster name exists in more than one tenant, which answers 409 without it. Ignored for customers, whose clusters resolve from their own token."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkloadIdentity"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/asgs":{"get":{"tags":["asgs"],"summary":"List Asgs","description":"The caller's autoscaling groups (tier 0 — GET carries no tier floor).\nFiltered by the same visibility rule every store-backed surface uses:\nadmins see all, a customer sees only groups stamped with their own\nresolved project.","operationId":"list_asgs_v1_asgs_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgList"}}}}},"security":[{"HTTPBearer":[]}]},"post":{"tags":["asgs"],"summary":"Create Asg","description":"Create an autoscaling group (tier >= 1).\n\nThe group is stamped with the caller's RESOLVED project — never a body\nfield. Instances boot under that project's own application credential,\nwhich is why a platform-admin token (no project scope) is refused here:\na group with no owning tenant has no credential to actuate with, and the\nfactory does not borrow the admin scope for anyone (CLAUDE.md §3).\n\nAdmission runs BEFORE the group is stored when desired > 0, so a caller\nover their tier grant gets the numbers now, not a degraded entry later.","operationId":"create_asg_v1_asgs_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/asgs/{name}":{"get":{"tags":["asgs"],"summary":"Get Asg","description":"One group of the CALLER'S tenant — another tenant's group answers 404,\nexactly like a name that does not exist. The view carries the honest\nstate: members with their drain/health standing, the persisted cooldown,\nand every reason the engine could not act (`degraded`).","operationId":"get_asg_v1_asgs__name__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["asgs"],"summary":"Scale Asg","description":"The manual scale path (tier >= 1): move min/max/desired.\n\nOrdering (min <= desired <= max) is validated against the MERGED values\ninside the store's compare-and-swap — validating against a snapshot and\nwriting against a fresher map is how contradictory bounds slip in.\nA raised desired passes tenant-quota admission first and the refusal\nnames the numbers. A manual change starts the cooldown clock: the human\nacted, and the alarms should not fight the human for the next window.\nActual boots and drains happen in the reconcile pass, bounded and\ndrain-first as always.","operationId":"scale_asg_v1_asgs__name__patch","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsgDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["asgs"],"summary":"Delete Asg","description":"Delete a group (tier 2) — 202, not 204, because deletion here is a\nPROCESS, not a row drop: members are drained from the LB pool FIRST,\nthe drain window is honoured, instances are deleted only then, and the\ngroup document is removed when the last one is gone. The reconciler owns\nthat sequence (it survives restarts because every deadline is in the\nstore); this route only records the intent. Deleting the document first\nand letting the VMs leak would have been easier and wrong twice.","operationId":"delete_asg_v1_asgs__name__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/":{"get":{"tags":["root"],"summary":"Root","operationId":"root__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"AsgDetail":{"properties":{"name":{"type":"string","title":"Name"},"project_id":{"type":"string","title":"Project Id"},"min":{"type":"integer","title":"Min"},"max":{"type":"integer","title":"Max"},"desired":{"type":"integer","title":"Desired"},"current":{"type":"integer","title":"Current"},"status":{"type":"string","enum":["active","deleting"],"title":"Status"},"degraded":{"items":{"type":"string"},"type":"array","title":"Degraded"},"cooldown_until":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cooldown Until"},"flavor":{"type":"string","title":"Flavor"},"image":{"type":"string","title":"Image"},"network_id":{"type":"string","title":"Network Id"},"health_check":{"$ref":"#/components/schemas/HealthCheck"},"lb_pool_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Lb Pool Id"},"lb_pool_member_port":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Lb Pool Member Port"},"scale_out_alarm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scale Out Alarm"},"scale_in_alarm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scale In Alarm"},"cooldown_seconds":{"type":"integer","title":"Cooldown Seconds"},"members":{"items":{"$ref":"#/components/schemas/AsgMemberView"},"type":"array","title":"Members"},"last_scale":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Last Scale"},"last_reconciled_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Reconciled At"},"created_by":{"type":"string","title":"Created By","default":""},"created_at":{"type":"string","title":"Created At","default":""}},"type":"object","required":["name","project_id","min","max","desired","current","status","flavor","image","network_id","health_check","cooldown_seconds"],"title":"AsgDetail"},"AsgList":{"properties":{"count":{"type":"integer","title":"Count"},"groups":{"items":{"$ref":"#/components/schemas/AsgSummary"},"type":"array","title":"Groups"}},"type":"object","required":["count","groups"],"title":"AsgList"},"AsgMemberView":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"state":{"type":"string","title":"State"},"nova_status":{"type":"string","title":"Nova Status"},"tcp_fails":{"type":"integer","title":"Tcp Fails"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["id","name","state","nova_status","tcp_fails","created_at"],"title":"AsgMemberView"},"AsgPatch":{"properties":{"min":{"anyOf":[{"type":"integer","maximum":60.0,"minimum":0.0},{"type":"null"}],"title":"Min"},"max":{"anyOf":[{"type":"integer","maximum":60.0,"minimum":1.0},{"type":"null"}],"title":"Max"},"desired":{"anyOf":[{"type":"integer","maximum":60.0,"minimum":0.0},{"type":"null"}],"title":"Desired"}},"type":"object","title":"AsgPatch","description":"PATCH /v1/asgs/{name} — the manual scale path. Ordering against the\nMERGED values (fresh from the store, inside the CAS) is validated by the\nroute; this model only bounds the individual fields."},"AsgRequest":{"properties":{"name":{"type":"string","maxLength":24,"minLength":3,"pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","title":"Name"},"flavor":{"type":"string","maxLength":64,"minLength":1,"title":"Flavor"},"image":{"type":"string","maxLength":128,"minLength":1,"title":"Image"},"network":{"anyOf":[{"type":"string","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"},{"type":"null"}],"title":"Network"},"min":{"type":"integer","maximum":60.0,"minimum":0.0,"title":"Min"},"max":{"type":"integer","maximum":60.0,"minimum":1.0,"title":"Max"},"desired":{"anyOf":[{"type":"integer","maximum":60.0,"minimum":0.0},{"type":"null"}],"title":"Desired"},"user_data":{"anyOf":[{"type":"string","maxLength":49152},{"type":"null"}],"title":"User Data"},"health_check":{"$ref":"#/components/schemas/HealthCheck"},"lb_pool_id":{"anyOf":[{"type":"string","maxLength":64,"minLength":1},{"type":"null"}],"title":"Lb Pool Id"},"lb_pool_member_port":{"anyOf":[{"type":"integer","maximum":65535.0,"minimum":1.0},{"type":"null"}],"title":"Lb Pool Member Port"},"scale_out_alarm":{"anyOf":[{"type":"string","maxLength":128,"minLength":1},{"type":"null"}],"title":"Scale Out Alarm"},"scale_in_alarm":{"anyOf":[{"type":"string","maxLength":128,"minLength":1},{"type":"null"}],"title":"Scale In Alarm"},"cooldown_seconds":{"type":"integer","maximum":86400.0,"minimum":0.0,"title":"Cooldown Seconds","default":300}},"type":"object","required":["name","flavor","image","min","max"],"title":"AsgRequest","description":"POST /v1/asgs body. min <= desired <= max is enforced HERE — a group\nwhose bounds contradict each other must never reach the store, because\nthe reconciler clamps to these bounds forever after."},"AsgSummary":{"properties":{"name":{"type":"string","title":"Name"},"project_id":{"type":"string","title":"Project Id"},"min":{"type":"integer","title":"Min"},"max":{"type":"integer","title":"Max"},"desired":{"type":"integer","title":"Desired"},"current":{"type":"integer","title":"Current"},"status":{"type":"string","enum":["active","deleting"],"title":"Status"},"degraded":{"items":{"type":"string"},"type":"array","title":"Degraded"},"cooldown_until":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cooldown Until"}},"type":"object","required":["name","project_id","min","max","desired","current","status"],"title":"AsgSummary"},"AutoscalingConfig":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false},"min":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min"},"max":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max"}},"type":"object","title":"AutoscalingConfig","description":"What the annotations currently say. `enabled` mirrors the upstream\nactivation rule (both annotations present); min/max are None when the\nannotation is absent or unparseable — reporting a number we could not\nread would be a healthy-looking lie."},"AutoscalingRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"min":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Min","default":0},"max":{"anyOf":[{"type":"integer","maximum":10.0,"minimum":0.0},{"type":"null"}],"title":"Max"}},"type":"object","required":["enabled"],"title":"AutoscalingRequest","description":"PATCH /v1/clusters/{name}/autoscaling body.\n\nBounds mirror ScaleRequest's per-pool ceiling (le=10) — that is the\nplatform's per-MachineDeployment bound; the TENANT bound (the tier's\nkaas.worker_nodes grant) is enforced in the handler, where the refusal\ncan state the numbers. min=0 is legal: it lets the autoscaler park the\npool at zero workers when nothing is scheduled."},"AutoscalingResponse":{"properties":{"name":{"type":"string","title":"Name"},"autoscaling":{"$ref":"#/components/schemas/AutoscalingConfig"}},"type":"object","required":["name","autoscaling"],"title":"AutoscalingResponse"},"ClusterDetail":{"properties":{"name":{"type":"string","title":"Name"},"namespace":{"type":"string","title":"Namespace"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint"},"phase":{"type":"string","title":"Phase","default":"Unknown"},"workers":{"$ref":"#/components/schemas/WorkerStatus"},"autoscaling":{"$ref":"#/components/schemas/AutoscalingConfig"},"created":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created"},"control_plane_ready":{"type":"boolean","title":"Control Plane Ready","default":false},"machine_deployment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Machine Deployment"},"load_balancers":{"items":{"$ref":"#/components/schemas/LBService"},"type":"array","title":"Load Balancers","default":[]}},"type":"object","required":["name","namespace"],"title":"ClusterDetail"},"ClusterList":{"properties":{"count":{"type":"integer","title":"Count"},"clusters":{"items":{"$ref":"#/components/schemas/ClusterSummary"},"type":"array","title":"Clusters"}},"type":"object","required":["count","clusters"],"title":"ClusterList"},"ClusterRequest":{"properties":{"name":{"type":"string","maxLength":32,"minLength":3,"pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","title":"Name"},"version":{"type":"string","pattern":"^v\\d+\\.\\d+\\.\\d+$","title":"Version","default":"v1.33.13"},"cp_replicas":{"type":"integer","maximum":3.0,"minimum":1.0,"title":"Cp Replicas","default":1},"worker_count":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Worker Count","default":1},"flavor":{"type":"string","maxLength":64,"minLength":1,"title":"Flavor","default":"m1.large"},"image":{"type":"string","maxLength":128,"minLength":1,"title":"Image","default":"Ubuntu-24.04"}},"type":"object","required":["name"],"title":"ClusterRequest"},"ClusterSummary":{"properties":{"name":{"type":"string","title":"Name"},"namespace":{"type":"string","title":"Namespace"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint"},"phase":{"type":"string","title":"Phase","default":"Unknown"},"workers":{"$ref":"#/components/schemas/WorkerStatus"},"autoscaling":{"$ref":"#/components/schemas/AutoscalingConfig"}},"type":"object","required":["name","namespace"],"title":"ClusterSummary"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthCheck":{"properties":{"type":{"type":"string","enum":["nova","tcp"],"title":"Type","default":"nova"},"port":{"anyOf":[{"type":"integer","maximum":65535.0,"minimum":1.0},{"type":"null"}],"title":"Port"}},"type":"object","title":"HealthCheck","description":"How a member proves it is serving.\n\n`nova` trusts Nova's own status (ACTIVE serving, ERROR dead) — always\navailable, blind to a wedged application. `tcp` additionally requires a\nTCP connect on `port` to succeed; ASG_TCP_FAIL_THRESHOLD consecutive\nfailures are required before a member is replaced, because one probe blip\nmust not delete a serving VM."},"LBService":{"properties":{"namespace":{"type":"string","title":"Namespace"},"name":{"type":"string","title":"Name"},"address":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Address"},"ports":{"items":{"type":"integer"},"type":"array","title":"Ports","default":[]}},"type":"object","required":["namespace","name"],"title":"LBService","description":"One type=LoadBalancer Service inside the tenant cluster (phase 53).\nThe address is what the customer publishes; absent means the OCCM is\nstill ensuring (or refusing — the Service's events say why)."},"NodeList":{"properties":{"cluster":{"type":"string","title":"Cluster"},"count":{"type":"integer","title":"Count"},"nodes":{"items":{"$ref":"#/components/schemas/NodeSummary"},"type":"array","title":"Nodes"}},"type":"object","required":["cluster","count","nodes"],"title":"NodeList"},"NodeRecycled":{"properties":{"cluster":{"type":"string","title":"Cluster"},"machine":{"type":"string","title":"Machine"},"replacement_expected":{"type":"boolean","title":"Replacement Expected","default":true},"detail":{"type":"string","title":"Detail"}},"type":"object","required":["cluster","machine","detail"],"title":"NodeRecycled"},"NodeSummary":{"properties":{"machine":{"type":"string","title":"Machine"},"node":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Node"},"phase":{"type":"string","title":"Phase","default":"Unknown"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"ready":{"type":"boolean","title":"Ready","default":false}},"type":"object","required":["machine"],"title":"NodeSummary","description":"One worker node, as CAPI models it.\n\n`machine` and `node` are DIFFERENT names and both are reported: `machine`\nis the CAPI Machine object (the handle the recycle endpoint takes), `node`\nis the Kubernetes Node it became once kubeadm-join completed. A caller\nholding only the Node name — which is what kubectl shows them — cannot\naddress the Machine, and reporting one of the two would force them to\nguess the mapping."},"ProtectionRequest":{"properties":{"protected":{"type":"boolean","title":"Protected","description":"Refuse deletion of this cluster while true"}},"type":"object","required":["protected"],"title":"ProtectionRequest"},"ScaleRequest":{"properties":{"worker_count":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Worker Count"}},"type":"object","required":["worker_count"],"title":"ScaleRequest"},"UpgradeRequest":{"properties":{"version":{"type":"string","pattern":"^v?1\\.\\d{1,3}\\.\\d{1,3}$","title":"Version","description":"Target Kubernetes version, e.g. v1.34.2"}},"type":"object","required":["version"],"title":"UpgradeRequest"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WorkerStatus":{"properties":{"desired":{"type":"integer","title":"Desired","default":0},"ready":{"type":"integer","title":"Ready","default":0}},"type":"object","title":"WorkerStatus"},"WorkloadIdentity":{"properties":{"id":{"type":"string","title":"Id"},"cluster":{"type":"string","title":"Cluster"},"namespace":{"type":"string","title":"Namespace"},"enabled":{"type":"boolean","title":"Enabled"},"issuer":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Issuer"},"jwks_uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jwks Uri"},"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint"}},"type":"object","required":["id","cluster","namespace","enabled"],"title":"WorkloadIdentity","description":"One cluster's ability to exchange pod tokens for IG1 credentials."},"WorkloadIdentityList":{"properties":{"workload_identities":{"items":{"$ref":"#/components/schemas/WorkloadIdentity"},"type":"array","title":"Workload Identities"}},"type":"object","required":["workload_identities"],"title":"WorkloadIdentityList"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}}}