From d15800b494d5fff92e40d2189d51dc5c6cb1ebf6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 12:07:22 +0700 Subject: [PATCH] feat: tolerate unusable runtime hours claims and decode -1 allocation as unlimited (#27984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes to the license/entitlements layer, preparing for runtime-hours usage reporting. **Tolerate unusable runtime hour claims.** Unusable `agent_runtime_hours_*` claim combinations no longer reject the whole license: rejecting a signed license over a cosmetic threshold claim would drop the deployment to unlicensed. `decodeAgentRuntimeHours` drops the unusable claims, surfaces the stable `LicenseAgentRuntimeHoursClaimsIgnoredWarningText` (deduplicated across licenses), and logs the affected license and claims through the new `FeatureArguments.Logger`; `validateAgentRuntimeHours` and its license-invalidating errors are removed. The dashboard recognizes the stable diagnostic text and renders it muted, with a "License notices" heading instead of the exceedance heading and without a sales link. **Unlimited allocation.** An `agent_runtime_hours_allocation` claim of exactly `-1` (`AgentRuntimeHoursUnlimitedAllocation`, mirrored in coder/license) is reserved to mean unlimited: it decodes to an enabled feature with no `limit` in `/api/v2/entitlements`, the shape the UI already renders as "Unlimited". Threshold claims alongside it have nothing to threshold against, so they are dropped with the claims-ignored warning, and any other negative allocation remains unusable. The issuer-side counterpart (refusing to mint `-1` together with threshold claims) is coder/license#49. The managed agent measurement path is intentionally untouched: managed agents are deprecated and slated for removal, so the shared usage-measurement failure policy (`measureUsage`) now lands in #27985 next to its runtime-hours consumer instead of converting a doomed call site here. Part 2 of a 3-PR stack splitting up #27796 (see there for review history). Stack: #27983 → this PR → #27985. --- codersdk/deployment.go | 12 +- codersdk/deployment_test.go | 25 + codersdk/licenses.go | 11 +- enterprise/coderd/coderd_test.go | 6 + .../coderd/coderdenttest/coderdenttest.go | 11 + enterprise/coderd/license/license.go | 174 ++++--- enterprise/coderd/license/license_test.go | 473 ++++++++++++++---- enterprise/coderd/licenses_test.go | 35 +- site/src/api/typesGenerated.ts | 4 + .../dashboard/LicenseBanner/LicenseBanner.tsx | 73 ++- .../LicenseBannerView.stories.tsx | 99 +++- .../LicenseBanner/LicenseBannerView.tsx | 41 +- 12 files changed, 728 insertions(+), 236 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5c5cd4fc5d..d5550c6a88 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -425,7 +425,7 @@ type UsagePeriod struct { // 2. The usage period has a greater end date (note: only certain features use usage periods) // 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values) // 4. The entitlement is greater -// 5. The limit is greater +// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit) // 6. Enabled is greater than disabled // 7. The actual is greater // @@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int { return entitlementDifference } - // If the entitlement is the same, then we can compare the limits. + // If the entitlement is the same, then we can compare the limits. A nil + // limit on a usage period feature means unlimited, so it outranks any set + // limit; on other features a nil limit loses to a set one. if f.Limit == nil && b.Limit != nil { + if bothHaveUsagePeriod { + return 1 + } return -1 } if f.Limit != nil && b.Limit == nil { + if bothHaveUsagePeriod { + return -1 + } return 1 } if f.Limit != nil && b.Limit != nil { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 5d366ee88e..88a0e60168 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -1226,6 +1226,31 @@ func TestFeatureComparison(t *testing.T) { }, Expected: 1, }, + { + // A nil limit on a usage period feature means unlimited, so it + // outranks a set limit on an exact usage period tie. + Name: "UnlimitedUsagePeriodOutranksMeteredOnTie", + A: codersdk.Feature{ + Entitlement: codersdk.EntitlementEntitled, + Enabled: true, + UsagePeriod: &codersdk.UsagePeriod{ + IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + }, + }, + B: codersdk.Feature{ + Entitlement: codersdk.EntitlementEntitled, + Enabled: true, + Limit: ptr.Ref(int64(100)), + UsagePeriod: &codersdk.UsagePeriod{ + IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + }, + }, + Expected: 1, + }, } for _, tc := range testCases { diff --git a/codersdk/licenses.go b/codersdk/licenses.go index f2ccf00984..414cfbcf04 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -12,11 +12,12 @@ import ( ) const ( - LicenseExpiryClaim = "license_expires" - LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" - LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows." - LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats." - LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)." + LicenseExpiryClaim = "license_expires" + LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled" + LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows." + LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats." + LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)." + LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued." ) type AddLicenseRequest struct { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index e023da7b1d..7be0140535 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) { // Enable all features features := make(license.Features) for _, feature := range codersdk.FeatureNames { + if feature == codersdk.FeatureAgentRuntimeHours { + // The feature name is not a valid license claim; the + // feature is encoded as its allocation claim. + features[license.ClaimAgentRuntimeHoursAllocation] = 1 + continue + } features[feature] = 1 } features[codersdk.FeatureUserLimit] = 100 diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 1115ba1211..1d2678fd78 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -237,6 +237,17 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions { return opts.Feature(codersdk.FeatureManagedAgentLimit, limit) } +func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions { + opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation) + if softLimit != nil { + opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit) + } + if hardLimit != nil { + opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit) + } + return opts +} + func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions { if opts.Features == nil { opts.Features = license.Features{} diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 8cce657de6..366a853706 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -93,6 +93,7 @@ func Entitlements( } entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ + Logger: logger, ActiveUserCount: activeUserCount, ActiveAISeatCount: activeAISeatCount, ReplicaCount: replicaCount, @@ -129,6 +130,7 @@ func Entitlements( } type FeatureArguments struct { + Logger slog.Logger ActiveUserCount int64 ActiveAISeatCount int64 ReplicaCount int @@ -508,11 +510,8 @@ func LicensesEntitlements( continue } - // Agent runtime hours are encoded as up to three claims and are - // decoded together after this loop, see - // decodeAgentRuntimeHours. The feature name itself is never a - // valid claim. The allocation must come from the dedicated claim - // so it is validated against the soft and hard limits. + // Agent runtime hour claims are decoded together after this + // loop; see decodeAgentRuntimeHours. if featureName == codersdk.FeatureAgentRuntimeHours || isAgentRuntimeHoursClaim(featureName) { continue @@ -577,14 +576,23 @@ func LicensesEntitlements( } } - // The loop above skips Agent runtime hours because the - // three claims that encode them decode into a single feature. - if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{ + runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{ IssuedAt: claims.IssuedAt.Time, Start: usagePeriodStart, End: usagePeriodEnd, - }); ok { - entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature) + }) + if granted { + entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature) + } + if len(ignoredClaims) > 0 { + featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license", + slog.F("license_id", license.UUID), + slog.F("ignored_claims", ignoredClaims), + ) + if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) { + entitlements.Warnings = append(entitlements.Warnings, + codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) + } } addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature) @@ -885,26 +893,32 @@ const ( VersionClaim = "version" ) -// Agent runtime hour license claims. These are the canonical claim names -// minted by github.com/coder/license. All three claims map to the single -// codersdk.FeatureAgentRuntimeHours feature and are validated together when -// the license is parsed, see validateClaims. -// -// The unit for all three claims is hours. +// Agent runtime hour license claims, minted by github.com/coder/license. +// All three are in hours and decode together into the single +// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours. const ( // ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour // allocation for the license term. It becomes the feature's Limit. + // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean + // unlimited; any other negative allocation is ignored, in which case + // the license does not grant the feature. ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation" // ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It - // must satisfy 0 <= soft < allocation, so it may only be set when the - // allocation is greater than 0. It becomes the feature's SoftLimit. + // becomes the feature's SoftLimit when 0 <= soft < allocation and is + // ignored otherwise. ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft" - // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be - // absent or >= allocation, and may only be set when the allocation is - // greater than 0. It becomes the feature's HardLimit. + // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes + // the feature's HardLimit when the allocation is greater than 0 and + // hard >= allocation, and is ignored otherwise. ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard" ) +// AgentRuntimeHoursUnlimitedAllocation is the reserved +// ClaimAgentRuntimeHoursAllocation value meaning the license grants +// unlimited runtime hours. It decodes to an enabled feature with a nil +// Limit. Mirrored in github.com/coder/license. +const AgentRuntimeHoursUnlimitedAllocation int64 = -1 + var ( ValidMethods = []string{"EdDSA"} @@ -917,19 +931,12 @@ var ( ErrMultipleIssues = xerrors.New("license has multiple issues; contact support") ErrMissingAccountType = xerrors.New("license must contain valid account type") ErrMissingAccountID = xerrors.New("license must contain valid account ID") - - ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation) - ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation) ) type Features map[codersdk.FeatureName]int64 -// isAgentRuntimeHoursClaim reports whether the claim name is one of the three -// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These -// claims are decoded together, see decodeAgentRuntimeHours. +// isAgentRuntimeHoursClaim reports whether name is one of the three claims +// decoded by decodeAgentRuntimeHours. func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { switch name { case ClaimAgentRuntimeHoursAllocation, @@ -941,62 +948,76 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { } } -// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature -// from the claims that encode it. It reports false when the license carries no -// allocation claim, in which case the license does not grant the feature. +// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours +// feature from its claims. granted is false when there is no usable +// allocation claim; per-claim validity rules live on the Claim* constants +// above. // -// The claim combination is validated when the license is parsed, see -// Features.validateAgentRuntimeHours. The allocation is never negative here -// and the soft and hard limits are only present alongside a positive -// allocation. -func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) { - allocation, ok := features[ClaimAgentRuntimeHoursAllocation] - if !ok { - return codersdk.Feature{}, false +// Unusable claims are dropped rather than invalidating the license, since +// rejecting a signed license over a cosmetic claim would drop the deployment +// to unlicensed. Each dropped claim is returned in ignoredClaims so the +// caller can warn and log instead of letting an incorrectly issued license +// look healthy. +// +// A zero allocation grants the feature disabled, but Actual is still +// measured and published. +func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) { + if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok { + ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours)) } - feature := codersdk.Feature{ + allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation] + soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft] + hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard] + + if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation { + if softOk { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft) + } + if hardOk { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard) + } + return codersdk.Feature{ + Enabled: true, + Entitlement: entitlement, + UsagePeriod: &usagePeriod, + }, true, ignoredClaims + } + + if !allocOk || allocation < 0 { + if allocOk && allocation < 0 { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation) + } + if softOk { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft) + } + if hardOk { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard) + } + return codersdk.Feature{}, false, ignoredClaims + } + + feature = codersdk.Feature{ Enabled: allocation > 0, Entitlement: entitlement, Limit: &allocation, UsagePeriod: &usagePeriod, } - if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok { - feature.SoftLimit = &soft - } - if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok { - feature.HardLimit = &hard - } - return feature, true -} - -// validateAgentRuntimeHours validates the relationship between the agent -// runtime hour claims. Invalid combinations reject the entire license. -func (f Features) validateAgentRuntimeHours() error { - allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation] - soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft] - hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard] - if !hasAllocation { - if hasSoft || hasHard { - return ErrMissingAgentRuntimeHoursAllocation + if softOk { + if soft >= 0 && soft < allocation { + feature.SoftLimit = &soft + } else { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft) } - return nil } - if allocation < 0 { - return ErrInvalidAgentRuntimeHoursAllocation + if hardOk { + if allocation > 0 && hard >= allocation { + feature.HardLimit = &hard + } else { + ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard) + } } - // A zero allocation disables the feature. - // A zero hard limit is not permitted. - if allocation == 0 && (hasSoft || hasHard) { - return ErrAgentRuntimeHoursLimitsWithZeroAllocation - } - if hasSoft && (soft < 0 || soft >= allocation) { - return ErrInvalidAgentRuntimeHoursSoftLimit - } - if hasHard && hard < allocation { - return ErrInvalidAgentRuntimeHoursHardLimit - } - return nil + return feature, true, ignoredClaims } // Claims is the full set of claims in a license. @@ -1089,9 +1110,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) { if claims.AccountID == "" { return nil, ErrMissingAccountID } - if err := claims.Features.validateAgentRuntimeHours(); err != nil { - return nil, err - } return claims, nil } return nil, xerrors.New("unable to parse Claims") diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 6fe5090285..24bd86b326 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -1,9 +1,11 @@ package license_test import ( + "bytes" "context" "encoding/json" "fmt" + "maps" "slices" "testing" "time" @@ -14,11 +16,14 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -2328,6 +2333,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { require.NotNil(t, feature.UsagePeriod) }) + // An unlimited (-1) allocation grants the feature enabled with no Limit, + // which the API serves as an omitted "limit" field, the shape the UI + // already renders as "Unlimited". + t.Run("UnlimitedAllocation", func(t *testing.T) { + t.Parallel() + + lic := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: time.Now().Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation, + }, + }), + } + + entitlements, err := license.LicensesEntitlements( + context.Background(), time.Now(), []database.License{lic}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + ) + require.NoError(t, err) + require.Empty(t, entitlements.Errors) + require.NotContains(t, entitlements.Warnings, + codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.True(t, feature.Enabled) + require.Nil(t, feature.Limit) + require.Nil(t, feature.SoftLimit) + require.Nil(t, feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + + // The entitlements JSON served by GET /api/v2/entitlements omits + // "limit" entirely for the unlimited feature. + data, err := json.Marshal(entitlements) + require.NoError(t, err) + var raw struct { + Features map[codersdk.FeatureName]map[string]any `json:"features"` + } + require.NoError(t, json.Unmarshal(data, &raw)) + rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, true, rawFeature["enabled"]) + require.NotContains(t, rawFeature, "limit") + require.Contains(t, rawFeature, "usage_period") + }) + // The license with the newest issued-at claim wins, even if another // license was loaded first or has a larger allocation. The soft and hard // limits come from the winning license. @@ -2396,6 +2450,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { } }) + // When an unlimited and a metered license are minted with identical + // issued-at and expiry claims, the unlimited grant must win the tie, + // regardless of load order. + t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) { + t.Parallel() + + // JWT NumericDate claims have second granularity, so truncate to + // keep the round-tripped issued-at values identical. + iat := time.Now().Add(-time.Minute).Truncate(time.Second) + nbf := iat + exp := iat.Add(time.Hour).Truncate(time.Second) + unlimited := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: exp, + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: iat, + NotBefore: nbf, + ExpiresAt: exp, + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation, + }, + }), + } + metered := database.License{ + ID: 2, + UploadedAt: time.Now(), + Exp: exp, + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: iat, + NotBefore: nbf, + ExpiresAt: exp, + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + }), + } + + for _, order := range [][]database.License{ + {unlimited, metered}, + {metered, unlimited}, + } { + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + require.NoError(t, err) + + feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours) + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.True(t, feature.Enabled) + require.Nil(t, feature.Limit) + require.Nil(t, feature.SoftLimit) + require.Nil(t, feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + } + }) + // A newer license without soft/hard limits must fully replace an older // license that carried them; the limits must not merge across licenses. t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) { @@ -2535,15 +2649,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { }) } -// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the -// agent runtime hour claims reject the entire license. -func TestAgentRuntimeHoursClaimValidation(t *testing.T) { +// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's +// tolerate-and-warn contract; see that function's doc for the rationale. +func TestAgentRuntimeHoursClaimTolerance(t *testing.T) { t.Parallel() testCases := []struct { - name string - features license.Features - expectedErr error + name string + features license.Features + + // expectFeature is nil when the feature must be absent. + expectFeature *codersdk.Feature + // expectClaimsIgnored is true when at least one present claim is + // dropped, which must surface the claims-ignored warning: tolerating + // a claim and signaling nothing would make an incorrectly issued license + // undetectable from the deployment. + expectClaimsIgnored bool }{ { name: "AllClaims", @@ -2552,85 +2673,63 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursLimitSoft: 80, license.ClaimAgentRuntimeHoursLimitHard: 120, }, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + SoftLimit: ptr.Ref[int64](80), + HardLimit: ptr.Ref[int64](120), + }, }, { name: "AllocationOnly", features: license.Features{ license.ClaimAgentRuntimeHoursAllocation: 100, }, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, }, { + // A zero soft limit is valid (0 <= soft < allocation) and warns + // from the start of the usage period. Omitting the claim is the + // way to express "no soft limit". name: "ZeroSoft", features: license.Features{ license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: 0, }, - }, - { - name: "HardEqualsAllocation", - features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 100, - license.ClaimAgentRuntimeHoursLimitHard: 100, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + SoftLimit: ptr.Ref[int64](0), }, }, - { - name: "ZeroAllocation", - features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 0, - }, - }, - { - name: "ZeroAllocationWithZeroHard", - features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 0, - license.ClaimAgentRuntimeHoursLimitHard: 0, - }, - expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation, - }, - { - name: "ZeroAllocationWithPositiveHard", - features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 0, - license.ClaimAgentRuntimeHoursLimitHard: 1000, - }, - expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation, - }, - { - name: "SoftWithoutAllocation", - features: license.Features{ - license.ClaimAgentRuntimeHoursLimitSoft: 80, - }, - expectedErr: license.ErrMissingAgentRuntimeHoursAllocation, - }, - { - name: "HardWithoutAllocation", - features: license.Features{ - license.ClaimAgentRuntimeHoursLimitHard: 120, - }, - expectedErr: license.ErrMissingAgentRuntimeHoursAllocation, - }, - { - name: "NegativeAllocation", - features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: -1, - }, - expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation, - }, { name: "NegativeSoft", features: license.Features{ license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: -1, }, - expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, + expectClaimsIgnored: true, }, { + // A soft limit at or above the allocation could never fire + // before the allocation warning supersedes it. name: "SoftEqualsAllocation", features: license.Features{ license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: 100, }, - expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, + expectClaimsIgnored: true, }, { name: "SoftAboveAllocation", @@ -2638,15 +2737,23 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: 150, }, - expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, + expectClaimsIgnored: true, }, { - name: "SoftWithZeroAllocation", + name: "HardEqualsAllocation", features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 0, - license.ClaimAgentRuntimeHoursLimitSoft: 0, + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitHard: 100, + }, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + HardLimit: ptr.Ref[int64](100), }, - expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation, }, { name: "HardBelowAllocation", @@ -2654,7 +2761,122 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitHard: 99, }, - expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, + expectClaimsIgnored: true, + }, + { + name: "ZeroAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + }, + expectFeature: &codersdk.Feature{ + Enabled: false, + Limit: ptr.Ref[int64](0), + }, + }, + { + // A zero allocation has no hour budget, so threshold claims + // alongside it are dropped, with the warning. + name: "ZeroAllocationWithLimits", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 1000, + }, + expectFeature: &codersdk.Feature{ + Enabled: false, + Limit: ptr.Ref[int64](0), + }, + expectClaimsIgnored: true, + }, + { + // An unlimited allocation grants the feature with no Limit and + // no warning: -1 is the canonical unlimited encoding, not an + // issuance mistake. + name: "UnlimitedAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation, + }, + expectFeature: &codersdk.Feature{ + Enabled: true, + }, + }, + { + // Threshold claims alongside an unlimited allocation have + // nothing to threshold against; the grant survives but the + // issuance mistake must stay visible via the warning. + name: "UnlimitedWithSoft", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + }, + expectFeature: &codersdk.Feature{ + Enabled: true, + }, + expectClaimsIgnored: true, + }, + { + name: "UnlimitedWithHard", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + expectFeature: &codersdk.Feature{ + Enabled: true, + }, + expectClaimsIgnored: true, + }, + { + // Only exactly -1 is the unlimited sentinel; any other negative + // allocation stays unusable. + name: "NegativeAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: -2, + }, + expectClaimsIgnored: true, + }, + { + name: "SoftWithoutAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursLimitSoft: 80, + }, + expectClaimsIgnored: true, + }, + { + name: "HardWithoutAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + expectClaimsIgnored: true, + }, + { + // The feature name itself is never a valid claim: the + // allocation must come from the dedicated claim. It is the + // shape every other metered feature uses, so a license minting + // it is the most plausible issuer mistake and must warn + // rather than being dropped silently. + name: "FeatureNameAsClaim", + features: license.Features{ + codersdk.FeatureAgentRuntimeHours: 100, + }, + expectClaimsIgnored: true, + }, + { + // The feature name claim is dropped (with the warning) even + // when a usable allocation claim grants the feature. + name: "FeatureNameAlongsideAllocation", + features: license.Features{ + codersdk.FeatureAgentRuntimeHours: 50, + license.ClaimAgentRuntimeHoursAllocation: 100, + }, + expectFeature: &codersdk.Feature{ + Enabled: true, + Limit: ptr.Ref[int64](100), + }, + expectClaimsIgnored: true, }, } @@ -2662,46 +2884,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - Features: tc.features, - }) - _, err := license.ParseClaims(jwt, coderdenttest.Keys) - if tc.expectedErr == nil { - require.NoError(t, err) + features := license.Features{ + codersdk.FeatureUserLimit: 100, + } + maps.Copy(features, tc.features) + lic := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: time.Now().Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: features, + }), + } + + var logBuf bytes.Buffer + entitlements, err := license.LicensesEntitlements( + context.Background(), time.Now(), []database.License{lic}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{ + Logger: slog.Make(sloghuman.Sink(&logBuf)), + }, + ) + require.NoError(t, err) + + // The license as a whole survives: unrelated paid features are + // unaffected by an unusable runtime hour claim. + require.Empty(t, entitlements.Errors) + require.True(t, entitlements.HasLicense) + userLimit := entitlements.Features[codersdk.FeatureUserLimit] + require.NotNil(t, userLimit.Limit) + require.EqualValues(t, 100, *userLimit.Limit) + + // Dropped claims are tolerated but never silent: the operator + // sees the stable warning, and the log names the license and + // the dropped claims for support. + if tc.expectClaimsIgnored { + require.Contains(t, entitlements.Warnings, + codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) + logs := logBuf.String() + require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license") + require.Contains(t, logs, lic.UUID.String()) + } else { + require.NotContains(t, entitlements.Warnings, + codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) + require.Empty(t, logBuf.String()) + } + + // Every known feature name has a default entry in the map, so + // "the license does not grant the feature" surfaces as the + // default: no limit, no usage period, not enabled. + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + if tc.expectFeature == nil { + require.Nil(t, feature.Limit, "feature must not be granted") + require.Nil(t, feature.UsagePeriod, "feature must not be granted") + require.False(t, feature.Enabled) return } - require.ErrorIs(t, err, tc.expectedErr) + require.NotNil(t, feature.UsagePeriod, "feature must be granted") + require.Equal(t, tc.expectFeature.Enabled, feature.Enabled) + require.Equal(t, tc.expectFeature.Limit, feature.Limit) + require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit) + require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit) }) } - // An invalid license already stored in the database is rejected entirely - // and produces an entitlements error. - t.Run("EntitlementsError", func(t *testing.T) { + t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) { t.Parallel() - lic := database.License{ - ID: 1, - UploadedAt: time.Now(), - Exp: time.Now().Add(time.Hour), - UUID: uuid.New(), - JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - Features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 100, - license.ClaimAgentRuntimeHoursLimitSoft: 150, - }, - }), + // Two licenses with unusable claims must publish the stable warning + // once, or the banner would stack identical texts, while the log + // names each affected license so the operator can tell which ones + // need re-issuing. + newLicense := func(id int32) database.License { + return database.License{ + ID: id, + UploadedAt: time.Now(), + Exp: time.Now().Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserLimit: 100, + // A threshold without an allocation is unusable. + license.ClaimAgentRuntimeHoursLimitSoft: 80, + }, + }), + } } + licenses := []database.License{newLicense(1), newLicense(2)} + var logBuf bytes.Buffer entitlements, err := license.LicensesEntitlements( - context.Background(), time.Now(), []database.License{lic}, - map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + context.Background(), time.Now(), licenses, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{ + Logger: slog.Make(sloghuman.Sink(&logBuf)), + }, ) require.NoError(t, err) - require.Len(t, entitlements.Errors, 1) - require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID)) - require.False(t, entitlements.HasLicense) - feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] - require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement) + + warningCount := 0 + for _, warning := range entitlements.Warnings { + if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText { + warningCount++ + } + } + require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once") + + logs := logBuf.String() + for _, lic := range licenses { + require.Contains(t, logs, lic.UUID.String()) + } }) } diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index c88f975f2c..811929f093 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) { require.Contains(t, errResp.Message, "Invalid license") }) - t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) { + t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) - // A soft limit claim without an allocation claim rejects the whole - // license. + // A soft limit claim without an allocation claim is unusable, but it + // never rejects the whole license: the license stays valid, the + // runtime hours feature is simply not granted, and the dropped claim + // is surfaced as a warning. See decodeAgentRuntimeHours. lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ Features: license.Features{ + codersdk.FeatureUserLimit: 100, license.ClaimAgentRuntimeHoursLimitSoft: 80, }, }) _, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{ License: lic, }) - errResp := &codersdk.Error{} - require.ErrorAs(t, err, &errResp) - require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) - require.Contains(t, errResp.Message, "Invalid license") + require.NoError(t, err) + // The claims round-trip through GET /api/v2/entitlements. + //nolint:gocritic // This test asserts license state, not authz behavior. + entitlements, err := client.Entitlements(context.Background()) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Empty(t, entitlements.Errors) + require.Contains(t, entitlements.Warnings, + codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Nil(t, feature.Limit) + require.Nil(t, feature.UsagePeriod) }) t.Run("AgentRuntimeClaims", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) - coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - Features: license.Features{ - license.ClaimAgentRuntimeHoursAllocation: 100, - license.ClaimAgentRuntimeHoursLimitSoft: 80, - license.ClaimAgentRuntimeHoursLimitHard: 120, - }, - }) + coderdenttest.AddLicense(t, client, + *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))) // The claims round-trip through GET /api/v2/entitlements. //nolint:gocritic // This test asserts license state, not authz behavior. entitlements, err := client.Entitlements(context.Background()) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 77385aea24..2e96aa9957 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5763,6 +5763,10 @@ export const LicenseAIGovernance90PercentWarningText = export const LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."; +// From codersdk/licenses.go +export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText = + "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."; + // From codersdk/licenses.go export const LicenseExpiryClaim = "license_expires"; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx index ba5f739d93..2c33e9fcbf 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { + LicenseAgentRuntimeHoursClaimsIgnoredWarningText, LicenseAIGovernance90PercentWarningText, LicenseAIGovernanceOverLimitWarningText, LicenseManagedAgentLimitExceededWarningText, @@ -24,7 +25,27 @@ const isAIGovernanceWarning = (message: string): boolean => message.startsWith(aiGovernanceNearLimitWarningPrefix) || message.startsWith(aiGovernanceOverLimitWarningPrefix); -const isAIGovernanceNearLimitWarning = (message: string): boolean => +// Substitutes the given values into the template's %d placeholders in order. +// No other fmt verb, width, or flag is implemented. +const formatLicenseMessage = (template: string, ...values: number[]): string => + values.reduce( + (message, value) => message.replace("%d", `${value}`), + template, + ); + +// Diagnostics about the license or the usage measurement rather than about +// usage itself. They render muted, without the exceedance heading or a sales +// link, even when they arrive via entitlements.errors. +const diagnosticMessages: readonly string[] = [ + LicenseAgentRuntimeHoursClaimsIgnoredWarningText, +]; + +const isDiagnosticMessage = (message: string): boolean => + diagnosticMessages.includes(message); + +// Advisories render muted to stay visually distinct from warnings that +// demand action, such as exceeding a license limit. +const isAdvisoryMessage = (message: string): boolean => message.startsWith(aiGovernanceNearLimitWarningPrefix); const aiGovernanceOverLimitMessage = ( @@ -48,9 +69,12 @@ const aiGovernanceOverLimitMessage = ( } const overLimitSeats = actual - limit; - return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`) - .replace("%d", `${limit}`) - .replace("%d", `${overLimitSeats}`); + return formatLicenseMessage( + LicenseAIGovernanceOverLimitWarningText, + actual, + limit, + overLimitSeats, + ); }; const aiGovernanceNearLimitMessage = ( @@ -99,7 +123,7 @@ const normalizeAIGovernanceWarning = ( ); }; -const messageLink = (message: string): LicenseBannerLink => { +const messageLink = (message: string): LicenseBannerLink | undefined => { if (message === LicenseManagedAgentLimitExceededWarningText) { return { href: docs("/ai-coder/ai-governance"), @@ -122,6 +146,29 @@ const messageLink = (message: string): LicenseBannerLink => { }; }; +// Classifies a raw entitlements message once and carries the result as +// structured message data, so rendering branches on the message's kind and +// variant fields rather than re-matching display text. +const toBannerMessage = ( + message: string, + channel: "errors" | "warnings", +): LicenseBannerMessage => { + // Measurement diagnostics travel in the errors channel but are not + // license errors. They render muted and without a sales link: they point + // the operator at the logs, not at sales. + if (isDiagnosticMessage(message)) { + return { message, variant: "warning", kind: "diagnostic" }; + } + if (channel === "errors") { + return { message, variant: "error", link: messageLink(message) }; + } + return { + message, + variant: isAdvisoryMessage(message) ? "warning" : "warningProminent", + link: messageLink(message), + }; +}; + export const LicenseBanner: FC = () => { const { entitlements } = useDashboard(); const { errors } = entitlements; @@ -144,18 +191,10 @@ export const LicenseBanner: FC = () => { ); const messages: LicenseBannerMessage[] = [ - ...errors.map((message) => ({ - message, - variant: "error" as const, - link: messageLink(message), - })), - ...normalizedWarnings.map((message) => ({ - message, - variant: isAIGovernanceNearLimitWarning(message) - ? ("warning" as const) - : ("warningProminent" as const), - link: messageLink(message), - })), + ...errors.map((message) => toBannerMessage(message, "errors")), + ...normalizedWarnings.map((message) => + toBannerMessage(message, "warnings"), + ), ]; if (messages.length === 0) { diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx index 582493a4ef..94b04f29e5 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; import { + type Entitlements, + LicenseAgentRuntimeHoursClaimsIgnoredWarningText, LicenseAIGovernance90PercentWarningText, LicenseManagedAgentLimitExceededWarningText, LicenseTelemetryRequiredErrorText, @@ -56,6 +58,10 @@ export const TwoWarnings: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByRole("status")).toBeInTheDocument(); + await expect( + canvas.getByText("Your license limits have been exceeded"), + ).toBeInTheDocument(); await expect( canvas.queryByRole("button", { name: "Show more" }), ).not.toBeInTheDocument(); @@ -180,30 +186,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = { }, }; -const renderLicenseBannerWithAIGovernance = ({ - actual, - entitlement = "entitled", - limit, +const renderLicenseBanner = ({ + errors = [], warnings = [], + features = {}, }: { - actual: number; - entitlement?: "entitled" | "grace_period" | "not_entitled"; - limit?: number; + errors?: string[]; warnings?: string[]; + features?: Partial; }) => { const mockDashboardValue: DashboardValue = { entitlements: { ...MockEntitlements, has_license: true, + errors, warnings, features: { ...MockEntitlements.features, - ai_governance_user_limit: { - enabled: true, - entitlement, - actual, - ...(limit !== undefined ? { limit } : {}), - }, + ...features, }, }, experiments: MockExperiments, @@ -215,12 +215,35 @@ const renderLicenseBannerWithAIGovernance = ({ }; return ( - + - + ); }; +const renderLicenseBannerWithAIGovernance = ({ + actual, + entitlement = "entitled", + limit, + warnings = [], +}: { + actual: number; + entitlement?: "entitled" | "grace_period" | "not_entitled"; + limit?: number; + warnings?: string[]; +}) => + renderLicenseBanner({ + warnings, + features: { + ai_governance_user_limit: { + enabled: true, + entitlement, + actual, + ...(limit !== undefined ? { limit } : {}), + }, + }, + }); + export const AIGovernanceNearLimit: Story = { render: () => renderLicenseBannerWithAIGovernance({ @@ -230,12 +253,16 @@ export const AIGovernanceNearLimit: Story = { }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByRole("status")).toHaveTextContent( + const banner = canvas.getByRole("status"); + await expect(banner).toHaveTextContent( "You have used 95% of your AI Governance add-on seats.", ); await expect( canvas.getByRole("link", { name: /Contact sales@coder\.com/i }), ).toHaveAttribute("href", "mailto:sales@coder.com"); + // A lone advisory is muted but not a diagnostic, so it renders + // without the notices heading. + await expect(canvas.queryByText("License notices")).not.toBeInTheDocument(); }, }; @@ -267,3 +294,43 @@ export const AIGovernanceOverLimitGracePeriod: Story = { ); }, }; + +// Each diagnostic pins role=status (not alert) and a suppressed sales +// link. Background mutedness is covered by the visual snapshot. +const playMutedDiagnostic = + (message: string): Story["play"] => + async ({ canvasElement }) => { + const canvas = within(canvasElement); + const banner = canvas.getByRole("status"); + await expect(banner).toHaveTextContent(message); + await expect( + canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }), + ).not.toBeInTheDocument(); + }; + +export const AgentRuntimeHoursClaimsIgnored: Story = { + render: () => + renderLicenseBanner({ + warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText], + }), + play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText), +}; + +// An all-diagnostic banner must not claim license limits were exceeded. +export const UsageDiagnosticsOnlyHeading: Story = { + render: () => + renderLicenseBanner({ + warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText], + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("status")).toBeInTheDocument(); + await expect(canvas.getByText("License notices")).toBeInTheDocument(); + await expect( + canvas.queryByText("Your license limits have been exceeded"), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByText("License errors require attention"), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx index 80674fcbb7..8212fe7073 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx @@ -25,6 +25,10 @@ export interface LicenseBannerLink { export interface LicenseBannerMessage { message: string; variant: LicenseBannerVariant; + // Diagnostics about the license or the usage measurement rather than + // about usage itself. They keep the "License notices" heading even when + // they are the only message, since the muted text needs that context. + kind?: "diagnostic"; link?: LicenseBannerLink; } @@ -70,10 +74,18 @@ const getBannerVariant = ( return hasProminentWarning ? "warningProminent" : "warning"; }; -const bannerTitle = (variant: LicenseBannerVariant): string => - variant === "error" - ? "License errors require attention" - : "Your license limits have been exceeded"; +// The muted "warning" variant means every message is an advisory or +// diagnostic, so the heading must not assert exceedance. +const bannerTitle = (variant: LicenseBannerVariant): string => { + switch (variant) { + case "error": + return "License errors require attention"; + case "warningProminent": + return "Your license limits have been exceeded"; + case "warning": + return "License notices"; + } +}; const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" => variant === "error" ? "alert" : "status"; @@ -138,6 +150,9 @@ export const LicenseBannerView: React.FC = ({ const bannerVariant = getBannerVariant(messages); const visibleMessages = messages.slice(0, 2); const hiddenMessages = messages.slice(2); + // A lone diagnostic keeps the heading: without it the muted banner is an + // unexplained sentence. Other single messages stay heading-less. + const showHeading = !isSingleMessage || messages[0].kind === "diagnostic"; return (
= ({ />
+ {showHeading && ( +
+ {bannerTitle(bannerVariant)} +
+ )} {isSingleMessage ? (
) : ( - <> -
- {bannerTitle(bannerVariant)} -
- - + )}