diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7a3bd8a0d913a..3618ed8610f5a 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 ded07f40f1163..11d403e75aad7 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 61c3c805a29a9..3844523063db7 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 c18e5775f7ae9..fcddab0a53788 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 38e22bd85e277..c9b65a97d2f03 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 053a738413060..2abcb2b3204f2 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 54dcb9c582628..47d248335dda1 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 2490707c751a1..9371c10c138d8 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 5ec28ffa9c294..fac1d2b44bb63 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 47a2984d374a2..b4df5654824bc 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[];