From 55174853347309b851b36379ac453f85ddeabd8e Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 2 Jul 2026 01:47:10 -0500 Subject: [PATCH 1/2] feat(ipam): add IPClass policy layer for claiming IP space Introduces IPClass, a cluster-scoped, platform-owned policy resource that names a kind of address space and the rules for handing it out (provisioner, ip family, allocation strategy, allowed/default prefix lengths, reclaim policy, visibility). Pools advertise the classes they back via spec.classNames; claims select a class via spec.className. An IPClaim naming a class (or falling through to the default class) resolves the class, folds its policy into the claim, and picks a backing pool across the caller's project and platform scopes, returning the CIDR synchronously. The allocation records its class for provenance. Existing poolRef/poolSelector paths are unchanged; poolSelector on the claim is deprecated in favor of the class. Adds the class dimension to allocation metrics, a milo-ipam 'class' command surface and 'prefix claim --class', IAM protected-resource + role verbs, examples, chainsaw e2e suites, and a k6 class-claim perf script. Cross-project claiming via projectRef and per-project class distribution via the service catalog are deferred to follow-ups. --- cmd/milo-ipam/class.go | 204 +++++ cmd/milo-ipam/class_test.go | 146 ++++ cmd/milo-ipam/prefix.go | 110 ++- cmd/milo-ipam/root.go | 8 +- .../iam/protected-resources/ipclass.yaml | 26 + .../protected-resources/kustomization.yaml | 1 + config/components/iam/roles/ipam-admin.yaml | 1 + .../components/iam/roles/ipam-provider.yaml | 8 + config/components/iam/roles/ipam-viewer.yaml | 3 + .../generated/class-claim-throughput.js | 697 ++++++++++++++++++ .../generated/concurrent-claims.js | 93 ++- .../cross-project-claim-throughput.js | 93 ++- .../generated/host-prefix-claim-concurrent.js | 93 ++- .../generated/ipv6-claim-throughput.js | 93 ++- .../generated/mixed-load.js | 93 ++- .../generated/pool-exhaustion.js | 93 ++- .../generated/pool-scale.js | 93 ++- .../generated/prefix-claim-throughput.js | 93 ++- .../generated/read-latency.js | 93 ++- .../generated/setup-pools.js | 93 ++- .../generated/watch-latency.js | 93 ++- .../k6-performance-tests/kustomization.yaml | 1 + .../testruns/class-throughput.yaml | 36 + examples/ipclass/ipclaim.yaml | 14 + examples/ipclass/ipclass.yaml | 26 + examples/ipclass/ippool.yaml | 16 + examples/ipclass/kustomization.yaml | 14 + internal/allocator/interface.go | 8 + internal/allocator/resolve.go | 183 +++++ internal/allocator/resolve_class_test.go | 162 ++++ internal/apiserver/apiserver.go | 13 + internal/metrics/metrics.go | 24 +- internal/registry/ipam/fieldindexes.go | 2 + internal/registry/ipam/ipclaim/storage.go | 211 +++++- .../ipam/ipclaim/storage_class_test.go | 201 +++++ internal/registry/ipam/ipclaim/strategy.go | 28 +- internal/registry/ipam/ipclass/storage.go | 168 +++++ internal/registry/ipam/ipclass/strategy.go | 228 ++++++ .../registry/ipam/ipclass/strategy_test.go | 218 ++++++ internal/registry/ipam/ippool/strategy.go | 7 + pkg/apis/ipam/register.go | 1 + pkg/apis/ipam/types.go | 77 +- pkg/apis/ipam/v1alpha1/conversion.go | 18 + pkg/apis/ipam/v1alpha1/conversion_impl.go | 102 ++- pkg/apis/ipam/v1alpha1/conversion_test.go | 112 +++ pkg/apis/ipam/v1alpha1/register.go | 1 + pkg/apis/ipam/v1alpha1/types.go | 100 +++ .../ipam/v1alpha1/zz_generated.deepcopy.go | 105 +++ .../ipam/v1alpha1/zz_generated.model_name.go | 20 + pkg/apis/ipam/zz_generated.deepcopy.go | 105 +++ .../versioned/fake/clientset_generated.go | 4 - .../ipam/v1alpha1/fake/fake_ipam_client.go | 4 + .../typed/ipam/v1alpha1/fake/fake_ipclass.go | 34 + .../ipam/v1alpha1/generated_expansion.go | 2 + .../typed/ipam/v1alpha1/ipam_client.go | 5 + .../versioned/typed/ipam/v1alpha1/ipclass.go | 52 ++ .../informers/externalversions/generic.go | 2 + .../ipam/v1alpha1/interface.go | 7 + .../externalversions/ipam/v1alpha1/ipclass.go | 85 +++ .../ipam/v1alpha1/expansion_generated.go | 4 + pkg/client/listers/ipam/v1alpha1/ipclass.go | 32 + pkg/generated/openapi/zz_generated.openapi.go | 234 ++++++ .../assert-default-class-claim-bound.yaml | 12 + .../assert-default-len-claim-bound.yaml | 13 + .../assertions/assert-egress-claim-bound.yaml | 13 + .../assert-immutable-claim-bound.yaml | 10 + .../assert-legacy-selector-claim-bound.yaml | 12 + test/e2e/ip-class/chainsaw-test.yaml | 301 ++++++++ .../ip-class/test-data/claim-by-class.yaml | 12 + .../ip-class/test-data/claim-by-selector.yaml | 16 + .../test-data/claim-class-and-pool.yaml | 13 + .../test-data/claim-class-bad-length.yaml | 11 + .../test-data/claim-class-not-found.yaml | 11 + .../test-data/claim-default-class.yaml | 11 + .../test-data/claim-default-prefixlen.yaml | 10 + .../ip-class/test-data/claim-immutable.yaml | 10 + .../test-data/claim-no-pool-for-class.yaml | 12 + test/e2e/ip-class/test-data/classes.yaml | 56 ++ .../test-data/patch-claim-classname.yaml | 10 + test/e2e/ip-class/test-data/pools.yaml | 48 ++ test/load/Taskfile.yaml | 29 +- test/load/lib/ipam-client.js | 93 ++- test/load/src/class-claim-throughput.js | 176 +++++ 83 files changed, 5669 insertions(+), 103 deletions(-) create mode 100644 cmd/milo-ipam/class.go create mode 100644 cmd/milo-ipam/class_test.go create mode 100644 config/components/iam/protected-resources/ipclass.yaml create mode 100644 config/components/k6-performance-tests/generated/class-claim-throughput.js create mode 100644 config/components/k6-performance-tests/testruns/class-throughput.yaml create mode 100644 examples/ipclass/ipclaim.yaml create mode 100644 examples/ipclass/ipclass.yaml create mode 100644 examples/ipclass/ippool.yaml create mode 100644 examples/ipclass/kustomization.yaml create mode 100644 internal/allocator/resolve_class_test.go create mode 100644 internal/registry/ipam/ipclaim/storage_class_test.go create mode 100644 internal/registry/ipam/ipclass/storage.go create mode 100644 internal/registry/ipam/ipclass/strategy.go create mode 100644 internal/registry/ipam/ipclass/strategy_test.go create mode 100644 pkg/apis/ipam/v1alpha1/conversion_test.go create mode 100644 pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipclass.go create mode 100644 pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipclass.go create mode 100644 pkg/client/informers/externalversions/ipam/v1alpha1/ipclass.go create mode 100644 pkg/client/listers/ipam/v1alpha1/ipclass.go create mode 100644 test/e2e/ip-class/assertions/assert-default-class-claim-bound.yaml create mode 100644 test/e2e/ip-class/assertions/assert-default-len-claim-bound.yaml create mode 100644 test/e2e/ip-class/assertions/assert-egress-claim-bound.yaml create mode 100644 test/e2e/ip-class/assertions/assert-immutable-claim-bound.yaml create mode 100644 test/e2e/ip-class/assertions/assert-legacy-selector-claim-bound.yaml create mode 100644 test/e2e/ip-class/chainsaw-test.yaml create mode 100644 test/e2e/ip-class/test-data/claim-by-class.yaml create mode 100644 test/e2e/ip-class/test-data/claim-by-selector.yaml create mode 100644 test/e2e/ip-class/test-data/claim-class-and-pool.yaml create mode 100644 test/e2e/ip-class/test-data/claim-class-bad-length.yaml create mode 100644 test/e2e/ip-class/test-data/claim-class-not-found.yaml create mode 100644 test/e2e/ip-class/test-data/claim-default-class.yaml create mode 100644 test/e2e/ip-class/test-data/claim-default-prefixlen.yaml create mode 100644 test/e2e/ip-class/test-data/claim-immutable.yaml create mode 100644 test/e2e/ip-class/test-data/claim-no-pool-for-class.yaml create mode 100644 test/e2e/ip-class/test-data/classes.yaml create mode 100644 test/e2e/ip-class/test-data/patch-claim-classname.yaml create mode 100644 test/e2e/ip-class/test-data/pools.yaml create mode 100644 test/load/src/class-claim-throughput.js diff --git a/cmd/milo-ipam/class.go b/cmd/milo-ipam/class.go new file mode 100644 index 0000000..884e2f7 --- /dev/null +++ b/cmd/milo-ipam/class.go @@ -0,0 +1,204 @@ +package main + +import ( + "context" + "fmt" + "sort" + + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +func setClassGVK(c *ipamv1alpha1.IPClass) { + c.APIVersion = apiVersion + c.Kind = "IPClass" +} + +// classIsDefault reports whether a class is marked the platform default. +func classIsDefault(c *ipamv1alpha1.IPClass) bool { + return c.Annotations[ipamv1alpha1.IsDefaultClassAnnotation] == "true" +} + +func newClassCommand(a *app) *cobra.Command { + cmd := &cobra.Command{ + Use: "class", + Short: "Browse the catalog of address-space policies (IPClass)", + Long: `An IPClass names a kind of address space and the policy for handing it out — +which family, allowed prefix sizes, placement strategy, and reclaim behavior. +Claim from a class by name with "prefix claim --class "; you never need to +know which pool backs it.`, + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return c.Help() + } + return unknownSubcommandError(c, args[0]) + }, + } + cmd.SuggestionsMinimumDistance = 2 + cmd.AddCommand( + newClassListCommand(a), + newClassShowCommand(a), + ) + return cmd +} + +// --------------------------------------------------------------------------- +// class list +// --------------------------------------------------------------------------- + +func newClassListCommand(a *app) *cobra.Command { + var selector string + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the address-space classes you can claim from", + Args: cobra.NoArgs, + Example: ` datumctl ipam class list + datumctl ipam class list -o wide`, + RunE: func(cmd *cobra.Command, args []string) error { + cs, _, err := a.client() + if err != nil { + return err + } + list, err := cs.IpamV1alpha1().IPClasses().List(context.Background(), metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return classifyError(err) + } + for i := range list.Items { + setClassGVK(&list.Items[i]) + } + list.APIVersion = apiVersion + list.Kind = "IPClassList" + + switch a.opts.output { + case outputJSON: + return encodeJSON(a.io.Out, list) + case outputYAML: + return encodeYAML(a.io.Out, list) + case outputName: + for i := range list.Items { + _, _ = fmt.Fprintf(a.io.Out, "ipclass/%s\n", list.Items[i].Name) + } + return nil + } + return a.renderClassTable(list.Items) + }, + } + cmd.Flags().StringVarP(&selector, "selector", "l", "", "Label selector to filter classes") + return cmd +} + +func (a *app) renderClassTable(classes []ipamv1alpha1.IPClass) error { + if len(classes) == 0 { + if !a.opts.quiet { + _, _ = fmt.Fprintln(a.io.ErrOut, "No classes found.") + } + return nil + } + sort.Slice(classes, func(i, j int) bool { return classes[i].Name < classes[j].Name }) + + wide := a.opts.output == outputWide + headers := []string{"NAME", "FAMILY", "PREFIXES", "RECLAIM", "DEFAULT"} + if wide { + headers = []string{"NAME", "FAMILY", "PREFIXES", "RECLAIM", "DEFAULT", "PROVISIONER", "VISIBILITY", "AGE"} + } + t := newTable(a.io.Out, headers) + for i := range classes { + c := &classes[i] + def := "" + if classIsDefault(c) { + def = "*" + } + if wide { + t.row(c.Name, orDash(string(c.Spec.IPFamily)), classPrefixRange(c), + orDash(string(c.Spec.ReclaimPolicy)), def, + orDash(c.Spec.Provisioner), orDash(c.Spec.Visibility), + humanDuration(c.CreationTimestamp)) + } else { + t.row(c.Name, orDash(string(c.Spec.IPFamily)), classPrefixRange(c), + orDash(string(c.Spec.ReclaimPolicy)), def) + } + } + return t.flush() +} + +// classPrefixRange renders a class's allowed prefix bounds as "/24 – /28", or a +// single bound / dash when only one or neither is set. +func classPrefixRange(c *ipamv1alpha1.IPClass) string { + lo := c.Spec.AllowedPrefixLengths.Min + hi := c.Spec.AllowedPrefixLengths.Max + switch { + case lo > 0 && hi > 0: + return fmt.Sprintf("/%d – /%d", lo, hi) + case lo > 0: + return fmt.Sprintf("≥ /%d", lo) + case hi > 0: + return fmt.Sprintf("≤ /%d", hi) + default: + return "—" + } +} + +// --------------------------------------------------------------------------- +// class show +// --------------------------------------------------------------------------- + +func newClassShowCommand(a *app) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Aliases: []string{"get", "describe"}, + Short: "Show a class's policy in detail", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cs, _, err := a.client() + if err != nil { + return err + } + class, err := cs.IpamV1alpha1().IPClasses().Get(context.Background(), args[0], metav1.GetOptions{}) + if err != nil { + return classGetError(err, args[0]) + } + setClassGVK(class) + if done, err := a.renderMachine(class, func() string { return "ipclass/" + class.Name }); done { + return err + } + return a.renderClassDetail(class) + }, + } + return cmd +} + +func (a *app) renderClassDetail(c *ipamv1alpha1.IPClass) error { + t := newTable(a.io.Out, []string{"FIELD", "VALUE"}) + t.row("Name", c.Name) + t.row("Family", orDash(string(c.Spec.IPFamily))) + t.row("Provisioner", orDash(c.Spec.Provisioner)) + t.row("Strategy", orDash(string(c.Spec.Strategy))) + t.row("Allowed prefixes", classPrefixRange(c)) + if c.Spec.DefaultPrefixLength > 0 { + t.row("Default prefix", fmt.Sprintf("/%d", c.Spec.DefaultPrefixLength)) + } + t.row("Reclaim policy", orDash(string(c.Spec.ReclaimPolicy))) + t.row("Visibility", orDash(c.Spec.Visibility)) + if classIsDefault(c) { + t.row("Default", "yes") + } + for k, v := range c.Spec.Parameters { + t.row("Parameter "+k, v) + } + t.row("Age", humanDuration(c.CreationTimestamp)) + return t.flush() +} + +// classGetError adds IPAM context to a failed class Get: a 404 becomes a clear +// "no such class" message with a pointer to the catalog. +func classGetError(err error, name string) error { + if apierrors.IsNotFound(err) { + return newCLIError(exitNotFound, fmt.Sprintf("class %q not found", name)). + withFix("list available classes:\n datumctl ipam class list").withCause(err) + } + return classifyError(err) +} diff --git a/cmd/milo-ipam/class_test.go b/cmd/milo-ipam/class_test.go new file mode 100644 index 0000000..fd62ee8 --- /dev/null +++ b/cmd/milo-ipam/class_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8stesting "k8s.io/client-go/testing" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +func newClass(name string, family ipamv1alpha1.IPFamily, min, max int, reclaim ipamv1alpha1.ReclaimPolicy, isDefault bool) *ipamv1alpha1.IPClass { + c := &ipamv1alpha1.IPClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: ipamv1alpha1.IPClassSpec{ + Provisioner: ipamv1alpha1.NativeProvisioner, + IPFamily: family, + Strategy: ipamv1alpha1.LeastUtilized, + AllowedPrefixLengths: ipamv1alpha1.PrefixLengthRange{Min: min, Max: max}, + DefaultPrefixLength: max, + ReclaimPolicy: reclaim, + Visibility: "shared", + }, + } + if isDefault { + c.Annotations = map[string]string{ipamv1alpha1.IsDefaultClassAnnotation: "true"} + } + return c +} + +func TestClassListTable(t *testing.T) { + cs := newFakeClientset( + newClass("internal-ipv4", ipamv1alpha1.IPv4, 24, 28, ipamv1alpha1.ReclaimDelete, true), + newClass("public-egress", ipamv1alpha1.IPv4, 24, 28, ipamv1alpha1.ReclaimRetain, false), + ) + ta := newTestApp(cs, nil) + if err := newClassListCommand(ta.app).RunE(newClassListCommand(ta.app), nil); err != nil { + t.Fatalf("class list failed: %v", err) + } + out := ta.out.String() + for _, want := range []string{"internal-ipv4", "public-egress", "/24 – /28", "Delete", "Retain", "*"} { + if !strings.Contains(out, want) { + t.Errorf("class list output missing %q:\n%s", want, out) + } + } +} + +func TestClassShowDetail(t *testing.T) { + cs := newFakeClientset(newClass("public-egress", ipamv1alpha1.IPv4, 24, 28, ipamv1alpha1.ReclaimRetain, false)) + ta := newTestApp(cs, nil) + cmd := newClassShowCommand(ta.app) + if err := cmd.RunE(cmd, []string{"public-egress"}); err != nil { + t.Fatalf("class show failed: %v", err) + } + out := ta.out.String() + for _, want := range []string{"public-egress", "IPv4", ipamv1alpha1.NativeProvisioner, "LeastUtilized", "Retain"} { + if !strings.Contains(out, want) { + t.Errorf("class show output missing %q:\n%s", want, out) + } + } +} + +func TestClassShowNotFound(t *testing.T) { + cs := newFakeClientset() + ta := newTestApp(cs, nil) + cmd := newClassShowCommand(ta.app) + err := cmd.RunE(cmd, []string{"ghost"}) + if err == nil { + t.Fatal("expected not-found error") + } + if toCLIError(err).code != exitNotFound { + t.Fatalf("code = %d, want notFound(%d)", toCLIError(err).code, exitNotFound) + } +} + +func TestPrefixClaimByClass(t *testing.T) { + cs := newFakeClientset( + newClass("public-egress", ipamv1alpha1.IPv4, 24, 28, ipamv1alpha1.ReclaimRetain, false), + ) + cs.PrependReactor("create", "ipclaims", func(action k8stesting.Action) (bool, runtime.Object, error) { + claim := action.(k8stesting.CreateAction).GetObject().(*ipamv1alpha1.IPClaim) + // The class-based claim must carry spec.className and no poolRef. + if claim.Spec.ClassName != "public-egress" { + t.Errorf("claim className = %q, want public-egress", claim.Spec.ClassName) + } + if claim.Spec.PoolRef != nil { + t.Errorf("class claim must not set poolRef, got %v", claim.Spec.PoolRef) + } + claim.Name = "egress-1" + claim.Status.Phase = ipamv1alpha1.ClaimBound + claim.Status.AllocatedCIDR = "203.0.113.0/26" + claim.Status.BoundAllocationRef = &ipamv1alpha1.LocalRef{Name: "alloc-abc"} + return true, claim, nil + }) + // The success line resolves the chosen pool from the bound allocation. + cs.PrependReactor("get", "ipallocations", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &ipamv1alpha1.IPAllocation{ + ObjectMeta: metav1.ObjectMeta{Name: "alloc-abc", Namespace: "default"}, + Spec: ipamv1alpha1.IPAllocationSpec{PoolRef: ipamv1alpha1.LocalRef{Name: "prod-egress-us-east"}, ClassName: "public-egress"}, + }, nil + }) + + ta := newTestApp(cs, nil) + if err := runPrefixClaim(ta.app, &claimOptions{class: "public-egress", length: 26}); err != nil { + t.Fatalf("class claim failed: %v", err) + } + out := ta.out.String() + for _, want := range []string{"Claimed", "203.0.113.0/26", `class "public-egress"`, "prod-egress-us-east"} { + if !strings.Contains(out, want) { + t.Errorf("class claim output missing %q:\n%s", want, out) + } + } +} + +func TestPrefixClaimClassMutuallyExclusive(t *testing.T) { + cs := newFakeClientset() + ta := newTestApp(cs, nil) + err := runPrefixClaim(ta.app, &claimOptions{class: "egress", pool: "p", length: 26}) + if err == nil { + t.Fatal("expected usage error for --class with --pool") + } + if toCLIError(err).code != exitUsage { + t.Fatalf("code = %d, want usage(%d)", toCLIError(err).code, exitUsage) + } +} + +func TestPrefixClaimByClassOmitsLength(t *testing.T) { + // With --class and no --length, the CLI must submit anyway (the server + // applies the class default); it must not fail on the "needs a size" guard. + cs := newFakeClientset(newClass("public-egress", ipamv1alpha1.IPv4, 24, 28, ipamv1alpha1.ReclaimRetain, false)) + cs.PrependReactor("create", "ipclaims", func(action k8stesting.Action) (bool, runtime.Object, error) { + claim := action.(k8stesting.CreateAction).GetObject().(*ipamv1alpha1.IPClaim) + claim.Name = "egress-2" + claim.Status.Phase = ipamv1alpha1.ClaimBound + claim.Status.AllocatedCIDR = "203.0.113.64/26" + return true, claim, nil + }) + ta := newTestApp(cs, nil) + if err := runPrefixClaim(ta.app, &claimOptions{class: "public-egress"}); err != nil { + t.Fatalf("class claim without --length should succeed: %v", err) + } + _ = context.Background() +} diff --git a/cmd/milo-ipam/prefix.go b/cmd/milo-ipam/prefix.go index 99f2c0a..ddb9dee 100644 --- a/cmd/milo-ipam/prefix.go +++ b/cmd/milo-ipam/prefix.go @@ -50,6 +50,7 @@ IPAllocation; -o yaml shows the real resource.`, // --------------------------------------------------------------------------- type claimOptions struct { + class string pool string length int cidr string @@ -72,26 +73,30 @@ func newPrefixClaimCommand(a *app) *cobra.Command { Allocation is not idempotent: each claim consumes space. Pass a stable --name to make retries safe — a retried claim with the same name returns the existing allocation instead of consuming a second block.`, - Example: ` # Claim a /24 by size + Example: ` # Claim a /26 from a class (recommended — portable across environments) + datumctl ipam prefix claim --class public-egress --length 26 + + # Claim a /24 from a specific pool (advanced escape hatch) datumctl ipam prefix claim --pool prod-backbone --length 24 # Idempotent claim (safe to retry) - datumctl ipam prefix claim --pool prod-backbone --length 24 --name app-net-3 + datumctl ipam prefix claim --class public-egress --length 26 --name app-net-3 # Preview without consuming space - datumctl ipam prefix claim --pool prod-backbone --length 14 --dry-run`, + datumctl ipam prefix claim --class public-egress --length 26 --dry-run`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runPrefixClaim(a, o) }, } f := cmd.Flags() - f.StringVar(&o.pool, "pool", "", "Pool to claim from (by name)") - f.IntVar(&o.length, "length", 0, "Requested prefix length in bits (e.g. 24)") + f.StringVar(&o.class, "class", "", "Class to claim from (by name) — the recommended, portable path") + f.StringVar(&o.pool, "pool", "", "Pool to claim from by name (advanced; prefer --class)") + f.IntVar(&o.length, "length", 0, "Requested prefix length in bits (e.g. 24); defaults to the class default when --class is used") f.StringVar(&o.cidr, "cidr", "", "Request a specific block; sets length and family from the CIDR") - f.StringVar(&o.family, "family", "", "Address family: ipv4|ipv6 (inferred from the pool or --cidr)") + f.StringVar(&o.family, "family", "", "Address family: ipv4|ipv6 (inferred from the class, pool, or --cidr)") f.StringVar(&o.name, "name", "", "Stable claim name; reusing it makes retries idempotent") - f.StringVarP(&o.selector, "selector", "l", "", "Select the pool by label instead of --pool") + f.StringVarP(&o.selector, "selector", "l", "", "Select the pool by label (deprecated; prefer --class)") f.StringVar(&o.childPool, "child-pool", "", "Also stand up a child pool over the claimed block (childPrefixTemplate)") f.StringVar(&o.strategy, "strategy", "", "Allocation strategy override: FirstFit|BestFit|LeastUtilized") f.StringVar(&o.reclaimPolicy, "reclaim-policy", "", "Reclaim policy: Delete|Retain") @@ -100,12 +105,28 @@ allocation instead of consuming a second block.`, } func runPrefixClaim(a *app, o *claimOptions) error { - if o.pool == "" && o.selector == "" { - return usageErrorf("a claim needs a pool: pass --pool or --selector ") + // A claim targets exactly one of: --class (recommended, portable), --pool + // (advanced escape hatch), or --selector (deprecated in favor of --class). + targets := 0 + if o.class != "" { + targets++ + } + if o.pool != "" { + targets++ } - if o.pool != "" && o.selector != "" { - return usageErrorf("--pool and --selector are mutually exclusive") + if o.selector != "" { + targets++ + } + switch { + case targets == 0: + return usageErrorf("a claim needs a target: pass --class (recommended), --pool , or --selector ") + case targets > 1: + return usageErrorf("--class, --pool, and --selector are mutually exclusive") } + if o.selector != "" { + a.vlogf("note: --selector is deprecated; prefer --class ") + } + usingClass := o.class != "" if o.childPool != "" { // The current IPAM API (IPClaimSpec) does not expose childPrefixTemplate. // Fail loudly rather than silently dropping the user's intent. @@ -156,17 +177,26 @@ func runPrefixClaim(a *app, o *claimOptions) error { family = p.Spec.IPFamily } } - if family == "" { - if o.selector != "" { - return usageErrorf("could not infer address family from a selector; pass --family ipv4|ipv6") + if usingClass { + // Class path: the server derives family and applies the class's default + // prefix length, so both are optional here. Only range-check when the + // caller supplied both a family and a length. + if o.length > 0 && family != "" && o.length > familyBits(family) { + return usageErrorf("--length /%d is out of range for %s (max /%d)", o.length, family, familyBits(family)) + } + } else { + if family == "" { + if o.selector != "" { + return usageErrorf("could not infer address family from a selector; pass --family ipv4|ipv6") + } + family = ipamv1alpha1.IPv4 + } + if o.length <= 0 { + return usageErrorf("a claim needs a size: pass --length or --cidr ") + } + if o.length > familyBits(family) { + return usageErrorf("--length /%d is out of range for %s (max /%d)", o.length, family, familyBits(family)) } - family = ipamv1alpha1.IPv4 - } - if o.length <= 0 { - return usageErrorf("a claim needs a size: pass --length or --cidr ") - } - if o.length > familyBits(family) { - return usageErrorf("--length /%d is out of range for %s (max /%d)", o.length, family, familyBits(family)) } // Idempotency: a named claim that already exists is returned as-is. @@ -219,6 +249,9 @@ func buildClaim(o *claimOptions, ns string, family ipamv1alpha1.IPFamily) *ipamv } else { claim.Name = generateResourceName("prefix") } + if o.class != "" { + claim.Spec.ClassName = o.class + } if o.pool != "" { claim.Spec.PoolRef = &ipamv1alpha1.NamespacedRef{Name: o.pool} } @@ -248,9 +281,23 @@ func (a *app) renderClaimResult(claim *ipamv1alpha1.IPClaim, poolBefore *ipamv1a return nil } + // Resolve the pool that actually satisfied the claim. A --pool claim names + // it directly; a --class or --selector claim leaves spec.poolRef empty, so + // the chosen pool lives on the bound IPAllocation — fetch it so the success + // line can always echo which pool served the block (the transparency the + // class layer would otherwise hide). poolName := "—" if claim.Spec.PoolRef != nil { poolName = claim.Spec.PoolRef.Name + } else if claim.Status.BoundAllocationRef != nil { + if alloc, err := cs.IpamV1alpha1().IPAllocations(claim.Namespace).Get(context.Background(), claim.Status.BoundAllocationRef.Name, metav1.GetOptions{}); err == nil { + poolName = alloc.Spec.PoolRef.Name + if poolBefore == nil && poolName != "" { + if p, perr := cs.IpamV1alpha1().IPPools().Get(context.Background(), poolName, metav1.GetOptions{}); perr == nil { + poolBefore = p + } + } + } } utilNote := "" @@ -267,7 +314,13 @@ func (a *app) renderClaimResult(claim *ipamv1alpha1.IPClaim, poolBefore *ipamv1a if idempotent { verb = "Reused existing claim for" } - _, _ = fmt.Fprintf(a.io.Out, "%s %s %s from pool %q%s\n", successPrefix(a.color), verb, orDash(cidr), poolName, utilNote) + // Class-based claims name the class in the headline (that is what the + // consumer asked for) and still show the resolved pool below. + if claim.Spec.ClassName != "" { + _, _ = fmt.Fprintf(a.io.Out, "%s %s %s from class %q%s\n", successPrefix(a.color), verb, orDash(cidr), claim.Spec.ClassName, utilNote) + } else { + _, _ = fmt.Fprintf(a.io.Out, "%s %s %s from pool %q%s\n", successPrefix(a.color), verb, orDash(cidr), poolName, utilNote) + } _, _ = fmt.Fprintf(a.io.Out, " prefix: %s\n", claim.Name) if claim.Status.BoundAllocationRef != nil { _, _ = fmt.Fprintf(a.io.Out, " allocation: %s\n", claim.Status.BoundAllocationRef.Name) @@ -278,6 +331,11 @@ func (a *app) renderClaimResult(claim *ipamv1alpha1.IPClaim, poolBefore *ipamv1a poolCIDR = poolBefore.Spec.CIDR } _, _ = fmt.Fprintf(a.io.Out, " pool: %s (%s, %s)\n", poolName, orDash(poolCIDR), orDash(string(claim.Spec.IPFamily))) + } else if poolName != "" && poolName != "—" { + // The backing pool couldn't be fetched (e.g. class-resolved, not + // directly visible), but we still know its name from the allocation — + // echo it for transparency. + _, _ = fmt.Fprintf(a.io.Out, " pool: %s\n", poolName) } _, _ = fmt.Fprintf(a.io.Out, " org/project: %s\n", a.scopeLine(claim.Namespace)) return nil @@ -309,10 +367,14 @@ func (a *app) renderClaimDryRun(o *claimOptions, pool *ipamv1alpha1.IPPool, fami } func poolDisplay(o *claimOptions) string { - if o.pool != "" { + switch { + case o.class != "": + return "(class " + o.class + ")" + case o.pool != "": return o.pool + default: + return "(selector " + o.selector + ")" } - return "(selector " + o.selector + ")" } // claimCreateError turns a failed claim Create into an IPAM-aware error. The diff --git a/cmd/milo-ipam/root.go b/cmd/milo-ipam/root.go index ccb87b9..3226afa 100644 --- a/cmd/milo-ipam/root.go +++ b/cmd/milo-ipam/root.go @@ -18,14 +18,15 @@ func newRootCommand(io IOStreams) *cobra.Command { Long: `Manage IP address space on Datum. The ipam plugin presents the IPAM service as a small set of resource-oriented -commands. The two nouns that matter most: +commands. The nouns that matter most: + class a named address-space policy you claim from (IPClass) pool an allocatable block of address space (IPPool) - prefix a sub-block claimed from a pool (IPClaim / IPAllocation) + prefix a sub-block claimed from a class or pool (IPClaim / IPAllocation) Claiming a prefix returns the allocated CIDR synchronously: - datumctl ipam prefix claim --pool prod-backbone --length 24 + datumctl ipam prefix claim --class public-egress --length 26 Output is a human table by default; -o json|yaml is a stable contract for scripts (data on stdout, diagnostics on stderr). Exit codes are documented and @@ -71,6 +72,7 @@ distinct per failure class (notably 7 = IPAM_POOL_EXHAUSTED).`, pf.StringVar(&opts.org, "org", "", "Override the active organization for this invocation") pf.StringVar(&opts.project, "project", "", "Override the active project for this invocation") + root.AddCommand(newClassCommand(a)) root.AddCommand(newPoolCommand(a)) root.AddCommand(newPrefixCommand(a)) root.AddCommand(newVersionCommand(io)) diff --git a/config/components/iam/protected-resources/ipclass.yaml b/config/components/iam/protected-resources/ipclass.yaml new file mode 100644 index 0000000..75fefa7 --- /dev/null +++ b/config/components/iam/protected-resources/ipclass.yaml @@ -0,0 +1,26 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: ipam.miloapis.com-ipclass +spec: + serviceRef: + name: "ipam.miloapis.com" + kind: IPClass + plural: ipclasses + singular: ipclass + permissions: + - list + - get + - watch + - create + - update + - patch + - delete + - updateStatus + # "use" mirrors the verb on IPPool: it is checked when a consumer claims + # from a class whose visibility admits cross-project use. Granting it on a + # class authorises consumers in other projects to allocate through it. + - use + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project diff --git a/config/components/iam/protected-resources/kustomization.yaml b/config/components/iam/protected-resources/kustomization.yaml index 20cff06..8391879 100644 --- a/config/components/iam/protected-resources/kustomization.yaml +++ b/config/components/iam/protected-resources/kustomization.yaml @@ -9,3 +9,4 @@ resources: - ippool.yaml - ipallocation.yaml - ipclaim.yaml + - ipclass.yaml diff --git a/config/components/iam/roles/ipam-admin.yaml b/config/components/iam/roles/ipam-admin.yaml index ea25e8d..45fafe8 100644 --- a/config/components/iam/roles/ipam-admin.yaml +++ b/config/components/iam/roles/ipam-admin.yaml @@ -22,3 +22,4 @@ spec: - ipam.miloapis.com/ippools.updateStatus - ipam.miloapis.com/ipclaims.updateStatus - ipam.miloapis.com/ipallocations.updateStatus + - ipam.miloapis.com/ipclasses.updateStatus diff --git a/config/components/iam/roles/ipam-provider.yaml b/config/components/iam/roles/ipam-provider.yaml index 97db795..97efebc 100644 --- a/config/components/iam/roles/ipam-provider.yaml +++ b/config/components/iam/roles/ipam-provider.yaml @@ -19,3 +19,11 @@ spec: # "use" authorises consumers of a shared pool; providers grant it on the # pools they want to admit cross-project allocation from. - ipam.miloapis.com/ippools.use + # Classes are platform-owned allocation policy; providers author and manage + # them alongside the pools that back them. "use" admits cross-project + # consumption of a class whose visibility is shared. + - ipam.miloapis.com/ipclasses.create + - ipam.miloapis.com/ipclasses.update + - ipam.miloapis.com/ipclasses.patch + - ipam.miloapis.com/ipclasses.delete + - ipam.miloapis.com/ipclasses.use diff --git a/config/components/iam/roles/ipam-viewer.yaml b/config/components/iam/roles/ipam-viewer.yaml index 85f227b..6cca7a5 100644 --- a/config/components/iam/roles/ipam-viewer.yaml +++ b/config/components/iam/roles/ipam-viewer.yaml @@ -11,6 +11,9 @@ spec: - ipam.miloapis.com/ippools.list - ipam.miloapis.com/ippools.get - ipam.miloapis.com/ippools.watch + - ipam.miloapis.com/ipclasses.list + - ipam.miloapis.com/ipclasses.get + - ipam.miloapis.com/ipclasses.watch - ipam.miloapis.com/ipallocations.list - ipam.miloapis.com/ipallocations.get - ipam.miloapis.com/ipallocations.watch diff --git a/config/components/k6-performance-tests/generated/class-claim-throughput.js b/config/components/k6-performance-tests/generated/class-claim-throughput.js new file mode 100644 index 0000000..1557055 --- /dev/null +++ b/config/components/k6-performance-tests/generated/class-claim-throughput.js @@ -0,0 +1,697 @@ +// Code generated by hack/bundle-k6.sh. DO NOT EDIT. +// Source: test/load/src/class-claim-throughput.js +// Lib: test/load/lib/ipam-client.js + +// Shared HTTP client for the IPAM apiserver. Provides typed helpers for the +// IPAM resources (IPPool, IPClaim, IPAllocation, ASNPool, ASNClaim) and +// standardized request configuration. +// +// Configuration via environment variables: +// IPAM_API_URL - Base URL of the apiserver (default: kubectl proxy localhost:8001) +// IPAM_TOKEN - Explicit bearer token (overrides in-cluster SA token) +// IPAM_TOKEN_FILE - Path to a file containing a bearer token (default: SA token path) +// K6_INSECURE_SKIP_TLS_VERIFY - Skip TLS verification (default: true) +// +// When running inside the k6 operator, the test pod's ServiceAccount token +// is mounted at /var/run/secrets/kubernetes.io/serviceaccount/token. The +// client reads it automatically at init time if IPAM_TOKEN isn't set. + +import http from 'k6/http'; + +export const BASE_URL = __ENV.IPAM_API_URL || 'http://localhost:8001'; +export const API_GROUP = 'ipam.miloapis.com'; +export const API_VERSION = 'v1alpha1'; +export const API_BASE = `${BASE_URL}/apis/${API_GROUP}/${API_VERSION}`; + +const DEFAULT_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'; + +function loadToken() { + if (__ENV.IPAM_TOKEN) { + return __ENV.IPAM_TOKEN; + } + const path = __ENV.IPAM_TOKEN_FILE || DEFAULT_TOKEN_PATH; + try { + return open(path).trim(); + } catch (e) { + return ''; + } +} + +const TOKEN = loadToken(); + +export function defaultHeaders() { + const h = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (TOKEN) { + h['Authorization'] = `Bearer ${TOKEN}`; + } + return h; +} + +function defaultParams(tag) { + return { + headers: defaultHeaders(), + tags: { operation: tag }, + }; +} + +// Returns k6 params with Milo tenant headers identifying the calling project. +// Merges with defaultHeaders() so auth + content-type are preserved. +export function withProject(projectID) { + return { + headers: { + ...defaultHeaders(), + 'X-Remote-Extra-Iam.Miloapis.Com.Parent-Api-Group': 'resourcemanager.miloapis.com', + 'X-Remote-Extra-Iam.Miloapis.Com.Parent-Type': 'Project', + 'X-Remote-Extra-Iam.Miloapis.Com.Parent-Name': projectID, + }, + tags: {}, + }; +} + +// withProjectTagged is like withProject but also sets an operation tag. +export function withProjectTagged(projectID, tag) { + const p = withProject(projectID); + p.tags = { operation: tag }; + return p; +} + +// --- Generic helpers --- + +export function ipamGet(path, tag) { + return http.get(`${API_BASE}${path}`, defaultParams(tag || 'get')); +} + +export function ipamPost(path, body, tag) { + return http.post(`${API_BASE}${path}`, JSON.stringify(body), defaultParams(tag || 'post')); +} + +export function ipamDelete(path, tag) { + return http.del(`${API_BASE}${path}`, null, defaultParams(tag || 'delete')); +} + +export function ipamList(path, tag) { + return http.get(`${API_BASE}${path}`, defaultParams(tag || 'list')); +} + +// --- Path helpers --- + +export function nsFor(n) { + return `ipam-perf-${n}`; +} + +// IPClaim is namespaced. +export function ipClaimPath(ns, name) { + return name + ? `/namespaces/${ns}/ipclaims/${name}` + : `/namespaces/${ns}/ipclaims`; +} + +// IPAllocation is namespaced (system-created allocation record). +export function ipAllocationPath(ns, name) { + return name + ? `/namespaces/${ns}/ipallocations/${name}` + : `/namespaces/${ns}/ipallocations`; +} + +// IPPool is cluster-scoped. +export function ipPoolPath(name) { + return name ? `/ippools/${name}` : '/ippools'; +} + +export function asnClaimPath(ns, name) { + return name + ? `/namespaces/${ns}/asnclaims/${name}` + : `/namespaces/${ns}/asnclaims`; +} + +export function asnPoolPath(name) { + return name ? `/asnpools/${name}` : '/asnpools'; +} + +export function asnPoolClassPath(name) { + return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; +} + +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + +// --- Resource builders --- + +// ipPool builds an IPPool body. visibility defaults to 'consumer'. Set +// visibility='shared' to allow cross-project use, 'platform' for backbone. +export function ipPool(name, cidr, { + ipFamily = 'IPv4', + visibility = 'consumer', + minLen = 20, + maxLen = 28, + strategy = 'FirstFit', + classNames = null, +} = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPPool', + metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, + spec: { + provisioner, + ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, + visibility, + }, + }; +} + +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + +// ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting +// spec.poolRef is `{ name: poolName }` (same-project). +export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec: { + ipFamily, + prefixLength, + poolRef: { name: poolName }, + reclaimPolicy, + }, + }; +} + +// crossProjectIPClaim is like ipClaim but sets spec.poolRef.projectRef to the +// pool's owning project, so the apiserver resolves the pool in that project's +// scope. +export function crossProjectIPClaim(ns, name, poolName, sourceProjectID, prefixLength, opts = {}) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec: { + ipFamily: opts.ipFamily || 'IPv4', + prefixLength, + poolRef: { + name: poolName, + projectRef: { name: sourceProjectID }, + }, + reclaimPolicy: opts.reclaimPolicy || 'Delete', + }, + }; +} + +export function asnPoolClass(name, { visibility = 'consumer' } = {}) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'ASNPoolClass', + metadata: { name }, + spec: { visibility }, + }; +} + +export function asnPool(name, ranges, classRef) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'ASNPool', + metadata: { name }, + spec: { ranges, classRef: { name: classRef } }, + }; +} + +export function asnClaim(ns, name, poolRef) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'ASNClaim', + metadata: { name, namespace: ns }, + spec: { poolRef: { name: poolRef } }, + }; +} + +export function asnClaimWithClassRef(ns, name, classRefName) { + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'ASNClaim', + metadata: { name, namespace: ns }, + spec: { classRef: { name: classRefName } }, + }; +} + +// --- Typed helper functions --- + +// IPPool create / read / delete. +export function createIPPool(name, cidr, opts) { + return ipamPost(ipPoolPath(), ipPool(name, cidr, opts), 'ippool_create'); +} + +export function getIPPool(name) { + return ipamGet(ipPoolPath(name), 'ippool_get'); +} + +export function listIPPools() { + return ipamList(ipPoolPath(), 'ippool_list'); +} + +export function deleteIPPool(name) { + return ipamDelete(ipPoolPath(name), 'ippool_delete'); +} + +// IPClaim helpers. +export function createIPClaim(ns, name, poolName, prefixLength, opts) { + return ipamPost(ipClaimPath(ns), ipClaim(ns, name, poolName, prefixLength, opts), 'ipclaim_create'); +} + +export function deleteIPClaim(ns, name) { + return ipamDelete(ipClaimPath(ns, name), 'ipclaim_delete'); +} + +export function getIPClaim(ns, name) { + return ipamGet(ipClaimPath(ns, name), 'ipclaim_get'); +} + +export function listIPClaims(ns) { + return ipamList(ipClaimPath(ns), 'ipclaim_list'); +} + +// IPAllocation helpers (system-created; tests only read/list). +export function listIPAllocations(ns) { + return ipamList(ipAllocationPath(ns), 'ipallocation_list'); +} + +// ASN helpers. +export function createASNClaim(ns, name, poolRef) { + return ipamPost(asnClaimPath(ns), asnClaim(ns, name, poolRef), 'asn_claim_create'); +} + +export function deleteASNClaim(ns, name) { + return ipamDelete(asnClaimPath(ns, name), 'asn_claim_delete'); +} + +export function getASNClaim(ns, name) { + return ipamGet(asnClaimPath(ns, name), 'asn_claim_get'); +} + +export function listASNClaims(ns) { + return ipamList(asnClaimPath(ns), 'asn_claim_list'); +} + +export function createASNPoolClass(name, opts) { + return ipamPost(asnPoolClassPath(), asnPoolClass(name, opts), 'asn_pool_class_create'); +} + +export function createASNPool(name, ranges, classRef) { + return ipamPost(asnPoolPath(), asnPool(name, ranges, classRef), 'asn_pool_create'); +} + +// --- Namespace helpers (core API) --- + +export function createNamespace(name) { + const body = { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { name }, + }; + return http.post(`${BASE_URL}/api/v1/namespaces`, JSON.stringify(body), defaultParams('ns_create')); +} + +export function deleteNamespace(name) { + return http.del(`${BASE_URL}/api/v1/namespaces/${name}`, null, defaultParams('ns_delete')); +} + +// --- RBAC helpers (core API, used by setup) --- + +export function createClusterRole(name, rules) { + const body = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + metadata: { name }, + rules, + }; + return http.post( + `${BASE_URL}/apis/rbac.authorization.k8s.io/v1/clusterroles`, + JSON.stringify(body), + defaultParams('cluster_role_create'), + ); +} + +export function createClusterRoleBinding(name, roleName, subjects) { + const body = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRoleBinding', + metadata: { name }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: roleName, + }, + subjects, + }; + return http.post( + `${BASE_URL}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`, + JSON.stringify(body), + defaultParams('cluster_role_binding_create'), + ); +} + +// --- Multi-tenant helpers --- + +// projectIDFor returns the perf project ID for index n. +export function projectIDFor(n) { + return `ipam-perf-${n}`; +} + +// createCrossProjectIPClaim posts a cross-project IPClaim with tenant headers +// for callerProjectID, targeting a pool owned by sourceProjectID. +export function createCrossProjectIPClaim(ns, name, poolName, sourceProjectID, callerProjectID, prefixLength, opts = {}) { + const body = crossProjectIPClaim(ns, name, poolName, sourceProjectID, prefixLength, opts); + const params = withProjectTagged(callerProjectID, 'cross_project_ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + +export function createIPClaimForProject(ns, name, poolName, prefixLength, projectID, opts = {}) { + const body = ipClaim(ns, name, poolName, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + +// buildIPClaimRequest returns an http.batch()-compatible descriptor instead +// of firing the request. Use when multiple claims must be sent concurrently +// from a single VU to test SELECT...FOR UPDATE contention. +export function buildIPClaimRequest(ns, name, poolName, prefixLength, projectID, opts = {}) { + return { + method: 'POST', + url: `${API_BASE}${ipClaimPath(ns)}`, + body: JSON.stringify(ipClaim(ns, name, poolName, prefixLength, opts)), + params: withProjectTagged(projectID, 'ipclaim_create'), + }; +} + +export function deleteIPClaimForProject(ns, name, projectID) { + const params = withProjectTagged(projectID, 'ipclaim_delete'); + return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); +} + +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + +export function getIPClaimForProject(ns, name, projectID) { + const params = withProjectTagged(projectID, 'ipclaim_get'); + return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); +} + +export function listIPClaimsForProject(ns, projectID) { + const params = withProjectTagged(projectID, 'ipclaim_list'); + return http.get(`${API_BASE}${ipClaimPath(ns)}`, params); +} + +export function listIPPoolsForProject(projectID) { + const params = withProjectTagged(projectID, 'ippool_list'); + return http.get(`${API_BASE}${ipPoolPath()}`, params); +} + +export function getIPPoolForProject(name, projectID) { + const params = withProjectTagged(projectID, 'ippool_get'); + return http.get(`${API_BASE}${ipPoolPath(name)}`, params); +} + +export function listIPAllocationsForProject(ns, projectID) { + const params = withProjectTagged(projectID, 'ipallocation_list'); + return http.get(`${API_BASE}${ipAllocationPath(ns)}`, params); +} + +export function createASNClaimForProject(ns, name, poolRef, projectID) { + const body = asnClaim(ns, name, poolRef); + const params = withProjectTagged(projectID, 'asn_claim_create'); + return http.post(`${API_BASE}${asnClaimPath(ns)}`, JSON.stringify(body), params); +} + +export function deleteASNClaimForProject(ns, name, projectID) { + const params = withProjectTagged(projectID, 'asn_claim_delete'); + return http.del(`${API_BASE}${asnClaimPath(ns, name)}`, null, params); +} + +// createASNClaimWithClassRefForProject posts an ASNClaim that references a +// class (not a pool). +export function createASNClaimWithClassRefForProject(ns, name, classRefName, projectID) { + const body = asnClaimWithClassRef(ns, name, classRefName); + const params = withProjectTagged(projectID, 'asn_claim_create'); + return http.post(`${API_BASE}${asnClaimPath(ns)}`, JSON.stringify(body), params); +} + +// LIST helpers used by the read-latency scenarios. All accept the project +// tenant headers so reads stay scoped to the requesting tenant. +export function listASNPoolsForProject(projectID) { + const params = withProjectTagged(projectID, 'asn_pool_list'); + return http.get(`${API_BASE}${asnPoolPath()}`, params); +} + +export function listASNClaimsForProject(ns, projectID) { + const params = withProjectTagged(projectID, 'asn_claim_list'); + return http.get(`${API_BASE}${asnClaimPath(ns)}`, params); +} + +// class-claim-throughput.js +// +// Measures the IPClass hot path: IPClaim creation throughput and latency when +// claims select a class by name (spec.className) rather than naming a pool. +// This is the standard claim path introduced by the IPClass enhancement +// (docs/enhancements/ip-class.md) — the consumer names a *kind* of address +// space and IPAM picks the backing pool server-side. +// +// Each VU: pick a random project N, POST an IPClaim with className=perf-class +// under project N's tenant headers (no poolRef), record latency + +// success, then DELETE the claim. +// +// This script is self-contained: setup() provisions the class and one backing +// pool per project; teardown() removes them. Namespaces (ipam-perf-) are +// expected to already exist from setup-pools.js (task test/load:setup). +// +// Thresholds mirror prefix-claim-throughput.js (p95 < 500ms, success > 0.95) +// so the class path is held to the same bar as direct pool claims — the +// enhancement's scalability note is that class resolution adds only a lookup +// and a scoped pool search, not a change to the atomic allocation guarantee. +// +// Configuration: +// NAMESPACE_COUNT - Pool of namespaces (must match setup, default 10) +// PROJECT_COUNT - Number of perf projects (default 5) +// VUS - Concurrent virtual users (default 10) +// DURATION - Test duration (default 2m) +// PREFIX_LENGTH - Requested prefix size (default 28) +// IPAM_API_URL - Apiserver URL (default localhost:8001) + +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; +const NAMESPACE_COUNT = parseInt(__ENV.NAMESPACE_COUNT || '10'); +const PROJECT_COUNT = parseInt(__ENV.PROJECT_COUNT || '5'); +const VUS = parseInt(__ENV.VUS || '10'); +const DURATION = __ENV.DURATION || '2m'; +const PREFIX_LENGTH = parseInt(__ENV.PREFIX_LENGTH || '28'); + +const CLASS_NAME = 'perf-class'; +// Allowed prefix lengths for the class; PREFIX_LENGTH must fall inside this. +const CLASS_MIN_LEN = 20; +const CLASS_MAX_LEN = 28; + +// backingPoolName / backingPoolCIDR give each project its own pool that offers +// capacity to perf-class. The 100.64.0.0/10 (CGNAT) block keeps these clear of +// the 10.x per-project pools that setup-pools.js provisions. +function backingPoolName(n) { + return `perf-class-pool-${n}`; +} +function backingPoolCIDR(n) { + return `100.${64 + (n % 64)}.0.0/16`; +} + +const claimCreateLatency = new Trend('ipam_claim_create_latency_ms', true); +const claimDeleteLatency = new Trend('ipam_claim_delete_latency_ms', true); +const claimSuccessRate = new Rate('ipam_claim_success_rate'); +const claimsCreated = new Counter('ipam_claims_created'); +const claimsDenied = new Counter('ipam_claims_denied'); +const claimErrors = new Counter('ipam_claim_errors'); + +export const options = { + insecureSkipTLSVerify: __ENV.K6_INSECURE_SKIP_TLS_VERIFY !== 'false', + scenarios: { + steady_throughput: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + tags: { scenario: 'steady' }, + }, + }, + thresholds: { + 'ipam_claim_create_latency_ms{phase:success}': ['p(95)<500', 'p(99)<2000'], + 'ipam_claim_success_rate': ['rate>0.95'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +export function setup() { + // Platform-owned policy object. visibility=consumer keeps it per-project, + // matching how the per-project backing pools below are scoped. + const c = createIPClass(CLASS_NAME, { + ipFamily: 'IPv4', + strategy: 'FirstFit', + minLen: CLASS_MIN_LEN, + maxLen: CLASS_MAX_LEN, + defaultPrefixLength: 28, + reclaimPolicy: 'Delete', + visibility: 'consumer', + }); + if (c.status !== 201 && c.status !== 409) { + throw new Error(`IPClass create failed: ${c.status} ${c.body}`); + } + + // One backing pool per project, each offering its capacity to the class. + let pools = 0; + for (let n = 0; n < PROJECT_COUNT; n++) { + const name = backingPoolName(n); + const r = createIPPool(name, backingPoolCIDR(n), { + ipFamily: 'IPv4', + visibility: 'consumer', + minLen: CLASS_MIN_LEN, + maxLen: CLASS_MAX_LEN, + strategy: 'FirstFit', + classNames: [CLASS_NAME], + }); + if (r.status === 201 || r.status === 409) { + pools++; + } else { + console.error(`backing pool ${name} create failed: ${r.status} ${r.body}`); + } + } + console.log(`setup complete: class ${CLASS_NAME}, ${pools}/${PROJECT_COUNT} backing pools`); + return { pools }; +} + +function recordCreate(res) { + const ok = check(res, { 'class claim created': (r) => r.status === 201 }); + if (ok) { + claimsCreated.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'success' }); + claimSuccessRate.add(1); + } else if (res.status === 507) { + claimsDenied.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'denied' }); + claimSuccessRate.add(0); + } else { + claimErrors.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'error' }); + claimSuccessRate.add(0); + if (__ITER < 5) { + console.error(`class claim error ${res.status}: ${res.body}`); + } + } + return ok; +} + +export default function () { + const ns = nsFor(Math.floor(Math.random() * NAMESPACE_COUNT)); + const claimName = `class-claim-${__VU}-${__ITER}`; + const projectIdx = Math.floor(Math.random() * PROJECT_COUNT); + const callerProject = projectIDFor(projectIdx); + + const createRes = createIPClaimWithClassForProject( + ns, + claimName, + CLASS_NAME, + PREFIX_LENGTH, + callerProject, + ); + + if (recordCreate(createRes)) { + const delRes = deleteIPClaimForProject(ns, claimName, callerProject); + claimDeleteLatency.add(delRes.timings.duration); + if (delRes.status !== 200 && delRes.status !== 202 && delRes.status !== 404) { + claimErrors.add(1); + } + } +} + +export function teardown() { + for (let n = 0; n < PROJECT_COUNT; n++) { + deleteIPPool(backingPoolName(n)); + } + deleteIPClass(CLASS_NAME); + console.log('teardown complete'); +} diff --git a/config/components/k6-performance-tests/generated/concurrent-claims.js b/config/components/k6-performance-tests/generated/concurrent-claims.js index eac6ea4..06275fc 100644 --- a/config/components/k6-performance-tests/generated/concurrent-claims.js +++ b/config/components/k6-performance-tests/generated/concurrent-claims.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/cross-project-claim-throughput.js b/config/components/k6-performance-tests/generated/cross-project-claim-throughput.js index 75347bc..584f7b0 100644 --- a/config/components/k6-performance-tests/generated/cross-project-claim-throughput.js +++ b/config/components/k6-performance-tests/generated/cross-project-claim-throughput.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/host-prefix-claim-concurrent.js b/config/components/k6-performance-tests/generated/host-prefix-claim-concurrent.js index e22bc4d..26c0a87 100644 --- a/config/components/k6-performance-tests/generated/host-prefix-claim-concurrent.js +++ b/config/components/k6-performance-tests/generated/host-prefix-claim-concurrent.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/ipv6-claim-throughput.js b/config/components/k6-performance-tests/generated/ipv6-claim-throughput.js index d1f41e0..b15fd82 100644 --- a/config/components/k6-performance-tests/generated/ipv6-claim-throughput.js +++ b/config/components/k6-performance-tests/generated/ipv6-claim-throughput.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/mixed-load.js b/config/components/k6-performance-tests/generated/mixed-load.js index 6e92c1c..343fe3c 100644 --- a/config/components/k6-performance-tests/generated/mixed-load.js +++ b/config/components/k6-performance-tests/generated/mixed-load.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/pool-exhaustion.js b/config/components/k6-performance-tests/generated/pool-exhaustion.js index 61981f1..5522d00 100644 --- a/config/components/k6-performance-tests/generated/pool-exhaustion.js +++ b/config/components/k6-performance-tests/generated/pool-exhaustion.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/pool-scale.js b/config/components/k6-performance-tests/generated/pool-scale.js index 84e894e..a7e4f32 100644 --- a/config/components/k6-performance-tests/generated/pool-scale.js +++ b/config/components/k6-performance-tests/generated/pool-scale.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/prefix-claim-throughput.js b/config/components/k6-performance-tests/generated/prefix-claim-throughput.js index 4e71a0e..8d3af7c 100644 --- a/config/components/k6-performance-tests/generated/prefix-claim-throughput.js +++ b/config/components/k6-performance-tests/generated/prefix-claim-throughput.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/read-latency.js b/config/components/k6-performance-tests/generated/read-latency.js index 2f5e647..f272b1d 100644 --- a/config/components/k6-performance-tests/generated/read-latency.js +++ b/config/components/k6-performance-tests/generated/read-latency.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/setup-pools.js b/config/components/k6-performance-tests/generated/setup-pools.js index e8bbb53..49b2aaf 100644 --- a/config/components/k6-performance-tests/generated/setup-pools.js +++ b/config/components/k6-performance-tests/generated/setup-pools.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/generated/watch-latency.js b/config/components/k6-performance-tests/generated/watch-latency.js index edcd0cf..008c7eb 100644 --- a/config/components/k6-performance-tests/generated/watch-latency.js +++ b/config/components/k6-performance-tests/generated/watch-latency.js @@ -135,6 +135,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -145,20 +150,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -385,6 +447,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/config/components/k6-performance-tests/kustomization.yaml b/config/components/k6-performance-tests/kustomization.yaml index 5ee0ee0..1abab40 100644 --- a/config/components/k6-performance-tests/kustomization.yaml +++ b/config/components/k6-performance-tests/kustomization.yaml @@ -14,6 +14,7 @@ configMapGenerator: files: - generated/setup-pools.js - generated/prefix-claim-throughput.js + - generated/class-claim-throughput.js - generated/pool-exhaustion.js - generated/read-latency.js - generated/pool-scale.js diff --git a/config/components/k6-performance-tests/testruns/class-throughput.yaml b/config/components/k6-performance-tests/testruns/class-throughput.yaml new file mode 100644 index 0000000..fa2c370 --- /dev/null +++ b/config/components/k6-performance-tests/testruns/class-throughput.yaml @@ -0,0 +1,36 @@ +apiVersion: k6.io/v1alpha1 +kind: TestRun +metadata: + name: ipam-perf-class-throughput + namespace: ipam-system +spec: + parallelism: 1 + separate: false + cleanup: post + script: + configMap: + name: ipam-k6-test-scripts + file: class-claim-throughput.js + runner: + image: grafana/k6:latest + serviceAccountName: ipam-k6-runner + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + env: + - name: IPAM_API_URL + value: "https://kubernetes.default.svc.cluster.local:443" + - name: K6_INSECURE_SKIP_TLS_VERIFY + value: "true" + - name: NAMESPACE_COUNT + value: "10" + - name: PROJECT_COUNT + value: "5" + - name: VUS + value: "10" + - name: DURATION + value: "2m" diff --git a/examples/ipclass/ipclaim.yaml b/examples/ipclass/ipclaim.yaml new file mode 100644 index 0000000..58b7a55 --- /dev/null +++ b/examples/ipclass/ipclaim.yaml @@ -0,0 +1,14 @@ +--- +# The consumer names a class and (optionally) a size. Everything else — which +# pool, how the block is placed, the address family, what happens on release — +# comes from the class. ipFamily is omitted here: the server derives it from +# the resolved pool. The allocated CIDR is returned synchronously in the claim +# status, exactly as a pool- or selector-based claim. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: my-service-egress + namespace: default +spec: + className: public-egress + prefixLength: 26 # optional; the class defaultPrefixLength applies if omitted diff --git a/examples/ipclass/ipclass.yaml b/examples/ipclass/ipclass.yaml new file mode 100644 index 0000000..fe70cfe --- /dev/null +++ b/examples/ipclass/ipclass.yaml @@ -0,0 +1,26 @@ +--- +# A platform-owned IPClass names a *kind* of address space and the policy for +# handing it out. It carries no CIDRs and no pool names — only policy and a +# pointer to the provisioner that satisfies it — which is what keeps consumer +# manifests portable across environments. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClass +metadata: + name: public-egress +spec: + # The allocator that satisfies claims of this class. Only the platform's + # native allocator ships today, and it is the default. + provisioner: ipam.miloapis.com/native + + # Allocation policy, lifted off the pool and the claim: + ipFamily: IPv4 + strategy: LeastUtilized + allowedPrefixLengths: + min: 24 + max: 28 + defaultPrefixLength: 26 + reclaimPolicy: Retain + + # Who may consume this class, reusing the platform's existing sharing model: + # platform | consumer | shared. + visibility: shared diff --git a/examples/ipclass/ippool.yaml b/examples/ipclass/ippool.yaml new file mode 100644 index 0000000..5ac7524 --- /dev/null +++ b/examples/ipclass/ippool.yaml @@ -0,0 +1,16 @@ +--- +# A pool advertises the classes it is willing to back via spec.classNames. The +# pool owner — not the class author — decides whether a given range serves a +# class. A pool may back several classes, and a class may be backed by many +# pools. Consumers never see this object. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: egress-us-east +spec: + cidr: 203.0.113.0/24 + ipFamily: IPv4 + visibility: shared + # This pool's capacity backs the "public-egress" class. + classNames: + - public-egress diff --git a/examples/ipclass/kustomization.yaml b/examples/ipclass/kustomization.yaml new file mode 100644 index 0000000..c26cb07 --- /dev/null +++ b/examples/ipclass/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# IPClass example: define a platform-owned class, attach a pool to it via +# spec.classNames, then claim from the class by name — the consumer never +# names a pool. +# +# Apply with: +# kubectl apply -k examples/ipclass/ + +resources: + - ipclass.yaml + - ippool.yaml + - ipclaim.yaml diff --git a/internal/allocator/interface.go b/internal/allocator/interface.go index e0c935f..b5a95c5 100644 --- a/internal/allocator/interface.go +++ b/internal/allocator/interface.go @@ -28,6 +28,14 @@ var ErrPoolNotFound = errors.New("ipam: pool not found") // Storage) at the registry boundary. var ErrPoolExhausted = errors.New("ipam: pool exhausted") +// ErrClassNotFound is returned when a named IPClass, or the requested default +// class, does not exist in the caller's scope. +var ErrClassNotFound = errors.New("ipam: class not found") + +// ErrNoPoolForClass is returned when a class exists but no pool in the caller's +// scope backs it for the required address family. +var ErrNoPoolForClass = errors.New("ipam: no pool backs class") + // PrefixAllocator atomically reserves a sub-CIDR from an IPPrefix pool. // // ownerProject scopes the allocation to a single tenant project so per-project diff --git a/internal/allocator/resolve.go b/internal/allocator/resolve.go index a662b34..0c50af8 100644 --- a/internal/allocator/resolve.go +++ b/internal/allocator/resolve.go @@ -3,7 +3,9 @@ package allocator import ( "context" "encoding/json" + "errors" "fmt" + "sort" "strings" "time" @@ -99,6 +101,185 @@ func listPools(ctx context.Context, tx pgx.Tx, kind, ownerProject string) ([]str return keys, datas, nil } +// LoadIPClass loads the named IPClass from the caller's scope. IPClass is +// cluster-scoped at the API layer but, like IPPool, is persisted under the +// tenant prefix of the control-plane it was created through, so it is +// addressed with the same ownerProject prefix the pools use. Returns +// ErrClassNotFound when no such class exists. +func LoadIPClass(ctx context.Context, tx pgx.Tx, ownerProject, name string) (*ipamv1alpha1.IPClass, error) { + defer metrics.ObserveQuery("load_ip_class", time.Now()) + + key := tenant.Identity{Name: ownerProject}.ResourceKey("ipclasses", name) + var data []byte + err := tx.QueryRow(ctx, + `SELECT data FROM ipam_objects WHERE key = $1 AND kind = 'IPClass'`, + key, + ).Scan(&data) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrClassNotFound + } + return nil, fmt.Errorf("load IPClass %q: %w", name, err) + } + var class ipamv1alpha1.IPClass + if err := json.Unmarshal(data, &class); err != nil { + return nil, fmt.Errorf("decode IPClass %q: %w", name, err) + } + return &class, nil +} + +// FindDefaultIPClass returns the IPClass in the caller's scope marked as the +// platform default via the is-default-class annotation. Returns +// ErrClassNotFound when no class is marked default. The single-default +// invariant is enforced on write (ipclass storage), so the first match is the +// only match. +func FindDefaultIPClass(ctx context.Context, tx pgx.Tx, ownerProject string) (*ipamv1alpha1.IPClass, error) { + defer metrics.ObserveQuery("find_default_ip_class", time.Now()) + + keys, datas, err := listPools(ctx, tx, "IPClass", ownerProject) + if err != nil { + return nil, err + } + for i, key := range keys { + var class ipamv1alpha1.IPClass + if err := json.Unmarshal(datas[i], &class); err != nil { + return nil, fmt.Errorf("decode IPClass %q: %w", key, err) + } + if class.Annotations[ipamv1alpha1.IsDefaultClassAnnotation] == "true" { + return &class, nil + } + } + return nil, ErrClassNotFound +} + +// ResolveIPPoolForClass returns the storage key of an IPPool that backs the +// named class for the given address family. Candidate pools are drawn from the +// caller's own project scope AND the platform scope — consumer projects usually +// own no pools, so a class's backing capacity typically lives platform-owned. +// Candidates are those whose spec.classNames contains className and whose +// effective family matches ipFamily; among them the class's placement strategy +// chooses deterministically (ties, and FirstFit, break by storage key so +// repeated claims are stable). Returns ErrNoPoolForClass when no pool backs the +// class. Cross-project (projectRef/SAR) resolution is intentionally not done +// here — that is a deferred follow-up. +func ResolveIPPoolForClass(ctx context.Context, tx pgx.Tx, className, ownerProject, ipFamily, strategy string) (string, error) { + defer metrics.ObserveQuery("resolve_ip_pool_for_class", time.Now()) + + var keys []string + var pools []ipamv1alpha1.IPPool + for _, scope := range poolScopes(ownerProject) { + sKeys, datas, err := listPools(ctx, tx, "IPPool", scope) + if err != nil { + return "", err + } + for i, key := range sKeys { + var p ipamv1alpha1.IPPool + if err := json.Unmarshal(datas[i], &p); err != nil { + return "", fmt.Errorf("decode IPPool %q: %w", key, err) + } + keys = append(keys, key) + pools = append(pools, p) + } + } + // Merged scopes are each individually key-sorted but not globally; sort the + // union so the first-fit-by-key tie-break stays deterministic. + sortPoolsByKey(keys, pools) + + key, ok := pickPoolForClass(keys, pools, className, ipFamily, strategy) + if !ok { + return "", ErrNoPoolForClass + } + return key, nil +} + +// poolScopes returns the storage scopes to search for a class's backing pools: +// the platform scope alone for a platform caller, or the caller's own project +// scope followed by the platform scope for a project caller. +func poolScopes(ownerProject string) []string { + if ownerProject == "" { + return []string{""} + } + return []string{ownerProject, ""} +} + +// sortPoolsByKey sorts the parallel (keys, pools) slices in place by key. +func sortPoolsByKey(keys []string, pools []ipamv1alpha1.IPPool) { + sort.Sort(&poolsByKey{keys: keys, pools: pools}) +} + +type poolsByKey struct { + keys []string + pools []ipamv1alpha1.IPPool +} + +func (p *poolsByKey) Len() int { return len(p.keys) } +func (p *poolsByKey) Less(i, j int) bool { return p.keys[i] < p.keys[j] } +func (p *poolsByKey) Swap(i, j int) { + p.keys[i], p.keys[j] = p.keys[j], p.keys[i] + p.pools[i], p.pools[j] = p.pools[j], p.pools[i] +} + +// pickPoolForClass chooses the backing pool for a class from candidate pools +// (keys and pools are parallel, sorted by key). It is pure so the placement +// policy is unit-testable without a database. Candidates are those whose +// spec.classNames offers className and whose family matches ipFamily (empty +// ipFamily skips the family filter); among them the strategy's score picks the +// winner, and because keys arrive sorted a strict "<" comparison breaks ties by +// lowest key — deterministic first-fit-by-key. +func pickPoolForClass(keys []string, pools []ipamv1alpha1.IPPool, className, ipFamily, strategy string) (string, bool) { + bestKey := "" + bestScore := 0 + found := false + for i := range pools { + p := &pools[i] + if !poolBacksClass(p, className) { + continue + } + if ipFamily != "" && effectivePoolFamily(p) != ipFamily { + continue + } + score := poolScore(strategy, p) + if !found || score < bestScore { + found = true + bestScore = score + bestKey = keys[i] + } + } + return bestKey, found +} + +// poolBacksClass reports whether a pool offers its capacity to className. +func poolBacksClass(pool *ipamv1alpha1.IPPool, className string) bool { + for _, c := range pool.Spec.ClassNames { + if c == className { + return true + } + } + return false +} + +// poolScore ranks a candidate pool for a placement strategy; lower is better. +// FirstFit (and any unknown/empty strategy) scores every pool equally so the +// lowest storage key wins. LeastUtilized prefers the pool with the smallest +// utilization. BestFit prefers the tightest pool — the one whose largest free +// block is smallest — so wide pools are kept intact for larger requests. +func poolScore(strategy string, pool *ipamv1alpha1.IPPool) int { + switch ipamv1alpha1.Strategy(strategy) { + case ipamv1alpha1.LeastUtilized: + return int(pool.Status.UtilizationPercent) + case ipamv1alpha1.BestFit: + // largestFreePrefix is a mask length: larger value = smaller free + // block = tighter fit. Zero means exhausted/unknown, ranked worst. + lf := int(pool.Status.LargestFreePrefix) + if lf == 0 { + return 128 + } + return 128 - lf + default: // FirstFit and empty + return 0 + } +} + // labelSelectorOrEverything compiles selector into a labels.Selector. A nil // or empty selector matches every pool — operators sometimes want a claim // to land in any pool of the resource type. @@ -116,6 +297,8 @@ func plural(kind string) string { switch kind { case "IPPool": return "ippools" + case "IPClass": + return "ipclasses" } // Conservative fallback — lowercase + "s" — never reached for the kinds // this resolver supports today, but defends against future kinds being diff --git a/internal/allocator/resolve_class_test.go b/internal/allocator/resolve_class_test.go new file mode 100644 index 0000000..72a5823 --- /dev/null +++ b/internal/allocator/resolve_class_test.go @@ -0,0 +1,162 @@ +package allocator + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +func classPool(name string, family ipamv1alpha1.IPFamily, util, largestFree int32, classes ...string) ipamv1alpha1.IPPool { + return ipamv1alpha1.IPPool{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: ipamv1alpha1.IPPoolSpec{ + IPFamily: family, + CIDR: "10.0.0.0/16", + ClassNames: classes, + }, + Status: ipamv1alpha1.IPPoolStatus{ + IPFamily: family, + UtilizationPercent: util, + LargestFreePrefix: largestFree, + }, + } +} + +func TestPoolBacksClass(t *testing.T) { + p := classPool("a", ipamv1alpha1.IPv4, 0, 0, "public-egress", "internal") + if !poolBacksClass(&p, "internal") { + t.Errorf("expected pool to back class internal") + } + if poolBacksClass(&p, "absent") { + t.Errorf("did not expect pool to back class absent") + } + none := classPool("b", ipamv1alpha1.IPv4, 0, 0) + if poolBacksClass(&none, "internal") { + t.Errorf("pool with no classNames should back nothing") + } +} + +func TestPickPoolForClass(t *testing.T) { + // keys must be sorted to mirror listPools ORDER BY key. + keys := []string{"/pool-a", "/pool-b", "/pool-c"} + pools := []ipamv1alpha1.IPPool{ + classPool("pool-a", ipamv1alpha1.IPv4, 80, 26, "egress"), + classPool("pool-b", ipamv1alpha1.IPv4, 20, 25, "egress"), + classPool("pool-c", ipamv1alpha1.IPv6, 10, 60, "egress"), + } + + tests := []struct { + name string + family string + strategy string + wantKey string + wantOK bool + }{ + { + name: "FirstFit picks lowest key among family matches", + family: "IPv4", + strategy: "FirstFit", + wantKey: "/pool-a", + wantOK: true, + }, + { + name: "empty strategy behaves as first-fit", + family: "IPv4", + strategy: "", + wantKey: "/pool-a", + wantOK: true, + }, + { + name: "LeastUtilized picks lowest utilization", + family: "IPv4", + strategy: "LeastUtilized", + wantKey: "/pool-b", + wantOK: true, + }, + { + name: "family filter selects the IPv6 pool", + family: "IPv6", + strategy: "FirstFit", + wantKey: "/pool-c", + wantOK: true, + }, + { + name: "no family match returns not found", + family: "IPv6", + strategy: "LeastUtilized", + wantKey: "/pool-c", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, ok := pickPoolForClass(keys, pools, "egress", tt.family, tt.strategy) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if key != tt.wantKey { + t.Fatalf("key = %q, want %q", key, tt.wantKey) + } + }) + } +} + +func TestPickPoolForClass_NoBackingPool(t *testing.T) { + keys := []string{"/pool-a"} + pools := []ipamv1alpha1.IPPool{classPool("pool-a", ipamv1alpha1.IPv4, 0, 24, "other")} + if _, ok := pickPoolForClass(keys, pools, "egress", "IPv4", "FirstFit"); ok { + t.Fatalf("expected no pool to back class egress") + } +} + +func TestPoolScopes(t *testing.T) { + if got := poolScopes(""); len(got) != 1 || got[0] != "" { + t.Errorf("platform caller scopes = %v, want [\"\"]", got) + } + got := poolScopes("acme") + if len(got) != 2 || got[0] != "acme" || got[1] != "" { + t.Errorf("project caller scopes = %v, want [acme \"\"]", got) + } +} + +func TestSortPoolsByKey(t *testing.T) { + // Simulate the union of a project-scoped list and a platform-scoped list, + // which arrive individually sorted but interleaved after concatenation. + keys := []string{"project/acme/p2", "/platform-p1"} + pools := []ipamv1alpha1.IPPool{ + classPool("p2", ipamv1alpha1.IPv4, 0, 0, "egress"), + classPool("p1", ipamv1alpha1.IPv4, 0, 0, "egress"), + } + sortPoolsByKey(keys, pools) + if keys[0] != "/platform-p1" || keys[1] != "project/acme/p2" { + t.Fatalf("keys not sorted: %v", keys) + } + // The pool paired with each key must move with it. + if pools[0].Name != "p1" || pools[1].Name != "p2" { + t.Fatalf("pools not permuted with keys: %s, %s", pools[0].Name, pools[1].Name) + } +} + +func TestPoolScore(t *testing.T) { + p := classPool("a", ipamv1alpha1.IPv4, 42, 26) + if s := poolScore("LeastUtilized", &p); s != 42 { + t.Errorf("LeastUtilized score = %d, want 42", s) + } + if s := poolScore("FirstFit", &p); s != 0 { + t.Errorf("FirstFit score = %d, want 0", s) + } + // BestFit prefers a tighter pool (larger largestFreePrefix → smaller score). + tight := classPool("t", ipamv1alpha1.IPv4, 0, 28) + loose := classPool("l", ipamv1alpha1.IPv4, 0, 20) + if poolScore("BestFit", &tight) >= poolScore("BestFit", &loose) { + t.Errorf("BestFit should score the tighter pool lower") + } + // Exhausted/unknown (largestFreePrefix 0) ranks worst. + exhausted := classPool("e", ipamv1alpha1.IPv4, 0, 0) + if poolScore("BestFit", &exhausted) != 128 { + t.Errorf("BestFit exhausted score = %d, want 128", poolScore("BestFit", &exhausted)) + } +} diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go index a4e073e..1425899 100644 --- a/internal/apiserver/apiserver.go +++ b/internal/apiserver/apiserver.go @@ -25,6 +25,7 @@ import ( "go.miloapis.com/ipam/internal/allocator" "go.miloapis.com/ipam/internal/registry/ipam/ipallocation" "go.miloapis.com/ipam/internal/registry/ipam/ipclaim" + "go.miloapis.com/ipam/internal/registry/ipam/ipclass" "go.miloapis.com/ipam/internal/registry/ipam/ippool" "go.miloapis.com/ipam/pkg/apis/ipam/install" "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" @@ -189,6 +190,18 @@ func (c completedConfig) New() (*IPAMServer, error) { v1alpha1Storage["ipclaims"] = ipClaimStore v1alpha1Storage["ipclaims/status"] = ipClaimStatusStore + // IPClass — cluster-scoped allocation policy (StorageClass analog). Plain + // CRUD with no allocator/db; the IPClaim handler reads a class to place an + // allocation and pools opt in via spec.classNames. + ipClassStore, err := ipclass.NewIPClassStorage( + Scheme, + c.GenericConfig.RESTOptionsGetter, + ) + if err != nil { + return nil, fmt.Errorf("create IPClass storage: %w", err) + } + v1alpha1Storage["ipclasses"] = ipClassStore + apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1Storage if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a5fbab6..1fa2341 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -58,8 +58,11 @@ var ( // org: parent-name when Kind=="Organization", else "" for now // (project-scoped requests do not carry the owning org id // in extras yet; see internal/tenant/tenant.go). + // class: the IPClass name a class-based claim resolved through, or + // "none" for direct poolRef/poolSelector claims. Bounded by + // the operator-defined class catalog (low cardinality). // Cardinality bound: see top-of-block comment for project / org. - []string{"resource", "result", "ip_family", "project", "org"}, + []string{"resource", "result", "ip_family", "project", "org", "class"}, ) // AllocationAttempts counts allocation attempts by resource type. Paired @@ -78,8 +81,9 @@ var ( // ip_family: "IPv4" | "IPv6" | "ASN" — sourced from the same handler // value used for ObserveAllocationDuration so attempts, // failures, and the latency histogram split identically. + // class: resolved IPClass name or "none"; see AllocationDuration. // project, org: see AllocationDuration for label semantics + cardinality. - []string{"resource", "ip_family", "project", "org"}, + []string{"resource", "ip_family", "project", "org", "class"}, ) // AllocationFailures counts allocation failures by reason. @@ -91,12 +95,13 @@ var ( StabilityLevel: metrics.ALPHA, }, // resource: "ipclaims" | "asnclaims" - // reason: "pool_exhausted" | "pool_not_found" | "verification_required" | "tx_error" | "internal" + // reason: "pool_exhausted" | "pool_not_found" | "no_pool_for_class" | "verification_required" | "tx_error" | "internal" // ip_family: "IPv4" | "IPv6" | "ASN" — mirrors AllocationAttempts so // success-ratio = 1 - (failures / attempts) can be computed // per address family. + // class: resolved IPClass name or "none"; see AllocationDuration. // project, org: see AllocationDuration for label semantics + cardinality. - []string{"resource", "reason", "ip_family", "project", "org"}, + []string{"resource", "reason", "ip_family", "project", "org", "class"}, ) // PoolUtilization tracks per-pool utilization as a ratio in [0, 1]. @@ -437,8 +442,8 @@ func ObservePgxpoolStat(stat PgxpoolStatLike) { // the family-tagged successes. `project` and `org` come from the tenant // identity helpers (Identity.Project() / Identity.Org()); both are "" for // platform-scoped requests. -func ObserveAllocationDuration(resource, result, ipFamily, project, org string, start time.Time) { - AllocationDuration.WithLabelValues(resource, result, ipFamily, project, org).Observe(time.Since(start).Seconds()) +func ObserveAllocationDuration(resource, result, ipFamily, project, org, class string, start time.Time) { + AllocationDuration.WithLabelValues(resource, result, ipFamily, project, org, class).Observe(time.Since(start).Seconds()) } // RecordAllocationFailure increments the failures counter for (resource, @@ -447,14 +452,15 @@ func ObserveAllocationDuration(resource, result, ipFamily, project, org string, // success-ratio = 1 - (failures / attempts) without depending on the // AllocationDuration histogram count. // -// Allowed reasons: "pool_exhausted" | "pool_not_found" | +// Allowed reasons: "pool_exhausted" | "pool_not_found" | "no_pool_for_class" | // "verification_required" | "tx_error" | "internal". // ipFamily mirrors the value passed to ObserveAllocationDuration ("IPv4" | // "IPv6" | "ASN", or "" for failures that fire before the claim spec is // readable). // `project` and `org` are the tenant labels (or "" for platform requests). -func RecordAllocationFailure(resource, reason, ipFamily, project, org string) { - AllocationFailures.WithLabelValues(resource, reason, ipFamily, project, org).Inc() +// `class` is the resolved IPClass name, or "none" for direct pool claims. +func RecordAllocationFailure(resource, reason, ipFamily, project, org, class string) { + AllocationFailures.WithLabelValues(resource, reason, ipFamily, project, org, class).Inc() } // SetPoolUtilization publishes the current allocated/total ratio for a pool. diff --git a/internal/registry/ipam/fieldindexes.go b/internal/registry/ipam/fieldindexes.go index e2a21cc..4cdea5f 100644 --- a/internal/registry/ipam/fieldindexes.go +++ b/internal/registry/ipam/fieldindexes.go @@ -4,6 +4,7 @@ import ( "go.miloapis.com/ipam/internal/fieldindex" "go.miloapis.com/ipam/internal/registry/ipam/ipallocation" "go.miloapis.com/ipam/internal/registry/ipam/ipclaim" + "go.miloapis.com/ipam/internal/registry/ipam/ipclass" "go.miloapis.com/ipam/internal/registry/ipam/ippool" ) @@ -14,5 +15,6 @@ func AllFieldIndexes() []fieldindex.FieldIndex { all = append(all, ipclaim.FieldIndexes...) all = append(all, ipallocation.FieldIndexes...) all = append(all, ippool.FieldIndexes...) + all = append(all, ipclass.FieldIndexes...) return all } diff --git a/internal/registry/ipam/ipclaim/storage.go b/internal/registry/ipam/ipclaim/storage.go index 8e1361d..c386831 100644 --- a/internal/registry/ipam/ipclaim/storage.go +++ b/internal/registry/ipam/ipclaim/storage.go @@ -163,6 +163,31 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV project := id.Project() org := id.Org() + // Resolve the claim's IPClass — an explicit spec.className, or the default + // class when the claim names no class, pool, or selector — and fold its + // policy (family, prefix bounds/default, reclaim policy) into the claim + // before anything downstream reads those fields. Direct poolRef/poolSelector + // claims resolve no class. Done outside the allocation transaction so a + // class lookup failure never opens one. The metric `class` label carries the + // resolved class name, or "none" for direct pool claims. + resolvedClass, cerr := r.resolveClass(ctx, id, claim) + if cerr != nil { + reason, apiErr := classResolutionError(cerr, claim) + metrics.RecordAllocationFailure("ipclaim", reason, string(claim.Spec.IPFamily), project, org, requestedClassLabel(claim)) + return nil, apiErr + } + if resolvedClass != nil { + // Record the resolved class on the claim so provenance and the + // className-immutability rule have a concrete value even when the claim + // arrived naming only the default. + claim.Spec.ClassName = resolvedClass.Name + applyClassPolicy(claim, resolvedClass) + } + class := "none" + if claim.Spec.ClassName != "" { + class = claim.Spec.ClassName + } + ipFamily := string(claim.Spec.IPFamily) // Root span for the whole allocation; every downstream span (tenant resolve, @@ -196,39 +221,48 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV attribute.Bool(tracing.AttrDryRun, dryRun), ) - metrics.AllocationAttempts.WithLabelValues("ipclaim", ipFamily, project, org).Inc() + metrics.AllocationAttempts.WithLabelValues("ipclaim", ipFamily, project, org, class).Inc() allocStart := time.Now() result := "error" defer func() { - metrics.ObserveAllocationDuration("ipclaim", result, ipFamily, project, org, allocStart) + metrics.ObserveAllocationDuration("ipclaim", result, ipFamily, project, org, class, allocStart) }() objectMeta, err := meta.Accessor(claim) if err != nil { - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, fmt.Errorf("get object metadata: %w", err) } rest.FillObjectMetaSystemFields(objectMeta) if err := rest.BeforeCreate(r.strategy, ctx, claim); err != nil { - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, err } if createValidation != nil { if err := createValidation(ctx, claim.DeepCopyObject()); err != nil { - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, err } } - if claim.Spec.PoolRef == nil && claim.Spec.PoolSelector == nil { - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) - return nil, apierrors.NewBadRequest("synchronous allocation requires spec.poolRef or spec.poolSelector") + usingClass := resolvedClass != nil + if !usingClass && claim.Spec.PoolRef == nil && claim.Spec.PoolSelector == nil { + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) + return nil, apierrors.NewBadRequest("synchronous allocation requires spec.className, spec.poolRef, or spec.poolSelector") } if claim.Spec.PoolRef != nil && claim.Spec.PoolSelector != nil { - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, apierrors.NewBadRequest("spec.poolRef and spec.poolSelector are mutually exclusive") } + // With the class default folded in, enforce the class's allowed prefix + // bounds before consuming any capacity. + if usingClass { + if err := prefixWithinClass(claim.Spec.PrefixLength, resolvedClass); err != nil { + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) + return nil, err + } + } if !id.IsPlatform() { // Overwrite client-supplied ownerRef — requestheader CA guarantees @@ -242,7 +276,7 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV tx, err := r.db.Begin(ctx) if err != nil { - metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org, class) failSpan(tracing.ReasonTxError) return nil, fmt.Errorf("begin allocation transaction: %w", err) } @@ -255,7 +289,28 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV // project's tenant identity. isCrossProject := false var poolKey, poolName string - if claim.Spec.PoolRef != nil { + if usingClass { + // Class path: pick a pool in the caller's own project whose + // spec.classNames offers this class and whose family matches, using + // the class's placement strategy. Cross-project sharing via class + // visibility is a future concern, so this stays project-local. + resolved, rerr := allocator.ResolveIPPoolForClass(ctx, tx, resolvedClass.Name, id.Name, string(claim.Spec.IPFamily), string(resolvedClass.Spec.Strategy)) + if rerr != nil { + _ = tx.Rollback(ctx) + if errors.Is(rerr, allocator.ErrNoPoolForClass) { + metrics.RecordAllocationFailure("ipclaim", "no_pool_for_class", ipFamily, project, org, class) + failSpan(tracing.ReasonPoolNotFound) + return nil, apierrors.NewBadRequest(fmt.Sprintf( + "no IPPool backs IPClass %q for address family %s in this project; attach a pool to the class via spec.classNames", + resolvedClass.Name, claim.Spec.IPFamily)) + } + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) + failSpan(tracing.ReasonTxError) + return nil, fmt.Errorf("resolve IPPool for class: %w", rerr) + } + poolKey = resolved + poolName = poolKey[strings.LastIndex(poolKey, "/")+1:] + } else if claim.Spec.PoolRef != nil { poolName = claim.Spec.PoolRef.Name isCrossProject = !id.IsPlatform() && claim.Spec.PoolRef.ProjectRef != nil && @@ -283,11 +338,11 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV if rerr != nil { _ = tx.Rollback(ctx) if errors.Is(rerr, allocator.ErrPoolNotFound) { - metrics.RecordAllocationFailure("ipclaim", "pool_not_found", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "pool_not_found", ipFamily, project, org, class) failSpan(tracing.ReasonPoolNotFound) return nil, apierrors.NewBadRequest("no IPPool matches spec.poolSelector") } - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) failSpan(tracing.ReasonTxError) return nil, fmt.Errorf("resolve IPPool: %w", rerr) } @@ -308,11 +363,11 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV // lookups can return Forbidden because the caller already // named the pool by hand. if claim.Spec.PoolSelector != nil { - metrics.RecordAllocationFailure("ipclaim", "pool_not_found", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "pool_not_found", ipFamily, project, org, class) failSpan(tracing.ReasonCrossProjectDenied) return nil, apierrors.NewBadRequest("no IPPool matches spec.poolSelector") } - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) failSpan(tracing.ReasonCrossProjectDenied) return nil, apierrors.NewForbidden( v1alpha1.Resource("ippools"), @@ -320,7 +375,7 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV fmt.Errorf("cross-project pool not accessible"), ) } - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) failSpan(tracing.ReasonTxError) return nil, err } @@ -330,7 +385,7 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV if err != nil { _ = tx.Rollback(ctx) reason := allocationFailureReason(err) - metrics.RecordAllocationFailure("ipclaim", reason, ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", reason, ipFamily, project, org, class) switch reason { case "pool_exhausted": result = "exhausted" @@ -373,8 +428,9 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV Namespace: claim.Namespace, }, Spec: ipam.IPAllocationSpec{ - IPFamily: claim.Spec.IPFamily, - PoolRef: ipam.LocalRef{Name: poolName}, + IPFamily: claim.Spec.IPFamily, + PoolRef: ipam.LocalRef{Name: poolName}, + ClassName: claim.Spec.ClassName, }, Status: ipam.IPAllocationStatus{ Phase: ipam.AllocationReady, @@ -384,12 +440,12 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV allocData, err := runtime.Encode(r.codec, alloc) if err != nil { _ = tx.Rollback(ctx) - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, fmt.Errorf("encode IPAllocation: %w", err) } if _, err := r.allocator.InsertObject(ctx, tx, allocationKey, "IPAllocation", claim.Namespace, allocationName, allocData); err != nil { _ = tx.Rollback(ctx) - metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org, class) return nil, fmt.Errorf("persist IPAllocation: %w", err) } @@ -400,24 +456,24 @@ func (r *AllocatingREST) Create(ctx context.Context, obj runtime.Object, createV claimData, err := runtime.Encode(r.codec, claim) if err != nil { _ = tx.Rollback(ctx) - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, fmt.Errorf("encode claim: %w", err) } rv, err := r.allocator.InsertObject(ctx, tx, claimKey, "IPClaim", claim.Namespace, claim.Name, claimData) if err != nil { _ = tx.Rollback(ctx) - metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org, class) return nil, fmt.Errorf("persist claim: %w", err) } versioner := storage.APIObjectVersioner{} if err := versioner.UpdateObject(claim, uint64(rv)); err != nil { _ = tx.Rollback(ctx) - metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "internal", ipFamily, project, org, class) return nil, fmt.Errorf("set resource version: %w", err) } if err := tx.Commit(ctx); err != nil { - metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org) + metrics.RecordAllocationFailure("ipclaim", "tx_error", ipFamily, project, org, class) return nil, fmt.Errorf("commit allocation transaction: %w", err) } @@ -661,6 +717,111 @@ func mapAllocationError(err error) error { } } +// resolveClass determines the IPClass a claim allocates through: an explicit +// spec.className, or the default class when the claim names no class, pool, or +// selector. A direct poolRef/poolSelector claim resolves no class (returns +// nil, nil). The lookup runs in its own short read transaction so it never +// leaves an allocation transaction open on failure. +func (r *AllocatingREST) resolveClass(ctx context.Context, id tenant.Identity, claim *ipam.IPClaim) (*v1alpha1.IPClass, error) { + hasPool := claim.Spec.PoolRef != nil || claim.Spec.PoolSelector != nil + switch { + case claim.Spec.ClassName != "": + // Explicit class. If a pool is also named, validation's + // mutual-exclusion rule rejects the claim; resolve the class regardless + // so that error surfaces with policy already applied. + return r.loadClass(ctx, id, claim.Spec.ClassName) + case !hasPool: + // No class, pool, or selector — fall back to the default class. + return r.loadDefaultClass(ctx, id) + default: + return nil, nil + } +} + +// loadClass loads a named class from the caller's own project scope, falling +// back to the platform scope — consumer projects usually own no classes, so a +// class typically lives platform-owned. +func (r *AllocatingREST) loadClass(ctx context.Context, id tenant.Identity, name string) (*v1alpha1.IPClass, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin class lookup transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + class, err := allocator.LoadIPClass(ctx, tx, id.Name, name) + if errors.Is(err, allocator.ErrClassNotFound) && id.Name != "" { + return allocator.LoadIPClass(ctx, tx, "", name) + } + return class, err +} + +// loadDefaultClass finds the default class in the caller's own project scope, +// falling back to the platform scope. +func (r *AllocatingREST) loadDefaultClass(ctx context.Context, id tenant.Identity) (*v1alpha1.IPClass, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin default-class lookup transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + class, err := allocator.FindDefaultIPClass(ctx, tx, id.Name) + if errors.Is(err, allocator.ErrClassNotFound) && id.Name != "" { + return allocator.FindDefaultIPClass(ctx, tx, "") + } + return class, err +} + +// classResolutionError maps a class-lookup failure onto a metric reason and the +// client-facing API error. A missing named class or a missing default class are +// both actionable bad requests; anything else is internal. +func classResolutionError(err error, claim *ipam.IPClaim) (string, error) { + if errors.Is(err, allocator.ErrClassNotFound) { + if claim.Spec.ClassName != "" { + return "class_not_found", apierrors.NewBadRequest(fmt.Sprintf("IPClass %q not found", claim.Spec.ClassName)) + } + return "class_not_found", apierrors.NewBadRequest( + "no IPClass specified and no default IPClass is configured; set spec.className, spec.poolRef, or spec.poolSelector") + } + return "internal", apierrors.NewInternalError(err) +} + +// requestedClassLabel is the metric `class` label to use when class resolution +// fails, before the resolved-class label is known. +func requestedClassLabel(claim *ipam.IPClaim) string { + if claim.Spec.ClassName != "" { + return claim.Spec.ClassName + } + return "none" +} + +// applyClassPolicy folds a resolved class's policy into a claim, filling only +// the fields the claim left empty so an explicit claim value always wins. +func applyClassPolicy(claim *ipam.IPClaim, class *v1alpha1.IPClass) { + if claim.Spec.IPFamily == "" { + claim.Spec.IPFamily = ipam.IPFamily(class.Spec.IPFamily) + } + if claim.Spec.PrefixLength == 0 { + claim.Spec.PrefixLength = class.Spec.DefaultPrefixLength + } + if claim.Spec.ReclaimPolicy == "" { + claim.Spec.ReclaimPolicy = ipam.ReclaimPolicy(class.Spec.ReclaimPolicy) + } +} + +// prefixWithinClass enforces the class's allowed prefix bounds against the +// (already defaulted) requested prefix length. A zero bound means "unbounded". +func prefixWithinClass(prefixLen int, class *v1alpha1.IPClass) error { + lo := class.Spec.AllowedPrefixLengths.Min + hi := class.Spec.AllowedPrefixLengths.Max + if lo > 0 && prefixLen < lo { + return apierrors.NewBadRequest(fmt.Sprintf( + "prefixLength %d is below IPClass %q minimum of %d", prefixLen, class.Name, lo)) + } + if hi > 0 && prefixLen > hi { + return apierrors.NewBadRequest(fmt.Sprintf( + "prefixLength %d exceeds IPClass %q maximum of %d", prefixLen, class.Name, hi)) + } + return nil +} + // Compile-time interface assertions. var ( _ rest.Storage = (*AllocatingREST)(nil) diff --git a/internal/registry/ipam/ipclaim/storage_class_test.go b/internal/registry/ipam/ipclaim/storage_class_test.go new file mode 100644 index 0000000..50146bd --- /dev/null +++ b/internal/registry/ipam/ipclaim/storage_class_test.go @@ -0,0 +1,201 @@ +package ipclaim + +import ( + "context" + "errors" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "go.miloapis.com/ipam/internal/allocator" + "go.miloapis.com/ipam/internal/tenant" + "go.miloapis.com/ipam/pkg/apis/ipam" + ipaminstall "go.miloapis.com/ipam/pkg/apis/ipam/install" + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +func testClass() *ipamv1alpha1.IPClass { + return &ipamv1alpha1.IPClass{ + ObjectMeta: metav1.ObjectMeta{Name: "public-egress"}, + Spec: ipamv1alpha1.IPClassSpec{ + Provisioner: ipamv1alpha1.NativeProvisioner, + IPFamily: ipamv1alpha1.IPv4, + Strategy: ipamv1alpha1.LeastUtilized, + AllowedPrefixLengths: ipamv1alpha1.PrefixLengthRange{Min: 24, Max: 28}, + DefaultPrefixLength: 26, + ReclaimPolicy: ipamv1alpha1.ReclaimRetain, + }, + } +} + +func TestApplyClassPolicy(t *testing.T) { + t.Run("fills empty fields from the class", func(t *testing.T) { + claim := &ipam.IPClaim{} + applyClassPolicy(claim, testClass()) + if claim.Spec.IPFamily != ipam.IPv4 { + t.Errorf("ipFamily = %q, want IPv4", claim.Spec.IPFamily) + } + if claim.Spec.PrefixLength != 26 { + t.Errorf("prefixLength = %d, want 26 (class default)", claim.Spec.PrefixLength) + } + if claim.Spec.ReclaimPolicy != ipam.ReclaimRetain { + t.Errorf("reclaimPolicy = %q, want Retain", claim.Spec.ReclaimPolicy) + } + }) + + t.Run("explicit claim values win over class policy", func(t *testing.T) { + claim := &ipam.IPClaim{Spec: ipam.IPClaimSpec{ + PrefixLength: 28, + ReclaimPolicy: ipam.ReclaimDelete, + }} + applyClassPolicy(claim, testClass()) + if claim.Spec.PrefixLength != 28 { + t.Errorf("prefixLength = %d, want 28 (explicit)", claim.Spec.PrefixLength) + } + if claim.Spec.ReclaimPolicy != ipam.ReclaimDelete { + t.Errorf("reclaimPolicy = %q, want Delete (explicit)", claim.Spec.ReclaimPolicy) + } + }) +} + +func TestPrefixWithinClass(t *testing.T) { + c := testClass() + tests := []struct { + name string + prefix int + wantErr bool + }{ + {name: "within bounds", prefix: 26}, + {name: "at min", prefix: 24}, + {name: "at max", prefix: 28}, + {name: "below min", prefix: 22, wantErr: true}, + {name: "above max", prefix: 30, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := prefixWithinClass(tt.prefix, c) + if tt.wantErr && err == nil { + t.Fatalf("expected error for prefix %d", tt.prefix) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for prefix %d: %v", tt.prefix, err) + } + }) + } +} + +func TestPrefixWithinClass_UnboundedClass(t *testing.T) { + // A class with no allowedPrefixLengths accepts any positive prefix. + c := &ipamv1alpha1.IPClass{Spec: ipamv1alpha1.IPClassSpec{IPFamily: ipamv1alpha1.IPv4}} + if err := prefixWithinClass(30, c); err != nil { + t.Fatalf("unbounded class should accept prefix 30: %v", err) + } +} + +func TestClassResolutionError(t *testing.T) { + t.Run("named class not found is a bad request", func(t *testing.T) { + claim := &ipam.IPClaim{Spec: ipam.IPClaimSpec{ClassName: "ghost"}} + reason, err := classResolutionError(allocator.ErrClassNotFound, claim) + if reason != "class_not_found" { + t.Errorf("reason = %q, want class_not_found", reason) + } + if err == nil { + t.Fatal("expected an error") + } + }) + + t.Run("no default class is a bad request", func(t *testing.T) { + claim := &ipam.IPClaim{} + reason, err := classResolutionError(allocator.ErrClassNotFound, claim) + if reason != "class_not_found" { + t.Errorf("reason = %q, want class_not_found", reason) + } + if err == nil { + t.Fatal("expected an error") + } + }) + + t.Run("other errors are internal", func(t *testing.T) { + claim := &ipam.IPClaim{} + reason, _ := classResolutionError(errors.New("boom"), claim) + if reason != "internal" { + t.Errorf("reason = %q, want internal", reason) + } + }) +} + +func TestResolveClass_DirectPoolResolvesNoClass(t *testing.T) { + scheme := runtime.NewScheme() + ipaminstall.Install(scheme) + r := &AllocatingREST{strategy: NewStrategy(scheme)} + claim := &ipam.IPClaim{Spec: ipam.IPClaimSpec{PoolRef: &ipam.NamespacedRef{Name: "us-east"}}} + // A direct poolRef claim must not touch the database; db is nil here, so a + // class lookup would panic. Getting (nil, nil) proves the class path is + // skipped entirely. + class, err := r.resolveClass(context.Background(), tenant.Identity{Name: "proj"}, claim) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if class != nil { + t.Fatalf("expected no class for a direct poolRef claim, got %v", class) + } +} + +func TestValidateIPClaim_SelectorExclusivity(t *testing.T) { + base := func() *ipam.IPClaim { + return &ipam.IPClaim{Spec: ipam.IPClaimSpec{IPFamily: ipam.IPv4, PrefixLength: 26}} + } + tests := []struct { + name string + mutate func(*ipam.IPClaim) + wantErr bool + }{ + {name: "className only", mutate: func(c *ipam.IPClaim) { c.Spec.ClassName = "egress" }}, + {name: "poolRef only", mutate: func(c *ipam.IPClaim) { c.Spec.PoolRef = &ipam.NamespacedRef{Name: "p"} }}, + { + name: "none set", + mutate: func(*ipam.IPClaim) {}, + wantErr: true, + }, + { + name: "className and poolRef", + mutate: func(c *ipam.IPClaim) { + c.Spec.ClassName = "egress" + c.Spec.PoolRef = &ipam.NamespacedRef{Name: "p"} + }, + wantErr: true, + }, + { + name: "poolRef and poolSelector", + mutate: func(c *ipam.IPClaim) { + c.Spec.PoolRef = &ipam.NamespacedRef{Name: "p"} + c.Spec.PoolSelector = &ipam.PoolSelector{} + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := base() + tt.mutate(c) + errs := validateIPClaim(c) + if tt.wantErr && len(errs) == 0 { + t.Fatalf("expected validation error, got none") + } + if !tt.wantErr && len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + }) + } +} + +func TestValidateUpdate_ClassNameImmutable(t *testing.T) { + old := &ipam.IPClaim{Spec: ipam.IPClaimSpec{IPFamily: ipam.IPv4, PrefixLength: 26, ClassName: "egress"}} + updated := old.DeepCopy() + updated.Spec.ClassName = "internal" + errs := NewStrategy(nil).ValidateUpdate(context.Background(), updated, old) + if len(errs) == 0 { + t.Fatalf("expected className immutability error") + } +} diff --git a/internal/registry/ipam/ipclaim/strategy.go b/internal/registry/ipam/ipclaim/strategy.go index 5c5ab1d..3ed14ef 100644 --- a/internal/registry/ipam/ipclaim/strategy.go +++ b/internal/registry/ipam/ipclaim/strategy.go @@ -82,6 +82,9 @@ func (ipClaimStrategy) ValidateUpdate(_ context.Context, obj, old runtime.Object if n.Spec.IPFamily != o.Spec.IPFamily { allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "ipFamily"), "ipFamily is immutable")) } + if n.Spec.ClassName != o.Spec.ClassName { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "className"), "className is immutable")) + } if n.Spec.PrefixLength != o.Spec.PrefixLength { allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "prefixLength"), "prefixLength is immutable")) } @@ -117,11 +120,28 @@ func validateIPClaim(c *ipam.IPClaim) field.ErrorList { if c.Spec.PrefixLength > maxLen { allErrs = append(allErrs, field.Invalid(specPath.Child("prefixLength"), c.Spec.PrefixLength, fmt.Sprintf("prefixLength must not exceed %d for %s", maxLen, c.Spec.IPFamily))) } - if c.Spec.PoolRef == nil && c.Spec.PoolSelector == nil { - allErrs = append(allErrs, field.Required(specPath, "exactly one of poolRef or poolSelector must be specified")) + // A claim selects its pool by exactly one of: className (the standard + // path), poolRef (advanced escape hatch), or poolSelector (deprecated). + // The class-based Create resolves and defaults spec.className (writing the + // default class name in when the claim named none) before validation runs, + // so by this point a class-satisfiable claim carries a non-empty className. + selectors := 0 + if c.Spec.ClassName != "" { + selectors++ + } + if c.Spec.PoolRef != nil { + selectors++ + } + if c.Spec.PoolSelector != nil { + selectors++ } - if c.Spec.PoolRef != nil && c.Spec.PoolSelector != nil { - allErrs = append(allErrs, field.Forbidden(specPath, "poolRef and poolSelector are mutually exclusive")) + switch { + case selectors == 0: + allErrs = append(allErrs, field.Required(specPath, + "one of className, poolRef, or poolSelector must be specified")) + case selectors > 1: + allErrs = append(allErrs, field.Forbidden(specPath, + "className, poolRef, and poolSelector are mutually exclusive")) } return allErrs } diff --git a/internal/registry/ipam/ipclass/storage.go b/internal/registry/ipam/ipclass/storage.go new file mode 100644 index 0000000..50336d3 --- /dev/null +++ b/internal/registry/ipam/ipclass/storage.go @@ -0,0 +1,168 @@ +package ipclass + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + + "go.miloapis.com/ipam/pkg/apis/ipam" + "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +// IPClassStorage is the REST storage for IPClass. It embeds the generic +// registry store for standard cluster-scoped CRUD and overrides Create/Update +// only to enforce the single-default-class invariant: at most one IPClass may +// carry the ipam.miloapis.com/is-default-class annotation set to "true". +type IPClassStorage struct { + *genericregistry.Store +} + +// NewIPClassStorage builds the IPClass REST storage. IPClass drives no +// allocation of its own, so — unlike the claim and pool stores — it takes no +// allocator or db: it is a plain CRUD shell over the generic registry store. +func NewIPClassStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*IPClassStorage, error) { + strategy := NewStrategy(scheme) + + store := &genericregistry.Store{ + NewFunc: func() runtime.Object { return &ipam.IPClass{} }, + NewListFunc: func() runtime.Object { return &ipam.IPClassList{} }, + DefaultQualifiedResource: v1alpha1.Resource("ipclasses"), + SingularQualifiedResource: v1alpha1.Resource("ipclass"), + + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + + TableConvertor: rest.NewDefaultTableConvertor(v1alpha1.Resource("ipclasses")), + } + + if err := store.CompleteWithOptions(&generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}); err != nil { + return nil, err + } + + return &IPClassStorage{Store: store}, nil +} + +// Create rejects a class marked default when another default already exists, +// then delegates to the standard store create. +func (r *IPClassStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + class, ok := obj.(*ipam.IPClass) + if !ok { + return nil, fmt.Errorf("expected *ipam.IPClass, got %T", obj) + } + if isDefaultClass(class) { + if err := r.rejectIfOtherDefaultExists(ctx, class.Name); err != nil { + return nil, err + } + } + return r.Store.Create(ctx, obj, createValidation, options) +} + +// Update rejects an update that would mark this class default while another +// default already exists, then delegates to the standard store update. The +// caller's transformer runs first so the single-default check sees the +// fully-resolved object (PUT body or applied patch) before the store writes. +func (r *IPClassStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + guarded := guardedUpdatedObjectInfo{ + inner: objInfo, + guard: func(newObj runtime.Object) error { + class, ok := newObj.(*ipam.IPClass) + if !ok { + return fmt.Errorf("expected *ipam.IPClass, got %T", newObj) + } + if isDefaultClass(class) { + return r.rejectIfOtherDefaultExists(ctx, name) + } + return nil + }, + } + return r.Store.Update(ctx, name, guarded, createValidation, updateValidation, forceAllowCreate, options) +} + +// guardedUpdatedObjectInfo runs the caller's UpdatedObjectInfo to produce the +// candidate object, then runs a guard over it, vetoing the write if the guard +// returns an error. +type guardedUpdatedObjectInfo struct { + inner rest.UpdatedObjectInfo + guard func(runtime.Object) error +} + +func (g guardedUpdatedObjectInfo) Preconditions() *metav1.Preconditions { + return g.inner.Preconditions() +} + +func (g guardedUpdatedObjectInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (runtime.Object, error) { + newObj, err := g.inner.UpdatedObject(ctx, oldObj) + if err != nil { + return nil, err + } + if err := g.guard(newObj); err != nil { + return nil, err + } + return newObj, nil +} + +// rejectIfOtherDefaultExists lists all IPClasses and returns an invalid-object +// error if any class other than self carries the default annotation. The class +// catalog is small and cluster-scoped, so a full list is cheap. +func (r *IPClassStorage) rejectIfOtherDefaultExists(ctx context.Context, self string) error { + list, err := r.Store.List(ctx, &metainternalversion.ListOptions{}) + if err != nil { + return fmt.Errorf("list IPClasses to enforce single default: %w", err) + } + classes, ok := list.(*ipam.IPClassList) + if !ok { + return fmt.Errorf("expected *ipam.IPClassList from List, got %T", list) + } + if conflict := otherDefaultClass(classes.Items, self); conflict != "" { + return apierrors.NewInvalid( + ipam.Kind("IPClass"), + self, + field.ErrorList{field.Invalid( + field.NewPath("metadata", "annotations").Key(ipam.IsDefaultClassAnnotation), + "true", + fmt.Sprintf("IPClass %q is already the default; at most one default class may exist. Remove its %s annotation first.", + conflict, ipam.IsDefaultClassAnnotation), + )}, + ) + } + return nil +} + +// otherDefaultClass returns the name of the first default-annotated class that +// is not self, or "" when self would be the only default. Pure so the +// single-default invariant is unit-testable without a store. +func otherDefaultClass(classes []ipam.IPClass, self string) string { + for i := range classes { + if classes[i].Name == self { + continue + } + if isDefaultClass(&classes[i]) { + return classes[i].Name + } + } + return "" +} + +// isDefaultClass reports whether an IPClass is marked as the platform default +// via the is-default-class annotation set to "true". +func isDefaultClass(c *ipam.IPClass) bool { + return c.Annotations[ipam.IsDefaultClassAnnotation] == "true" +} + +// Compile-time interface assertions. +var ( + _ rest.Storage = (*IPClassStorage)(nil) + _ rest.Creater = (*IPClassStorage)(nil) + _ rest.Updater = (*IPClassStorage)(nil) + _ rest.Lister = (*IPClassStorage)(nil) + _ rest.Getter = (*IPClassStorage)(nil) +) diff --git a/internal/registry/ipam/ipclass/strategy.go b/internal/registry/ipam/ipclass/strategy.go new file mode 100644 index 0000000..6debbaf --- /dev/null +++ b/internal/registry/ipam/ipclass/strategy.go @@ -0,0 +1,228 @@ +// Package ipclass provides REST storage for the cluster-scoped IPClass +// resource. An IPClass is a platform-owned allocation policy — the analog of a +// Kubernetes StorageClass. It carries no addresses and drives no allocation of +// its own, so the storage is plain CRUD (no allocator or db dependency); the +// IPClaim handler reads a class to place an allocation, and pools opt in to +// backing a class via spec.classNames. +package ipclass + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/storage" + "k8s.io/apiserver/pkg/storage/names" + + "go.miloapis.com/ipam/internal/fieldindex" + "go.miloapis.com/ipam/pkg/apis/ipam" +) + +// FieldIndexes are the SQL expression indexes backing IPClass field selectors +// declared in SelectableFields. Applied idempotently by SyncIndexes. +var FieldIndexes = []fieldindex.FieldIndex{ + { + IndexName: "idx_ipam_ipclass_ip_family", + Expression: `((ipam_data_to_jsonb(data) -> 'spec' ->> 'ipFamily')) WHERE kind = 'IPClass'`, + }, + { + IndexName: "idx_ipam_ipclass_provisioner", + Expression: `((ipam_data_to_jsonb(data) -> 'spec' ->> 'provisioner')) WHERE kind = 'IPClass'`, + }, +} + +type ipClassStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +func NewStrategy(typer runtime.ObjectTyper) ipClassStrategy { + return ipClassStrategy{ObjectTyper: typer, NameGenerator: names.SimpleNameGenerator} +} + +func (ipClassStrategy) NamespaceScoped() bool { return false } + +// PrepareForCreate defaults an empty provisioner to the native allocator, the +// only provisioner shipped today. Everything else is validated as-authored. +func (ipClassStrategy) PrepareForCreate(_ context.Context, obj runtime.Object) { + c := obj.(*ipam.IPClass) + if c.Spec.Provisioner == "" { + c.Spec.Provisioner = ipam.NativeProvisioner + } +} + +// PrepareForUpdate keeps the same provisioner defaulting for updates that clear +// the field. +func (ipClassStrategy) PrepareForUpdate(_ context.Context, obj, _ runtime.Object) { + c := obj.(*ipam.IPClass) + if c.Spec.Provisioner == "" { + c.Spec.Provisioner = ipam.NativeProvisioner + } +} + +func (ipClassStrategy) Validate(_ context.Context, obj runtime.Object) field.ErrorList { + return validateIPClass(obj.(*ipam.IPClass)) +} + +func (ipClassStrategy) WarningsOnCreate(_ context.Context, _ runtime.Object) []string { return nil } +func (ipClassStrategy) AllowCreateOnUpdate() bool { return false } +func (ipClassStrategy) AllowUnconditionalUpdate() bool { return true } +func (ipClassStrategy) Canonicalize(_ runtime.Object) {} + +// ValidateUpdate re-runs the full field validation and freezes the two fields +// that would strand existing allocations if changed: the address family and +// the provisioner that satisfies the class. +func (ipClassStrategy) ValidateUpdate(_ context.Context, obj, old runtime.Object) field.ErrorList { + n := obj.(*ipam.IPClass) + o := old.(*ipam.IPClass) + allErrs := validateIPClass(n) + specPath := field.NewPath("spec") + if n.Spec.IPFamily != o.Spec.IPFamily { + allErrs = append(allErrs, field.Forbidden(specPath.Child("ipFamily"), "spec.ipFamily is immutable")) + } + if n.Spec.Provisioner != o.Spec.Provisioner { + allErrs = append(allErrs, field.Forbidden(specPath.Child("provisioner"), "spec.provisioner is immutable")) + } + return allErrs +} + +func (ipClassStrategy) WarningsOnUpdate(_ context.Context, _, _ runtime.Object) []string { + return nil +} + +// familyMaxPrefix is the widest prefix length valid for a family: 32 bits for +// IPv4, 128 for IPv6. +func familyMaxPrefix(f ipam.IPFamily) int { + if f == ipam.IPv6 { + return 128 + } + return 32 +} + +func validateIPClass(c *ipam.IPClass) field.ErrorList { + var allErrs field.ErrorList + specPath := field.NewPath("spec") + + // Provisioner — only the native allocator ships today; a class naming any + // other provisioner is rejected until that provisioner exists. + if c.Spec.Provisioner != ipam.NativeProvisioner { + allErrs = append(allErrs, field.NotSupported(specPath.Child("provisioner"), + c.Spec.Provisioner, []string{ipam.NativeProvisioner})) + } + + // IPFamily — a class is single-family and must declare it. + switch c.Spec.IPFamily { + case ipam.IPv4, ipam.IPv6: + // ok + case "": + allErrs = append(allErrs, field.Required(specPath.Child("ipFamily"), + "ipFamily is required; a class hands out a single address family")) + default: + allErrs = append(allErrs, field.NotSupported(specPath.Child("ipFamily"), + c.Spec.IPFamily, []string{string(ipam.IPv4), string(ipam.IPv6)})) + } + + // Strategy — optional, but must be a known strategy when set. + switch c.Spec.Strategy { + case "", ipam.FirstFit, ipam.BestFit, ipam.LeastUtilized: + // ok + default: + allErrs = append(allErrs, field.NotSupported(specPath.Child("strategy"), + c.Spec.Strategy, []string{string(ipam.FirstFit), string(ipam.BestFit), string(ipam.LeastUtilized)})) + } + + // ReclaimPolicy — optional, but must be Delete or Retain when set. + switch c.Spec.ReclaimPolicy { + case "", ipam.ReclaimDelete, ipam.ReclaimRetain: + // ok + default: + allErrs = append(allErrs, field.NotSupported(specPath.Child("reclaimPolicy"), + c.Spec.ReclaimPolicy, []string{string(ipam.ReclaimDelete), string(ipam.ReclaimRetain)})) + } + + // Visibility — reuses the pool sharing model. + switch c.Spec.Visibility { + case "", "platform", "consumer", "shared": + // ok + default: + allErrs = append(allErrs, field.NotSupported(specPath.Child("visibility"), + c.Spec.Visibility, []string{"", "platform", "consumer", "shared"})) + } + + // AllowedPrefixLengths — bounds must be sane and lie within the family's + // valid prefix range. Only enforce the family-relative ceiling when the + // family itself is valid, to avoid piling confusing errors on a bad family. + allowed := c.Spec.AllowedPrefixLengths + prefixPath := specPath.Child("allowedPrefixLengths") + familyOK := c.Spec.IPFamily == ipam.IPv4 || c.Spec.IPFamily == ipam.IPv6 + maxLen := familyMaxPrefix(c.Spec.IPFamily) + + if allowed.Min < 0 { + allErrs = append(allErrs, field.Invalid(prefixPath.Child("min"), allowed.Min, "must be >= 0")) + } + if allowed.Max < 0 { + allErrs = append(allErrs, field.Invalid(prefixPath.Child("max"), allowed.Max, "must be >= 0")) + } + if allowed.Min > 0 && allowed.Max > 0 && allowed.Min > allowed.Max { + allErrs = append(allErrs, field.Invalid(prefixPath, allowed, + "allowedPrefixLengths.min must be <= allowedPrefixLengths.max")) + } + if familyOK { + if allowed.Min > maxLen { + allErrs = append(allErrs, field.Invalid(prefixPath.Child("min"), allowed.Min, + fmt.Sprintf("must be <= %d for %s", maxLen, c.Spec.IPFamily))) + } + if allowed.Max > maxLen { + allErrs = append(allErrs, field.Invalid(prefixPath.Child("max"), allowed.Max, + fmt.Sprintf("must be <= %d for %s", maxLen, c.Spec.IPFamily))) + } + } + + // DefaultPrefixLength — optional (0 means "no default; claims must ask"); + // when set it must fall inside the allowed range and the family bounds. + if def := c.Spec.DefaultPrefixLength; def != 0 { + defPath := specPath.Child("defaultPrefixLength") + if def < 0 { + allErrs = append(allErrs, field.Invalid(defPath, def, "must be >= 0")) + } + if familyOK && def > maxLen { + allErrs = append(allErrs, field.Invalid(defPath, def, + fmt.Sprintf("must be <= %d for %s", maxLen, c.Spec.IPFamily))) + } + if allowed.Min > 0 && def < allowed.Min { + allErrs = append(allErrs, field.Invalid(defPath, def, + fmt.Sprintf("must be >= allowedPrefixLengths.min (%d)", allowed.Min))) + } + if allowed.Max > 0 && def > allowed.Max { + allErrs = append(allErrs, field.Invalid(defPath, def, + fmt.Sprintf("must be <= allowedPrefixLengths.max (%d)", allowed.Max))) + } + } + + return allErrs +} + +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + c, ok := obj.(*ipam.IPClass) + if !ok { + return nil, nil, fmt.Errorf("given object is not an IPClass") + } + return c.Labels, SelectableFields(c), nil +} + +func SelectableFields(c *ipam.IPClass) fields.Set { + objectMetaFields := generic.ObjectMetaFieldsSet(&c.ObjectMeta, false) + specific := fields.Set{ + "spec.ipFamily": string(c.Spec.IPFamily), + "spec.provisioner": c.Spec.Provisioner, + } + return generic.MergeFieldsSets(objectMetaFields, specific) +} + +func Match(label labels.Selector, fld fields.Selector) storage.SelectionPredicate { + return storage.SelectionPredicate{Label: label, Field: fld, GetAttrs: GetAttrs} +} diff --git a/internal/registry/ipam/ipclass/strategy_test.go b/internal/registry/ipam/ipclass/strategy_test.go new file mode 100644 index 0000000..a1fbd5a --- /dev/null +++ b/internal/registry/ipam/ipclass/strategy_test.go @@ -0,0 +1,218 @@ +package ipclass + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.miloapis.com/ipam/pkg/apis/ipam" +) + +func validClass() *ipam.IPClass { + return &ipam.IPClass{ + ObjectMeta: metav1.ObjectMeta{Name: "public-egress"}, + Spec: ipam.IPClassSpec{ + Provisioner: ipam.NativeProvisioner, + IPFamily: ipam.IPv4, + Strategy: ipam.LeastUtilized, + AllowedPrefixLengths: ipam.PrefixLengthRange{Min: 24, Max: 28}, + DefaultPrefixLength: 26, + ReclaimPolicy: ipam.ReclaimRetain, + Visibility: "shared", + }, + } +} + +func TestPrepareForCreateDefaultsProvisioner(t *testing.T) { + c := validClass() + c.Spec.Provisioner = "" + NewStrategy(nil).PrepareForCreate(context.Background(), c) + if c.Spec.Provisioner != ipam.NativeProvisioner { + t.Fatalf("provisioner: got %q, want %q", c.Spec.Provisioner, ipam.NativeProvisioner) + } +} + +func TestValidateIPClass(t *testing.T) { + tests := []struct { + name string + mutate func(*ipam.IPClass) + wantErr bool + }{ + {name: "valid", mutate: func(*ipam.IPClass) {}}, + { + name: "unknown provisioner rejected", + mutate: func(c *ipam.IPClass) { c.Spec.Provisioner = "ipam.miloapis.com/aws-byoip" }, + wantErr: true, + }, + { + name: "missing ipFamily rejected", + mutate: func(c *ipam.IPClass) { c.Spec.IPFamily = "" }, + wantErr: true, + }, + { + name: "bogus ipFamily rejected", + mutate: func(c *ipam.IPClass) { c.Spec.IPFamily = "IPv5" }, + wantErr: true, + }, + { + name: "unknown strategy rejected", + mutate: func(c *ipam.IPClass) { c.Spec.Strategy = "Random" }, + wantErr: true, + }, + { + name: "empty strategy allowed", + mutate: func(c *ipam.IPClass) { c.Spec.Strategy = "" }, + }, + { + name: "unknown reclaimPolicy rejected", + mutate: func(c *ipam.IPClass) { c.Spec.ReclaimPolicy = "Recycle" }, + wantErr: true, + }, + { + name: "unknown visibility rejected", + mutate: func(c *ipam.IPClass) { c.Spec.Visibility = "public" }, + wantErr: true, + }, + { + name: "min greater than max rejected", + mutate: func(c *ipam.IPClass) { c.Spec.AllowedPrefixLengths = ipam.PrefixLengthRange{Min: 28, Max: 24} }, + wantErr: true, + }, + { + name: "IPv4 max above 32 rejected", + mutate: func(c *ipam.IPClass) { + c.Spec.AllowedPrefixLengths = ipam.PrefixLengthRange{Min: 24, Max: 40} + c.Spec.DefaultPrefixLength = 26 + }, + wantErr: true, + }, + { + name: "IPv6 max up to 128 allowed", + mutate: func(c *ipam.IPClass) { + c.Spec.IPFamily = ipam.IPv6 + c.Spec.AllowedPrefixLengths = ipam.PrefixLengthRange{Min: 48, Max: 64} + c.Spec.DefaultPrefixLength = 56 + }, + }, + { + name: "default below allowed min rejected", + mutate: func(c *ipam.IPClass) { c.Spec.DefaultPrefixLength = 20 }, + wantErr: true, + }, + { + name: "default above allowed max rejected", + mutate: func(c *ipam.IPClass) { c.Spec.DefaultPrefixLength = 30 }, + wantErr: true, + }, + { + name: "zero default allowed (no default)", + mutate: func(c *ipam.IPClass) { c.Spec.DefaultPrefixLength = 0 }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := validClass() + tt.mutate(c) + errs := validateIPClass(c) + if tt.wantErr && len(errs) == 0 { + t.Fatalf("expected validation error, got none") + } + if !tt.wantErr && len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + }) + } +} + +func TestValidateUpdateImmutability(t *testing.T) { + tests := []struct { + name string + mutate func(*ipam.IPClass) + wantErr bool + }{ + {name: "no change ok", mutate: func(*ipam.IPClass) {}}, + { + name: "ipFamily change rejected", + mutate: func(c *ipam.IPClass) { c.Spec.IPFamily = ipam.IPv6 }, + wantErr: true, + }, + { + name: "provisioner change rejected", + mutate: func(c *ipam.IPClass) { c.Spec.Provisioner = "ipam.miloapis.com/aws-byoip" }, + wantErr: true, + }, + { + name: "policy fields mutable", + mutate: func(c *ipam.IPClass) { c.Spec.ReclaimPolicy = ipam.ReclaimDelete; c.Spec.Strategy = ipam.FirstFit }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := validClass() + updated := validClass() + tt.mutate(updated) + errs := NewStrategy(nil).ValidateUpdate(context.Background(), updated, old) + if tt.wantErr && len(errs) == 0 { + t.Fatalf("expected update error, got none") + } + if !tt.wantErr && len(errs) != 0 { + t.Fatalf("unexpected update errors: %v", errs) + } + }) + } +} + +func TestOtherDefaultClass(t *testing.T) { + mkDefault := func(name string) ipam.IPClass { + return ipam.IPClass{ObjectMeta: metav1.ObjectMeta{ + Name: name, + Annotations: map[string]string{ipam.IsDefaultClassAnnotation: "true"}, + }} + } + mkPlain := func(name string) ipam.IPClass { + return ipam.IPClass{ObjectMeta: metav1.ObjectMeta{Name: name}} + } + + tests := []struct { + name string + classes []ipam.IPClass + self string + want string + }{ + { + name: "no other default", + classes: []ipam.IPClass{mkPlain("a"), mkDefault("self")}, + self: "self", + want: "", + }, + { + name: "another class is default", + classes: []ipam.IPClass{mkDefault("other"), mkPlain("self")}, + self: "self", + want: "other", + }, + { + name: "self already the only default (update no-op)", + classes: []ipam.IPClass{mkDefault("self")}, + self: "self", + want: "", + }, + { + name: "creating first default", + classes: []ipam.IPClass{mkPlain("a"), mkPlain("b")}, + self: "self", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := otherDefaultClass(tt.classes, tt.self); got != tt.want { + t.Fatalf("otherDefaultClass = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/registry/ipam/ippool/strategy.go b/internal/registry/ipam/ippool/strategy.go index d130177..91ec7f3 100644 --- a/internal/registry/ipam/ippool/strategy.go +++ b/internal/registry/ipam/ippool/strategy.go @@ -35,6 +35,13 @@ var FieldIndexes = []fieldindex.FieldIndex{ IndexName: "idx_ipam_ippool_parent_pool_ref_name", Expression: `((ipam_data_to_jsonb(data) -> 'spec' -> 'parentPoolRef' ->> 'name')) WHERE kind = 'IPPool'`, }, + { + // classNames is a JSON array, so membership ("does this pool back class + // X?") is a containment query. A GIN index over the array supports the + // jsonb @> operator used when filtering pools by the class they back. + IndexName: "idx_ipam_ippool_class_names", + Expression: `USING GIN ((ipam_data_to_jsonb(data) -> 'spec' -> 'classNames')) WHERE kind = 'IPPool'`, + }, } type ipPoolStrategy struct { diff --git a/pkg/apis/ipam/register.go b/pkg/apis/ipam/register.go index de676d7..c50e2a9 100644 --- a/pkg/apis/ipam/register.go +++ b/pkg/apis/ipam/register.go @@ -34,6 +34,7 @@ func addKnownTypes(scheme *runtime.Scheme) error { &IPPool{}, &IPPoolList{}, &IPAllocation{}, &IPAllocationList{}, &IPClaim{}, &IPClaimList{}, + &IPClass{}, &IPClassList{}, ) return nil } diff --git a/pkg/apis/ipam/types.go b/pkg/apis/ipam/types.go index 06f6831..0e0a4ed 100644 --- a/pkg/apis/ipam/types.go +++ b/pkg/apis/ipam/types.go @@ -28,6 +28,16 @@ const ( ReclaimRetain ReclaimPolicy = "Retain" ) +// NativeProvisioner is the built-in allocator that satisfies claims from the +// platform's own pools. It is the only provisioner shipped today and the +// default for any IPClass that omits spec.provisioner. +const NativeProvisioner = "ipam.miloapis.com/native" + +// IsDefaultClassAnnotation, when set to "true" on an IPClass, marks that class +// as the default used by claims that name neither a class nor a pool. At most +// one IPClass may carry this annotation. +const IsDefaultClassAnnotation = "ipam.miloapis.com/is-default-class" + // ClaimPhase is the high-level lifecycle phase of a claim. type ClaimPhase string @@ -128,6 +138,9 @@ type IPPoolSpec struct { PrefixLength int Allocation AllocationSpec Visibility string + // ClassNames is the set of IPClass names this pool offers its capacity to. + // A claim naming one of these classes may be satisfied from this pool. + ClassNames []string } type IPPoolStatus struct { @@ -177,6 +190,10 @@ type IPAllocation struct { type IPAllocationSpec struct { IPFamily IPFamily PoolRef LocalRef + // ClassName records the IPClass a claim used to reach this allocation, + // empty when the claim named a pool or selector directly. Provenance only; + // it does not affect the allocation itself. + ClassName string } type IPAllocationStatus struct { @@ -209,8 +226,12 @@ type IPClaim struct { } type IPClaimSpec struct { - IPFamily IPFamily - PrefixLength int + IPFamily IPFamily + PrefixLength int + // ClassName selects an IPClass to satisfy this claim. Mutually exclusive + // with PoolSelector and PoolRef; when all three are empty the default + // class is used. Immutable after creation. + ClassName string PoolSelector *PoolSelector PoolRef *NamespacedRef ReclaimPolicy ReclaimPolicy @@ -231,3 +252,55 @@ type IPClaimList struct { metav1.ListMeta Items []IPClaim } + +// ---------------------------------------------------------------------------- +// IPClass — cluster-scoped allocation policy, the analog of a StorageClass. +// ---------------------------------------------------------------------------- + +// PrefixLengthRange bounds the prefix sizes a class will hand out, inclusive. +type PrefixLengthRange struct { + Min int + Max int +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +genclient +// +genclient:nonNamespaced + +// IPClass names a kind of address space and the policy for allocating it. It +// carries no CIDRs — only rules and a pointer to the provisioner that +// satisfies claims of the class. +type IPClass struct { + metav1.TypeMeta + metav1.ObjectMeta + + Spec IPClassSpec +} + +type IPClassSpec struct { + // Provisioner is the allocator that satisfies claims of this class. + // Defaults to the native provisioner (ipam.miloapis.com/native). + Provisioner string + // Parameters are opaque, provisioner-specific settings. + Parameters map[string]string + // IPFamily is the single address family this class hands out. + IPFamily IPFamily + // Strategy selects how a free block is chosen from a backing pool. + Strategy Strategy + // AllowedPrefixLengths bounds the sizes a claim of this class may request. + AllowedPrefixLengths PrefixLengthRange + // DefaultPrefixLength is used when a claim omits its prefix length. + DefaultPrefixLength int + // ReclaimPolicy controls disposition of the allocation on claim deletion. + ReclaimPolicy ReclaimPolicy + // Visibility reuses the pool sharing model: platform | consumer | shared. + Visibility string +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type IPClassList struct { + metav1.TypeMeta + metav1.ListMeta + Items []IPClass +} diff --git a/pkg/apis/ipam/v1alpha1/conversion.go b/pkg/apis/ipam/v1alpha1/conversion.go index 65915b7..7317051 100644 --- a/pkg/apis/ipam/v1alpha1/conversion.go +++ b/pkg/apis/ipam/v1alpha1/conversion.go @@ -71,6 +71,24 @@ func RegisterConversions(s *runtime.Scheme) error { return convert_ipam_IPClaimList_To_v1alpha1(a.(*ipam.IPClaimList), b.(*IPClaimList)) }, }, + { + (*ipam.IPClass)(nil), (*IPClass)(nil), + func(a, b any, sc conversion.Scope) error { + return convert_v1alpha1_IPClass_To_ipam(a.(*IPClass), b.(*ipam.IPClass)) + }, + func(a, b any, sc conversion.Scope) error { + return convert_ipam_IPClass_To_v1alpha1(a.(*ipam.IPClass), b.(*IPClass)) + }, + }, + { + (*ipam.IPClassList)(nil), (*IPClassList)(nil), + func(a, b any, sc conversion.Scope) error { + return convert_v1alpha1_IPClassList_To_ipam(a.(*IPClassList), b.(*ipam.IPClassList)) + }, + func(a, b any, sc conversion.Scope) error { + return convert_ipam_IPClassList_To_v1alpha1(a.(*ipam.IPClassList), b.(*IPClassList)) + }, + }, } for _, p := range pairs { if err := s.AddGeneratedConversionFunc(p.external, p.internal, p.toInternal); err != nil { diff --git a/pkg/apis/ipam/v1alpha1/conversion_impl.go b/pkg/apis/ipam/v1alpha1/conversion_impl.go index 40c7c19..440c6d6 100644 --- a/pkg/apis/ipam/v1alpha1/conversion_impl.go +++ b/pkg/apis/ipam/v1alpha1/conversion_impl.go @@ -108,6 +108,26 @@ func toIpamConditions(in []metav1.Condition) []metav1.Condition { return out } +func toStringSlice(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +func toStringMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + // ---------------------------------------------------------------------------- // IPPool // ---------------------------------------------------------------------------- @@ -122,6 +142,7 @@ func convert_v1alpha1_IPPool_To_ipam(in *IPPool, out *ipam.IPPool) error { PrefixLength: in.Spec.PrefixLength, Allocation: toIpamAllocation(in.Spec.Allocation), Visibility: in.Spec.Visibility, + ClassNames: toStringSlice(in.Spec.ClassNames), } out.Status = ipam.IPPoolStatus{ Phase: ipam.PoolPhase(in.Status.Phase), @@ -144,6 +165,7 @@ func convert_ipam_IPPool_To_v1alpha1(in *ipam.IPPool, out *IPPool) error { PrefixLength: in.Spec.PrefixLength, Allocation: toV1Allocation(in.Spec.Allocation), Visibility: in.Spec.Visibility, + ClassNames: toStringSlice(in.Spec.ClassNames), } out.Status = IPPoolStatus{ Phase: PoolPhase(in.Status.Phase), @@ -192,8 +214,9 @@ func convert_v1alpha1_IPAllocation_To_ipam(in *IPAllocation, out *ipam.IPAllocat out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = ipam.IPAllocationSpec{ - IPFamily: ipam.IPFamily(in.Spec.IPFamily), - PoolRef: ipam.LocalRef{Name: in.Spec.PoolRef.Name}, + IPFamily: ipam.IPFamily(in.Spec.IPFamily), + PoolRef: ipam.LocalRef{Name: in.Spec.PoolRef.Name}, + ClassName: in.Spec.ClassName, } out.Status = ipam.IPAllocationStatus{ Phase: ipam.AllocationPhase(in.Status.Phase), @@ -206,8 +229,9 @@ func convert_ipam_IPAllocation_To_v1alpha1(in *ipam.IPAllocation, out *IPAllocat out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = IPAllocationSpec{ - IPFamily: IPFamily(in.Spec.IPFamily), - PoolRef: LocalRef{Name: in.Spec.PoolRef.Name}, + IPFamily: IPFamily(in.Spec.IPFamily), + PoolRef: LocalRef{Name: in.Spec.PoolRef.Name}, + ClassName: in.Spec.ClassName, } out.Status = IPAllocationStatus{ Phase: AllocationPhase(in.Status.Phase), @@ -254,6 +278,7 @@ func convert_v1alpha1_IPClaim_To_ipam(in *IPClaim, out *ipam.IPClaim) error { out.Spec = ipam.IPClaimSpec{ IPFamily: ipam.IPFamily(in.Spec.IPFamily), PrefixLength: in.Spec.PrefixLength, + ClassName: in.Spec.ClassName, PoolSelector: toIpamPoolSelector(in.Spec.PoolSelector), PoolRef: toIpamNamespacedRef(in.Spec.PoolRef), ReclaimPolicy: ipam.ReclaimPolicy(in.Spec.ReclaimPolicy), @@ -273,6 +298,7 @@ func convert_ipam_IPClaim_To_v1alpha1(in *ipam.IPClaim, out *IPClaim) error { out.Spec = IPClaimSpec{ IPFamily: IPFamily(in.Spec.IPFamily), PrefixLength: in.Spec.PrefixLength, + ClassName: in.Spec.ClassName, PoolSelector: toV1PoolSelector(in.Spec.PoolSelector), PoolRef: toV1NamespacedRef(in.Spec.PoolRef), ReclaimPolicy: ReclaimPolicy(in.Spec.ReclaimPolicy), @@ -313,3 +339,71 @@ func convert_ipam_IPClaimList_To_v1alpha1(in *ipam.IPClaimList, out *IPClaimList } return nil } + +// ---------------------------------------------------------------------------- +// IPClass +// ---------------------------------------------------------------------------- + +func convert_v1alpha1_IPClass_To_ipam(in *IPClass, out *ipam.IPClass) error { + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = ipam.IPClassSpec{ + Provisioner: in.Spec.Provisioner, + Parameters: toStringMap(in.Spec.Parameters), + IPFamily: ipam.IPFamily(in.Spec.IPFamily), + Strategy: ipam.Strategy(in.Spec.Strategy), + AllowedPrefixLengths: ipam.PrefixLengthRange{ + Min: in.Spec.AllowedPrefixLengths.Min, + Max: in.Spec.AllowedPrefixLengths.Max, + }, + DefaultPrefixLength: in.Spec.DefaultPrefixLength, + ReclaimPolicy: ipam.ReclaimPolicy(in.Spec.ReclaimPolicy), + Visibility: in.Spec.Visibility, + } + return nil +} +func convert_ipam_IPClass_To_v1alpha1(in *ipam.IPClass, out *IPClass) error { + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = IPClassSpec{ + Provisioner: in.Spec.Provisioner, + Parameters: toStringMap(in.Spec.Parameters), + IPFamily: IPFamily(in.Spec.IPFamily), + Strategy: Strategy(in.Spec.Strategy), + AllowedPrefixLengths: PrefixLengthRange{ + Min: in.Spec.AllowedPrefixLengths.Min, + Max: in.Spec.AllowedPrefixLengths.Max, + }, + DefaultPrefixLength: in.Spec.DefaultPrefixLength, + ReclaimPolicy: ReclaimPolicy(in.Spec.ReclaimPolicy), + Visibility: in.Spec.Visibility, + } + return nil +} + +func convert_v1alpha1_IPClassList_To_ipam(in *IPClassList, out *ipam.IPClassList) error { + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + out.Items = make([]ipam.IPClass, len(in.Items)) + for i := range in.Items { + if err := convert_v1alpha1_IPClass_To_ipam(&in.Items[i], &out.Items[i]); err != nil { + return err + } + } + } + return nil +} +func convert_ipam_IPClassList_To_v1alpha1(in *ipam.IPClassList, out *IPClassList) error { + out.TypeMeta = in.TypeMeta + out.ListMeta = in.ListMeta + if in.Items != nil { + out.Items = make([]IPClass, len(in.Items)) + for i := range in.Items { + if err := convert_ipam_IPClass_To_v1alpha1(&in.Items[i], &out.Items[i]); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/apis/ipam/v1alpha1/conversion_test.go b/pkg/apis/ipam/v1alpha1/conversion_test.go new file mode 100644 index 0000000..db4ee12 --- /dev/null +++ b/pkg/apis/ipam/v1alpha1/conversion_test.go @@ -0,0 +1,112 @@ +package v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.miloapis.com/ipam/pkg/apis/ipam" +) + +// TestIPClassRoundTrip verifies the IPClass converters preserve every spec +// field across external → internal → external. +func TestIPClassRoundTrip(t *testing.T) { + orig := &IPClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "public-egress", + Annotations: map[string]string{IsDefaultClassAnnotation: "true"}, + }, + Spec: IPClassSpec{ + Provisioner: NativeProvisioner, + Parameters: map[string]string{"k": "v"}, + IPFamily: IPv4, + Strategy: LeastUtilized, + AllowedPrefixLengths: PrefixLengthRange{Min: 24, Max: 28}, + DefaultPrefixLength: 26, + ReclaimPolicy: ReclaimRetain, + Visibility: "shared", + }, + } + + var internal ipam.IPClass + if err := convert_v1alpha1_IPClass_To_ipam(orig, &internal); err != nil { + t.Fatalf("to internal: %v", err) + } + if internal.Spec.Provisioner != NativeProvisioner || + internal.Spec.IPFamily != ipam.IPv4 || + internal.Spec.Strategy != ipam.LeastUtilized || + internal.Spec.AllowedPrefixLengths.Min != 24 || + internal.Spec.AllowedPrefixLengths.Max != 28 || + internal.Spec.DefaultPrefixLength != 26 || + internal.Spec.ReclaimPolicy != ipam.ReclaimRetain || + internal.Spec.Visibility != "shared" || + internal.Spec.Parameters["k"] != "v" { + t.Fatalf("internal spec mismatch: %+v", internal.Spec) + } + if internal.Annotations[ipam.IsDefaultClassAnnotation] != "true" { + t.Fatalf("default annotation not preserved to internal") + } + + var back IPClass + if err := convert_ipam_IPClass_To_v1alpha1(&internal, &back); err != nil { + t.Fatalf("to external: %v", err) + } + if back.Spec.Provisioner != orig.Spec.Provisioner || + back.Spec.IPFamily != orig.Spec.IPFamily || + back.Spec.Strategy != orig.Spec.Strategy || + back.Spec.AllowedPrefixLengths != orig.Spec.AllowedPrefixLengths || + back.Spec.DefaultPrefixLength != orig.Spec.DefaultPrefixLength || + back.Spec.ReclaimPolicy != orig.Spec.ReclaimPolicy || + back.Spec.Visibility != orig.Spec.Visibility || + back.Spec.Parameters["k"] != "v" { + t.Fatalf("round-trip spec mismatch:\n got %+v\n want %+v", back.Spec, orig.Spec) + } + if back.Annotations[IsDefaultClassAnnotation] != "true" { + t.Fatalf("default annotation not preserved on round-trip") + } +} + +// TestClassNameFieldsRoundTrip verifies the new className/classNames fields on +// the existing resources survive conversion in both directions. +func TestClassNameFieldsRoundTrip(t *testing.T) { + pool := &IPPool{Spec: IPPoolSpec{CIDR: "10.0.0.0/16", IPFamily: IPv4, ClassNames: []string{"a", "b"}}} + var poolInt ipam.IPPool + if err := convert_v1alpha1_IPPool_To_ipam(pool, &poolInt); err != nil { + t.Fatalf("pool to internal: %v", err) + } + if len(poolInt.Spec.ClassNames) != 2 || poolInt.Spec.ClassNames[0] != "a" || poolInt.Spec.ClassNames[1] != "b" { + t.Fatalf("pool classNames not preserved: %v", poolInt.Spec.ClassNames) + } + var poolBack IPPool + if err := convert_ipam_IPPool_To_v1alpha1(&poolInt, &poolBack); err != nil { + t.Fatalf("pool to external: %v", err) + } + if len(poolBack.Spec.ClassNames) != 2 { + t.Fatalf("pool classNames lost on round-trip: %v", poolBack.Spec.ClassNames) + } + + claim := &IPClaim{Spec: IPClaimSpec{IPFamily: IPv4, PrefixLength: 26, ClassName: "egress"}} + var claimInt ipam.IPClaim + if err := convert_v1alpha1_IPClaim_To_ipam(claim, &claimInt); err != nil { + t.Fatalf("claim to internal: %v", err) + } + if claimInt.Spec.ClassName != "egress" { + t.Fatalf("claim className not preserved: %q", claimInt.Spec.ClassName) + } + + alloc := &IPAllocation{Spec: IPAllocationSpec{IPFamily: IPv4, PoolRef: LocalRef{Name: "p"}, ClassName: "egress"}} + var allocInt ipam.IPAllocation + if err := convert_v1alpha1_IPAllocation_To_ipam(alloc, &allocInt); err != nil { + t.Fatalf("alloc to internal: %v", err) + } + if allocInt.Spec.ClassName != "egress" { + t.Fatalf("allocation className not preserved: %q", allocInt.Spec.ClassName) + } + var allocBack IPAllocation + if err := convert_ipam_IPAllocation_To_v1alpha1(&allocInt, &allocBack); err != nil { + t.Fatalf("alloc to external: %v", err) + } + if allocBack.Spec.ClassName != "egress" { + t.Fatalf("allocation className lost on round-trip: %q", allocBack.Spec.ClassName) + } +} diff --git a/pkg/apis/ipam/v1alpha1/register.go b/pkg/apis/ipam/v1alpha1/register.go index 3edad05..2c7ff4e 100644 --- a/pkg/apis/ipam/v1alpha1/register.go +++ b/pkg/apis/ipam/v1alpha1/register.go @@ -27,6 +27,7 @@ func addKnownTypes(scheme *runtime.Scheme) error { &IPPool{}, &IPPoolList{}, &IPAllocation{}, &IPAllocationList{}, &IPClaim{}, &IPClaimList{}, + &IPClass{}, &IPClassList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/pkg/apis/ipam/v1alpha1/types.go b/pkg/apis/ipam/v1alpha1/types.go index 507fa7e..f7c3858 100644 --- a/pkg/apis/ipam/v1alpha1/types.go +++ b/pkg/apis/ipam/v1alpha1/types.go @@ -94,6 +94,16 @@ const ( VisibilityShared string = "shared" ) +// NativeProvisioner is the built-in allocator that satisfies claims from the +// platform's own pools. It is the only provisioner shipped today and the +// default for any IPClass that omits spec.provisioner. +const NativeProvisioner = "ipam.miloapis.com/native" + +// IsDefaultClassAnnotation, when set to "true" on an IPClass, marks that class +// as the default used by claims that name neither a class nor a pool. At most +// one IPClass may carry this annotation. +const IsDefaultClassAnnotation = "ipam.miloapis.com/is-default-class" + // ObjectRef is an opaque cross-API reference. type ObjectRef struct { APIGroup string `json:"apiGroup"` @@ -163,6 +173,11 @@ type IPPoolSpec struct { // +optional // +kubebuilder:validation:Enum=platform;consumer;shared Visibility string `json:"visibility,omitempty"` + // classNames is the set of IPClass names this pool offers its capacity to. + // A claim naming one of these classes may be satisfied from this pool. + // +optional + // +listType=set + ClassNames []string `json:"classNames,omitempty"` } type IPPoolStatus struct { @@ -233,6 +248,11 @@ type IPAllocation struct { type IPAllocationSpec struct { IPFamily IPFamily `json:"ipFamily"` PoolRef LocalRef `json:"poolRef"` + // className records the IPClass a claim used to reach this allocation, + // empty when the claim named a pool or selector directly. Provenance only; + // it does not affect the allocation itself. + // +optional + ClassName string `json:"className,omitempty"` } type IPAllocationStatus struct { @@ -285,6 +305,11 @@ type IPClaimSpec struct { // +kubebuilder:validation:Minimum=0 // +kubebuilder:validation:Maximum=128 PrefixLength int `json:"prefixLength"` + // className selects an IPClass to satisfy this claim. Mutually exclusive + // with poolSelector and poolRef; when all three are empty the default + // class is used. Immutable after creation. + // +optional + ClassName string `json:"className,omitempty"` // +optional PoolSelector *PoolSelector `json:"poolSelector,omitempty"` // +optional @@ -315,3 +340,78 @@ type IPClaimList struct { metav1.ListMeta `json:"metadata,omitempty"` Items []IPClaim `json:"items"` } + +// ---------------------------------------------------------------------------- +// IPClass — cluster-scoped allocation policy, the analog of a StorageClass. +// ---------------------------------------------------------------------------- + +// PrefixLengthRange bounds the prefix sizes a class will hand out, inclusive. +type PrefixLengthRange struct { + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=128 + Min int `json:"min"` + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=128 + Max int `json:"max"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,shortName=ipclass +// +kubebuilder:printcolumn:name="Family",type=string,JSONPath=`.spec.ipFamily` +// +kubebuilder:printcolumn:name="Provisioner",type=string,JSONPath=`.spec.provisioner` +// +kubebuilder:printcolumn:name="Reclaim",type=string,JSONPath=`.spec.reclaimPolicy` +// +kubebuilder:printcolumn:name="Default",type=string,JSONPath=`.metadata.annotations.ipam\.miloapis\.com/is-default-class` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +genclient +// +genclient:nonNamespaced + +// IPClass names a kind of address space and the policy for allocating it. It +// carries no CIDRs — only rules and a pointer to the provisioner that +// satisfies claims of the class. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type IPClass struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec IPClassSpec `json:"spec,omitempty"` +} + +type IPClassSpec struct { + // provisioner is the allocator that satisfies claims of this class. + // Defaults to the native provisioner (ipam.miloapis.com/native). + // +optional + Provisioner string `json:"provisioner,omitempty"` + // parameters are opaque, provisioner-specific settings. + // +optional + Parameters map[string]string `json:"parameters,omitempty"` + // ipFamily is the single address family this class hands out. + // +optional + IPFamily IPFamily `json:"ipFamily,omitempty"` + // strategy selects how a free block is chosen from a backing pool. + // +optional + Strategy Strategy `json:"strategy,omitempty"` + // allowedPrefixLengths bounds the sizes a claim of this class may request. + // +optional + AllowedPrefixLengths PrefixLengthRange `json:"allowedPrefixLengths,omitempty"` + // defaultPrefixLength is used when a claim omits its prefix length. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=128 + DefaultPrefixLength int `json:"defaultPrefixLength,omitempty"` + // reclaimPolicy controls disposition of the allocation on claim deletion. + // +optional + // +kubebuilder:validation:Enum=Delete;Retain + ReclaimPolicy ReclaimPolicy `json:"reclaimPolicy,omitempty"` + // visibility reuses the pool sharing model: platform | consumer | shared. + // +optional + // +kubebuilder:validation:Enum=platform;consumer;shared + Visibility string `json:"visibility,omitempty"` +} + +// +kubebuilder:object:root=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type IPClassList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IPClass `json:"items"` +} diff --git a/pkg/apis/ipam/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/ipam/v1alpha1/zz_generated.deepcopy.go index e2b9898..7686d80 100644 --- a/pkg/apis/ipam/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/ipam/v1alpha1/zz_generated.deepcopy.go @@ -247,6 +247,90 @@ func (in *IPClaimStatus) DeepCopy() *IPClaimStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClass) DeepCopyInto(out *IPClass) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClass. +func (in *IPClass) DeepCopy() *IPClass { + if in == nil { + return nil + } + out := new(IPClass) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPClass) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClassList) DeepCopyInto(out *IPClassList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IPClass, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClassList. +func (in *IPClassList) DeepCopy() *IPClassList { + if in == nil { + return nil + } + out := new(IPClassList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPClassList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClassSpec) DeepCopyInto(out *IPClassSpec) { + *out = *in + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.AllowedPrefixLengths = in.AllowedPrefixLengths + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClassSpec. +func (in *IPClassSpec) DeepCopy() *IPClassSpec { + if in == nil { + return nil + } + out := new(IPClassSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPPool) DeepCopyInto(out *IPPool) { *out = *in @@ -317,6 +401,11 @@ func (in *IPPoolSpec) DeepCopyInto(out *IPPoolSpec) { **out = **in } out.Allocation = in.Allocation + if in.ClassNames != nil { + in, out := &in.ClassNames, &out.ClassNames + *out = make([]string, len(*in)) + copy(*out, *in) + } return } @@ -448,3 +537,19 @@ func (in *PoolSelector) DeepCopy() *PoolSelector { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrefixLengthRange) DeepCopyInto(out *PrefixLengthRange) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixLengthRange. +func (in *PrefixLengthRange) DeepCopy() *PrefixLengthRange { + if in == nil { + return nil + } + out := new(PrefixLengthRange) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/ipam/v1alpha1/zz_generated.model_name.go b/pkg/apis/ipam/v1alpha1/zz_generated.model_name.go index edc8915..0ec572f 100644 --- a/pkg/apis/ipam/v1alpha1/zz_generated.model_name.go +++ b/pkg/apis/ipam/v1alpha1/zz_generated.model_name.go @@ -50,6 +50,21 @@ func (in IPClaimStatus) OpenAPIModelName() string { return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.IPClaimStatus" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in IPClass) OpenAPIModelName() string { + return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.IPClass" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in IPClassList) OpenAPIModelName() string { + return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.IPClassList" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in IPClassSpec) OpenAPIModelName() string { + return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.IPClassSpec" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in IPPool) OpenAPIModelName() string { return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.IPPool" @@ -94,3 +109,8 @@ func (in PoolCapacity) OpenAPIModelName() string { func (in PoolSelector) OpenAPIModelName() string { return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.PoolSelector" } + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in PrefixLengthRange) OpenAPIModelName() string { + return "com.miloapis.go.ipam.pkg.apis.ipam.v1alpha1.PrefixLengthRange" +} diff --git a/pkg/apis/ipam/zz_generated.deepcopy.go b/pkg/apis/ipam/zz_generated.deepcopy.go index 0ac7cf7..d052a24 100644 --- a/pkg/apis/ipam/zz_generated.deepcopy.go +++ b/pkg/apis/ipam/zz_generated.deepcopy.go @@ -247,6 +247,90 @@ func (in *IPClaimStatus) DeepCopy() *IPClaimStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClass) DeepCopyInto(out *IPClass) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClass. +func (in *IPClass) DeepCopy() *IPClass { + if in == nil { + return nil + } + out := new(IPClass) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPClass) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClassList) DeepCopyInto(out *IPClassList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IPClass, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClassList. +func (in *IPClassList) DeepCopy() *IPClassList { + if in == nil { + return nil + } + out := new(IPClassList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IPClassList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IPClassSpec) DeepCopyInto(out *IPClassSpec) { + *out = *in + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + out.AllowedPrefixLengths = in.AllowedPrefixLengths + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPClassSpec. +func (in *IPClassSpec) DeepCopy() *IPClassSpec { + if in == nil { + return nil + } + out := new(IPClassSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IPPool) DeepCopyInto(out *IPPool) { *out = *in @@ -317,6 +401,11 @@ func (in *IPPoolSpec) DeepCopyInto(out *IPPoolSpec) { **out = **in } out.Allocation = in.Allocation + if in.ClassNames != nil { + in, out := &in.ClassNames, &out.ClassNames + *out = make([]string, len(*in)) + copy(*out, *in) + } return } @@ -448,3 +537,19 @@ func (in *PoolSelector) DeepCopy() *PoolSelector { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrefixLengthRange) DeepCopyInto(out *PrefixLengthRange) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixLengthRange. +func (in *PrefixLengthRange) DeepCopy() *PrefixLengthRange { + if in == nil { + return nil + } + out := new(PrefixLengthRange) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/client/clientset/versioned/fake/clientset_generated.go b/pkg/client/clientset/versioned/fake/clientset_generated.go index 817d916..8e5efab 100644 --- a/pkg/client/clientset/versioned/fake/clientset_generated.go +++ b/pkg/client/clientset/versioned/fake/clientset_generated.go @@ -18,10 +18,6 @@ import ( // It's backed by a very simple object tracker that processes creates, updates and deletions as-is, // without applying any field management, validations and/or defaults. It shouldn't be considered a replacement // for a real clientset and is mostly useful in simple unit tests. -// -// Deprecated: NewClientset replaces this with support for field management, which significantly improves -// server side apply testing. NewClientset is only available when apply configurations are generated (e.g. -// via --with-applyconfig). func NewSimpleClientset(objects ...runtime.Object) *Clientset { o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) for _, obj := range objects { diff --git a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipam_client.go b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipam_client.go index 05659ad..31d5506 100644 --- a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipam_client.go +++ b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipam_client.go @@ -20,6 +20,10 @@ func (c *FakeIpamV1alpha1) IPClaims(namespace string) v1alpha1.IPClaimInterface return newFakeIPClaims(c, namespace) } +func (c *FakeIpamV1alpha1) IPClasses() v1alpha1.IPClassInterface { + return newFakeIPClasses(c) +} + func (c *FakeIpamV1alpha1) IPPools() v1alpha1.IPPoolInterface { return newFakeIPPools(c) } diff --git a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipclass.go b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipclass.go new file mode 100644 index 0000000..e94bd33 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/fake/fake_ipclass.go @@ -0,0 +1,34 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + ipamv1alpha1 "go.miloapis.com/ipam/pkg/client/clientset/versioned/typed/ipam/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeIPClasses implements IPClassInterface +type fakeIPClasses struct { + *gentype.FakeClientWithList[*v1alpha1.IPClass, *v1alpha1.IPClassList] + Fake *FakeIpamV1alpha1 +} + +func newFakeIPClasses(fake *FakeIpamV1alpha1) ipamv1alpha1.IPClassInterface { + return &fakeIPClasses{ + gentype.NewFakeClientWithList[*v1alpha1.IPClass, *v1alpha1.IPClassList]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("ipclasses"), + v1alpha1.SchemeGroupVersion.WithKind("IPClass"), + func() *v1alpha1.IPClass { return &v1alpha1.IPClass{} }, + func() *v1alpha1.IPClassList { return &v1alpha1.IPClassList{} }, + func(dst, src *v1alpha1.IPClassList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.IPClassList) []*v1alpha1.IPClass { return gentype.ToPointerSlice(list.Items) }, + func(list *v1alpha1.IPClassList, items []*v1alpha1.IPClass) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/generated_expansion.go b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/generated_expansion.go index f7d7cff..975ebd7 100644 --- a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/generated_expansion.go +++ b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/generated_expansion.go @@ -6,4 +6,6 @@ type IPAllocationExpansion interface{} type IPClaimExpansion interface{} +type IPClassExpansion interface{} + type IPPoolExpansion interface{} diff --git a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipam_client.go b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipam_client.go index 936570e..bc4a41f 100644 --- a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipam_client.go +++ b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipam_client.go @@ -14,6 +14,7 @@ type IpamV1alpha1Interface interface { RESTClient() rest.Interface IPAllocationsGetter IPClaimsGetter + IPClassesGetter IPPoolsGetter } @@ -30,6 +31,10 @@ func (c *IpamV1alpha1Client) IPClaims(namespace string) IPClaimInterface { return newIPClaims(c, namespace) } +func (c *IpamV1alpha1Client) IPClasses() IPClassInterface { + return newIPClasses(c) +} + func (c *IpamV1alpha1Client) IPPools() IPPoolInterface { return newIPPools(c) } diff --git a/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipclass.go b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipclass.go new file mode 100644 index 0000000..dd91f0c --- /dev/null +++ b/pkg/client/clientset/versioned/typed/ipam/v1alpha1/ipclass.go @@ -0,0 +1,52 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + scheme "go.miloapis.com/ipam/pkg/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// IPClassesGetter has a method to return a IPClassInterface. +// A group's client should implement this interface. +type IPClassesGetter interface { + IPClasses() IPClassInterface +} + +// IPClassInterface has methods to work with IPClass resources. +type IPClassInterface interface { + Create(ctx context.Context, iPClass *ipamv1alpha1.IPClass, opts v1.CreateOptions) (*ipamv1alpha1.IPClass, error) + Update(ctx context.Context, iPClass *ipamv1alpha1.IPClass, opts v1.UpdateOptions) (*ipamv1alpha1.IPClass, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*ipamv1alpha1.IPClass, error) + List(ctx context.Context, opts v1.ListOptions) (*ipamv1alpha1.IPClassList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *ipamv1alpha1.IPClass, err error) + IPClassExpansion +} + +// iPClasses implements IPClassInterface +type iPClasses struct { + *gentype.ClientWithList[*ipamv1alpha1.IPClass, *ipamv1alpha1.IPClassList] +} + +// newIPClasses returns a IPClasses +func newIPClasses(c *IpamV1alpha1Client) *iPClasses { + return &iPClasses{ + gentype.NewClientWithList[*ipamv1alpha1.IPClass, *ipamv1alpha1.IPClassList]( + "ipclasses", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *ipamv1alpha1.IPClass { return &ipamv1alpha1.IPClass{} }, + func() *ipamv1alpha1.IPClassList { return &ipamv1alpha1.IPClassList{} }, + ), + } +} diff --git a/pkg/client/informers/externalversions/generic.go b/pkg/client/informers/externalversions/generic.go index aaa50d9..155188c 100644 --- a/pkg/client/informers/externalversions/generic.go +++ b/pkg/client/informers/externalversions/generic.go @@ -41,6 +41,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Ipam().V1alpha1().IPAllocations().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("ipclaims"): return &genericInformer{resource: resource.GroupResource(), informer: f.Ipam().V1alpha1().IPClaims().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("ipclasses"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Ipam().V1alpha1().IPClasses().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("ippools"): return &genericInformer{resource: resource.GroupResource(), informer: f.Ipam().V1alpha1().IPPools().Informer()}, nil diff --git a/pkg/client/informers/externalversions/ipam/v1alpha1/interface.go b/pkg/client/informers/externalversions/ipam/v1alpha1/interface.go index fa5e2e5..18a7bc6 100644 --- a/pkg/client/informers/externalversions/ipam/v1alpha1/interface.go +++ b/pkg/client/informers/externalversions/ipam/v1alpha1/interface.go @@ -12,6 +12,8 @@ type Interface interface { IPAllocations() IPAllocationInformer // IPClaims returns a IPClaimInformer. IPClaims() IPClaimInformer + // IPClasses returns a IPClassInformer. + IPClasses() IPClassInformer // IPPools returns a IPPoolInformer. IPPools() IPPoolInformer } @@ -37,6 +39,11 @@ func (v *version) IPClaims() IPClaimInformer { return &iPClaimInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// IPClasses returns a IPClassInformer. +func (v *version) IPClasses() IPClassInformer { + return &iPClassInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + // IPPools returns a IPPoolInformer. func (v *version) IPPools() IPPoolInformer { return &iPPoolInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} diff --git a/pkg/client/informers/externalversions/ipam/v1alpha1/ipclass.go b/pkg/client/informers/externalversions/ipam/v1alpha1/ipclass.go new file mode 100644 index 0000000..21c3590 --- /dev/null +++ b/pkg/client/informers/externalversions/ipam/v1alpha1/ipclass.go @@ -0,0 +1,85 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + apisipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + versioned "go.miloapis.com/ipam/pkg/client/clientset/versioned" + internalinterfaces "go.miloapis.com/ipam/pkg/client/informers/externalversions/internalinterfaces" + ipamv1alpha1 "go.miloapis.com/ipam/pkg/client/listers/ipam/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// IPClassInformer provides access to a shared informer and lister for +// IPClasses. +type IPClassInformer interface { + Informer() cache.SharedIndexInformer + Lister() ipamv1alpha1.IPClassLister +} + +type iPClassInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewIPClassInformer constructs a new informer for IPClass type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewIPClassInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredIPClassInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredIPClassInformer constructs a new informer for IPClass type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredIPClassInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IpamV1alpha1().IPClasses().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IpamV1alpha1().IPClasses().Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IpamV1alpha1().IPClasses().List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IpamV1alpha1().IPClasses().Watch(ctx, options) + }, + }, client), + &apisipamv1alpha1.IPClass{}, + resyncPeriod, + indexers, + ) +} + +func (f *iPClassInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredIPClassInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *iPClassInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apisipamv1alpha1.IPClass{}, f.defaultInformer) +} + +func (f *iPClassInformer) Lister() ipamv1alpha1.IPClassLister { + return ipamv1alpha1.NewIPClassLister(f.Informer().GetIndexer()) +} diff --git a/pkg/client/listers/ipam/v1alpha1/expansion_generated.go b/pkg/client/listers/ipam/v1alpha1/expansion_generated.go index bb1c070..a2f7027 100644 --- a/pkg/client/listers/ipam/v1alpha1/expansion_generated.go +++ b/pkg/client/listers/ipam/v1alpha1/expansion_generated.go @@ -18,6 +18,10 @@ type IPClaimListerExpansion interface{} // IPClaimNamespaceLister. type IPClaimNamespaceListerExpansion interface{} +// IPClassListerExpansion allows custom methods to be added to +// IPClassLister. +type IPClassListerExpansion interface{} + // IPPoolListerExpansion allows custom methods to be added to // IPPoolLister. type IPPoolListerExpansion interface{} diff --git a/pkg/client/listers/ipam/v1alpha1/ipclass.go b/pkg/client/listers/ipam/v1alpha1/ipclass.go new file mode 100644 index 0000000..cc11a25 --- /dev/null +++ b/pkg/client/listers/ipam/v1alpha1/ipclass.go @@ -0,0 +1,32 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + ipamv1alpha1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// IPClassLister helps list IPClasses. +// All objects returned here must be treated as read-only. +type IPClassLister interface { + // List lists all IPClasses in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*ipamv1alpha1.IPClass, err error) + // Get retrieves the IPClass from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*ipamv1alpha1.IPClass, error) + IPClassListerExpansion +} + +// iPClassLister implements the IPClassLister interface. +type iPClassLister struct { + listers.ResourceIndexer[*ipamv1alpha1.IPClass] +} + +// NewIPClassLister returns a new IPClassLister. +func NewIPClassLister(indexer cache.Indexer) IPClassLister { + return &iPClassLister{listers.New[*ipamv1alpha1.IPClass](indexer, ipamv1alpha1.Resource("ipclass"))} +} diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index d58ac80..8f1827c 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -26,6 +26,9 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA v1alpha1.IPClaimList{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClaimList(ref), v1alpha1.IPClaimSpec{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClaimSpec(ref), v1alpha1.IPClaimStatus{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClaimStatus(ref), + v1alpha1.IPClass{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClass(ref), + v1alpha1.IPClassList{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClassList(ref), + v1alpha1.IPClassSpec{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPClassSpec(ref), v1alpha1.IPPool{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPPool(ref), v1alpha1.IPPoolList{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPPoolList(ref), v1alpha1.IPPoolSpec{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_IPPoolSpec(ref), @@ -35,6 +38,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA v1alpha1.ObjectRef{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_ObjectRef(ref), v1alpha1.PoolCapacity{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_PoolCapacity(ref), v1alpha1.PoolSelector{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_PoolSelector(ref), + v1alpha1.PrefixLengthRange{}.OpenAPIModelName(): schema_pkg_apis_ipam_v1alpha1_PrefixLengthRange(ref), resource.Quantity{}.OpenAPIModelName(): schema_apimachinery_pkg_api_resource_Quantity(ref), v1.APIGroup{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroup(ref), v1.APIGroupList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroupList(ref), @@ -237,6 +241,13 @@ func schema_pkg_apis_ipam_v1alpha1_IPAllocationSpec(ref common.ReferenceCallback Ref: ref(v1alpha1.LocalRef{}.OpenAPIModelName()), }, }, + "className": { + SchemaProps: spec.SchemaProps{ + Description: "className records the IPClass a claim used to reach this allocation, empty when the claim named a pool or selector directly. Provenance only; it does not affect the allocation itself.", + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"ipFamily", "poolRef"}, }, @@ -408,6 +419,13 @@ func schema_pkg_apis_ipam_v1alpha1_IPClaimSpec(ref common.ReferenceCallback) com Format: "int32", }, }, + "className": { + SchemaProps: spec.SchemaProps{ + Description: "className selects an IPClass to satisfy this claim. Mutually exclusive with poolSelector and poolRef; when all three are empty the default class is used. Immutable after creation.", + Type: []string{"string"}, + Format: "", + }, + }, "poolSelector": { SchemaProps: spec.SchemaProps{ Ref: ref(v1alpha1.PoolSelector{}.OpenAPIModelName()), @@ -490,6 +508,174 @@ func schema_pkg_apis_ipam_v1alpha1_IPClaimStatus(ref common.ReferenceCallback) c } } +func schema_pkg_apis_ipam_v1alpha1_IPClass(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "IPClass names a kind of address space and the policy for allocating it. It carries no CIDRs — only rules and a pointer to the provisioner that satisfies claims of the class.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.IPClassSpec{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + Dependencies: []string{ + v1alpha1.IPClassSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + } +} + +func schema_pkg_apis_ipam_v1alpha1_IPClassList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.IPClass{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + v1alpha1.IPClass{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + } +} + +func schema_pkg_apis_ipam_v1alpha1_IPClassSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "provisioner": { + SchemaProps: spec.SchemaProps{ + Description: "provisioner is the allocator that satisfies claims of this class. Defaults to the native provisioner (ipam.miloapis.com/native).", + Type: []string{"string"}, + Format: "", + }, + }, + "parameters": { + SchemaProps: spec.SchemaProps{ + Description: "parameters are opaque, provisioner-specific settings.", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "ipFamily": { + SchemaProps: spec.SchemaProps{ + Description: "ipFamily is the single address family this class hands out.", + Type: []string{"string"}, + Format: "", + }, + }, + "strategy": { + SchemaProps: spec.SchemaProps{ + Description: "strategy selects how a free block is chosen from a backing pool.", + Type: []string{"string"}, + Format: "", + }, + }, + "allowedPrefixLengths": { + SchemaProps: spec.SchemaProps{ + Description: "allowedPrefixLengths bounds the sizes a claim of this class may request.", + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.PrefixLengthRange{}.OpenAPIModelName()), + }, + }, + "defaultPrefixLength": { + SchemaProps: spec.SchemaProps{ + Description: "defaultPrefixLength is used when a claim omits its prefix length.", + Type: []string{"integer"}, + Format: "int32", + }, + }, + "reclaimPolicy": { + SchemaProps: spec.SchemaProps{ + Description: "reclaimPolicy controls disposition of the allocation on claim deletion.", + Type: []string{"string"}, + Format: "", + }, + }, + "visibility": { + SchemaProps: spec.SchemaProps{ + Description: "visibility reuses the pool sharing model: platform | consumer | shared.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + Dependencies: []string{ + v1alpha1.PrefixLengthRange{}.OpenAPIModelName()}, + } +} + func schema_pkg_apis_ipam_v1alpha1_IPPool(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -626,6 +812,26 @@ func schema_pkg_apis_ipam_v1alpha1_IPPoolSpec(ref common.ReferenceCallback) comm Format: "", }, }, + "classNames": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "classNames is the set of IPClass names this pool offers its capacity to. A claim naming one of these classes may be satisfied from this pool.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, }, }, }, @@ -890,6 +1096,34 @@ func schema_pkg_apis_ipam_v1alpha1_PoolSelector(ref common.ReferenceCallback) co } } +func schema_pkg_apis_ipam_v1alpha1_PrefixLengthRange(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "PrefixLengthRange bounds the prefix sizes a class will hand out, inclusive.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "min": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "max": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + }, + Required: []string{"min", "max"}, + }, + }, + } +} + func schema_apimachinery_pkg_api_resource_Quantity(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.EmbedOpenAPIDefinitionIntoV2Extension(common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/test/e2e/ip-class/assertions/assert-default-class-claim-bound.yaml b/test/e2e/ip-class/assertions/assert-default-class-claim-bound.yaml new file mode 100644 index 0000000..4ce766d --- /dev/null +++ b/test/e2e/ip-class/assertions/assert-default-class-claim-bound.yaml @@ -0,0 +1,12 @@ +--- +# Bare claim resolved via the default class (e2e-internal-ipv4) → its backing +# pool ipclass-pool-default (10.141.0.0/20). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-default-class-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef.name != null): true + (regex_match('^10\.141\.\d+\.\d+/26$', allocatedCIDR)): true diff --git a/test/e2e/ip-class/assertions/assert-default-len-claim-bound.yaml b/test/e2e/ip-class/assertions/assert-default-len-claim-bound.yaml new file mode 100644 index 0000000..0584f55 --- /dev/null +++ b/test/e2e/ip-class/assertions/assert-default-len-claim-bound.yaml @@ -0,0 +1,13 @@ +--- +# prefixLength was omitted; the class default (/26) applies. Still drawn from +# the e2e-public-egress backing pool (10.140.0.0/20), non-overlapping with the +# earlier /26. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-default-len-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef.name != null): true + (regex_match('^10\.140\.\d+\.\d+/26$', allocatedCIDR)): true diff --git a/test/e2e/ip-class/assertions/assert-egress-claim-bound.yaml b/test/e2e/ip-class/assertions/assert-egress-claim-bound.yaml new file mode 100644 index 0000000..c605b71 --- /dev/null +++ b/test/e2e/ip-class/assertions/assert-egress-claim-bound.yaml @@ -0,0 +1,13 @@ +--- +# Claimed by class e2e-public-egress (/26). Must bind synchronously with a +# /26 drawn from the class's only backing pool (ipclass-pool-egress, +# 10.140.0.0/20). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-egress-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef.name != null): true + (regex_match('^10\.140\.\d+\.\d+/26$', allocatedCIDR)): true diff --git a/test/e2e/ip-class/assertions/assert-immutable-claim-bound.yaml b/test/e2e/ip-class/assertions/assert-immutable-claim-bound.yaml new file mode 100644 index 0000000..c01ebe3 --- /dev/null +++ b/test/e2e/ip-class/assertions/assert-immutable-claim-bound.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-immutable-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef.name != null): true + (regex_match('^10\.140\.\d+\.\d+/26$', allocatedCIDR)): true diff --git a/test/e2e/ip-class/assertions/assert-legacy-selector-claim-bound.yaml b/test/e2e/ip-class/assertions/assert-legacy-selector-claim-bound.yaml new file mode 100644 index 0000000..bd0689c --- /dev/null +++ b/test/e2e/ip-class/assertions/assert-legacy-selector-claim-bound.yaml @@ -0,0 +1,12 @@ +--- +# Backward-compat: poolSelector resolved onto the labelled legacy pool +# (ipclass-pool-legacy, 10.142.0.0/20). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-legacy-selector-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef.name != null): true + (starts_with(allocatedCIDR, '10.142.')): true diff --git a/test/e2e/ip-class/chainsaw-test.yaml b/test/e2e/ip-class/chainsaw-test.yaml new file mode 100644 index 0000000..f0af7bb --- /dev/null +++ b/test/e2e/ip-class/chainsaw-test.yaml @@ -0,0 +1,301 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: ip-class +spec: + description: | + IPClass policy-layer e2e suite (docs/enhancements/ip-class.md): + - Create IPClass objects; back them with IPPools via spec.classNames. + - Claim by spec.className → CIDR returned synchronously from a backing + pool; IPAllocation records the class (provenance). + - Class default prefix length applies when the claim omits prefixLength. + - Default-class path: a claim with no className/poolRef/poolSelector is + satisfied from the annotated default class. + - Error paths: class-not-found, no-pool-for-class, prefixLength outside + the class's allowedPrefixLengths, and className+poolRef being mutually + exclusive. + - spec.className is immutable after creation. + - Backward compat: the deprecated poolSelector path still resolves. + + SCOPE: className resolution this milestone spans the caller's own scope + PLUS the platform scope (a consumer project may claim from a platform-owned + class). This suite runs in the platform scope (no impersonation), so the + classes and pools here are platform-owned and every claim resolves against + them. Explicit cross-project shared-class claiming (projectRef + a + cross-project SAR) is DEFERRED to a follow-up milestone and is intentionally + not covered here. + + NOTE: field names follow the enhancement doc and match the generated types; + the default-class marker is the ipam.miloapis.com/is-default-class + annotation. Error strings are still being finalized, so the error-path + assertions use OR-alternatives that tolerate the final wording. + + steps: + - name: setup-classes-and-pools + description: Create the IPClasses and their backing IPPools; wait pools Ready + try: + - create: + file: test-data/classes.yaml + - create: + file: test-data/pools.yaml + - script: + timeout: 60s + content: | + set -e + for pool in ipclass-pool-egress ipclass-pool-default ipclass-pool-legacy; do + for i in $(seq 1 30); do + phase=$(kubectl get ippool "$pool" \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Ready" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Ready" ]; then + echo "FAIL: $pool not Ready after 30s (phase=$phase)" + exit 1 + fi + done + echo "OK all ip-class pools Ready" + check: + ($error == null): true + (contains($stdout, 'OK all ip-class pools Ready')): true + + - name: claim-by-class-name + description: | + Claim naming spec.className=e2e-public-egress binds synchronously to a + /26 from that class's backing pool (10.140.0.0/20). The generated + IPAllocation must record the class it was drawn from (provenance). + try: + - create: + file: test-data/claim-by-class.yaml + - script: + timeout: 45s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + for i in $(seq 1 30); do + phase=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-egress-claim \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Bound" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Bound" ]; then + echo "FAIL: ipclass-egress-claim not Bound after 30s (phase=$phase)" + exit 1 + fi + check: + ($error == null): true + - assert: + file: assertions/assert-egress-claim-bound.yaml + - script: + timeout: 30s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + # Provenance: the IPAllocation the claim bound to records the + # class name it was allocated from. + ref=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-egress-claim -o jsonpath='{.status.boundAllocationRef.name}') + if [ -z "$ref" ]; then + echo "FAIL: empty boundAllocationRef.name" + exit 1 + fi + class=$(kubectl get ipallocation -n "$NAMESPACE" "$ref" -o jsonpath='{.spec.className}' 2>/dev/null || echo "") + if [ "$class" != "e2e-public-egress" ]; then + echo "FAIL: IPAllocation $ref spec.className=$class (expected e2e-public-egress)" + exit 1 + fi + echo "OK IPAllocation $ref records className=e2e-public-egress" + check: + ($error == null): true + (contains($stdout, 'OK IPAllocation ')): true + + - name: class-default-prefix-length + description: | + Claim by class with prefixLength omitted → the class's + defaultPrefixLength (/26) is applied. + try: + - create: + file: test-data/claim-default-prefixlen.yaml + - script: + timeout: 45s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + for i in $(seq 1 30); do + phase=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-default-len-claim \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Bound" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Bound" ]; then + echo "FAIL: ipclass-default-len-claim not Bound after 30s (phase=$phase)" + exit 1 + fi + check: + ($error == null): true + - assert: + file: assertions/assert-default-len-claim-bound.yaml + + - name: default-class-path + description: | + A claim with no className, poolRef, or poolSelector is satisfied from + the annotated default class (e2e-internal-ipv4), drawing from its + backing pool (10.141.0.0/20). + try: + - create: + file: test-data/claim-default-class.yaml + - script: + timeout: 45s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + for i in $(seq 1 30); do + phase=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-default-class-claim \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Bound" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Bound" ]; then + echo "FAIL: ipclass-default-class-claim not Bound after 30s (phase=$phase)" + exit 1 + fi + check: + ($error == null): true + - assert: + file: assertions/assert-default-class-claim-bound.yaml + + - name: class-not-found + description: Claim naming a nonexistent class is rejected + try: + - create: + file: test-data/claim-class-not-found.yaml + expect: + - check: + ($error != null): true + (contains($error, 'not found') || contains($error, 'IPClass') || contains($error, 'e2e-does-not-exist') || contains($error, 'no such class')): true + + - name: no-pool-for-class + description: | + Claim naming a real class that no pool backs fails with a specific + "no pool backs class" error (distinct from 507 exhaustion). + try: + - create: + file: test-data/claim-no-pool-for-class.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no pool') || contains($error, 'no IPPool') || contains($error, 'backs class') || contains($error, 'e2e-orphan-class')): true + + - name: prefix-length-outside-class-bounds + description: | + Claim requesting /30 against a class whose allowedPrefixLengths is + 24–28 is rejected by class-bounds validation. + try: + - create: + file: test-data/claim-class-bad-length.yaml + expect: + - check: + ($error != null): true + (contains($error, 'prefixLength') || contains($error, 'allowed') || contains($error, 'allowedPrefixLengths') || contains($error, 'exceeds') || contains($error, '400')): true + + - name: classname-poolref-mutually-exclusive + description: Setting both className and poolRef is rejected + try: + - create: + file: test-data/claim-class-and-pool.yaml + expect: + - check: + ($error != null): true + (contains($error, 'mutually exclusive') || contains($error, 'at most one') || contains($error, 'className') || contains($error, '400')): true + + - name: classname-immutable + description: | + Bind a claim by class, then patch spec.className → rejected as + immutable. + try: + - create: + file: test-data/claim-immutable.yaml + - script: + timeout: 45s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + for i in $(seq 1 30); do + phase=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-immutable-claim \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Bound" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Bound" ]; then + echo "FAIL: ipclass-immutable-claim not Bound after 30s (phase=$phase)" + exit 1 + fi + check: + ($error == null): true + - assert: + file: assertions/assert-immutable-claim-bound.yaml + - patch: + file: test-data/patch-claim-classname.yaml + expect: + - check: + ($error != null): true + (contains($error, 'immutable') && contains($error, 'className')): true + + - name: legacy-selector-backward-compat + description: | + The deprecated poolSelector path still resolves — a claim with no + className but a matching label selector binds to the labelled pool. + try: + - create: + file: test-data/claim-by-selector.yaml + - script: + timeout: 45s + env: + - name: NAMESPACE + value: ($namespace) + content: | + set -e + for i in $(seq 1 30); do + phase=$(kubectl get ipclaim -n "$NAMESPACE" ipclass-legacy-selector-claim \ + -o jsonpath='{.status.phase}' 2>/dev/null || echo "") + if [ "$phase" = "Bound" ]; then break; fi + sleep 1 + done + if [ "$phase" != "Bound" ]; then + echo "FAIL: ipclass-legacy-selector-claim not Bound after 30s (phase=$phase)" + exit 1 + fi + check: + ($error == null): true + - assert: + file: assertions/assert-legacy-selector-claim-bound.yaml + + finally: + - script: + env: + - name: NAMESPACE + value: ($namespace) + content: | + kubectl delete ipclaim -n "$NAMESPACE" \ + ipclass-egress-claim ipclass-default-len-claim ipclass-default-class-claim \ + ipclass-immutable-claim ipclass-legacy-selector-claim \ + ipclass-notfound-claim ipclass-nopool-claim ipclass-badlen-claim ipclass-conflict-claim \ + --ignore-not-found >/dev/null 2>&1 || true + kubectl delete ippool \ + ipclass-pool-egress ipclass-pool-default ipclass-pool-legacy \ + --ignore-not-found >/dev/null 2>&1 || true + kubectl delete ipclass \ + e2e-internal-ipv4 e2e-public-egress e2e-orphan-class \ + --ignore-not-found >/dev/null 2>&1 || true + echo "ip-class cleanup done" + check: + ($error == null): true diff --git a/test/e2e/ip-class/test-data/claim-by-class.yaml b/test/e2e/ip-class/test-data/claim-by-class.yaml new file mode 100644 index 0000000..361c481 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-by-class.yaml @@ -0,0 +1,12 @@ +--- +# Claim by class name. The consumer names WHAT it wants (a public-egress /26) +# and never the pool. ipFamily is intentionally omitted — it comes from the +# class. The CIDR is returned synchronously in status on the create response. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-egress-claim + namespace: ($namespace) +spec: + className: e2e-public-egress + prefixLength: 26 diff --git a/test/e2e/ip-class/test-data/claim-by-selector.yaml b/test/e2e/ip-class/test-data/claim-by-selector.yaml new file mode 100644 index 0000000..8858b85 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-by-selector.yaml @@ -0,0 +1,16 @@ +--- +# Backward compatibility: the deprecated poolSelector path still resolves. No +# className is set; the label selector picks ipclass-pool-legacy +# (10.142.0.0/20). ipFamily is retained here to mirror a legacy claim exactly. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-legacy-selector-claim + namespace: ($namespace) +spec: + ipFamily: IPv4 + prefixLength: 24 + poolSelector: + matchLabels: + selection: ipclass-legacy + reclaimPolicy: Delete diff --git a/test/e2e/ip-class/test-data/claim-class-and-pool.yaml b/test/e2e/ip-class/test-data/claim-class-and-pool.yaml new file mode 100644 index 0000000..a261619 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-class-and-pool.yaml @@ -0,0 +1,13 @@ +--- +# Sets both className and poolRef. At most one selection mechanism +# (className / poolRef / poolSelector) is allowed, so this is rejected. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-conflict-claim + namespace: ($namespace) +spec: + className: e2e-public-egress + prefixLength: 26 + poolRef: + name: ipclass-pool-egress diff --git a/test/e2e/ip-class/test-data/claim-class-bad-length.yaml b/test/e2e/ip-class/test-data/claim-class-bad-length.yaml new file mode 100644 index 0000000..9fd53f1 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-class-bad-length.yaml @@ -0,0 +1,11 @@ +--- +# Requests a /30, outside the class's allowedPrefixLengths (24–28). Must be +# rejected by class-bounds validation. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-badlen-claim + namespace: ($namespace) +spec: + className: e2e-public-egress + prefixLength: 30 diff --git a/test/e2e/ip-class/test-data/claim-class-not-found.yaml b/test/e2e/ip-class/test-data/claim-class-not-found.yaml new file mode 100644 index 0000000..7bc5b92 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-class-not-found.yaml @@ -0,0 +1,11 @@ +--- +# Names a class that does not exist. Must be rejected with a class-not-found +# error, not a silent fallback to the default class. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-notfound-claim + namespace: ($namespace) +spec: + className: e2e-does-not-exist + prefixLength: 26 diff --git a/test/e2e/ip-class/test-data/claim-default-class.yaml b/test/e2e/ip-class/test-data/claim-default-class.yaml new file mode 100644 index 0000000..22c966a --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-default-class.yaml @@ -0,0 +1,11 @@ +--- +# The simplest possible claim: no className, no poolRef, no poolSelector. It +# must be satisfied from the default class (e2e-internal-ipv4), drawing from +# that class's backing pool (ipclass-pool-default, 10.141.0.0/20). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-default-class-claim + namespace: ($namespace) +spec: + prefixLength: 26 diff --git a/test/e2e/ip-class/test-data/claim-default-prefixlen.yaml b/test/e2e/ip-class/test-data/claim-default-prefixlen.yaml new file mode 100644 index 0000000..12176e2 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-default-prefixlen.yaml @@ -0,0 +1,10 @@ +--- +# Claim by class name with prefixLength omitted. The class's +# spec.defaultPrefixLength (/26) must be applied, so this binds to a /26. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-default-len-claim + namespace: ($namespace) +spec: + className: e2e-public-egress diff --git a/test/e2e/ip-class/test-data/claim-immutable.yaml b/test/e2e/ip-class/test-data/claim-immutable.yaml new file mode 100644 index 0000000..fa76dc9 --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-immutable.yaml @@ -0,0 +1,10 @@ +--- +# Bound first, then patched to change spec.className — which is immutable. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-immutable-claim + namespace: ($namespace) +spec: + className: e2e-public-egress + prefixLength: 26 diff --git a/test/e2e/ip-class/test-data/claim-no-pool-for-class.yaml b/test/e2e/ip-class/test-data/claim-no-pool-for-class.yaml new file mode 100644 index 0000000..4fba76c --- /dev/null +++ b/test/e2e/ip-class/test-data/claim-no-pool-for-class.yaml @@ -0,0 +1,12 @@ +--- +# Names a real class (e2e-orphan-class) that no pool backs. Must fail with a +# specific "no pool backs class" error — the misconfiguration signal, distinct +# from pool exhaustion (507). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-nopool-claim + namespace: ($namespace) +spec: + className: e2e-orphan-class + prefixLength: 26 diff --git a/test/e2e/ip-class/test-data/classes.yaml b/test/e2e/ip-class/test-data/classes.yaml new file mode 100644 index 0000000..6022077 --- /dev/null +++ b/test/e2e/ip-class/test-data/classes.yaml @@ -0,0 +1,56 @@ +--- +# The default class. Marked via the ipam.miloapis.com/is-default-class +# annotation (IsDefaultClassAnnotation), mirroring Kubernetes' +# storageclass.kubernetes.io/is-default-class convention. A claim that names +# no className, poolRef, or poolSelector is satisfied from this class. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClass +metadata: + name: e2e-internal-ipv4 + annotations: + ipam.miloapis.com/is-default-class: "true" +spec: + provisioner: ipam.miloapis.com/native + ipFamily: IPv4 + strategy: FirstFit + allowedPrefixLengths: + min: 24 + max: 28 + defaultPrefixLength: 26 + reclaimPolicy: Delete + visibility: consumer +--- +# A second, non-default class with a distinct reclaim policy. Backed by its +# own pool (ipclass-pool-egress) so class-scoped pool resolution has exactly +# one candidate. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClass +metadata: + name: e2e-public-egress +spec: + provisioner: ipam.miloapis.com/native + ipFamily: IPv4 + strategy: FirstFit + allowedPrefixLengths: + min: 24 + max: 28 + defaultPrefixLength: 26 + reclaimPolicy: Retain + visibility: consumer +--- +# A class no pool backs. Claims of this class must fail with a specific, +# actionable "no pool backs class" error rather than a generic exhaustion. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClass +metadata: + name: e2e-orphan-class +spec: + provisioner: ipam.miloapis.com/native + ipFamily: IPv4 + strategy: FirstFit + allowedPrefixLengths: + min: 24 + max: 28 + defaultPrefixLength: 26 + reclaimPolicy: Delete + visibility: consumer diff --git a/test/e2e/ip-class/test-data/patch-claim-classname.yaml b/test/e2e/ip-class/test-data/patch-claim-classname.yaml new file mode 100644 index 0000000..c10d9a9 --- /dev/null +++ b/test/e2e/ip-class/test-data/patch-claim-classname.yaml @@ -0,0 +1,10 @@ +--- +# Attempts to switch the bound claim to a different class. spec.className is +# fixed at creation, so the server rejects this as immutable. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipclass-immutable-claim + namespace: ($namespace) +spec: + className: e2e-internal-ipv4 diff --git a/test/e2e/ip-class/test-data/pools.yaml b/test/e2e/ip-class/test-data/pools.yaml new file mode 100644 index 0000000..1d8da60 --- /dev/null +++ b/test/e2e/ip-class/test-data/pools.yaml @@ -0,0 +1,48 @@ +--- +# Backs e2e-public-egress. spec.classNames is how a pool opts its capacity in +# to a class (the pool owner decides, not the class author). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: ipclass-pool-egress +spec: + cidr: 10.140.0.0/20 + ipFamily: IPv4 + visibility: consumer + classNames: [e2e-public-egress] + allocation: + minPrefixLength: 24 + maxPrefixLength: 28 + strategy: FirstFit +--- +# Backs the default class e2e-internal-ipv4. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: ipclass-pool-default +spec: + cidr: 10.141.0.0/20 + ipFamily: IPv4 + visibility: consumer + classNames: [e2e-internal-ipv4] + allocation: + minPrefixLength: 24 + maxPrefixLength: 28 + strategy: FirstFit +--- +# Carries NO classNames but is labelled, so the deprecated poolSelector path +# still resolves onto it — the backward-compatibility case. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: ipclass-pool-legacy + labels: + selection: ipclass-legacy +spec: + cidr: 10.142.0.0/20 + ipFamily: IPv4 + visibility: consumer + allocation: + minPrefixLength: 24 + maxPrefixLength: 28 + strategy: FirstFit diff --git a/test/load/Taskfile.yaml b/test/load/Taskfile.yaml index 795d712..818699c 100644 --- a/test/load/Taskfile.yaml +++ b/test/load/Taskfile.yaml @@ -78,15 +78,15 @@ tasks: | KUBECONFIG={{.KUBECONFIG}} kubectl apply -n ipam-system -f - k6:run: - desc: 'Trigger a single TestRun. Vars: TEST=setup|throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput' + desc: 'Trigger a single TestRun. Vars: TEST=setup|throughput|class-throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput' silent: true cmds: - | set -e TEST="{{.TEST | default "throughput"}}" case "$TEST" in - setup|throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput) ;; - *) echo "ERROR: TEST must be one of: setup, throughput, asn-throughput, exhaustion, reads, scale, address-concurrent, mixed-load, concurrent, cross-project-throughput, watch-latency, ipv6-throughput" >&2; exit 1 ;; + setup|throughput|class-throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput) ;; + *) echo "ERROR: TEST must be one of: setup, throughput, class-throughput, asn-throughput, exhaustion, reads, scale, address-concurrent, mixed-load, concurrent, cross-project-throughput, watch-latency, ipv6-throughput" >&2; exit 1 ;; esac KUBECONFIG={{.KUBECONFIG}} kubectl delete testrun.k6.io/ipam-perf-${TEST} -n ipam-system --ignore-not-found KUBECONFIG={{.KUBECONFIG}} kubectl apply -f {{.ROOT_DIR}}/config/components/k6-performance-tests/testruns/${TEST}.yaml @@ -96,7 +96,7 @@ tasks: echo " kubectl logs -n ipam-system -l k6_cr=ipam-perf-${TEST} -f" k6:logs: - desc: 'Tail logs from a TestRun. Vars: TEST=setup|throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput' + desc: 'Tail logs from a TestRun. Vars: TEST=setup|throughput|class-throughput|asn-throughput|exhaustion|reads|scale|address-concurrent|mixed-load|concurrent|cross-project-throughput|watch-latency|ipv6-throughput' silent: true cmds: - | @@ -166,6 +166,21 @@ tasks: --summary-export={{.RESULTS_DIR}}/prefix-throughput.json \ {{.K6_SRC_DIR}}/prefix-claim-throughput.js + class-throughput: + desc: 'Measure class-based (spec.className) prefix-claim throughput. Vars: VUS, DURATION, PROJECT_COUNT, NAMESPACE_COUNT' + silent: true + cmds: + - | + mkdir -p {{.RESULTS_DIR}} + k6 run \ + -e IPAM_API_URL={{.IPAM_API_URL}} \ + -e NAMESPACE_COUNT={{.NAMESPACE_COUNT | default "10"}} \ + -e PROJECT_COUNT={{.PROJECT_COUNT | default "5"}} \ + -e VUS={{.VUS | default "10"}} \ + -e DURATION={{.DURATION | default "2m"}} \ + --summary-export={{.RESULTS_DIR}}/class-throughput.json \ + {{.K6_SRC_DIR}}/class-claim-throughput.js + asn-throughput: desc: 'Measure ASN-claim creation throughput via classRef. Vars: VUS, DURATION' silent: true @@ -371,4 +386,10 @@ tasks: KUBECONFIG={{.KUBECONFIG}} kubectl delete asnpoolclass.ipam.miloapis.com perf-asn-classref --ignore-not-found || true KUBECONFIG={{.KUBECONFIG}} kubectl delete ippool.ipam.miloapis.com perf-host-claim-pool --ignore-not-found || true + echo "Deleting IPClass backing pools + class (class-claim-throughput.js setup leaks)..." + for n in $(seq 0 ${LAST}); do + KUBECONFIG={{.KUBECONFIG}} kubectl delete ippool.ipam.miloapis.com perf-class-pool-${n} --ignore-not-found || true + done + KUBECONFIG={{.KUBECONFIG}} kubectl delete ipclass.ipam.miloapis.com perf-class --ignore-not-found || true + echo "Cleanup complete." diff --git a/test/load/lib/ipam-client.js b/test/load/lib/ipam-client.js index 6c13833..1c2d2e2 100644 --- a/test/load/lib/ipam-client.js +++ b/test/load/lib/ipam-client.js @@ -131,6 +131,11 @@ export function asnPoolClassPath(name) { return name ? `/asnpoolclasses/${name}` : '/asnpoolclasses'; } +// IPClass is cluster-scoped (the platform-owned allocation policy object). +export function ipClassPath(name) { + return name ? `/ipclasses/${name}` : '/ipclasses'; +} + // --- Resource builders --- // ipPool builds an IPPool body. visibility defaults to 'consumer'. Set @@ -141,20 +146,77 @@ export function ipPool(name, cidr, { minLen = 20, maxLen = 28, strategy = 'FirstFit', + classNames = null, } = {}) { + const spec = { + cidr, + ipFamily, + visibility, + allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, + }; + // classNames lists the IPClasses this pool offers its capacity to. Only + // emitted when supplied so pools that predate IPClass stay byte-identical. + if (classNames && classNames.length) { + spec.classNames = classNames; + } return { apiVersion: `${API_GROUP}/${API_VERSION}`, kind: 'IPPool', metadata: { name }, + spec, + }; +} + +// ipClass builds an IPClass body — the platform-owned allocation policy that +// pools offer capacity to and claims select by name. Mirrors the shape in +// docs/enhancements/ip-class.md: policy + provisioner only, no CIDRs. +export function ipClass(name, { + provisioner = 'ipam.miloapis.com/native', + ipFamily = 'IPv4', + strategy = 'FirstFit', + minLen = 20, + maxLen = 28, + defaultPrefixLength = 28, + reclaimPolicy = 'Delete', + visibility = 'consumer', + isDefault = false, +} = {}) { + const metadata = { name }; + if (isDefault) { + metadata.annotations = { 'ipam.miloapis.com/is-default-class': 'true' }; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClass', + metadata, spec: { - cidr, + provisioner, ipFamily, + strategy, + allowedPrefixLengths: { min: minLen, max: maxLen }, + defaultPrefixLength, + reclaimPolicy, visibility, - allocation: { minPrefixLength: minLen, maxPrefixLength: maxLen, strategy }, }, }; } +// ipClaimWithClass builds an IPClaim that selects an IPClass by name. The +// server derives the address family and (when prefixLength is omitted) the +// size from the class, so neither is set here beyond the requested length. +export function ipClaimWithClass(ns, name, className, prefixLength, { reclaimPolicy = 'Delete' } = {}) { + const spec = { className, reclaimPolicy }; + if (prefixLength) { + spec.prefixLength = prefixLength; + } + return { + apiVersion: `${API_GROUP}/${API_VERSION}`, + kind: 'IPClaim', + metadata: { name, namespace: ns }, + spec, + }; +} + // ipClaim builds an IPClaim body. poolName is the IPPool name; the resulting // spec.poolRef is `{ name: poolName }` (same-project). export function ipClaim(ns, name, poolName, prefixLength, { ipFamily = 'IPv4', reclaimPolicy = 'Delete' } = {}) { @@ -381,6 +443,33 @@ export function deleteIPClaimForProject(ns, name, projectID) { return http.del(`${API_BASE}${ipClaimPath(ns, name)}`, null, params); } +// --- IPClass helpers --- + +export function createIPClass(name, opts) { + return ipamPost(ipClassPath(), ipClass(name, opts), 'ipclass_create'); +} + +export function getIPClass(name) { + return ipamGet(ipClassPath(name), 'ipclass_get'); +} + +export function listIPClasses() { + return ipamList(ipClassPath(), 'ipclass_list'); +} + +export function deleteIPClass(name) { + return ipamDelete(ipClassPath(name), 'ipclass_delete'); +} + +// createIPClaimWithClassForProject posts a class-selected IPClaim with the +// tenant headers for projectID. The pool is chosen server-side from the pools +// that back the class and are visible to the project. +export function createIPClaimWithClassForProject(ns, name, className, prefixLength, projectID, opts = {}) { + const body = ipClaimWithClass(ns, name, className, prefixLength, opts); + const params = withProjectTagged(projectID, 'ipclaim_create'); + return http.post(`${API_BASE}${ipClaimPath(ns)}`, JSON.stringify(body), params); +} + export function getIPClaimForProject(ns, name, projectID) { const params = withProjectTagged(projectID, 'ipclaim_get'); return http.get(`${API_BASE}${ipClaimPath(ns, name)}`, params); diff --git a/test/load/src/class-claim-throughput.js b/test/load/src/class-claim-throughput.js new file mode 100644 index 0000000..b182ccd --- /dev/null +++ b/test/load/src/class-claim-throughput.js @@ -0,0 +1,176 @@ +// class-claim-throughput.js +// +// Measures the IPClass hot path: IPClaim creation throughput and latency when +// claims select a class by name (spec.className) rather than naming a pool. +// This is the standard claim path introduced by the IPClass enhancement +// (docs/enhancements/ip-class.md) — the consumer names a *kind* of address +// space and IPAM picks the backing pool server-side. +// +// Each VU: pick a random project N, POST an IPClaim with className=perf-class +// under project N's tenant headers (no poolRef), record latency + +// success, then DELETE the claim. +// +// This script is self-contained: setup() provisions the class and one backing +// pool per project; teardown() removes them. Namespaces (ipam-perf-) are +// expected to already exist from setup-pools.js (task test/load:setup). +// +// Thresholds mirror prefix-claim-throughput.js (p95 < 500ms, success > 0.95) +// so the class path is held to the same bar as direct pool claims — the +// enhancement's scalability note is that class resolution adds only a lookup +// and a scoped pool search, not a change to the atomic allocation guarantee. +// +// Configuration: +// NAMESPACE_COUNT - Pool of namespaces (must match setup, default 10) +// PROJECT_COUNT - Number of perf projects (default 5) +// VUS - Concurrent virtual users (default 10) +// DURATION - Test duration (default 2m) +// PREFIX_LENGTH - Requested prefix size (default 28) +// IPAM_API_URL - Apiserver URL (default localhost:8001) + +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; +import { + createIPClass, + deleteIPClass, + createIPPool, + deleteIPPool, + createIPClaimWithClassForProject, + deleteIPClaimForProject, + nsFor, + projectIDFor, +} from '../lib/ipam-client.js'; + +const NAMESPACE_COUNT = parseInt(__ENV.NAMESPACE_COUNT || '10'); +const PROJECT_COUNT = parseInt(__ENV.PROJECT_COUNT || '5'); +const VUS = parseInt(__ENV.VUS || '10'); +const DURATION = __ENV.DURATION || '2m'; +const PREFIX_LENGTH = parseInt(__ENV.PREFIX_LENGTH || '28'); + +const CLASS_NAME = 'perf-class'; +// Allowed prefix lengths for the class; PREFIX_LENGTH must fall inside this. +const CLASS_MIN_LEN = 20; +const CLASS_MAX_LEN = 28; + +// backingPoolName / backingPoolCIDR give each project its own pool that offers +// capacity to perf-class. The 100.64.0.0/10 (CGNAT) block keeps these clear of +// the 10.x per-project pools that setup-pools.js provisions. +function backingPoolName(n) { + return `perf-class-pool-${n}`; +} +function backingPoolCIDR(n) { + return `100.${64 + (n % 64)}.0.0/16`; +} + +const claimCreateLatency = new Trend('ipam_claim_create_latency_ms', true); +const claimDeleteLatency = new Trend('ipam_claim_delete_latency_ms', true); +const claimSuccessRate = new Rate('ipam_claim_success_rate'); +const claimsCreated = new Counter('ipam_claims_created'); +const claimsDenied = new Counter('ipam_claims_denied'); +const claimErrors = new Counter('ipam_claim_errors'); + +export const options = { + insecureSkipTLSVerify: __ENV.K6_INSECURE_SKIP_TLS_VERIFY !== 'false', + scenarios: { + steady_throughput: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + tags: { scenario: 'steady' }, + }, + }, + thresholds: { + 'ipam_claim_create_latency_ms{phase:success}': ['p(95)<500', 'p(99)<2000'], + 'ipam_claim_success_rate': ['rate>0.95'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +export function setup() { + // Platform-owned policy object. visibility=consumer keeps it per-project, + // matching how the per-project backing pools below are scoped. + const c = createIPClass(CLASS_NAME, { + ipFamily: 'IPv4', + strategy: 'FirstFit', + minLen: CLASS_MIN_LEN, + maxLen: CLASS_MAX_LEN, + defaultPrefixLength: 28, + reclaimPolicy: 'Delete', + visibility: 'consumer', + }); + if (c.status !== 201 && c.status !== 409) { + throw new Error(`IPClass create failed: ${c.status} ${c.body}`); + } + + // One backing pool per project, each offering its capacity to the class. + let pools = 0; + for (let n = 0; n < PROJECT_COUNT; n++) { + const name = backingPoolName(n); + const r = createIPPool(name, backingPoolCIDR(n), { + ipFamily: 'IPv4', + visibility: 'consumer', + minLen: CLASS_MIN_LEN, + maxLen: CLASS_MAX_LEN, + strategy: 'FirstFit', + classNames: [CLASS_NAME], + }); + if (r.status === 201 || r.status === 409) { + pools++; + } else { + console.error(`backing pool ${name} create failed: ${r.status} ${r.body}`); + } + } + console.log(`setup complete: class ${CLASS_NAME}, ${pools}/${PROJECT_COUNT} backing pools`); + return { pools }; +} + +function recordCreate(res) { + const ok = check(res, { 'class claim created': (r) => r.status === 201 }); + if (ok) { + claimsCreated.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'success' }); + claimSuccessRate.add(1); + } else if (res.status === 507) { + claimsDenied.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'denied' }); + claimSuccessRate.add(0); + } else { + claimErrors.add(1); + claimCreateLatency.add(res.timings.duration, { phase: 'error' }); + claimSuccessRate.add(0); + if (__ITER < 5) { + console.error(`class claim error ${res.status}: ${res.body}`); + } + } + return ok; +} + +export default function () { + const ns = nsFor(Math.floor(Math.random() * NAMESPACE_COUNT)); + const claimName = `class-claim-${__VU}-${__ITER}`; + const projectIdx = Math.floor(Math.random() * PROJECT_COUNT); + const callerProject = projectIDFor(projectIdx); + + const createRes = createIPClaimWithClassForProject( + ns, + claimName, + CLASS_NAME, + PREFIX_LENGTH, + callerProject, + ); + + if (recordCreate(createRes)) { + const delRes = deleteIPClaimForProject(ns, claimName, callerProject); + claimDeleteLatency.add(delRes.timings.duration); + if (delRes.status !== 200 && delRes.status !== 202 && delRes.status !== 404) { + claimErrors.add(1); + } + } +} + +export function teardown() { + for (let n = 0; n < PROJECT_COUNT; n++) { + deleteIPPool(backingPoolName(n)); + } + deleteIPClass(CLASS_NAME); + console.log('teardown complete'); +} From c15291dffcc86364bf1888d774864c17af54b8c5 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Thu, 2 Jul 2026 08:10:27 -0500 Subject: [PATCH 2/2] test(ipam): add IPClass platform-scope e2e suite, class-throughput task, and store race-regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/e2e/ip-class-platform-scope: a consumer project claiming a platform-owned IPClass by spec.className binds to the platform pool across the caller+platform scope, with IPAllocation provenance and no use-grant required (validates the class resolution scoping decision). - Taskfile: test/load:class-throughput target for the class-based claim k6 script. - Race-regression tests over the store GetList decode->convert->encode and watch paths, plus the apiserver codec, all clean under -race — guarding the shared serving path IPClass conversion runs through. --- Taskfile.yaml | 5 + internal/apiserver/race_test.go | 75 +++++++++ .../storage/postgres/getlist_race_test.go | 128 +++++++++++++++ internal/storage/postgres/watch_race_test.go | 136 ++++++++++++++++ .../assert-consumer-claim-bound.yaml | 13 ++ .../assert-platform-pool-ready.yaml | 8 + .../chainsaw-test.yaml | 152 ++++++++++++++++++ .../resources/consumer-claim.yaml | 16 ++ .../resources/impersonation-rbac.yaml | 72 +++++++++ .../resources/platform-class.yaml | 19 +++ .../resources/platform-pool.yaml | 16 ++ 11 files changed, 640 insertions(+) create mode 100644 internal/apiserver/race_test.go create mode 100644 internal/storage/postgres/getlist_race_test.go create mode 100644 internal/storage/postgres/watch_race_test.go create mode 100644 test/e2e/ip-class-platform-scope/assertions/assert-consumer-claim-bound.yaml create mode 100644 test/e2e/ip-class-platform-scope/assertions/assert-platform-pool-ready.yaml create mode 100644 test/e2e/ip-class-platform-scope/chainsaw-test.yaml create mode 100644 test/e2e/ip-class-platform-scope/resources/consumer-claim.yaml create mode 100644 test/e2e/ip-class-platform-scope/resources/impersonation-rbac.yaml create mode 100644 test/e2e/ip-class-platform-scope/resources/platform-class.yaml create mode 100644 test/e2e/ip-class-platform-scope/resources/platform-pool.yaml diff --git a/Taskfile.yaml b/Taskfile.yaml index 32df53b..d77375c 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -186,6 +186,11 @@ tasks: cmds: - task: load:throughput + test/load:class-throughput: + desc: Run class-based (spec.className) prefix-claim throughput load test + cmds: + - task: load:class-throughput + test/load:asn-throughput: desc: Run ASN-claim throughput load test cmds: diff --git a/internal/apiserver/race_test.go b/internal/apiserver/race_test.go new file mode 100644 index 0000000..2bbf567 --- /dev/null +++ b/internal/apiserver/race_test.go @@ -0,0 +1,75 @@ +package apiserver + +import ( + "bytes" + "sync" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.miloapis.com/ipam/pkg/apis/ipam" +) + +// poolJSON is a v1alpha1 IPPool with classNames + conditions, the shape the +// perf read-spike lists over. +var poolJSON = []byte(`{"apiVersion":"ipam.miloapis.com/v1alpha1","kind":"IPPool",` + + `"metadata":{"name":"prod-backbone"},` + + `"spec":{"cidr":"10.0.0.0/16","ipFamily":"IPv4","classNames":["public-egress","internal-ipv4"]},` + + `"status":{"phase":"Ready","allocatedCIDR":"10.0.0.0/16","ipFamily":"IPv4",` + + `"conditions":[{"type":"Allocated","status":"True","reason":"AllocationSucceeded","message":"ready","lastTransitionTime":"2020-01-01T00:00:00Z"}]}}`) + +// TestConcurrentPoolCodec is the regression guard for the perf read-spike heap +// corruption ("found bad pointer in Go heap" in the IPPool conversion path). It +// exercises the LIST hot path — decode (v1alpha1 JSON → internal) and encode +// (internal → v1alpha1 wire) — through a SINGLE shared codec across many +// goroutines, exactly as the postgres store (GetList) and the apiserver response +// writer do under a read spike, with each goroutine owning its own fresh objects +// (as the real request path does). +// +// Invariant it locks in: with per-request (exclusively-owned) objects, concurrent +// decode+encode through the shared codec/scheme is race-free. The corruption only +// occurs if the SAME top-level object is encoded by two goroutines at once, +// because the versioning codec's encode path mutates the object's TypeMeta in +// place (SetGroupVersionKind + deferred restore) via the unsafe object convertor. +// The fix for the incident is therefore to never share a to-be-encoded object +// across request goroutines — which the store's fresh-object read path already +// guarantees, and this test enforces going forward. Run with -race. +func TestConcurrentPoolCodec(t *testing.T) { + codec := Codecs.LegacyCodec(Scheme.PrioritizedVersionsAllGroups()...) + + const goroutines = 48 + const iterations = 400 + + var wg sync.WaitGroup + errCh := make(chan error, goroutines) + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + // Decode: fresh internal object per row, like store.GetList. + into := &ipam.IPPool{} + if _, _, err := codec.Decode(poolJSON, nil, into); err != nil { + errCh <- err + return + } + // Build a list and encode it back to the wire, like the + // apiserver response writer serializing a LIST. + list := &ipam.IPPoolList{ + TypeMeta: metav1.TypeMeta{APIVersion: "ipam.miloapis.com/v1alpha1", Kind: "IPPoolList"}, + Items: []ipam.IPPool{*into, *into, *into}, + } + var buf bytes.Buffer + if err := codec.Encode(list, &buf); err != nil { + errCh <- err + return + } + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("codec round-trip error: %v", err) + } +} diff --git a/internal/storage/postgres/getlist_race_test.go b/internal/storage/postgres/getlist_race_test.go new file mode 100644 index 0000000..f0a0c18 --- /dev/null +++ b/internal/storage/postgres/getlist_race_test.go @@ -0,0 +1,128 @@ +package postgres + +import ( + "bytes" + "context" + "fmt" + "sync" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/storage" + + ipamapiserver "go.miloapis.com/ipam/internal/apiserver" + "go.miloapis.com/ipam/pkg/apis/ipam" + ipamv1 "go.miloapis.com/ipam/pkg/apis/ipam/v1alpha1" +) + +// TestGetListConvertRace reproduces the perf read-spike heap corruption path +// against a real Postgres: many goroutines concurrently drive +// Store.GetList (decode via the shared s.codec) and then encode the resulting +// internal list back to the v1alpha1 wire form (the convert_ipam_IPPool -> +// convert_ipam_IPPoolList path the GC caught). It uses the PRODUCTION codec +// (LegacyCodec over PrioritizedVersionsAllGroups) so the internal<->v1alpha1 +// conversion actually runs, unlike the v1alpha1<->v1alpha1 test codec. +// +// Run with -race. Skips when Docker is unavailable. +func TestGetListConvertRace(t *testing.T) { + db := startEphemeralPostgres(t) + + // Production codec: encode/decode round-trips through the internal type, + // exercising the hand-written conversions (incl. the IPPool ClassNames / + // Conditions copies) exactly as the aggregated apiserver does. + codec := ipamapiserver.Codecs.LegacyCodec(ipamapiserver.Scheme.PrioritizedVersionsAllGroups()...) + + ctx := context.Background() + + // Seed a batch of IPPool rows under a cluster-scoped key prefix, each with + // classNames + status conditions so the conversion copies real slices. + const pools = 12 + for i := 0; i < pools; i++ { + p := &ipamv1.IPPool{ + TypeMeta: metav1.TypeMeta{APIVersion: "ipam.miloapis.com/v1alpha1", Kind: "IPPool"}, + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("pool-%02d", i), Labels: map[string]string{"env": "perf"}}, + Spec: ipamv1.IPPoolSpec{ + CIDR: fmt.Sprintf("10.%d.0.0/16", i), + IPFamily: ipamv1.IPv4, + ClassNames: []string{"public-egress", "internal-ipv4"}, + }, + Status: ipamv1.IPPoolStatus{ + Phase: ipamv1.PoolReady, + AllocatedCIDR: fmt.Sprintf("10.%d.0.0/16", i), + IPFamily: ipamv1.IPv4, + Conditions: []metav1.Condition{{ + Type: "Allocated", Status: metav1.ConditionTrue, + Reason: "AllocationSucceeded", Message: "ready", + LastTransitionTime: metav1.Now(), + }}, + }, + } + var buf bytes.Buffer + if err := codec.Encode(p, &buf); err != nil { + t.Fatalf("encode seed pool: %v", err) + } + key := fmt.Sprintf("/ipam.miloapis.com/ippools/pool-%02d", i) + if _, err := db.ExecContext(ctx, + `INSERT INTO ipam_objects (key, kind, name, data, labels) VALUES ($1, 'IPPool', $2, $3, '{}')`, + key, fmt.Sprintf("pool-%02d", i), buf.Bytes(), + ); err != nil { + t.Fatalf("insert seed pool: %v", err) + } + } + + s := &Store{db: db, codec: codec, versioner: storage.APIObjectVersioner{}} + + // Protobuf encoder — the k8s aggregation clients request protobuf by + // default, so exercise that serializer path (flagged as a race candidate) + // alongside JSON. + pbInfo, ok := runtime.SerializerInfoForMediaType(ipamapiserver.Codecs.SupportedMediaTypes(), "application/vnd.kubernetes.protobuf") + if !ok { + t.Fatal("no protobuf serializer registered") + } + pbEncoder := ipamapiserver.Codecs.EncoderForVersion(pbInfo.Serializer, ipamv1.SchemeGroupVersion) + + const goroutines = 64 + const iterations = 60 + var wg sync.WaitGroup + errCh := make(chan error, goroutines) + for g := 0; g < goroutines; g++ { + useProto := g%2 == 0 + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + list := &ipam.IPPoolList{} + if err := s.GetList(ctx, "/ipam.miloapis.com/ippools", + storage.ListOptions{Recursive: true, Predicate: storage.Everything}, + list, + ); err != nil { + errCh <- fmt.Errorf("GetList: %w", err) + return + } + if len(list.Items) != pools { + errCh <- fmt.Errorf("got %d items, want %d", len(list.Items), pools) + return + } + // Encode the internal list back to the wire form — this is the + // convert_ipam_IPPoolList_To_v1alpha1 path the crash was in. + var buf bytes.Buffer + enc := runtime.Encoder(codec) + if useProto { + enc = pbEncoder + } + if err := enc.Encode(list, &buf); err != nil { + errCh <- fmt.Errorf("encode list: %w", err) + return + } + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatal(err) + } +} + +var _ = runtime.Object(nil) diff --git a/internal/storage/postgres/watch_race_test.go b/internal/storage/postgres/watch_race_test.go new file mode 100644 index 0000000..f307946 --- /dev/null +++ b/internal/storage/postgres/watch_race_test.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "bytes" + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/storage" + + ipamapiserver "go.miloapis.com/ipam/internal/apiserver" + "go.miloapis.com/ipam/pkg/apis/ipam" +) + +// TestWatchAndListConvertRace stresses the shared s.codec across every path +// that uses it concurrently — GetList decode, Create/Delete encode+decode, +// the watcher poll-loop decode, and consumer-side re-encode (the apiserver +// watch/list serializer). This is the broadest reproduction attempt for the +// perf-spike heap corruption: if any of these shares unsynchronized mutable +// state, -race names the conflict. Skips without Docker. +func TestWatchAndListConvertRace(t *testing.T) { + db := startEphemeralPostgres(t) + codec := ipamapiserver.Codecs.LegacyCodec(ipamapiserver.Scheme.PrioritizedVersionsAllGroups()...) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + store := NewWithWatchExclusions(db, codec, "", nil) // polling-only watcher + store.SetNewFunc(func() runtime.Object { return &ipam.IPPool{} }) + t.Cleanup(store.Stop) + + poolObj := func(i int) *ipam.IPPool { + return &ipam.IPPool{ + TypeMeta: metav1.TypeMeta{APIVersion: "ipam.miloapis.com/v1alpha1", Kind: "IPPool"}, + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("seed-%02d", i), Labels: map[string]string{"env": "perf"}}, + Spec: ipam.IPPoolSpec{CIDR: fmt.Sprintf("10.%d.0.0/16", i), IPFamily: ipam.IPv4, ClassNames: []string{"public-egress", "internal-ipv4"}}, + Status: ipam.IPPoolStatus{Phase: ipam.PoolReady, AllocatedCIDR: fmt.Sprintf("10.%d.0.0/16", i), IPFamily: ipam.IPv4, + Conditions: []metav1.Condition{{Type: "Allocated", Status: metav1.ConditionTrue, Reason: "ok", Message: "ready", LastTransitionTime: metav1.Now()}}}, + } + } + + // Seed baseline pools. + const seeds = 8 + for i := 0; i < seeds; i++ { + out := &ipam.IPPool{} + if err := store.Create(ctx, fmt.Sprintf("/ipam.miloapis.com/ippools/seed-%02d", i), poolObj(i), out, 0); err != nil { + t.Fatalf("seed create: %v", err) + } + } + + var wg sync.WaitGroup + var stop atomic.Bool + + // Watchers: each poll goroutine decodes changelog events via the shared + // codec; consumers re-encode them (the watch serializer path). + for w := 0; w < 6; w++ { + iface, err := store.Watch(ctx, "/ipam.miloapis.com/ippools", storage.ListOptions{Recursive: true, Predicate: storage.Everything}) + if err != nil { + t.Fatalf("watch: %v", err) + } + wg.Add(1) + go func() { + defer wg.Done() + defer iface.Stop() + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-iface.ResultChan(): + if !ok { + return + } + if ev.Object != nil { + var buf bytes.Buffer + _ = codec.Encode(ev.Object, &buf) + } + if stop.Load() { + return + } + } + } + }() + } + + // Readers: GetList decode + convert + encode. + for r := 0; r < 24; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for !stop.Load() { + list := &ipam.IPPoolList{} + if err := store.GetList(ctx, "/ipam.miloapis.com/ippools", storage.ListOptions{Recursive: true, Predicate: storage.Everything}, list); err != nil { + if ctx.Err() != nil { + return + } + continue + } + var buf bytes.Buffer + _ = codec.Encode(list, &buf) + } + }() + } + + // Writers: churn extra pools (ADDED/DELETED changelog) to feed watchers. + for wr := 0; wr < 4; wr++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + n := 0 + for !stop.Load() { + key := fmt.Sprintf("/ipam.miloapis.com/ippools/churn-%d-%d", id, n) + p := poolObj(id) + p.Name = fmt.Sprintf("churn-%d-%d", id, n) + out := &ipam.IPPool{} + if err := store.Create(ctx, key, p, out, 0); err != nil { + if ctx.Err() != nil { + return + } + continue + } + del := &ipam.IPPool{} + _ = store.Delete(ctx, key, del, nil, nil, nil, storage.DeleteOptions{}) + n++ + } + }(wr) + } + + time.Sleep(6 * time.Second) + stop.Store(true) + cancel() + wg.Wait() +} diff --git a/test/e2e/ip-class-platform-scope/assertions/assert-consumer-claim-bound.yaml b/test/e2e/ip-class-platform-scope/assertions/assert-consumer-claim-bound.yaml new file mode 100644 index 0000000..3ea6987 --- /dev/null +++ b/test/e2e/ip-class-platform-scope/assertions/assert-consumer-claim-bound.yaml @@ -0,0 +1,13 @@ +--- +# Read back under the SAME project-alpha scope the claim was created in. The +# consumer bound to the platform pool: a /26 inside 10.150.0.0/20 (third octet +# 0..15, /26 boundary in the fourth octet: 0, 64, 128, or 192). +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipc-consumer-claim + namespace: ($namespace) +status: + phase: Bound + (boundAllocationRef != null): true + (regex_match('^10\.150\.([0-9]|1[0-5])\.(0|64|128|192)/26$', allocatedCIDR)): true diff --git a/test/e2e/ip-class-platform-scope/assertions/assert-platform-pool-ready.yaml b/test/e2e/ip-class-platform-scope/assertions/assert-platform-pool-ready.yaml new file mode 100644 index 0000000..85905ca --- /dev/null +++ b/test/e2e/ip-class-platform-scope/assertions/assert-platform-pool-ready.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: ipc-platform-pool +status: + phase: Ready + (allocatedCIDR): "10.150.0.0/20" diff --git a/test/e2e/ip-class-platform-scope/chainsaw-test.yaml b/test/e2e/ip-class-platform-scope/chainsaw-test.yaml new file mode 100644 index 0000000..8c872fd --- /dev/null +++ b/test/e2e/ip-class-platform-scope/chainsaw-test.yaml @@ -0,0 +1,152 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: ip-class-platform-scope +spec: + # Tenant-scoped objects (the consumer claim) live in IPAM's postgres under a + # project prefix, not kube etcd; chainsaw's auto-cleanup runs on the platform + # cluster and cannot see them. Skip auto-cleanup and rely on the explicit + # finally deletes under the owning contexts. + skipDelete: true + description: | + Consumer→platform IPClass resolution (the primary IPClass use case). + + Validates core's caller-scope + platform-scope resolution end to end: a + CONSUMER project (project-alpha, via Kubernetes impersonation) claims a + PLATFORM-owned IPClass backed by a PLATFORM-owned IPPool, WITHOUT naming a + pool and WITHOUT any cross-project `use` grant. Class resolution searches + the caller's own scope PLUS the platform scope and falls back to the + platform class + platform pool. + + Impersonation mirrors the multi-tenant suite: the tenant-platform context + is unimpersonated (platform scope); tenant-project-alpha carries the + iam.miloapis.com/parent-* userextras that land in UserInfo.Extra, scoping + the claim to project-alpha. See test/e2e/lib/gen-impersonation-kubeconfig.sh. + + MILESTONE SCOPE: + * NO `use`/SAR gate on the platform-scope fallback — no grant is set up. + * visibility is intent/metadata only this milestone; resolution does NOT + gate on it, so this suite asserts a successful BIND and never a + visibility-based denial. + * Explicit foreign-project claiming (projectRef + cross-project SAR) is + deferred and is covered by neither this suite nor multi-tenant. + + Cluster-scoped RBAC here is uniquely named (ipc-*) so it never collides + with the multi-tenant suite's mt-* RBAC under parallel runs. + + clusters: + tenant-platform: + kubeconfig: ../.tenant-impersonation-ipc.kubeconfig + context: tenant-platform + project-alpha: + kubeconfig: ../.tenant-impersonation-ipc.kubeconfig + context: tenant-project-alpha + + timeouts: + apply: 30s + assert: 60s + cleanup: 90s + + steps: + - name: seed-rbac + description: | + Generate the impersonation kubeconfig this suite's clusters block + references, then create the impersonation + tenant-user RBAC. Done + in-suite (not only via `task e2e`) so `chainsaw test test/e2e/` works + standalone. chainsaw runs scripts with cwd = this suite dir, so cd to + the repo root where the source KUBECONFIG and lib paths resolve. The + per-suite output filename avoids a parallel-run write race. + try: + - script: + content: | + cd ../../.. + sh test/e2e/lib/gen-impersonation-kubeconfig.sh test/e2e/.tenant-impersonation-ipc.kubeconfig + check: + ($error == null): true + - create: + file: resources/impersonation-rbac.yaml + + - name: seed-platform-class-and-pool + description: | + Create the platform-owned IPClass and its backing IPPool under the + unimpersonated tenant-platform context (platform scope). Wait for the + pool to be Ready. + try: + - create: + cluster: tenant-platform + file: resources/platform-class.yaml + - create: + cluster: tenant-platform + file: resources/platform-pool.yaml + - assert: + cluster: tenant-platform + file: assertions/assert-platform-pool-ready.yaml + + - name: consumer-claims-platform-class + description: | + project-alpha claims the platform class by name and binds to the + platform pool (a /26 in 10.150.0.0/20), read back under the same + project-alpha scope. The bound IPAllocation records the class name + (provenance). + try: + - create: + cluster: project-alpha + file: resources/consumer-claim.yaml + - assert: + cluster: project-alpha + file: assertions/assert-consumer-claim-bound.yaml + - script: + timeout: 30s + env: + - name: NAMESPACE + value: ($namespace) + - name: KCFG + value: ../.tenant-impersonation-ipc.kubeconfig + content: | + set -e + # Read the bound allocation under the SAME project-alpha scope and + # confirm it records the platform class it was drawn from. + ref=$(kubectl --kubeconfig "$KCFG" --context tenant-project-alpha \ + get ipclaim -n "$NAMESPACE" ipc-consumer-claim \ + -o jsonpath='{.status.boundAllocationRef.name}') + if [ -z "$ref" ]; then + echo "FAIL: empty boundAllocationRef.name on ipc-consumer-claim" + exit 1 + fi + # Read without swallowing errors: a Forbidden/NotFound here must + # fail loudly (set -e), not masquerade as an empty className. + class=$(kubectl --kubeconfig "$KCFG" --context tenant-project-alpha \ + get ipallocation -n "$NAMESPACE" "$ref" \ + -o jsonpath='{.spec.className}') + if [ "$class" != "ipc-platform-egress" ]; then + echo "FAIL: IPAllocation $ref spec.className=$class (expected ipc-platform-egress)" + exit 1 + fi + echo "OK IPAllocation $ref records className=ipc-platform-egress" + check: + ($error == null): true + (contains($stdout, 'OK IPAllocation ')): true + finally: + - delete: + cluster: project-alpha + ref: + apiVersion: ipam.miloapis.com/v1alpha1 + kind: IPClaim + namespace: ($namespace) + name: ipc-consumer-claim + - script: + env: + - name: KCFG + value: ../.tenant-impersonation-ipc.kubeconfig + content: | + kubectl --kubeconfig "$KCFG" --context tenant-platform \ + delete ippool ipc-platform-pool --ignore-not-found >/dev/null 2>&1 || true + kubectl --kubeconfig "$KCFG" --context tenant-platform \ + delete ipclass ipc-platform-egress --ignore-not-found >/dev/null 2>&1 || true + kubectl --kubeconfig "$KCFG" --context tenant-platform \ + delete clusterrolebinding ipc-tenant-impersonator-binding ipc-tenant-ipam-user-binding --ignore-not-found >/dev/null 2>&1 || true + kubectl --kubeconfig "$KCFG" --context tenant-platform \ + delete clusterrole ipc-tenant-impersonator ipc-tenant-ipam-user --ignore-not-found >/dev/null 2>&1 || true + echo "ip-class-platform-scope cleanup done" + check: + ($error == null): true diff --git a/test/e2e/ip-class-platform-scope/resources/consumer-claim.yaml b/test/e2e/ip-class-platform-scope/resources/consumer-claim.yaml new file mode 100644 index 0000000..4f7f690 --- /dev/null +++ b/test/e2e/ip-class-platform-scope/resources/consumer-claim.yaml @@ -0,0 +1,16 @@ +--- +# Claimed by a CONSUMER project (project-alpha, via impersonation) naming a +# PLATFORM-owned class. The consumer never names a pool. Class resolution +# searches the caller's own (project-alpha) scope PLUS the platform scope and +# falls back to the platform class + platform pool. ipFamily is omitted — it +# comes from the class. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClaim +metadata: + name: ipc-consumer-claim + namespace: ($namespace) + labels: + ipc-suite: "true" +spec: + className: ipc-platform-egress + prefixLength: 26 diff --git a/test/e2e/ip-class-platform-scope/resources/impersonation-rbac.yaml b/test/e2e/ip-class-platform-scope/resources/impersonation-rbac.yaml new file mode 100644 index 0000000..892570e --- /dev/null +++ b/test/e2e/ip-class-platform-scope/resources/impersonation-rbac.yaml @@ -0,0 +1,72 @@ +# Impersonation + tenant-user RBAC for the consumer→platform IPClass suite. +# +# Uniquely named (ipc-*) so this suite's cluster-scoped RBAC never collides +# with the multi-tenant suite's mt-* RBAC when the two run in parallel. Both +# grant the SAME impersonated identity (e2e-tenant-tester); the grants are +# additive. +# +# This suite exercises the caller-scope→platform-scope FALLBACK: a consumer +# project (project-alpha) claims a PLATFORM-owned IPClass backed by a +# PLATFORM-owned IPPool. Per the milestone decision there is NO `use`/SAR gate +# on that fallback, so no cross-project grant is set up here — the tenant user +# just needs ordinary ipam access. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ipc-tenant-impersonator +rules: + # Impersonate the tenant user identity. + - apiGroups: [""] + resources: ["users"] + verbs: ["impersonate"] + resourceNames: ["e2e-tenant-tester"] + # Impersonate the iam.miloapis.com/parent-* userextras that carry the tenant + # scope into UserInfo.Extra. project-alpha needs no extra group. + - apiGroups: ["authentication.k8s.io"] + resources: + - "userextras/iam.miloapis.com/parent-name" + - "userextras/iam.miloapis.com/parent-type" + - "userextras/iam.miloapis.com/parent-api-group" + verbs: ["impersonate"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ipc-tenant-impersonator-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ipc-tenant-impersonator +subjects: + - kind: Group + apiGroup: rbac.authorization.k8s.io + name: system:masters +--- +# Ordinary ipam access for the impersonated tenant user. Includes read on +# ipclasses so class resolution has no RBAC surprises; NOT cluster admin and +# WITHOUT the `use` verb (the platform-scope fallback needs neither). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ipc-tenant-ipam-user +rules: + - apiGroups: ["ipam.miloapis.com"] + resources: ["ipclaims", "ipclaims/status"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["ipam.miloapis.com"] + resources: ["ippools", "ippools/status", "ipclasses", "ipallocations", "ipallocations/status"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ipc-tenant-ipam-user-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ipc-tenant-ipam-user +subjects: + - kind: User + apiGroup: rbac.authorization.k8s.io + name: e2e-tenant-tester diff --git a/test/e2e/ip-class-platform-scope/resources/platform-class.yaml b/test/e2e/ip-class-platform-scope/resources/platform-class.yaml new file mode 100644 index 0000000..9ab4812 --- /dev/null +++ b/test/e2e/ip-class-platform-scope/resources/platform-class.yaml @@ -0,0 +1,19 @@ +--- +# A PLATFORM-owned class (created under the unimpersonated tenant-platform +# context). visibility: shared expresses "offered to consumer projects". +# Visibility is intent/metadata this milestone — resolution does NOT gate on +# it — so this suite must not assert any visibility-based denial. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPClass +metadata: + name: ipc-platform-egress +spec: + provisioner: ipam.miloapis.com/native + ipFamily: IPv4 + strategy: FirstFit + allowedPrefixLengths: + min: 24 + max: 28 + defaultPrefixLength: 26 + reclaimPolicy: Delete + visibility: shared diff --git a/test/e2e/ip-class-platform-scope/resources/platform-pool.yaml b/test/e2e/ip-class-platform-scope/resources/platform-pool.yaml new file mode 100644 index 0000000..9e3bcf2 --- /dev/null +++ b/test/e2e/ip-class-platform-scope/resources/platform-pool.yaml @@ -0,0 +1,16 @@ +--- +# A PLATFORM-owned pool backing the platform class. Created under the +# tenant-platform (unimpersonated) context, so it lives in the platform scope. +apiVersion: ipam.miloapis.com/v1alpha1 +kind: IPPool +metadata: + name: ipc-platform-pool +spec: + cidr: 10.150.0.0/20 + ipFamily: IPv4 + visibility: platform + classNames: [ipc-platform-egress] + allocation: + minPrefixLength: 24 + maxPrefixLength: 28 + strategy: FirstFit