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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user