From 64a3c6f90acd577ad21f65e81c8bc41244ac6b26 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Wed, 23 Sep 2026 23:57:13 +0200 Subject: [PATCH 01/12] refactor(extension): single source for the edition and the tier table The licence manager moves to internal/commercial/license and is reached through extension.EditionSource. The tier table (capability to edition, caps and history per edition) lives in internal/commercial/tiers.go on top of the Community policy declared by the core. cmd/maintenant registers it before building the app; a boundary test keeps core packages from importing internal/commercial. GET /api/v1/edition and get_edition gain an additive tiers field, which the Editions page now reads instead of its own copy of the caps. Dead extension scaffolding and an always-true swarm capability check go away. --- cmd/maintenant/main.go | 2 + frontend/src/pages/EditionsPage.vue | 38 ++-- .../src/pages/__tests__/EditionsPage.spec.ts | 94 ++++++++ frontend/src/services/editionApi.ts | 2 + internal/agentserver/main_test.go | 23 ++ internal/api/v1/license_status_test.go | 21 +- internal/api/v1/main_test.go | 23 ++ internal/api/v1/router.go | 10 +- internal/app/app.go | 42 ++-- internal/app/main_test.go | 23 ++ internal/app/mode_gate_test.go | 10 +- internal/app/multihost_plan_test.go | 8 +- internal/app/wiring_test.go | 7 +- internal/commercial/boundary_test.go | 57 +++++ internal/{ => commercial}/license/cache.go | 0 .../{ => commercial}/license/cache_test.go | 0 internal/{ => commercial}/license/manager.go | 26 +-- .../{ => commercial}/license/manager_test.go | 0 .../license/manager_window_test.go | 0 internal/{ => commercial}/license/pubkey.go | 0 internal/{ => commercial}/license/verifier.go | 0 .../{ => commercial}/license/verifier_test.go | 0 internal/{ => commercial}/license/window.go | 4 +- .../{ => commercial}/license/window_test.go | 0 internal/commercial/register.go | 33 +++ internal/commercial/tiers.go | 96 ++++++++ internal/commercial/tiers_test.go | 209 ++++++++++++++++++ internal/extension/alert.go | 25 +-- internal/extension/capability.go | 46 +--- internal/extension/capability_test.go | 172 ++++++++------ internal/extension/edition.go | 1 - internal/extension/extension_test.go | 82 ------- internal/extension/history_window.go | 21 +- internal/extension/history_window_test.go | 114 +++------- internal/extension/multihost.go | 29 --- internal/extension/policy.go | 130 +++++++++++ internal/extension/quota.go | 64 ++---- internal/extension/quota_test.go | 94 ++++---- internal/extension/status.go | 95 -------- internal/mcp/main_test.go | 23 ++ internal/mcp/tools_edition.go | 1 + 41 files changed, 1009 insertions(+), 616 deletions(-) create mode 100644 frontend/src/pages/__tests__/EditionsPage.spec.ts create mode 100644 internal/agentserver/main_test.go create mode 100644 internal/api/v1/main_test.go create mode 100644 internal/app/main_test.go create mode 100644 internal/commercial/boundary_test.go rename internal/{ => commercial}/license/cache.go (100%) rename internal/{ => commercial}/license/cache_test.go (100%) rename internal/{ => commercial}/license/manager.go (92%) rename internal/{ => commercial}/license/manager_test.go (100%) rename internal/{ => commercial}/license/manager_window_test.go (100%) rename internal/{ => commercial}/license/pubkey.go (100%) rename internal/{ => commercial}/license/verifier.go (100%) rename internal/{ => commercial}/license/verifier_test.go (100%) rename internal/{ => commercial}/license/window.go (98%) rename internal/{ => commercial}/license/window_test.go (100%) create mode 100644 internal/commercial/register.go create mode 100644 internal/commercial/tiers.go create mode 100644 internal/commercial/tiers_test.go delete mode 100644 internal/extension/multihost.go create mode 100644 internal/extension/policy.go delete mode 100644 internal/extension/status.go create mode 100644 internal/mcp/main_test.go diff --git a/cmd/maintenant/main.go b/cmd/maintenant/main.go index fbacdc2e..ae8bf815 100644 --- a/cmd/maintenant/main.go +++ b/cmd/maintenant/main.go @@ -21,6 +21,7 @@ import ( "github.com/kolapsis/maintenant/internal/agent" "github.com/kolapsis/maintenant/internal/app" + "github.com/kolapsis/maintenant/internal/commercial" _ "github.com/kolapsis/maintenant/internal/kubernetes" "github.com/kolapsis/maintenant/internal/resource" "github.com/kolapsis/maintenant/internal/trust" @@ -192,6 +193,7 @@ func main() { return } + commercial.Register() application, err := app.New(cfg, logger) if err != nil { if !logStorageStartupError(logger, err, cfg.DatabaseURL) { diff --git a/frontend/src/pages/EditionsPage.vue b/frontend/src/pages/EditionsPage.vue index 4fc3e939..a0b6c890 100644 --- a/frontend/src/pages/EditionsPage.vue +++ b/frontend/src/pages/EditionsPage.vue @@ -84,18 +84,16 @@ const RESOURCE_LABELS: Record = { agent_hosts: 'Machines monitored', } -/** - * The caps per tier. They mirror extension.Limit — the backend only reports the - * running edition's own limits, so the other two columns cannot be read from - * the API and are stated here. - */ -const QUOTAS: Record> = { - endpoints: { community: 10, personal: -1, pro: -1 }, - heartbeats: { community: 5, personal: -1, pro: -1 }, - certificates: { community: 5, personal: -1, pro: -1 }, - status_components: { community: 3, personal: -1, pro: -1 }, - agent_hosts: { community: 1, personal: 20, pro: -1 }, -} +/** The caps per tier, as the engine declares them. */ +const quotaRows = computed(() => { + const tiers = edition.value?.tiers + if (!tiers) return [] + return (Object.keys(RESOURCE_LABELS) as QuotaResource[]).map((resource) => ({ + resource, + label: RESOURCE_LABELS[resource], + limits: TIERS.map((tier) => tiers[tier]?.[resource]), + })) +}) /** * The capability rows, grouped by the tier that opens them and ordered @@ -144,9 +142,11 @@ function includedIn(minEdition: Edition, tier: Tier): boolean { return min !== undefined && RANK[tier] >= min } -function quotaLabel(value: number): string { +function quotaLabel(resource: QuotaResource, value: number | undefined): string { + if (value === undefined) return '' if (value < 0) return 'Unlimited' - if (value === 1) return 'This machine only' + // agent_hosts counts remote agents: none means only the local machine. + if (resource === 'agent_hosts' && value === 0) return 'This machine only' return String(value) } @@ -252,18 +252,18 @@ function isUpgrade(tier: Tier): boolean { - {{ label }} + {{ row.label }} - {{ quotaLabel(QUOTAS[resource][tier]) }} + {{ quotaLabel(row.resource, row.limits[i]) }} diff --git a/frontend/src/pages/__tests__/EditionsPage.spec.ts b/frontend/src/pages/__tests__/EditionsPage.spec.ts new file mode 100644 index 00000000..a5004575 --- /dev/null +++ b/frontend/src/pages/__tests__/EditionsPage.spec.ts @@ -0,0 +1,94 @@ +// Copyright 2026 Benjamin Touchard (kOlapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import type { EditionResponse } from '@/services/editionApi' +import EditionsPage from '@/pages/EditionsPage.vue' + +const edition = ref(null) + +vi.mock('@/composables/useEdition', () => ({ + useEdition: () => ({ + edition, + editionName: computed(() => 'community'), + editionRank: computed(() => 0), + licenseMessage: computed(() => ''), + hasLicenseIssue: computed(() => false), + licenseSeverity: computed(() => 'warning'), + licenseLabel: computed(() => 'LICENSE'), + loadLicenseStatus: () => Promise.resolve(), + }), +})) + +vi.mock('@/composables/useFeedbackUrl', () => ({ + useFeedbackUrl: () => ({ feedbackUrl: computed(() => '') }), +})) + +function limitsRow(wrapper: ReturnType, label: string): string[] { + const row = wrapper.findAll('tr').find((tr) => tr.findAll('td')[0]?.text() === label) + expect(row, label).toBeDefined() + return row! + .findAll('td') + .slice(1) + .map((td) => td.text()) +} + +describe('EditionsPage limits', () => { + it('renders the caps the engine declares for every tier', () => { + edition.value = { + edition: 'community', + organisation_name: '', + features: {}, + tiers: { + community: { + endpoints: 10, + heartbeats: 5, + certificates: 5, + status_components: 3, + agent_hosts: 0, + }, + personal: { + endpoints: -1, + heartbeats: -1, + certificates: -1, + status_components: -1, + agent_hosts: 20, + }, + pro: { + endpoints: -1, + heartbeats: -1, + certificates: -1, + status_components: -1, + agent_hosts: -1, + }, + }, + } + const wrapper = mount(EditionsPage, { global: { stubs: ['EditionBadge', 'InlineAlert'] } }) + + expect(limitsRow(wrapper, 'Endpoints')).toEqual(['10', 'Unlimited', 'Unlimited']) + expect(limitsRow(wrapper, 'Heartbeats')).toEqual(['5', 'Unlimited', 'Unlimited']) + expect(limitsRow(wrapper, 'Certificate monitors')).toEqual(['5', 'Unlimited', 'Unlimited']) + expect(limitsRow(wrapper, 'Status page components')).toEqual(['3', 'Unlimited', 'Unlimited']) + expect(limitsRow(wrapper, 'Machines monitored')).toEqual([ + 'This machine only', + '20', + 'Unlimited', + ]) + }) + + it('shows no limit row when the engine reports no tiers', () => { + edition.value = { edition: 'community', organisation_name: '', features: {} } + const wrapper = mount(EditionsPage, { global: { stubs: ['EditionBadge', 'InlineAlert'] } }) + + expect(wrapper.findAll('td').some((td) => td.text() === 'Endpoints')).toBe(false) + }) +}) diff --git a/frontend/src/services/editionApi.ts b/frontend/src/services/editionApi.ts index ce104fd2..65106116 100644 --- a/frontend/src/services/editionApi.ts +++ b/frontend/src/services/editionApi.ts @@ -61,6 +61,8 @@ export interface EditionResponse { /** capability -> minimum edition that opens it, projected from the backend registry */ feature_editions?: Record quotas?: Partial> + /** edition -> resource -> cap (-1 unlimited), for every edition. */ + tiers?: Record>> /** Absent on an engine older than the tiered history: no catalogue, no cap. */ resource_history?: ResourceHistoryContract demo?: boolean diff --git a/internal/agentserver/main_test.go b/internal/agentserver/main_test.go new file mode 100644 index 00000000..c2321615 --- /dev/null +++ b/internal/agentserver/main_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package agentserver + +import ( + "os" + "testing" + + "github.com/kolapsis/maintenant/internal/commercial" +) + +func TestMain(m *testing.M) { + commercial.Register() + os.Exit(m.Run()) +} diff --git a/internal/api/v1/license_status_test.go b/internal/api/v1/license_status_test.go index 162f5085..660bad47 100644 --- a/internal/api/v1/license_status_test.go +++ b/internal/api/v1/license_status_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "github.com/kolapsis/maintenant/internal/extension" - "github.com/kolapsis/maintenant/internal/license" ) // GET /api/v1/license/status is what the banner and the Editions page read. Two @@ -21,11 +20,11 @@ func TestLicenseStatusPayload_KeysAreAlwaysPresent(t *testing.T) { "verified_at", "expires_at", "updates_until", "update_grace_until", } - states := map[string]*license.State{ + states := map[string]*extension.LicenseState{ "no license configured": {Status: "inactive"}, "active personal": {Status: "active", Edition: extension.Personal, Plan: "personal"}, - "window grace": {Status: license.StatusUpdateWindowGrace, Edition: extension.Personal}, - "window ended": {Status: license.StatusUpdateWindowEnded, Edition: extension.Community}, + "window grace": {Status: "update_window_grace", Edition: extension.Personal}, + "window ended": {Status: extension.LicenseStatusUpdateWindowEnded, Edition: extension.Community}, } for name, state := range states { @@ -43,7 +42,7 @@ func TestLicenseStatusPayload_KeysAreAlwaysPresent(t *testing.T) { // perpetual and an instance inside its window has no grace deadline. Neither // may surface as 0001-01-01. func TestLicenseStatusPayload_AbsentDatesRenderEmpty(t *testing.T) { - body := licenseStatusPayload(&license.State{ + body := licenseStatusPayload(&extension.LicenseState{ Status: "active", Edition: extension.Personal, Plan: "personal", @@ -59,8 +58,8 @@ func TestLicenseStatusPayload_WindowDatesAreExposed(t *testing.T) { windowEnd := time.Date(2027, time.August, 9, 12, 0, 0, 0, time.UTC) graceEnd := time.Date(2027, time.October, 1, 8, 0, 0, 0, time.UTC) - body := licenseStatusPayload(&license.State{ - Status: license.StatusUpdateWindowGrace, + body := licenseStatusPayload(&extension.LicenseState{ + Status: "update_window_grace", Edition: extension.Personal, Plan: "personal", Message: "…Renew your Personal updates…", @@ -68,7 +67,7 @@ func TestLicenseStatusPayload_WindowDatesAreExposed(t *testing.T) { UpdateGraceUntil: graceEnd, }, extension.Personal) - assert.Equal(t, license.StatusUpdateWindowGrace, body["status"]) + assert.Equal(t, "update_window_grace", body["status"]) assert.Equal(t, "personal", body["edition"], "the grace keeps the edition") assert.Equal(t, "2027-08-09T12:00:00Z", body["updates_until"]) assert.Equal(t, "2027-10-01T08:00:00Z", body["update_grace_until"]) @@ -78,14 +77,14 @@ func TestLicenseStatusPayload_WindowDatesAreExposed(t *testing.T) { // instance really is Community, and the route must say so rather than repeat // what was bought. func TestLicenseStatusPayload_EndedReportsCommunity(t *testing.T) { - body := licenseStatusPayload(&license.State{ - Status: license.StatusUpdateWindowEnded, + body := licenseStatusPayload(&extension.LicenseState{ + Status: extension.LicenseStatusUpdateWindowEnded, Edition: extension.Community, Plan: "personal", UpdatesUntil: time.Date(2027, time.August, 9, 12, 0, 0, 0, time.UTC), }, extension.Community) - require.Equal(t, license.StatusUpdateWindowEnded, body["status"]) + require.Equal(t, extension.LicenseStatusUpdateWindowEnded, body["status"]) assert.Equal(t, "community", body["edition"]) assert.Equal(t, "2027-08-09T12:00:00Z", body["updates_until"], "the date the user has to renew against stays visible") diff --git a/internal/api/v1/main_test.go b/internal/api/v1/main_test.go new file mode 100644 index 00000000..04bae753 --- /dev/null +++ b/internal/api/v1/main_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package v1 + +import ( + "os" + "testing" + + "github.com/kolapsis/maintenant/internal/commercial" +) + +func TestMain(m *testing.M) { + commercial.Register() + os.Exit(m.Run()) +} diff --git a/internal/api/v1/router.go b/internal/api/v1/router.go index 988b5151..689fc5c1 100644 --- a/internal/api/v1/router.go +++ b/internal/api/v1/router.go @@ -27,7 +27,6 @@ import ( "github.com/kolapsis/maintenant/internal/eol" "github.com/kolapsis/maintenant/internal/extension" "github.com/kolapsis/maintenant/internal/heartbeat" - "github.com/kolapsis/maintenant/internal/license" "github.com/kolapsis/maintenant/internal/outbound" "github.com/kolapsis/maintenant/internal/resource" "github.com/kolapsis/maintenant/internal/runtime" @@ -123,7 +122,7 @@ type HandlerDeps struct { EscalationSvc *escalation.Service // License - LicenseMgr *license.Manager + LicenseMgr extension.EditionSource // Swarm SwarmCluster func() *swarm.SwarmCluster @@ -581,7 +580,7 @@ func rfc3339OrEmpty(t time.Time) string { // licenseStatusPayload is the wire shape of GET /api/v1/license/status. Every // branch goes through it so the response always carries the same keys. -func licenseStatusPayload(state *license.State, edition extension.Edition) map[string]interface{} { +func licenseStatusPayload(state *extension.LicenseState, edition extension.Edition) map[string]interface{} { return map[string]interface{}{ "status": state.Status, "edition": string(edition), @@ -594,13 +593,13 @@ func licenseStatusPayload(state *license.State, edition extension.Edition) map[s } } -func (r *Router) registerLicenseRoutes(mgr *license.Manager) { +func (r *Router) registerLicenseRoutes(mgr extension.EditionSource) { r.mux.HandleFunc("GET /api/v1/license/status", func(w http.ResponseWriter, req *http.Request) { // No license configured (Community): report an inactive state rather than // leaving the documented route unregistered (404). if mgr == nil { WriteJSON(w, http.StatusOK, - licenseStatusPayload(&license.State{Status: "inactive"}, extension.Community)) + licenseStatusPayload(&extension.LicenseState{Status: "inactive"}, extension.Community)) return } @@ -864,6 +863,7 @@ func (r *Router) handleGetEdition(smtpConfigured bool, d HandlerDeps) http.Handl "features": features, "feature_editions": featureEditions, "quotas": r.computeQuotas(ctx, d), + "tiers": extension.Tiers(), "resource_history": map[string]interface{}{ "max_window": maxWindow.Name, "max_window_seconds": int64(maxWindow.Duration / time.Second), diff --git a/internal/app/app.go b/internal/app/app.go index fe3bf8c7..edc84a7f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -40,7 +40,6 @@ import ( "github.com/kolapsis/maintenant/internal/extension" "github.com/kolapsis/maintenant/internal/heartbeat" "github.com/kolapsis/maintenant/internal/kubernetes" - "github.com/kolapsis/maintenant/internal/license" "github.com/kolapsis/maintenant/internal/mcp" "github.com/kolapsis/maintenant/internal/outbound" "github.com/kolapsis/maintenant/internal/ratelimit" @@ -127,7 +126,7 @@ type App struct { rl *ratelimit.Limiter apiRL *ratelimit.Limiter subscribeRL *ratelimit.Limiter - licenseMgr *license.Manager + licenseMgr extension.EditionSource mcpServer *gomcp.Server // degradedPlanLogged keeps the multi-host degradation to one line: the // helper is consulted at three call sites during a single startup. @@ -252,16 +251,19 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { // edition, Pro included. // --- License manager --- - license.InitPublicKey(cfg.PublicKeyB64) - if cfg.LicenseKey != "" { - dataDir := filepath.Dir(cfg.DBPath) - lm, err := license.NewManager(cfg.LicenseKey, dataDir, cfg.Version, cfg.BuildDate, logger) - if err != nil { - logger.Warn("license manager initialization failed, running as Community Edition", "error", err) - } else { - a.licenseMgr = lm - extension.CurrentEdition = lm.Edition - } + lm, err := extension.NewEditionSource(extension.SourceConfig{ + LicenseKey: cfg.LicenseKey, + PublicKeyB64: cfg.PublicKeyB64, + DataDir: filepath.Dir(cfg.DBPath), + Version: cfg.Version, + BuildDate: cfg.BuildDate, + Logger: logger, + }) + if err != nil { + logger.Warn("license manager initialization failed, running as Community Edition", "error", err) + } else if lm != nil { + a.licenseMgr = lm + extension.CurrentEdition = lm.Edition } // --- Runtime detection --- @@ -317,15 +319,11 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { }) a.swarmEvents = swarm.NewEventProcessor(a.swarmDiscovery, logger) - // Node health, crash-loop detection and update tracking follow the - // swarm dashboard capability. - if extension.Allows(extension.CapSwarmDashboard) { - a.swarmNodeSvc = swarm.NewNodeService(dr.Client(), a.swarmNodeStore, logger) - a.swarmCrashLoop = swarm.NewCrashLoopDetector(logger) - a.swarmUpdateTracker = swarm.NewUpdateTracker(dr.Client(), logger) - a.swarmTaskTracker = swarm.NewTaskTracker(dr.Client(), logger) - a.swarmReplicaChecker = swarm.NewReplicaHealthChecker(logger) - } + a.swarmNodeSvc = swarm.NewNodeService(dr.Client(), a.swarmNodeStore, logger) + a.swarmCrashLoop = swarm.NewCrashLoopDetector(logger) + a.swarmUpdateTracker = swarm.NewUpdateTracker(dr.Client(), logger) + a.swarmTaskTracker = swarm.NewTaskTracker(dr.Client(), logger) + a.swarmReplicaChecker = swarm.NewReplicaHealthChecker(logger) } } } @@ -789,7 +787,7 @@ func (a *App) multihostPlanAllowed() bool { // multihostPlanPermitted is the decision itself, kept apart from the manager so // it can be exercised directly. func multihostPlanPermitted(capabilityGranted bool, licenseStatus string) bool { - return capabilityGranted || licenseStatus == license.StatusUpdateWindowEnded + return capabilityGranted || licenseStatus == extension.LicenseStatusUpdateWindowEnded } // Start begins all background services and the HTTP server. diff --git a/internal/app/main_test.go b/internal/app/main_test.go new file mode 100644 index 00000000..0eb58cd0 --- /dev/null +++ b/internal/app/main_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package app + +import ( + "os" + "testing" + + "github.com/kolapsis/maintenant/internal/commercial" +) + +func TestMain(m *testing.M) { + commercial.Register() + os.Exit(m.Run()) +} diff --git a/internal/app/mode_gate_test.go b/internal/app/mode_gate_test.go index 9a8d974e..4e0bb175 100644 --- a/internal/app/mode_gate_test.go +++ b/internal/app/mode_gate_test.go @@ -20,7 +20,9 @@ import ( // gateErrFragment is the part of the mode-gate error these tests key on. The // gate follows the multihost capability, so the edition it names is whatever // the registry declares — not a hardcoded tier. -var gateErrFragment = "requires the " + string(extension.MinEdition(extension.CapMultihost)) + " edition" +func gateErrFragment() string { + return "requires the " + string(extension.MinEdition(extension.CapMultihost)) + " edition" +} func withEdition(t *testing.T, e extension.Edition) { t.Helper() @@ -113,7 +115,7 @@ func TestStart_ServerMode_RejectedWithoutMultihost(t *testing.T) { err = startAndCollect(t, a, 5*time.Second) require.Error(t, err, "server mode must be refused where multi-host is closed") - assert.Contains(t, err.Error(), gateErrFragment) + assert.Contains(t, err.Error(), gateErrFragment()) assert.Contains(t, err.Error(), string(extension.Community), "the refusal must name the edition actually in force") } @@ -133,7 +135,7 @@ func TestStart_ServerMode_AllowedWhereMultihostIsOpen(t *testing.T) { err = startAndCollect(t, a, 2*time.Second) if err != nil { - assert.NotContains(t, err.Error(), gateErrFragment, + assert.NotContains(t, err.Error(), gateErrFragment(), "server mode must not be refused when multi-host is open") } }) @@ -152,7 +154,7 @@ func TestStart_EmbeddedMode_NotGated(t *testing.T) { err = startAndCollect(t, a, 2*time.Second) if err != nil { - assert.False(t, strings.Contains(err.Error(), gateErrFragment), + assert.False(t, strings.Contains(err.Error(), gateErrFragment()), "embedded mode must never hit the mode gate") } }) diff --git a/internal/app/multihost_plan_test.go b/internal/app/multihost_plan_test.go index 0b72b5e7..96f9b79b 100644 --- a/internal/app/multihost_plan_test.go +++ b/internal/app/multihost_plan_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/kolapsis/maintenant/internal/license" + "github.com/kolapsis/maintenant/internal/extension" ) // The mode gate is the only refusal to start in the product. A Personal @@ -21,12 +21,12 @@ func TestMultihostPlanPermitted(t *testing.T) { want bool }{ {"capability granted", true, "active", true}, - {"capability granted, window in grace", true, license.StatusUpdateWindowGrace, true}, + {"capability granted, window in grace", true, "update_window_grace", true}, {"community, no license", false, "", false}, {"community, license expired", false, "expired", false}, {"community, license revoked", false, "revoked", false}, {"community, server unreachable", false, "unreachable", false}, - {"bridled by a closed update window", false, license.StatusUpdateWindowEnded, true}, + {"bridled by a closed update window", false, extension.LicenseStatusUpdateWindowEnded, true}, } for _, c := range cases { @@ -40,6 +40,6 @@ func TestMultihostPlanPermitted(t *testing.T) { // edition is untouched, so the capability is still granted and the degraded // branch must never be the reason the plan runs. func TestMultihostPlanPermitted_GraceIsNotADegradation(t *testing.T) { - assert.False(t, multihostPlanPermitted(false, license.StatusUpdateWindowGrace), + assert.False(t, multihostPlanPermitted(false, "update_window_grace"), "a Community instance must not be let through by the grace status") } diff --git a/internal/app/wiring_test.go b/internal/app/wiring_test.go index 90950064..110bbee7 100644 --- a/internal/app/wiring_test.go +++ b/internal/app/wiring_test.go @@ -28,7 +28,6 @@ import ( "github.com/kolapsis/maintenant/internal/alert" "github.com/kolapsis/maintenant/internal/alert/escalation" "github.com/kolapsis/maintenant/internal/extension" - "github.com/kolapsis/maintenant/internal/license" "github.com/kolapsis/maintenant/internal/store" "github.com/kolapsis/maintenant/internal/uid" ) @@ -147,9 +146,9 @@ func TestEditionDowngradePropagatesToEscalation(t *testing.T) { // and trigger a Pro→CE transition. var ( cbMu sync.Mutex - callbacks []license.EditionChangeCallback + callbacks []extension.EditionChangeCallback ) - register := func(cb license.EditionChangeCallback) { + register := func(cb extension.EditionChangeCallback) { cbMu.Lock() defer cbMu.Unlock() callbacks = append(callbacks, cb) @@ -168,7 +167,7 @@ func TestEditionDowngradePropagatesToEscalation(t *testing.T) { // Fire: Pro → Community. cbMu.Lock() - cbs := make([]license.EditionChangeCallback, len(callbacks)) + cbs := make([]extension.EditionChangeCallback, len(callbacks)) copy(cbs, callbacks) cbMu.Unlock() for _, cb := range cbs { diff --git a/internal/commercial/boundary_test.go b/internal/commercial/boundary_test.go new file mode 100644 index 00000000..bc652c2f --- /dev/null +++ b/internal/commercial/boundary_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package commercial + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + modulePath = "github.com/kolapsis/maintenant" + commercialPath = modulePath + "/internal/commercial" +) + +func TestNoCorePackageImportsCommercial(t *testing.T) { + gomod, err := exec.Command("go", "env", "GOMOD").Output() + if err != nil { + t.Fatalf("go env GOMOD: %v", err) + } + + cmd := exec.Command("go", "list", "-f", "{{.ImportPath}} {{join .Imports \" \"}}", "./...") + cmd.Dir = filepath.Dir(strings.TrimSpace(string(gomod))) + out, err := cmd.Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pkg := fields[0] + if isCommercial(pkg) || pkg == modulePath+"/cmd/maintenant" { + continue + } + for _, imp := range fields[1:] { + if isCommercial(imp) { + t.Errorf("%s imports %s: only cmd/maintenant may import internal/commercial", pkg, imp) + } + } + } +} + +func isCommercial(pkg string) bool { + return pkg == commercialPath || strings.HasPrefix(pkg, commercialPath+"/") +} diff --git a/internal/license/cache.go b/internal/commercial/license/cache.go similarity index 100% rename from internal/license/cache.go rename to internal/commercial/license/cache.go diff --git a/internal/license/cache_test.go b/internal/commercial/license/cache_test.go similarity index 100% rename from internal/license/cache_test.go rename to internal/commercial/license/cache_test.go diff --git a/internal/license/manager.go b/internal/commercial/license/manager.go similarity index 92% rename from internal/license/manager.go rename to internal/commercial/license/manager.go index 8b991847..45385b60 100644 --- a/internal/license/manager.go +++ b/internal/commercial/license/manager.go @@ -35,29 +35,11 @@ const ( graceDegradationDisabled = 60 * 24 * time.Hour // 60 days ) -// State represents the current license status, safe to read concurrently. -// A zero Edition means Community: no usable license. -type State struct { - Edition extension.Edition `json:"edition"` - Plan string `json:"plan,omitempty"` - Features []string `json:"features,omitempty"` - Status string `json:"status"` - VerifiedAt time.Time `json:"verified_at,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - Message string `json:"message,omitempty"` - // UpdatesUntil is the last day the license covers a newly released version. - // UpdateGraceUntil is when a build released past that day loses the edition; - // it is named apart from GraceUntil, which already means the server-side - // grace of a past_due subscription. - UpdatesUntil time.Time `json:"updates_until,omitempty"` - UpdateGraceUntil time.Time `json:"update_grace_until,omitempty"` -} +// State is the licence status the manager maintains. +type State = extension.LicenseState -// EditionChangeCallback is invoked after the manager observes a transition -// between two distinct editions. prev and next are the old and new editions. -// The callback is invoked from a dedicated goroutine with a 30-second timeout; -// it must not block. RegisterEditionChangeCallback must be called before Start. -type EditionChangeCallback func(ctx context.Context, prev, next extension.Edition) +// EditionChangeCallback is invoked from a dedicated goroutine with a 30-second timeout and must not block. +type EditionChangeCallback = extension.EditionChangeCallback // Manager handles license verification, caching, and state management. type Manager struct { diff --git a/internal/license/manager_test.go b/internal/commercial/license/manager_test.go similarity index 100% rename from internal/license/manager_test.go rename to internal/commercial/license/manager_test.go diff --git a/internal/license/manager_window_test.go b/internal/commercial/license/manager_window_test.go similarity index 100% rename from internal/license/manager_window_test.go rename to internal/commercial/license/manager_window_test.go diff --git a/internal/license/pubkey.go b/internal/commercial/license/pubkey.go similarity index 100% rename from internal/license/pubkey.go rename to internal/commercial/license/pubkey.go diff --git a/internal/license/verifier.go b/internal/commercial/license/verifier.go similarity index 100% rename from internal/license/verifier.go rename to internal/commercial/license/verifier.go diff --git a/internal/license/verifier_test.go b/internal/commercial/license/verifier_test.go similarity index 100% rename from internal/license/verifier_test.go rename to internal/commercial/license/verifier_test.go diff --git a/internal/license/window.go b/internal/commercial/license/window.go similarity index 98% rename from internal/license/window.go rename to internal/commercial/license/window.go index 87e0c415..deac1954 100644 --- a/internal/license/window.go +++ b/internal/commercial/license/window.go @@ -17,6 +17,8 @@ import ( "os" "path/filepath" "time" + + "github.com/kolapsis/maintenant/internal/extension" ) // The two statuses a closed update window produces. A Personal license is @@ -26,7 +28,7 @@ import ( // would be a lie. const ( StatusUpdateWindowGrace = "update_window_grace" - StatusUpdateWindowEnded = "update_window_ended" + StatusUpdateWindowEnded = extension.LicenseStatusUpdateWindowEnded ) const ( diff --git a/internal/license/window_test.go b/internal/commercial/license/window_test.go similarity index 100% rename from internal/license/window_test.go rename to internal/commercial/license/window_test.go diff --git a/internal/commercial/register.go b/internal/commercial/register.go new file mode 100644 index 00000000..9bdd1b23 --- /dev/null +++ b/internal/commercial/register.go @@ -0,0 +1,33 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package commercial + +import ( + "github.com/kolapsis/maintenant/internal/commercial/license" + "github.com/kolapsis/maintenant/internal/extension" +) + +// Register installs the commercial tier table and the licence-backed edition source into the core. +func Register() { + extension.Register(Tiers{}, newEditionSource) +} + +func newEditionSource(cfg extension.SourceConfig) (extension.EditionSource, error) { + if cfg.LicenseKey == "" { + return nil, nil + } + license.InitPublicKey(cfg.PublicKeyB64) + m, err := license.NewManager(cfg.LicenseKey, cfg.DataDir, cfg.Version, cfg.BuildDate, cfg.Logger) + if err != nil { + return nil, err + } + return m, nil +} diff --git a/internal/commercial/tiers.go b/internal/commercial/tiers.go new file mode 100644 index 00000000..69a0fdc5 --- /dev/null +++ b/internal/commercial/tiers.go @@ -0,0 +1,96 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package commercial + +import ( + "time" + + "github.com/kolapsis/maintenant/internal/extension" +) + +var minEdition = map[extension.Capability]extension.Edition{ + extension.CapMultihost: extension.Personal, + extension.CapCVEEnrichment: extension.Personal, + extension.CapRiskScoring: extension.Personal, + extension.CapChangelog: extension.Personal, + extension.CapIncidents: extension.Personal, + extension.CapSMTP: extension.Personal, + extension.CapAlertAdvancedFilters: extension.Personal, + extension.CapSecurityPosture: extension.Personal, + extension.CapOCSPStapling: extension.Personal, + extension.CapTelegram: extension.Personal, + + extension.CapSlack: extension.Pro, + extension.CapTeams: extension.Pro, + extension.CapAlertEscalation: extension.Pro, + extension.CapAlertEntityRouting: extension.Pro, + extension.CapMaintenanceWindows: extension.Pro, + extension.CapSubscribers: extension.Pro, + extension.CapPersonalization: extension.Pro, +} + +var limits = map[extension.Edition]map[extension.Resource]int{ + extension.Personal: { + extension.ResourceEndpoints: extension.Unlimited, + extension.ResourceHeartbeats: extension.Unlimited, + extension.ResourceCertificates: extension.Unlimited, + extension.ResourceStatusComponents: extension.Unlimited, + extension.ResourceAgentHosts: 20, + }, + extension.Pro: { + extension.ResourceEndpoints: extension.Unlimited, + extension.ResourceHeartbeats: extension.Unlimited, + extension.ResourceCertificates: extension.Unlimited, + extension.ResourceStatusComponents: extension.Unlimited, + extension.ResourceAgentHosts: extension.Unlimited, + }, +} + +var historyCaps = map[extension.Edition]time.Duration{ + extension.Personal: 30 * 24 * time.Hour, + extension.Pro: 90 * 24 * time.Hour, +} + +// Tiers is the commercial tier table: Community as the core declares it, plus what Personal and Pro add. +type Tiers struct{} + +// Capabilities lists every declared capability. +func (Tiers) Capabilities() []extension.Capability { + out := extension.CommunityPolicy{}.Capabilities() + for c := range minEdition { + out = append(out, c) + } + return out +} + +// MinEdition returns the lowest edition that opens c, Pro for an undeclared capability. +func (Tiers) MinEdition(c extension.Capability) extension.Edition { + if e, ok := minEdition[c]; ok { + return e + } + return extension.CommunityPolicy{}.MinEdition(c) +} + +// Limit returns the cap on r under e, 0 for an undeclared resource. +func (Tiers) Limit(e extension.Edition, r extension.Resource) int { + if l, ok := limits[e]; ok { + return l[r] + } + return extension.CommunityPolicy{}.Limit(e, r) +} + +// HistoryCap returns how far back e may look, the Community cap for an unknown edition. +func (Tiers) HistoryCap(e extension.Edition) time.Duration { + if d, ok := historyCaps[e]; ok { + return d + } + return extension.CommunityPolicy{}.HistoryCap(e) +} diff --git a/internal/commercial/tiers_test.go b/internal/commercial/tiers_test.go new file mode 100644 index 00000000..b7cf4905 --- /dev/null +++ b/internal/commercial/tiers_test.go @@ -0,0 +1,209 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package commercial + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kolapsis/maintenant/internal/extension" +) + +func TestMain(m *testing.M) { + Register() + os.Exit(m.Run()) +} + +func withEdition(t *testing.T, e extension.Edition) { + t.Helper() + prev := extension.CurrentEdition + extension.CurrentEdition = func() extension.Edition { return e } + t.Cleanup(func() { extension.CurrentEdition = prev }) +} + +var editions = []extension.Edition{extension.Community, extension.Personal, extension.Pro} + +func TestAllows_EveryEditionEveryCapability(t *testing.T) { + catalog := extension.Catalog() + require.Len(t, catalog, 21) + + for _, edition := range editions { + for c, min := range catalog { + t.Run(string(edition)+"/"+string(c), func(t *testing.T) { + withEdition(t, edition) + assert.Equal(t, edition.AtLeast(min), extension.Allows(c), "min edition %q", min) + }) + } + } +} + +func TestMinEdition_TierMembership(t *testing.T) { + tiers := map[extension.Edition][]extension.Capability{ + extension.Community: { + extension.CapAlertRouting, extension.CapSwarmDashboard, + extension.CapK8sCluster, extension.CapResourceHistory, + }, + extension.Personal: { + extension.CapMultihost, extension.CapCVEEnrichment, extension.CapRiskScoring, + extension.CapChangelog, extension.CapIncidents, extension.CapSMTP, + extension.CapAlertAdvancedFilters, extension.CapSecurityPosture, + extension.CapOCSPStapling, extension.CapTelegram, + }, + extension.Pro: { + extension.CapSlack, extension.CapTeams, extension.CapAlertEscalation, + extension.CapAlertEntityRouting, extension.CapMaintenanceWindows, + extension.CapSubscribers, extension.CapPersonalization, + }, + } + + total := 0 + for want, caps := range tiers { + total += len(caps) + for _, c := range caps { + assert.Equal(t, want, extension.MinEdition(c), "capability %q", c) + for _, edition := range editions { + withEdition(t, edition) + assert.Equal(t, edition.AtLeast(want), extension.Allows(c), "%q under %q", c, edition) + } + } + } + assert.Equal(t, 21, total) +} + +func TestMinEdition_UnknownCapability(t *testing.T) { + assert.Equal(t, extension.Pro, extension.MinEdition("no_such_capability")) + + withEdition(t, extension.Community) + assert.False(t, extension.Allows("no_such_capability")) + withEdition(t, extension.Personal) + assert.False(t, extension.Allows("no_such_capability")) + withEdition(t, extension.Pro) + assert.True(t, extension.Allows("no_such_capability")) +} + +func TestCatalog_ReturnsACopy(t *testing.T) { + c := extension.Catalog() + c[extension.CapAlertEscalation] = extension.Community + c["invented"] = extension.Community + + assert.Equal(t, extension.Pro, extension.MinEdition(extension.CapAlertEscalation)) + assert.Len(t, extension.Catalog(), 21) +} + +func TestLimit_Matrix(t *testing.T) { + u := extension.Unlimited + want := map[extension.Resource]map[extension.Edition]int{ + extension.ResourceEndpoints: {extension.Community: 10, extension.Personal: u, extension.Pro: u}, + extension.ResourceHeartbeats: {extension.Community: 5, extension.Personal: u, extension.Pro: u}, + extension.ResourceCertificates: {extension.Community: 5, extension.Personal: u, extension.Pro: u}, + extension.ResourceStatusComponents: {extension.Community: 3, extension.Personal: u, extension.Pro: u}, + extension.ResourceAgentHosts: {extension.Community: 0, extension.Personal: 20, extension.Pro: u}, + "no_such_resource": {extension.Community: 0, extension.Personal: 0, extension.Pro: 0}, + } + + for resource, perEdition := range want { + for edition, limit := range perEdition { + t.Run(string(edition)+"/"+string(resource), func(t *testing.T) { + withEdition(t, edition) + assert.Equal(t, limit, extension.Limit(resource)) + }) + } + } +} + +func TestTiers_MatchesTheLimits(t *testing.T) { + u := extension.Unlimited + assert.Equal(t, map[extension.Edition]map[extension.Resource]int{ + extension.Community: {"endpoints": 10, "heartbeats": 5, "certificates": 5, "status_components": 3, "agent_hosts": 0}, + extension.Personal: {"endpoints": u, "heartbeats": u, "certificates": u, "status_components": u, "agent_hosts": 20}, + extension.Pro: {"endpoints": u, "heartbeats": u, "certificates": u, "status_components": u, "agent_hosts": u}, + }, extension.Tiers()) +} + +func TestHistoryCap_IsTheAnnouncedTiering(t *testing.T) { + assert.Equal(t, 7*24*time.Hour, Tiers{}.HistoryCap(extension.Community)) + assert.Equal(t, 30*24*time.Hour, Tiers{}.HistoryCap(extension.Personal)) + assert.Equal(t, 90*24*time.Hour, Tiers{}.HistoryCap(extension.Pro)) +} + +func TestMinEditionForHistoryWindow_IsDerivedFromTheCaps(t *testing.T) { + want := map[string]extension.Edition{ + "1h": extension.Community, "6h": extension.Community, "24h": extension.Community, "7d": extension.Community, + "30d": extension.Personal, + "90d": extension.Pro, + } + for name, expected := range want { + w, ok := extension.ResolveHistoryWindow(name) + require.True(t, ok, "window %q is not in the catalogue", name) + assert.Equal(t, expected, extension.MinEditionForHistoryWindow(w), "window %q", name) + } +} + +func TestMaxHistoryWindow_PerEdition(t *testing.T) { + for edition, want := range map[extension.Edition]string{ + extension.Community: "7d", + extension.Personal: "30d", + extension.Pro: "90d", + } { + withEdition(t, edition) + assert.Equal(t, want, extension.MaxHistoryWindow().Name, "edition %q", edition) + } +} + +func TestAllowsHistoryWindow_MatchesTheCaps(t *testing.T) { + opens := map[extension.Edition]map[string]bool{ + extension.Community: {"1h": true, "6h": true, "24h": true, "7d": true, "30d": false, "90d": false}, + extension.Personal: {"1h": true, "6h": true, "24h": true, "7d": true, "30d": true, "90d": false}, + extension.Pro: {"1h": true, "6h": true, "24h": true, "7d": true, "30d": true, "90d": true}, + } + required := map[string]extension.Edition{ + "1h": extension.Community, "6h": extension.Community, "24h": extension.Community, "7d": extension.Community, + "30d": extension.Personal, "90d": extension.Pro, + } + for edition, windows := range opens { + withEdition(t, edition) + for name, want := range windows { + w, ok := extension.ResolveHistoryWindow(name) + require.True(t, ok) + allowed, req := extension.AllowsHistoryWindow(w) + assert.Equal(t, want, allowed, "%s / %s", edition, name) + assert.Equal(t, required[name], req, "%s / %s required edition", edition, name) + } + } +} + +func TestMaxHistoryWindow_UnknownEditionFallsBackToTheFloor(t *testing.T) { + withEdition(t, extension.Edition("enterprise-2030")) + + assert.Equal(t, "7d", extension.MaxHistoryWindow().Name) + + paid, ok := extension.ResolveHistoryWindow("30d") + require.True(t, ok) + allowed, required := extension.AllowsHistoryWindow(paid) + assert.False(t, allowed) + assert.Equal(t, extension.Personal, required) +} + +func TestNewEditionSource_NoKeyMeansNoSource(t *testing.T) { + src, err := extension.NewEditionSource(extension.SourceConfig{}) + require.NoError(t, err) + assert.Nil(t, src) +} + +func TestNewEditionSource_UnusablePublicKeyReturnsAnError(t *testing.T) { + src, err := extension.NewEditionSource(extension.SourceConfig{LicenseKey: "key", PublicKeyB64: ""}) + require.Error(t, err) + assert.True(t, src == nil, "a failed source must be an untyped nil") +} diff --git a/internal/extension/alert.go b/internal/extension/alert.go index 21097fbd..46e576ef 100644 --- a/internal/extension/alert.go +++ b/internal/extension/alert.go @@ -11,30 +11,7 @@ package extension -import ( - "context" - "time" - - "github.com/kolapsis/maintenant/internal/alert" -) - -// NoopEscalator is the CE default. Implements alert.Escalator. -type NoopEscalator struct{} - -func (NoopEscalator) EvaluateCycle(_ context.Context) error { return nil } -func (NoopEscalator) OnAlertCreated(_ context.Context, _ *alert.Alert) error { return nil } -func (NoopEscalator) OnAlertAcknowledged(_ context.Context, _ string, _ alert.Acknowledgment) error { - return nil -} -func (NoopEscalator) OnAlertResolved(_ context.Context, _ string, _ time.Time) error { return nil } -func (NoopEscalator) OnEditionDowngraded(_ context.Context) error { return nil } - -// NoopEntityRouter is the CE default. Implements alert.EntityRouter. -type NoopEntityRouter struct{} - -func (NoopEntityRouter) Route(_ context.Context, _ string, _ string, _ string) ([]string, error) { - return nil, nil -} +import "context" // NoopMaintenanceSuppressor is the CE default. Implements alert.MaintenanceSuppressor. type NoopMaintenanceSuppressor struct{} diff --git a/internal/extension/capability.go b/internal/extension/capability.go index 21d61e8a..6dcf83fb 100644 --- a/internal/extension/capability.go +++ b/internal/extension/capability.go @@ -11,8 +11,6 @@ package extension -import "maps" - // Capability names a unit of functionality that an edition may or may not // open. The identifiers are the ones already exposed by GET /api/v1/edition, // and the MCP tools use the same vocabulary, so a capability resolves the same @@ -51,37 +49,6 @@ const ( CapPersonalization Capability = "personalization" ) -// minEdition is the single source of truth for authorization. The REST -// middleware, the MCP tools and the /api/v1/edition response all read it, which -// is what keeps the three surfaces from drifting apart. -var minEdition = map[Capability]Edition{ - CapAlertRouting: Community, - CapSwarmDashboard: Community, - CapK8sCluster: Community, - CapResourceHistory: Community, - - CapMultihost: Personal, - CapCVEEnrichment: Personal, - CapRiskScoring: Personal, - CapChangelog: Personal, - CapIncidents: Personal, - CapSMTP: Personal, - CapAlertAdvancedFilters: Personal, - CapSecurityPosture: Personal, - CapOCSPStapling: Personal, - CapTelegram: Personal, - - CapSlack: Pro, - CapTeams: Pro, - CapAlertEscalation: Pro, - CapAlertEntityRouting: Pro, - CapMaintenanceWindows: Pro, - CapSubscribers: Pro, - CapPersonalization: Pro, -} - -// MinEdition returns the lowest edition that opens c. An unknown capability -// resolves to Pro: a capability nobody declared is not one we hand out. // channelCapabilities maps a notification channel type to the capability that // opens it. A type absent from the map is open in every edition. var channelCapabilities = map[string]Capability{ @@ -98,11 +65,9 @@ func ChannelCapability(channelType string) (Capability, bool) { return c, ok } +// MinEdition returns the lowest edition that opens c. func MinEdition(c Capability) Edition { - if e, ok := minEdition[c]; ok { - return e - } - return Pro + return policy.MinEdition(c) } // Allows reports whether the running edition opens c. @@ -113,7 +78,10 @@ func Allows(c Capability) bool { // Catalog returns a copy of the capability table, for callers that project it // (the edition endpoint) rather than query it. func Catalog() map[Capability]Edition { - out := make(map[Capability]Edition, len(minEdition)) - maps.Copy(out, minEdition) + caps := policy.Capabilities() + out := make(map[Capability]Edition, len(caps)) + for _, c := range caps { + out[c] = policy.MinEdition(c) + } return out } diff --git a/internal/extension/capability_test.go b/internal/extension/capability_test.go index 742ff7da..793b556f 100644 --- a/internal/extension/capability_test.go +++ b/internal/extension/capability_test.go @@ -1,9 +1,20 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant package extension -import "testing" +import ( + "testing" + "time" +) -// withEdition swaps the global edition for the duration of a test. Tests using -// it cannot run in parallel with each other. func withEdition(t *testing.T, e Edition) { t.Helper() prev := CurrentEdition @@ -11,88 +22,119 @@ func withEdition(t *testing.T, e Edition) { t.Cleanup(func() { CurrentEdition = prev }) } -// TestAllows_EveryEditionEveryCapability walks the whole matrix — 3 editions × -// 21 capabilities — and asserts Allows agrees with the declared order. This is -// SC-003 on the extension side. -func TestAllows_EveryEditionEveryCapability(t *testing.T) { +func withPolicy(t *testing.T, p Policy) { + t.Helper() + prev := policy + policy = p + t.Cleanup(func() { policy = prev }) +} + +type fakePolicy struct { + min map[Capability]Edition + limits map[Edition]map[Resource]int + history map[Edition]time.Duration +} + +func (f fakePolicy) Capabilities() []Capability { + out := make([]Capability, 0, len(f.min)) + for c := range f.min { + out = append(out, c) + } + return out +} + +func (f fakePolicy) MinEdition(c Capability) Edition { + if e, ok := f.min[c]; ok { + return e + } + return Pro +} + +func (f fakePolicy) Limit(e Edition, r Resource) int { return f.limits[e][r] } + +func (f fakePolicy) HistoryCap(e Edition) time.Duration { return f.history[e] } + +func TestDefaultPolicy_OpensOnlyCommunityCapabilities(t *testing.T) { catalog := Catalog() - if len(catalog) != 21 { - t.Fatalf("catalog holds %d capabilities, expected 21", len(catalog)) + if len(catalog) != 4 { + t.Fatalf("default catalog holds %d capabilities, expected 4", len(catalog)) + } + for _, c := range []Capability{CapAlertRouting, CapSwarmDashboard, CapK8sCluster, CapResourceHistory} { + if got := catalog[c]; got != Community { + t.Errorf("default catalog[%q] = %q, want %q", c, got, Community) + } + if !Allows(c) { + t.Errorf("Allows(%q) under the default policy = false, want true", c) + } + } + for _, c := range []Capability{CapMultihost, CapTelegram, CapSlack, CapAlertEscalation, "no_such_capability"} { + if Allows(c) { + t.Errorf("Allows(%q) under the default policy = true, want false", c) + } } +} + +func TestAllows_DelegatesToThePolicy(t *testing.T) { + withPolicy(t, fakePolicy{min: map[Capability]Edition{CapSlack: Personal, CapMultihost: Community}}) - for _, edition := range []Edition{Community, Personal, Pro} { - for cap, min := range catalog { - t.Run(string(edition)+"/"+string(cap), func(t *testing.T) { - withEdition(t, edition) - want := edition.AtLeast(min) - if got := Allows(cap); got != want { - t.Errorf("Allows(%q) under %q = %v, want %v (min edition %q)", - cap, edition, got, want, min) - } - }) + cases := []struct { + edition Edition + cap Capability + want bool + }{ + {Community, CapMultihost, true}, + {Community, CapSlack, false}, + {Personal, CapSlack, true}, + {Personal, "no_such_capability", false}, + {Pro, "no_such_capability", true}, + } + for _, c := range cases { + withEdition(t, c.edition) + if got := Allows(c.cap); got != c.want { + t.Errorf("Allows(%q) under %q = %v, want %v", c.cap, c.edition, got, c.want) } } + + catalog := Catalog() + if len(catalog) != 2 || catalog[CapSlack] != Personal || catalog[CapMultihost] != Community { + t.Errorf("Catalog() = %v, want the policy table", catalog) + } } -// TestCatalog_ReturnsACopy: the registry must not be mutable through Catalog. func TestCatalog_ReturnsACopy(t *testing.T) { c := Catalog() - c[CapAlertEscalation] = Community + c[CapAlertRouting] = Pro c["invented"] = Community - if got := MinEdition(CapAlertEscalation); got != Pro { - t.Errorf("mutating the Catalog copy changed the registry: alert_escalation = %q, want %q", got, Pro) + if got := MinEdition(CapAlertRouting); got != Community { + t.Errorf("mutating the Catalog copy changed the policy: alert_routing = %q, want %q", got, Community) } - if len(Catalog()) != 21 { - t.Errorf("mutating the Catalog copy changed the registry size: %d", len(Catalog())) + if len(Catalog()) != 4 { + t.Errorf("mutating the Catalog copy changed the catalog size: %d", len(Catalog())) } } -// TestMinEdition_TierMembership pins the three tiers, so a capability cannot -// silently change price. -func TestMinEdition_TierMembership(t *testing.T) { - tiers := map[Edition][]Capability{ - // resource_history is the right to see a history at all, which every - // edition has. What the paid editions buy is how far back: a duration, - // held in history_window.go, not a flag. - Community: {CapAlertRouting, CapSwarmDashboard, CapK8sCluster, CapResourceHistory}, - Personal: { - CapMultihost, CapCVEEnrichment, CapRiskScoring, CapChangelog, - CapIncidents, CapSMTP, CapAlertAdvancedFilters, - CapSecurityPosture, CapOCSPStapling, CapTelegram, - }, - Pro: { - CapSlack, CapTeams, CapAlertEscalation, CapAlertEntityRouting, - CapMaintenanceWindows, CapSubscribers, CapPersonalization, - }, - } - - total := 0 - for want, caps := range tiers { - total += len(caps) - for _, c := range caps { - if got := MinEdition(c); got != want { - t.Errorf("MinEdition(%q) = %q, want %q", c, got, want) - } +func TestChannelCapability(t *testing.T) { + for channel, want := range map[string]Capability{ + "slack": CapSlack, "teams": CapTeams, "email": CapSMTP, "telegram": CapTelegram, + } { + got, ok := ChannelCapability(channel) + if !ok || got != want { + t.Errorf("ChannelCapability(%q) = (%q, %v), want (%q, true)", channel, got, ok, want) } } - if total != 21 { - t.Errorf("the three tiers list %d capabilities, expected 21", total) + if _, ok := ChannelCapability("webhook"); ok { + t.Error("webhook must not be gated") } } -// TestMinEdition_UnknownCapability: an undeclared capability grants nothing. -func TestMinEdition_UnknownCapability(t *testing.T) { - if got := MinEdition("no_such_capability"); got != Pro { - t.Errorf("MinEdition of an unknown capability = %q, want %q", got, Pro) - } +func TestNewEditionSource_NoneRegistered(t *testing.T) { + prev := newSource + newSource = nil + t.Cleanup(func() { newSource = prev }) - withEdition(t, Personal) - if Allows("no_such_capability") { - t.Error("an unknown capability must not be allowed on Personal") - } - withEdition(t, Pro) - if !Allows("no_such_capability") { - t.Error("an unknown capability resolves to Pro, so Pro must allow it") + src, err := NewEditionSource(SourceConfig{LicenseKey: "key"}) + if err != nil || src != nil { + t.Errorf("NewEditionSource without a registered factory = (%v, %v), want (nil, nil)", src, err) } } diff --git a/internal/extension/edition.go b/internal/extension/edition.go index 6e319086..dd838968 100644 --- a/internal/extension/edition.go +++ b/internal/extension/edition.go @@ -62,5 +62,4 @@ func ParseEdition(s string) (Edition, bool) { var ErrNotAvailable = errors.New("this feature requires an extended edition of maintenant") // CurrentEdition returns the edition of the running binary. -// CE always returns Community. Extended editions override this via the build. var CurrentEdition = func() Edition { return Community } diff --git a/internal/extension/extension_test.go b/internal/extension/extension_test.go index 7437e08a..e4602c97 100644 --- a/internal/extension/extension_test.go +++ b/internal/extension/extension_test.go @@ -14,9 +14,6 @@ package extension import ( "context" "testing" - "time" - - "github.com/kolapsis/maintenant/internal/alert" ) func TestCurrentEditionReturnsCommunity(t *testing.T) { @@ -90,37 +87,6 @@ func TestParseEdition(t *testing.T) { } } -func TestNoopEscalator(t *testing.T) { - ctx := context.Background() - n := NoopEscalator{} - - if err := n.EvaluateCycle(ctx); err != nil { - t.Fatalf("EvaluateCycle: unexpected error: %v", err) - } - if err := n.OnAlertCreated(ctx, &alert.Alert{ID: "1"}); err != nil { - t.Fatalf("OnAlertCreated: unexpected error: %v", err) - } - if err := n.OnAlertAcknowledged(ctx, "1", alert.Acknowledgment{By: "alice", At: time.Now()}); err != nil { - t.Fatalf("OnAlertAcknowledged: unexpected error: %v", err) - } - if err := n.OnAlertResolved(ctx, "1", time.Now()); err != nil { - t.Fatalf("OnAlertResolved: unexpected error: %v", err) - } - if err := n.OnEditionDowngraded(ctx); err != nil { - t.Fatalf("OnEditionDowngraded: unexpected error: %v", err) - } -} - -func TestNoopEntityRouter(t *testing.T) { - channels, err := NoopEntityRouter{}.Route(context.Background(), "container", "c-1", "critical") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if channels != nil { - t.Fatal("expected nil channels") - } -} - func TestNoopMaintenanceSuppressor(t *testing.T) { suppressed, err := NoopMaintenanceSuppressor{}.IsSuppressed(context.Background(), "update", "container", "c-1") if err != nil { @@ -130,51 +96,3 @@ func TestNoopMaintenanceSuppressor(t *testing.T) { t.Fatal("expected not suppressed") } } - -func TestNoopIncidentManager(t *testing.T) { - ctx := context.Background() - m := NoopIncidentManager{} - - if err := m.HandleAlertEvent(ctx, alert.Event{}); err != nil { - t.Fatalf("HandleAlertEvent: unexpected error: %v", err) - } - - incidents, err := m.ListActiveIncidents(ctx) - if err != nil { - t.Fatalf("ListActiveIncidents: unexpected error: %v", err) - } - if incidents != nil { - t.Fatal("expected nil incidents") - } - - recent, err := m.ListRecentIncidents(ctx, 10) - if err != nil { - t.Fatalf("ListRecentIncidents: unexpected error: %v", err) - } - if recent != nil { - t.Fatal("expected nil recent incidents") - } -} - -func TestNoopSubscriberNotifier(t *testing.T) { - if err := (NoopSubscriberNotifier{}).NotifyAll(context.Background(), "subject", "

body

"); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestNoopMaintenanceScheduler(t *testing.T) { - ctx := context.Background() - s := NoopMaintenanceScheduler{} - - if err := s.Start(ctx); err != nil { - t.Fatalf("Start: unexpected error: %v", err) - } - - windows, err := s.ListUpcoming(ctx) - if err != nil { - t.Fatalf("ListUpcoming: unexpected error: %v", err) - } - if windows != nil { - t.Fatal("expected nil windows") - } -} diff --git a/internal/extension/history_window.go b/internal/extension/history_window.go index 1504f6c9..ff34c0cd 100644 --- a/internal/extension/history_window.go +++ b/internal/extension/history_window.go @@ -33,18 +33,8 @@ type HistoryWindowSpec struct { MinEdition string `json:"min_edition"` } -// editionHistoryCap is how far back each edition may look. Three entries, and -// the only place a change of the commercial tiering has to touch. It is a -// duration and not a list of windows on purpose: adding a window to the product -// must not require editing this table. -var editionHistoryCap = map[Edition]time.Duration{ - Community: 7 * 24 * time.Hour, - Personal: 30 * 24 * time.Hour, - Pro: 90 * 24 * time.Hour, -} - // historyWindows are the windows the product serves, ordered by duration. -// Adding one here adds neither a capability nor an entry in editionHistoryCap: +// Adding one here adds neither a capability nor an edition cap: // the edition that opens it is derived, never written down. var historyWindows = []HistoryWindow{ {Name: "1h", Duration: time.Hour}, @@ -58,15 +48,8 @@ var historyWindows = []HistoryWindow{ // editionOrder is the ascending edition order the derivation walks. var editionOrder = []Edition{Community, Personal, Pro} -// historyCap returns how far back e may look. An edition this binary does not -// know falls back to the Community cap rather than to zero: it keeps -// MaxHistoryWindow from returning an empty window, and it falls on the safe -// side, since an unreadable edition opens nothing that is paid for. func historyCap(e Edition) time.Duration { - if d, ok := editionHistoryCap[e]; ok { - return d - } - return editionHistoryCap[Community] + return policy.HistoryCap(e) } // MinEditionForHistoryWindow returns the lowest edition whose cap covers w. diff --git a/internal/extension/history_window_test.go b/internal/extension/history_window_test.go index 37283214..cfaacea0 100644 --- a/internal/extension/history_window_test.go +++ b/internal/extension/history_window_test.go @@ -23,21 +23,6 @@ func TestHistoryWindowCatalog_IsOrderedByDuration(t *testing.T) { // The minimum edition of every window is derived from the three caps. This is // the table the specification states, and nothing writes it by hand. -func TestMinEditionForHistoryWindow_IsDerivedFromTheCaps(t *testing.T) { - want := map[string]Edition{ - "1h": Community, "6h": Community, "24h": Community, "7d": Community, - "30d": Personal, - "90d": Pro, - } - for name, expected := range want { - w, ok := ResolveHistoryWindow(name) - require.True(t, ok, "window %q is not in the catalogue", name) - assert.Equal(t, expected, MinEditionForHistoryWindow(w), "window %q", name) - } -} - -// The catalogue projection carries the same derivation, and it is identical in -// every edition: it describes the product, not the running tier. func TestHistoryWindowCatalog_IsTheSameInEveryEdition(t *testing.T) { var first []HistoryWindowSpec for _, e := range []Edition{Community, Personal, Pro} { @@ -51,56 +36,6 @@ func TestHistoryWindowCatalog_IsTheSameInEveryEdition(t *testing.T) { } } -func TestMaxHistoryWindow_PerEdition(t *testing.T) { - for edition, want := range map[Edition]string{ - Community: "7d", - Personal: "30d", - Pro: "90d", - } { - withEdition(t, edition) - assert.Equal(t, want, MaxHistoryWindow().Name, "edition %q", edition) - } -} - -func TestAllowsHistoryWindow_MatchesTheCaps(t *testing.T) { - type want struct { - allowed bool - required Edition - } - matrix := map[Edition]map[string]want{ - Community: { - "1h": {true, Community}, "6h": {true, Community}, - "24h": {true, Community}, "7d": {true, Community}, - "30d": {false, Personal}, - "90d": {false, Pro}, - }, - Personal: { - "1h": {true, Community}, "6h": {true, Community}, - "24h": {true, Community}, "7d": {true, Community}, - "30d": {true, Personal}, - "90d": {false, Pro}, - }, - Pro: { - "1h": {true, Community}, "6h": {true, Community}, - "24h": {true, Community}, "7d": {true, Community}, - "30d": {true, Personal}, - "90d": {true, Pro}, - }, - } - for edition, windows := range matrix { - withEdition(t, edition) - for name, expected := range windows { - w, ok := ResolveHistoryWindow(name) - require.True(t, ok) - allowed, required := AllowsHistoryWindow(w) - assert.Equal(t, expected.allowed, allowed, "%s / %s", edition, name) - assert.Equal(t, expected.required, required, "%s / %s required edition", edition, name) - } - } -} - -// A window nobody declared is not resolved at all. The caller turns that into a -// bad request, which is a different refusal from one the edition does not open. func TestResolveHistoryWindow_RejectsWhatTheProductDoesNotServe(t *testing.T) { for _, name := range []string{"12h", "2d", "", "1H", "365d"} { _, ok := ResolveHistoryWindow(name) @@ -111,26 +46,43 @@ func TestResolveHistoryWindow_RejectsWhatTheProductDoesNotServe(t *testing.T) { // An edition absent from the cap table falls back to the Community floor, never // to zero. Unreachable from the server, which reports its own edition, but it // is what keeps max_window from being empty in the /api/v1/edition contract. -func TestMaxHistoryWindow_UnknownEditionFallsBackToTheFloor(t *testing.T) { - withEdition(t, Edition("enterprise-2030")) - - assert.Equal(t, "7d", MaxHistoryWindow().Name) - - paid, ok := ResolveHistoryWindow("30d") - require.True(t, ok) - allowed, required := AllowsHistoryWindow(paid) - assert.False(t, allowed, "an unreadable edition opens nothing that is paid for") - assert.Equal(t, Personal, required) -} - func TestHistoryWindowNames_ListsTheWholeCatalogue(t *testing.T) { assert.Equal(t, "1h, 6h, 24h, 7d, 30d, 90d", HistoryWindowNames()) } // The caps themselves, asserted once so a change of tiering is a deliberate act // and not a silent edit. -func TestEditionHistoryCap_IsTheAnnouncedTiering(t *testing.T) { - assert.Equal(t, 7*24*time.Hour, editionHistoryCap[Community]) - assert.Equal(t, 30*24*time.Hour, editionHistoryCap[Personal]) - assert.Equal(t, 90*24*time.Hour, editionHistoryCap[Pro]) + +func TestHistoryWindows_DefaultPolicyOpensSevenDays(t *testing.T) { + for _, e := range []Edition{Community, Personal, Pro} { + withEdition(t, e) + assert.Equal(t, "7d", MaxHistoryWindow().Name, "edition %q", e) + } + + for name, want := range map[string]Edition{"7d": Community, "30d": Pro, "90d": Pro} { + w, ok := ResolveHistoryWindow(name) + require.True(t, ok) + assert.Equal(t, want, MinEditionForHistoryWindow(w), "window %q", name) + } +} + +func TestHistoryWindows_DelegateToThePolicy(t *testing.T) { + withPolicy(t, fakePolicy{history: map[Edition]time.Duration{ + Community: 24 * time.Hour, + Personal: 7 * 24 * time.Hour, + Pro: 90 * 24 * time.Hour, + }}) + + withEdition(t, Personal) + assert.Equal(t, "7d", MaxHistoryWindow().Name) + + w, ok := ResolveHistoryWindow("30d") + require.True(t, ok) + allowed, required := AllowsHistoryWindow(w) + assert.False(t, allowed) + assert.Equal(t, Pro, required) + + w, ok = ResolveHistoryWindow("7d") + require.True(t, ok) + assert.Equal(t, Personal, MinEditionForHistoryWindow(w)) } diff --git a/internal/extension/multihost.go b/internal/extension/multihost.go deleted file mode 100644 index ebf42e23..00000000 --- a/internal/extension/multihost.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 Benjamin Touchard (Kolapsis) -// -// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) -// or a commercial license. You may not use this file except in compliance -// with one of these licenses. -// -// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html -// Commercial: See COMMERCIAL-LICENSE.md -// -// Source: https://github.com/kolapsis/maintenant - -package extension - -import "context" - -// MultiHostAgent is the interface for the optional multi-host server/agent extension. -// CE uses NoopMultiHostAgent. Pro overrides via build injection. -type MultiHostAgent interface { - StartServer(ctx context.Context) error - StartAgent(ctx context.Context) error - Stop() error -} - -// NoopMultiHostAgent is the CE default — returns ErrNotAvailable for server/agent modes. -type NoopMultiHostAgent struct{} - -func (NoopMultiHostAgent) StartServer(_ context.Context) error { return ErrNotAvailable } -func (NoopMultiHostAgent) StartAgent(_ context.Context) error { return ErrNotAvailable } -func (NoopMultiHostAgent) Stop() error { return nil } diff --git a/internal/extension/policy.go b/internal/extension/policy.go new file mode 100644 index 00000000..9cc112f8 --- /dev/null +++ b/internal/extension/policy.go @@ -0,0 +1,130 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant + +package extension + +import ( + "context" + "log/slog" + "slices" + "time" +) + +// Policy is the tier table the core queries to decide what an edition opens. +type Policy interface { + Capabilities() []Capability + MinEdition(c Capability) Edition + Limit(e Edition, r Resource) int + HistoryCap(e Edition) time.Duration +} + +// LicenseStatusUpdateWindowEnded is the licence status of a build released after its update window and grace. +const LicenseStatusUpdateWindowEnded = "update_window_ended" + +// LicenseState is the licence status reported by an EditionSource. +type LicenseState struct { + Edition Edition `json:"edition"` + Plan string `json:"plan,omitempty"` + Features []string `json:"features,omitempty"` + Status string `json:"status"` + VerifiedAt time.Time `json:"verified_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Message string `json:"message,omitempty"` + UpdatesUntil time.Time `json:"updates_until,omitempty"` + UpdateGraceUntil time.Time `json:"update_grace_until,omitempty"` +} + +// EditionChangeCallback is invoked after the edition moves from prev to next. +type EditionChangeCallback func(ctx context.Context, prev, next Edition) + +// EditionSource resolves the running edition from a licence. +type EditionSource interface { + Edition() Edition + State() *LicenseState + RegisterEditionChangeCallback(cb EditionChangeCallback) + Start(ctx context.Context) + Stop() +} + +// SourceConfig carries what an EditionSource needs to read and verify a licence. +type SourceConfig struct { + LicenseKey string + PublicKeyB64 string + DataDir string + Version string + BuildDate string + Logger *slog.Logger +} + +// SourceFactory builds an EditionSource, or returns nil when there is no licence to read. +type SourceFactory func(cfg SourceConfig) (EditionSource, error) + +var ( + policy Policy = CommunityPolicy{} + newSource SourceFactory +) + +// Register installs the tier table and the licence-backed edition source. +func Register(p Policy, f SourceFactory) { + policy = p + newSource = f +} + +// NewEditionSource builds the registered edition source, or returns nil when none applies. +func NewEditionSource(cfg SourceConfig) (EditionSource, error) { + if newSource == nil { + return nil, nil + } + return newSource(cfg) +} + +var communityCapabilities = []Capability{ + CapAlertRouting, + CapSwarmDashboard, + CapK8sCluster, + CapResourceHistory, +} + +var communityLimits = map[Resource]int{ + ResourceEndpoints: 10, + ResourceHeartbeats: 5, + ResourceCertificates: 5, + ResourceStatusComponents: 3, + ResourceAgentHosts: 0, +} + +const communityHistoryCap = 7 * 24 * time.Hour + +// CommunityPolicy is the tier table of the Community edition, the one the core applies when nothing is registered. +type CommunityPolicy struct{} + +// Capabilities lists the capabilities Community opens. +func (CommunityPolicy) Capabilities() []Capability { + return slices.Clone(communityCapabilities) +} + +// MinEdition returns Community for a Community capability and Pro for any other. +func (CommunityPolicy) MinEdition(c Capability) Edition { + if slices.Contains(communityCapabilities, c) { + return Community + } + return Pro +} + +// Limit returns the Community cap on r, whatever the edition. +func (CommunityPolicy) Limit(_ Edition, r Resource) int { + return communityLimits[r] +} + +// HistoryCap returns the Community history cap, whatever the edition. +func (CommunityPolicy) HistoryCap(_ Edition) time.Duration { + return communityHistoryCap +} diff --git a/internal/extension/quota.go b/internal/extension/quota.go index 4a3f2363..5889b518 100644 --- a/internal/extension/quota.go +++ b/internal/extension/quota.go @@ -25,52 +25,28 @@ const ( // Unlimited is the limit value meaning "no cap". It is reported to the UI as-is. const Unlimited = -1 -// Limit returns the cap for r under the running edition. This is the only -// declaration of the caps: the value that refuses a creation and the value the -// interface displays both come from here, so they cannot drift apart. -// -// endpoints and certificates count only what an operator created by hand; -// entries discovered from container labels are never capped nor counted. -// -// agent_hosts is 0 on Community by design — the multihost capability itself is -// Personal, so the REST routes refuse before any count happens. The 0 still -// matters: it is what the gRPC enrollment barrier reads, and it has no -// middleware in front of it. +var resources = []Resource{ + ResourceEndpoints, + ResourceHeartbeats, + ResourceCertificates, + ResourceStatusComponents, + ResourceAgentHosts, +} + +// Limit returns the cap for r under the running edition. func Limit(r Resource) int { - edition := CurrentEdition() + return policy.Limit(CurrentEdition(), r) +} - switch r { - case ResourceEndpoints: - if edition.AtLeast(Personal) { - return Unlimited - } - return 10 - case ResourceHeartbeats: - if edition.AtLeast(Personal) { - return Unlimited - } - return 5 - case ResourceCertificates: - if edition.AtLeast(Personal) { - return Unlimited - } - return 5 - case ResourceStatusComponents: - if edition.AtLeast(Personal) { - return Unlimited - } - return 3 - case ResourceAgentHosts: - switch { - case edition.AtLeast(Pro): - return Unlimited - case edition.AtLeast(Personal): - return 20 - default: - return 0 +// Tiers returns the cap of every resource for every edition. +func Tiers() map[Edition]map[Resource]int { + out := make(map[Edition]map[Resource]int, len(editionOrder)) + for _, e := range editionOrder { + limits := make(map[Resource]int, len(resources)) + for _, r := range resources { + limits[r] = policy.Limit(e, r) } - default: - // An undeclared resource is not one we hand out capacity for. - return 0 + out[e] = limits } + return out } diff --git a/internal/extension/quota_test.go b/internal/extension/quota_test.go index ca29e9d6..a4f2a766 100644 --- a/internal/extension/quota_test.go +++ b/internal/extension/quota_test.go @@ -1,65 +1,69 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant package extension -import "testing" +import ( + "reflect" + "testing" +) -// TestLimit_Matrix pins the caps for the five resources across the three -// editions. This table is the only declaration; if it changes, the value that -// refuses a creation and the value shown in the UI change together. -func TestLimit_Matrix(t *testing.T) { - want := map[Resource]map[Edition]int{ - ResourceEndpoints: {Community: 10, Personal: Unlimited, Pro: Unlimited}, - ResourceHeartbeats: {Community: 5, Personal: Unlimited, Pro: Unlimited}, - ResourceCertificates: {Community: 5, Personal: Unlimited, Pro: Unlimited}, - ResourceStatusComponents: {Community: 3, Personal: Unlimited, Pro: Unlimited}, - ResourceAgentHosts: {Community: 0, Personal: 20, Pro: Unlimited}, +func TestLimit_DefaultPolicyIsCommunityInEveryEdition(t *testing.T) { + want := map[Resource]int{ + ResourceEndpoints: 10, + ResourceHeartbeats: 5, + ResourceCertificates: 5, + ResourceStatusComponents: 3, + ResourceAgentHosts: 0, + "no_such_resource": 0, } - - if len(want) != 5 { - t.Fatalf("the matrix covers %d resources, expected 5", len(want)) - } - - for resource, perEdition := range want { - for edition, limit := range perEdition { - t.Run(string(edition)+"/"+string(resource), func(t *testing.T) { - withEdition(t, edition) - if got := Limit(resource); got != limit { - t.Errorf("Limit(%q) under %q = %d, want %d", resource, edition, got, limit) - } - }) + for _, edition := range []Edition{Community, Personal, Pro} { + withEdition(t, edition) + for r, limit := range want { + if got := Limit(r); got != limit { + t.Errorf("Limit(%q) under %q with the default policy = %d, want %d", r, edition, got, limit) + } } } } -// TestUnlimited_IsMinusOne: the wire contract says -1 means unlimited, and the -// front-end keys off that exact value. func TestUnlimited_IsMinusOne(t *testing.T) { if Unlimited != -1 { t.Errorf("Unlimited = %d, want -1", Unlimited) } } -// TestLimit_UnknownResource: an undeclared resource grants no capacity. -func TestLimit_UnknownResource(t *testing.T) { - for _, edition := range []Edition{Community, Personal, Pro} { - withEdition(t, edition) - if got := Limit("no_such_resource"); got != 0 { - t.Errorf("Limit of an unknown resource under %q = %d, want 0", edition, got) - } +func TestLimit_DelegatesToThePolicy(t *testing.T) { + withPolicy(t, fakePolicy{limits: map[Edition]map[Resource]int{ + Personal: {ResourceEndpoints: 42}, + }}) + withEdition(t, Personal) + + if got := Limit(ResourceEndpoints); got != 42 { + t.Errorf("Limit(endpoints) = %d, want the policy value 42", got) } } -// TestLimit_AgentHostsIsTheOnlyCappedPaidResource guards the segmentation -// choice: Personal lifts every cap except the number of machines. -func TestLimit_AgentHostsIsTheOnlyCappedPaidResource(t *testing.T) { - withEdition(t, Personal) - for _, r := range []Resource{ - ResourceEndpoints, ResourceHeartbeats, ResourceCertificates, ResourceStatusComponents, - } { - if got := Limit(r); got != Unlimited { - t.Errorf("Limit(%q) on Personal = %d, want Unlimited", r, got) - } +func TestTiers_ProjectsEveryResourceForEveryEdition(t *testing.T) { + withPolicy(t, fakePolicy{limits: map[Edition]map[Resource]int{ + Community: {ResourceEndpoints: 1, ResourceAgentHosts: 0}, + Personal: {ResourceEndpoints: 2, ResourceAgentHosts: 20}, + Pro: {ResourceEndpoints: Unlimited, ResourceAgentHosts: Unlimited}, + }}) + + want := map[Edition]map[Resource]int{ + Community: {ResourceEndpoints: 1, ResourceHeartbeats: 0, ResourceCertificates: 0, ResourceStatusComponents: 0, ResourceAgentHosts: 0}, + Personal: {ResourceEndpoints: 2, ResourceHeartbeats: 0, ResourceCertificates: 0, ResourceStatusComponents: 0, ResourceAgentHosts: 20}, + Pro: {ResourceEndpoints: Unlimited, ResourceHeartbeats: 0, ResourceCertificates: 0, ResourceStatusComponents: 0, ResourceAgentHosts: Unlimited}, } - if got := Limit(ResourceAgentHosts); got != 20 { - t.Errorf("Limit(agent_hosts) on Personal = %d, want 20", got) + if got := Tiers(); !reflect.DeepEqual(got, want) { + t.Errorf("Tiers() = %v, want %v", got, want) } } diff --git a/internal/extension/status.go b/internal/extension/status.go deleted file mode 100644 index 38572016..00000000 --- a/internal/extension/status.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2026 Benjamin Touchard (Kolapsis) -// -// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) -// or a commercial license. You may not use this file except in compliance -// with one of these licenses. -// -// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html -// Commercial: See COMMERCIAL-LICENSE.md -// -// Source: https://github.com/kolapsis/maintenant - -package extension - -import ( - "context" - "time" - - "github.com/kolapsis/maintenant/internal/alert" -) - -// StatusIncident is a minimal representation for the status page API response. -type StatusIncident struct { - ID int64 - Title string - Severity string - Status string - CreatedAt time.Time - Updates []StatusIncidentUpdate -} - -// StatusIncidentUpdate is a timestamped entry in an incident timeline. -type StatusIncidentUpdate struct { - Status string - Message string - CreatedAt time.Time -} - -// StatusMaintenanceWindow is a minimal representation for the status page API response. -type StatusMaintenanceWindow struct { - ID int64 - Title string - Description string - StartsAt time.Time - EndsAt time.Time - Active bool -} - -// IncidentManager handles incident lifecycle on the status page. -// CE: no-op. Pro: full incident CRUD + auto-incident from alerts. -type IncidentManager interface { - HandleAlertEvent(ctx context.Context, evt alert.Event) error - ListActiveIncidents(ctx context.Context) ([]StatusIncident, error) - ListRecentIncidents(ctx context.Context, limit int) ([]StatusIncident, error) -} - -// SubscriberNotifier sends email notifications to status page subscribers. -// CE: no-op. Pro: SMTP delivery. -type SubscriberNotifier interface { - NotifyAll(ctx context.Context, subject string, htmlBody string) error -} - -// MaintenanceScheduler manages scheduled maintenance windows on the status page. -// CE: no-op. Pro: auto-activate/deactivate windows, create incidents, notify subscribers. -type MaintenanceScheduler interface { - Start(ctx context.Context) error - ListUpcoming(ctx context.Context) ([]StatusMaintenanceWindow, error) -} - -// NoopIncidentManager is the CE default. -type NoopIncidentManager struct{} - -func (NoopIncidentManager) HandleAlertEvent(_ context.Context, _ alert.Event) error { - return nil -} -func (NoopIncidentManager) ListActiveIncidents(_ context.Context) ([]StatusIncident, error) { - return nil, nil -} -func (NoopIncidentManager) ListRecentIncidents(_ context.Context, _ int) ([]StatusIncident, error) { - return nil, nil -} - -// NoopSubscriberNotifier is the CE default. -type NoopSubscriberNotifier struct{} - -func (NoopSubscriberNotifier) NotifyAll(_ context.Context, _ string, _ string) error { - return nil -} - -// NoopMaintenanceScheduler is the CE default. -type NoopMaintenanceScheduler struct{} - -func (NoopMaintenanceScheduler) Start(_ context.Context) error { return nil } -func (NoopMaintenanceScheduler) ListUpcoming(_ context.Context) ([]StatusMaintenanceWindow, error) { - return nil, nil -} diff --git a/internal/mcp/main_test.go b/internal/mcp/main_test.go new file mode 100644 index 00000000..c70fcfbd --- /dev/null +++ b/internal/mcp/main_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. You may not use this file except in compliance +// with one of these licenses. +// +// AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html +// Commercial: See COMMERCIAL-LICENSE.md +// +// Source: https://github.com/kolapsis/maintenant +package mcp + +import ( + "os" + "testing" + + "github.com/kolapsis/maintenant/internal/commercial" +) + +func TestMain(m *testing.M) { + commercial.Register() + os.Exit(m.Run()) +} diff --git a/internal/mcp/tools_edition.go b/internal/mcp/tools_edition.go index 55ac1649..c6433508 100644 --- a/internal/mcp/tools_edition.go +++ b/internal/mcp/tools_edition.go @@ -35,6 +35,7 @@ func getEditionHandler(svc *Services) gomcp.ToolHandlerFor[getEditionInput, any] "features": features, "feature_editions": featureEditions, "quotas": editionQuotas(ctx, svc), + "tiers": extension.Tiers(), "resource_history": map[string]any{ "max_window": maxWindow.Name, "max_window_seconds": int64(maxWindow.Duration / time.Second), From 83ca50a8f0c680c205d32d2f812948a695dc7147 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Thu, 24 Sep 2026 00:04:24 +0200 Subject: [PATCH 02/12] refactor(quota): read every cap from the tier table The endpoint, heartbeat and certificate services default to extension.Limit instead of their own literals, the refusal names the edition that lifts the cap through extension.LiftingEdition, and the upsell copy reads host counts from the edition tiers. A handler-level test covers every capped resource on Community, Personal and Pro. --- frontend/src/composables/useEdition.ts | 6 + frontend/src/layouts/DefaultLayout.vue | 6 +- frontend/src/pages/AgentsPage.vue | 10 +- internal/api/v1/middleware.go | 9 +- internal/api/v1/quota_tiers_test.go | 166 +++++++++++++++++++++++++ internal/certificate/service.go | 4 +- internal/commercial/tiers_test.go | 23 ++++ internal/endpoint/service.go | 5 +- internal/extension/quota.go | 11 ++ internal/extension/quota_test.go | 6 + internal/heartbeat/service.go | 5 +- 11 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 internal/api/v1/quota_tiers_test.go diff --git a/frontend/src/composables/useEdition.ts b/frontend/src/composables/useEdition.ts index 39bd72cb..600b014b 100644 --- a/frontend/src/composables/useEdition.ts +++ b/frontend/src/composables/useEdition.ts @@ -236,6 +236,11 @@ export function useEdition() { }) } + /** The cap on a resource in a given edition (-1 unlimited), or null when the engine reports none. */ + function tierLimit(tier: Edition, resource: QuotaResource): number | null { + return edition.value?.tiers?.[tier]?.[resource] ?? null + } + /** * The resource-history catalogue, exactly as the engine declares it. An * engine that does not report one gives an empty catalogue: the interface @@ -300,6 +305,7 @@ export function useEdition() { load, reload, getQuota, + tierLimit, licenseStatus, licenseMessage, licenseStatusValue, diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue index 0584db2b..bbd55666 100644 --- a/frontend/src/layouts/DefaultLayout.vue +++ b/frontend/src/layouts/DefaultLayout.vue @@ -68,7 +68,9 @@ const { licenseSeverity, licenseLabel, loadLicenseStatus, + tierLimit, } = useEdition() +const personalHostLimit = computed(() => tierLimit('personal', 'agent_hosts')) const swarmStore = useSwarmStore() const runtimeStore = useRuntimeStore() const storageStore = useStorageStore() @@ -369,8 +371,8 @@ watch(