Skip to content

Commit 26f7b66

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 cae444b commit 26f7b66

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
@@ -44,9 +44,9 @@ func entitlementWeight(e Entitlement) int {
4444
case EntitlementGracePeriod:
4545
return 1
4646
case EntitlementNotEntitled:
47-
return 0
48-
default:
4947
return -1
48+
default:
49+
return -2
5050
}
5151
}
5252

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

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

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

239297
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)