Add monitored-system admission extension hook

This commit is contained in:
rcourtman 2026-04-15 14:04:21 +01:00
parent 3596acfeb2
commit bd8b2efd1b
10 changed files with 233 additions and 7 deletions

View file

@ -1175,6 +1175,13 @@ lifecycle-local counter, drift on source priority, or treat missing monitored-
system usage as zero; when an active cap is present and usage cannot be
resolved, the route must fail closed with retryable unavailable guidance
instead of silently admitting a net-new monitored system.
That same lifecycle boundary also assumes any private monitored-system
admission policy hook stays backend-owned. Even when `pkg/server/server.go`
wires `ResolveMonitoredSystemAdmissionPolicy` into shared `internal/api/`
admission helpers, setup and install flows must continue to source candidate
projection, replacement identity, and usage-availability truth from the
canonical monitored-system preview/resolver path instead of adding a second
lifecycle-local policy branch or exemption rule.
When lifecycle-adjacent setup or support surfaces need to explain why a
candidate would count or dedupe, they must consume the shared monitored-system
ledger preview contract rather than rebuilding a second preview model from

View file

@ -1470,6 +1470,17 @@ must expose `reason` as `limit_reached`, `preexisting_usage`, or
`legacy_migration_capture_pending` so browser surfaces can distinguish a full
plan boundary from a frozen above-plan carry-forward and from migrated legacy
continuity that is still being verified.
That same admission family also owns the private monitored-system policy hook
boundary. `internal/api/enterprise_extension_monitored_system_admission.go`
may register one private `ResolveMonitoredSystemAdmissionPolicy` hook through
`pkg/extensions/monitored_system_admission.go`, but that hook must consume the
canonical counted-system input already resolved by shared API admission
helpers. Private builds may not use that extension point to invent
provider-local counters, replacement semantics, or usage-availability fallbacks
that diverge from the shared monitored-system resolver. `pkg/server/server.go`
may wire the hook during startup, but public runtime still owns counted-system
projection unless and until a later governed enforcement slice actually routes
live admission through that private decision boundary.
That same contract now also owns migrated legacy continuity. When a supported
v5 license auto-exchanges or is activated manually in v6, `/api/license/status`
and `/api/license/entitlements` must surface `max_monitored_systems` from the

View file

@ -79,6 +79,13 @@ querying, and the operator-facing storage health presentation layer.
current/projected grouped systems plus enforced limit verdicts for setup and
support clarity, but storage and recovery must not reinterpret those routes
as recovery-local onboarding or restore APIs.
That same adjacent API boundary also owns any private monitored-system
policy hook wired through `pkg/server/server.go`. Storage- or
recovery-adjacent writes may observe the shared verdict returned by
`ResolveMonitoredSystemAdmissionPolicy`, but they must not inject
provider-specific policy, infer a different over-cap rule, or bypass the
canonical monitored-system preview/resolver contract when the private hook
is absent.
Any adjacent list surfaces that reuse `internal/api/resources.go` must also
preserve the canonical unified-resource `name -> type -> id` order so
duplicate-name storage and recovery resources do not reshuffle between cold

View file

@ -202,13 +202,14 @@ func TestPaidDomainBoundaryAudit(t *testing.T) {
// Use an exact allowlist to prevent new paid implementations from
// hiding behind the enterprise_extension_ prefix.
knownBinderFiles := map[string]bool{
"enterprise_extension_ai_alert_analysis.go": true,
"enterprise_extension_ai_autofix.go": true,
"enterprise_extension_ai_investigation.go": true,
"enterprise_extension_audit_admin.go": true,
"enterprise_extension_rbac_admin.go": true,
"enterprise_extension_reporting_admin.go": true,
"enterprise_extension_sso_admin.go": true,
"enterprise_extension_ai_alert_analysis.go": true,
"enterprise_extension_ai_autofix.go": true,
"enterprise_extension_ai_investigation.go": true,
"enterprise_extension_audit_admin.go": true,
"enterprise_extension_monitored_system_admission.go": true,
"enterprise_extension_rbac_admin.go": true,
"enterprise_extension_reporting_admin.go": true,
"enterprise_extension_sso_admin.go": true,
}
// These reporting files are intentionally public shared surfaces rather than

View file

@ -40,6 +40,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
@ -5735,6 +5736,57 @@ func TestContract_MonitoredSystemUsageUnavailableIncludesReason(t *testing.T) {
}
}
func TestContract_ResolveMonitoredSystemAdmissionPolicyHookUsesCanonicalInput(t *testing.T) {
SetResolveMonitoredSystemAdmissionPolicy(nil)
t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) })
if hook := getResolveMonitoredSystemAdmissionPolicy(); hook != nil {
t.Fatalf("expected no monitored-system admission hook by default, got %v", hook)
}
SetResolveMonitoredSystemAdmissionPolicy(func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision {
return extensions.MonitoredSystemAdmissionDecision{
Current: input.Current,
Additional: input.Additional,
Limit: input.Limit,
UsageAvailable: input.UsageAvailable,
UsageUnavailableReason: input.UsageUnavailableReason,
Exceeded: input.CandidateCountsTowardCap &&
input.UsageAvailable &&
input.Additional > 0 &&
input.Limit > 0 &&
input.Current+input.Additional > input.Limit,
}
})
hook := getResolveMonitoredSystemAdmissionPolicy()
if hook == nil {
t.Fatal("expected monitored-system admission hook to round-trip through the shared API boundary")
}
decision := hook(context.Background(), extensions.MonitoredSystemAdmissionInput{
Current: 5,
Additional: 1,
Limit: 5,
UsageAvailable: true,
CandidateCountsTowardCap: true,
})
if !decision.Exceeded {
t.Fatalf("expected canonical counted-system input to preserve the exceeded verdict, got %+v", decision)
}
decision = hook(context.Background(), extensions.MonitoredSystemAdmissionInput{
Current: 5,
Additional: 1,
Limit: 5,
UsageAvailable: true,
CandidateCountsTowardCap: false,
})
if decision.Exceeded {
t.Fatalf("expected non-counted candidate to stay outside the exceeded verdict, got %+v", decision)
}
}
func TestContract_PlatformConnectionWritesFailClosedWhenUsageUnavailable(t *testing.T) {
t.Run("truenas add", func(t *testing.T) {
setTrueNASFeatureForTest(t, true)

View file

@ -0,0 +1,26 @@
package api
import (
"sync"
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
)
var (
monitoredSystemAdmissionPolicyMu sync.RWMutex
resolveMonitoredSystemPolicyFunc extensions.ResolveMonitoredSystemAdmissionPolicyFunc
)
// SetResolveMonitoredSystemAdmissionPolicy registers a private commercial
// policy hook for monitored-system admission decisions.
func SetResolveMonitoredSystemAdmissionPolicy(fn extensions.ResolveMonitoredSystemAdmissionPolicyFunc) {
monitoredSystemAdmissionPolicyMu.Lock()
defer monitoredSystemAdmissionPolicyMu.Unlock()
resolveMonitoredSystemPolicyFunc = fn
}
func getResolveMonitoredSystemAdmissionPolicy() extensions.ResolveMonitoredSystemAdmissionPolicyFunc {
monitoredSystemAdmissionPolicyMu.RLock()
defer monitoredSystemAdmissionPolicyMu.RUnlock()
return resolveMonitoredSystemPolicyFunc
}

View file

@ -0,0 +1,52 @@
package api
import (
"context"
"testing"
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
)
func TestGetResolveMonitoredSystemAdmissionPolicy_DefaultNil(t *testing.T) {
SetResolveMonitoredSystemAdmissionPolicy(nil)
t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) })
if getResolveMonitoredSystemAdmissionPolicy() != nil {
t.Fatal("expected nil when no monitored-system admission policy hook is registered")
}
}
func TestGetResolveMonitoredSystemAdmissionPolicy_RoundTripsHook(t *testing.T) {
SetResolveMonitoredSystemAdmissionPolicy(nil)
t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) })
SetResolveMonitoredSystemAdmissionPolicy(func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision {
return extensions.MonitoredSystemAdmissionDecision{
Current: input.Current,
Additional: input.Additional,
Limit: input.Limit,
UsageAvailable: input.UsageAvailable,
UsageUnavailableReason: input.UsageUnavailableReason,
Exceeded: input.UsageAvailable && input.Additional > 0 && input.Limit > 0 && input.Current+input.Additional > input.Limit,
}
})
hook := getResolveMonitoredSystemAdmissionPolicy()
if hook == nil {
t.Fatal("expected monitored-system admission policy hook to be returned")
}
decision := hook(context.Background(), extensions.MonitoredSystemAdmissionInput{
Current: 5,
Additional: 1,
Limit: 5,
UsageAvailable: true,
UsageUnavailableReason: "",
})
if !decision.Exceeded {
t.Fatalf("expected hook result to preserve exceeded state, got %+v", decision)
}
if decision.Current != 5 || decision.Additional != 1 || decision.Limit != 5 {
t.Fatalf("expected hook result to preserve admission values, got %+v", decision)
}
}

View file

@ -0,0 +1,33 @@
package extensions
import "context"
// MonitoredSystemAdmissionInput captures the public counted-system admission
// state computed by the OSS runtime before any private commercial policy hook
// is consulted.
type MonitoredSystemAdmissionInput struct {
Current int
Additional int
Limit int
UsageAvailable bool
UsageUnavailableReason string
CandidateCountsTowardCap bool
}
// MonitoredSystemAdmissionDecision captures the commercial admission outcome
// that a private build may return. This intentionally stays scoped to backend
// admission semantics rather than customer-facing billing/posture messaging.
type MonitoredSystemAdmissionDecision struct {
Current int
Additional int
Limit int
UsageAvailable bool
UsageUnavailableReason string
Exceeded bool
}
// ResolveMonitoredSystemAdmissionPolicyFunc allows private builds to own the
// commercial monitored-system admission decision without importing internal API
// packages. The public runtime remains the source of truth for counted-system
// projection and may call this hook in a later migration slice.
type ResolveMonitoredSystemAdmissionPolicyFunc func(context.Context, MonitoredSystemAdmissionInput) MonitoredSystemAdmissionDecision

View file

@ -96,6 +96,11 @@ type BusinessHooks struct {
// CreateAlertAnalyzer creates the premium alert-triggered analyzer.
// Returns nil in OSS. Enterprise provides a concrete implementation.
CreateAlertAnalyzer func(deps aicontracts.AlertAnalyzerDeps) aicontracts.AlertAnalyzer
// ResolveMonitoredSystemAdmissionPolicy allows private builds to own the
// commercial monitored-system admission decision without importing internal
// API packages. Public runtime still owns counted-system projection.
ResolveMonitoredSystemAdmissionPolicy extensions.ResolveMonitoredSystemAdmissionPolicyFunc
}
var (
@ -299,6 +304,7 @@ func Run(ctx context.Context, version string) error {
createInvestigationStore := globalHooks.CreateInvestigationStore
createInvestigationOrchestrator := globalHooks.CreateInvestigationOrchestrator
createAlertAnalyzer := globalHooks.CreateAlertAnalyzer
resolveMonitoredSystemAdmissionPolicy := globalHooks.ResolveMonitoredSystemAdmissionPolicy
globalHooksMu.Unlock()
api.SetAIInvestigationEnabled(aiInvestigationEnabled)
@ -306,6 +312,7 @@ func Run(ctx context.Context, version string) error {
api.SetCreateInvestigationStore(createInvestigationStore)
api.SetCreateInvestigationOrchestrator(createInvestigationOrchestrator)
api.SetCreateAlertAnalyzer(createAlertAnalyzer)
api.SetResolveMonitoredSystemAdmissionPolicy(resolveMonitoredSystemAdmissionPolicy)
api.SetRBACAdminEndpointsBinder(bindRBACAdminEndpoints)
api.SetAuditAdminEndpointsBinder(bindAuditAdminEndpoints)
api.SetSSOAdminEndpointsBinder(bindSSOAdminEndpoints)

View file

@ -9,17 +9,30 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func TestBusinessHooks(t *testing.T) {
called := false
resolveCalled := false
hook := func(store *metrics.Store) {
called = true
}
SetBusinessHooks(BusinessHooks{
OnMetricsStoreReady: hook,
ResolveMonitoredSystemAdmissionPolicy: func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision {
resolveCalled = true
return extensions.MonitoredSystemAdmissionDecision{
Current: input.Current,
Additional: input.Additional,
Limit: input.Limit,
UsageAvailable: input.UsageAvailable,
UsageUnavailableReason: input.UsageUnavailableReason,
Exceeded: input.UsageAvailable && input.Additional > 0 && input.Limit > 0 && input.Current+input.Additional > input.Limit,
}
},
})
globalHooksMu.Lock()
@ -34,6 +47,23 @@ func TestBusinessHooks(t *testing.T) {
if !called {
t.Error("expected hook to be called")
}
if globalHooks.ResolveMonitoredSystemAdmissionPolicy == nil {
t.Error("expected ResolveMonitoredSystemAdmissionPolicy to be set")
}
decision := globalHooks.ResolveMonitoredSystemAdmissionPolicy(context.Background(), extensions.MonitoredSystemAdmissionInput{
Current: 5,
Additional: 1,
Limit: 5,
UsageAvailable: true,
})
if !resolveCalled {
t.Error("expected monitored-system admission policy hook to be called")
}
if !decision.Exceeded {
t.Fatalf("expected exceeded decision from hook, got %+v", decision)
}
}
func TestPerformAutoImport_Success(t *testing.T) {