Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ COPY internal/ internal/

RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o vpc-controller cmd/main.go
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o fabric-identity-controller cmd/fabric-identity-controller/main.go
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -ldflags "-s -w" -o egress-address-controller cmd/egress-address-controller/main.go

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=builder /workspace/vpc-controller .
COPY --from=builder /workspace/fabric-identity-controller .
COPY --from=builder /workspace/egress-address-controller .
USER 65532:65532

ENTRYPOINT ["/vpc-controller"]
169 changes: 169 additions & 0 deletions cmd/egress-address-controller/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
Copyright © 2026 Datum Technology, Inc. All rights reserved.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

// Command egress-address-controller gives each egress shard in a cell the
// public IPv6 address it translates to.
//
// It runs in the cell, unlike fabric-identity-controller, which allocates
// centrally because a network spans locations and its identity must be the same
// in all of them. A shard is the opposite case: it names the Node it executes
// on, so it exists only where that Node does, and nothing about its address has
// to agree with any other location.
//
// It is a binary of its own rather than a reconciler inside vpc-controller
// because it needs a credential vpc-controller does not have. vpc-controller
// writes the attachment state of every workload in the cell and serves an
// admission webhook; giving that pod a credential into the platform's own
// tenancy widens the blast radius of the one component the cell cannot run
// without, and a missing or expired address credential would stop workloads
// attaching. Split out, an address that cannot be claimed costs new shards
// their addresses and costs nothing else.
package main

import (
"flag"
"os"

"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/clientcmd"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

"go.datum.net/cloud/internal/controller"
"go.datum.net/cloud/internal/ipam"
bgpv1alpha1 "go.datum.net/network/api/v1alpha1"
)

var scheme = runtime.NewScheme()

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(bgpv1alpha1.AddToScheme(scheme))
}

func main() {
var metricsAddr, probeAddr string
var addressClass, claimNamespace, location, platformProject, ipamKubeconfig string
var enableLeaderElection bool

flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", true,
"Enable leader election. A single writer is what keeps one shard to one address.")
flag.StringVar(&addressClass, "address-class-ipv6", "",
"Required. The IPClass that hands out shard addresses. It draws from announceable public space shared by every shard in a location.")
flag.StringVar(&claimNamespace, "claim-namespace", "default",
"Namespace in the platform's own tenancy that address claims are written to.")
flag.StringVar(&location, "location", "",
"Required. The location this cell serves. It selects the shared public range addresses come from; two cells serving one location draw from the same range.")
flag.StringVar(&platformProject, "platform-project", "",
"Required. The project control plane the platform allocates its own values in. A shard's address is not a consumer's address and must not be drawn from any one consumer's space or counted against their quota.")
flag.StringVar(&ipamKubeconfig, "ipam-kubeconfig", "",
"Required. Path to a kubeconfig for the cluster serving the IPAM API.")

opts := zap.Options{Development: false}
opts.BindFlags(flag.CommandLine)
flag.Parse()

ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
setupLog := ctrl.Log.WithName("setup")

// Every one of these is fatal at startup rather than per shard. A shard's
// address cannot be corrected once written, so a deployment that would draw
// from the wrong space, or from no space, must not reach a single shard.
switch {
case addressClass == "":
setupLog.Error(nil, "-address-class-ipv6 is required")
os.Exit(1)
case location == "":
// A claim carrying the wrong location is the dangerous case, not the
// missing one: it succeeds, and hands this cell an address that another
// location's fabric attracts.
setupLog.Error(nil, "-location is required")
os.Exit(1)
case platformProject == "":
setupLog.Error(nil, "-platform-project is required")
os.Exit(1)
case ipamKubeconfig == "":
setupLog.Error(nil, "-ipam-kubeconfig is required")
os.Exit(1)
}

// The manager runs against the cell this is scheduled on, which is also
// where the shards are. There is no second cluster: an EgressShard names a
// Node, so it is never anywhere but the cell holding that Node.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "egress-address-controller.cloud.datumapis.com",
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}

ipamRestConfig, err := clientcmd.BuildConfigFromFlags("", ipamKubeconfig)
if err != nil {
setupLog.Error(err, "unable to load the IPAM kubeconfig")
os.Exit(1)
}

ipamScheme, err := ipam.Scheme()
if err != nil {
setupLog.Error(err, "unable to build the IPAM scheme")
os.Exit(1)
}

ipamClients, err := ipam.NewClientFactory(ipamRestConfig, ipamScheme, platformProject)
if err != nil {
setupLog.Error(err, "unable to build the IPAM client factory")
os.Exit(1)
}

if err := (&controller.EgressShardAddressReconciler{
Client: mgr.GetClient(),
IPAM: ipamClients,
AddressClassIPv6: addressClass,
ClaimNamespace: claimNamespace,
PlatformProject: platformProject,
Location: location,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "EgressShardAddress")
os.Exit(1)
}

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}

setupLog.Info("starting egress address controller", "location", location)
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}
}
145 changes: 145 additions & 0 deletions config/components/egress-address/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: egress-address-controller
namespace: system
labels:
app.kubernetes.io/name: egress-address-controller
app.kubernetes.io/component: egress-address-controller
app.kubernetes.io/managed-by: kustomize
spec:
# A shard's address is decided once and cannot be reassigned. Leader election
# is what keeps that true across a rollout; the replica count is not.
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: egress-address-controller
template:
metadata:
annotations:
kubectl.kubernetes.io/default-container: manager
labels:
app.kubernetes.io/name: egress-address-controller
app.kubernetes.io/component: egress-address-controller
spec:
serviceAccountName: vpc-controller
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: manager
image: ghcr.io/datum-cloud/vpc-controller
command:
- /egress-address-controller
# Args reference env vars so an overlay can retarget any value with a
# strategic-merge patch on env, matched by name, instead of rewriting
# the args list.
args:
- --leader-elect
- --health-probe-bind-address=:8081
- --metrics-bind-address=:8080
- --address-class-ipv6=$(ADDRESS_CLASS_IPV6)
- --claim-namespace=$(CLAIM_NAMESPACE)
- --location=$(LOCATION)
- --platform-project=$(PLATFORM_PROJECT)
- --ipam-kubeconfig=/etc/egress-address-ipam/kubeconfig
env:
# The class that hands out shard addresses. Required; a deployment
# naming no class refuses to start rather than draw from a default
# that would hand a shard a private address nothing routes.
- name: ADDRESS_CLASS_IPV6
value: datum-egress-shard-address-ipv6
- name: CLAIM_NAMESPACE
value: default
# The location this cell serves, which must equal the cell's own
# topology.datum.net/location label. Required and deployment
# specific: a cell claiming under another location's name is handed
# an address that location's fabric attracts, and the assignment
# cannot be taken back.
- name: LOCATION
value: ""
# A shard's address is not a consumer's address, so the claim is
# written in a project the platform owns. Required and
# deployment-specific.
- name: PLATFORM_PROJECT
value: ""
ports:
- name: metrics
containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: egress-address-ipam-kubeconfig
mountPath: /etc/egress-address-ipam
readOnly: true
volumes:
# BLAST RADIUS OF THIS CREDENTIAL. Read this before widening it.
#
# It is a client certificate authenticating to Milo as
# system:egress-address@cloud.datumapis.com, and it is what lets this
# component claim addresses in the platform's own project -- the tenancy
# holding every platform-owned allocation, including the fabric
# identities a network's forwarding state is keyed on. There is one of
# these per cell, and a cell is the least trusted cluster in the fleet.
#
# The narrowest scope the platform can express is (project) x (resource
# type) x (verb), and RBAC alone cannot even do the project: Milo
# carries the project in the caller's user extras, which RBAC does not
# read. Per-namespace scoping does not exist at all -- IPAM declares
# Project as the only parent of an IPClaim -- so "claims in one
# namespace" is not a grant that can be written. See
# ../../egress-address/milo-grant.yaml for the grant and the reasoning.
#
# What a compromised holder could therefore do: create, read and delete
# IPClaims, and read IPAllocations, in ANY project, under any class it
# can name. Deleting a claim of a class whose reclaim policy is Delete
# releases that address for reissue, so the reachable damage includes
# taking another shard's or another consumer's address out from under
# it.
#
# What it could not do: change a pool or a class, because no grant here
# includes them; take an address already written into a shard's spec,
# because those fields are write-once; or reach any consumer's workload
# or control plane, because nothing else is bound to this identity.
#
# It is deliberately NOT the cell controller's ipam-cluster-kubeconfig,
# which authenticates as system:nso-cell and additionally carries delete
# on IPAllocations. Sharing that Secret name would silently hand this
# component the broader identity.
#
# Not optional. This component does one thing and cannot do it without
# the address service. A pod waiting in ContainerCreating for a
# credential that has not landed says so plainly; one started against an
# empty dir crashloops until the kubelet's next volume resync, which
# reads as a broken image rather than a missing secret.
#
# There is deliberately no second mount. Unlike the central fabric
# identity controller, everything this reads and writes in the cluster
# is local: an EgressShard names a Node, so it never exists anywhere but
# the cell holding that Node.
- name: egress-address-ipam-kubeconfig
secret:
secretName: egress-address-ipam-kubeconfig
terminationGracePeriodSeconds: 10
5 changes: 5 additions & 0 deletions config/components/egress-address/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources:
- deployment.yaml
- metrics_service.yaml
17 changes: 17 additions & 0 deletions config/components/egress-address/metrics_service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: egress-address-metrics
namespace: system
labels:
app.kubernetes.io/name: egress-address-controller
app.kubernetes.io/component: egress-address-controller
app.kubernetes.io/managed-by: kustomize
spec:
ports:
- name: metrics
port: 8080
protocol: TCP
targetPort: metrics
selector:
app.kubernetes.io/name: egress-address-controller
42 changes: 42 additions & 0 deletions config/egress-address/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# The claimer of an egress shard's public address. It runs in the cell, beside
# the shards it writes, which is what separates it from the fabric identity
# overlay next door: a network spans locations and its identity cannot be
# decided in any one of them, while a shard names the Node it executes on and
# exists only where that Node does.
#
# It is its own overlay rather than a reconciler inside the cell manager because
# it holds a credential the cell manager does not. The cell manager writes the
# attachment state of every workload here and serves an admission webhook;
# giving that pod a credential into the platform's own tenancy widens the blast
# radius of the one component a cell cannot run without, and an address
# credential that expired would stop workloads attaching. Split out, an address
# that cannot be claimed costs new shards their addresses and nothing else.
#
# RBAC comes from ../rbac unchanged. The repo generates one ClusterRole from
# every marker under ./internal/..., so every role this image runs shares a role
# and a ServiceAccount name.
#
# The authorization this component needs is NOT here. It is applied to the Milo
# control plane rather than to a cell, so it would be wrong for this overlay to
# carry it: see milo-grant.yaml beside this file for the grant, the certificate
# and Secret that have to exist with it, and why the scope cannot be narrowed
# to one project by RBAC or to one namespace by anything.
#
# The EgressShard CRD is not included here. It belongs to
# go.datum.net/network and is installed by the data plane that owns it, not by
# a consumer of it.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: egress-address-system
resources:
- ../rbac
components:
- ../components/egress-address

# Pinned here rather than inherited from ../manager: this overlay does not
# include it, and the publish workflow rewrites the tag in every overlay it is
# given.
images:
- name: ghcr.io/datum-cloud/vpc-controller
newName: ghcr.io/datum-cloud/vpc-controller
newTag: latest
Loading
Loading