feat: tolerate unusable runtime hours claims and decode -1 allocation as unlimited (#27984)

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.
This commit is contained in:
Jaayden Halko
2026-08-18 12:07:22 +07:00
committed by GitHub
parent fb3ed7a56a
commit d15800b494
12 changed files with 728 additions and 236 deletions
+6
View File
@@ -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
@@ -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{}
+96 -78
View File
@@ -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")
+382 -91
View File
@@ -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())
}
})
}
+21 -14
View File
@@ -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())