diff --git a/internal/registry/ipam/tableconvertor/columns.go b/internal/registry/ipam/tableconvertor/columns.go index 6bcb2d8..d1e558c 100644 --- a/internal/registry/ipam/tableconvertor/columns.go +++ b/internal/registry/ipam/tableconvertor/columns.go @@ -2,6 +2,7 @@ package tableconvertor import ( "fmt" + "math/big" "sort" "strings" @@ -36,17 +37,16 @@ func IPPools() rest.TableConvertor { nameColumn, {Name: "CIDR", Type: "string", Description: "Address range the pool holds."}, {Name: "Family", Type: "string", Description: "Address family of the pool."}, - {Name: "Classes", Type: "string", Description: "Classes this pool offers itself to."}, + {Name: "Class", Type: "string", Description: "Class this pool serves: the classes it offers itself to, or the class that provisioned it."}, {Name: "Utilization", Type: "string", Description: "Allocated share of the pool's address space."}, ageColumn, {Name: "Parent", Type: "string", Priority: wide, Description: "Pool this one was carved from."}, - {Name: "Class", Type: "string", Priority: wide, Description: "Class that provisioned this pool, if any."}, {Name: "Scope", Type: "string", Priority: wide, Description: "References this pool exists for."}, {Name: "Phase", Type: "string", Priority: wide, Description: "Current lifecycle phase."}, }, func(obj runtime.Object, name, age string) []interface{} { p, ok := obj.(*ipam.IPPool) if !ok { - return []interface{}{name, "", "", "", "", age, "", "", "", ""} + return []interface{}{name, "", "", "", "", age, "", "", ""} } // The carved range on a provisioned pool, the declared one on a root. cidr := p.Status.AllocatedCIDR @@ -61,11 +61,10 @@ func IPPools() rest.TableConvertor { name, orEmpty(cidr), orEmpty(string(family)), - orEmpty(strings.Join(p.Spec.ClassNames, ",")), - percent(p.Status.UtilizationPercent), + poolClass(p), + poolUtilization(p), age, localRefName(p.Spec.ParentPoolRef), - localRefName(p.Spec.ClassRef), formatScope(p.Spec.Scope), orEmpty(string(p.Status.Phase)), } @@ -214,18 +213,39 @@ func formatScope(scope map[string]ipam.ScopeRef) string { return strings.Join(cells, " ") } -// percent renders utilization to one decimal place. A pool that has handed out -// a /64 from a /32 is not empty, so a value that rounds to zero but is not zero -// prints as "<0.1%" rather than claiming the pool is untouched. -func percent(v float64) string { - switch { - case v <= 0: +// poolClass names the class a pool serves. Exactly one of these is set on any +// given pool: an operator-authored pool lists the classes it offers itself to, +// and a cascade-provisioned pool names the class that carved it. Reading only +// the first would print "" for every provisioned pool, which is most of +// them. +func poolClass(p *ipam.IPPool) string { + if len(p.Spec.ClassNames) > 0 { + return strings.Join(p.Spec.ClassNames, ",") + } + return localRefName(p.Spec.ClassRef) +} + +// poolUtilization renders the allocated share of a pool. +// +// status.utilizationPercent is rounded to four decimal places, and an IPv6 pool +// is big enough that a real allocation rounds to zero: a /48 carved from a /32 +// is 0.0000%. Whether the pool has been drawn on at all is therefore read from +// the capacity, which is exact, and the percentage is only used for how much. +func poolUtilization(p *ipam.IPPool) string { + if !allocatedAny(p.Status.Capacity.Allocated) { return "0%" - case v < 0.05: + } + if p.Status.UtilizationPercent < 0.05 { return "<0.1%" - default: - return fmt.Sprintf("%.1f%%", v) } + return fmt.Sprintf("%.1f%%", p.Status.UtilizationPercent) +} + +// allocatedAny reports whether a capacity count is a non-zero number. The counts +// are decimal strings because an IPv6 pool overflows every integer type. +func allocatedAny(s string) bool { + n, ok := new(big.Int).SetString(s, 10) + return ok && n.Sign() > 0 } func orEmpty(s string) string { diff --git a/internal/registry/ipam/tableconvertor/tableconvertor_test.go b/internal/registry/ipam/tableconvertor/tableconvertor_test.go index 87e478e..5ca86b4 100644 --- a/internal/registry/ipam/tableconvertor/tableconvertor_test.go +++ b/internal/registry/ipam/tableconvertor/tableconvertor_test.go @@ -109,7 +109,7 @@ func TestDefaultColumnsAreTheIdentifyingOnes(t *testing.T) { obj runtime.Object want []string }{ - {"ippools", IPPools(), &ipam.IPPool{}, []string{"Name", "CIDR", "Family", "Classes", "Utilization", "Age"}}, + {"ippools", IPPools(), &ipam.IPPool{}, []string{"Name", "CIDR", "Family", "Class", "Utilization", "Age"}}, {"ipclaims", IPClaims(), &ipam.IPClaim{}, []string{"Name", "Class", "Family", "Allocated", "Pool", "Phase", "Age"}}, {"ipallocations", IPAllocations(), &ipam.IPAllocation{}, []string{"Name", "Allocated", "Pool", "Class", "Purpose", "Phase", "Age"}}, {"ipclasses", IPClasses(), &ipam.IPClass{}, []string{"Name", "Family", "Parent", "Prefix", "Pool Per", "Phase", "Age"}}, @@ -186,22 +186,75 @@ func TestListCarriesPaginationState(t *testing.T) { } } -// A pool holding one /64 out of a /32 is not empty. Rounding it to 0% would -// say the pool is untouched when it is not. -func TestPercentDistinguishesEmptyFromNearlyEmpty(t *testing.T) { +// status.utilizationPercent is rounded to four decimal places, so a real +// allocation out of an IPv6 pool arrives here as a literal 0. Reading emptiness +// off that number reports every fabric-identity and subnet pool in staging as +// untouched when they are not; the capacity counts are exact and settle it. +func TestUtilizationReadsEmptinessFromCapacity(t *testing.T) { for _, tc := range []struct { - in float64 + name string + pool ipam.IPPool want string }{ - {0, "0%"}, - {0.0000001, "<0.1%"}, - {0.04, "<0.1%"}, - {12.34, "12.3%"}, - {100, "100.0%"}, + { + name: "never drawn on", + pool: ipam.IPPool{Status: ipam.IPPoolStatus{ + Capacity: ipam.PoolCapacity{Allocated: "0", Total: "18446744073709551616"}, + }}, + want: "0%", + }, + { + name: "drawn on, but rounds to zero: a /48 out of a /32", + pool: ipam.IPPool{Status: ipam.IPPoolStatus{ + UtilizationPercent: 0, + Capacity: ipam.PoolCapacity{Allocated: "1033017668127734890496", Total: "79228162514264337593543950336"}, + }}, + want: "<0.1%", + }, + { + name: "measurable", + pool: ipam.IPPool{Status: ipam.IPPoolStatus{ + UtilizationPercent: 12.34, + Capacity: ipam.PoolCapacity{Allocated: "64", Total: "512"}, + }}, + want: "12.3%", + }, + { + name: "full", + pool: ipam.IPPool{Status: ipam.IPPoolStatus{ + UtilizationPercent: 100, + Capacity: ipam.PoolCapacity{Allocated: "512", Total: "512"}, + }}, + want: "100.0%", + }, + { + name: "no capacity reported at all", + pool: ipam.IPPool{Status: ipam.IPPoolStatus{}}, + want: "0%", + }, } { - if got := percent(tc.in); got != tc.want { - t.Errorf("percent(%v) = %q, want %q", tc.in, got, tc.want) - } + t.Run(tc.name, func(t *testing.T) { + if got := poolUtilization(&tc.pool); got != tc.want { + t.Errorf("poolUtilization = %q, want %q", got, tc.want) + } + }) + } +} + +// Exactly one of classNames and classRef is set on any given pool. Printing +// only classNames showed "" for every cascade-provisioned pool, which in +// staging is nearly all of them. +func TestClassColumnIsPopulatedOnBothKindsOfPool(t *testing.T) { + operator := &ipam.IPPool{Spec: ipam.IPPoolSpec{ClassNames: []string{"datum-fabric-identity"}}} + if got := poolClass(operator); got != "datum-fabric-identity" { + t.Errorf("operator-authored pool class = %q, want the class it offers itself to", got) + } + provisioned := &ipam.IPPool{Spec: ipam.IPPoolSpec{ClassRef: &ipam.LocalRef{Name: "datum-subnet-ipv6"}}} + if got := poolClass(provisioned); got != "datum-subnet-ipv6" { + t.Errorf("provisioned pool class = %q, want the class that carved it", got) + } + if got := poolClass(&ipam.IPPool{}); got != "" { + t.Errorf("pool with neither = %q, want ", got) } }