Before you start
You need:
- a Kubernetes cluster, 1.34 to 1.37 (EKS is the usual home, but any cluster that can reach the AWS APIs works). The chart installs on 1.28 and newer, but only 1.34 to 1.37 are supported and tested — see the support policy;
- Helm 3.8 or newer and
kubectl; - an IAM role the operator can use, with
ec2:DescribeVpcs,ec2:DescribeSubnetsandec2:DescribeRouteTables; addsts:AssumeRolewhen you discover more than one account; - VPCs tagged so the operator can find them,
hs/managed=trueby default.
Describe
calls. Creating subnets from a SubnetClaim and tagging resources from a
ResourceImport are a separate, opt-in switch with an IAM role of their own, and nothing
the operator does ever deletes a cloud resource.
network.hypersurgery.dev/v1 API comes with a written
compatibility promise,
and every change is tested in Kind against Moto, an AWS
emulator, and as an upgrade from the latest release. It has not yet been run against a real AWS
organization: if you run it in one, starting with the read-only core, please tell us how it went.
Quick start
Add the chart repository
helm repo add hypersurgery https://charts.hypersurgery.dev helm repo update hypersurgery helm search repo hypersurgery/subnet-operator
Give the operator AWS credentials
With EKS Pod Identity, associate your role with the service account that the release creates:
# after step 3, the service account is # subnet-operator-system/subnet-operator aws eks create-pod-identity-association \ --cluster-name my-cluster \ --namespace subnet-operator-system \ --service-account subnet-operator \ --role-arn arn:aws:iam::111111111111:role/aws-subnet-operator
With IRSA, annotate the service account instead (step 3 shows where).
For other accounts, deploy the read-only role from
deploy/iam/spoke-readonly-role.cfn.yaml with a CloudFormation StackSet.
Install the chart
The image is public and signed, so there is no pull secret and nothing to log in to. If you would rather check what you are about to run before you run it:
cosign verify ghcr.io/aivandrago/subnet-operator:v3.2.0 \
--certificate-identity-regexp '^https://github.com/aivandrago/subnet-operator/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
The signature names the workflow file and the tag that built the image, so it says what produced it rather than merely that somebody signed it.
helm install subnet-operator hypersurgery/subnet-operator \ --namespace subnet-operator-system --create-namespace \ --set providers.aws.region=eu-central-1 \ --set providers.aws.irsaRoleARN=arn:aws:iam::111111111111:role/aws-subnet-operator
The chart installs the CRDs, RBAC, two replicas of the manager with its admission and
conversion webhooks, and a metrics service. Drop the providers.aws.irsaRoleARN
line if you use Pod Identity.
Every release also publishes the chart to ghcr.io as an OCI artifact, signed keyless by the same release workflow as the image. With it you can skip step 1, check the chart the same way and install it straight from the registry:
cosign verify ghcr.io/aivandrago/charts/subnet-operator:<version> \ --certificate-identity-regexp '^https://github.com/aivandrago/subnet-operator/\.github/workflows/release\.yml@' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com helm install subnet-operator oci://ghcr.io/aivandrago/charts/subnet-operator \ --version <version> \ --namespace subnet-operator-system --create-namespace \ --set providers.aws.region=eu-central-1 \ --set providers.aws.irsaRoleARN=arn:aws:iam::111111111111:role/aws-subnet-operator
Installing through OLM instead (OpenShift, or any cluster with the Operator Lifecycle
Manager)? From 1.0 every release also publishes a signed bundle image,
ghcr.io/aivandrago/subnet-operator-bundle, and attaches the bundle to the GitHub
release; a listing on OperatorHub.io follows once it has been accepted there. The
OLM guide
covers installing the bundle and how it differs from the chart.
Tell it what to discover
kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: NetworkScope metadata: name: organization spec: provider: AWS accounts: - id: "111111111111" # the operator's own account - id: "222222222222" aws: roleARN: arn:aws:iam::222222222222:role/aws-subnet-operator-readonly regions: [eu-central-1, eu-west-1] networkSelector: matchTags: hs/managed: "true" requiredSubnetTags: [hs/owner, hs/env, hs/tier] resyncInterval: 10m EOF
Leave networkSelector out to take every VPC in the listed regions. The IAM role
names keep the aws-subnet-operator prefix the CloudFormation templates give them.
Verify
kubectl get nscope NAME PROVIDER NETWORKS SUBNETS UNMANAGED READY LAST SYNC organization AWS 42 318 7 True 12s kubectl get hssubnet -o wide kubectl get hsnet -o wide kubectl get hssubnet -l network.hypersurgery.dev/account=222222222222 kubectl get hypersurgery # everything the operator knows about
The kinds have prefixed short names because networks and subnets are
plain names other projects use too. If READY stays False, the scope status
names the account and region that failed:
kubectl get nscope organization -o jsonpath='{.status.targets}' | jq
Change events (optional)
Without events, changes appear at the next resync. With them, a changed account and region is re-read about ten seconds after the API call.
- Make sure CloudTrail records management events in every account, for example with an organization trail.
- Deploy
deploy/events/hub-events.cfn.yamlin the operator's account, anddeploy/events/spoke-events.cfn.yamlto every other account and region with a StackSet. - Point the release at the queue:
helm upgrade subnet-operator hypersurgery/subnet-operator \
--namespace subnet-operator-system --reuse-values \
--set providers.aws.events.queueUrl=https://sqs.eu-central-1.amazonaws.com/111111111111/aws-subnet-operator-events
Dashboard and alerts
With kube-prometheus-stack in the cluster:
helm upgrade subnet-operator hypersurgery/subnet-operator \ --namespace subnet-operator-system --reuse-values \ --set metrics.serviceMonitor.enabled=true \ --set metrics.serviceMonitor.labels.release=kube-prometheus-stack \ --set prometheusRule.enabled=true \ --set prometheusRule.labels.release=kube-prometheus-stack \ --set grafanaDashboard.enabled=true
The dashboard ConfigMap is labelled grafana_dashboard: "1", which the Grafana sidecar
imports on its own. You get headline counts, utilization by environment, free addresses per account,
the fullest subnets, tag compliance, what is still unmanaged, what the auto-import policy decided,
target health and the age of the last full sync — the same panels as the
live demo.
The alerts, each with an entry in the runbook, are listed below.
Google Sheet mirror (optional)
For people who still want a table. The operator writes the sheet and never reads it back.
# share the spreadsheet with the service account address first kubectl create secret generic google-sheets -n subnet-operator-system \ --from-file=credentials.json=./service-account.json kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: SheetExport metadata: name: organization spec: scopeRef: organization spreadsheetID: 1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 credentialsSecretRef: name: google-sheets namespace: subnet-operator-system refreshInterval: 5m EOF
Only service account keys are accepted: other credential configurations can make the Google client run a local command, which a Secret should never be able to do.
Subnets on demand (optional)
A SubnetClaim asks for one subnet per availability zone. The operator reserves free
CIDRs in the VPC, skipping existing subnets and other claims, and creates the subnets with your
tags. Together with the tags a ResourceImport applies, it is the only thing the operator
ever writes to AWS, and it is off until you turn it on.
- Deploy
deploy/iam/spoke-write-role.cfn.yamlto the accounts that may get new subnets. It is a separate role from discovery and cannot delete anything. - Reference it in the scope as
spec.accounts[].aws.writeRoleARN, and list the namespaces that may use it inspec.namespaceSelector— a scope without one lends its roles to no namespace. - Enable writes on the release:
helm upgrade subnet-operator hypersurgery/subnet-operator \ --namespace subnet-operator-system --reuse-values \ --set writes.enabled=true
kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: SubnetClaim metadata: name: payments namespace: default spec: scopeRef: organization account: "222222222222" region: eu-central-1 networkID: vpc-0aa11bb2cc33dd44e prefixLength: 24 zones: [eu-central-1a, eu-central-1b] owner: team-payments env: prod tier: private EOF kubectl get subnetclaims.network.hypersurgery.dev payments -o jsonpath='{.status.allocations}' | jq
Each allocation carries the CIDR and, once created, the subnet ID. Subnets are tagged
hs/owner, hs/env, hs/tier,
hs/managed-by=subnet-operator and hs/claim=<namespace>/<name>.
mode: Allocate: the operator only reserves CIDRs and Terraform creates the subnets
from status.allocations. The Allocated and Ready conditions say how
far a claim got; Ready says WritesDisabled while writes are off.
Taking resources under management
Discovery does not stop at the tag selector. With discoverUnmanaged — on by default —
the operator also lists the VPCs the selector leaves out, and their subnets, and reports them as
counts and metrics rather than as objects: they are not managed, and the inventory should keep
meaning what it says.
kubectl get nscope NAME PROVIDER NETWORKS SUBNETS UNMANAGED READY organization AWS 14 212 9 True # hs_unmanaged_resources{kind="subnet"} says where they are kubectl get nscope organization -o jsonpath='{.status.targets}' | jq
A ResourceImport takes one of them over. The operator applies exactly these tags with
ec2:CreateTags and does nothing else: the resource keeps its configuration and every tag
the import does not name, and running the same import twice changes nothing.
kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: ResourceImport metadata: name: subnet-04d1c2b3a4e5f607-import namespace: default spec: scopeRef: organization account: "333333333333" region: eu-central-1 resourceID: subnet-04d1c2b3a4e5f607 tags: hs/managed: "true" hs/owner: team-data requestedBy: anton (ticket NET-412) dryRun: false EOF kubectl get resourceimports.network.hypersurgery.dev -A
Imports need writes.enabled=true and, for another account, a write role — but only
ec2:CreateTags from it, so they can be allowed where subnet creation is not. With
dryRun: true the object records what it would apply and stays Skipped.
The auto-import policy
The alternative to importing by hand is letting the scope do it. The CloudTrail event that says a subnet was created also says who created it, so the tags can usually be worked out without asking anybody. Add a policy to the scope:
spec: autoImport: mode: DryRun # Off | DryRun | Apply namespace: platform # where the generated imports land fromCreator: # 1. who made it - principalPrefix: "arn:aws:sts::222222222222:assumed-role/payments-" tags: { hs/owner: team-payments } inheritFromNetwork: [hs/owner, hs/env] # 2. the VPC it lives in accountDefaults: # 3. the account it is in - account: "111111111111" tags: { hs/owner: team-platform } skip: - tagKey: managed-by tagValue: terraform - principalPrefix: "arn:aws:sts::222222222222:assumed-role/terraform-" requiredTags: [hs/owner]
The three sources are tried in that order and the first match wins. What the policy produces is an
ordinary ResourceImport, so an automatic decision and one somebody wrote by hand take the
same path and leave the same audit trail; spec.requestedBy names the creator the tags
came from. The demo dashboard shows the same decisions as a review queue.
Who created a resource is known on AWS (CloudTrail) and, with change events, on Google Cloud (audit logs). On Azure it is not: see what is different on Azure.
DryRun and read a day of
decisions before anything changes. Nothing whose owner no rule can name is ever tagged — it stays
unmanaged and the alert asks a human. Terraform is left alone, by tag or by the principal that created
the resource. Tags the resource already has win over inferred ones, so the policy only adds what is
missing. And without writes.enabled the imports are created but stay
Pending, saying why.
Google Cloud (2.0)
providers.gcp. It is tested against an in-repository fake of the Google APIs and not yet
against a real project (#50): if you run it in one, please tell us how it went.
The model maps onto GCP directly: an account is a project (for a Shared VPC, the host
project), a network is a global VPC network with no address space of its own, a subnet is a
regional subnetwork with optional secondary ranges, and ownership lives in Resource Manager
tags bound to the network or subnetwork: hs-owner, hs-env, hs-tier
(a GCP tag key cannot contain /). The full guide is
docs/gcp.md; every
permission, side by side with AWS, is in the
IAM reference.
The operator's own identity
On GKE, Workload Identity: let the release's Kubernetes service account act as a Google
service account, and name it in the values. Anywhere else, Workload Identity Federation:
set providers.gcp.wif.provider to your workload identity pool provider, and the chart
renders the credential configuration and mounts a projected token. Not both.
gcloud iam service-accounts add-iam-policy-binding \ subnet-operator@ops-tools.iam.gserviceaccount.com \ --role=roles/iam.workloadIdentityUser \ --member="serviceAccount:ops-tools.svc.id.goog[subnet-operator-system/subnet-operator]" helm install subnet-operator ./charts/subnet-operator \ --namespace subnet-operator-system --create-namespace \ -f examples/values-gcp.yaml # providers.gcp.enabled, workloadIdentity.serviceAccount
With networkPolicy.enabled, egress to the GKE metadata server is allowed by default and
switched off with wif; turn it off (providers.gcp.metadataServer.enabled: false) on
any cluster that is not GKE.
Read and write service accounts
Each project is read either with the operator's own identity or by impersonating
accounts[].gcp.serviceAccount; writes use gcp.writeServiceAccount and never the
read one. The own identity needs iam.serviceAccounts.getAccessToken on each service account
it impersonates. The roles are custom role definitions in deploy/gcp/:
| Role | Grant to | For |
|---|---|---|
reader-role.yaml | the read service account, in every project of the scope | discovery |
impersonator-role.yaml | the operator's own identity, on each service account | impersonation |
writer-role.yaml | the write service account, in the host project | claims and imports |
tag-user-role.yaml | the write service account, on the tag keys | binding tags |
tag-value-creator-role.yaml | the write service account, on the operator's keys only | only with createTagValues |
Tag keys and values
The scope names the organization or project that owns the tag keys, spec.gcp.tagParent.
The operator never creates tag keys: create hs-owner, hs-env, hs-tier,
hs-managed and hs-managed-by there first (and hs-claim for scopes with
spec.gcp.claimTag: Bind), as someone holding
roles/resourcemanager.tagAdmin. Values must exist too: a claim or import that needs a
missing one fails with TagValueMissing and binds nothing, unless the scope sets
spec.gcp.createTagValues: true, which lets the write identity create values (not keys).
The per-claim tag hs-claim is optional on GCP: by default (claimTag: Skip) a
claim's subnetwork carries only the shared ownership values and the claim finds it by name; with
Bind every claim needs a value of its own. A resource carries one value per key, and the operator never removes a binding:
an import that would change one fails with TagValueConflict.
Change events (optional)
Without them, a change in a project shows up at the next resync; with them, within seconds. An
aggregated log sink routes the Admin Activity audit logs of network, subnetwork and tag binding
changes to a Pub/Sub topic, and the leader pulls its subscription as the operator's own identity,
which needs deploy/gcp/events-subscriber-role.yaml (pubsub.subscriptions.consume) or
roles/pubsub.subscriber on the subscription. The events also tell the auto-import policy
who created a network or subnetwork. The sink, topic and subscription, with gcloud and Terraform, are in
deploy/gcp/events.md.
helm upgrade subnet-operator ./charts/subnet-operator \
--namespace subnet-operator-system --reuse-values \
--set providers.gcp.events.subscription=projects/ops-tools/subscriptions/subnet-operator-events
A pull that keeps failing raises SubnetInventoryChangeEventsFailing; the periodic resync
keeps running meanwhile (runbook).
A scope and a claim
kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: NetworkScope metadata: name: gcp-shared-vpc spec: provider: GCP gcp: tagParent: organizations/123456789012 accounts: - id: net-host-prod gcp: serviceAccount: [email protected] writeServiceAccount: [email protected] regions: [europe-west1, europe-west4] networkSelector: matchTags: hs-managed: "true" namespaceSelector: matchLabels: kubernetes.io/metadata.name: default --- apiVersion: network.hypersurgery.dev/v1 kind: SubnetClaim metadata: name: checkout namespace: default spec: scopeRef: gcp-shared-vpc account: net-host-prod region: europe-west1 networkID: projects/net-host-prod/global/networks/shared-prod prefixLength: 24 owner: team-payments env: prod namePrefix: payments-checkout gcp: poolCIDRs: [10.60.0.0/16] EOF
A claim on GCP is for one regional subnetwork: it lists no zones, and names the pool its range
is carved from, since a VPC network has no address space of its own. namePrefix is the
subnetwork's name. When a project cannot be read, the scope's status.targets[].error
says why; the runbook
has the GCP causes, from a service account that cannot be impersonated to a missing tag key.
Azure (3.0)
providers.azure. It has been tested against in-repository fakes of Azure Resource Manager,
Microsoft Entra ID and the Storage queue API, not yet against a real subscription (#56). What only
a subscription can confirm, whether the roles in deploy/azure hold every action Azure asks
for, the error codes the operator recognises and the fields of the events, is marked in the
Azure guide. Start read-only, and if you
try it in a subscription, please tell us how it went.
The model maps onto Azure like this: an account is a subscription (its ID, a UUID in any case),
a region is a location as Resource Manager spells it (westeurope), a network is a
virtual network with an address space of its own, and a subnet is a regional subnet without a zone.
A virtual network carries its ownership as tags, hs-owner, hs-env, hs-tier
(an Azure tag name cannot contain /). An Azure subnet cannot carry tags, so the
operator keeps a subnet's ownership on its virtual network, in one tag per subnet,
hs-subnet-<subnet name>. The full guide is
docs/azure.md; every permission, side by side with AWS and Google Cloud,
is in the IAM reference.
The operator's own identity
Microsoft Entra Workload ID, on any cluster. The operator holds no client secret: its identity
is a user-assigned managed identity or an app registration with a federated identity credential
that trusts the release's Kubernetes service account (issuer: the cluster's service account issuer;
subject: system:serviceaccount:subnet-operator-system:subnet-operator; audience:
api://AzureADTokenExchange).
ISSUER=$(az aks show --resource-group rg-aks --name prod --query oidcIssuerProfile.issuerUrl -o tsv) az identity create --resource-group rg-identities --name subnet-operator az identity federated-credential create --resource-group rg-identities --identity-name subnet-operator \ --name subnet-operator --issuer "$ISSUER" \ --subject system:serviceaccount:subnet-operator-system:subnet-operator \ --audiences api://AzureADTokenExchange helm install subnet-operator ./charts/subnet-operator \ --namespace subnet-operator-system --create-namespace \ -f examples/values-azure.yaml # providers.azure.enabled, workloadIdentity.enabled and .clientId
On AKS with the OIDC issuer and the workload identity add-on, that is all: the chart labels the
pod and annotates the service account, and the add-on's webhook projects the token.
On EKS, GKE or a self-managed cluster, set providers.azure.workloadIdentity.webhook: false
and providers.azure.tenantId; the chart then projects the token and sets the
AZURE_* variables itself, and the federated credential names that cluster's issuer.
providers.azure.cloud selects AzureUSGovernment or AzureChina.
Roles, and identities per subscription
Azure has no AssumeRole. A subscription is reached either with the operator's own identity, or with
identities named on the scope's account entry: accounts[].azure.clientID reads,
azure.writeClientID writes and is never used for discovery, and azure.tenantID
names their tenant when it is not the operator's. The operator exchanges the same service account
token for a token of each, so each needs a federated identity credential like the one above, and
its own role assignments. The roles are custom role definitions in deploy/azure/, least
privilege, assigned per subscription:
| Role | Assign to | For |
|---|---|---|
reader-role.json | the read identity, on every subscription of the scope, or only on the resource groups the scope names in spec.azure.resourceGroups | discovery, and the read of the virtual network before an import |
writer-role.json | the write identity, on the subscription or the virtual networks' resource groups | claims and imports |
| Tag Contributor (built in) | the write identity, instead of the writer role | imports alone |
| Storage Queue Data Message Processor (built in) | the operator's own identity, on the queue | only with change events |
SUB=5b3c2a10-0000-4000-8000-00000000a2e1 sed "s#/subscriptions/00000000-0000-0000-0000-000000000000#/subscriptions/$SUB#" \ deploy/azure/reader-role.json > /tmp/reader-role.json az role definition create --role-definition @/tmp/reader-role.json az role assignment create --assignee "$READER_CLIENT_ID" --role "Subnet operator reader" \ --scope "/subscriptions/$SUB"
Neither role can delete anything, change a virtual network's address space or peerings, or assign
roles. A subscription read through azure.clientID without an azure.writeClientID
is not written to at all: claims for it are only allocated and imports stay Pending
(NoWriteRole).
A scope, a claim and an import
kubectl apply -f - <<'EOF' apiVersion: network.hypersurgery.dev/v1 kind: NetworkScope metadata: name: azure-landing-zone spec: provider: Azure azure: resourceGroups: [rg-network-prod] # optional: without it, the whole subscription accounts: - id: 5b3c2a10-0000-4000-8000-00000000a2e1 azure: clientID: 3f0c6a1e-0000-4000-8000-0000000000b1 writeClientID: 3f0c6a1e-0000-4000-8000-0000000000c1 regions: [westeurope, northeurope] networkSelector: matchTags: hs-managed: "true" namespaceSelector: matchLabels: kubernetes.io/metadata.name: default --- apiVersion: network.hypersurgery.dev/v1 kind: SubnetClaim metadata: name: checkout namespace: default spec: scopeRef: azure-landing-zone account: 5b3c2a10-0000-4000-8000-00000000a2e1 region: westeurope networkID: /subscriptions/5b3c2a10-0000-4000-8000-00000000a2e1/resourceGroups/rg-network-prod/providers/Microsoft.Network/virtualNetworks/vnet-prod prefixLength: 24 owner: team-payments env: prod namePrefix: payments-checkout EOF
The chart can create the scope with the release instead: networkScope.create with
networkScope.provider: Azure and networkScope.azure.resourceGroups
(examples/values-azure.yaml).
- A
SubnetClaimis for one subnet, namednamePrefix, carved from the virtual network's address space; it lists no zones. With writes enabled the operator first writes the subnet's ownership on the virtual network (hs-subnet-payments-checkout, with the claim inhs-claim) and then creates the subnet, so a subnet it created never exists without its owner. It never changes a subnet that already exists. - A
ResourceImportof a virtual network adds tags to the network; one of a subnet adds them to the subnet's entry on its network, leaving out what the subnet inherits from the network anyway. Both only ever add: a tag that already has another value refuses the whole import (TagValueConflict). Before it writes, the operator reads the virtual network as the read identity: one that does not exist, or has no subnet of that name, fails the import withResourceNotFoundand nothing is written, and the import goes through once the subnet exists.spec.regionis the virtual network's location; leave it out for a network the operator has discovered and the webhook fills it in, and one that contradicts the network is refused. - The tag budget. Azure allows 50 tags on a resource, and the entries of a virtual network's
subnets share them with its own tags. A write that would need a 51st fails with
TagBudgetExceededbefore anything is written, and a subnet's entry longer than the 256 characters of a tag value withOwnershipEntryTooLong.status.azure.tagCounton theNetwork, the metrichs_network_tagsand the alertNetworkTagBudgetLow(at 45) show a network running out. - IDs in any case. A subscription ID, a claim's
networkIDand an import'sresourceIDmay be written as the portal shows them; the operator stores and shows them in lowercase. Tag names are matched without regard to case too, as Azure does; values exactly.
Change events (optional)
Without them, a change in a subscription shows up at the next resync; with them, within seconds. Each
subscription's Event Grid system topic delivers the write and delete events of virtual networks to a
Storage queue, which the leader polls as the operator's own identity; that identity needs the built-in
Storage Queue Data Message Processor role on the queue, and no key or SAS token. The Bicep templates and
every command are in deploy/azure/events.md. A scope that names
resource groups in a busy subscription should give the template the same groups
(resourceGroupNames), so that other teams' virtual networks do not reach the queue.
helm upgrade subnet-operator ./charts/subnet-operator \
--namespace subnet-operator-system --reuse-values \
--set providers.azure.events.queueUrl=https://subnetopevents.queue.core.windows.net/subnet-operator-events
A queue that cannot be read raises SubnetInventoryChangeEventsFailing; the periodic resync
keeps running meanwhile (runbook).
What is different from AWS and Google Cloud
- Subnets cannot be tagged. Their ownership is an entry on the virtual network, which is why
a virtual network has a tag budget. A subnet without an entry inherits its network's tags
(
status.ownershipSource: Network). - No creator attribution, an accepted limitation of 3.0. An Azure resource event does not say
whether a write created the resource or updated it, so the operator records no creator on Azure and
the auto-import policy's
fromCreatorrules, andskiprules with aprincipalPrefix, never match there. A scope that has them is accepted with a warning from the webhook;accountDefaults,inheritFromNetworkandskiprules by tag are what apply. - No zones. A subnet is regional: a claim creates one subnet and lists no zones.
- Nothing to set up for tags, unlike Google Cloud: a tag name and value exist once written, and
the claim tag
hs-claimis always written, as on AWS. - Peerings are not read, so
NetworkPeeredCIDROverlapnever fires for Azure;NetworkCIDROverlapcompares the address spaces of the scope's virtual networks.
When a subscription cannot be read, the scope's status.targets[].error says why; the
runbook has the Azure causes,
from an identity without a federated credential to a resource group that does not exist.
Alerts
With prometheusRule.enabled=true the chart ships these rules. They carry the provider,
account, region and resource labels (network_id, subnet_id, zone), so
routing them per team in Alertmanager is a matter of matchers. Every alert has an entry in the
runbook:
what it means, how to confirm it, what to do and when to ignore it. Coming from 0.8, whose rules
read hs_aws_ metrics: the upgrade notes
have the mapping.
| Alert | Fires when | Severity |
|---|---|---|
SubnetOperatorDown | no replica of the operator has been scraped successfully for 10 minutes. Every other alert is computed from the operator's own metrics and goes quiet when it is gone; this is the one that notices. Rendered with the chart's ServiceMonitor, or with prometheusRule.operatorDown.job set | critical |
SubnetClaimNotReady | a SubnetClaim has not been fulfilled for 30 minutes; reason says why — NoSpace, WritesDisabled, CreateFailed | warning |
ResourceImportNotSettled | tags a ResourceImport asked for have not reached the cloud for 30 minutes; a dry run never fires it | warning |
SubnetNearlyFull | a subnet is at or above prometheusRule.thresholds.subnetUsedRatio (0.85) for 15 minutes | warning |
SubnetFull | a subnet has no free IPv4 address for 15 minutes | critical |
SubnetInventoryTargetDown | an account/region cannot be discovered for 15 minutes, for a reason other than throttling | warning |
SubnetInventoryTargetThrottled | the cloud provider has throttled an account/region for prometheusRule.thresholds.throttledFor (30 minutes). The operator backs the target off and retries it on its own; the target stays reachable, so SubnetInventoryTargetDown does not fire for it | warning |
NetworkCIDROverlap | two networks in the scope overlap for 30 minutes: two VPCs' CIDRs on AWS, the ranges of two GCP networks' subnetworks (primary, secondary, IPv6) on GCP | warning |
NetworkPeeredCIDROverlap | two overlapping networks are peered, for 10 minutes: the peering cannot become active or drops the overlapping routes. GCP, whose peerings the operator reads; see status.gcp.overlaps | critical |
SubnetInventoryChangeEventsFailing | the change events source (the SQS queue on AWS, the Pub/Sub subscription on GCP) keeps failing to be read; changes then wait for the resync | warning |
NetworkTagBudgetLow | Azure (from 3.0): a virtual network carries prometheusRule.thresholds.networkTags (45) or more of the 50 tags Azure allows, its subnets' ownership entries included, for 15 minutes | warning |
SubnetInventoryStale | no full resync for prometheusRule.thresholds.staleSyncSeconds (1 hour) | warning |
UnmanagedNetworkResource | a network or subnet without the managed tag (hs/managed, on GCP hs-managed) is seen for the first time | warning |
AutoImportedResources | the auto-import policy applied imports in the last 24 hours, as a record of what changed. Its counter exists at 0 for every target of a scope that runs the policy, so the first import after a restart is in the digest too | info |
Routing them into Slack or Teams is ordinary Alertmanager configuration; the alerting section on the project page shows both, including the button that links back to the dashboard.
UnmanagedNetworkResource is the onboarding alert. It fires once per resource that
has never been seen before, not on every resync, so it stays quiet until something actually appears
untagged. Taking resources under management is what it asks you to do.
Admission webhooks
Validation runs at kubectl apply, so an object that cannot work is refused with a
readable message instead of being accepted and failing in a reconcile loop later. A
SubnetClaim is rejected when the prefix does not fit the VPC, when the availability
zone belongs to another region, when the scope does not cover the account, when there is no free
space for it, when its namespace may not use the scope, or when mode: Create is asked
for without a write role. Anything that
depends on discovery having run — a VPC the operator has not seen yet — is a warning, not an
error, so the order in which your manifests are applied does not matter.
The webhooks need a serving certificate. The chart uses cert-manager when its API is installed
and signs its own otherwise; set webhook.certificate.certManager to decide
explicitly. If no certificate is found at startup the operator says so and carries on taking
inventory rather than crash-looping — the webhooks are simply off.
webhook.certificate.certManager=true).
The chart-signed certificate lives webhook.certificate.duration (a year by default)
and is renewed only by a helm upgrade within renewBefore of its expiry,
so a release nobody upgrades ends up with an expired one; its private key is stored in every Helm
release Secret of the namespace, and helm template — which is what Argo CD and Flux
with post-rendering run — signs a new CA and key on every render, so the webhook configuration and
the Secret can drift apart until the next sync. cert-manager renews the certificate on its own and
keeps the key out of the release. A certificate you manage yourself goes in
webhook.certificate.existingSecret.
The webhooks also decide who may use a scope. A NetworkScope's
spec.namespaceSelector lists the namespaces whose claims and imports may use its
roles; anything from another namespace is refused at apply time, and by the controller with
NamespaceNotAllowed if it got in while the webhooks were off. A scope without a
selector lends its roles to no namespace; an empty one ({}) to every namespace, which
is warned about. Create NetworkScopes as a cluster administrator, and give teams
create on subnetclaims and resourceimports only in their own
namespaces.
$ kubectl apply -f claim.yaml The SubnetClaim "payments" is invalid: spec.prefixLength: Invalid value: 24: network vpc-0aa11bb2cc33dd44e has no room for 2 more /24: no free block of the requested size
To install without them — for example in a cluster where you cannot run a webhook —
set webhook.enabled=false. The same rules still run in the controller; you find out
from status instead of from kubectl.
Audit trail
The operator changes tags on real infrastructure, so every import, allocation and policy
decision is recorded twice: as a Kubernetes Event on the object, which is what
kubectl describe shows, and as one JSON line on a stream you can ship to a SIEM.
The audit stream is stdout and the operational log is stderr, so they can be separated without
parsing.
{"time":"2026-09-23T17:42:11.481293Z","action":"import","result":"applied",
"scope":"organization","account":"111111111111","region":"eu-central-1",
"resource_id":"subnet-0abc1111","object":"ResourceImport/default/subnet-0abc1111-import",
"principal":"auto-import policy (created by arn:aws:sts::111111111111:assumed-role/payments-deploy/maria.k)",
"created_by":"system:serviceaccount:subnet-operator-system:subnet-operator",
"reason":"tags from creator rule \"arn:aws:sts::111111111111:assumed-role/payments-\"",
"tags_before":{"Name":"legacy-db"},
"tags_after":{"Name":"legacy-db","hs/managed":"true","hs/owner":"team-payments"}}
principal is who the change is attributed to, not who authenticated to Kubernetes:
for a hand-written import it is the requestedBy you wrote, and for an automatic one
it is the CloudTrail creator the policy matched. created_by is who authenticated:
the admission webhook records the Kubernetes user in the network.hypersurgery.dev/created-by
annotation when a claim or import is created and refuses any change to it, and imports the policy
writes carry the operator's own service account. With the webhooks off it says
unknown. Set audit.sink=off to turn the
stream off; the Events stay either way. Field by field:
docs/audit.md.
Common values
| Value | Default | What it does |
|---|---|---|
image.repository | ghcr.io/aivandrago/subnet-operator | Manager image. Public and signed; point it at your own registry if you mirror it. |
providers.aws.region | empty | Region for the operator's own calls. |
providers.aws.irsaRoleARN | empty | The operator's role with IRSA; EKS Pod Identity needs nothing. |
discovery.concurrency | 4 | Account/region pairs discovered in parallel. |
providers.aws.events.queueUrl | empty | SQS queue with EC2 change events (the QueueUrl output of the hub stack). |
metrics.secure | true | HTTPS with authn/authz on the metrics endpoint. |
metrics.serviceMonitor.enabled | false | ServiceMonitor for the Prometheus operator. |
prometheusRule.enabled | false | Alerting rules. |
grafanaDashboard.enabled | false | Dashboard ConfigMap for the Grafana sidecar. |
writes.enabled | false | Let SubnetClaims in Create mode create subnets and ResourceImports apply tags. Needs a write role per account. |
networkScope.create | false | Create a scope with the release instead of by hand. |
networkScope.namespaceSelector | null | Namespaces allowed to use that scope. null allows none; {} every namespace, with a warning. |
replicaCount | 2 | Leader election keeps one active; the standby takes the lease when a node is drained. |
podDisruptionBudget.enabled | true | Not rendered while replicaCount is 1 — a budget over one replica blocks the drain instead of surviving it. |
terminationGracePeriodSeconds | 45 | Longer than the manager's graceful shutdown, so an in-flight sync is not killed mid-write. |
networkPolicy.enabled | false | Egress to the API server and AWS, ingress for the metrics scrape, probes and the webhook port. |
webhook.enabled | true | Admission webhooks. Off means invalid objects are accepted and reported in status instead. |
webhook.certificate.certManager | auto | Uses cert-manager when its API is present, a chart-signed certificate otherwise. Set true in production and with GitOps. |
webhook.certificate.duration / renewBefore | 8760h / 720h | Certificate lifetime and renewal window, for cert-manager and the chart-signed certificate (renewed on upgrade). |
webhook.failurePolicy | Fail | Ignore lets objects through while the operator is unreachable. |
audit.sink | stdout | off or stdout. The audit stream is stdout; the operational log stays on stderr. |
rbac.credentialSecretNamespaces | [] | Extra namespaces whose credential Secrets the operator may read. It reads no Secret outside them. |
dashboard.enabled | false | The dashboard app in the cluster, reading as each viewer with their own token. See below. |
resources | 500m / 512Mi | Container resources. |
Everything else: helm show values hypersurgery/subnet-operator.
Upgrade and uninstall
Helm never upgrades CRDs, so apply the new release's CRDs first, then upgrade the release:
helm repo update hypersurgery helm pull hypersurgery/subnet-operator --untar && kubectl apply -f subnet-operator/crds/ helm upgrade subnet-operator hypersurgery/subnet-operator \ --namespace subnet-operator-system --reuse-values
NetworkTagBudgetLow, which only an Azure
scope can raise, and one more panel in the Grafana dashboard.
Step by step: the upgrade guide.
From 1.x to 2.0 the operator's tag keys depend on the provider. On AWS nothing changes: the keys it writes and defaults to stay
hs/owner, hs/env, hs/tier,
hs/managed, hs/managed-by and hs/claim, byte for byte. On Google Cloud
they are hs-*, because a GCP tag key cannot contain /. Nothing is migrated; check
tag policies, dashboards and scripts that take the keys to be the same for every provider.
Step by step: the upgrade guide.
From 0.9 to 1.0 the API becomes
network.hypersurgery.dev/v1. It has the same
fields as v1beta1, which stays served, deprecated, until at least 1.2 and at least six
months after 1.0. After the upgrade the operator rewrites what 0.9 stored at v1, trims the CRDs'
status.storedVersions to [v1] and converts between the versions with its
webhook. The values and flags 0.9 deprecated (events.*, aws.*,
networkPolicy.egress.podIdentity, --events-queue-url,
--events-debounce, EVENTS_QUEUE_URL) are removed: move them under
providers.aws before upgrading, since the 1.0 chart refuses to render with them and the
operator refuses to start. Manifests in git only need their apiVersion changed.
Step by step: the upgrade guide
(the removed values and flags), and what
v1 promises: API compatibility.
From an older release, go one step at a time. 0.7 and earlier still use the
aws.hypersurgery API and the aws-subnet-operator chart: upgrade to 0.8, which
migrates every object into the new group itself, then to 0.9, then to 1.0, then to 2.0, then to 3.0
(0.7 to 0.8,
0.8 to 0.9). The
upgrade guide also covers the behaviour changes of the earlier releases and
how to roll back.
Which upgrades are tested, what counts as a breaking change and how long a deprecated field lives: the versioning, deprecation and support policy.
helm uninstall subnet-operator -n subnet-operator-system
Uninstalling removes the operator. Helm leaves the CRDs, and with them your
NetworkScopes and the Network and Subnet objects they own, until
you delete them; a scope the release created with networkScope.create goes with the
release, and its objects with it. Nothing happens to your cloud networks either way: uninstalling
never touches them.
The dashboard app
hypersurgery.dev/dashboard/ is the same set of panels as an installable web app: open it in Chrome or Edge and use the install icon in the address bar, or the Install app button in its header; on iOS and macOS Safari use Share → Add to Home Screen or Add to Dock. It keeps working offline and carries four themes (terminal, cyberpunk, midnight and daylight).
Your own cluster, through kubectl proxy
Nothing to install. kubectl proxy serves the app from a checkout of the repository and
forwards its API calls with your kubeconfig's credentials:
git clone https://github.com/aivandrago/subnet-operator && cd subnet-operator kubectl proxy --www=site --www-prefix=/ui/
Then open http://127.0.0.1:8001/ui/dashboard/?source=kubectl. The app lists
NetworkScope, Network, Subnet, SubnetClaim and
ResourceImport objects; a panel whose objects your RBAC does not let you list says so
instead of going blank. Subnets and ResourceImports are watched, so a change
shows up as it happens; the other kinds are listed again every 15 seconds, and so is a watched kind
whenever a watch is not possible. Only two watches, because each one holds a connection open and a
browser allows six per server over HTTP/1.1, shared by all its tabs; a tab in the background closes
its watches. Import on an unmanaged resource creates a
ResourceImport, and the operator's webhook records your user name in its
network.hypersurgery.dev/created-by annotation, and in requestedBy when you leave it empty.
The header shows who the API server says you are. Through kubectl proxy that is the
proxy's own identity — the user in your kubeconfig — because the proxy, not the browser, sends the
credentials.
The page has to come from the proxy itself: kubectl proxy sends no CORS headers, so a page
from anywhere else — this site included — cannot read from it. While it runs, anything on your machine
that can reach 127.0.0.1:8001 acts with your credentials, so stop it when you are done.
Another port or host: ?api=http://127.0.0.1:9001.
In the cluster, with the Helm chart
helm upgrade subnet-operator hypersurgery/subnet-operator \ --namespace subnet-operator-system --reuse-values \ --set dashboard.enabled=true
This adds a Deployment and a Service of their own, running the operator's image with its second
entrypoint, /dashboard, so the image signature and SBOM cover it. The dashboard has no
access of its own: its service account has no RBAC and no token. It reaches the API server with the
cluster CA and passes on the Authorization: Bearer header of each request, untouched. A
request without one gets 401; there is no fallback identity. It forwards reads and watches of
the network.hypersurgery.dev group, the creation of ResourceImports, and an empty
SelfSubjectReview — the API server's answer to "who am I", which the header shows — and
refuses everything else with 403 — Secrets, other groups, updates, deletes — before the API server is asked.
Impersonate-* and every other header outside a short list are dropped.
The token gets there in one of two ways:
- Single sign-on in front. An SSO proxy such as oauth2-proxy logs the viewer in and sets the
header:
--pass-authorization-headerwhen it proxies to the dashboard itself, or--set-authorization-headerbehind ingress-nginx'sauth-urlwithauth-response-headers: Authorization. The API server has to accept that ID token, i.e. trust the same OIDC issuer. - A pasted token. Without SSO, the app asks for a token and keeps it in the browser tab only
(
sessionStorage): gone when the tab closes, never in a cookie. For a quick look,kubectl port-forward svc/subnet-operator-dashboard 8080:80, openhttp://127.0.0.1:8080/and paste the output ofkubectl create token <service-account>.
Writes have to come from the app's own page: a JSON body and a custom header that another site cannot make a browser send, so an SSO session cookie alone never creates an import. The page itself runs under a strict Content-Security-Policy — no inline script, nothing loaded from anywhere else.
Viewers need RBAC of their own, like with kubectl:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: subnet-inventory-viewer
rules:
- apiGroups: [network.hypersurgery.dev]
resources: [networkscopes, networks, subnets, subnetclaims, resourceimports]
verbs: [get, list, watch]
# To import, also allow creating resourceimports in the namespace the import goes to.
Showing the user name needs no extra RBAC: Kubernetes lets every authenticated user create a
SelfSubjectReview (Kubernetes 1.28 and later; on older clusters the name is not shown).
Publish it with dashboard.ingress.*, keep tokens encrypted up to the pod with
dashboard.tls.secretName, and with networkPolicy.enabled=true narrow who may
reach it through dashboard.networkPolicy.from.
Troubleshooting
The scope says Ready=False
Read status.targets: each account and region carries its own error.
AccessDenied usually means the spoke role's trust policy does not list the operator's
role, or the aws.externalID does not match. The operator also refuses a roleARN
whose account does not match the declared account ID, so a typo shows up here rather than as
someone else's networks.
No subnets appear
The tag selector matches VPCs, not subnets: a VPC without hs/managed=true is skipped
together with everything inside it. Check with
aws ec2 describe-vpcs --filters Name=tag:hs/managed,Values=true.
Utilization looks stale
Free-address counts change with every ENI and are refreshed by the full resync, not by events.
Lower resyncInterval if you need them fresher.
Metrics are not scraped
With metrics.secure=true Prometheus needs a token and must accept the self-signed
serving certificate; the chart's ServiceMonitor sets both. Check the labels your Prometheus
selects ServiceMonitors on.