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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
# Hash of Go module dependencies.
# Update this after changing go.mod/go.sum:
# task nix-update-hash
vendorHash = "sha256-+VvpuUSdZqqfz6CzCjrgDxN8QTYaFO8fp9GiASiaY8k=";
vendorHash = "sha256-I9FLS+nvDZoQTF4BYG3MBgAhukLQkdIv2LNxPHABvZo=";

env.CGO_ENABLED = 0;

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.miloapis.com/service-catalog v0.3.2-0.20260710002135-ef9a1b2c5b20
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ go.miloapis.com/activity v0.7.1 h1:e2c4xVISMA10QQJwPv8pT8U1T1xS3eTGhy8FJHXOKvY=
go.miloapis.com/activity v0.7.1/go.mod h1:Sh2Irbq6siJcfq17nLjHvm4JHN/2Csc5YCHB+ycz20c=
go.miloapis.com/milo v0.29.3 h1:x9dK3gKNtY8VERK2b0DkYpjekiOh3U42kvT5FiYtrXM=
go.miloapis.com/milo v0.29.3/go.mod h1:p9O2kk194mvoL8rhqjwb+LWB+GIyY4vQqiTowwibVWo=
go.miloapis.com/service-catalog v0.3.2-0.20260710002135-ef9a1b2c5b20 h1:KiTartmhinf9VB79bzblA+/KbkV58YjO0oakpNuSGI8=
go.miloapis.com/service-catalog v0.3.2-0.20260710002135-ef9a1b2c5b20/go.mod h1:znOMOYlmNfQmIvS/7ZpaI909DtLfKEvFe5QK9CgO8GE=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
Expand Down
118 changes: 118 additions & 0 deletions serviceactivation/classify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package serviceactivation

import (
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1"
)

const (
// conditionTypeReady is the single condition type the entitlement controller
// writes. It is unexported by service-catalog today (the RFC's one upstream
// ask is to promote it to api/v1alpha1); mirror it here until then.
conditionTypeReady = "Ready"

// reasonServiceNotPublished is the only Ready reason that branches control
// flow: it maps a Rejected entitlement to Unavailable rather than Denied.
// All other reasons refine wording only.
reasonServiceNotPublished = "ServiceNotPublished"
)

// Observation is the raw input Classify reduces to a State: the selected
// entitlement (nil when none was found) and whether the catalog API group is
// absent. It exists so classification stays a pure, table-testable function.
type Observation struct {
// Entitlement is the entitlement selected for this service, or nil if the
// catalog is reachable but no matching entitlement exists.
Entitlement *servicesv1alpha1.ServiceEntitlement

// CatalogAbsent is true when the services.miloapis.com API group is not
// served — the one unavailability knowable before any create.
CatalogAbsent bool
}

// Classify reduces an Observation to a CLI State. It reads only phase, the Ready
// condition reason, and entitledAt — never the root Service's policy.
func Classify(obs Observation) State {
if obs.CatalogAbsent {
return StateCatalogUnavailable
}
e := obs.Entitlement
if e == nil {
return StateNotRequested
}

ready := apimeta.FindStatusCondition(e.Status.Conditions, conditionTypeReady)
if e.Status.Phase == "" || ready == nil {
// The operator has not written status yet.
return StateProcessing
}

switch e.Status.Phase {
case servicesv1alpha1.EntitlementPhaseActive:
return StateActive
case servicesv1alpha1.EntitlementPhasePendingApproval:
return StatePendingApproval
case servicesv1alpha1.EntitlementPhaseRejected:
// ServiceNotPublished is checked before the Denied/Revoked split.
if ready.Reason == reasonServiceNotPublished {
return StateUnavailable
}
// entitledAt is written once on first activation and never cleared, so it
// — not the reason — distinguishes a revocation from an initial denial.
if e.Status.EntitledAt != nil {
return StateRevoked
}
return StateDenied
default:
// Unknown phase: treat as still processing rather than inventing a state.
return StateProcessing
}
}

// SelectEntitlement picks the entitlement representing the configured service
// from a list, preferring a canonical-name match over the object-name fallback.
// It returns nil when none matches.
//
// The canonical identity would ideally come from a controller-stamped
// status.serviceName, but that field does not yet exist on the API; until it
// does, canonicalNameOf reads spec.serviceRef.name, which dependency-origin
// entitlements carry as the canonical name.
func SelectEntitlement(list *servicesv1alpha1.ServiceEntitlementList, cfg Config) *servicesv1alpha1.ServiceEntitlement {
if list == nil {
return nil
}
var fallback *servicesv1alpha1.ServiceEntitlement
for i := range list.Items {
item := &list.Items[i]
if canonicalNameOf(item) == cfg.CanonicalName {
return item
}
if item.Spec.ServiceRef.Name == cfg.ObjectName && fallback == nil {
fallback = item
}
}
return fallback
}

// canonicalNameOf returns the best-known canonical service identity for an
// entitlement. status.serviceName is not yet part of the API, so this reads the
// spec reference; update it to prefer status.serviceName once that lands.
func canonicalNameOf(e *servicesv1alpha1.ServiceEntitlement) string {
return e.Spec.ServiceRef.Name
}

// catalogAbsent reports whether a List error means the services API group is
// not served, as opposed to a transient or permission error.
func catalogAbsent(err error) bool {
return apimeta.IsNoMatchError(err)
}

// readyCondition returns the entitlement's Ready condition, or nil.
func readyCondition(e *servicesv1alpha1.ServiceEntitlement) *metav1.Condition {
if e == nil {
return nil
}
return apimeta.FindStatusCondition(e.Status.Conditions, conditionTypeReady)
}
198 changes: 198 additions & 0 deletions serviceactivation/classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package serviceactivation

import (
"testing"
"time"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

servicesv1alpha1 "go.miloapis.com/service-catalog/api/v1alpha1"
)

// testConfig mirrors the compute plugin's configuration.
var testConfig = Config{
ObjectName: "compute",
CanonicalName: "compute.datumapis.com",
DisplayName: "Compute",
AccessCommand: "datumctl compute access",
}

// Reason and message strings below mirror exactly what the service-catalog
// serviceentitlement controller writes (internal/controller/serviceentitlement_controller.go),
// so the fixtures match production object shape rather than an invented one.
const (
reasonEntitlementActive = "EntitlementActive"
reasonEntitlementPendingApproval = "EntitlementPendingApproval"
reasonEntitlementRejected = "EntitlementRejected"
// reasonConsumerDenied is a transient relay reason no state may key on.
reasonConsumerDenied = "ConsumerDenied"
)

// entitlement builds a ServiceEntitlement with a Ready condition, matching the
// controller's setEntitlementStatus write path.
func entitlement(spec string, phase servicesv1alpha1.EntitlementPhase, reason, message string, entitledAt *metav1.Time) *servicesv1alpha1.ServiceEntitlement {
e := &servicesv1alpha1.ServiceEntitlement{
ObjectMeta: metav1.ObjectMeta{Name: "compute"},
Spec: servicesv1alpha1.ServiceEntitlementSpec{ServiceRef: servicesv1alpha1.ServiceRef{Name: spec}},
Status: servicesv1alpha1.ServiceEntitlementStatus{
Phase: phase,
EntitledAt: entitledAt,
},
}
status := metav1.ConditionFalse
if phase == servicesv1alpha1.EntitlementPhaseActive {
status = metav1.ConditionTrue
}
e.Status.Conditions = []metav1.Condition{{
Type: conditionTypeReady,
Status: status,
Reason: reason,
Message: message,
}}
return e
}

func TestClassify(t *testing.T) {
now := metav1.Now()

tests := []struct {
name string
obs Observation
want State
}{
{
name: "catalog API group absent",
obs: Observation{CatalogAbsent: true},
want: StateCatalogUnavailable,
},
{
name: "no entitlement found",
obs: Observation{Entitlement: nil},
want: StateNotRequested,
},
{
name: "created but no status yet",
obs: Observation{Entitlement: &servicesv1alpha1.ServiceEntitlement{
ObjectMeta: metav1.ObjectMeta{Name: "compute"},
Spec: servicesv1alpha1.ServiceEntitlementSpec{ServiceRef: servicesv1alpha1.ServiceRef{Name: "compute"}},
}},
want: StateProcessing,
},
{
name: "phase set but Ready condition not yet written",
obs: Observation{Entitlement: &servicesv1alpha1.ServiceEntitlement{
ObjectMeta: metav1.ObjectMeta{Name: "compute"},
Status: servicesv1alpha1.ServiceEntitlementStatus{Phase: servicesv1alpha1.EntitlementPhasePendingApproval},
}},
want: StateProcessing,
},
{
name: "active",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseActive,
reasonEntitlementActive, "This service is enabled and ready to use.", &now)},
want: StateActive,
},
{
name: "pending approval",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhasePendingApproval,
reasonEntitlementPendingApproval, "Waiting for the service provider to approve this request.", nil)},
want: StatePendingApproval,
},
{
name: "denied: rejected with entitledAt unset",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseRejected,
reasonEntitlementRejected, "The service provider denied this request.", nil)},
want: StateDenied,
},
{
name: "revoked: rejected with entitledAt set",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseRejected,
reasonEntitlementRejected, "The service provider denied this request.", &now)},
want: StateRevoked,
},
{
name: "revoked keys on entitledAt even when reason is the transient ConsumerDenied relay",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseRejected,
reasonConsumerDenied, "The service provider denied this request.", &now)},
want: StateRevoked,
},
{
name: "unavailable: rejected with ServiceNotPublished (service missing)",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseRejected,
reasonServiceNotPublished, "The requested service could not be found.", nil)},
want: StateUnavailable,
},
{
name: "unavailable takes precedence over the revoked split even when entitledAt is set",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseRejected,
reasonServiceNotPublished, "The service \"compute.datumapis.com\" isn't published yet, so it can't be enabled.", &now)},
want: StateUnavailable,
},
{
name: "active classification ignores a transient relay reason",
obs: Observation{Entitlement: entitlement("compute", servicesv1alpha1.EntitlementPhaseActive,
"ConsumerApproved", "This service is enabled and ready to use.", &now)},
want: StateActive,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Classify(tc.obs); got != tc.want {
t.Fatalf("Classify() = %q, want %q", got, tc.want)
}
})
}
}

func TestSelectEntitlement(t *testing.T) {
direct := entitlement("compute", servicesv1alpha1.EntitlementPhaseActive, reasonEntitlementActive, "ok", nil)
direct.Name = "compute"
canonical := entitlement("compute.datumapis.com", servicesv1alpha1.EntitlementPhasePendingApproval, reasonEntitlementPendingApproval, "waiting", nil)
canonical.Name = "compute.datumapis.com"
other := entitlement("network", servicesv1alpha1.EntitlementPhaseActive, reasonEntitlementActive, "ok", nil)
other.Name = "network"

t.Run("object-name match when only the direct entitlement exists", func(t *testing.T) {
list := &servicesv1alpha1.ServiceEntitlementList{Items: []servicesv1alpha1.ServiceEntitlement{*other, *direct}}
got := SelectEntitlement(list, testConfig)
if got == nil || got.Spec.ServiceRef.Name != "compute" {
t.Fatalf("SelectEntitlement() = %+v, want the compute (object-name) entitlement", got)
}
})

t.Run("canonical-name match is preferred over the object-name fallback", func(t *testing.T) {
list := &servicesv1alpha1.ServiceEntitlementList{Items: []servicesv1alpha1.ServiceEntitlement{*direct, *canonical}}
got := SelectEntitlement(list, testConfig)
if got == nil || got.Spec.ServiceRef.Name != "compute.datumapis.com" {
t.Fatalf("SelectEntitlement() = %+v, want the canonical-name entitlement", got)
}
})

t.Run("no match returns nil", func(t *testing.T) {
list := &servicesv1alpha1.ServiceEntitlementList{Items: []servicesv1alpha1.ServiceEntitlement{*other}}
if got := SelectEntitlement(list, testConfig); got != nil {
t.Fatalf("SelectEntitlement() = %+v, want nil", got)
}
})
}

// ageAgo is exercised indirectly by the renderer; a direct check guards the
// boundary wording used in status copy.
func TestAgeAgo(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{30 * time.Second, "just now"},
{5 * time.Minute, "5m ago"},
{2 * time.Hour, "2h ago"},
{49 * time.Hour, "2d ago"},
}
for _, tc := range cases {
got := ageAgo(metav1.NewTime(time.Now().Add(-tc.d)))
if got != tc.want {
t.Errorf("ageAgo(%s) = %q, want %q", tc.d, got, tc.want)
}
}
}
Loading