Subnet Operator discovers VPCs and subnets across every account and region from resource tags, keeps them as Kubernetes objects and flags what is wrong. No more spreadsheets. Untagged networks are one click from being managed, and when you are ready it can also allocate subnets automatically.
Most IT teams track VPCs and subnets by hand: a shared sheet, a wiki page, a CMDB export. It goes stale on the first day, and the gaps show up at the worst possible moment.
Subnets are created by Terraform, consoles and scripts. Nobody updates the sheet, so it slowly turns into fiction.
Two teams pick the same CIDR in different accounts. You find out when peering or Transit Gateway routing breaks.
Who owns this subnet? Is it 90% full? Answering takes a ticket, three consoles and a guess.
And every tool that promises to fix this starts with "first, tag everything" — which is why the spreadsheet is still there.
The inventory is useful on its own and safe to run anywhere: it only reads. Automatic management is a separate, opt-in layer on top of the same API.
Everything the spreadsheet tried to be, generated from the cloud and kept current.
ResourceImport tags the resource in AWS, nothing elseec2:Describe* calls. Nothing in your cloud is ever modified. The Google Sheet mirror is written, never read back, so the old spreadsheet stops being something anyone maintains.Teams ask for capacity; the operator picks a free CIDR and creates correctly tagged subnets.
SubnetClaim: prefix length, zones, owner, environment, tier and the target network (VPC)hs/claim tag that lets the operator adopt a subnet it created--enable-writes (writes.enabled in the chart) and
deploy a write role of its own, and only for the namespaces a scope allows. Tagging a resource needs
nothing but ec2:CreateTags.The operator runs in a hub cluster and reaches spoke accounts with short-lived AssumeRole credentials. Change events tell it which account and region to look at again.
Mark VPCs with hs/managed=true and owners — by hand, in Terraform, or by importing what discovery found untagged. Tag policies keep the schema honest.
A NetworkScope lists accounts, regions and required tags.
The operator assumes a read-only role per account and mirrors what it finds.
CloudTrail events trigger a targeted resync of just the account/region that changed.
Discovery does not stop at the tag selector: it lists what it can see but nobody has claimed. Importing a resource applies the organization's tags in AWS — nothing else changes — and the inventory picks it up on the next CloudTrail event, by hand or by policy.
Discovered, but not tagged for the operator.
| Resource | CIDR | ||
|---|---|---|---|
| vpc | vpc-0f3e2a1b7c9d4e56terraform | 10.90.0.0/16 | Import |
| subnet | subnet-04d1c2b3a4e5f607 | 10.30.4.0/24 | Import |
| subnet | subnet-0b2c3d4e5f6a7b89 | 10.20.40.0/22 | Import |
apiVersion: network.hypersurgery.dev/v1 kind: ResourceImport spec: scopeRef: organization account: "333333333333" region: eu-central-1 resourceID: subnet-04d1c2b3a4e5f607 tags: hs/managed: "true" hs/owner: team-data hs/env: prod hs/tier: private
Every VPC and subnet in the accounts is discovered; the tag selector decides what is managed, not what is visible.
A ResourceImport object, applied by hand or through GitOps, becomes one ec2:CreateTags call. Tags stay the source of truth; the CRD is not a second spreadsheet.
For resources that carry managed-by=terraform the app warns and shows the ignore_changes snippet, so the next plan does not undo the import.
Importing needs nothing but ec2:CreateTags; who imported what, and when, is in the object, in a Kubernetes Event, in the operator's audit stream and in CloudTrail.
Someone creates a subnet from the console at 5 pm on a Friday. The CloudTrail event that tells the operator about it also says who did it. With an auto-import policy on the scope, the operator derives the tags — from the creator's role, from the parent VPC, or from the account's defaults — and applies them within seconds. What it cannot resolve becomes an alert for a human instead of a silent gap.
ResourceImport objects — you review a day of them before switching to Applyspec: autoImport: mode: DryRun # Off | DryRun | Apply namespace: platform # where its ResourceImports go fromCreator: - principalPrefix: "arn:aws:sts::222222222222:assumed-role/payments-" tags: { hs/owner: team-payments } - principalPrefix: "arn:aws:sts::333333333333:assumed-role/data-platform-" tags: { hs/owner: team-data } inheritFromNetwork: [hs/owner, hs/env] accountDefaults: - account: "333333333333" tags: { hs/env: prod } skip: - tagKey: managed-by tagValue: terraform - principalPrefix: "arn:aws:sts::222222222222:assumed-role/terraform-" requiredTags: [hs/owner] # otherwise: leave unmanaged, alert
The operator's metrics carry every unmanaged resource with its account and region, so an ordinary Prometheus rule turns a hand-made subnet into a Slack or Teams message a few seconds after CloudTrail sees it — with the import one click away.
hs/managed in the last 10 minutes.- alert: UnmanagedNetworkResource expr: increase(hs_unmanaged_resources_total[30m]) > 0 for: 10m labels: { severity: warning } annotations: summary: "{{ $value }} new unmanaged {{ $labels.kind }} resources in {{ $labels.account }}/{{ $labels.region }}" runbook_url: "https://hypersurgery.dev/docs/#unmanaged" - alert: AutoImportedResources # info: a daily digest, not a page expr: increase(hs_auto_imports_total{result="applied"}[24h]) > 0 labels: { severity: info }
receivers: - name: network-slack slack_configs: - channel: "#network-inventory" title: "{{ .CommonLabels.alertname }}" actions: - type: button text: "Import…" url: "https://hypersurgery.dev/dashboard/" - name: network-teams msteamsv2_configs: - webhook_url: "https://….webhook.office.com/…" route: routes: - matchers: [alertname = UnmanagedNetworkResource] receiver: network-slack continue: true - matchers: [alertname = AutoImportedResources] receiver: network-teams
The chart's PrometheusRule ships all of it: the unmanaged-resource rule
above, a daily digest of what the policy imported by itself, plus a subnet at 85% (configurable), a subnet with no
free addresses, an unreachable or throttled account, overlapping network CIDRs, a stale inventory, a claim or import
stuck for too long and an operator that is not running. Each has a
runbook entry,
and they all travel this route into Slack or Teams.
Select VPCs by tags; every subnet inside is included, so untagged ones cannot hide.
One operator, many accounts. Role ARNs are checked against the declared account ID.
EventBridge → SQS, debounced, leader-only. Only the changed account/region is resynced.
Missing required tags per subnet, surfaced in status and as metrics.
CIDR overlaps across VPCs, GCP networks, accounts and projects, before peering or TGW routing fails.
Free and total IPs, missing tags, overlaps, target health. Alert on 80% before it is 100%.
Terraform-owned resources are observed, never mutated. Allocate-only mode keeps Terraform in charge.
An admission webhook rejects a claim that cannot be satisfied — a prefix that does not fit the VPC, an AZ from another region — with a readable message, instead of failing quietly in a reconcile loop.
Every import, allocation and policy decision leaves a Kubernetes Event on the object and one JSON line on a separate stream: who asked, what changed, and the tags before and after.
Untagged VPCs and subnets are listed, not hidden. Importing one applies the tags in AWS — with a warning when Terraform owns it.
Group network.hypersurgery.dev, version v1: one API for every cloud, with a written
compatibility promise
(v1beta1, the API of 0.8 and 0.9, is still served, deprecated). Network and Subnet objects are written by the operator and owned by their scope.
apiVersion: network.hypersurgery.dev/v1 kind: NetworkScope metadata: name: organization spec: provider: AWS accounts: - id: "111111111111" # hub, own credentials - 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
spec: provider: AWS id: subnet-0e4f5a6b networkID: vpc-0aa11bb2 account: "111111111111" region: eu-central-1 status: cidrBlock: 10.20.2.0/24 zone: eu-central-1b totalIPs: 251 availableIPs: 31 utilizationPercent: 87 owner: team-payments tier: private missingTags: [hs/env] aws: public: false
The core uses three EC2 Describe calls. Write permissions live in a separate role you add later, if ever.
Removing an object from the cluster never removes a subnet. Deletion stays a human decision.
Credentials are verified against the declared account ID, so a typo cannot report another account's networks.
An unreachable account is reported, not erased. Its last known state stays until it syncs again.
Two replicas by default with a disruption budget and leader election, so draining the node holding the lease does not pause discovery.
The credential Secret is read through a namespaced Role, not a cluster-wide permission. A compromised operator does not open every Secret you have.
These badges are live. They come from the runs themselves, not from a screenshot someone took on a good day.
v1 API included.v1 API and its compatibility rules — but it has not yet been run against a real AWS organization, and the Google Cloud provider of 2.0 and the Azure provider of 3.0, tested against in-repository fakes of the Google and Azure APIs, not against a real project or subscription. If you can run the read-only core in one, we would like to hear how it went; until someone has, this page will keep saying so.
Why the tags are the inventory and the table never was, what happens in the five minutes after somebody creates a subnet by hand, and what the alerts, the Grafana dashboard and the manifests actually look like.
Read the articleMulti-account, multi-region discovery; Network (VPC) and Subnet CRDs; compliance findings; CIDR overlaps; metrics.
Near-real-time updates and the views people actually look at.
SubnetClaim with a built-in first-fit CIDR allocator, route-table association, allocate-only mode for Terraform; ResourceImport to take untagged networks under management with a single object, and a policy that does it by itself.
Admission webhooks refuse an impossible claim at kubectl apply instead of a reconcile later;
every import, allocation and policy decision leaves a Kubernetes Event and a line in a JSON audit stream;
the chart runs two replicas with a disruption budget and reads credentials from one namespace, not the cluster.
The image is public, multi-arch and signed with cosign keyless, so installing needs no
credentials and you can check what you are about to run. The operator alerts on its own
absence and on claims and imports that stay stuck, and a stopped leader hands its lease
over in about a second and a half. A target that AWS throttles backs off on its own and
alerts as throttled, not as down; the chart itself is signed on ghcr.io; and the dashboard
can run in the cluster, reading it as whoever is looking. Every change is now tested as an
upgrade from the last release, capacity is measured rather than estimated (400
account/region pairs), and a threat model came with fixes: the write role can touch only
networks, auto-import never overwrites a tag somebody set, a scope can be limited to the
namespaces allowed to write through it, and the audit trail records who really created a
claim or an import. Still open: a conformance run
against a real AWS organization.
The API moved to network.hypersurgery.dev, with the cloud as a field of the scope rather
than part of every name, and the project became subnet-operator. 0.8 migrated
existing objects itself, status included, so an upgrade did not lose a reservation; 0.9 dropped the
old group, and will not run next to an object that was never migrated. Decided in
ADR 0002.
The v1 API with a written
compatibility promise,
in which a scope that names no namespaces can be written through by none. v1beta1 stays served,
deprecated, through a conversion webhook, and the operator moves what 0.9 stored to v1 on its own.
The support policy applies in
full, and every release also ships a signed OLM bundle. Stable means that promise: the conformance run
against a real AWS organization is still open.
The same inventory, claims and imports for GCP projects, added to the v1 API without breaking
it. A major release because the ownership tag keys become provider-dependent (hs/* stays on
AWS, hs-* on GCP), with an upgrade guide, as the
support policy
provides for a new provider. Released in 2.0.0:
discovery of VPC networks and subnetworks with their IP usage and Resource Manager tags; subnetworks
created by claims and tags bound by imports, only ever added; GKE Workload Identity, Workload Identity
Federation and a read and a write service account per project; and change events from Cloud Audit Logs
through a log sink and Pub/Sub; and overlaps between GCP networks, peered ones flagged. Still open is a
conformance run against a real Google Cloud project.
The GCP guide.
The same for Azure subscriptions, still on the v1 API. Azure subnets cannot carry tags, so
their ownership is kept on the parent virtual network, one tag per subnet. A major release because it
adds a provider, as the
support policy
provides; nothing breaks for AWS or Google Cloud. Released in 3.0.0: discovery of virtual networks and subnets with their
address usage; subnets created by claims and ownership written by imports, only ever added, within the 50
tags a virtual network may carry; Microsoft Entra Workload ID with a read and a write identity per
subscription; and change events from Event Grid through a Storage queue. All of it is tested against
in-repository fakes of the Azure APIs. Still open is a conformance run against real subscriptions, and
Azure's events do not say who created a resource, so creator rules of the auto-import policy do not apply
there.
The Azure guide.
One API group for every cloud, with the provider as a field: what all clouds share is a neutral field, what only one has lives in that provider's own member, so the model follows real platforms instead of a lowest common denominator.
provider: AWSVPCs, subnets, route tables, CloudTrail events, AssumeRole. Available now.
provider: GCPGlobal VPC networks, regional subnetworks with secondary ranges, Shared VPC host projects, Resource Manager tags, Workload Identity, audit log events through Pub/Sub. Available from 2.0.
provider: AzureVirtual networks and subnets across subscriptions, subnet ownership kept as tags on the virtual network, Microsoft Entra Workload ID, Event Grid events through a Storage queue. Available from 3.0; tested against fakes, not yet against real subscriptions.
helm repo add hypersurgery https://charts.hypersurgery.dev
We are preparing the project for the CNCF Sandbox. The most useful help right now:
v1 API: it only grows now, so additions are still possible