From 183a6ebbdf31963730f54d15c3187fadea7f484c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 17 Jul 2025 20:19:01 +1000 Subject: [PATCH] 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 --- coderd/apidoc/docs.go | 29 + coderd/apidoc/swagger.json | 29 + codersdk/deployment.go | 157 ++++- codersdk/deployment_test.go | 14 +- docs/reference/api/enterprise.md | 16 +- docs/reference/api/schemas.md | 58 +- .../coderd/coderdenttest/coderdenttest.go | 36 +- enterprise/coderd/license/license.go | 288 ++++++++- enterprise/coderd/license/license_test.go | 590 +++++++++++++++++- site/src/api/typesGenerated.ts | 11 + 10 files changed, 1155 insertions(+), 73 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7a3bd8a0d9..3618ed8610 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12985,6 +12985,18 @@ const docTemplate = `{ }, "limit": { "type": "integer" + }, + "soft_limit": { + "description": "SoftLimit is the soft limit of the feature, and is only used for showing\nincluded limits in the dashboard. No license validation or warnings are\ngenerated from this value.", + "type": "integer" + }, + "usage_period": { + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit", + "allOf": [ + { + "$ref": "#/definitions/codersdk.UsagePeriod" + } + ] } } }, @@ -17242,6 +17254,23 @@ const docTemplate = `{ "UsageAppNameSSH" ] }, + "codersdk.UsagePeriod": { + "type": "object", + "properties": { + "end": { + "type": "string", + "format": "date-time" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "start": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.User": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ded07f40f1..11d403e75a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11654,6 +11654,18 @@ }, "limit": { "type": "integer" + }, + "soft_limit": { + "description": "SoftLimit is the soft limit of the feature, and is only used for showing\nincluded limits in the dashboard. No license validation or warnings are\ngenerated from this value.", + "type": "integer" + }, + "usage_period": { + "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit", + "allOf": [ + { + "$ref": "#/definitions/codersdk.UsagePeriod" + } + ] } } }, @@ -15728,6 +15740,23 @@ "UsageAppNameSSH" ] }, + "codersdk.UsagePeriod": { + "type": "object", + "properties": { + "end": { + "type": "string", + "format": "date-time" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "start": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.User": { "type": "object", "required": ["created_at", "email", "id", "username"], diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 61c3c805a2..3844523063 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -85,31 +85,47 @@ const ( FeatureCustomRoles FeatureName = "custom_roles" FeatureMultipleOrganizations FeatureName = "multiple_organizations" FeatureWorkspacePrebuilds FeatureName = "workspace_prebuilds" + // ManagedAgentLimit is a usage period feature, so the value in the license + // contains both a soft and hard limit. Refer to + // enterprise/coderd/license/license.go for the license format. + FeatureManagedAgentLimit FeatureName = "managed_agent_limit" ) -// FeatureNames must be kept in-sync with the Feature enum above. -var FeatureNames = []FeatureName{ - FeatureUserLimit, - FeatureAuditLog, - FeatureConnectionLog, - FeatureBrowserOnly, - FeatureSCIM, - FeatureTemplateRBAC, - FeatureHighAvailability, - FeatureMultipleExternalAuth, - FeatureExternalProvisionerDaemons, - FeatureAppearance, - FeatureAdvancedTemplateScheduling, - FeatureWorkspaceProxy, - FeatureUserRoleManagement, - FeatureExternalTokenEncryption, - FeatureWorkspaceBatchActions, - FeatureAccessControl, - FeatureControlSharedPorts, - FeatureCustomRoles, - FeatureMultipleOrganizations, - FeatureWorkspacePrebuilds, -} +var ( + // FeatureNames must be kept in-sync with the Feature enum above. + FeatureNames = []FeatureName{ + FeatureUserLimit, + FeatureAuditLog, + FeatureConnectionLog, + FeatureBrowserOnly, + FeatureSCIM, + FeatureTemplateRBAC, + FeatureHighAvailability, + FeatureMultipleExternalAuth, + FeatureExternalProvisionerDaemons, + FeatureAppearance, + FeatureAdvancedTemplateScheduling, + FeatureWorkspaceProxy, + FeatureUserRoleManagement, + FeatureExternalTokenEncryption, + FeatureWorkspaceBatchActions, + FeatureAccessControl, + FeatureControlSharedPorts, + FeatureCustomRoles, + FeatureMultipleOrganizations, + FeatureWorkspacePrebuilds, + FeatureManagedAgentLimit, + } + + // FeatureNamesMap is a map of all feature names for quick lookups. + FeatureNamesMap = func() map[FeatureName]struct{} { + featureNamesMap := make(map[FeatureName]struct{}, len(FeatureNames)) + for _, featureName := range FeatureNames { + featureNamesMap[featureName] = struct{}{} + } + return featureNamesMap + }() +) // Humanize returns the feature name in a human-readable format. func (n FeatureName) Humanize() string { @@ -153,6 +169,22 @@ func (n FeatureName) Enterprise() bool { } } +// UsesLimit returns true if the feature uses a limit, and therefore should not +// be included in any feature sets (as they are not boolean features). +func (n FeatureName) UsesLimit() bool { + return map[FeatureName]bool{ + FeatureUserLimit: true, + FeatureManagedAgentLimit: true, + }[n] +} + +// UsesUsagePeriod returns true if the feature uses period-based usage limits. +func (n FeatureName) UsesUsagePeriod() bool { + return map[FeatureName]bool{ + FeatureManagedAgentLimit: true, + }[n] +} + // FeatureSet represents a grouping of features. Rather than manually // assigning features al-la-carte when making a license, a set can be specified. // Sets are dynamic in the sense a feature can be added to a set, granting the @@ -177,13 +209,17 @@ func (set FeatureSet) Features() []FeatureName { copy(enterpriseFeatures, FeatureNames) // Remove the selection enterpriseFeatures = slices.DeleteFunc(enterpriseFeatures, func(f FeatureName) bool { - return !f.Enterprise() + return !f.Enterprise() || f.UsesLimit() }) return enterpriseFeatures case FeatureSetPremium: premiumFeatures := make([]FeatureName, len(FeatureNames)) copy(premiumFeatures, FeatureNames) + // Remove the selection + premiumFeatures = slices.DeleteFunc(premiumFeatures, func(f FeatureName) bool { + return f.UsesLimit() + }) // FeatureSetPremium is just all features. return premiumFeatures } @@ -196,6 +232,29 @@ type Feature struct { Enabled bool `json:"enabled"` Limit *int64 `json:"limit,omitempty"` Actual *int64 `json:"actual,omitempty"` + + // Below is only for features that use usage periods. + + // SoftLimit is the soft limit of the feature, and is only used for showing + // included limits in the dashboard. No license validation or warnings are + // generated from this value. + SoftLimit *int64 `json:"soft_limit,omitempty"` + // UsagePeriod denotes that the usage is a counter that accumulates over + // this period (and most likely resets with the issuance of the next + // license). + // + // These dates are determined from the license that this entitlement comes + // from, see enterprise/coderd/license/license.go. + // + // Only certain features set these fields: + // - FeatureManagedAgentLimit + UsagePeriod *UsagePeriod `json:"usage_period,omitempty"` +} + +type UsagePeriod struct { + IssuedAt time.Time `json:"issued_at" format:"date-time"` + Start time.Time `json:"start" format:"date-time"` + End time.Time `json:"end" format:"date-time"` } // Compare compares two features and returns an integer representing @@ -204,13 +263,30 @@ type Feature struct { // than the second feature. It is assumed the features are for the same FeatureName. // // A feature is considered greater than another feature if: -// 1. Graceful & capable > Entitled & not capable -// 2. The entitlement is greater -// 3. The limit is greater -// 4. Enabled is greater than disabled -// 5. The actual is greater +// 1. The usage period has a greater issued at date (note: only certain features use usage periods) +// 2. The usage period has a greater end date (note: only certain features use usage periods) +// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values) +// 4. The entitlement is greater +// 5. The limit is greater +// 6. Enabled is greater than disabled +// 7. The actual is greater func (f Feature) Compare(b Feature) int { - if !f.Capable() || !b.Capable() { + // For features with usage period constraints only, check the issued at and + // end dates. + bothHaveUsagePeriod := f.UsagePeriod != nil && b.UsagePeriod != nil + if bothHaveUsagePeriod { + issuedAtCmp := f.UsagePeriod.IssuedAt.Compare(b.UsagePeriod.IssuedAt) + if issuedAtCmp != 0 { + return issuedAtCmp + } + endCmp := f.UsagePeriod.End.Compare(b.UsagePeriod.End) + if endCmp != 0 { + return endCmp + } + } + + // Only perform capability comparisons if both features have actual values. + if f.Actual != nil && b.Actual != nil && (!f.Capable() || !b.Capable()) { // If either is incapable, then it is possible a grace period // feature can be "greater" than an entitled. // If either is "NotEntitled" then we can defer to a strict entitlement @@ -225,7 +301,9 @@ func (f Feature) Compare(b Feature) int { } } - // Strict entitlement check. Higher is better + // Strict entitlement check. Higher is better. We don't apply this check for + // usage period features as we always want the issued at date to be the main + // decision maker. entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight() if entitlementDifference != 0 { return entitlementDifference @@ -295,6 +373,13 @@ type Entitlements struct { // the set of features granted by the entitlements. If it does not, it will // be ignored and the existing feature with the same name will remain. // +// Features that abide by usage period constraints should have the following +// fields set or they will be ignored. Other features will have these fields +// cleared. +// - UsagePeriodIssuedAt +// - UsagePeriodStart +// - UsagePeriodEnd +// // All features should be added as atomic items, and not merged in any way. // Merging entitlements could lead to unexpected behavior, like a larger user // limit in grace period merging with a smaller one in an "entitled" state. This @@ -306,6 +391,16 @@ func (e *Entitlements) AddFeature(name FeatureName, add Feature) { return } + // If we're trying to add a feature that uses a usage period and it's not + // set, then we should not add it. + if name.UsesUsagePeriod() { + if add.UsagePeriod == nil || add.UsagePeriod.IssuedAt.IsZero() || add.UsagePeriod.Start.IsZero() || add.UsagePeriod.End.IsZero() { + return + } + } else { + add.UsagePeriod = nil + } + // Compare the features, keep the one that is "better" comparison := add.Compare(existing) if comparison > 0 { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index c18e5775f7..fcddab0a53 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -554,10 +554,16 @@ func TestPremiumSuperSet(t *testing.T) { // Premium ⊃ Enterprise require.Subset(t, premium.Features(), enterprise.Features(), "premium should be a superset of enterprise. If this fails, update the premium feature set to include all enterprise features.") - // Premium = All Features - // This is currently true. If this assertion changes, update this test - // to reflect the change in feature sets. - require.ElementsMatch(t, premium.Features(), codersdk.FeatureNames, "premium should contain all features") + // Premium = All Features EXCEPT usage limit features + expectedPremiumFeatures := []codersdk.FeatureName{} + for _, feature := range codersdk.FeatureNames { + if feature.UsesLimit() { + continue + } + expectedPremiumFeatures = append(expectedPremiumFeatures, feature) + } + require.NotEmpty(t, expectedPremiumFeatures, "expectedPremiumFeatures should not be empty") + require.ElementsMatch(t, premium.Features(), expectedPremiumFeatures, "premium should contain all features except usage limit features") // This check exists because if you misuse the slices.Delete, you can end up // with zero'd values. diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 38e22bd85e..c9b65a97d2 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -326,13 +326,25 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ "actual": 0, "enabled": true, "entitlement": "entitled", - "limit": 0 + "limit": 0, + "soft_limit": 0, + "usage_period": { + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" + } }, "property2": { "actual": 0, "enabled": true, "entitlement": "entitled", - "limit": 0 + "limit": 0, + "soft_limit": 0, + "usage_period": { + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" + } } }, "has_license": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 053a738413..2abcb2b320 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3210,13 +3210,25 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "actual": 0, "enabled": true, "entitlement": "entitled", - "limit": 0 + "limit": 0, + "soft_limit": 0, + "usage_period": { + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" + } }, "property2": { "actual": 0, "enabled": true, "entitlement": "entitled", - "limit": 0 + "limit": 0, + "soft_limit": 0, + "usage_period": { + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" + } } }, "has_license": true, @@ -3452,18 +3464,28 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "actual": 0, "enabled": true, "entitlement": "entitled", - "limit": 0 + "limit": 0, + "soft_limit": 0, + "usage_period": { + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" + } } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|----------------------------------------------|----------|--------------|-------------| -| `actual` | integer | false | | | -| `enabled` | boolean | false | | | -| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | -| `limit` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `actual` | integer | false | | | +| `enabled` | boolean | false | | | +| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | +| `limit` | integer | false | | | +| `soft_limit` | integer | false | | Soft limit is the soft limit of the feature, and is only used for showing included limits in the dashboard. No license validation or warnings are generated from this value. | +|`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). +These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. +Only certain features set these fields: - FeatureManagedAgentLimit| ## codersdk.FriendlyDiagnostic @@ -8200,6 +8222,24 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `reconnecting-pty` | | `ssh` | +## codersdk.UsagePeriod + +```json +{ + "end": "2019-08-24T14:15:22Z", + "issued_at": "2019-08-24T14:15:22Z", + "start": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `end` | string | false | | | +| `issued_at` | string | false | | | +| `start` | string | false | | | + ## codersdk.User ```json diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 54dcb9c582..47d248335d 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -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) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 2490707c75..9371c10c13 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -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 } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 5ec28ffa9c..fac1d2b44b 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 47a2984d37..b4df565482 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -980,6 +980,8 @@ export interface Feature { readonly enabled: boolean; readonly limit?: number; readonly actual?: number; + readonly soft_limit?: number; + readonly usage_period?: UsagePeriod; } // From codersdk/deployment.go @@ -995,6 +997,7 @@ export type FeatureName = | "external_provisioner_daemons" | "external_token_encryption" | "high_availability" + | "managed_agent_limit" | "multiple_external_auth" | "multiple_organizations" | "scim" @@ -1017,6 +1020,7 @@ export const FeatureNames: FeatureName[] = [ "external_provisioner_daemons", "external_token_encryption", "high_availability", + "managed_agent_limit", "multiple_external_auth", "multiple_organizations", "scim", @@ -3238,6 +3242,13 @@ export const UsageAppNames: UsageAppName[] = [ "vscode", ]; +// From codersdk/deployment.go +export interface UsagePeriod { + readonly issued_at: string; + readonly start: string; + readonly end: string; +} + // From codersdk/users.go export interface User extends ReducedUser { readonly organization_ids: readonly string[];