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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
204 changes: 204 additions & 0 deletions cmd/milo-ipam/class.go
Original file line number Diff line number Diff line change
@@ -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 <name>"; 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 <name>",
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)
}
146 changes: 146 additions & 0 deletions cmd/milo-ipam/class_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading