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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user