diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c27365ac07..17208b34619 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20246,11 +20246,19 @@ const docTemplate = `{ "entitlement": { "$ref": "#/definitions/codersdk.Entitlement" }, + "hard_limit": { + "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": { "type": "integer" }, + "soft_limit": { + "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": { - "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 7f87f2bad29..34bb5feeabb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18412,11 +18412,19 @@ "entitlement": { "$ref": "#/definitions/codersdk.Entitlement" }, + "hard_limit": { + "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": { "type": "integer" }, + "soft_limit": { + "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": { - "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 2c4a0bffa9d..0724ae31c26 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" + // 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" ) 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,18 @@ type Feature struct { Entitlement Entitlement `json:"entitlement"` Enabled bool `json:"enabled"` Limit *int64 `json:"limit,omitempty"` - Actual *int64 `json:"actual,omitempty"` + // 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 this field: + // - FeatureAgentRuntimeHours + SoftLimit *int64 `json:"soft_limit,omitempty"` + // 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"` + Actual *int64 `json:"actual,omitempty"` // Below is only for features that use usage periods. @@ -385,6 +405,7 @@ type Feature struct { // // Only certain features set these fields: // - FeatureManagedAgentLimit + // - FeatureAgentRuntimeHours UsagePeriod *UsagePeriod `json:"usage_period,omitempty"` } @@ -407,6 +428,8 @@ 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. 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 a70fef938a6..3e7e5216c8e 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -1002,6 +1002,38 @@ func TestFeatureComparison(t *testing.T) { B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil}, Expected: 1, }, + { + 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 eeaf8032ec0..3cfe0a2c971 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 ddba40ad34a..dc16b258047 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", @@ -7471,15 +7477,18 @@ 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 | | | -| `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). 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/license/license.go b/enterprise/coderd/license/license.go index 8092e5f6258..3248678d211 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -297,6 +297,16 @@ 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. + if featureName == codersdk.FeatureAgentRuntimeHours || + isAgentRuntimeHoursClaim(featureName) { + continue + } + if featureValue < 0 { // We currently don't use negative values for features. continue @@ -356,6 +366,16 @@ 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{ + 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 @@ -541,6 +561,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 +636,26 @@ 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, 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, and may only be set when the allocation is + // greater than 0. It becomes the feature's HardLimit. + ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard" +) + var ( ValidMethods = []string{"EdDSA"} @@ -623,10 +668,88 @@ 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. +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. 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. +func (f Features) validateAgentRuntimeHours() error { + allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation] + soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft] + hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard] + if !hasAllocation { + if hasSoft || hasHard { + return ErrMissingAgentRuntimeHoursAllocation + } + return nil + } + if allocation < 0 { + return ErrInvalidAgentRuntimeHoursAllocation + } + // 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 +} + // Claims is the full set of claims in a license. type Claims struct { jwt.RegisteredClaims @@ -717,6 +840,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 10dea231cd8..258548e2ec2 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,10 @@ func TestEntitlements(t *testing.T) { f[codersdk.FeatureManagedAgentLimit] = 100 continue } + if name == codersdk.FeatureAgentRuntimeHours { + f[license.ClaimAgentRuntimeHoursAllocation] = 100 + continue + } f[name] = 1 } return f @@ -373,6 +378,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 @@ -386,6 +392,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() @@ -2138,6 +2150,553 @@ 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) + }) + + // 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() + + 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) + }) + + // 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 + // 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, + }, + 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, + }, + { + name: "SoftEqualsAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 100, + }, + expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, + }, + { + name: "SoftAboveAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitSoft: 150, + }, + expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit, + }, + { + name: "SoftWithZeroAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 0, + license.ClaimAgentRuntimeHoursLimitSoft: 0, + }, + expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation, + }, + { + name: "HardBelowAllocation", + features: license.Features{ + license.ClaimAgentRuntimeHoursAllocation: 100, + license.ClaimAgentRuntimeHoursLimitHard: 99, + }, + expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit, + }, + } + + 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 73d16535d4e..c88f975f2c0 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 8a4c22d8dbb..49dfc45d6ae 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5011,6 +5011,21 @@ export interface Feature { readonly entitlement: Entitlement; readonly enabled: boolean; readonly limit?: number; + /** + * 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 this field: + * - FeatureAgentRuntimeHours + */ + readonly soft_limit?: number; + /** + * 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. + */ + readonly hard_limit?: number; readonly actual?: number; /** * UsagePeriod denotes that the usage is a counter that accumulates over @@ -5022,6 +5037,7 @@ export interface Feature { * * Only certain features set these fields: * - FeatureManagedAgentLimit + * - FeatureAgentRuntimeHours */ readonly usage_period?: UsagePeriod; } @@ -5032,6 +5048,7 @@ export type FeatureName = | "ai_governance_user_limit" | "access_control" | "advanced_template_scheduling" + | "agent_runtime_hours" | "appearance" | "audit_log" | "boundary" @@ -5061,6 +5078,7 @@ export const FeatureNames: FeatureName[] = [ "ai_governance_user_limit", "access_control", "advanced_template_scheduling", + "agent_runtime_hours", "appearance", "audit_log", "boundary",