Skip to content

Commit 183a6eb

Browse files
authored
chore: add managed_agent_limit licensing feature (#18876)
Note that enforcement and checking usage will come in a future PR. This feature is implemented differently than existing features in a few ways. It's highly recommended that reviewers read: - This document which outlines the methods we could've used for license enforcement: https://www.notion.so/coderhq/AI-Agent-License-Enforcement-21ed579be59280c088b9c1dc5e364ee8 - Phase 0 of the actual RFC document: https://www.notion.so/coderhq/Usage-based-Billing-AI-b-210d579be592800eb257de7eecd2d26d ### Multiple features in the license, a single feature in codersdk Firstly, the feature is represented as a single feature in the codersdk world, but is represented with multiple features in the license. E.g. in the license you may have: { "features": { "managed_agent_limit_soft": 100, "managed_agent_limit_hard": 200 } } But the entitlements endpoint will return a single feature: { "features": { "managed_agent_limit": { "limit": 200, "soft_limit": 100 } } } This is required because of our rigid parsing that uses a `map[string]int64` for features in the license. To avoid requiring all customers to upgrade to use new licenses, the decision was made to just use two features and merge them into one. Older Coder deployments will parse this feature (from new licenses) as two separate features, but it's not a problem because they don't get used anywhere obviously. The reason we want to differentiate between a "soft" and "hard" limit is so we can show admins how much of the usage is "included" vs. how much they can use before they get hard cut-off. ### Usage period features will be compared and trump based on license issuance time The second major difference to other features is that "usage period" features such as `managed_agent_limit` will now be primarily compared by the `iat` (issued at) claim of the license they come from. This differs from previous features. The reason this was done was so we could reduce limits with newer licenses, which the current comparison code does not allow for. This effectively means if you have two active licenses: - `iat`: 2025-07-14, `managed_agent_limit_soft`: 100, `managed_agent_limit_hard`: 200 - `iat`: 2025-07-15, `managed_agent_limit_soft`: 50, `managed_agent_limit_hard`: 100 Then the resulting `managed_agent_limit` entitlement will come from the second license, even though the values are smaller than another valid license. The existing comparison code would prefer the first license even though it was issued earlier. ### Usage period features will count usage between the start and end dates of the license Existing limit features, like the user limit, just measure the current usage value of the feature. The active user count is a gauge that goes up and down, whereas agent usage can only be incremented, so it doesn't make sense to use a continually incrementing counter forever and ever for managed agents. For managed agent limit, we count the usage between `nbf` (not before) and `exp` (expires at) of the license that the entitlement comes from. In the example above, we'd use the issued at date and expiry of the second license as this date range. This essentially means, when you get a new license, the usage resets to zero. The actual usage counting code will be implemented in a follow-up PR. ### Managed agent limit has a default entitlement value Temporarily (until further notice), we will be providing licenses with `feature_set` set to `premium` a default limit. - Soft limit: `800 * user_limit` - Hard limit: `1000 * user_limit` "Enterprise" licenses do not get any default limit and are not entitled to use the feature. Unlicensed customers (e.g. OSS) will be permitted to use the feature as much as they want without limits. This will be implemented when the counting code is implemented in a follow-up PR. Closes coder/internal#760
1 parent a1b87a6 commit 183a6eb

File tree

10 files changed

+1155
-73
lines changed

10 files changed

+1155
-73
lines changed

coderd/apidoc/docs.go

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codersdk/deployment.go

Lines changed: 126 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{}, len(FeatureNames))
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,13 +209,17 @@ 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
189225
}
@@ -196,6 +232,29 @@ type Feature struct {
196232
Enabled bool `json:"enabled"`
197233
Limit *int64 `json:"limit,omitempty"`
198234
Actual *int64 `json:"actual,omitempty"`
235+
236+
// Below is only for features that use usage periods.
237+
238+
// SoftLimit is the soft limit of the feature, and is only used for showing
239+
// included limits in the dashboard. No license validation or warnings are
240+
// generated from this value.
241+
SoftLimit *int64 `json:"soft_limit,omitempty"`
242+
// UsagePeriod denotes that the usage is a counter that accumulates over
243+
// this period (and most likely resets with the issuance of the next
244+
// license).
245+
//
246+
// These dates are determined from the license that this entitlement comes
247+
// from, see enterprise/coderd/license/license.go.
248+
//
249+
// Only certain features set these fields:
250+
// - FeatureManagedAgentLimit
251+
UsagePeriod *UsagePeriod `json:"usage_period,omitempty"`
252+
}
253+
254+
type UsagePeriod struct {
255+
IssuedAt time.Time `json:"issued_at" format:"date-time"`
256+
Start time.Time `json:"start" format:"date-time"`
257+
End time.Time `json:"end" format:"date-time"`
199258
}
200259

201260
// Compare compares two features and returns an integer representing
@@ -204,13 +263,30 @@ type Feature struct {
204263
// than the second feature. It is assumed the features are for the same FeatureName.
205264
//
206265
// A feature is considered greater than another feature if:
207-
// 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+
// 1. The usage period has a greater issued at date (note: only certain features use usage periods)
267+
// 2. The usage period has a greater end date (note: only certain features use usage periods)
268+
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
269+
// 4. The entitlement is greater
270+
// 5. The limit is greater
271+
// 6. Enabled is greater than disabled
272+
// 7. The actual is greater
212273
func (f Feature) Compare(b Feature) int {
213-
if !f.Capable() || !b.Capable() {
274+
// For features with usage period constraints only, check the issued at and
275+
// end dates.
276+
bothHaveUsagePeriod := f.UsagePeriod != nil && b.UsagePeriod != nil
277+
if bothHaveUsagePeriod {
278+
issuedAtCmp := f.UsagePeriod.IssuedAt.Compare(b.UsagePeriod.IssuedAt)
279+
if issuedAtCmp != 0 {
280+
return issuedAtCmp
281+
}
282+
endCmp := f.UsagePeriod.End.Compare(b.UsagePeriod.End)
283+
if endCmp != 0 {
284+
return endCmp
285+
}
286+
}
287+
288+
// Only perform capability comparisons if both features have actual values.
289+
if f.Actual != nil && b.Actual != nil && (!f.Capable() || !b.Capable()) {
214290
// If either is incapable, then it is possible a grace period
215291
// feature can be "greater" than an entitled.
216292
// If either is "NotEntitled" then we can defer to a strict entitlement
@@ -225,7 +301,9 @@ func (f Feature) Compare(b Feature) int {
225301
}
226302
}
227303

228-
// Strict entitlement check. Higher is better
304+
// Strict entitlement check. Higher is better. We don't apply this check for
305+
// usage period features as we always want the issued at date to be the main
306+
// decision maker.
229307
entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight()
230308
if entitlementDifference != 0 {
231309
return entitlementDifference
@@ -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,16 @@ 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.UsagePeriod == nil || add.UsagePeriod.IssuedAt.IsZero() || add.UsagePeriod.Start.IsZero() || add.UsagePeriod.End.IsZero() {
398+
return
399+
}
400+
} else {
401+
add.UsagePeriod = nil
402+
}
403+
309404
// Compare the features, keep the one that is "better"
310405
comparison := add.Compare(existing)
311406
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.

docs/reference/api/enterprise.md

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)