← Back to dashboard

Kubernetes

The dashboard provisions (or imports) managed Kubernetes clusters and layers management + privileged access on top — the same provisioning + stacked layers model as Databases and Cloud VMs, adapted to Kubernetes:

The whole feature is gated by the k8s_management_enabled toggle (surfaces the /k8s page + /api/k8s; permission scope k8s).

Provider Provision Entra → RBAC federation End-user reach
AWS EKS ✅ Terraform (self-contained VPC) shared Entra app as the cluster's OIDC IdP API TCP tunnel + kubectl oidc-login
Azure AKS ✅ Terraform (self-contained VNet) native managed-AAD (federation is a no-op) API TCP tunnel + kubelogin
GCP GKE ✅ Terraform (self-contained VPC) Workforce Identity Federation + Connect Gateway Connect Gateway
OCI OKE ⚠️ experimental — Terraform (self-contained VCN) ❌ none API TCP tunnel (in-process kubectl)

You can also register/import an existing or local cluster (cloud = aws|azure|gcp|local, e.g. kind/k3s) from a full kubeconfig — no provisioning required. To build that on-prem cluster in the first place, examples/playbooks/k3s/ stands up k3s over SSH and emits a registration-ready kubeconfig. A registered cloud=local cluster is a Config Management target like any other, except that its runs execute in a sibling container on the dashboard host rather than on an in-cloud runner — an on-prem API endpoint is reachable from your LAN, not from an ECS task. That host therefore needs a docker CLI and a route to the cluster. One caveat documented with the playbooks: a registered kubeconfig is stored and used verbatim — no token is minted for it — so it is standing cluster-admin for every run made against it.

OCI OKE status. Provisioning is wired end to end — the module (terraform/k8s_cluster/oci_oke), _PROVISION_IMPLEMENTED, the provision_options pickers, and the oci (OKE) entry in the Provision modal. Three gaps keep it experimental:

Router/model docstrings that say "aws/azure/gcp only / 501" predate OKE.


Provision / register — per provider

All four Terraform modules (terraform/k8s_cluster/{aws_eks,azure_aks,gcp_gke,oci_oke}) are self-contained — each builds its own network (VPC/VNet + subnets + egress) so clusters don't consume sandbox subnets, and destroys it on decommission. Each exposes a stable egress IP (module output nat_public_ipk8s_clusters.egress_ip, auto-added to the Rancher firewall whitelist). Provisioning assembles an exec-auth kubeconfig from the module outputs, stores it as a secrets-backend reference, and flips the row to registered.

AWS EKS

Builds its own VPC (default 10.97.0.0/16 — must not overlap the sandbox 10.99.0.0/16; give each concurrent cluster a distinct block) with 1 public + 2 private subnets (EKS needs ≥2 AZs), an IGW, and a cheap NAT instance (arm64, holds an EIP for the stable egress IP). Notable specifics:

Config: aws_vpc_id (sandbox VPC to peer back to, import-only), aws_eks_vpc_cidr (10.97.0.0/16), aws_eks_k8s_version, aws_eks_node_instance_type — all editable in Settings → Kubernetes Management; the Provision modal's Cluster VPC CIDR field overrides aws_eks_vpc_cidr per-cluster. aws_k8s_subnet_a_id / aws_k8s_subnet_b_id are vestigial (still shown in Settings, ignored by the module).

Azure AKS

Builds its own VNet (default 10.96.0.0/16) with Azure CNI, egress via the AKS-managed outbound load balancer pinned to our own static IP (stable, whitelistable). Supplying the IP replaces AKS's managed outbound IP, which would otherwise live in the opaque MC_ node RG and could rotate. This replaced a per-cluster user-assigned NAT gateway: same /32 contract, ~40% less per cluster-hour and ~9× less per GB. Uses the existing resource group (azure_resource_group, default vm-cli-rg) because the dashboard SP is RG-scoped. AAD- integrated with Azure RBAC (oidc_issuer_enabled + workload_identity_enabled); creates a per-cluster Key Vault + user-assigned managed identity + federated credential — the Entitle agent's azure_secret_manager backend (the in-cluster Secrets path 401s on AKS).

Config (import-only): azure_aks_k8s_version, azure_aks_node_vm_size, azure_aks_authorized_cidrs.

Clusters provisioned before the load-balancer switch still hold a NAT gateway in their Terraform state, and terraform destroy removes it from state even though the module no longer declares it. After decommissioning one of those, confirm nothing was left billing:

az network nat-gateway list -g <rg> --query "[?tags.\"managed-by\"=='vm-dashboard'].{name:name,rg:resourceGroup}" -o table

GCP GKE

Builds a self-contained VPC-native cluster; private nodes, public control-plane endpoint (restrict with gcp_gke_authorized_cidrs), egress via Cloud Router + Cloud NAT + reserved static IP. Two connectivity modes (the service picks based on config):

The private control plane gets a per-cluster /28, allocated as the lowest free slot in gcp_gke_master_cidr_base (172.16.0.0/16) — GCP materializes it as a gke-…-pe-subnet subnetwork and rejects overlaps VPC-wide (other regions included), so a shared base with one fixed /28 only ever fits one cluster. Slots in use are read from each cluster's provisioning job plus a live scan of every cluster's masterIpv4CidrBlock, so orphans and hand-made clusters are skipped too — note the pe-subnet itself does not show up in gcloud compute networks subnets list, so the cluster scan is the only way to see a range that a failed/ERROR cluster still holds.

Config (import-only): gcp_gke_k8s_version, gcp_gke_machine_type, gcp_gke_authorized_cidrs, gcp_gke_master_cidr_base; connectivity from the region config's network / k8s_subnetwork + secondary-range names.

OCI OKE — experimental

Builds a self-contained VCN (default 10.96.0.0/16 — must not overlap the sandbox VCN 10.98.0.0/16; give each concurrent cluster a distinct block) with api / nodes / lb subnets, an IGW, a NAT gateway (its nat_ip is the stable egress IP), and a service gateway so nodes reach the OKE control plane and OCIR without traversing the internet. The cluster is a BASIC_CLUSTER (free control plane) with a FLANNEL overlay and a public API endpoint; the node pool defaults to a single Always-Free Ampere VM.Standard.A1.Flex node at 2 OCPU / 12 GB — the whole free Ampere allocation. Leave node_image_id blank and the module auto-picks the newest Oracle-Linux image whose OKE-<version> suffix matches the cluster's exact patch version and whose flavour matches the shape. Note OKE tags only its ARM images (…-aarch64-…) — the x86 images carry no arch token at all, so the match is by exclusion (no aarch64 ⇒ x86, no Gen2-GPU ⇒ non-GPU); an x86_64 name match finds nothing and leaves the node pool with an empty image.

Credentials reach Terraform as TF_VAR_* (terraform_provider_env.oci_env()) rather than provider-native env vars — the module declares tenancy_ocid / user_ocid / fingerprint / private_key / private_key_passphrase / region as variables. region has no default, but settings.oci_region falls back to us-ashburn-1, so it is always populated — and, as with OCI databases, the cluster always lands in oci_region regardless of the region picked in the form.

Config: oci_oke_vcn_cidr (10.96.0.0/16) is editable in Settings → Kubernetes Management, and the Provision modal's Cluster VCN CIDR field overrides it per-cluster — it travels on the same vpc_cidr request field AWS uses (there is no separate vcn_cidr field). oci_oke_k8s_version / oci_oke_node_shape stay import-only (they seed the form's version / node-size pickers). Compartment from oci_compartment_ocid (falling back to oci_tenancy_ocid).

Versions are resolved live, not pinned. OKE retires Kubernetes versions every few months and then hard-rejects them (400 InvalidParameter, Invalid kubernetesVersion), so nothing in this path carries a hard-coded default: the module reads oci_containerengine_cluster_option and, when k8s_version is blank, picks the newest version the region offers (echoed back as the k8s_version output); the form's picker reads the same list through oci_service.oke_cluster_versions(), falling back to K8S_VERSIONS["oci"] only when OCI is unconfigured. Versions use OKE's v-prefixed patch format (v1.36.1); a version you pin explicitly is validated against the live list at plan time, so a stale pin fails before the VCN is built rather than half-way through the apply.

Node shapes are read live too. OKE accepts only a subset of the compute shapes OCI offers, and the subset varies by region and tenancy — VM.Standard.E4.Flex is a normal Compute shape that OKE does not take in us-chicago-1, while the newer Ampere VM.Standard.A2.Flex is one it does. A shape outside the subset is not rejected at submit: it fails at node-pool creation, ~10 minutes into the apply, with the VCN and cluster already built. So the Node size picker reads oci_service.oke_node_pool_shapes() (the API behind oci ce node-pool-options get), falling back to K8S_NODE_TYPES["oci"] only when OCI is unconfigured. Unlike the version pin there is no plan-time gate: the live list is scoped to one region and tenancy, so it seeds the picker but never rejects a submission, and oci_oke_node_shape is always merged in first — a shape valid in another region stays reachable through config. Shapes are ordered free-tier first, bare metal last (BM.Standard.E5.192 is a 192-OCPU machine billed whole, and the picker is where a lab cluster gets sized).

Sandbox prerequisites

The sandbox scripts no longer create k8s subnets — clusters own their networks. The scripts grant the k8s IAM/roles and emit the peering inputs the modules consume (AWS: aws_vpc_id/aws_vpc_cidr/aws_private_route_table_id + DB/VM SGs; Azure: azure_vnet_id; GCP: gcp_network or the co-location subnet + secondary ranges). See the "Managed Kubernetes" row in Cloud Sandbox.


Management plane

Cluster-API operations (kubectl apply, helm, secret reads) run as transient runner Jobs on the job worker — in-process by default (k8s_runner=local) or as a one-shot cloud task (ECS / ACI / Cloud Run) using stock dtzar/helm-kubectl:latest. The cloud path exists to side-step a TLS-inspecting corporate proxy rejecting direct kubectl to a private-CA API. Config: k8s_runner (local|ecs|aci|gcp), k8s_runner_aws/_azure/_gcp/_oci, k8s_runner_image.

Secret delivery walkthrough (ESO)

Installing ESO only stands up the plumbing — the operator plus the beyondtrust-store ClusterSecretStore. Nothing syncs until a workload declares an ExternalSecret naming a Password Safe entry. End to end:

  1. Install the plumbing (once per cluster). Run the secret-delivery action — POST /clusters/{id}/secret-delivery (kind eso), or the Secrets button on the cluster. It Helm-installs ESO into external-secrets and applies the BeyondTrust ClusterSecretStore (beyondtrust-store), authenticated with the pscli_* OAuth client. (Prerequisite: Password Safe OAuth must be configured.)
  2. Store the credential in Password Safe. Create it in Secrets Safe (or a managed account) and note its path — that becomes the ExternalSecret's remoteRef.key. The path format follows eso_bt_retrieval_type: SECRETfolder/title, MANAGED_ACCOUNTsystem/account.
  3. Declare an ExternalSecret. Apply a manifest referencing secretStoreRef: { kind: ClusterSecretStore, name: beyondtrust-store } that maps Password Safe entries → keys in a target Secret. ESO reconciles it and creates a native Kubernetes Secret — no secret value ever lives in the manifest, only the pointer.
  4. Consume the Secret from your Deployment/StatefulSet like any other Secret — secretKeyRef for a single key, or envFrom.secretRef to load every key as an env var.

Ready-to-adapt starters in examples/k8s/:

(app-secret.yaml ships the inline-Secret anti-pattern these replace — a literal credential in the manifest, landing in git and etcd in clear text.)


Access & identity

Three per-cluster access paths (jobs run on the worker). Together they cover the PAM stack for clusters: PRA tunnels (Layer 1 — reach it), the PRA vault token / ESO (Layer 2 — secrets), and Entitle + Entra federation (Layer 3 — time-boxed access).

Config: entra_rbac_group_id / _name / _role (cluster-admin), pra_k8s_namespace (pra-access), pra_k8s_sa_name (pra-access), k8s_api_tunnel_local_port (6443), bt_vault_account_group_id.


Config Management

Registered/provisioned clusters appear in the Config Management target dropdown. They are not SSH targetskubernetes.core plays run hosts: localhost, connection: local and reach the API via an injected token-prepped kubeconfig. These runs always use a remote in-cloud runner (never local Docker) with the ansible-cloud image. Starters live in examples/playbooks/k8s/. See Config Management.


Corporate TLS inspection

If your network TLS-inspects egress, the dashboard's own kubectl/helm to a private-CA API server will fail. Either trust the corporate root CA in the dashboard container (onboard.sh --hub --corp-ca, or bake corp-ca/*.crt into a from-source build) or use the in-cloud runners (k8s_runner=ecs|aci|gcp), which get clean egress from inside the cloud. This is not Kubernetes-specific — it's the same corp-CA story as the rest of the dashboard.


Troubleshooting

Source of truth: web_dashboard/api/k8s.py, web_dashboard/services/k8s_service.py, the terraform/k8s_cluster/* modules, and web_dashboard/api/setup.py (K8sManagementFeatureConfig). For the network topology see Cloud Sandbox.