mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add agent runtime hour license claims and entitlement feature (#27459)
Licenses can now carry three agent runtime hour claims:
`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` / `hard_limit` fields on `codersdk.Feature` carry
the thresholds.
Invalid combinations reject the entire license via `validateClaims`
(both at upload and when computing entitlements for stored licenses):
soft/hard without allocation, negative allocation, soft outside `0 <=
soft < allocation`, or `hard < allocation`.
Soft and hard limits are not comparison inputs in `Feature.Compare`;
they ride along with whichever license wins (newest `iat`, existing
behavior). None of the three claim names is a feature name, so old
servers ignore them via the existing unknown-claim tolerance, protecting
rollout of licenses minted with the new claims.
The claim name constants defined in `enterprise/coderd/license` are the
canonical contract for `github.com/coder/license` (X1).
Part of
[CODAGT-837](https://linear.app/codercom/issue/CODAGT-837/a1-agent-runtime-license-claims-and-entitlement-feature).
Blocks B4 (usage wiring + warnings), C1 (hard-limit admission gate), F1
(licenses page), A4 (managed-agent coexistence), X1 (licensor).
Out of scope, handled by follow-up issues: `Actual` usage wiring,
threshold warnings, admission gating, premium defaults, and FE surfacing
beyond regenerated types.
<details>
<summary>Implementation plan and decision log</summary>
## Decisions (confirmed by jaayden, 2026-07-23)
1. **Claim names / unit:**
- `agent_runtime_hours_allocation` - allocation (unit: hours, int64)
- `agent_runtime_hours_limit_soft` - soft limit
- `agent_runtime_hours_limit_hard` - hard limit
- None of the three claim names is itself a `FeatureName`; all three map
to the single new usage-period feature `agent_runtime_hours`
(`FeatureAgentRuntimeHours`), mirroring how `managed_agent_limit_soft`
mapped onto `managed_agent_limit`. Old servers therefore ignore all
three claims via the `FeatureNamesMap` check.
2. **Reject-license.** Invalid claim combinations reject the whole
license via `validateClaims` (upload returns 400 via
`ParseClaimsIgnoreNbf`; already-stored licenses produce an `Invalid
license ... parsing claims` entitlements error and contribute nothing).
## Design notes
- `codersdk.Feature` had a `SoftLimit` field until 051ed34580 ("feat:
convert soft_limit to limit", #22048) collapsed managed-agent soft/hard
into a single `limit`. This reintroduces soft/hard as optional fields
without changing managed-agent behavior.
- Existing usage-period machinery populates `UsagePeriod` from
`nbf`/`exp` (`usagePeriodStart`/`usagePeriodEnd` in
`LicensesEntitlements`); reused unchanged, consistent with managed
agents.
- `Entitlements.AddFeature` replaces whole `Feature` structs (no
merging), so soft/hard automatically ride along with the winning
license. No `Feature.Compare` logic change; doc updates plus tests pin
that soft/hard are not comparison inputs.
- The feature name itself is not accepted as a claim; the allocation
must come from the dedicated claim so it is validated against soft/hard
(prevents a validation bypass where a direct feature-name claim could
win precedence with unvalidated thresholds).
- The generic "enabled but not entitled/expired" warning loop skips the
feature, mirroring `FeatureManagedAgentLimit`; usage-based warnings
arrive with B4.
- No premium default for this feature (unlike managed agents).
## Changes
1. `codersdk/deployment.go`: new `FeatureAgentRuntimeHours` (in
`FeatureNames`, `UsesLimit()`, `UsesUsagePeriod()`, keeping it out of
`FeatureSet` expansion); `Feature.SoftLimit`/`Feature.HardLimit`
(`soft_limit`/`hard_limit`, omitempty); doc updates for `UsagePeriod`
and `Compare`.
2. `enterprise/coderd/license/license.go`: canonical claim constants;
validation helper called from `validateClaims`; al-la-carte loop maps
the allocation claim to the feature and attaches soft/hard from the
companion claims; skips for the companion claims and the raw feature
name; generic warning loop skip.
3. `enterprise/coderd/coderdenttest`: `AgentRuntimeHours(allocation)`
builder.
4. Tests:
- `TestAgentRuntimeHoursLicenses`: entitled/grace round-trips (including
JSON field assertions), allocation-only, explicit zero,
`IssuedAtRanking` mirror, soft/hard ride-along with a newer
allocation-only license, direct feature-name claim ignored,
unknown-claims compatibility (old-server simulation).
- `TestAgentRuntimeHoursClaimValidation`: table of valid/invalid claim
combinations against `ParseClaims`, plus stored-license entitlements
error.
- `TestPostLicense`: API-level 400 rejection and a happy-path POST +
`GET /api/v2/entitlements` round-trip.
- `TestFeatureComparison`: soft/hard ignored in comparison; newest `iat`
wins over larger soft/hard.
5. `make gen`: regenerated `site/src/api/typesGenerated.ts`,
`coderd/apidoc/*`, `docs/reference/api/*`.
## Verification
- `go test ./enterprise/coderd/license/ ./codersdk/` and `go test
./enterprise/coderd/ -run 'TestPostLicense|TestEntitlements'` pass.
- `golangci-lint` clean on changed packages; `make lint/emdash` clean;
FE `tsc --noEmit` clean.
- Independent agent review of the diff found no blockers; its minor
findings (direct feature-name claim validation bypass, precedence test
gap, missing API happy-path test) were addressed.
</details>
> [!NOTE]
> Generated by Coder Agents on behalf of @jaaydenh (Linear CODAGT-837
agent session).
This commit is contained in:
Generated
+9
-1
@@ -20568,11 +20568,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"
|
||||
|
||||
Generated
+9
-1
@@ -18702,11 +18702,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"
|
||||
|
||||
+24
-1
@@ -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.
|
||||
|
||||
@@ -1015,6 +1015,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 {
|
||||
|
||||
Generated
+4
@@ -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",
|
||||
|
||||
Generated
+16
-7
@@ -7179,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",
|
||||
@@ -7190,7 +7192,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",
|
||||
@@ -7473,7 +7477,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",
|
||||
@@ -7484,15 +7490,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
|
||||
|
||||
|
||||
@@ -508,6 +508,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
|
||||
@@ -567,6 +577,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)
|
||||
licenseHasAIGovernanceAddon := false
|
||||
|
||||
@@ -790,6 +810,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 {
|
||||
@@ -860,6 +885,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"}
|
||||
|
||||
@@ -872,10 +917,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
|
||||
@@ -966,6 +1089,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")
|
||||
|
||||
@@ -2,6 +2,7 @@ package license_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -87,6 +88,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
|
||||
@@ -381,6 +386,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
|
||||
@@ -394,6 +400,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()
|
||||
@@ -2146,6 +2158,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()
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Generated
+18
@@ -5247,6 +5247,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
|
||||
@@ -5258,6 +5273,7 @@ export interface Feature {
|
||||
*
|
||||
* Only certain features set these fields:
|
||||
* - FeatureManagedAgentLimit
|
||||
* - FeatureAgentRuntimeHours
|
||||
*/
|
||||
readonly usage_period?: UsagePeriod;
|
||||
}
|
||||
@@ -5268,6 +5284,7 @@ export type FeatureName =
|
||||
| "ai_governance_user_limit"
|
||||
| "access_control"
|
||||
| "advanced_template_scheduling"
|
||||
| "agent_runtime_hours"
|
||||
| "appearance"
|
||||
| "audit_log"
|
||||
| "boundary"
|
||||
@@ -5297,6 +5314,7 @@ export const FeatureNames: FeatureName[] = [
|
||||
"ai_governance_user_limit",
|
||||
"access_control",
|
||||
"advanced_template_scheduling",
|
||||
"agent_runtime_hours",
|
||||
"appearance",
|
||||
"audit_log",
|
||||
"boundary",
|
||||
|
||||
Reference in New Issue
Block a user