Skip to content

Commit 743c442

Browse files
committed
feat: implement premium vs enterprise licenses
Refactor license logic to keep license features atomic (no merging). Refactor logic to be more unit testable.
1 parent 4527037 commit 743c442

File tree

5 files changed

+441
-40
lines changed

5 files changed

+441
-40
lines changed

codersdk/deployment.go

Lines changed: 91 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ func entitlementWeight(e Entitlement) int {
4545
case EntitlementGracePeriod:
4646
return 1
4747
case EntitlementNotEntitled:
48-
return 0
49-
default:
5048
return -1
49+
default:
50+
return -2
5151
}
5252
}
5353

@@ -177,6 +177,91 @@ type Feature struct {
177177
Actual *int64 `json:"actual,omitempty"`
178178
}
179179

180+
// CompareFeatures compares two features and returns an integer representing
181+
// if the first feature is greater than, equal to, or less than the second feature.
182+
// "Greater than" means the first feature has more functionality than the
183+
// second feature. It is assumed the features are for the same FeatureName.
184+
//
185+
// A feature is considered greater than another feature if:
186+
// - Graceful & capable > Entitled & not capable
187+
// - The entitlement is greater
188+
// - The limit is greater
189+
// - Enabled is greater than disabled
190+
// - The actual is greater
191+
func CompareFeatures(a, b Feature) int {
192+
if !a.Capable() || !b.Capable() {
193+
// If either is incapable, then it is possible a grace period
194+
// feature can be "greater" than an entitled.
195+
// If either is "NotEntitled" then we can defer to a strict entitlement
196+
// check.
197+
if entitlementWeight(a.Entitlement) >= 0 && entitlementWeight(b.Entitlement) >= 0 {
198+
if a.Capable() && !b.Capable() {
199+
return 1
200+
}
201+
if b.Capable() && !a.Capable() {
202+
return -1
203+
}
204+
}
205+
}
206+
207+
entitlement := CompareEntitlements(a.Entitlement, b.Entitlement)
208+
if entitlement > 0 {
209+
return 1
210+
}
211+
if entitlement < 0 {
212+
return -1
213+
}
214+
215+
// If the entitlement is the same, then we can compare the limits.
216+
if a.Limit == nil && b.Limit != nil {
217+
return -1
218+
}
219+
if a.Limit != nil && b.Limit == nil {
220+
return 1
221+
}
222+
if a.Limit != nil && b.Limit != nil {
223+
difference := *a.Limit - *b.Limit
224+
if *a.Limit-*b.Limit != 0 {
225+
return int(difference)
226+
}
227+
}
228+
229+
// Enabled is better than disabled.
230+
if a.Enabled && !b.Enabled {
231+
return 1
232+
}
233+
if !a.Enabled && b.Enabled {
234+
return -1
235+
}
236+
237+
// Higher actual is better
238+
if a.Actual == nil && b.Actual != nil {
239+
return -1
240+
}
241+
if a.Actual != nil && b.Actual == nil {
242+
return 1
243+
}
244+
if a.Actual != nil && b.Actual != nil {
245+
difference := *a.Actual - *b.Actual
246+
if *a.Actual-*b.Actual != 0 {
247+
return int(difference)
248+
}
249+
}
250+
251+
return 0
252+
}
253+
254+
// Capable is a helper function that returns if a given feature has a limit
255+
// that is greater than or equal to the actual.
256+
// If this condition is not true, then the feature is not capable of being used
257+
// since the limit is not high enough.
258+
func (f Feature) Capable() bool {
259+
if f.Limit != nil && f.Actual != nil {
260+
return *f.Limit >= *f.Actual
261+
}
262+
return true
263+
}
264+
180265
type Entitlements struct {
181266
Features map[FeatureName]Feature `json:"features"`
182267
Warnings []string `json:"warnings"`
@@ -193,48 +278,21 @@ type Entitlements struct {
193278
//
194279
// All features should be added as atomic items, and not merged in any way.
195280
// Merging entitlements could lead to unexpected behavior, like a larger user
196-
// limit in grace period merging with a smaller one in a grace period. This could
197-
// lead to the larger limit being extended as "entitled", which is not correct.
281+
// limit in grace period merging with a smaller one in an "entitled" state. This
282+
// could lead to the larger limit being extended as "entitled", which is not correct.
198283
func (e *Entitlements) AddFeature(name FeatureName, add Feature) {
199284
existing, ok := e.Features[name]
200285
if !ok {
201286
e.Features[name] = add
202287
return
203288
}
204289

205-
comparison := CompareEntitlements(add.Entitlement, existing.Entitlement)
206-
// If the new entitlement is greater than the existing entitlement, replace it.
207-
// The edge case is if the previous entitlement is in a grace period with a
208-
// higher value.
209-
// TODO: Address the edge case.
290+
// Compare the features, keep the one that is "better"
291+
comparison := CompareFeatures(add, existing)
210292
if comparison > 0 {
211293
e.Features[name] = add
212294
return
213295
}
214-
215-
// If they have the same entitlement, then we can compare the limits.
216-
if comparison == 0 {
217-
if add.Limit != nil {
218-
if existing.Limit == nil || *add.Limit > *existing.Limit {
219-
e.Features[name] = add
220-
return
221-
}
222-
}
223-
224-
// Enabled is better than disabled.
225-
if add.Enabled && !existing.Enabled {
226-
e.Features[name] = add
227-
return
228-
}
229-
230-
// If the actual value is greater than the existing actual value, replace it.
231-
if add.Actual != nil {
232-
if existing.Actual == nil || *add.Actual > *existing.Actual {
233-
e.Features[name] = add
234-
return
235-
}
236-
}
237-
}
238296
}
239297

240298
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {

enterprise/coderd/coderdenttest/coderdenttest.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,55 @@ func NewWithAPI(t *testing.T, options *Options) (
146146
return client, provisionerCloser, coderAPI, user
147147
}
148148

149+
// LicenseOptions is used to generate a license for testing.
150+
// It supports the builder pattern for easy customization.
149151
type LicenseOptions struct {
150152
AccountType string
151153
AccountID string
152154
DeploymentIDs []string
153155
Trial bool
156+
FeatureSet codersdk.FeatureSet
154157
AllFeatures bool
155-
GraceAt time.Time
156-
ExpiresAt time.Time
157-
Features license.Features
158+
// GraceAt is the time at which the license will enter the grace period.
159+
GraceAt time.Time
160+
// ExpiresAt is the time at which the license will hard expire.
161+
// ExpiresAt should always be greater then GraceAt.
162+
ExpiresAt time.Time
163+
Features license.Features
164+
}
165+
166+
func (opts *LicenseOptions) Expired(now time.Time) *LicenseOptions {
167+
opts.ExpiresAt = now.Add(time.Hour * 24 * -2)
168+
opts.GraceAt = now.Add(time.Hour * 24 * -3)
169+
return opts
170+
}
171+
172+
func (opts *LicenseOptions) GracePeriod(now time.Time) *LicenseOptions {
173+
opts.ExpiresAt = now.Add(time.Hour * 24)
174+
opts.GraceAt = now.Add(time.Hour * 24 * -1)
175+
return opts
176+
}
177+
178+
func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
179+
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
180+
opts.GraceAt = now.Add(time.Hour * 24 * 53)
181+
return opts
182+
}
183+
184+
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
185+
return opts.Feature(codersdk.FeatureUserLimit, limit)
186+
}
187+
188+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
189+
if opts.Features == nil {
190+
opts.Features = license.Features{}
191+
}
192+
opts.Features[name] = value
193+
return opts
194+
}
195+
196+
func (opts *LicenseOptions) Generate(t *testing.T) string {
197+
return GenerateLicense(t, *opts)
158198
}
159199

160200
// AddFullLicense generates a license with all features enabled.
@@ -195,6 +235,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
195235
Trial: options.Trial,
196236
Version: license.CurrentVersion,
197237
AllFeatures: options.AllFeatures,
238+
FeatureSet: options.FeatureSet,
198239
Features: options.Features,
199240
}
200241
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

enterprise/coderd/license/license.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ func LicensesEntitlements(
135135

136136
// Add all features from the feature set defined.
137137
for _, featureName := range claims.FeatureSet.Features() {
138+
if featureName == codersdk.FeatureUserLimit {
139+
// FeatureUserLimit is unique in that it must be specifically defined
140+
// in the license. There is no default meaning if no "limit" is set.
141+
continue
142+
}
138143
entitlements.AddFeature(featureName, codersdk.Feature{
139144
Entitlement: entitlement,
140145
Enabled: enablements[featureName] || featureName.AlwaysEnable(),
@@ -212,11 +217,15 @@ func LicensesEntitlements(
212217
}
213218

214219
if entitlements.HasLicense {
215-
userLimit := entitlements.Features[codersdk.FeatureUserLimit].Limit
216-
if userLimit != nil && featureArguments.ActiveUserCount > *userLimit {
220+
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
221+
if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit {
217222
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
218223
"Your deployment has %d active users but is only licensed for %d.",
219-
featureArguments.ActiveUserCount, *userLimit))
224+
featureArguments.ActiveUserCount, *userLimit.Limit))
225+
} else if userLimit.Limit != nil && userLimit.Entitlement == codersdk.EntitlementGracePeriod {
226+
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
227+
"Your deployment has %d active users but the license with the limit %d is expired.",
228+
featureArguments.ActiveUserCount, *userLimit.Limit))
220229
}
221230

222231
// Add a warning for every feature that is enabled but not entitled or
@@ -298,7 +307,7 @@ type Claims struct {
298307
FeatureSet codersdk.FeatureSet `json:"feature_set"`
299308
// AllFeatures represents 'FeatureSet = FeatureSetEnterprise'
300309
// Deprecated: AllFeatures is deprecated in favor of FeatureSet.
301-
AllFeatures bool `json:"all_features"`
310+
AllFeatures bool `json:"all_features,omitempty"`
302311
Version uint64 `json:"version"`
303312
Features Features `json:"features"`
304313
RequireTelemetry bool `json:"require_telemetry,omitempty"`

0 commit comments

Comments
 (0)