mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: tolerate unusable runtime hours claims and decode -1 allocation as unlimited (#27984)
Two coupled changes to the license/entitlements layer, preparing for runtime-hours usage reporting. **Tolerate unusable runtime hour claims.** Unusable `agent_runtime_hours_*` claim combinations no longer reject the whole license: rejecting a signed license over a cosmetic threshold claim would drop the deployment to unlicensed. `decodeAgentRuntimeHours` drops the unusable claims, surfaces the stable `LicenseAgentRuntimeHoursClaimsIgnoredWarningText` (deduplicated across licenses), and logs the affected license and claims through the new `FeatureArguments.Logger`; `validateAgentRuntimeHours` and its license-invalidating errors are removed. The dashboard recognizes the stable diagnostic text and renders it muted, with a "License notices" heading instead of the exceedance heading and without a sales link. **Unlimited allocation.** An `agent_runtime_hours_allocation` claim of exactly `-1` (`AgentRuntimeHoursUnlimitedAllocation`, mirrored in coder/license) is reserved to mean unlimited: it decodes to an enabled feature with no `limit` in `/api/v2/entitlements`, the shape the UI already renders as "Unlimited". Threshold claims alongside it have nothing to threshold against, so they are dropped with the claims-ignored warning, and any other negative allocation remains unusable. The issuer-side counterpart (refusing to mint `-1` together with threshold claims) is coder/license#49. The managed agent measurement path is intentionally untouched: managed agents are deprecated and slated for removal, so the shared usage-measurement failure policy (`measureUsage`) now lands in #27985 next to its runtime-hours consumer instead of converting a doomed call site here. Part 2 of a 3-PR stack splitting up #27796 (see there for review history). Stack: #27983 → this PR → #27985.
This commit is contained in:
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
|
||||
// Enable all features
|
||||
features := make(license.Features)
|
||||
for _, feature := range codersdk.FeatureNames {
|
||||
if feature == codersdk.FeatureAgentRuntimeHours {
|
||||
// The feature name is not a valid license claim; the
|
||||
// feature is encoded as its allocation claim.
|
||||
features[license.ClaimAgentRuntimeHoursAllocation] = 1
|
||||
continue
|
||||
}
|
||||
features[feature] = 1
|
||||
}
|
||||
features[codersdk.FeatureUserLimit] = 100
|
||||
|
||||
@@ -237,6 +237,17 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
|
||||
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
|
||||
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
|
||||
if softLimit != nil {
|
||||
opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
|
||||
}
|
||||
if hardLimit != nil {
|
||||
opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
|
||||
if opts.Features == nil {
|
||||
opts.Features = license.Features{}
|
||||
|
||||
@@ -93,6 +93,7 @@ func Entitlements(
|
||||
}
|
||||
|
||||
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
|
||||
Logger: logger,
|
||||
ActiveUserCount: activeUserCount,
|
||||
ActiveAISeatCount: activeAISeatCount,
|
||||
ReplicaCount: replicaCount,
|
||||
@@ -129,6 +130,7 @@ func Entitlements(
|
||||
}
|
||||
|
||||
type FeatureArguments struct {
|
||||
Logger slog.Logger
|
||||
ActiveUserCount int64
|
||||
ActiveAISeatCount int64
|
||||
ReplicaCount int
|
||||
@@ -508,11 +510,8 @@ 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.
|
||||
// Agent runtime hour claims are decoded together after this
|
||||
// loop; see decodeAgentRuntimeHours.
|
||||
if featureName == codersdk.FeatureAgentRuntimeHours ||
|
||||
isAgentRuntimeHoursClaim(featureName) {
|
||||
continue
|
||||
@@ -577,14 +576,23 @@ 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{
|
||||
runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
|
||||
IssuedAt: claims.IssuedAt.Time,
|
||||
Start: usagePeriodStart,
|
||||
End: usagePeriodEnd,
|
||||
}); ok {
|
||||
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
|
||||
})
|
||||
if granted {
|
||||
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
|
||||
}
|
||||
if len(ignoredClaims) > 0 {
|
||||
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
|
||||
slog.F("license_id", license.UUID),
|
||||
slog.F("ignored_claims", ignoredClaims),
|
||||
)
|
||||
if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
|
||||
entitlements.Warnings = append(entitlements.Warnings,
|
||||
codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
|
||||
}
|
||||
}
|
||||
|
||||
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
|
||||
@@ -885,26 +893,32 @@ 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.
|
||||
// Agent runtime hour license claims, minted by github.com/coder/license.
|
||||
// All three are in hours and decode together into the single
|
||||
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
|
||||
const (
|
||||
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
|
||||
// allocation for the license term. It becomes the feature's Limit.
|
||||
// AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
|
||||
// unlimited; any other negative allocation is ignored, in which case
|
||||
// the license does not grant the feature.
|
||||
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.
|
||||
// becomes the feature's SoftLimit when 0 <= soft < allocation and is
|
||||
// ignored otherwise.
|
||||
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 is the enforcement ceiling. It becomes
|
||||
// the feature's HardLimit when the allocation is greater than 0 and
|
||||
// hard >= allocation, and is ignored otherwise.
|
||||
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
|
||||
)
|
||||
|
||||
// AgentRuntimeHoursUnlimitedAllocation is the reserved
|
||||
// ClaimAgentRuntimeHoursAllocation value meaning the license grants
|
||||
// unlimited runtime hours. It decodes to an enabled feature with a nil
|
||||
// Limit. Mirrored in github.com/coder/license.
|
||||
const AgentRuntimeHoursUnlimitedAllocation int64 = -1
|
||||
|
||||
var (
|
||||
ValidMethods = []string{"EdDSA"}
|
||||
|
||||
@@ -917,19 +931,12 @@ 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.
|
||||
// isAgentRuntimeHoursClaim reports whether name is one of the three claims
|
||||
// decoded by decodeAgentRuntimeHours.
|
||||
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
|
||||
switch name {
|
||||
case ClaimAgentRuntimeHoursAllocation,
|
||||
@@ -941,62 +948,76 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
|
||||
// feature from its claims. granted is false when there is no usable
|
||||
// allocation claim; per-claim validity rules live on the Claim* constants
|
||||
// above.
|
||||
//
|
||||
// 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
|
||||
// Unusable claims are dropped rather than invalidating the license, since
|
||||
// rejecting a signed license over a cosmetic claim would drop the deployment
|
||||
// to unlicensed. Each dropped claim is returned in ignoredClaims so the
|
||||
// caller can warn and log instead of letting an incorrectly issued license
|
||||
// look healthy.
|
||||
//
|
||||
// A zero allocation grants the feature disabled, but Actual is still
|
||||
// measured and published.
|
||||
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
|
||||
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
|
||||
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
|
||||
}
|
||||
|
||||
feature := codersdk.Feature{
|
||||
allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
|
||||
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
|
||||
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
|
||||
|
||||
if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
|
||||
if softOk {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
|
||||
}
|
||||
if hardOk {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
|
||||
}
|
||||
return codersdk.Feature{
|
||||
Enabled: true,
|
||||
Entitlement: entitlement,
|
||||
UsagePeriod: &usagePeriod,
|
||||
}, true, ignoredClaims
|
||||
}
|
||||
|
||||
if !allocOk || allocation < 0 {
|
||||
if allocOk && allocation < 0 {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
|
||||
}
|
||||
if softOk {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
|
||||
}
|
||||
if hardOk {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
|
||||
}
|
||||
return codersdk.Feature{}, false, ignoredClaims
|
||||
}
|
||||
|
||||
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
|
||||
if softOk {
|
||||
if soft >= 0 && soft < allocation {
|
||||
feature.SoftLimit = &soft
|
||||
} else {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if allocation < 0 {
|
||||
return ErrInvalidAgentRuntimeHoursAllocation
|
||||
if hardOk {
|
||||
if allocation > 0 && hard >= allocation {
|
||||
feature.HardLimit = &hard
|
||||
} else {
|
||||
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
return feature, true, ignoredClaims
|
||||
}
|
||||
|
||||
// Claims is the full set of claims in a license.
|
||||
@@ -1089,9 +1110,6 @@ 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")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package license_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,11 +16,14 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -2328,6 +2333,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
|
||||
require.NotNil(t, feature.UsagePeriod)
|
||||
})
|
||||
|
||||
// An unlimited (-1) allocation grants the feature enabled with no Limit,
|
||||
// which the API serves as an omitted "limit" field, the shape the UI
|
||||
// already renders as "Unlimited".
|
||||
t.Run("UnlimitedAllocation", 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: license.AgentRuntimeHoursUnlimitedAllocation,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
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.NotContains(t, entitlements.Warnings,
|
||||
codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
|
||||
|
||||
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
|
||||
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
|
||||
require.True(t, feature.Enabled)
|
||||
require.Nil(t, feature.Limit)
|
||||
require.Nil(t, feature.SoftLimit)
|
||||
require.Nil(t, feature.HardLimit)
|
||||
require.NotNil(t, feature.UsagePeriod)
|
||||
|
||||
// The entitlements JSON served by GET /api/v2/entitlements omits
|
||||
// "limit" entirely for the unlimited feature.
|
||||
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.Equal(t, true, rawFeature["enabled"])
|
||||
require.NotContains(t, rawFeature, "limit")
|
||||
require.Contains(t, rawFeature, "usage_period")
|
||||
})
|
||||
|
||||
// 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.
|
||||
@@ -2396,6 +2450,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// When an unlimited and a metered license are minted with identical
|
||||
// issued-at and expiry claims, the unlimited grant must win the tie,
|
||||
// regardless of load order.
|
||||
t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// JWT NumericDate claims have second granularity, so truncate to
|
||||
// keep the round-tripped issued-at values identical.
|
||||
iat := time.Now().Add(-time.Minute).Truncate(time.Second)
|
||||
nbf := iat
|
||||
exp := iat.Add(time.Hour).Truncate(time.Second)
|
||||
unlimited := database.License{
|
||||
ID: 1,
|
||||
UploadedAt: time.Now(),
|
||||
Exp: exp,
|
||||
UUID: uuid.New(),
|
||||
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
IssuedAt: iat,
|
||||
NotBefore: nbf,
|
||||
ExpiresAt: exp,
|
||||
Features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
|
||||
},
|
||||
}),
|
||||
}
|
||||
metered := database.License{
|
||||
ID: 2,
|
||||
UploadedAt: time.Now(),
|
||||
Exp: exp,
|
||||
UUID: uuid.New(),
|
||||
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
IssuedAt: iat,
|
||||
NotBefore: nbf,
|
||||
ExpiresAt: exp,
|
||||
Features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 120,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
for _, order := range [][]database.License{
|
||||
{unlimited, metered},
|
||||
{metered, unlimited},
|
||||
} {
|
||||
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.True(t, feature.Enabled)
|
||||
require.Nil(t, feature.Limit)
|
||||
require.Nil(t, feature.SoftLimit)
|
||||
require.Nil(t, feature.HardLimit)
|
||||
require.NotNil(t, feature.UsagePeriod)
|
||||
}
|
||||
})
|
||||
|
||||
// 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) {
|
||||
@@ -2535,15 +2649,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
|
||||
// agent runtime hour claims reject the entire license.
|
||||
func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
|
||||
// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
|
||||
// tolerate-and-warn contract; see that function's doc for the rationale.
|
||||
func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
features license.Features
|
||||
expectedErr error
|
||||
name string
|
||||
features license.Features
|
||||
|
||||
// expectFeature is nil when the feature must be absent.
|
||||
expectFeature *codersdk.Feature
|
||||
// expectClaimsIgnored is true when at least one present claim is
|
||||
// dropped, which must surface the claims-ignored warning: tolerating
|
||||
// a claim and signaling nothing would make an incorrectly issued license
|
||||
// undetectable from the deployment.
|
||||
expectClaimsIgnored bool
|
||||
}{
|
||||
{
|
||||
name: "AllClaims",
|
||||
@@ -2552,85 +2673,63 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 120,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
SoftLimit: ptr.Ref[int64](80),
|
||||
HardLimit: ptr.Ref[int64](120),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AllocationOnly",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A zero soft limit is valid (0 <= soft < allocation) and warns
|
||||
// from the start of the usage period. Omitting the claim is the
|
||||
// way to express "no soft limit".
|
||||
name: "ZeroSoft",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "HardEqualsAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 100,
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
SoftLimit: ptr.Ref[int64](0),
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
// A soft limit at or above the allocation could never fire
|
||||
// before the allocation warning supersedes it.
|
||||
name: "SoftEqualsAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 100,
|
||||
},
|
||||
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "SoftAboveAllocation",
|
||||
@@ -2638,15 +2737,23 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 150,
|
||||
},
|
||||
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "SoftWithZeroAllocation",
|
||||
name: "HardEqualsAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 0,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 0,
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 100,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
HardLimit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
|
||||
},
|
||||
{
|
||||
name: "HardBelowAllocation",
|
||||
@@ -2654,7 +2761,122 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 99,
|
||||
},
|
||||
expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "ZeroAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 0,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: false,
|
||||
Limit: ptr.Ref[int64](0),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A zero allocation has no hour budget, so threshold claims
|
||||
// alongside it are dropped, with the warning.
|
||||
name: "ZeroAllocationWithLimits",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: 0,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 1000,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: false,
|
||||
Limit: ptr.Ref[int64](0),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
// An unlimited allocation grants the feature with no Limit and
|
||||
// no warning: -1 is the canonical unlimited encoding, not an
|
||||
// issuance mistake.
|
||||
name: "UnlimitedAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Threshold claims alongside an unlimited allocation have
|
||||
// nothing to threshold against; the grant survives but the
|
||||
// issuance mistake must stay visible via the warning.
|
||||
name: "UnlimitedWithSoft",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "UnlimitedWithHard",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 120,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
// Only exactly -1 is the unlimited sentinel; any other negative
|
||||
// allocation stays unusable.
|
||||
name: "NegativeAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursAllocation: -2,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "SoftWithoutAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
name: "HardWithoutAllocation",
|
||||
features: license.Features{
|
||||
license.ClaimAgentRuntimeHoursLimitHard: 120,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
// The feature name itself is never a valid claim: the
|
||||
// allocation must come from the dedicated claim. It is the
|
||||
// shape every other metered feature uses, so a license minting
|
||||
// it is the most plausible issuer mistake and must warn
|
||||
// rather than being dropped silently.
|
||||
name: "FeatureNameAsClaim",
|
||||
features: license.Features{
|
||||
codersdk.FeatureAgentRuntimeHours: 100,
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
{
|
||||
// The feature name claim is dropped (with the warning) even
|
||||
// when a usable allocation claim grants the feature.
|
||||
name: "FeatureNameAlongsideAllocation",
|
||||
features: license.Features{
|
||||
codersdk.FeatureAgentRuntimeHours: 50,
|
||||
license.ClaimAgentRuntimeHoursAllocation: 100,
|
||||
},
|
||||
expectFeature: &codersdk.Feature{
|
||||
Enabled: true,
|
||||
Limit: ptr.Ref[int64](100),
|
||||
},
|
||||
expectClaimsIgnored: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2662,46 +2884,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
|
||||
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)
|
||||
features := license.Features{
|
||||
codersdk.FeatureUserLimit: 100,
|
||||
}
|
||||
maps.Copy(features, tc.features)
|
||||
lic := database.License{
|
||||
ID: 1,
|
||||
UploadedAt: time.Now(),
|
||||
Exp: time.Now().Add(time.Hour),
|
||||
UUID: uuid.New(),
|
||||
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
Features: features,
|
||||
}),
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
entitlements, err := license.LicensesEntitlements(
|
||||
context.Background(), time.Now(), []database.License{lic},
|
||||
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
|
||||
Logger: slog.Make(sloghuman.Sink(&logBuf)),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The license as a whole survives: unrelated paid features are
|
||||
// unaffected by an unusable runtime hour claim.
|
||||
require.Empty(t, entitlements.Errors)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
|
||||
require.NotNil(t, userLimit.Limit)
|
||||
require.EqualValues(t, 100, *userLimit.Limit)
|
||||
|
||||
// Dropped claims are tolerated but never silent: the operator
|
||||
// sees the stable warning, and the log names the license and
|
||||
// the dropped claims for support.
|
||||
if tc.expectClaimsIgnored {
|
||||
require.Contains(t, entitlements.Warnings,
|
||||
codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
|
||||
logs := logBuf.String()
|
||||
require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
|
||||
require.Contains(t, logs, lic.UUID.String())
|
||||
} else {
|
||||
require.NotContains(t, entitlements.Warnings,
|
||||
codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
|
||||
require.Empty(t, logBuf.String())
|
||||
}
|
||||
|
||||
// Every known feature name has a default entry in the map, so
|
||||
// "the license does not grant the feature" surfaces as the
|
||||
// default: no limit, no usage period, not enabled.
|
||||
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
|
||||
if tc.expectFeature == nil {
|
||||
require.Nil(t, feature.Limit, "feature must not be granted")
|
||||
require.Nil(t, feature.UsagePeriod, "feature must not be granted")
|
||||
require.False(t, feature.Enabled)
|
||||
return
|
||||
}
|
||||
require.ErrorIs(t, err, tc.expectedErr)
|
||||
require.NotNil(t, feature.UsagePeriod, "feature must be granted")
|
||||
require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
|
||||
require.Equal(t, tc.expectFeature.Limit, feature.Limit)
|
||||
require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
|
||||
require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
|
||||
})
|
||||
}
|
||||
|
||||
// An invalid license already stored in the database is rejected entirely
|
||||
// and produces an entitlements error.
|
||||
t.Run("EntitlementsError", func(t *testing.T) {
|
||||
t.Run("WarningDeduplicatedAcrossLicenses", 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,
|
||||
},
|
||||
}),
|
||||
// Two licenses with unusable claims must publish the stable warning
|
||||
// once, or the banner would stack identical texts, while the log
|
||||
// names each affected license so the operator can tell which ones
|
||||
// need re-issuing.
|
||||
newLicense := func(id int32) database.License {
|
||||
return database.License{
|
||||
ID: id,
|
||||
UploadedAt: time.Now(),
|
||||
Exp: time.Now().Add(time.Hour),
|
||||
UUID: uuid.New(),
|
||||
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureUserLimit: 100,
|
||||
// A threshold without an allocation is unusable.
|
||||
license.ClaimAgentRuntimeHoursLimitSoft: 80,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
licenses := []database.License{newLicense(1), newLicense(2)}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
entitlements, err := license.LicensesEntitlements(
|
||||
context.Background(), time.Now(), []database.License{lic},
|
||||
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
|
||||
context.Background(), time.Now(), licenses,
|
||||
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
|
||||
Logger: slog.Make(sloghuman.Sink(&logBuf)),
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
warningCount := 0
|
||||
for _, warning := range entitlements.Warnings {
|
||||
if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
|
||||
warningCount++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
|
||||
|
||||
logs := logBuf.String()
|
||||
for _, lic := range licenses {
|
||||
require.Contains(t, logs, lic.UUID.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
|
||||
require.Contains(t, errResp.Message, "Invalid license")
|
||||
})
|
||||
|
||||
t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
|
||||
t.Run("UnusableAgentRuntimeClaims", 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.
|
||||
// A soft limit claim without an allocation claim is unusable, but it
|
||||
// never rejects the whole license: the license stays valid, the
|
||||
// runtime hours feature is simply not granted, and the dropped claim
|
||||
// is surfaced as a warning. See decodeAgentRuntimeHours.
|
||||
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureUserLimit: 100,
|
||||
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")
|
||||
require.NoError(t, err)
|
||||
// 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)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
require.Empty(t, entitlements.Errors)
|
||||
require.Contains(t, entitlements.Warnings,
|
||||
codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
|
||||
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
|
||||
require.Nil(t, feature.Limit)
|
||||
require.Nil(t, feature.UsagePeriod)
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
coderdenttest.AddLicense(t, client,
|
||||
*(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](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())
|
||||
|
||||
Reference in New Issue
Block a user