feat: convert soft_limit to limit (#22048)

In relation to
[`internal#1281`](https://github.com/coder/internal/issues/1281)

Remove the `soft_limit` field from the `Feature` type and simplify
license limit handling. This change:

- Removes the `soft_limit` field from the API and SDK
- Uses the soft limit value as the single `limit` value in the UI and
API
- Simplifies warning logic to only show warnings when the limit is
exceeded
- Updates tests to reflect the new behavior
- Updates the UI to use the single limit value for display
This commit is contained in:
Jake Howell
2026-02-20 16:09:12 +11:00
committed by GitHub
parent 203899718f
commit 051ed34580
13 changed files with 301 additions and 504 deletions
-4
View File
@@ -15368,10 +15368,6 @@ const docTemplate = `{
"limit": { "limit": {
"type": "integer" "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": { "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", "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": [ "allOf": [
-4
View File
@@ -13895,10 +13895,6 @@
"limit": { "limit": {
"type": "integer" "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": { "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", "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": [ "allOf": [
-4
View File
@@ -372,10 +372,6 @@ type Feature struct {
// Below is only for features that use usage periods. // 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 // UsagePeriod denotes that the usage is a counter that accumulates over
// this period (and most likely resets with the issuance of the next // this period (and most likely resets with the issuance of the next
// license). // license).
-2
View File
@@ -329,7 +329,6 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
"enabled": true, "enabled": true,
"entitlement": "entitled", "entitlement": "entitled",
"limit": 0, "limit": 0,
"soft_limit": 0,
"usage_period": { "usage_period": {
"end": "2019-08-24T14:15:22Z", "end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z",
@@ -341,7 +340,6 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
"enabled": true, "enabled": true,
"entitlement": "entitled", "entitlement": "entitled",
"limit": 0, "limit": 0,
"soft_limit": 0,
"usage_period": { "usage_period": {
"end": "2019-08-24T14:15:22Z", "end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z",
+6 -10
View File
@@ -3902,7 +3902,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"enabled": true, "enabled": true,
"entitlement": "entitled", "entitlement": "entitled",
"limit": 0, "limit": 0,
"soft_limit": 0,
"usage_period": { "usage_period": {
"end": "2019-08-24T14:15:22Z", "end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z",
@@ -3914,7 +3913,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"enabled": true, "enabled": true,
"entitlement": "entitled", "entitlement": "entitled",
"limit": 0, "limit": 0,
"soft_limit": 0,
"usage_period": { "usage_period": {
"end": "2019-08-24T14:15:22Z", "end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z",
@@ -4196,7 +4194,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"enabled": true, "enabled": true,
"entitlement": "entitled", "entitlement": "entitled",
"limit": 0, "limit": 0,
"soft_limit": 0,
"usage_period": { "usage_period": {
"end": "2019-08-24T14:15:22Z", "end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z", "issued_at": "2019-08-24T14:15:22Z",
@@ -4207,13 +4204,12 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |---------------|----------------------------------------------|----------|--------------|-------------|
| `actual` | integer | false | | | | `actual` | integer | false | | |
| `enabled` | boolean | false | | | | `enabled` | boolean | false | | |
| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | | `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
| `limit` | integer | 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). |`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. 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| Only certain features set these fields: - FeatureManagedAgentLimit|
+1 -5
View File
@@ -678,7 +678,7 @@ func TestManagedAgentLimit(t *testing.T) {
// expiry warnings. // expiry warnings.
GraceAt: time.Now().Add(time.Hour * 24 * 60), GraceAt: time.Now().Add(time.Hour * 24 * 60),
ExpiresAt: time.Now().Add(time.Hour * 24 * 90), ExpiresAt: time.Now().Add(time.Hour * 24 * 90),
}).ManagedAgentLimit(1, 1), }).ManagedAgentLimit(1),
}) })
// Get entitlements to check that the license is a-ok. // Get entitlements to check that the license is a-ok.
@@ -689,11 +689,7 @@ func TestManagedAgentLimit(t *testing.T) {
require.True(t, agentLimit.Enabled) require.True(t, agentLimit.Enabled)
require.NotNil(t, agentLimit.Limit) require.NotNil(t, agentLimit.Limit)
require.EqualValues(t, 1, *agentLimit.Limit) require.EqualValues(t, 1, *agentLimit.Limit)
require.NotNil(t, agentLimit.SoftLimit)
require.EqualValues(t, 1, *agentLimit.SoftLimit)
require.Empty(t, sdkEntitlements.Errors) require.Empty(t, sdkEntitlements.Errors)
// There should be a warning since we're really close to our agent limit.
require.Equal(t, sdkEntitlements.Warnings[0], "You are approaching the managed agent limit in your license. Please refer to the Deployment Licenses page for more information.")
// Create a fake provision response that claims there are agents in the // Create a fake provision response that claims there are agents in the
// template and every built workspace. // template and every built workspace.
@@ -231,12 +231,8 @@ func (opts *LicenseOptions) AIGovernanceAddon(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureAIGovernanceUserLimit, limit) return opts.Feature(codersdk.FeatureAIGovernanceUserLimit, limit)
} }
func (opts *LicenseOptions) ManagedAgentLimit(soft int64, hard int64) *LicenseOptions { func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
// These don't use named or exported feature names, see return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
// 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 { func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
+31 -156
View File
@@ -15,60 +15,9 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk" "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. // Entitlements processes licenses to return whether features are enabled or not.
// TODO(@deansheather): This function and the related LicensesEntitlements // TODO(@deansheather): This function and the related LicensesEntitlements
// function should be refactored into smaller functions that: // function should be refactored into smaller functions that:
@@ -280,17 +229,15 @@ func LicensesEntitlements(
// licenses with the corresponding features actually set // licenses with the corresponding features actually set
// trump this default entitlement, even if they are set to a // trump this default entitlement, even if they are set to a
// smaller value. // smaller value.
defaultManagedAgentsIsuedAt = time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC) defaultManagedAgentsIsuedAt = time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC)
defaultManagedAgentsStart = defaultManagedAgentsIsuedAt defaultManagedAgentsStart = defaultManagedAgentsIsuedAt
defaultManagedAgentsEnd = defaultManagedAgentsStart.AddDate(100, 0, 0) defaultManagedAgentsEnd = defaultManagedAgentsStart.AddDate(100, 0, 0)
defaultManagedAgentsSoftLimit int64 = 1000 defaultManagedAgentsLimit int64 = 1000
defaultManagedAgentsHardLimit int64 = 1000
) )
entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, codersdk.Feature{ entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, codersdk.Feature{
Enabled: true, Enabled: true,
Entitlement: entitlement, Entitlement: entitlement,
SoftLimit: &defaultManagedAgentsSoftLimit, Limit: &defaultManagedAgentsLimit,
Limit: &defaultManagedAgentsHardLimit,
UsagePeriod: &codersdk.UsagePeriod{ UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: defaultManagedAgentsIsuedAt, IssuedAt: defaultManagedAgentsIsuedAt,
Start: defaultManagedAgentsStart, Start: defaultManagedAgentsStart,
@@ -310,15 +257,6 @@ func LicensesEntitlements(
// Add all features from the feature set. // Add all features from the feature set.
for _, featureName := range claims.FeatureSet.Features() { for _, featureName := range claims.FeatureSet.Features() {
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.UsesLimit() || featureName.UsesUsagePeriod() { if featureName.UsesLimit() || featureName.UsesUsagePeriod() {
// Limit and usage period features are handled below. // Limit and usage period features are handled below.
// They don't provide default values as they are always enabled // They don't provide default values as they are always enabled
@@ -335,30 +273,24 @@ func LicensesEntitlements(
}) })
} }
// A map of SDK feature name to the uncommitted usage feature.
uncommittedUsageFeatures := map[codersdk.FeatureName]usageLimit{}
// Features al-la-carte // Features al-la-carte
for featureName, featureValue := range claims.Features { for featureName, featureValue := range claims.Features {
if _, ok := licenseForbiddenFeatures[featureName]; ok { // Old-style licenses encode the managed agent limit as
entitlements.Errors = append(entitlements.Errors, // separate soft/hard features.
fmt.Sprintf("Feature %s is forbidden to be set in a license.", featureName)) //
continue // This could be removed in a future release, but can only be
// done once all old licenses containing this are no longer in use.
if featureName == "managed_agent_limit_soft" {
// Maps the soft limit to the canonical feature name
featureName = codersdk.FeatureManagedAgentLimit
} }
if featureValue < 0 { if featureName == "managed_agent_limit_hard" {
// We currently don't use negative values for features. // We can safely ignore the hard limit as it is no longer used.
continue continue
} }
// Special handling for grouped (e.g. usage period) features. if featureValue < 0 {
if grouping, ok := featureGrouping[featureName]; ok { // We currently don't use negative values for features.
ul := uncommittedUsageFeatures[grouping.sdkFeature]
if grouping.isSoft {
ul.Soft = &featureValue
} else {
ul.Hard = &featureValue
}
uncommittedUsageFeatures[grouping.sdkFeature] = ul
continue continue
} }
@@ -372,6 +304,17 @@ func LicensesEntitlements(
// Handling for limit features. // Handling for limit features.
switch { switch {
case featureName.UsesUsagePeriod():
entitlements.AddFeature(featureName, codersdk.Feature{
Enabled: featureValue > 0,
Entitlement: entitlement,
Limit: &featureValue,
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
},
})
case featureName.UsesLimit(): case featureName.UsesLimit():
if featureValue <= 0 { if featureValue <= 0 {
// 0 limit value or less doesn't make sense, so we skip it. // 0 limit value or less doesn't make sense, so we skip it.
@@ -402,46 +345,6 @@ func LicensesEntitlements(
} }
} }
// 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)
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature) addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
// Finally, add all features from the addons. We do this last so that // Finally, add all features from the addons. We do this last so that
@@ -557,32 +460,9 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit) entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled. // Only issue warnings if the feature is enabled.
if agentLimit.Enabled { if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
var softLimit int64 entitlements.Warnings = append(entitlements.Warnings,
if agentLimit.SoftLimit != nil { codersdk.LicenseManagedAgentLimitExceededWarningText)
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,
codersdk.LicenseManagedAgentLimitExceededWarningText)
} 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.")
}
} }
} }
} }
@@ -683,11 +563,6 @@ var (
type Features map[codersdk.FeatureName]int64 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. // Claims is the full set of claims in a license.
type Claims struct { type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
+249 -251
View File
@@ -76,8 +76,7 @@ func TestEntitlements(t *testing.T) {
f := make(license.Features) f := make(license.Features)
for _, name := range codersdk.FeatureNames { for _, name := range codersdk.FeatureNames {
if name == codersdk.FeatureManagedAgentLimit { if name == codersdk.FeatureManagedAgentLimit {
f[codersdk.FeatureName("managed_agent_limit_soft")] = 100 f[codersdk.FeatureManagedAgentLimit] = 100
f[codersdk.FeatureName("managed_agent_limit_hard")] = 200
continue continue
} }
f[name] = 1 f[name] = 1
@@ -533,8 +532,7 @@ func TestEntitlements(t *testing.T) {
t.Run("Premium", func(t *testing.T) { t.Run("Premium", func(t *testing.T) {
t.Parallel() t.Parallel()
const userLimit = 1 const userLimit = 1
const expectedAgentSoftLimit = 1000 const expectedAgentLimit = 1000
const expectedAgentHardLimit = 1000
db, _ := dbtestutil.NewDB(t) db, _ := dbtestutil.NewDB(t)
licenseOptions := coderdenttest.LicenseOptions{ licenseOptions := coderdenttest.LicenseOptions{
@@ -566,8 +564,7 @@ func TestEntitlements(t *testing.T) {
agentEntitlement := entitlements.Features[featureName] agentEntitlement := entitlements.Features[featureName]
require.True(t, agentEntitlement.Enabled) require.True(t, agentEntitlement.Enabled)
require.Equal(t, codersdk.EntitlementEntitled, agentEntitlement.Entitlement) require.Equal(t, codersdk.EntitlementEntitled, agentEntitlement.Entitlement)
require.EqualValues(t, expectedAgentSoftLimit, *agentEntitlement.SoftLimit) require.EqualValues(t, expectedAgentLimit, *agentEntitlement.Limit)
require.EqualValues(t, expectedAgentHardLimit, *agentEntitlement.Limit)
// This might be shocking, but there's a sound reason for this. // This might be shocking, but there's a sound reason for this.
// See license.go for more details. // See license.go for more details.
@@ -840,7 +837,7 @@ func TestEntitlements(t *testing.T) {
}, },
}). }).
UserLimit(100). UserLimit(100).
ManagedAgentLimit(100, 200) ManagedAgentLimit(100)
lic := database.License{ lic := database.License{
ID: 1, ID: 1,
@@ -882,16 +879,15 @@ func TestEntitlements(t *testing.T) {
managedAgentLimit, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit] managedAgentLimit, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.True(t, ok) require.True(t, ok)
require.NotNil(t, managedAgentLimit.SoftLimit)
require.EqualValues(t, 100, *managedAgentLimit.SoftLimit)
require.NotNil(t, managedAgentLimit.Limit) require.NotNil(t, managedAgentLimit.Limit)
require.EqualValues(t, 200, *managedAgentLimit.Limit) // The soft limit value (100) is used as the single Limit.
require.EqualValues(t, 100, *managedAgentLimit.Limit)
require.NotNil(t, managedAgentLimit.Actual) require.NotNil(t, managedAgentLimit.Actual)
require.EqualValues(t, 175, *managedAgentLimit.Actual) require.EqualValues(t, 175, *managedAgentLimit.Actual)
// Should've also populated a warning. // Usage exceeds the limit, so an exceeded warning should be present.
require.Len(t, entitlements.Warnings, 1) require.Len(t, entitlements.Warnings, 1)
require.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]) require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
}) })
} }
@@ -1121,13 +1117,12 @@ func TestLicenseEntitlements(t *testing.T) {
{ {
Name: "ManagedAgentLimit", Name: "ManagedAgentLimit",
Licenses: []*coderdenttest.LicenseOptions{ Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100).ManagedAgentLimit(100, 200), enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
}, },
Arguments: license.FeatureArguments{ Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { 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 // 74 is below the limit (soft=100), so no warning.
// difference between the soft and hard limit. return 74, nil
return 174, nil
}, },
}, },
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
@@ -1136,9 +1131,9 @@ func TestLicenseEntitlements(t *testing.T) {
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit] feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled) assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit) // Soft limit value is used as the single Limit.
assert.Equal(t, int64(200), *feature.Limit) assert.Equal(t, int64(100), *feature.Limit)
assert.Equal(t, int64(174), *feature.Actual) assert.Equal(t, int64(74), *feature.Actual)
}, },
}, },
{ {
@@ -1151,7 +1146,7 @@ func TestLicenseEntitlements(t *testing.T) {
WithIssuedAt(time.Now().Add(-time.Hour * 2)), WithIssuedAt(time.Now().Add(-time.Hour * 2)),
enterpriseLicense(). enterpriseLicense().
UserLimit(100). UserLimit(100).
ManagedAgentLimit(100, 100). ManagedAgentLimit(100).
WithIssuedAt(time.Now().Add(-time.Hour * 1)). WithIssuedAt(time.Now().Add(-time.Hour * 1)).
GracePeriod(time.Now()), GracePeriod(time.Now()),
}, },
@@ -1168,7 +1163,6 @@ func TestLicenseEntitlements(t *testing.T) {
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit] feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement) assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
assert.True(t, feature.Enabled) assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit)
assert.Equal(t, int64(100), *feature.Limit) assert.Equal(t, int64(100), *feature.Limit)
assert.Equal(t, int64(74), *feature.Actual) assert.Equal(t, int64(74), *feature.Actual)
}, },
@@ -1183,7 +1177,7 @@ func TestLicenseEntitlements(t *testing.T) {
WithIssuedAt(time.Now().Add(-time.Hour * 2)), WithIssuedAt(time.Now().Add(-time.Hour * 2)),
enterpriseLicense(). enterpriseLicense().
UserLimit(100). UserLimit(100).
ManagedAgentLimit(100, 200). ManagedAgentLimit(100).
WithIssuedAt(time.Now().Add(-time.Hour * 1)). WithIssuedAt(time.Now().Add(-time.Hour * 1)).
Expired(time.Now()), Expired(time.Now()),
}, },
@@ -1196,84 +1190,33 @@ func TestLicenseEntitlements(t *testing.T) {
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit] feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
assert.False(t, feature.Enabled) assert.False(t, feature.Enabled)
assert.Nil(t, feature.SoftLimit)
assert.Nil(t, feature.Limit) assert.Nil(t, feature.Limit)
assert.Nil(t, feature.Actual) assert.Nil(t, feature.Actual)
}, },
}, },
{ {
Name: "ManagedAgentLimitWarning/ApproachingLimit/DifferentSoftAndHardLimit", Name: "ManagedAgentLimitWarning/ExceededLimit",
Licenses: []*coderdenttest.LicenseOptions{ Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense(). enterpriseLicense().
UserLimit(100). UserLimit(100).
ManagedAgentLimit(100, 200), ManagedAgentLimit(100),
}, },
Arguments: license.FeatureArguments{ Arguments: license.FeatureArguments{
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 175, nil return 150, nil
}, },
}, },
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.Len(t, entitlements.Warnings, 1) 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]) assert.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
assertNoErrors(t, entitlements) assertNoErrors(t, entitlements)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit] feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
assert.True(t, feature.Enabled) assert.True(t, feature.Enabled)
assert.Equal(t, int64(100), *feature.SoftLimit) // Soft limit (100) is used as the single Limit.
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(100), *feature.Limit)
assert.Equal(t, int64(75), *feature.Actual) assert.Equal(t, int64(150), *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.", 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)
}, },
}, },
{ {
@@ -1472,173 +1415,240 @@ func TestAIBridgeSoftWarning(t *testing.T) {
func TestUsageLimitFeatures(t *testing.T) { func TestUsageLimitFeatures(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { // Ensures that usage limit features are ranked by issued at, not by
sdkFeatureName codersdk.FeatureName // values.
softLimitFeatureName codersdk.FeatureName t.Run("IssuedAtRanking", func(t *testing.T) {
hardLimitFeatureName codersdk.FeatureName t.Parallel()
}{
{
sdkFeatureName: codersdk.FeatureManagedAgentLimit,
softLimitFeatureName: codersdk.FeatureName("managed_agent_limit_soft"),
hardLimitFeatureName: codersdk.FeatureName("managed_agent_limit_hard"),
},
}
for _, c := range cases { // Generate 2 real licenses both with managed agent limit
t.Run(string(c.sdkFeatureName), func(t *testing.T) { // features. lic2 should trump lic1 even though it has a lower
t.Parallel() // 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{
codersdk.FeatureManagedAgentLimit: 100,
},
}),
}
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{
codersdk.FeatureManagedAgentLimit: 50,
},
}),
}
// Test for either a missing soft or hard limit feature value. const actualAgents = 10
t.Run("MissingGroupedFeature", func(t *testing.T) { arguments := license.FeatureArguments{
t.Parallel() ActiveUserCount: 10,
ReplicaCount: 0,
ExternalAuthCount: 0,
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
}
for _, feature := range []codersdk.FeatureName{ // Load the licenses in both orders to ensure the correct
c.softLimitFeatureName, // behavior is observed no matter the order.
c.hardLimitFeatureName, for _, order := range [][]database.License{
} { {lic1, lic2},
t.Run(string(feature), func(t *testing.T) { {lic2, lic1},
t.Parallel() } {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
require.NoError(t, err)
lic := database.License{ feature, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit]
ID: 1, require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
UploadedAt: time.Now(), require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
Exp: time.Now().Add(time.Hour), require.NotNil(t, feature.Limit)
UUID: uuid.New(), require.EqualValues(t, 50, *feature.Limit)
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ require.NotNil(t, feature.Actual)
Features: license.Features{ require.EqualValues(t, actualAgents, *feature.Actual)
feature: 100, 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)
}
})
}
arguments := license.FeatureArguments{ // TestOldStyleManagedAgentLicenses ensures backward compatibility with
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { // older licenses that encode the managed agent limit using separate
return 0, nil // "managed_agent_limit_soft" and "managed_agent_limit_hard" feature keys
}, // instead of the canonical "managed_agent_limit" key.
} func TestOldStyleManagedAgentLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments) t.Parallel()
require.NoError(t, err)
feature, ok := entitlements.Features[c.sdkFeatureName] t.Run("SoftAndHard", func(t *testing.T) {
require.True(t, ok, "feature %s not found", c.sdkFeatureName) t.Parallel()
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Len(t, entitlements.Errors, 1) lic := database.License{
require.Equal(t, fmt.Sprintf("Invalid license (%v): feature %s has missing soft or hard limit values", lic.UUID, c.sdkFeatureName), entitlements.Errors[0]) ID: 1,
}) UploadedAt: time.Now(),
} Exp: time.Now().Add(time.Hour),
}) UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureName("managed_agent_limit_soft"): 100,
codersdk.FeatureName("managed_agent_limit_hard"): 200,
},
}),
}
t.Run("HardBelowSoft", func(t *testing.T) { const actualAgents = 42
t.Parallel() arguments := license.FeatureArguments{
ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
return actualAgents, nil
},
}
lic := database.License{ entitlements, err := license.LicensesEntitlements(
ID: 1, context.Background(), time.Now(), []database.License{lic},
UploadedAt: time.Now(), map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments,
Exp: time.Now().Add(time.Hour), )
UUID: uuid.New(), require.NoError(t, err)
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ require.Empty(t, entitlements.Errors)
Features: license.Features{
c.softLimitFeatureName: 100,
c.hardLimitFeatureName: 50,
},
}),
}
arguments := license.FeatureArguments{ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) { require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
return 0, nil require.True(t, feature.Enabled)
}, require.NotNil(t, feature.Limit)
} // The soft limit should be used as the canonical limit.
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments) require.EqualValues(t, 100, *feature.Limit)
require.NoError(t, err) require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod)
})
feature, ok := entitlements.Features[c.sdkFeatureName] t.Run("OnlySoft", func(t *testing.T) {
require.True(t, ok, "feature %s not found", c.sdkFeatureName) t.Parallel()
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Len(t, entitlements.Errors, 1) lic := database.License{
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]) ID: 1,
}) UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureName("managed_agent_limit_soft"): 75,
},
}),
}
// Ensures that these features are ranked by issued at, not by const actualAgents = 10
// values. arguments := license.FeatureArguments{
t.Run("IssuedAtRanking", func(t *testing.T) { ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
t.Parallel() return actualAgents, nil
},
}
// Generate 2 real licenses both with managed agent limit entitlements, err := license.LicensesEntitlements(
// features. lic2 should trump lic1 even though it has a lower context.Background(), time.Now(), []database.License{lic},
// limit, because it was issued later. map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments,
lic1 := database.License{ )
ID: 1, require.NoError(t, err)
UploadedAt: time.Now(), require.Empty(t, entitlements.Errors)
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 feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
arguments := license.FeatureArguments{ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
ActiveUserCount: 10, require.True(t, feature.Enabled)
ReplicaCount: 0, require.NotNil(t, feature.Limit)
ExternalAuthCount: 0, require.EqualValues(t, 75, *feature.Limit)
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 // A license with only the hard limit key should silently ignore it,
// behavior is observed no matter the order. // leaving the feature unset (not entitled).
for _, order := range [][]database.License{ t.Run("OnlyHard", func(t *testing.T) {
{lic1, lic2}, t.Parallel()
{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] lic := database.License{
require.True(t, ok, "feature %s not found", c.sdkFeatureName) ID: 1,
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) UploadedAt: time.Now(),
require.NotNil(t, feature.Limit) Exp: time.Now().Add(time.Hour),
require.EqualValues(t, 100, *feature.Limit) UUID: uuid.New(),
require.NotNil(t, feature.SoftLimit) JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
require.EqualValues(t, 50, *feature.SoftLimit) Features: license.Features{
require.NotNil(t, feature.Actual) codersdk.FeatureName("managed_agent_limit_hard"): 200,
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) arguments := license.FeatureArguments{
} ManagedAgentCountFn: func(_ context.Context, _, _ 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)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
})
// Old-style license with both soft and hard set to zero should
// explicitly disable the feature (and override any Premium default).
t.Run("ExplicitZero", 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 = 5
arguments := license.FeatureArguments{
ActiveUserCount: 10,
ManagedAgentCountFn: func(_ context.Context, _, _ 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 := entitlements.Features[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.Actual)
require.EqualValues(t, actualAgents, *feature.Actual)
})
} }
func TestManagedAgentLimitDefault(t *testing.T) { func TestManagedAgentLimitDefault(t *testing.T) {
@@ -1676,20 +1686,16 @@ func TestManagedAgentLimitDefault(t *testing.T) {
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit) require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement) require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Nil(t, feature.Limit) require.Nil(t, feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.Actual) require.Nil(t, feature.Actual)
require.Nil(t, feature.UsagePeriod) require.Nil(t, feature.UsagePeriod)
}) })
// "Premium" licenses should receive a default managed agent limit of: // "Premium" licenses should receive a default managed agent limit of 1000.
// soft = 1000
// hard = 1000
t.Run("Premium", func(t *testing.T) { t.Run("Premium", func(t *testing.T) {
t.Parallel() t.Parallel()
const userLimit = 33 const userLimit = 33
const softLimit = 1000 const defaultLimit = 1000
const hardLimit = 1000
lic := database.License{ lic := database.License{
ID: 1, ID: 1,
UploadedAt: time.Now(), UploadedAt: time.Now(),
@@ -1720,9 +1726,7 @@ func TestManagedAgentLimitDefault(t *testing.T) {
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit) require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit) require.NotNil(t, feature.Limit)
require.EqualValues(t, hardLimit, *feature.Limit) require.EqualValues(t, defaultLimit, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, softLimit, *feature.SoftLimit)
require.NotNil(t, feature.Actual) require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod) require.NotNil(t, feature.UsagePeriod)
@@ -1731,8 +1735,8 @@ func TestManagedAgentLimitDefault(t *testing.T) {
require.NotZero(t, feature.UsagePeriod.End) require.NotZero(t, feature.UsagePeriod.End)
}) })
// "Premium" licenses with an explicit managed agent limit should not // "Premium" licenses with an explicit managed agent limit should use
// receive a default managed agent limit. // that value instead of the default.
t.Run("PremiumExplicitValues", func(t *testing.T) { t.Run("PremiumExplicitValues", func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1744,9 +1748,8 @@ func TestManagedAgentLimitDefault(t *testing.T) {
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium, FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{ Features: license.Features{
codersdk.FeatureUserLimit: 100, codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("managed_agent_limit_soft"): 100, codersdk.FeatureManagedAgentLimit: 100,
codersdk.FeatureName("managed_agent_limit_hard"): 200,
}, },
}), }),
} }
@@ -1768,9 +1771,7 @@ func TestManagedAgentLimitDefault(t *testing.T) {
require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit) require.True(t, ok, "feature %s not found", codersdk.FeatureManagedAgentLimit)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit) require.NotNil(t, feature.Limit)
require.EqualValues(t, 200, *feature.Limit) require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 100, *feature.SoftLimit)
require.NotNil(t, feature.Actual) require.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod) require.NotNil(t, feature.UsagePeriod)
@@ -1792,9 +1793,8 @@ func TestManagedAgentLimitDefault(t *testing.T) {
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium, FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{ Features: license.Features{
codersdk.FeatureUserLimit: 100, codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("managed_agent_limit_soft"): 0, codersdk.FeatureManagedAgentLimit: 0,
codersdk.FeatureName("managed_agent_limit_hard"): 0,
}, },
}), }),
} }
@@ -1818,8 +1818,6 @@ func TestManagedAgentLimitDefault(t *testing.T) {
require.False(t, feature.Enabled) require.False(t, feature.Enabled)
require.NotNil(t, feature.Limit) require.NotNil(t, feature.Limit)
require.EqualValues(t, 0, *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.NotNil(t, feature.Actual)
require.EqualValues(t, actualAgents, *feature.Actual) require.EqualValues(t, actualAgents, *feature.Actual)
require.NotNil(t, feature.UsagePeriod) require.NotNil(t, feature.UsagePeriod)
+1 -1
View File
@@ -4728,7 +4728,7 @@ func TestWorkspaceAITask(t *testing.T) {
Features: license.Features{ Features: license.Features{
codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureTemplateRBAC: 1,
}, },
}).ManagedAgentLimit(10, 20), }).ManagedAgentLimit(10),
}) })
client, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, client, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID,
-6
View File
@@ -2112,12 +2112,6 @@ export interface Feature {
readonly enabled: boolean; readonly enabled: boolean;
readonly limit?: number; readonly limit?: number;
readonly actual?: number; readonly actual?: number;
/**
* 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.
*/
readonly soft_limit?: number;
/** /**
* UsagePeriod 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 * this period (and most likely resets with the issuance of the next
@@ -9,8 +9,7 @@ const meta: Meta<typeof ManagedAgentsConsumption> = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 50000, actual: 50000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -31,8 +30,7 @@ export const ZeroUsage: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 0, actual: 0,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -47,9 +45,8 @@ export const NearLimit: Story = {
args: { args: {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 115000, actual: 55000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -60,13 +57,12 @@ export const NearLimit: Story = {
}, },
}; };
export const OverIncluded: Story = { export const OverLimit: Story = {
args: { args: {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 80000, actual: 80000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -82,25 +78,7 @@ export const LowUsage: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 25000, actual: 25000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: {
start: "February 27, 2025",
end: "February 27, 2026",
issued_at: "February 27, 2025",
},
entitlement: "entitled",
},
},
};
export const IncludedAtLimit: Story = {
args: {
managedAgentFeature: {
enabled: true,
actual: 25000,
soft_limit: 30500,
limit: 30500,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -116,7 +94,6 @@ export const Disabled: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: false, enabled: false,
actual: undefined, actual: undefined,
soft_limit: undefined,
limit: undefined, limit: undefined,
usage_period: undefined, usage_period: undefined,
entitlement: "not_entitled", entitlement: "not_entitled",
@@ -136,7 +113,6 @@ export const ErrorMissingData: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: undefined, actual: undefined,
soft_limit: undefined,
limit: undefined, limit: undefined,
usage_period: undefined, usage_period: undefined,
entitlement: "entitled", entitlement: "entitled",
@@ -149,25 +125,7 @@ export const ErrorNegativeValues: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: -100, actual: -100,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: {
start: "February 27, 2025",
end: "February 27, 2026",
issued_at: "February 27, 2025",
},
entitlement: "entitled",
},
},
};
export const ErrorSoftLimitExceedsLimit: Story = {
args: {
managedAgentFeature: {
enabled: true,
actual: 50000,
soft_limit: 150000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2025", start: "February 27, 2025",
end: "February 27, 2026", end: "February 27, 2026",
@@ -183,8 +141,7 @@ export const ErrorInvalidDates: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 50000, actual: 50000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "invalid-date", start: "invalid-date",
end: "February 27, 2026", end: "February 27, 2026",
@@ -200,8 +157,7 @@ export const ErrorEndBeforeStart: Story = {
managedAgentFeature: { managedAgentFeature: {
enabled: true, enabled: true,
actual: 50000, actual: 50000,
soft_limit: 60000, limit: 60000,
limit: 120000,
usage_period: { usage_period: {
start: "February 27, 2026", start: "February 27, 2026",
end: "February 27, 2025", end: "February 27, 2025",
@@ -39,7 +39,7 @@ export const ManagedAgentsConsumption: FC<ManagedAgentsConsumptionProps> = ({
} }
const usage = managedAgentFeature.actual; const usage = managedAgentFeature.actual;
const included = managedAgentFeature.soft_limit; const included = managedAgentFeature.limit;
const startDate = managedAgentFeature.usage_period?.start; const startDate = managedAgentFeature.usage_period?.start;
const endDate = managedAgentFeature.usage_period?.end; const endDate = managedAgentFeature.usage_period?.end;