chore: add managed_agent_limit licensing feature (#18876)

Note that enforcement and checking usage will come in a future PR.

This feature is implemented differently than existing features in a few
ways.

It's highly recommended that reviewers read:
- This document which outlines the methods we could've used for license
enforcement:
https://www.notion.so/coderhq/AI-Agent-License-Enforcement-21ed579be59280c088b9c1dc5e364ee8
- Phase 0 of the actual RFC document:
https://www.notion.so/coderhq/Usage-based-Billing-AI-b-210d579be592800eb257de7eecd2d26d

### Multiple features in the license, a single feature in codersdk

Firstly, the feature is represented as a single feature in the codersdk
world, but is represented with multiple features in the license.

E.g. in the license you may have:

    {
      "features": {
        "managed_agent_limit_soft": 100,
        "managed_agent_limit_hard": 200
      }
    }

But the entitlements endpoint will return a single feature:

    {
      "features": {
        "managed_agent_limit": {
          "limit": 200,
          "soft_limit": 100
        }
      }
    }

This is required because of our rigid parsing that uses a
`map[string]int64` for features in the license. To avoid requiring all
customers to upgrade to use new licenses, the decision was made to just
use two features and merge them into one. Older Coder deployments will
parse this feature (from new licenses) as two separate features, but
it's not a problem because they don't get used anywhere obviously.

The reason we want to differentiate between a "soft" and "hard" limit is
so we can show admins how much of the usage is "included" vs. how much
they can use before they get hard cut-off.

### Usage period features will be compared and trump based on license
issuance time

The second major difference to other features is that "usage period"
features such as `managed_agent_limit` will now be primarily compared by
the `iat` (issued at) claim of the license they come from. This differs
from previous features. The reason this was done was so we could reduce
limits with newer licenses, which the current comparison code does not
allow for.

This effectively means if you have two active licenses:
- `iat`: 2025-07-14, `managed_agent_limit_soft`: 100,
`managed_agent_limit_hard`: 200
- `iat`: 2025-07-15, `managed_agent_limit_soft`: 50,
`managed_agent_limit_hard`: 100

Then the resulting `managed_agent_limit` entitlement will come from the
second license, even though the values are smaller than another valid
license. The existing comparison code would prefer the first license
even though it was issued earlier.

### Usage period features will count usage between the start and end
dates of the license

Existing limit features, like the user limit, just measure the current
usage value of the feature. The active user count is a gauge that goes
up and down, whereas agent usage can only be incremented, so it doesn't
make sense to use a continually incrementing counter forever and ever
for managed agents.

For managed agent limit, we count the usage between `nbf` (not before)
and `exp` (expires at) of the license that the entitlement comes from.
In the example above, we'd use the issued at date and expiry of the
second license as this date range.

This essentially means, when you get a new license, the usage resets to
zero.

The actual usage counting code will be implemented in a follow-up PR.

### Managed agent limit has a default entitlement value

Temporarily (until further notice), we will be providing licenses with
`feature_set` set to `premium` a default limit.
- Soft limit: `800 * user_limit`
- Hard limit: `1000 * user_limit`

"Enterprise" licenses do not get any default limit and are not entitled
to use the feature.

Unlicensed customers (e.g. OSS) will be permitted to use the feature as
much as they want without limits. This will be implemented when the
counting code is implemented in a follow-up PR.

Closes https://github.com/coder/internal/issues/760
This commit is contained in:
Dean Sheather
2025-07-17 20:19:01 +10:00
committed by GitHub
parent a1b87a67c6
commit 183a6ebbdf
10 changed files with 1155 additions and 73 deletions
@@ -176,16 +176,27 @@ type LicenseOptions struct {
// zero value, the `nbf` claim on the license is set to 1 minute in the
// past.
NotBefore time.Time
Features license.Features
// IssuedAt is the time at which the license was issued. If set to the
// zero value, the `iat` claim on the license is set to 1 minute in the
// past.
IssuedAt time.Time
Features license.Features
}
func (opts *LicenseOptions) WithIssuedAt(now time.Time) *LicenseOptions {
opts.IssuedAt = now
return opts
}
func (opts *LicenseOptions) Expired(now time.Time) *LicenseOptions {
opts.NotBefore = now.Add(time.Hour * 24 * -4) // needs to be before the grace period
opts.ExpiresAt = now.Add(time.Hour * 24 * -2)
opts.GraceAt = now.Add(time.Hour * 24 * -3)
return opts
}
func (opts *LicenseOptions) GracePeriod(now time.Time) *LicenseOptions {
opts.NotBefore = now.Add(time.Hour * 24 * -2) // needs to be before the grace period
opts.ExpiresAt = now.Add(time.Hour * 24)
opts.GraceAt = now.Add(time.Hour * 24 * -1)
return opts
@@ -208,6 +219,14 @@ func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureUserLimit, limit)
}
func (opts *LicenseOptions) ManagedAgentLimit(soft int64, hard int64) *LicenseOptions {
// These don't use named or exported feature names, see
// enterprise/coderd/license/license.go.
opts = opts.Feature(codersdk.FeatureName("managed_agent_limit_soft"), soft)
opts = opts.Feature(codersdk.FeatureName("managed_agent_limit_hard"), hard)
return opts
}
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
@@ -236,6 +255,7 @@ func AddLicense(t *testing.T, client *codersdk.Client, options LicenseOptions) c
// GenerateLicense returns a signed JWT using the test key.
func GenerateLicense(t *testing.T, options LicenseOptions) string {
t.Helper()
if options.ExpiresAt.IsZero() {
options.ExpiresAt = time.Now().Add(time.Hour)
}
@@ -246,13 +266,18 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
options.NotBefore = time.Now().Add(-time.Minute)
}
issuedAt := options.IssuedAt
if issuedAt.IsZero() {
issuedAt = time.Now().Add(-time.Minute)
}
c := &license.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
Issuer: "test@testing.test",
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
NotBefore: jwt.NewNumericDate(options.NotBefore),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
IssuedAt: jwt.NewNumericDate(issuedAt),
},
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
AccountType: options.AccountType,
@@ -264,7 +289,12 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
FeatureSet: options.FeatureSet,
Features: options.Features,
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
return GenerateLicenseRaw(t, c)
}
func GenerateLicenseRaw(t *testing.T, claims jwt.Claims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
tok.Header[license.HeaderKeyID] = testKeyID
signedTok, err := tok.SignedString(testPrivateKey)
require.NoError(t, err)
+277 -11
View File
@@ -12,10 +12,66 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
const (
// These features are only included in the license and are not actually
// entitlements after the licenses are processed. These values will be
// merged into the codersdk.FeatureManagedAgentLimit feature.
//
// The reason we need two separate features is because the License v3 format
// uses map[string]int64 for features, so we're unable to use a single value
// with a struct like `{"soft": 100, "hard": 200}`. This is unfortunate and
// we should fix this with a new license format v4 in the future.
//
// These are intentionally not exported as they should not be used outside
// of this package (except tests).
featureManagedAgentLimitHard codersdk.FeatureName = "managed_agent_limit_hard"
featureManagedAgentLimitSoft codersdk.FeatureName = "managed_agent_limit_soft"
)
var (
// Mapping of license feature names to the SDK feature name.
// This is used to map from multiple usage period features into a single SDK
// feature.
featureGrouping = map[codersdk.FeatureName]struct {
// The parent feature.
sdkFeature codersdk.FeatureName
// Whether the value of the license feature is the soft limit or the hard
// limit.
isSoft bool
}{
// Map featureManagedAgentLimitHard and featureManagedAgentLimitSoft to
// codersdk.FeatureManagedAgentLimit.
featureManagedAgentLimitHard: {
sdkFeature: codersdk.FeatureManagedAgentLimit,
isSoft: false,
},
featureManagedAgentLimitSoft: {
sdkFeature: codersdk.FeatureManagedAgentLimit,
isSoft: true,
},
}
// Features that are forbidden to be set in a license. These are the SDK
// features in the usagedBasedFeatureGrouping map.
licenseForbiddenFeatures = func() map[codersdk.FeatureName]struct{} {
features := make(map[codersdk.FeatureName]struct{})
for _, feature := range featureGrouping {
features[feature.sdkFeature] = struct{}{}
}
return features
}()
)
// Entitlements processes licenses to return whether features are enabled or not.
// TODO(@deansheather): This function and the related LicensesEntitlements
// function should be refactored into smaller functions that:
// 1. evaluate entitlements from fetched licenses
// 2. populate current usage values on the entitlements
// 3. generate warnings related to usage
func Entitlements(
ctx context.Context,
db database.Store,
@@ -39,10 +95,15 @@ func Entitlements(
}
// always shows active user count regardless of license
entitlements, err := LicensesEntitlements(now, licenses, enablements, keys, FeatureArguments{
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
ActiveUserCount: activeUserCount,
ReplicaCount: replicaCount,
ExternalAuthCount: externalAuthCount,
ManagedAgentCountFn: func(_ context.Context, _ time.Time, _ time.Time) (int64, error) {
// TODO(@deansheather): replace this with a real implementation in a
// follow up PR.
return 0, nil
},
})
if err != nil {
return entitlements, err
@@ -55,8 +116,14 @@ type FeatureArguments struct {
ActiveUserCount int64
ReplicaCount int
ExternalAuthCount int
// Unfortunately, managed agent count is not a simple count of the current
// state of the world, but a count between two points in time determined by
// the licenses.
ManagedAgentCountFn ManagedAgentCountFn
}
type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error)
// LicensesEntitlements returns the entitlements for licenses. Entitlements are
// merged from all licenses and the highest entitlement is used for each feature.
// Arguments:
@@ -68,6 +135,7 @@ type FeatureArguments struct {
// the 'feat.AlwaysEnable()' return true to disallow disabling.
// featureArguments: Additional arguments required by specific features.
func LicensesEntitlements(
ctx context.Context,
now time.Time,
licenses []database.License,
enablements map[codersdk.FeatureName]bool,
@@ -113,6 +181,17 @@ func LicensesEntitlements(
continue
}
usagePeriodStart := claims.NotBefore.Time // checked not-nil when validating claims
usagePeriodEnd := claims.ExpiresAt.Time // checked not-nil when validating claims
if usagePeriodStart.After(usagePeriodEnd) {
// This shouldn't be possible to be hit. You'd need to have a
// license with `nbf` after `exp`. Because `nbf` must be in the past
// and `exp` must be in the future, this can never happen.
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Invalid license (%s): not_before (%s) is after license_expires (%s)", license.UUID.String(), usagePeriodStart, usagePeriodEnd))
continue
}
// Any valid license should toggle this boolean
entitlements.HasLicense = true
@@ -142,11 +221,24 @@ func LicensesEntitlements(
// Add all features from the feature set defined.
for _, featureName := range claims.FeatureSet.Features() {
if featureName == codersdk.FeatureUserLimit {
// FeatureUserLimit is unique in that it must be specifically defined
// in the license. There is no default meaning if no "limit" is set.
if _, ok := licenseForbiddenFeatures[featureName]; ok {
// Ignore any FeatureSet features that are forbidden to be set
// in a license.
continue
}
if _, ok := featureGrouping[featureName]; ok {
// These features need very special handling due to merging
// multiple feature values into a single SDK feature.
continue
}
if featureName == codersdk.FeatureUserLimit || featureName.UsesUsagePeriod() {
// FeatureUserLimit and usage period features are handled below.
// They don't provide default values as they are always enabled
// and require a limit to be specified in the license to have
// any effect.
continue
}
entitlements.AddFeature(featureName, codersdk.Feature{
Entitlement: entitlement,
Enabled: enablements[featureName] || featureName.AlwaysEnable(),
@@ -155,30 +247,132 @@ func LicensesEntitlements(
})
}
// A map of SDK feature name to the uncommitted usage feature.
uncommittedUsageFeatures := map[codersdk.FeatureName]usageLimit{}
// Features al-la-carte
for featureName, featureValue := range claims.Features {
// Can this be negative?
if featureValue <= 0 {
if _, ok := licenseForbiddenFeatures[featureName]; ok {
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Feature %s is forbidden to be set in a license.", featureName))
continue
}
if featureValue < 0 {
// We currently don't use negative values for features.
continue
}
// Special handling for grouped (e.g. usage period) features.
if grouping, ok := featureGrouping[featureName]; ok {
ul := uncommittedUsageFeatures[grouping.sdkFeature]
if grouping.isSoft {
ul.Soft = &featureValue
} else {
ul.Hard = &featureValue
}
uncommittedUsageFeatures[grouping.sdkFeature] = ul
continue
}
if _, ok := codersdk.FeatureNamesMap[featureName]; !ok {
// Silently ignore any features that we don't know about.
// They're either old features that no longer exist, or new
// features that are not yet supported by the current server
// version.
continue
}
// Handling for non-grouped features.
switch featureName {
case codersdk.FeatureUserLimit:
// User limit has special treatment as our only non-boolean feature.
limit := featureValue
if featureValue <= 0 {
// 0 user count doesn't make sense, so we skip it.
continue
}
entitlements.AddFeature(codersdk.FeatureUserLimit, codersdk.Feature{
Enabled: true,
Entitlement: entitlement,
Limit: &limit,
Limit: &featureValue,
Actual: &featureArguments.ActiveUserCount,
})
// Temporary: If the license doesn't have a managed agent limit,
// we add a default of 800 managed agents per user.
// This only applies to "Premium" licenses.
if claims.FeatureSet == codersdk.FeatureSetPremium {
var (
// We intentionally use a fixed issue time here, before the
// entitlement was added to any new licenses, so any
// licenses with the corresponding features actually set
// trump this default entitlement, even if they are set to a
// smaller value.
issueTime = time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC)
defaultSoftAgentLimit = 800 * featureValue
defaultHardAgentLimit = 1000 * featureValue
)
entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, codersdk.Feature{
Enabled: true,
Entitlement: entitlement,
SoftLimit: &defaultSoftAgentLimit,
Limit: &defaultHardAgentLimit,
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: issueTime,
Start: usagePeriodStart,
End: usagePeriodEnd,
},
})
}
default:
if featureValue <= 0 {
// The feature is disabled.
continue
}
entitlements.Features[featureName] = codersdk.Feature{
Entitlement: entitlement,
Enabled: enablements[featureName] || featureName.AlwaysEnable(),
}
}
}
// Apply uncommitted usage features to the entitlements.
for featureName, ul := range uncommittedUsageFeatures {
if ul.Soft == nil || ul.Hard == nil {
// Invalid license.
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Invalid license (%s): feature %s has missing soft or hard limit values", license.UUID.String(), featureName))
continue
}
if *ul.Hard < *ul.Soft {
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Invalid license (%s): feature %s has a hard limit less than the soft limit", license.UUID.String(), featureName))
continue
}
if *ul.Hard < 0 || *ul.Soft < 0 {
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Invalid license (%s): feature %s has a soft or hard limit less than 0", license.UUID.String(), featureName))
continue
}
feature := codersdk.Feature{
Enabled: true,
Entitlement: entitlement,
SoftLimit: ul.Soft,
Limit: ul.Hard,
// `Actual` will be populated below when warnings are generated.
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
},
}
// If the hard limit is 0, the feature is disabled.
if *ul.Hard <= 0 {
feature.Enabled = false
feature.SoftLimit = ptr.Ref(int64(0))
feature.Limit = ptr.Ref(int64(0))
}
entitlements.AddFeature(featureName, feature)
}
}
// Now the license specific warnings and errors are added to the entitlements.
@@ -223,6 +417,58 @@ func LicensesEntitlements(
}
}
// Managed agent warnings are applied based on usage period. We only
// generate a warning if the license actually has managed agents.
// Note that agents are free when unlicensed.
agentLimit := entitlements.Features[codersdk.FeatureManagedAgentLimit]
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
var (
managedAgentCount int64
err = xerrors.New("dev error: managed agent count function is not set")
)
if featureArguments.ManagedAgentCountFn != nil {
managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
}
if err != nil {
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
} else {
agentLimit.Actual = &managedAgentCount
entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled {
var softLimit int64
if agentLimit.SoftLimit != nil {
softLimit = *agentLimit.SoftLimit
}
var hardLimit int64
if agentLimit.Limit != nil {
hardLimit = *agentLimit.Limit
}
// Issue a warning early:
// 1. If the soft limit and hard limit are equal, at 75% of the hard
// limit.
// 2. If the limit is greater than the soft limit, at 75% of the
// difference between the hard limit and the soft limit.
softWarningThreshold := int64(float64(hardLimit) * 0.75)
if hardLimit > softLimit && softLimit > 0 {
softWarningThreshold = softLimit + int64(float64(hardLimit-softLimit)*0.75)
}
if managedAgentCount >= *agentLimit.Limit {
entitlements.Warnings = append(entitlements.Warnings,
"You have built more workspaces with managed agents than your license allows. Further managed agent builds will be blocked.")
} else if managedAgentCount >= softWarningThreshold {
entitlements.Warnings = append(entitlements.Warnings,
"You are approaching the managed agent limit in your license. Please refer to the Deployment Licenses page for more information.")
}
}
}
}
if entitlements.HasLicense {
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit {
@@ -250,6 +496,10 @@ func LicensesEntitlements(
if featureName == codersdk.FeatureMultipleExternalAuth {
continue
}
// Managed agent limits have it's own warnings based on the number of built agents!
if featureName == codersdk.FeatureManagedAgentLimit {
continue
}
feature := entitlements.Features[featureName]
if !feature.Enabled {
@@ -293,13 +543,21 @@ var (
ErrInvalidVersion = xerrors.New("license must be version 3")
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
ErrMissingLicenseExpires = xerrors.New("license missing license_expires")
ErrMissingExp = xerrors.New("exp claim missing or not parsable")
ErrMissingIssuedAt = xerrors.New("license has invalid or missing iat (issued at) claim")
ErrMissingNotBefore = xerrors.New("license has invalid or missing nbf (not before) claim")
ErrMissingLicenseExpires = xerrors.New("license has invalid or missing license_expires claim")
ErrMissingExp = xerrors.New("license has invalid or missing exp (expires at) claim")
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
)
type Features map[codersdk.FeatureName]int64
type usageLimit struct {
Soft *int64
Hard *int64 // 0 means "disabled"
}
// Claims is the full set of claims in a license.
type Claims struct {
jwt.RegisteredClaims
// LicenseExpires is the end of the legit license term, and the start of the grace period, if
@@ -322,6 +580,8 @@ type Claims struct {
RequireTelemetry bool `json:"require_telemetry,omitempty"`
}
var _ jwt.Claims = &Claims{}
// ParseRaw consumes a license and returns the claims.
func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
tok, err := jwt.Parse(
@@ -365,6 +625,12 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.Version != uint64(CurrentVersion) {
return nil, ErrInvalidVersion
}
if claims.IssuedAt == nil {
return nil, ErrMissingIssuedAt
}
if claims.NotBefore == nil {
return nil, ErrMissingNotBefore
}
if claims.LicenseExpires == nil {
return nil, ErrMissingLicenseExpires
}
+577 -13
View File
@@ -73,6 +73,11 @@ func TestEntitlements(t *testing.T) {
Features: func() license.Features {
f := make(license.Features)
for _, name := range codersdk.FeatureNames {
if name == codersdk.FeatureManagedAgentLimit {
f[codersdk.FeatureName("managed_agent_limit_soft")] = 100
f[codersdk.FeatureName("managed_agent_limit_hard")] = 200
continue
}
f[name] = 1
}
return f
@@ -98,6 +103,7 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureAuditLog: 1,
},
NotBefore: dbtime.Now().Add(-time.Hour * 2),
GraceAt: dbtime.Now().Add(-time.Hour),
ExpiresAt: dbtime.Now().Add(time.Hour),
}),
@@ -243,13 +249,9 @@ func TestEntitlements(t *testing.T) {
require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial)
for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit {
continue
}
if featureName == codersdk.FeatureHighAvailability {
continue
}
if featureName == codersdk.FeatureMultipleExternalAuth {
if featureName == codersdk.FeatureUserLimit || featureName == codersdk.FeatureHighAvailability || featureName == codersdk.FeatureMultipleExternalAuth || featureName == codersdk.FeatureManagedAgentLimit {
// These fields don't generate warnings when not entitled unless
// a limit is breached.
continue
}
niceName := featureName.Humanize()
@@ -384,6 +386,10 @@ func TestEntitlements(t *testing.T) {
if featureName == codersdk.FeatureUserLimit {
continue
}
if featureName == codersdk.FeatureManagedAgentLimit {
// Enterprise licenses don't get any agents by default.
continue
}
if slices.Contains(enterpriseFeatures, featureName) {
require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
@@ -396,12 +402,25 @@ func TestEntitlements(t *testing.T) {
t.Run("Premium", func(t *testing.T) {
t.Parallel()
const userLimit = 1
const expectedAgentSoftLimit = 800 * userLimit
const expectedAgentHardLimit = 1000 * userLimit
db, _ := dbtestutil.NewDB(t)
licenseOptions := coderdenttest.LicenseOptions{
NotBefore: dbtime.Now().Add(-time.Hour * 2),
GraceAt: dbtime.Now().Add(time.Hour * 24),
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 2),
FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{
// Temporary: allows the default value for the
// managed_agent_limit feature to be used.
codersdk.FeatureUserLimit: 1,
},
}
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
Exp: time.Now().Add(time.Hour),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
}),
JWT: coderdenttest.GenerateLicense(t, licenseOptions),
})
require.NoError(t, err)
entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
@@ -415,6 +434,20 @@ func TestEntitlements(t *testing.T) {
if featureName == codersdk.FeatureUserLimit {
continue
}
if featureName == codersdk.FeatureManagedAgentLimit {
agentEntitlement := entitlements.Features[featureName]
require.True(t, agentEntitlement.Enabled)
require.Equal(t, codersdk.EntitlementEntitled, agentEntitlement.Entitlement)
require.EqualValues(t, expectedAgentSoftLimit, *agentEntitlement.SoftLimit)
require.EqualValues(t, expectedAgentHardLimit, *agentEntitlement.Limit)
// This might be shocking, but there's a sound reason for this.
// See license.go for more details.
require.Equal(t, time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC), agentEntitlement.UsagePeriod.IssuedAt)
require.WithinDuration(t, licenseOptions.NotBefore, agentEntitlement.UsagePeriod.Start, time.Second)
require.WithinDuration(t, licenseOptions.ExpiresAt, agentEntitlement.UsagePeriod.End, time.Second)
continue
}
if slices.Contains(enterpriseFeatures, featureName) {
require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
@@ -464,7 +497,7 @@ func TestEntitlements(t *testing.T) {
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit {
if featureName.UsesLimit() {
continue
}
if slices.Contains(enterpriseFeatures, featureName) {
@@ -493,7 +526,7 @@ func TestEntitlements(t *testing.T) {
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit {
if featureName.UsesLimit() {
continue
}
@@ -515,6 +548,7 @@ func TestEntitlements(t *testing.T) {
Exp: dbtime.Now().Add(time.Hour),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
AllFeatures: true,
NotBefore: dbtime.Now().Add(-time.Hour * 2),
GraceAt: dbtime.Now().Add(-time.Hour),
ExpiresAt: dbtime.Now().Add(time.Hour),
}),
@@ -577,6 +611,7 @@ func TestEntitlements(t *testing.T) {
Features: license.Features{
codersdk.FeatureHighAvailability: 1,
},
NotBefore: time.Now().Add(-time.Hour * 2),
GraceAt: time.Now().Add(-time.Hour),
ExpiresAt: time.Now().Add(time.Hour),
}),
@@ -626,6 +661,7 @@ func TestEntitlements(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
NotBefore: time.Now().Add(-time.Hour * 2),
GraceAt: time.Now().Add(-time.Hour),
ExpiresAt: time.Now().Add(time.Hour),
Features: license.Features{
@@ -852,6 +888,164 @@ func TestLicenseEntitlements(t *testing.T) {
entitlements.Features[codersdk.FeatureCustomRoles].Entitlement)
},
},
{
Name: "ManagedAgentLimit",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100).ManagedAgentLimit(100, 200),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
// 175 will generate a warning as it's over 75% of the
// difference between the soft and hard limit.
return 174, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(200), *feature.Limit)
assert.Equal(t, int64(174), *feature.Actual)
},
},
{
Name: "ManagedAgentLimitWithGrace",
Licenses: []*coderdenttest.LicenseOptions{
// Add another license that is not entitled to managed agents to
// suppress warnings for other features.
enterpriseLicense().
UserLimit(100).
WithIssuedAt(time.Now().Add(-time.Hour * 2)),
enterpriseLicense().
UserLimit(100).
ManagedAgentLimit(100, 100).
WithIssuedAt(time.Now().Add(-time.Hour * 1)).
GracePeriod(time.Now()),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
// When the soft and hard limit are equal, the warning is
// triggered at 75% of the hard limit.
return 74, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(100), *feature.Limit)
assert.Equal(t, int64(74), *feature.Actual)
},
},
{
Name: "ManagedAgentLimitWithExpired",
Licenses: []*coderdenttest.LicenseOptions{
// Add another license that is not entitled to managed agents to
// suppress warnings for other features.
enterpriseLicense().
UserLimit(100).
WithIssuedAt(time.Now().Add(-time.Hour * 2)),
enterpriseLicense().
UserLimit(100).
ManagedAgentLimit(100, 200).
WithIssuedAt(time.Now().Add(-time.Hour * 1)).
Expired(time.Now()),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 10, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
assert.False(t, feature.Enabled)
assert.Nil(t, feature.SoftLimit)
assert.Nil(t, feature.Limit)
assert.Nil(t, feature.Actual)
},
},
{
Name: "ManagedAgentLimitWarning/ApproachingLimit/DifferentSoftAndHardLimit",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().
UserLimit(100).
ManagedAgentLimit(100, 200),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 175, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.Len(t, entitlements.Warnings, 1)
assert.Equal(t, "You are approaching the managed agent limit in your license. Please refer to the Deployment Licenses page for more information.", entitlements.Warnings[0])
assertNoErrors(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(200), *feature.Limit)
assert.Equal(t, int64(175), *feature.Actual)
},
},
{
Name: "ManagedAgentLimitWarning/ApproachingLimit/EqualSoftAndHardLimit",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().
UserLimit(100).
ManagedAgentLimit(100, 100),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 75, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.Len(t, entitlements.Warnings, 1)
assert.Equal(t, "You are approaching the managed agent limit in your license. Please refer to the Deployment Licenses page for more information.", entitlements.Warnings[0])
assertNoErrors(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(100), *feature.Limit)
assert.Equal(t, int64(75), *feature.Actual)
},
},
{
Name: "ManagedAgentLimitWarning/BreachedLimit",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().
UserLimit(100).
ManagedAgentLimit(100, 200),
},
Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 200, nil
},
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.Len(t, entitlements.Warnings, 1)
assert.Equal(t, "You have built more workspaces with managed agents than your license allows. Further managed agent builds will be blocked.", entitlements.Warnings[0])
assertNoErrors(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(200), *feature.Limit)
assert.Equal(t, int64(200), *feature.Actual)
},
},
}
for _, tc := range testCases {
@@ -869,7 +1063,14 @@ func TestLicenseEntitlements(t *testing.T) {
})
}
entitlements, err := license.LicensesEntitlements(time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
// Default to 0 managed agent count.
if tc.Arguments.ManagedAgentCountFn == nil {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -881,15 +1082,378 @@ func TestLicenseEntitlements(t *testing.T) {
}
}
func TestUsageLimitFeatures(t *testing.T) {
t.Parallel()
cases := []struct {
sdkFeatureName codersdk.FeatureName
softLimitFeatureName codersdk.FeatureName
hardLimitFeatureName codersdk.FeatureName
}{
{
sdkFeatureName: codersdk.FeatureManagedAgentLimit,
softLimitFeatureName: codersdk.FeatureName("managed_agent_limit_soft"),
hardLimitFeatureName: codersdk.FeatureName("managed_agent_limit_hard"),
},
}
for _, c := range cases {
t.Run(string(c.sdkFeatureName), func(t *testing.T) {
t.Parallel()
// Test for either a missing soft or hard limit feature value.
t.Run("MissingGroupedFeature", func(t *testing.T) {
t.Parallel()
for _, feature := range []codersdk.FeatureName{
c.softLimitFeatureName,
c.hardLimitFeatureName,
} {
t.Run(string(feature), 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{
feature: 100,
},
}),
}
arguments := license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[c.sdkFeatureName]
require.True(t, ok, "feature %s not found", c.sdkFeatureName)
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Len(t, entitlements.Errors, 1)
require.Equal(t, fmt.Sprintf("Invalid license (%v): feature %s has missing soft or hard limit values", lic.UUID, c.sdkFeatureName), entitlements.Errors[0])
})
}
})
t.Run("HardBelowSoft", 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{
c.softLimitFeatureName: 100,
c.hardLimitFeatureName: 50,
},
}),
}
arguments := license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[c.sdkFeatureName]
require.True(t, ok, "feature %s not found", c.sdkFeatureName)
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Len(t, entitlements.Errors, 1)
require.Equal(t, fmt.Sprintf("Invalid license (%v): feature %s has a hard limit less than the soft limit", lic.UUID, c.sdkFeatureName), entitlements.Errors[0])
})
// Ensures that these features are ranked by issued at, not by
// values.
t.Run("IssuedAtRanking", func(t *testing.T) {
t.Parallel()
// Generate 2 real licenses both with managed agent limit
// features. lic2 should trump lic1 even though it has a lower
// limit, because it was issued later.
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{
c.softLimitFeatureName: 100,
c.hardLimitFeatureName: 200,
},
}),
}
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{
c.softLimitFeatureName: 50,
c.hardLimitFeatureName: 100,
},
}),
}
const actualAgents = 10
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
}
// 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, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[c.sdkFeatureName]
require.True(t, ok, "feature %s not found", c.sdkFeatureName)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 50, *feature.SoftLimit)
require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
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)
}
})
})
}
}
func TestManagedAgentLimitDefault(t *testing.T) {
t.Parallel()
// "Enterprise" licenses should not receive a default managed agent limit.
t.Run("Enterprise", 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{
FeatureSet: codersdk.FeatureSetEnterprise,
Features: license.Features{
codersdk.FeatureUserLimit: 100,
},
}),
}
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Nil(t, feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.Actual)
require.Nil(t, feature.UsagePeriod)
})
// "Premium" licenses should receive a default managed agent limit of:
// soft = 800 * user_limit
// hard = 1000 * user_limit
t.Run("Premium", func(t *testing.T) {
t.Parallel()
const userLimit = 100
const softLimit = 800 * userLimit
const hardLimit = 1000 * userLimit
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{
codersdk.FeatureUserLimit: userLimit,
},
}),
}
const actualAgents = 10
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, hardLimit, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, softLimit, *feature.SoftLimit)
require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.NotZero(t, feature.UsagePeriod.IssuedAt)
require.NotZero(t, feature.UsagePeriod.Start)
require.NotZero(t, feature.UsagePeriod.End)
})
// "Premium" licenses with an explicit managed agent limit should not
// receive a default managed agent limit.
t.Run("PremiumExplicitValues", 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{
FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("managed_agent_limit_soft"): 100,
codersdk.FeatureName("managed_agent_limit_hard"): 200,
},
}),
}
const actualAgents = 10
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 200, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 100, *feature.SoftLimit)
require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.NotZero(t, feature.UsagePeriod.IssuedAt)
require.NotZero(t, feature.UsagePeriod.Start)
require.NotZero(t, feature.UsagePeriod.End)
})
// "Premium" licenses with an explicit 0 count should be entitled to 0
// agents and should not receive a default managed agent limit.
t.Run("PremiumExplicitZero", 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{
FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("managed_agent_limit_soft"): 0,
codersdk.FeatureName("managed_agent_limit_hard"): 0,
},
}),
}
const actualAgents = 10
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
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.SoftLimit)
require.EqualValues(t, 0, *feature.SoftLimit)
require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.NotZero(t, feature.UsagePeriod.IssuedAt)
require.NotZero(t, feature.UsagePeriod.Start)
require.NotZero(t, feature.UsagePeriod.End)
})
}
func assertNoErrors(t *testing.T, entitlements codersdk.Entitlements) {
t.Helper()
assert.Empty(t, entitlements.Errors, "no errors")
}
func assertNoWarnings(t *testing.T, entitlements codersdk.Entitlements) {
t.Helper()
assert.Empty(t, entitlements.Warnings, "no warnings")
}
func assertEnterpriseFeatures(t *testing.T, entitlements codersdk.Entitlements) {
t.Helper()
for _, expected := range codersdk.FeatureSetEnterprise.Features() {
f := entitlements.Features[expected]
assert.Equalf(t, codersdk.EntitlementEntitled, f.Entitlement, "%s entitled", expected)