Skip to content

feat: Allocate an egress shard's public address - #26

Draft
scotwells wants to merge 5 commits into
mainfrom
feat/egress-shard-address-claim
Draft

scotwells wants to merge 5 commits into
mainfrom
feat/egress-shard-address-claim

Conversation

@scotwells

@scotwells scotwells commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

The public address an egress shard translates to is hand written by an operator today, so no shard outside a lab has one and nothing records which address went where.

This claims an address from the addressing service for each shard and assigns it, running in the edge cluster that holds the shard rather than centrally, so the credential stays off the machines carrying tenant packets and off the path that attaches a workload.

It also ships the addressing inventory an operator applies to make the space real, deliberately without a prefix, so applying it unedited is refused rather than quietly reserving public space nobody assigned.

Reaching the internet still needs that prefix chosen and its route announced, neither of which this does.

Architecture

sequenceDiagram
    participant S as Egress shard
    participant C as Address controller, in the same edge cluster
    participant I as Addressing service, in the project
    S->>C: a shard exists with no public address
    C->>I: claim one address from the operator's pool
    I-->>C: a new address, or the one this shard already holds
    C->>S: assign it, and record the claim it came from
    Note over S: assignment is write-once, so a recovered claim has to be the real one
    S->>C: shard released
    C->>I: return the address to the pool
Loading

Test plan

  • A shard with no address gets one, and a shard that has one is left alone
  • An address already held is recovered rather than reallocated
  • Applying the inventory without a chosen prefix is refused
  • Releasing a shard returns its address to the pool

Related to datum-cloud/network-services-operator#475

scotwells and others added 5 commits September 17, 2026 17:49
An egress shard no longer waits for an operator to hand-write the public
address it translates to. A controller in the cell claims one address per
shard from the addressing service and writes it into the shard's spec,
where the data plane already reads it.

The shard does not claim its own address. A shard runs on every
translating node, including hardware at the edge, and the process that
would make the claim is the one serving the datapath -- so the credential
would sit on every such node, reachable from the process that also
handles tenant packets, and the allocation would sit beside the path that
attaches a workload. One controller per cell moves the credential count
from the number of translating nodes to the number of cells and takes the
allocation off that path: an address is claimed when a node is
commissioned, not when a workload arrives.

It is a binary of its own rather than a reconciler inside vpc-controller,
following the split fabric-identity-controller already establishes for a
controller needing an addressing credential. vpc-controller writes the
attachment state of every workload in the cell and serves an admission
webhook; a credential into the platform's own tenancy widens the blast
radius of the one component a cell cannot run without, and one that
expired would stop workloads attaching.

spec.shardAddressIPv6 is write-once, so nothing here writes a value that
is not an address the service has already bound. Every path that cannot
produce one leaves the field untouched and retries.

Key changes:
- Add internal/egressaddress, which holds one address per shard under a
  claim named for that shard. Attribution rides on the name and on
  annotations because the service overwrites spec.ownerRef with the
  requesting project's identity
- Add EgressShardAddressReconciler, which assigns the address and the
  IPv6 family label the selectors placing traffic on a shard match, and
  releases the claim only for a shard that is actually gone
- Read the address from status.allocatedCIDR alone. The API carries a
  status.address holding the single-address form and no released version
  of the service writes it
- Adopt a retained allocation the service refuses a same-named claim
  with, so an address held by an earlier claim is read rather than lost
  behind a conflict that never clears (milo-os/ipam #107)
- Claim with reclaimPolicy Delete. Retain would hand a recreated shard
  the same address back and defeat the only remedy the write-once field
  leaves for a wrong one, and would never return the address at all
- Add config/ipam/egress-shard-ipv6.yaml, the pool and class chain an
  operator applies. One pool per location, keyed on location rather than
  cell because two cells already serve one location. IPv6 only: the
  platform pairs no resolver with a translator, so an IPv4 egress address
  would be reachability the API reports and the data plane does not
  deliver
- Leave the root pool's cidr empty. The service refuses a root pool
  without one, so applying the file unedited is rejected rather than
  seeding a permanent aggregate from a guess

This cannot build until go.datum.net/network publishes the EgressShard
spec address fields and label keys, which are on an unpushed branch. A
local go.work resolves them for development and is gitignored; a
committed replace directive would break the build for every checkout and
would still need reverting after the dependency ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI checks out this repo alone, so a module replace pointing at a local
path cannot resolve. Point both at the pushed commits instead, which CI
can fetch, so the build reflects the branches this depends on.

Key changes:
- Replace the network module with the pushed egress address commit
- Replace the operator module with the pushed egress API commit
- Drop the local workspace file in favour of resolvable versions

Revert both replaces once those modules release the fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claiming an address now reports the record in the addressing service that
holds it, alongside the address itself. Nothing is written to the shard
yet: the spec field that will carry the reference is not published, so
this is the plumbing a correct reference needs and not the reference.

The record has to be reported rather than recomputed by whoever wants it.
The service overwrites spec.ownerRef on a claim with the requesting
project's identity, so a claim cannot say which shard it was made for,
and the only place that link can live is on the shard pointing back.

The adopt path is why a holder carries its kind. When an allocation
retained by an earlier claim already occupies a claim's identity, the
service rolls its transaction back before refusing, so the claim it
refused is never stored. The address is recovered from the IPAllocation
the refusal names, and that name is a hash of the claim's namespace and
name rather than the claim name, so it cannot be derived from the shard.
A reference built from the shard's own name would, in exactly this case,
name an object that does not exist.

Key changes:
- Return a Holding from egressaddress.Claim carrying the address and the
  namespace, kind and name of the record holding it, taken from the
  object actually stored rather than recomputed
- Report a retained allocation as held by KindIPAllocation, under the
  name the refusal carried
- Report nothing at all for an unbound claim, so there is no partial
  holder to write into a field that cannot be rewritten
- Assign the address in one update, so a rule pairing it with the record
  holding it is satisfied by that write rather than by a second one that
  could fail on its own
- Log the holder as a stopgap for auditability. A log line is not
  queryable and ages out; it is not the record

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A shard now carries the addressing-service record its public IPv6 address
came from, written in the same update as the address itself. The address
alone is unattributable: the service overwrites a claim's spec.ownerRef
with the requesting project's identity, so a claim cannot say which shard
it was made for and the trail has to run from the shard back.

The reference names the record that exists, which is not always a claim.
An address recovered from a retained allocation is held by an
IPAllocation and by no claim at all -- the service rolls its transaction
back before refusing the create, so the claim it refused was never
stored, and its name is a hash of the claim's namespace and name rather
than the claim name. Kind carries that difference instead of taking its
default, because both fields are write-once and a reference naming a
claim that does not exist would be permanent for the shard's lifetime.

Nothing gains a reference it did not earn. An address assigned by hand
has no claim behind it and stays unattributable rather than gaining one
this controller invented, and a claim holding no address yet writes
neither field.

Key changes:
- Write spec.shardAddressIPv6ClaimRef beside the address in one update.
  The API pairs neither field with the other, so this is for atomicity
  rather than to satisfy validation: two writes would leave a window
  where the address is recorded and its origin is not
- Set Kind from the record actually read, IPClaim or IPAllocation, rather
  than accepting the IPClaim default
- Carry the platform project on the reconciler and wire it from the
  existing -platform-project flag. The reference requires it: a claim is
  namespaced within a project and this reference is read from outside
  every project, so a namespace alone does not identify one
- Refuse to start without it, alongside the existing required values
- Repin go.datum.net/network to affb112, which publishes
  AddressClaimRef and the field

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claimer mounted a credential that nothing had permitted to create
claims. This adds the grant, gives the component an identity of its own,
and states on the deployment what holding that credential reaches.

The narrowest grant the platform can express is coarser than the one the
component wants. Milo carries the project in the caller's user extras and
RBAC does not read extras, so verbs are authorized on every project path
at once; an iam.miloapis.com PolicyBinding naming the Project is the only
way to narrow that, and both existing IPAM grants are wide for the same
reason. Per-namespace scoping cannot be expressed at all: IPAM declares
Project as the only parent of an IPClaim, so a namespaced Role would
authorize that namespace name in every project and narrow nothing.

The grant is not part of the cell overlay. It is applied to the Milo
control plane, so carrying it here would invite applying it to a cell.

Key changes:
- Add config/egress-address/milo-grant.yaml: a ClusterRole granting get,
  create and delete on IPClaims and get on IPAllocations, bound to
  system:egress-address@cloud.datumapis.com. Deliberately excluded from
  the overlay's kustomization
- Grant no classes and no pools under any verb, and no delete on
  IPAllocations. Claims carry reclaim policy Delete, so removing a claim
  frees its address without reaching past it; address space is operator
  inventory and nothing that allocates from it needs to reshape it
- Specify the certificate and per-cell Secret that have to be created
  with it, including that the Secret must not be the cell controller's
  ipam-cluster-kubeconfig
- Mount egress-address-ipam-kubeconfig rather than reusing that Secret
  name, which authenticates as system:nso-cell and additionally carries
  delete on IPAllocations. Sharing the name would have handed this
  component the broader identity silently
- State the blast radius on the volume a reviewer reads to understand the
  credential: what a compromised holder could do, what it could not, and
  why the scope stops where it does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant