From ff052413bf050983a57accd8783a87e747e69c4d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 15 Jul 2025 09:53:29 +0000 Subject: [PATCH 1/4] chore: add managed_agent_limit licensing feature Note that enforcement and checking usage will come in a future PR. This feature is implemented differently than existing features in a few ways. 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. 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. 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. 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. --- codersdk/deployment.go | 160 +++++-- codersdk/deployment_test.go | 14 +- .../coderd/coderdenttest/coderdenttest.go | 23 +- enterprise/coderd/license/license.go | 268 ++++++++++- enterprise/coderd/license/license_test.go | 423 +++++++++++++++++- 5 files changed, 826 insertions(+), 62 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 61c3c805a29a9..ef3a6016d6936 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{}) + 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,15 +209,22 @@ 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 + case FeatureSetNone: + default: + panic("unexpected codersdk.FeatureSet") } // By default, return an empty set. return []FeatureName{} @@ -196,6 +235,25 @@ 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"` + // 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 + UsagePeriodIssuedAt *time.Time `json:"usage_period_issued_at,omitempty" format:"date-time"` + UsagePeriodStart *time.Time `json:"usage_period_start,omitempty" format:"date-time"` + UsagePeriodEnd *time.Time `json:"usage_period_end,omitempty" format:"date-time"` } // Compare compares two features and returns an integer representing @@ -205,12 +263,15 @@ type Feature struct { // // 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 +// 2. The usage period has a greater end date (note: only certain features use usage periods) +// 3. The usage period has a greater issued at date (note: only certain features use usage periods) +// 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() { + // 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,12 +286,29 @@ 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. + bothHaveIssuedAt := f.UsagePeriodIssuedAt != nil && b.UsagePeriodIssuedAt != nil entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight() - if entitlementDifference != 0 { + if !bothHaveIssuedAt && entitlementDifference != 0 { return entitlementDifference } + // For features with usage period constraints only: + if bothHaveIssuedAt { + cmp := f.UsagePeriodIssuedAt.Compare(*b.UsagePeriodIssuedAt) + if cmp != 0 { + return cmp + } + } + if f.UsagePeriodEnd != nil && b.UsagePeriodEnd != nil { + cmp := f.UsagePeriodEnd.Compare(*b.UsagePeriodEnd) + if cmp != 0 { + return cmp + } + } + // If the entitlement is the same, then we can compare the limits. if f.Limit == nil && b.Limit != nil { return -1 @@ -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,19 @@ 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.UsagePeriodIssuedAt == nil || add.UsagePeriodStart == nil || add.UsagePeriodEnd == nil { + return + } + } else { + // Ensure the usage period values are not set. + add.UsagePeriodIssuedAt = nil + add.UsagePeriodStart = nil + add.UsagePeriodEnd = 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/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 54dcb9c582628..d94e088daa98e 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -176,16 +176,22 @@ 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) 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 @@ -236,6 +242,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 +253,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 +276,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..de462027febe6 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -12,10 +12,55 @@ 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 ( + 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 +84,14 @@ 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(ctx context.Context, from time.Time, to time.Time) (int64, error) { + // TODO: this + return 0, nil + }, }) if err != nil { return entitlements, err @@ -55,6 +104,10 @@ 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 func(ctx context.Context, from time.Time, to time.Time) (int64, error) } // LicensesEntitlements returns the entitlements for licenses. Entitlements are @@ -68,6 +121,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 +167,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 +207,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 +233,129 @@ 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, + UsagePeriodIssuedAt: &issueTime, + UsagePeriodStart: &usagePeriodStart, + UsagePeriodEnd: &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 value will be populated below when warnings are + // generated. + UsagePeriodIssuedAt: &claims.IssuedAt.Time, + UsagePeriodStart: &usagePeriodStart, + UsagePeriodEnd: &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 +400,55 @@ 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.UsagePeriodStart != nil && agentLimit.UsagePeriodEnd != 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.UsagePeriodStart, *agentLimit.UsagePeriodEnd) + } + 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) + + 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 +476,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 +523,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 +560,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 +605,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..4e49d9a1d7b9d 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.UsagePeriodIssuedAt) + require.WithinDuration(t, licenseOptions.NotBefore, *agentEntitlement.UsagePeriodStart, time.Second) + require.WithinDuration(t, licenseOptions.ExpiresAt, *agentEntitlement.UsagePeriodEnd, 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 == codersdk.FeatureUserLimit || featureName == codersdk.FeatureManagedAgentLimit { 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 == codersdk.FeatureUserLimit || featureName == codersdk.FeatureManagedAgentLimit { 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{ @@ -869,7 +905,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 +924,369 @@ 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, + }, + }), + } + + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + 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, + }, + }), + } + + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + 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.UsagePeriodIssuedAt) + require.WithinDuration(t, lic2Iat, *feature.UsagePeriodIssuedAt, 2*time.Second) + require.NotNil(t, feature.UsagePeriodStart) + require.WithinDuration(t, lic2Nbf, *feature.UsagePeriodStart, 2*time.Second) + require.NotNil(t, feature.UsagePeriodEnd) + require.WithinDuration(t, lic2Exp, *feature.UsagePeriodEnd, 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.UsagePeriodIssuedAt) + require.Nil(t, feature.UsagePeriodStart) + require.Nil(t, feature.UsagePeriodEnd) + }) + + // "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.UsagePeriodIssuedAt) + require.NotNil(t, feature.UsagePeriodStart) + require.NotNil(t, feature.UsagePeriodEnd) + }) + + // "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.UsagePeriodIssuedAt) + require.NotNil(t, feature.UsagePeriodStart) + require.NotNil(t, feature.UsagePeriodEnd) + }) + + // "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.UsagePeriodIssuedAt) + require.NotNil(t, feature.UsagePeriodStart) + require.NotNil(t, feature.UsagePeriodEnd) + }) +} + 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) From e50d4f4ac9d2a0f1c5ac868d28733c0693b9bf70 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 15 Jul 2025 10:55:24 +0000 Subject: [PATCH 2/4] chore: additional tests --- .../coderd/coderdenttest/coderdenttest.go | 13 ++ enterprise/coderd/license/license.go | 4 +- enterprise/coderd/license/license_test.go | 158 ++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index d94e088daa98e..47d248335dda1 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -183,6 +183,11 @@ type LicenseOptions struct { 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) @@ -214,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{} diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index de462027febe6..3c525033f0a64 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -439,10 +439,10 @@ func LicensesEntitlements( if hardLimit > softLimit && softLimit > 0 { softWarningThreshold = softLimit + int64(float64(hardLimit-softLimit)*0.75) } - if managedAgentCount > *agentLimit.Limit { + 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 { + } 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.") } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 4e49d9a1d7b9d..9109b3a0355e3 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -888,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 { From c7d0a84d31b36d103bc3366f68c7f72067a0c8ad Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 17 Jul 2025 04:00:50 +0000 Subject: [PATCH 3/4] PR comments and additional changes --- codersdk/deployment.go | 59 ++++++------- enterprise/coderd/license/license.go | 100 +++++++++++++--------- enterprise/coderd/license/license_test.go | 59 +++++++------ 3 files changed, 122 insertions(+), 96 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ef3a6016d6936..3844523063db7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -119,7 +119,7 @@ var ( // FeatureNamesMap is a map of all feature names for quick lookups. FeatureNamesMap = func() map[FeatureName]struct{} { - featureNamesMap := make(map[FeatureName]struct{}) + featureNamesMap := make(map[FeatureName]struct{}, len(FeatureNames)) for _, featureName := range FeatureNames { featureNamesMap[featureName] = struct{}{} } @@ -222,9 +222,6 @@ func (set FeatureSet) Features() []FeatureName { }) // FeatureSetPremium is just all features. return premiumFeatures - case FeatureSetNone: - default: - panic("unexpected codersdk.FeatureSet") } // By default, return an empty set. return []FeatureName{} @@ -242,7 +239,7 @@ type Feature struct { // included limits in the dashboard. No license validation or warnings are // generated from this value. SoftLimit *int64 `json:"soft_limit,omitempty"` - // Usage period denotes that the usage is a counter that accumulates over + // UsagePeriod denotes that the usage is a counter that accumulates over // this period (and most likely resets with the issuance of the next // license). // @@ -251,9 +248,13 @@ type Feature struct { // // Only certain features set these fields: // - FeatureManagedAgentLimit - UsagePeriodIssuedAt *time.Time `json:"usage_period_issued_at,omitempty" format:"date-time"` - UsagePeriodStart *time.Time `json:"usage_period_start,omitempty" format:"date-time"` - UsagePeriodEnd *time.Time `json:"usage_period_end,omitempty" format:"date-time"` + 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 @@ -262,14 +263,28 @@ 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 +// 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. The usage period has a greater issued at 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 { + // 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 @@ -289,26 +304,11 @@ func (f Feature) Compare(b Feature) int { // 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. - bothHaveIssuedAt := f.UsagePeriodIssuedAt != nil && b.UsagePeriodIssuedAt != nil entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight() - if !bothHaveIssuedAt && entitlementDifference != 0 { + if entitlementDifference != 0 { return entitlementDifference } - // For features with usage period constraints only: - if bothHaveIssuedAt { - cmp := f.UsagePeriodIssuedAt.Compare(*b.UsagePeriodIssuedAt) - if cmp != 0 { - return cmp - } - } - if f.UsagePeriodEnd != nil && b.UsagePeriodEnd != nil { - cmp := f.UsagePeriodEnd.Compare(*b.UsagePeriodEnd) - if cmp != 0 { - return cmp - } - } - // If the entitlement is the same, then we can compare the limits. if f.Limit == nil && b.Limit != nil { return -1 @@ -394,14 +394,11 @@ func (e *Entitlements) AddFeature(name FeatureName, add Feature) { // 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.UsagePeriodIssuedAt == nil || add.UsagePeriodStart == nil || add.UsagePeriodEnd == nil { + if add.UsagePeriod == nil || add.UsagePeriod.IssuedAt.IsZero() || add.UsagePeriod.Start.IsZero() || add.UsagePeriod.End.IsZero() { return } } else { - // Ensure the usage period values are not set. - add.UsagePeriodIssuedAt = nil - add.UsagePeriodStart = nil - add.UsagePeriodEnd = nil + add.UsagePeriod = nil } // Compare the features, keep the one that is "better" diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 3c525033f0a64..9371c10c138d8 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -17,6 +17,17 @@ import ( ) 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" ) @@ -88,8 +99,9 @@ func Entitlements( ActiveUserCount: activeUserCount, ReplicaCount: replicaCount, ExternalAuthCount: externalAuthCount, - ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { - // TODO: this + 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 }, }) @@ -107,9 +119,11 @@ type FeatureArguments struct { // 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 func(ctx context.Context, from time.Time, to time.Time) (int64, error) + 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: @@ -297,13 +311,15 @@ func LicensesEntitlements( defaultHardAgentLimit = 1000 * featureValue ) entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, codersdk.Feature{ - Enabled: true, - Entitlement: entitlement, - SoftLimit: &defaultSoftAgentLimit, - Limit: &defaultHardAgentLimit, - UsagePeriodIssuedAt: &issueTime, - UsagePeriodStart: &usagePeriodStart, - UsagePeriodEnd: &usagePeriodEnd, + Enabled: true, + Entitlement: entitlement, + SoftLimit: &defaultSoftAgentLimit, + Limit: &defaultHardAgentLimit, + UsagePeriod: &codersdk.UsagePeriod{ + IssuedAt: issueTime, + Start: usagePeriodStart, + End: usagePeriodEnd, + }, }) } default: @@ -342,11 +358,12 @@ func LicensesEntitlements( Entitlement: entitlement, SoftLimit: ul.Soft, Limit: ul.Hard, - // Actual value will be populated below when warnings are - // generated. - UsagePeriodIssuedAt: &claims.IssuedAt.Time, - UsagePeriodStart: &usagePeriodStart, - UsagePeriodEnd: &usagePeriodEnd, + // `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 { @@ -404,7 +421,7 @@ func LicensesEntitlements( // 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.UsagePeriodStart != nil && agentLimit.UsagePeriodEnd != nil { + if entitlements.HasLicense && agentLimit.UsagePeriod != nil { // Calculate the amount of agents between the usage period start and // end. var ( @@ -412,7 +429,7 @@ func LicensesEntitlements( err = xerrors.New("dev error: managed agent count function is not set") ) if featureArguments.ManagedAgentCountFn != nil { - managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, *agentLimit.UsagePeriodStart, *agentLimit.UsagePeriodEnd) + managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End) } if err != nil { entitlements.Errors = append(entitlements.Errors, @@ -421,30 +438,33 @@ func LicensesEntitlements( agentLimit.Actual = &managedAgentCount entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit) - var softLimit int64 - if agentLimit.SoftLimit != nil { - softLimit = *agentLimit.SoftLimit - } - var hardLimit int64 - if agentLimit.Limit != nil { - hardLimit = *agentLimit.Limit - } + // 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.") + // 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.") + } } } } diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 9109b3a0355e3..fac1d2b44bb63 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -442,9 +442,9 @@ func TestEntitlements(t *testing.T) { 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.UsagePeriodIssuedAt) - require.WithinDuration(t, licenseOptions.NotBefore, *agentEntitlement.UsagePeriodStart, time.Second) - require.WithinDuration(t, licenseOptions.ExpiresAt, *agentEntitlement.UsagePeriodEnd, time.Second) + 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 } @@ -497,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 || featureName == codersdk.FeatureManagedAgentLimit { + if featureName.UsesLimit() { continue } if slices.Contains(enterpriseFeatures, featureName) { @@ -526,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 || featureName == codersdk.FeatureManagedAgentLimit { + if featureName.UsesLimit() { continue } @@ -1124,7 +1124,12 @@ func TestUsageLimitFeatures(t *testing.T) { }), } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + 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] @@ -1153,7 +1158,12 @@ func TestUsageLimitFeatures(t *testing.T) { }), } - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{}) + 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] @@ -1234,12 +1244,10 @@ func TestUsageLimitFeatures(t *testing.T) { require.EqualValues(t, 50, *feature.SoftLimit) require.NotNil(t, feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual) - require.NotNil(t, feature.UsagePeriodIssuedAt) - require.WithinDuration(t, lic2Iat, *feature.UsagePeriodIssuedAt, 2*time.Second) - require.NotNil(t, feature.UsagePeriodStart) - require.WithinDuration(t, lic2Nbf, *feature.UsagePeriodStart, 2*time.Second) - require.NotNil(t, feature.UsagePeriodEnd) - require.WithinDuration(t, lic2Exp, *feature.UsagePeriodEnd, 2*time.Second) + 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) } }) }) @@ -1283,9 +1291,7 @@ func TestManagedAgentLimitDefault(t *testing.T) { require.Nil(t, feature.Limit) require.Nil(t, feature.SoftLimit) require.Nil(t, feature.Actual) - require.Nil(t, feature.UsagePeriodIssuedAt) - require.Nil(t, feature.UsagePeriodStart) - require.Nil(t, feature.UsagePeriodEnd) + require.Nil(t, feature.UsagePeriod) }) // "Premium" licenses should receive a default managed agent limit of: @@ -1332,9 +1338,10 @@ func TestManagedAgentLimitDefault(t *testing.T) { require.EqualValues(t, softLimit, *feature.SoftLimit) require.NotNil(t, feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual) - require.NotNil(t, feature.UsagePeriodIssuedAt) - require.NotNil(t, feature.UsagePeriodStart) - require.NotNil(t, feature.UsagePeriodEnd) + 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 @@ -1379,9 +1386,10 @@ func TestManagedAgentLimitDefault(t *testing.T) { require.EqualValues(t, 100, *feature.SoftLimit) require.NotNil(t, feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual) - require.NotNil(t, feature.UsagePeriodIssuedAt) - require.NotNil(t, feature.UsagePeriodStart) - require.NotNil(t, feature.UsagePeriodEnd) + 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 @@ -1427,9 +1435,10 @@ func TestManagedAgentLimitDefault(t *testing.T) { require.EqualValues(t, 0, *feature.SoftLimit) require.NotNil(t, feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual) - require.NotNil(t, feature.UsagePeriodIssuedAt) - require.NotNil(t, feature.UsagePeriodStart) - require.NotNil(t, feature.UsagePeriodEnd) + require.NotNil(t, feature.UsagePeriod) + require.NotZero(t, feature.UsagePeriod.IssuedAt) + require.NotZero(t, feature.UsagePeriod.Start) + require.NotZero(t, feature.UsagePeriod.End) }) } From 38d5baec6334003ba608132ae00ab69ef0881871 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 17 Jul 2025 04:33:04 +0000 Subject: [PATCH 4/4] gen --- coderd/apidoc/docs.go | 29 ++++++++++++++++ coderd/apidoc/swagger.json | 29 ++++++++++++++++ docs/reference/api/enterprise.md | 16 +++++++-- docs/reference/api/schemas.md | 58 +++++++++++++++++++++++++++----- site/src/api/typesGenerated.ts | 11 ++++++ 5 files changed, 132 insertions(+), 11 deletions(-) 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/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/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[];