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
100 changes: 100 additions & 0 deletions api/v1alpha1/rule_admission_envtest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2026 Datum Cloud, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later

package v1alpha1_test

import (
"context"
"os"
"path/filepath"
"testing"

corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
"sigs.k8s.io/yaml"
)

func TestNetworkRuleBackendSelectionAdmission(t *testing.T) {
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
t.Skip("set KUBEBUILDER_ASSETS for isolated API admission tests")
}
apiRoot := os.Getenv("GALACTIC_TEST_NETWORK_API")
if apiRoot == "" {
apiRoot = "../.."
}
data, err := os.ReadFile(filepath.Join(apiRoot, "config", "crd", "network.datumapis.com_networkrules.yaml"))
if err != nil {
t.Fatal(err)
}
crd := &apiextensionsv1.CustomResourceDefinition{}
if err := yaml.Unmarshal(data, crd); err != nil {
t.Fatal(err)
}
environment := &envtest.Environment{CRDs: []*apiextensionsv1.CustomResourceDefinition{crd}}
cfg, err := environment.Start()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := environment.Stop(); err != nil {
t.Errorf("stop API: %v", err)
}
})
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
c, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := c.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "rule-admission"}}); err != nil {
t.Fatal(err)
}

for _, tt := range []struct {
name string
selector map[string]any
addresses any
wantError bool
}{
{name: "neither-selection", wantError: true},
{name: "empty-selector", selector: map[string]any{}, wantError: true},
{name: "selector-only", selector: map[string]any{"matchLabels": map[string]any{"app": "web"}}},
{name: "addresses-only", addresses: []any{"fd00:10::1"}},
{name: "explicit-empty-addresses", addresses: []any{}},
{name: "empty-selector-with-addresses", selector: map[string]any{}, addresses: []any{"fd00:10::1"}},
} {
t.Run(tt.name, func(t *testing.T) {
spec := map[string]any{
"vpcRef": "vpc-a", "vipAddresses": []any{"2001:db8::10"},
"protocol": "tcp", "port": int64(443), "backendPort": int64(8443),
}
if tt.selector != nil {
spec["backendSelector"] = tt.selector
}
if tt.addresses != nil {
spec["backendAddresses"] = tt.addresses
}
rule := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "network.datumapis.com/v1alpha1", "kind": "NetworkRule",
"metadata": map[string]any{"name": tt.name, "namespace": "rule-admission"},
"spec": spec,
}}
err := c.Create(ctx, rule)
if (err != nil) != tt.wantError {
t.Fatalf("create error=%v, wantError=%t", err, tt.wantError)
}
if tt.wantError && !apierrors.IsInvalid(err) {
t.Fatalf("expected schema rejection, got %v", err)
}
})
}
}
34 changes: 16 additions & 18 deletions api/v1alpha1/rule_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,10 @@ const (
// backend selection never needs a single "owning" node the way Full-NAT's
// SNAT-source model did.
//
// Backends are not listed. BackendSelector picks the VPCAttachments that
// serve the rule, so the backend set follows attachments as they are
// created, deleted or moved to another node, and galactic-router on each
// node generates the ServiceVIPBinding its backends need from the same
// selection.
// BackendSelector can pick VPCAttachments by label, while BackendAddresses
// can pin an explicit subset of their IPv6 addresses. Either field may select
// backends, so galactic-router on each node generates ServiceVIPBindings from
// the same VPC-scoped attachment set as the gateways.
//
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
Expand All @@ -72,6 +71,7 @@ type NetworkRule struct {

// NetworkRuleSpec defines the desired ingress load-balancing state for a
// tenant VPC.
// +kubebuilder:validation:XValidation:rule="has(self.backendAddresses) || (has(self.backendSelector) && ((has(self.backendSelector.matchLabels) && size(self.backendSelector.matchLabels) > 0) || (has(self.backendSelector.matchExpressions) && size(self.backendSelector.matchExpressions) > 0)))",message="backendSelector must be nonempty unless backendAddresses is set"
type NetworkRuleSpec struct {
// VPCRef is the opaque identifier of the target VPC this rule applies
// to. This repo does not own the VPC API and does not validate the
Expand Down Expand Up @@ -110,19 +110,17 @@ type NetworkRuleSpec struct {
// IPv6 addresses in its spec.interface.addresses as backends, on
// BackendPort; the DSR datapath carries IPv6 backends only. An attachment
// with no status.node or no IPv6 address is not a backend until it has
// both. An empty selector is rejected rather than read as "every attachment
// in the VPC".
// +kubebuilder:validation:Required
// +kubebuilder:validation:XValidation:rule="(has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions) && size(self.matchExpressions) > 0)",message="backendSelector must not be empty"
BackendSelector metav1.LabelSelector `json:"backendSelector"`

// BackendAddresses, when set, limits the selected VPCAttachment backends
// to these IPv6 addresses. An explicitly empty list selects no backends;
// omitting the field preserves selector-only behavior. The gateway and
// backend nodes apply the same filter, so removing an address removes both
// its ingress selection and its ServiceVIPBinding. The addresses must still
// belong to selected attachments in VPCRef; this field cannot authorize a
// backend in another VPC.
// both. When omitted, BackendAddresses must be set; only attachments whose
// authoritative status.vpc equals VPCRef are considered for those addresses.
// +optional
BackendSelector metav1.LabelSelector `json:"backendSelector,omitempty"`

// BackendAddresses, when set, limits the VPC's VPCAttachment backends to
// these IPv6 addresses, also applying BackendSelector if it is present. An
// explicitly empty list selects no backends; omitting the field preserves
// selector-only behavior. The gateway and backend nodes apply the same
// filter, so removing an address removes both its ingress selection and its
// ServiceVIPBinding. An address in another VPC is never selected.
// +optional
// +kubebuilder:validation:MaxItems=256
// +kubebuilder:validation:XValidation:rule="self.all(a, isIP(a) && a.contains(':'))",message="backendAddresses must contain IPv6 addresses"
Expand Down
37 changes: 18 additions & 19 deletions config/crd/network.datumapis.com_networkrules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,10 @@ spec:
backend selection never needs a single "owning" node the way Full-NAT's
SNAT-source model did.

Backends are not listed. BackendSelector picks the VPCAttachments that
serve the rule, so the backend set follows attachments as they are
created, deleted or moved to another node, and galactic-router on each
node generates the ServiceVIPBinding its backends need from the same
selection.
BackendSelector can pick VPCAttachments by label, while BackendAddresses
can pin an explicit subset of their IPv6 addresses. Either field may select
backends, so galactic-router on each node generates ServiceVIPBindings from
the same VPC-scoped attachment set as the gateways.
properties:
apiVersion:
description: |-
Expand All @@ -80,13 +79,12 @@ spec:
properties:
backendAddresses:
description: |-
BackendAddresses, when set, limits the selected VPCAttachment backends
to these IPv6 addresses. An explicitly empty list selects no backends;
omitting the field preserves selector-only behavior. The gateway and
backend nodes apply the same filter, so removing an address removes both
its ingress selection and its ServiceVIPBinding. The addresses must still
belong to selected attachments in VPCRef; this field cannot authorize a
backend in another VPC.
BackendAddresses, when set, limits the VPC's VPCAttachment backends to
these IPv6 addresses, also applying BackendSelector if it is present. An
explicitly empty list selects no backends; omitting the field preserves
selector-only behavior. The gateway and backend nodes apply the same
filter, so removing an address removes both its ingress selection and its
ServiceVIPBinding. An address in another VPC is never selected.
items:
type: string
maxItems: 256
Expand All @@ -112,8 +110,8 @@ spec:
IPv6 addresses in its spec.interface.addresses as backends, on
BackendPort; the DSR datapath carries IPv6 backends only. An attachment
with no status.node or no IPv6 address is not a backend until it has
both. An empty selector is rejected rather than read as "every attachment
in the VPC".
both. When omitted, BackendAddresses must be set; only attachments whose
authoritative status.vpc equals VPCRef are considered for those addresses.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements.
Expand Down Expand Up @@ -158,10 +156,6 @@ spec:
type: object
type: object
x-kubernetes-map-type: atomic
x-kubernetes-validations:
- message: backendSelector must not be empty
rule: (has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions)
&& size(self.matchExpressions) > 0)
port:
description: Port is the ingress port on VIPAddresses that this rule
load-balances.
Expand Down Expand Up @@ -200,12 +194,17 @@ spec:
type: string
required:
- backendPort
- backendSelector
- port
- protocol
- vipAddresses
- vpcRef
type: object
x-kubernetes-validations:
- message: backendSelector must be nonempty unless backendAddresses is
set
rule: has(self.backendAddresses) || (has(self.backendSelector) && ((has(self.backendSelector.matchLabels)
&& size(self.backendSelector.matchLabels) > 0) || (has(self.backendSelector.matchExpressions)
&& size(self.backendSelector.matchExpressions) > 0)))
status:
description: NetworkRuleStatus defines the observed state of a NetworkRule.
properties:
Expand Down
13 changes: 6 additions & 7 deletions docs/api/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,10 @@ flow (internal/maglev), and forwards without rewriting anything —
backend selection never needs a single "owning" node the way Full-NAT's
SNAT-source model did.

Backends are not listed. BackendSelector picks the VPCAttachments that
serve the rule, so the backend set follows attachments as they are
created, deleted or moved to another node, and galactic-router on each
node generates the ServiceVIPBinding its backends need from the same
selection.
BackendSelector can pick VPCAttachments by label, while BackendAddresses
can pin an explicit subset of their IPv6 addresses. Either field may select
backends, so galactic-router on each node generates ServiceVIPBindings from
the same VPC-scoped attachment set as the gateways.



Expand Down Expand Up @@ -435,8 +434,8 @@ _Appears in:_
| `vipAddresses` _string array_ | VIPAddresses is the list of ingress VIP addresses (IPv4 and/or IPv6)<br />this rule provisions on the assigned gateway node(s). | | MaxItems: 8 <br />MinItems: 1 <br />Required: \{\} <br />items:MaxLength: 45 <br /> |
| `protocol` _[NetworkRuleProtocol](#networkruleprotocol)_ | Protocol is the transport protocol matched by VIPAddresses/Port. | | Enum: [tcp udp] <br />Required: \{\} <br /> |
| `port` _integer_ | Port is the ingress port on VIPAddresses that this rule load-balances. | | Maximum: 65535 <br />Minimum: 1 <br />Required: \{\} <br /> |
| `backendSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | BackendSelector selects the VPCAttachments (cloud.datumapis.com/v1alpha1,<br />in any namespace) that serve this rule, matched against each<br />VPCAttachment object's own labels. Only attachments whose status.vpc<br />equals VPCRef are candidates, whatever their labels, so a selector cannot<br />reach into another tenant's VPC. Each selected attachment contributes the<br />IPv6 addresses in its spec.interface.addresses as backends, on<br />BackendPort; the DSR datapath carries IPv6 backends only. An attachment<br />with no status.node or no IPv6 address is not a backend until it has<br />both. An empty selector is rejected rather than read as "every attachment<br />in the VPC". | | Required: \{\} <br /> |
| `backendAddresses` _string_ | BackendAddresses, when set, limits the selected VPCAttachment backends<br />to these IPv6 addresses. An explicitly empty list selects no backends;<br />omitting the field preserves selector-only behavior. The gateway and<br />backend nodes apply the same filter, so removing an address removes both<br />its ingress selection and its ServiceVIPBinding. The addresses must still<br />belong to selected attachments in VPCRef; this field cannot authorize a<br />backend in another VPC. | | MaxItems: 256 <br /> |
| `backendSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | BackendSelector selects the VPCAttachments (cloud.datumapis.com/v1alpha1,<br />in any namespace) that serve this rule, matched against each<br />VPCAttachment object's own labels. Only attachments whose status.vpc<br />equals VPCRef are candidates, whatever their labels, so a selector cannot<br />reach into another tenant's VPC. Each selected attachment contributes the<br />IPv6 addresses in its spec.interface.addresses as backends, on<br />BackendPort; the DSR datapath carries IPv6 backends only. An attachment<br />with no status.node or no IPv6 address is not a backend until it has<br />both. When omitted, BackendAddresses must be set; only attachments whose<br />authoritative status.vpc equals VPCRef are considered for those addresses. | | |
| `backendAddresses` _string_ | BackendAddresses, when set, limits the VPC's VPCAttachment backends to<br />these IPv6 addresses, also applying BackendSelector if it is present. An<br />explicitly empty list selects no backends; omitting the field preserves<br />selector-only behavior. The gateway and backend nodes apply the same<br />filter, so removing an address removes both its ingress selection and its<br />ServiceVIPBinding. An address in another VPC is never selected. | | MaxItems: 256 <br /> |
| `backendPort` _integer_ | BackendPort is the destination port on every selected backend. | | Maximum: 65535 <br />Minimum: 1 <br />Required: \{\} <br /> |


Expand Down
Loading