Skip to content

Commit c74ead4

Browse files
committed
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.
1 parent f1eec2d commit c74ead4

File tree

5 files changed

+826
-62
lines changed

5 files changed

+826
-62
lines changed

codersdk/deployment.go

Lines changed: 129 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -85,31 +85,47 @@ const (
8585
FeatureCustomRoles FeatureName = "custom_roles"
8686
FeatureMultipleOrganizations FeatureName = "multiple_organizations"
8787
FeatureWorkspacePrebuilds FeatureName = "workspace_prebuilds"
88+
// ManagedAgentLimit is a usage period feature, so the value in the license
89+
// contains both a soft and hard limit. Refer to
90+
// enterprise/coderd/license/license.go for the license format.
91+
FeatureManagedAgentLimit FeatureName = "managed_agent_limit"
8892
)
8993

90-
// FeatureNames must be kept in-sync with the Feature enum above.
91-
var FeatureNames = []FeatureName{
92-
FeatureUserLimit,
93-
FeatureAuditLog,
94-
FeatureConnectionLog,
95-
FeatureBrowserOnly,
96-
FeatureSCIM,
97-
FeatureTemplateRBAC,
98-
FeatureHighAvailability,
99-
FeatureMultipleExternalAuth,
100-
FeatureExternalProvisionerDaemons,
101-
FeatureAppearance,
102-
FeatureAdvancedTemplateScheduling,
103-
FeatureWorkspaceProxy,
104-
FeatureUserRoleManagement,
105-
FeatureExternalTokenEncryption,
106-
FeatureWorkspaceBatchActions,
107-
FeatureAccessControl,
108-
FeatureControlSharedPorts,
109-
FeatureCustomRoles,
110-
FeatureMultipleOrganizations,
111-
FeatureWorkspacePrebuilds,
112-
}
94+
var (
95+
// FeatureNames must be kept in-sync with the Feature enum above.
96+
FeatureNames = []FeatureName{
97+
FeatureUserLimit,
98+
FeatureAuditLog,
99+
FeatureConnectionLog,
100+
FeatureBrowserOnly,
101+
FeatureSCIM,
102+
FeatureTemplateRBAC,
103+
FeatureHighAvailability,
104+
FeatureMultipleExternalAuth,
105+
FeatureExternalProvisionerDaemons,
106+
FeatureAppearance,
107+
FeatureAdvancedTemplateScheduling,
108+
FeatureWorkspaceProxy,
109+
FeatureUserRoleManagement,
110+
FeatureExternalTokenEncryption,
111+
FeatureWorkspaceBatchActions,
112+
FeatureAccessControl,
113+
FeatureControlSharedPorts,
114+
FeatureCustomRoles,
115+
FeatureMultipleOrganizations,
116+
FeatureWorkspacePrebuilds,
117+
FeatureManagedAgentLimit,
118+
}
119+
120+
// FeatureNamesMap is a map of all feature names for quick lookups.
121+
FeatureNamesMap = func() map[FeatureName]struct{} {
122+
featureNamesMap := make(map[FeatureName]struct{})
123+
for _, featureName := range FeatureNames {
124+
featureNamesMap[featureName] = struct{}{}
125+
}
126+
return featureNamesMap
127+
}()
128+
)
113129

114130
// Humanize returns the feature name in a human-readable format.
115131
func (n FeatureName) Humanize() string {
@@ -153,6 +169,22 @@ func (n FeatureName) Enterprise() bool {
153169
}
154170
}
155171

172+
// UsesLimit returns true if the feature uses a limit, and therefore should not
173+
// be included in any feature sets (as they are not boolean features).
174+
func (n FeatureName) UsesLimit() bool {
175+
return map[FeatureName]bool{
176+
FeatureUserLimit: true,
177+
FeatureManagedAgentLimit: true,
178+
}[n]
179+
}
180+
181+
// UsesUsagePeriod returns true if the feature uses period-based usage limits.
182+
func (n FeatureName) UsesUsagePeriod() bool {
183+
return map[FeatureName]bool{
184+
FeatureManagedAgentLimit: true,
185+
}[n]
186+
}
187+
156188
// FeatureSet represents a grouping of features. Rather than manually
157189
// assigning features al-la-carte when making a license, a set can be specified.
158190
// 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 {
177209
copy(enterpriseFeatures, FeatureNames)
178210
// Remove the selection
179211
enterpriseFeatures = slices.DeleteFunc(enterpriseFeatures, func(f FeatureName) bool {
180-
return !f.Enterprise()
212+
return !f.Enterprise() || f.UsesLimit()
181213
})
182214

183215
return enterpriseFeatures
184216
case FeatureSetPremium:
185217
premiumFeatures := make([]FeatureName, len(FeatureNames))
186218
copy(premiumFeatures, FeatureNames)
219+
// Remove the selection
220+
premiumFeatures = slices.DeleteFunc(premiumFeatures, func(f FeatureName) bool {
221+
return f.UsesLimit()
222+
})
187223
// FeatureSetPremium is just all features.
188224
return premiumFeatures
225+
case FeatureSetNone:
226+
default:
227+
panic("unexpected codersdk.FeatureSet")
189228
}
190229
// By default, return an empty set.
191230
return []FeatureName{}
@@ -196,6 +235,25 @@ type Feature struct {
196235
Enabled bool `json:"enabled"`
197236
Limit *int64 `json:"limit,omitempty"`
198237
Actual *int64 `json:"actual,omitempty"`
238+
239+
// Below is only for features that use usage periods.
240+
241+
// SoftLimit is the soft limit of the feature, and is only used for showing
242+
// included limits in the dashboard. No license validation or warnings are
243+
// generated from this value.
244+
SoftLimit *int64 `json:"soft_limit,omitempty"`
245+
// Usage period denotes that the usage is a counter that accumulates over
246+
// this period (and most likely resets with the issuance of the next
247+
// license).
248+
//
249+
// These dates are determined from the license that this entitlement comes
250+
// from, see enterprise/coderd/license/license.go.
251+
//
252+
// Only certain features set these fields:
253+
// - FeatureManagedAgentLimit
254+
UsagePeriodIssuedAt *time.Time `json:"usage_period_issued_at,omitempty" format:"date-time"`
255+
UsagePeriodStart *time.Time `json:"usage_period_start,omitempty" format:"date-time"`
256+
UsagePeriodEnd *time.Time `json:"usage_period_end,omitempty" format:"date-time"`
199257
}
200258

201259
// Compare compares two features and returns an integer representing
@@ -205,12 +263,15 @@ type Feature struct {
205263
//
206264
// A feature is considered greater than another feature if:
207265
// 1. Graceful & capable > Entitled & not capable
208-
// 2. The entitlement is greater
209-
// 3. The limit is greater
210-
// 4. Enabled is greater than disabled
211-
// 5. The actual is greater
266+
// 2. The usage period has a greater end date (note: only certain features use usage periods)
267+
// 3. The usage period has a greater issued at date (note: only certain features use usage periods)
268+
// 4. The entitlement is greater
269+
// 5. The limit is greater
270+
// 6. Enabled is greater than disabled
271+
// 7. The actual is greater
212272
func (f Feature) Compare(b Feature) int {
213-
if !f.Capable() || !b.Capable() {
273+
// Only perform capability comparisons if both features have actual values.
274+
if f.Actual != nil && b.Actual != nil && (!f.Capable() || !b.Capable()) {
214275
// If either is incapable, then it is possible a grace period
215276
// feature can be "greater" than an entitled.
216277
// If either is "NotEntitled" then we can defer to a strict entitlement
@@ -225,12 +286,29 @@ func (f Feature) Compare(b Feature) int {
225286
}
226287
}
227288

228-
// Strict entitlement check. Higher is better
289+
// Strict entitlement check. Higher is better. We don't apply this check for
290+
// usage period features as we always want the issued at date to be the main
291+
// decision maker.
292+
bothHaveIssuedAt := f.UsagePeriodIssuedAt != nil && b.UsagePeriodIssuedAt != nil
229293
entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight()
230-
if entitlementDifference != 0 {
294+
if !bothHaveIssuedAt && entitlementDifference != 0 {
231295
return entitlementDifference
232296
}
233297

298+
// For features with usage period constraints only:
299+
if bothHaveIssuedAt {
300+
cmp := f.UsagePeriodIssuedAt.Compare(*b.UsagePeriodIssuedAt)
301+
if cmp != 0 {
302+
return cmp
303+
}
304+
}
305+
if f.UsagePeriodEnd != nil && b.UsagePeriodEnd != nil {
306+
cmp := f.UsagePeriodEnd.Compare(*b.UsagePeriodEnd)
307+
if cmp != 0 {
308+
return cmp
309+
}
310+
}
311+
234312
// If the entitlement is the same, then we can compare the limits.
235313
if f.Limit == nil && b.Limit != nil {
236314
return -1
@@ -295,6 +373,13 @@ type Entitlements struct {
295373
// the set of features granted by the entitlements. If it does not, it will
296374
// be ignored and the existing feature with the same name will remain.
297375
//
376+
// Features that abide by usage period constraints should have the following
377+
// fields set or they will be ignored. Other features will have these fields
378+
// cleared.
379+
// - UsagePeriodIssuedAt
380+
// - UsagePeriodStart
381+
// - UsagePeriodEnd
382+
//
298383
// All features should be added as atomic items, and not merged in any way.
299384
// Merging entitlements could lead to unexpected behavior, like a larger user
300385
// 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) {
306391
return
307392
}
308393

394+
// If we're trying to add a feature that uses a usage period and it's not
395+
// set, then we should not add it.
396+
if name.UsesUsagePeriod() {
397+
if add.UsagePeriodIssuedAt == nil || add.UsagePeriodStart == nil || add.UsagePeriodEnd == nil {
398+
return
399+
}
400+
} else {
401+
// Ensure the usage period values are not set.
402+
add.UsagePeriodIssuedAt = nil
403+
add.UsagePeriodStart = nil
404+
add.UsagePeriodEnd = nil
405+
}
406+
309407
// Compare the features, keep the one that is "better"
310408
comparison := add.Compare(existing)
311409
if comparison > 0 {

codersdk/deployment_test.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -554,10 +554,16 @@ func TestPremiumSuperSet(t *testing.T) {
554554
// Premium ⊃ Enterprise
555555
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.")
556556

557-
// Premium = All Features
558-
// This is currently true. If this assertion changes, update this test
559-
// to reflect the change in feature sets.
560-
require.ElementsMatch(t, premium.Features(), codersdk.FeatureNames, "premium should contain all features")
557+
// Premium = All Features EXCEPT usage limit features
558+
expectedPremiumFeatures := []codersdk.FeatureName{}
559+
for _, feature := range codersdk.FeatureNames {
560+
if feature.UsesLimit() {
561+
continue
562+
}
563+
expectedPremiumFeatures = append(expectedPremiumFeatures, feature)
564+
}
565+
require.NotEmpty(t, expectedPremiumFeatures, "expectedPremiumFeatures should not be empty")
566+
require.ElementsMatch(t, premium.Features(), expectedPremiumFeatures, "premium should contain all features except usage limit features")
561567

562568
// This check exists because if you misuse the slices.Delete, you can end up
563569
// with zero'd values.

enterprise/coderd/coderdenttest/coderdenttest.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,22 @@ type LicenseOptions struct {
176176
// zero value, the `nbf` claim on the license is set to 1 minute in the
177177
// past.
178178
NotBefore time.Time
179-
Features license.Features
179+
// IssuedAt is the time at which the license was issued. If set to the
180+
// zero value, the `iat` claim on the license is set to 1 minute in the
181+
// past.
182+
IssuedAt time.Time
183+
Features license.Features
180184
}
181185

182186
func (opts *LicenseOptions) Expired(now time.Time) *LicenseOptions {
187+
opts.NotBefore = now.Add(time.Hour * 24 * -4) // needs to be before the grace period
183188
opts.ExpiresAt = now.Add(time.Hour * 24 * -2)
184189
opts.GraceAt = now.Add(time.Hour * 24 * -3)
185190
return opts
186191
}
187192

188193
func (opts *LicenseOptions) GracePeriod(now time.Time) *LicenseOptions {
194+
opts.NotBefore = now.Add(time.Hour * 24 * -2) // needs to be before the grace period
189195
opts.ExpiresAt = now.Add(time.Hour * 24)
190196
opts.GraceAt = now.Add(time.Hour * 24 * -1)
191197
return opts
@@ -236,6 +242,7 @@ func AddLicense(t *testing.T, client *codersdk.Client, options LicenseOptions) c
236242

237243
// GenerateLicense returns a signed JWT using the test key.
238244
func GenerateLicense(t *testing.T, options LicenseOptions) string {
245+
t.Helper()
239246
if options.ExpiresAt.IsZero() {
240247
options.ExpiresAt = time.Now().Add(time.Hour)
241248
}
@@ -246,13 +253,18 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
246253
options.NotBefore = time.Now().Add(-time.Minute)
247254
}
248255

256+
issuedAt := options.IssuedAt
257+
if issuedAt.IsZero() {
258+
issuedAt = time.Now().Add(-time.Minute)
259+
}
260+
249261
c := &license.Claims{
250262
RegisteredClaims: jwt.RegisteredClaims{
251263
ID: uuid.NewString(),
252264
Issuer: "test@testing.test",
253265
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
254266
NotBefore: jwt.NewNumericDate(options.NotBefore),
255-
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
267+
IssuedAt: jwt.NewNumericDate(issuedAt),
256268
},
257269
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
258270
AccountType: options.AccountType,
@@ -264,7 +276,12 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
264276
FeatureSet: options.FeatureSet,
265277
Features: options.Features,
266278
}
267-
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
279+
return GenerateLicenseRaw(t, c)
280+
}
281+
282+
func GenerateLicenseRaw(t *testing.T, claims jwt.Claims) string {
283+
t.Helper()
284+
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
268285
tok.Header[license.HeaderKeyID] = testKeyID
269286
signedTok, err := tok.SignedString(testPrivateKey)
270287
require.NoError(t, err)

0 commit comments

Comments
 (0)