From 3463d7e242ef241bba2fc1f4e30d7329554ae543 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 23 Jul 2026 12:38:48 +0000 Subject: [PATCH 1/3] feat: add agent runtime hour license claims and entitlement feature Licenses can now carry three claims for agent runtime hours: agent_runtime_hours_allocation, agent_runtime_hours_limit_soft, and agent_runtime_hours_limit_hard (unit: hours). They surface as the new usage-period feature agent_runtime_hours in GET /api/v2/entitlements, where limit carries the allocation and the new optional soft_limit and hard_limit fields carry the thresholds. Invalid claim combinations (soft/hard without allocation, negative allocation, soft outside 0 <= soft < allocation, hard < allocation) reject the entire license, following the validateClaims precedent. Soft and hard limits are not comparison inputs in Feature.Compare; they ride along with whichever license wins (newest iat, existing behavior). Old servers ignore all three claims since none of them is a feature name, protecting rollout of licenses minted with the new claims. Part of CODAGT-837. The claim name constants defined here are the canonical contract for github.com/coder/license (X1). --- coderd/apidoc/docs.go | 9 +- coderd/apidoc/swagger.json | 9 +- codersdk/deployment.go | 24 +- codersdk/deployment_test.go | 34 ++ docs/reference/api/enterprise.md | 4 + docs/reference/api/schemas.md | 11 +- .../coderd/coderdenttest/coderdenttest.go | 8 + enterprise/coderd/license/license.go | 92 ++- enterprise/coderd/license/license_test.go | 529 ++++++++++++++++++ enterprise/coderd/licenses_test.go | 45 ++ site/src/api/typesGenerated.ts | 14 + 11 files changed, 773 insertions(+), 6 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c27365ac0797..4fae94a16e386 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20246,11 +20246,18 @@ const docTemplate = `{ "entitlement": { "$ref": "#/definitions/codersdk.Entitlement" }, + "hard_limit": { + "type": "integer" + }, "limit": { "type": "integer" }, + "soft_limit": { + "description": "SoftLimit and HardLimit are optional thresholds that accompany Limit\nfor features whose license carries them. For these features, Limit\ncarries the purchased allocation, SoftLimit is the advisory warning\nthreshold, and HardLimit is the enforcement ceiling.\n\nOnly certain features set these fields:\n- FeatureAgentRuntimeHours", + "type": "integer" + }, "usage_period": { - "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit", + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours", "allOf": [ { "$ref": "#/definitions/codersdk.UsagePeriod" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f87f2bad29c0..e980d67497c07 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18412,11 +18412,18 @@ "entitlement": { "$ref": "#/definitions/codersdk.Entitlement" }, + "hard_limit": { + "type": "integer" + }, "limit": { "type": "integer" }, + "soft_limit": { + "description": "SoftLimit and HardLimit are optional thresholds that accompany Limit\nfor features whose license carries them. For these features, Limit\ncarries the purchased allocation, SoftLimit is the advisory warning\nthreshold, and HardLimit is the enforcement ceiling.\n\nOnly certain features set these fields:\n- FeatureAgentRuntimeHours", + "type": "integer" + }, "usage_period": { - "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit", + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours", "allOf": [ { "$ref": "#/definitions/codersdk.UsagePeriod" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2c4a0bffa9d24..56fe0ed68038a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -199,6 +199,12 @@ const ( FeatureBoundary FeatureName = "boundary" FeatureServiceAccounts FeatureName = "service_accounts" FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit" + // AgentRuntimeHours is a usage period feature. It is never a license + // claim itself; it is populated from the agent_runtime_hours_allocation, + // agent_runtime_hours_limit_soft and agent_runtime_hours_limit_hard + // claims. Refer to enterprise/coderd/license/license.go for the license + // format. + FeatureAgentRuntimeHours FeatureName = "agent_runtime_hours" ) var ( @@ -231,6 +237,7 @@ var ( FeatureBoundary, FeatureServiceAccounts, FeatureAIGovernanceUserLimit, + FeatureAgentRuntimeHours, } // FeatureNamesMap is a map of all feature names for quick lookups. @@ -300,6 +307,7 @@ func (n FeatureName) UsesLimit() bool { FeatureUserLimit: true, FeatureManagedAgentLimit: true, FeatureAIGovernanceUserLimit: true, + FeatureAgentRuntimeHours: true, }[n] } @@ -307,6 +315,7 @@ func (n FeatureName) UsesLimit() bool { func (n FeatureName) UsesUsagePeriod() bool { return map[FeatureName]bool{ FeatureManagedAgentLimit: true, + FeatureAgentRuntimeHours: true, }[n] } @@ -372,7 +381,16 @@ type Feature struct { Entitlement Entitlement `json:"entitlement"` Enabled bool `json:"enabled"` Limit *int64 `json:"limit,omitempty"` - Actual *int64 `json:"actual,omitempty"` + // SoftLimit and HardLimit are optional thresholds that accompany Limit + // for features whose license carries them. For these features, Limit + // carries the purchased allocation, SoftLimit is the advisory warning + // threshold, and HardLimit is the enforcement ceiling. + // + // Only certain features set these fields: + // - FeatureAgentRuntimeHours + SoftLimit *int64 `json:"soft_limit,omitempty"` + HardLimit *int64 `json:"hard_limit,omitempty"` + Actual *int64 `json:"actual,omitempty"` // Below is only for features that use usage periods. @@ -385,6 +403,7 @@ type Feature struct { // // Only certain features set these fields: // - FeatureManagedAgentLimit + // - FeatureAgentRuntimeHours UsagePeriod *UsagePeriod `json:"usage_period,omitempty"` } @@ -407,6 +426,9 @@ type UsagePeriod struct { // 5. The limit is greater // 6. Enabled is greater than disabled // 7. The actual is greater +// +// SoftLimit and HardLimit are not comparison inputs; they ride along with +// whichever feature wins the comparison. func (f Feature) Compare(b Feature) int { // For features with usage period constraints only, check the issued at and // end dates. diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index a70fef938a64c..be09a71d5f97d 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -1002,6 +1002,40 @@ func TestFeatureComparison(t *testing.T) { B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil}, Expected: 1, }, + // Soft and hard limits are not comparison inputs; they ride along + // with whichever feature wins. + { + Name: "SoftHardLimitsIgnored", + A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), SoftLimit: ptr.Ref(int64(80)), HardLimit: ptr.Ref(int64(120))}, + B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100))}, + Expected: 0, + }, + { + Name: "NewerIssuedAtWinsOverSoftHardLimits", + A: codersdk.Feature{ + Entitlement: codersdk.EntitlementEntitled, + Limit: ptr.Ref(int64(50)), + SoftLimit: ptr.Ref(int64(40)), + HardLimit: ptr.Ref(int64(60)), + UsagePeriod: &codersdk.UsagePeriod{ + IssuedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + Start: time.Date(2026, 2, 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, + Limit: ptr.Ref(int64(100)), + SoftLimit: ptr.Ref(int64(80)), + HardLimit: ptr.Ref(int64(120)), + 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/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index eeaf8032ec0a3..3cfe0a2c971b7 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -571,7 +571,9 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ "actual": 0, "enabled": true, "entitlement": "entitled", + "hard_limit": 0, "limit": 0, + "soft_limit": 0, "usage_period": { "end": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z", @@ -582,7 +584,9 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ "actual": 0, "enabled": true, "entitlement": "entitled", + "hard_limit": 0, "limit": 0, + "soft_limit": 0, "usage_period": { "end": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ddba40ad34a87..3b045f48c12f6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7166,7 +7166,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "actual": 0, "enabled": true, "entitlement": "entitled", + "hard_limit": 0, "limit": 0, + "soft_limit": 0, "usage_period": { "end": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z", @@ -7177,7 +7179,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "actual": 0, "enabled": true, "entitlement": "entitled", + "hard_limit": 0, "limit": 0, + "soft_limit": 0, "usage_period": { "end": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z", @@ -7460,7 +7464,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "actual": 0, "enabled": true, "entitlement": "entitled", + "hard_limit": 0, "limit": 0, + "soft_limit": 0, "usage_period": { "end": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z", @@ -7476,10 +7482,13 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `actual` | integer | false | | | | `enabled` | boolean | false | | | | `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | +| `hard_limit` | integer | false | | | | `limit` | integer | false | | | +|`soft_limit`|integer|false||Soft limit and HardLimit are optional thresholds that accompany Limit for features whose license carries them. For these features, Limit carries the purchased allocation, SoftLimit is the advisory warning threshold, and HardLimit is the enforcement ceiling. +Only certain features set these fields: - FeatureAgentRuntimeHours| |`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. -Only certain features set these fields: - FeatureManagedAgentLimit| +Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgentRuntimeHours| ## codersdk.FriendlyDiagnostic diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 1115ba12118c7..550bea9c870d1 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -237,6 +237,14 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions { return opts.Feature(codersdk.FeatureManagedAgentLimit, limit) } +// AgentRuntimeHours sets the agent runtime hour allocation claim. Soft and +// hard limit claims can be set with Feature using +// license.ClaimAgentRuntimeHoursLimitSoft and +// license.ClaimAgentRuntimeHoursLimitHard. +func (opts *LicenseOptions) AgentRuntimeHours(allocation int64) *LicenseOptions { + return opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation) +} + 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 8092e5f625839..97b319c17d001 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -297,6 +297,25 @@ func LicensesEntitlements( continue } + // Agent runtime hours are encoded as up to three claims: the + // allocation plus optional soft and hard limits. They surface as + // the single agent_runtime_hours feature. The combination is + // validated when the license is parsed, see validateClaims. + if featureName == codersdk.FeatureAgentRuntimeHours { + // 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. + continue + } + if featureName == ClaimAgentRuntimeHoursAllocation { + // Maps the allocation to the canonical feature name. + featureName = codersdk.FeatureAgentRuntimeHours + } + if featureName == ClaimAgentRuntimeHoursLimitSoft || featureName == ClaimAgentRuntimeHoursLimitHard { + // Consumed alongside the allocation claim below. + continue + } + if featureValue < 0 { // We currently don't use negative values for features. continue @@ -313,7 +332,7 @@ func LicensesEntitlements( // Handling for limit features. switch { case featureName.UsesUsagePeriod(): - entitlements.AddFeature(featureName, codersdk.Feature{ + feature := codersdk.Feature{ Enabled: featureValue > 0, Entitlement: entitlement, Limit: &featureValue, @@ -322,7 +341,18 @@ func LicensesEntitlements( Start: usagePeriodStart, End: usagePeriodEnd, }, - }) + } + // The agent runtime hours feature carries optional soft and + // hard limit claims alongside the allocation. + if featureName == codersdk.FeatureAgentRuntimeHours { + if soft, ok := claims.Features[ClaimAgentRuntimeHoursLimitSoft]; ok { + feature.SoftLimit = &soft + } + if hard, ok := claims.Features[ClaimAgentRuntimeHoursLimitHard]; ok { + feature.HardLimit = &hard + } + } + entitlements.AddFeature(featureName, feature) case featureName.UsesLimit(): if featureValue <= 0 { // 0 limit value or less doesn't make sense, so we skip it. @@ -541,6 +571,11 @@ func LicensesEntitlements( if featureName == codersdk.FeatureManagedAgentLimit { continue } + // Agent runtime hours is a usage period feature and does not + // generate generic entitlement warnings. + if featureName == codersdk.FeatureAgentRuntimeHours { + continue + } feature := entitlements.Features[featureName] if !feature.Enabled { @@ -611,6 +646,25 @@ 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. +const ( + // ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour + // allocation for the license term. It becomes the feature's Limit. + ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation" + // ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It + // must satisfy 0 <= soft < allocation. It becomes the feature's + // SoftLimit. + ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft" + // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be + // absent or >= allocation. It becomes the feature's HardLimit. + ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard" +) + var ( ValidMethods = []string{"EdDSA"} @@ -623,10 +677,41 @@ 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") + + ErrMissingAgentRuntimeAllocation = xerrors.Errorf("license has agent runtime limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation) + ErrInvalidAgentRuntimeAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation) + ErrInvalidAgentRuntimeSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation) + ErrInvalidAgentRuntimeHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation) ) type Features map[codersdk.FeatureName]int64 +// validateAgentRuntimeHours validates the relationship between the agent +// runtime hour claims. Invalid combinations reject the entire license rather +// than ignoring the feature, since these claims form a contract with the +// license issuer. +func (f Features) validateAgentRuntimeHours() error { + allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation] + soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft] + hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard] + if !hasAllocation { + if hasSoft || hasHard { + return ErrMissingAgentRuntimeAllocation + } + return nil + } + if allocation < 0 { + return ErrInvalidAgentRuntimeAllocation + } + if hasSoft && (soft < 0 || soft >= allocation) { + return ErrInvalidAgentRuntimeSoftLimit + } + if hasHard && hard < allocation { + return ErrInvalidAgentRuntimeHardLimit + } + return nil +} + // Claims is the full set of claims in a license. type Claims struct { jwt.RegisteredClaims @@ -717,6 +802,9 @@ 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 10dea231cd8ce..d12ae9c160fa9 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -2,6 +2,7 @@ package license_test import ( "context" + "encoding/json" "fmt" "slices" "testing" @@ -79,6 +80,12 @@ func TestEntitlements(t *testing.T) { f[codersdk.FeatureManagedAgentLimit] = 100 continue } + if name == codersdk.FeatureAgentRuntimeHours { + // The feature is never a claim itself; it is + // granted through the allocation claim. + f[license.ClaimAgentRuntimeHoursAllocation] = 100 + continue + } f[name] = 1 } return f @@ -373,6 +380,7 @@ func TestEntitlements(t *testing.T) { featureName == codersdk.FeatureHighAvailability || featureName == codersdk.FeatureMultipleExternalAuth || featureName == codersdk.FeatureManagedAgentLimit || + featureName == codersdk.FeatureAgentRuntimeHours || featureName == codersdk.FeatureAIGovernanceUserLimit || featureName == codersdk.FeatureBoundary { // These fields don't generate warnings when not entitled unless @@ -2138,6 +2146,527 @@ func TestManagedAgentLimitDefault(t *testing.T) { }) } +// TestAgentRuntimeHoursLicenses ensures licenses carrying the agent runtime +// hour claims (allocation, soft limit, hard limit) surface as the single +// agent_runtime_hours feature. +func TestAgentRuntimeHoursLicenses(t *testing.T) { + t.Parallel() + + t.Run("AllClaims", func(t *testing.T) { + t.Parallel() + + licIat := time.Now().Add(-time.Minute) + licNbf := licIat.Add(-time.Minute) + licExp := licIat.Add(time.Hour) + lic := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: licExp, + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: licIat, + NotBefore: licNbf, + ExpiresAt: licExp, + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + }), + } + + 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) + + 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.NotNil(t, feature.Limit) + require.EqualValues(t, 100, *feature.Limit) + require.NotNil(t, feature.SoftLimit) + require.EqualValues(t, 80, *feature.SoftLimit) + require.NotNil(t, feature.HardLimit) + require.EqualValues(t, 120, *feature.HardLimit) + require.Nil(t, feature.Actual) + require.NotNil(t, feature.UsagePeriod) + require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second) + require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second) + require.WithinDuration(t, licExp, feature.UsagePeriod.End, 2*time.Second) + + // The feature round-trips into the entitlements JSON served by + // GET /api/v2/entitlements with all four fields. + 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.EqualValues(t, 100, rawFeature["limit"]) + require.EqualValues(t, 80, rawFeature["soft_limit"]) + require.EqualValues(t, 120, rawFeature["hard_limit"]) + require.Contains(t, rawFeature, "usage_period") + }) + + t.Run("GracePeriod", func(t *testing.T) { + t.Parallel() + + now := time.Now() + opts := coderdenttest.LicenseOptions{ + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + } + opts.GracePeriod(now) + lic := database.License{ + ID: 1, + UploadedAt: now, + Exp: now.Add(time.Hour * 24), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, opts), + } + + entitlements, err := license.LicensesEntitlements( + context.Background(), now, []database.License{lic}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + ) + require.NoError(t, err) + require.Empty(t, entitlements.Errors) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement) + require.True(t, feature.Enabled) + require.NotNil(t, feature.Limit) + require.EqualValues(t, 100, *feature.Limit) + require.NotNil(t, feature.SoftLimit) + require.EqualValues(t, 80, *feature.SoftLimit) + require.NotNil(t, feature.HardLimit) + require.EqualValues(t, 120, *feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + }) + + t.Run("AllocationOnly", 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, + }, + }), + } + + 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) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.True(t, feature.Enabled) + require.NotNil(t, feature.Limit) + require.EqualValues(t, 100, *feature.Limit) + require.Nil(t, feature.SoftLimit) + require.Nil(t, feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + }) + + // A license with an explicit zero allocation is entitled but disabled, + // mirroring the managed agent limit behavior. + t.Run("ExplicitZero", 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: 0, + }, + }), + } + + 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) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.False(t, feature.Enabled) + require.NotNil(t, feature.Limit) + require.EqualValues(t, 0, *feature.Limit) + require.NotNil(t, feature.UsagePeriod) + }) + + // Ensures that the license with the newest iat wins regardless of load + // order or a larger allocation, and that the winning license's soft and + // hard limits ride along. Mirrors TestUsageLimitFeatures/IssuedAtRanking. + t.Run("IssuedAtRanking", func(t *testing.T) { + t.Parallel() + + lic1 := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: time.Now().Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: time.Now().Add(-time.Minute * 2), + NotBefore: time.Now().Add(-time.Minute * 2), + ExpiresAt: time.Now().Add(time.Hour * 2), + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + }), + } + lic2Iat := time.Now().Add(-time.Minute * 1) + lic2Nbf := lic2Iat.Add(-time.Minute) + lic2Exp := lic2Iat.Add(time.Hour) + lic2 := database.License{ + ID: 2, + UploadedAt: time.Now(), + Exp: lic2Exp, + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: lic2Iat, + NotBefore: lic2Nbf, + ExpiresAt: lic2Exp, + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 50, + license.ClaimAgentRuntimeHoursLimitSoft: 40, + license.ClaimAgentRuntimeHoursLimitHard: 60, + }, + }), + } + + // Load the licenses in both orders to ensure the correct + // behavior is observed no matter the order. + for _, order := range [][]database.License{ + {lic1, lic2}, + {lic2, lic1}, + } { + 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.NotNil(t, feature.Limit) + require.EqualValues(t, 50, *feature.Limit) + require.NotNil(t, feature.SoftLimit) + require.EqualValues(t, 40, *feature.SoftLimit) + require.NotNil(t, feature.HardLimit) + require.EqualValues(t, 60, *feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + require.WithinDuration(t, lic2Iat, feature.UsagePeriod.IssuedAt, 2*time.Second) + require.WithinDuration(t, lic2Nbf, feature.UsagePeriod.Start, 2*time.Second) + require.WithinDuration(t, lic2Exp, feature.UsagePeriod.End, 2*time.Second) + } + }) + + // 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) { + t.Parallel() + + lic1 := database.License{ + ID: 1, + UploadedAt: time.Now(), + Exp: time.Now().Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: time.Now().Add(-time.Minute * 2), + NotBefore: time.Now().Add(-time.Minute * 2), + ExpiresAt: time.Now().Add(time.Hour * 2), + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + }), + } + lic2Iat := time.Now().Add(-time.Minute * 1) + lic2 := database.License{ + ID: 2, + UploadedAt: time.Now(), + Exp: lic2Iat.Add(time.Hour), + UUID: uuid.New(), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + IssuedAt: lic2Iat, + NotBefore: lic2Iat.Add(-time.Minute), + ExpiresAt: lic2Iat.Add(time.Hour), + Features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 50, + }, + }), + } + + for _, order := range [][]database.License{ + {lic1, lic2}, + {lic2, lic1}, + } { + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + require.NoError(t, err) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.NotNil(t, feature.Limit) + require.EqualValues(t, 50, *feature.Limit) + require.Nil(t, feature.SoftLimit) + require.Nil(t, feature.HardLimit) + } + }) + + // The feature name itself is not a valid claim; the allocation must come + // from the dedicated claim. + t.Run("DirectFeatureNameClaimIgnored", 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{ + codersdk.FeatureAgentRuntimeHours: 100, + }, + }), + } + + 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) + + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement) + require.Nil(t, feature.Limit) + }) + + // Ensures licenses carrying claims for features this server version does + // not know about do not break entitlement computation. This is exactly + // what old deployments see when a license carries the agent runtime hour + // claims, since none of the three claim names is a feature name. + t.Run("UnknownClaimsCompatibility", 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{ + codersdk.FeatureUserLimit: 100, + codersdk.FeatureName("future_feature_allocation"): 100, + codersdk.FeatureName("future_feature_limit_soft"): 80, + codersdk.FeatureName("future_feature_limit_hard"): 120, + codersdk.FeatureName("future_boolean_feature"): 1, + }, + }), + } + + 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.True(t, entitlements.HasLicense) + + // The unknown claims are silently ignored and the known claim is + // still entitled. + require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_feature_allocation")) + require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_boolean_feature")) + userLimit := entitlements.Features[codersdk.FeatureUserLimit] + require.NotNil(t, userLimit.Limit) + require.EqualValues(t, 100, *userLimit.Limit) + }) +} + +// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the +// agent runtime hour claims reject the entire license. +func TestAgentRuntimeHoursClaimValidation(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + features license.Features + expectedErr error + }{ + { + name: "AllClaims", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 80, + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + }, + { + name: "AllocationOnly", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + }, + }, + { + name: "ZeroSoft", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 0, + }, + }, + { + name: "HardEqualsAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitHard: 100, + }, + }, + { + name: "ZeroAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + }, + }, + { + name: "ZeroAllocationWithZeroHard", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + license.ClaimAgentRuntimeHoursLimitHard: 0, + }, + }, + { + name: "SoftWithoutAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursLimitSoft: 80, + }, + expectedErr: license.ErrMissingAgentRuntimeAllocation, + }, + { + name: "HardWithoutAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursLimitHard: 120, + }, + expectedErr: license.ErrMissingAgentRuntimeAllocation, + }, + { + name: "NegativeAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: -1, + }, + expectedErr: license.ErrInvalidAgentRuntimeAllocation, + }, + { + name: "NegativeSoft", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: -1, + }, + expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + }, + { + name: "SoftEqualsAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 100, + }, + expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + }, + { + name: "SoftAboveAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 150, + }, + expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + }, + { + name: "SoftWithZeroAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + license.ClaimAgentRuntimeHoursLimitSoft: 0, + }, + expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + }, + { + name: "HardBelowAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitHard: 99, + }, + expectedErr: license.ErrInvalidAgentRuntimeHardLimit, + }, + } + + for _, tc := range testCases { + 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) + return + } + require.ErrorIs(t, err, tc.expectedErr) + }) + } + + // An invalid license already stored in the database is rejected entirely + // and produces an entitlements error. + t.Run("EntitlementsError", 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, + }, + }), + } + + entitlements, err := license.LicensesEntitlements( + context.Background(), time.Now(), []database.License{lic}, + map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}, + ) + 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) + }) +} + func TestAIGovernanceAddon(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 73d16535d4e5d..c88f975f2c002 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -105,6 +105,51 @@ func TestPostLicense(t *testing.T) { require.Contains(t, errResp.Message, "Invalid license") }) + t.Run("InvalidAgentRuntimeClaims", 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. + lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + 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") + }) + + 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, + }, + }) + // 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) + feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours] + require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) + require.True(t, feature.Enabled) + require.NotNil(t, feature.Limit) + require.EqualValues(t, 100, *feature.Limit) + require.NotNil(t, feature.SoftLimit) + require.EqualValues(t, 80, *feature.SoftLimit) + require.NotNil(t, feature.HardLimit) + require.EqualValues(t, 120, *feature.HardLimit) + require.NotNil(t, feature.UsagePeriod) + }) + t.Run("Unauthorized", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8a4c22d8dbb02..6412f24991ec6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5011,6 +5011,17 @@ export interface Feature { readonly entitlement: Entitlement; readonly enabled: boolean; readonly limit?: number; + /** + * SoftLimit and HardLimit are optional thresholds that accompany Limit + * for features whose license carries them. For these features, Limit + * carries the purchased allocation, SoftLimit is the advisory warning + * threshold, and HardLimit is the enforcement ceiling. + * + * Only certain features set these fields: + * - FeatureAgentRuntimeHours + */ + readonly soft_limit?: number; + readonly hard_limit?: number; readonly actual?: number; /** * UsagePeriod denotes that the usage is a counter that accumulates over @@ -5022,6 +5033,7 @@ export interface Feature { * * Only certain features set these fields: * - FeatureManagedAgentLimit + * - FeatureAgentRuntimeHours */ readonly usage_period?: UsagePeriod; } @@ -5032,6 +5044,7 @@ export type FeatureName = | "ai_governance_user_limit" | "access_control" | "advanced_template_scheduling" + | "agent_runtime_hours" | "appearance" | "audit_log" | "boundary" @@ -5061,6 +5074,7 @@ export const FeatureNames: FeatureName[] = [ "ai_governance_user_limit", "access_control", "advanced_template_scheduling", + "agent_runtime_hours", "appearance", "audit_log", "boundary", From ccd457237dd74ab5d0c20b36372779881038df18 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 27 Jul 2026 11:51:11 +0000 Subject: [PATCH 2/3] fix(enterprise/coderd/license): address agent runtime hours review findings - Remove the unused AgentRuntimeHours license test builder. - Reject agent runtime hours soft and hard limit claims when the allocation is 0, since a zero allocation disables the feature and a zero hard limit would read as a ceiling that blocks all agent runtime. - Move claim-to-feature decoding into decodeAgentRuntimeHours so the loop no longer remaps and attaches limits in separate places. - Pin the rollout invariant that no agent runtime hours claim name is a feature name. - Assert the entitlement warning is suppressed for agent runtime hours. - Rename the error vars to carry Hours and name the offending claims. - Document HardLimit so the generated API schema describes it. --- coderd/apidoc/docs.go | 3 +- coderd/apidoc/swagger.json | 3 +- codersdk/deployment.go | 22 ++-- docs/reference/api/schemas.md | 18 +-- .../coderd/coderdenttest/coderdenttest.go | 8 -- enterprise/coderd/license/license.go | 123 ++++++++++++------ enterprise/coderd/license/license_test.go | 47 +++++-- site/src/api/typesGenerated.ts | 14 +- 8 files changed, 156 insertions(+), 82 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4fae94a16e386..ca4dc73504e92 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20247,13 +20247,14 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.Entitlement" }, "hard_limit": { + "description": "HardLimit is the enforcement ceiling that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", "type": "integer" }, "limit": { "type": "integer" }, "soft_limit": { - "description": "SoftLimit and HardLimit are optional thresholds that accompany Limit\nfor features whose license carries them. For these features, Limit\ncarries the purchased allocation, SoftLimit is the advisory warning\nthreshold, and HardLimit is the enforcement ceiling.\n\nOnly certain features set these fields:\n- FeatureAgentRuntimeHours", + "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours", "type": "integer" }, "usage_period": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e980d67497c07..f9d982f3f67d2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18413,13 +18413,14 @@ "$ref": "#/definitions/codersdk.Entitlement" }, "hard_limit": { + "description": "HardLimit is the enforcement ceiling that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", "type": "integer" }, "limit": { "type": "integer" }, "soft_limit": { - "description": "SoftLimit and HardLimit are optional thresholds that accompany Limit\nfor features whose license carries them. For these features, Limit\ncarries the purchased allocation, SoftLimit is the advisory warning\nthreshold, and HardLimit is the enforcement ceiling.\n\nOnly certain features set these fields:\n- FeatureAgentRuntimeHours", + "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours", "type": "integer" }, "usage_period": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 56fe0ed68038a..f0fed900278c0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -199,11 +199,11 @@ const ( FeatureBoundary FeatureName = "boundary" FeatureServiceAccounts FeatureName = "service_accounts" FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit" - // AgentRuntimeHours is a usage period feature. It is never a license - // claim itself; it is populated from the agent_runtime_hours_allocation, - // agent_runtime_hours_limit_soft and agent_runtime_hours_limit_hard - // claims. Refer to enterprise/coderd/license/license.go for the license - // format. + // FeatureAgentRuntimeHours is a usage period feature. It is never a + // license claim itself; it is populated from the + // agent_runtime_hours_allocation, agent_runtime_hours_limit_soft and + // agent_runtime_hours_limit_hard claims. Refer to + // enterprise/coderd/license/license.go for the license format. FeatureAgentRuntimeHours FeatureName = "agent_runtime_hours" ) @@ -381,14 +381,16 @@ type Feature struct { Entitlement Entitlement `json:"entitlement"` Enabled bool `json:"enabled"` Limit *int64 `json:"limit,omitempty"` - // SoftLimit and HardLimit are optional thresholds that accompany Limit - // for features whose license carries them. For these features, Limit - // carries the purchased allocation, SoftLimit is the advisory warning - // threshold, and HardLimit is the enforcement ceiling. + // SoftLimit is the advisory warning threshold that accompanies Limit for + // features whose license carries it. For these features, Limit carries + // the purchased allocation. // - // Only certain features set these fields: + // Only certain features set this field: // - FeatureAgentRuntimeHours SoftLimit *int64 `json:"soft_limit,omitempty"` + // HardLimit is the enforcement ceiling that accompanies Limit for + // features whose license carries it. See SoftLimit for the set of + // features that use these thresholds. HardLimit *int64 `json:"hard_limit,omitempty"` Actual *int64 `json:"actual,omitempty"` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 3b045f48c12f6..0d0ab93861b4b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7477,15 +7477,15 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|----------------------------------------------|----------|--------------|-------------| -| `actual` | integer | false | | | -| `enabled` | boolean | false | | | -| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | -| `hard_limit` | integer | false | | | -| `limit` | integer | false | | | -|`soft_limit`|integer|false||Soft limit and HardLimit are optional thresholds that accompany Limit for features whose license carries them. For these features, Limit carries the purchased allocation, SoftLimit is the advisory warning threshold, and HardLimit is the enforcement ceiling. -Only certain features set these fields: - FeatureAgentRuntimeHours| +| Name | Type | Required | Restrictions | Description | +|---------------|----------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `actual` | integer | false | | | +| `enabled` | boolean | false | | | +| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | +| `hard_limit` | integer | false | | Hard limit is the enforcement ceiling that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. | +| `limit` | integer | false | | | +|`soft_limit`|integer|false||Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. +Only certain features set this field: - FeatureAgentRuntimeHours| |`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgentRuntimeHours| diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 550bea9c870d1..1115ba12118c7 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -237,14 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions { return opts.Feature(codersdk.FeatureManagedAgentLimit, limit) } -// AgentRuntimeHours sets the agent runtime hour allocation claim. Soft and -// hard limit claims can be set with Feature using -// license.ClaimAgentRuntimeHoursLimitSoft and -// license.ClaimAgentRuntimeHoursLimitHard. -func (opts *LicenseOptions) AgentRuntimeHours(allocation int64) *LicenseOptions { - return opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation) -} - 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 97b319c17d001..7aaf7872c8144 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -297,22 +297,13 @@ func LicensesEntitlements( continue } - // Agent runtime hours are encoded as up to three claims: the - // allocation plus optional soft and hard limits. They surface as - // the single agent_runtime_hours feature. The combination is - // validated when the license is parsed, see validateClaims. - if featureName == codersdk.FeatureAgentRuntimeHours { - // 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. - continue - } - if featureName == ClaimAgentRuntimeHoursAllocation { - // Maps the allocation to the canonical feature name. - featureName = codersdk.FeatureAgentRuntimeHours - } - if featureName == ClaimAgentRuntimeHoursLimitSoft || featureName == ClaimAgentRuntimeHoursLimitHard { - // Consumed alongside the allocation claim below. + // 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. + if featureName == codersdk.FeatureAgentRuntimeHours || + isAgentRuntimeHoursClaim(featureName) { continue } @@ -332,7 +323,7 @@ func LicensesEntitlements( // Handling for limit features. switch { case featureName.UsesUsagePeriod(): - feature := codersdk.Feature{ + entitlements.AddFeature(featureName, codersdk.Feature{ Enabled: featureValue > 0, Entitlement: entitlement, Limit: &featureValue, @@ -341,18 +332,7 @@ func LicensesEntitlements( Start: usagePeriodStart, End: usagePeriodEnd, }, - } - // The agent runtime hours feature carries optional soft and - // hard limit claims alongside the allocation. - if featureName == codersdk.FeatureAgentRuntimeHours { - if soft, ok := claims.Features[ClaimAgentRuntimeHoursLimitSoft]; ok { - feature.SoftLimit = &soft - } - if hard, ok := claims.Features[ClaimAgentRuntimeHoursLimitHard]; ok { - feature.HardLimit = &hard - } - } - entitlements.AddFeature(featureName, feature) + }) case featureName.UsesLimit(): if featureValue <= 0 { // 0 limit value or less doesn't make sense, so we skip it. @@ -386,6 +366,16 @@ func LicensesEntitlements( } } + // Agent runtime hours are skipped by the loop above because the + // three claims that encode them decode into a single feature. + if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{ + IssuedAt: claims.IssuedAt.Time, + Start: usagePeriodStart, + End: usagePeriodEnd, + }); ok { + entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature) + } + addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature) // Finally, add all features from the addons. We do this last so that @@ -657,11 +647,12 @@ const ( // allocation for the license term. It becomes the feature's Limit. ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation" // ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It - // must satisfy 0 <= soft < allocation. It becomes the feature's - // SoftLimit. + // must satisfy 0 <= soft < allocation, so it may only be set when the + // allocation is greater than 0. It becomes the feature's SoftLimit. ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft" // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be - // absent or >= allocation. It becomes the feature's HardLimit. + // absent or >= allocation, and may only be set when the allocation is + // greater than 0. It becomes the feature's HardLimit. ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard" ) @@ -678,14 +669,59 @@ var ( ErrMissingAccountType = xerrors.New("license must contain valid account type") ErrMissingAccountID = xerrors.New("license must contain valid account ID") - ErrMissingAgentRuntimeAllocation = xerrors.Errorf("license has agent runtime limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation) - ErrInvalidAgentRuntimeHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation) + 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 by decodeAgentRuntimeHours rather than +// individually as al-la-carte features. +func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { + switch name { + case ClaimAgentRuntimeHoursAllocation, + ClaimAgentRuntimeHoursLimitSoft, + ClaimAgentRuntimeHoursLimitHard: + return true + default: + return false + } +} + +// 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. +// +// The claim combination is validated when the license is parsed, see +// Features.validateAgentRuntimeHours, so 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 + } + + 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 rather // than ignoring the feature, since these claims form a contract with the @@ -696,18 +732,25 @@ func (f Features) validateAgentRuntimeHours() error { hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard] if !hasAllocation { if hasSoft || hasHard { - return ErrMissingAgentRuntimeAllocation + return ErrMissingAgentRuntimeHoursAllocation } return nil } if allocation < 0 { - return ErrInvalidAgentRuntimeAllocation + return ErrInvalidAgentRuntimeHoursAllocation + } + // A zero allocation leaves the feature disabled, so neither threshold has + // anything to describe. A zero hard limit would additionally read as an + // enforcement ceiling that blocks all agent runtime, so both claims are + // rejected instead of silently ignored. + if allocation == 0 && (hasSoft || hasHard) { + return ErrAgentRuntimeHoursLimitsWithZeroAllocation } if hasSoft && (soft < 0 || soft >= allocation) { - return ErrInvalidAgentRuntimeSoftLimit + return ErrInvalidAgentRuntimeHoursSoftLimit } if hasHard && hard < allocation { - return ErrInvalidAgentRuntimeHardLimit + return ErrInvalidAgentRuntimeHoursHardLimit } return nil } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index d12ae9c160fa9..1b94e50b04e6a 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -394,6 +394,12 @@ func TestEntitlements(t *testing.T) { require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement) require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName)) } + // Agent runtime hours is enabled by `all` and not granted by this + // license, which is exactly the state the warning suppression covers. + require.NotContains(t, entitlements.Warnings, fmt.Sprintf( + "%s is enabled but your license is not entitled to this feature.", + codersdk.FeatureAgentRuntimeHours.Humanize(), + )) }) t.Run("TooManyUsers", func(t *testing.T) { t.Parallel() @@ -2465,6 +2471,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { require.Nil(t, feature.Limit) }) + // The rollout guarantee for old deployments is that none of the three + // claim names is itself a feature name, so an old server ignores them as + // unknown claims. Pin the invariant so a future feature registration + // cannot break it silently. + t.Run("ClaimNamesAreNotFeatureNames", func(t *testing.T) { + t.Parallel() + + for _, claim := range []string{ + license.ClaimAgentRuntimeHoursAllocation, + license.ClaimAgentRuntimeHoursLimitSoft, + license.ClaimAgentRuntimeHoursLimitHard, + } { + require.NotContains(t, codersdk.FeatureNamesMap, codersdk.FeatureName(claim)) + } + }) + // Ensures licenses carrying claims for features this server version does // not know about do not break entitlement computation. This is exactly // what old deployments see when a license carries the agent runtime hour @@ -2556,27 +2578,36 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { 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.ErrMissingAgentRuntimeAllocation, + expectedErr: license.ErrMissingAgentRuntimeHoursAllocation, }, { name: "HardWithoutAllocation", features: license.Features{ license.ClaimAgentRuntimeHoursLimitHard: 120, }, - expectedErr: license.ErrMissingAgentRuntimeAllocation, + expectedErr: license.ErrMissingAgentRuntimeHoursAllocation, }, { name: "NegativeAllocation", features: license.Features{ license.ClaimAgentRuntimeHoursAllocation: -1, }, - expectedErr: license.ErrInvalidAgentRuntimeAllocation, + expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation, }, { name: "NegativeSoft", @@ -2584,7 +2615,7 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: -1, }, - expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, }, { name: "SoftEqualsAllocation", @@ -2592,7 +2623,7 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: 100, }, - expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, }, { name: "SoftAboveAllocation", @@ -2600,7 +2631,7 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitSoft: 150, }, - expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, }, { name: "SoftWithZeroAllocation", @@ -2608,7 +2639,7 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 0, license.ClaimAgentRuntimeHoursLimitSoft: 0, }, - expectedErr: license.ErrInvalidAgentRuntimeSoftLimit, + expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation, }, { name: "HardBelowAllocation", @@ -2616,7 +2647,7 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) { license.ClaimAgentRuntimeHoursAllocation: 100, license.ClaimAgentRuntimeHoursLimitHard: 99, }, - expectedErr: license.ErrInvalidAgentRuntimeHardLimit, + expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit, }, } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6412f24991ec6..4408ec26fa3b1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5012,15 +5012,19 @@ export interface Feature { readonly enabled: boolean; readonly limit?: number; /** - * SoftLimit and HardLimit are optional thresholds that accompany Limit - * for features whose license carries them. For these features, Limit - * carries the purchased allocation, SoftLimit is the advisory warning - * threshold, and HardLimit is the enforcement ceiling. + * SoftLimit is the advisory warning threshold that accompanies Limit for + * features whose license carries it. For these features, Limit carries + * the purchased allocation. * - * Only certain features set these fields: + * Only certain features set this field: * - FeatureAgentRuntimeHours */ readonly soft_limit?: number; + /** + * HardLimit is the enforcement ceiling that accompanies Limit for + * features whose license carries it. See SoftLimit for the set of + * features that use these thresholds. + */ readonly hard_limit?: number; readonly actual?: number; /** From 2346a956ba0c124cd87ea6d249e96b6d8e5c4dd4 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 29 Jul 2026 06:28:50 +0000 Subject: [PATCH 3/3] chore: updates for PR comments --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- codersdk/deployment.go | 7 +++---- codersdk/deployment_test.go | 2 -- docs/reference/api/schemas.md | 14 +++++++------- enterprise/coderd/license/license.go | 19 +++++++------------ enterprise/coderd/license/license_test.go | 9 ++++----- site/src/api/typesGenerated.ts | 2 +- 8 files changed, 24 insertions(+), 33 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ca4dc73504e92..17208b346197c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20247,7 +20247,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.Entitlement" }, "hard_limit": { - "description": "HardLimit is the enforcement ceiling that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", + "description": "HardLimit is the enforcement threshold that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", "type": "integer" }, "limit": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f9d982f3f67d2..34bb5feeabb01 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18413,7 +18413,7 @@ "$ref": "#/definitions/codersdk.Entitlement" }, "hard_limit": { - "description": "HardLimit is the enforcement ceiling that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", + "description": "HardLimit is the enforcement threshold that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.", "type": "integer" }, "limit": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f0fed900278c0..0724ae31c2646 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -200,7 +200,7 @@ const ( FeatureServiceAccounts FeatureName = "service_accounts" FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit" // FeatureAgentRuntimeHours is a usage period feature. It is never a - // license claim itself; it is populated from the + // license claim itself. It is populated from the // agent_runtime_hours_allocation, agent_runtime_hours_limit_soft and // agent_runtime_hours_limit_hard claims. Refer to // enterprise/coderd/license/license.go for the license format. @@ -388,7 +388,7 @@ type Feature struct { // Only certain features set this field: // - FeatureAgentRuntimeHours SoftLimit *int64 `json:"soft_limit,omitempty"` - // HardLimit is the enforcement ceiling that accompanies Limit for + // HardLimit is the enforcement threshold that accompanies Limit for // features whose license carries it. See SoftLimit for the set of // features that use these thresholds. HardLimit *int64 `json:"hard_limit,omitempty"` @@ -429,8 +429,7 @@ type UsagePeriod struct { // 6. Enabled is greater than disabled // 7. The actual is greater // -// SoftLimit and HardLimit are not comparison inputs; they ride along with -// whichever feature wins the comparison. +// SoftLimit and HardLimit are not comparison inputs. func (f Feature) Compare(b Feature) int { // For features with usage period constraints only, check the issued at and // end dates. diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index be09a71d5f97d..3e7e5216c8ed6 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -1002,8 +1002,6 @@ func TestFeatureComparison(t *testing.T) { B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil}, Expected: 1, }, - // Soft and hard limits are not comparison inputs; they ride along - // with whichever feature wins. { Name: "SoftHardLimitsIgnored", A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), SoftLimit: ptr.Ref(int64(80)), HardLimit: ptr.Ref(int64(120))}, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0d0ab93861b4b..dc16b25804772 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7477,13 +7477,13 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|----------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `actual` | integer | false | | | -| `enabled` | boolean | false | | | -| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | -| `hard_limit` | integer | false | | Hard limit is the enforcement ceiling that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. | -| `limit` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `actual` | integer | false | | | +| `enabled` | boolean | false | | | +| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | +| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. | +| `limit` | integer | false | | | |`soft_limit`|integer|false||Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. Only certain features set this field: - FeatureAgentRuntimeHours| |`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 7aaf7872c8144..3248678d2111e 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -300,7 +300,7 @@ func LicensesEntitlements( // 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 + // valid claim. The allocation must come from the dedicated claim // so it is validated against the soft and hard limits. if featureName == codersdk.FeatureAgentRuntimeHours || isAgentRuntimeHoursClaim(featureName) { @@ -366,7 +366,7 @@ func LicensesEntitlements( } } - // Agent runtime hours are skipped by the loop above because the + // 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{ IssuedAt: claims.IssuedAt.Time, @@ -680,8 +680,7 @@ 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 by decodeAgentRuntimeHours rather than -// individually as al-la-carte features. +// claims are decoded together, see decodeAgentRuntimeHours. func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { switch name { case ClaimAgentRuntimeHoursAllocation, @@ -698,7 +697,7 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool { // allocation claim, in which case the license does not grant the feature. // // The claim combination is validated when the license is parsed, see -// Features.validateAgentRuntimeHours, so the allocation is never negative here +// 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) { @@ -723,9 +722,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement } // validateAgentRuntimeHours validates the relationship between the agent -// runtime hour claims. Invalid combinations reject the entire license rather -// than ignoring the feature, since these claims form a contract with the -// license issuer. +// runtime hour claims. Invalid combinations reject the entire license. func (f Features) validateAgentRuntimeHours() error { allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation] soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft] @@ -739,10 +736,8 @@ func (f Features) validateAgentRuntimeHours() error { if allocation < 0 { return ErrInvalidAgentRuntimeHoursAllocation } - // A zero allocation leaves the feature disabled, so neither threshold has - // anything to describe. A zero hard limit would additionally read as an - // enforcement ceiling that blocks all agent runtime, so both claims are - // rejected instead of silently ignored. + // A zero allocation disables the feature. + // A zero hard limit is not permitted. if allocation == 0 && (hasSoft || hasHard) { return ErrAgentRuntimeHoursLimitsWithZeroAllocation } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 1b94e50b04e6a..258548e2ec233 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -81,8 +81,6 @@ func TestEntitlements(t *testing.T) { continue } if name == codersdk.FeatureAgentRuntimeHours { - // The feature is never a claim itself; it is - // granted through the allocation claim. f[license.ClaimAgentRuntimeHoursAllocation] = 100 continue } @@ -2322,9 +2320,10 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) { require.NotNil(t, feature.UsagePeriod) }) - // Ensures that the license with the newest iat wins regardless of load - // order or a larger allocation, and that the winning license's soft and - // hard limits ride along. Mirrors TestUsageLimitFeatures/IssuedAtRanking. + // 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. + // Mirrors TestUsageLimitFeatures/IssuedAtRanking. t.Run("IssuedAtRanking", func(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4408ec26fa3b1..49dfc45d6ae0b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5021,7 +5021,7 @@ export interface Feature { */ readonly soft_limit?: number; /** - * HardLimit is the enforcement ceiling that accompanies Limit for + * HardLimit is the enforcement threshold that accompanies Limit for * features whose license carries it. See SoftLimit for the set of * features that use these thresholds. */