Documentation

Install with Helm

From nothing to a live subnet inventory in four steps. The chart comes from charts.hypersurgery.dev (or, signed, from ghcr.io), and unless you turn writes on the operator only reads your AWS accounts.

Before you start

You need:

Out of the box the operator never writes to AWS. The inventory uses three 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.
What "stable" means here. The 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

1

Add the chart repository

helm repo add hypersurgery https://charts.hypersurgery.dev
helm repo update hypersurgery
helm search repo hypersurgery/subnet-operator
2

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.

3

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.

4

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.

  1. Make sure CloudTrail records management events in every account, for example with an organization trail.
  2. Deploy deploy/events/hub-events.cfn.yaml in the operator's account, and deploy/events/spoke-events.cfn.yaml to every other account and region with a StackSet.
  3. 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.

  1. Deploy deploy/iam/spoke-write-role.cfn.yaml to the accounts that may get new subnets. It is a separate role from discovery and cannot delete anything.
  2. Reference it in the scope as spec.accounts[].aws.writeRoleARN, and list the namespaces that may use it in spec.namespaceSelector — a scope without one lends its roles to no namespace.
  3. 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>.

Nothing is ever deleted. Deleting a claim leaves its subnets in AWS. Terraform-first teams set 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.

Five rules keep it from being a surprise. Start in 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)

New in 2.0. The Google Cloud provider is what 2.0 adds; a 1.x chart has no 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/:

RoleGrant toFor
reader-role.yamlthe read service account, in every project of the scopediscovery
impersonator-role.yamlthe operator's own identity, on each service accountimpersonation
writer-role.yamlthe write service account, in the host projectclaims and imports
tag-user-role.yamlthe write service account, on the tag keysbinding tags
tag-value-creator-role.yamlthe write service account, on the operator's keys onlyonly 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)

New in 3.0. The Azure provider is what 3.0 adds; a 2.x chart has no 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:

RoleAssign toFor
reader-role.jsonthe read identity, on every subscription of the scope, or only on the resource groups the scope names in spec.azure.resourceGroupsdiscovery, and the read of the virtual network before an import
writer-role.jsonthe write identity, on the subscription or the virtual networks' resource groupsclaims and imports
Tag Contributor (built in)the write identity, instead of the writer roleimports alone
Storage Queue Data Message Processor (built in)the operator's own identity, on the queueonly 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).

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

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.

AlertFires whenSeverity
SubnetOperatorDownno 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 setcritical
SubnetClaimNotReadya SubnetClaim has not been fulfilled for 30 minutes; reason says why — NoSpace, WritesDisabled, CreateFailedwarning
ResourceImportNotSettledtags a ResourceImport asked for have not reached the cloud for 30 minutes; a dry run never fires itwarning
SubnetNearlyFulla subnet is at or above prometheusRule.thresholds.subnetUsedRatio (0.85) for 15 minuteswarning
SubnetFulla subnet has no free IPv4 address for 15 minutescritical
SubnetInventoryTargetDownan account/region cannot be discovered for 15 minutes, for a reason other than throttlingwarning
SubnetInventoryTargetThrottledthe 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 itwarning
NetworkCIDROverlaptwo networks in the scope overlap for 30 minutes: two VPCs' CIDRs on AWS, the ranges of two GCP networks' subnetworks (primary, secondary, IPv6) on GCPwarning
NetworkPeeredCIDROverlaptwo 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.overlapscritical
SubnetInventoryChangeEventsFailingthe change events source (the SQS queue on AWS, the Pub/Sub subscription on GCP) keeps failing to be read; changes then wait for the resyncwarning
NetworkTagBudgetLowAzure (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 minuteswarning
SubnetInventoryStaleno full resync for prometheusRule.thresholds.staleSyncSeconds (1 hour)warning
UnmanagedNetworkResourcea network or subnet without the managed tag (hs/managed, on GCP hs-managed) is seen for the first timewarning
AutoImportedResourcesthe 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 tooinfo

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.

Use cert-manager in production and with GitOps (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

ValueDefaultWhat it does
image.repositoryghcr.io/aivandrago/subnet-operatorManager image. Public and signed; point it at your own registry if you mirror it.
providers.aws.regionemptyRegion for the operator's own calls.
providers.aws.irsaRoleARNemptyThe operator's role with IRSA; EKS Pod Identity needs nothing.
discovery.concurrency4Account/region pairs discovered in parallel.
providers.aws.events.queueUrlemptySQS queue with EC2 change events (the QueueUrl output of the hub stack).
metrics.securetrueHTTPS with authn/authz on the metrics endpoint.
metrics.serviceMonitor.enabledfalseServiceMonitor for the Prometheus operator.
prometheusRule.enabledfalseAlerting rules.
grafanaDashboard.enabledfalseDashboard ConfigMap for the Grafana sidecar.
writes.enabledfalseLet SubnetClaims in Create mode create subnets and ResourceImports apply tags. Needs a write role per account.
networkScope.createfalseCreate a scope with the release instead of by hand.
networkScope.namespaceSelectornullNamespaces allowed to use that scope. null allows none; {} every namespace, with a warning.
replicaCount2Leader election keeps one active; the standby takes the lease when a node is drained.
podDisruptionBudget.enabledtrueNot rendered while replicaCount is 1 — a budget over one replica blocks the drain instead of surviving it.
terminationGracePeriodSeconds45Longer than the manager's graceful shutdown, so an in-flight sync is not killed mid-write.
networkPolicy.enabledfalseEgress to the API server and AWS, ingress for the metrics scrape, probes and the webhook port.
webhook.enabledtrueAdmission webhooks. Off means invalid objects are accepted and reported in status instead.
webhook.certificate.certManagerautoUses cert-manager when its API is present, a chart-signed certificate otherwise. Set true in production and with GitOps.
webhook.certificate.duration / renewBefore8760h / 720hCertificate lifetime and renewal window, for cert-manager and the chart-signed certificate (renewed on upgrade).
webhook.failurePolicyFailIgnore lets objects through while the operator is unreachable.
audit.sinkstdoutoff 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.enabledfalseThe dashboard app in the cluster, reading as each viewer with their own token. See below.
resources500m / 512MiContainer 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
From 2.x to 3.0 nothing breaks. 3.0 is a major release because it adds the Azure provider, which is off until you enable it. No value, flag, metric or alert of 2.x is removed or renamed, the operator's RBAC and the AWS and Google Cloud permissions are the same, and nothing is migrated: apply the CRDs and upgrade. New for those who use them: the alert 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).

On this site the app shows demo data, so it is a way to see the shape of the inventory before you install anything. The same app reads your own cluster in two ways, below. Either way it reads as you: the Kubernetes API decides what you see, and an import is created in your name.

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:

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.