Files
coder/coderd/entitlements/entitlements.go
Hugo Dutka ac88c9ba17 fix: ensure the web UI doesn't break when license telemetry required check fails (#16667)
Addresses https://github.com/coder/coder/issues/16455.

## Changes

- Initialize default entitlements in a Set to include all features
- Initialize entitlements' `Warnings` and `Errors` fields to arrays
rather than `nil`s.
- Minor changes in formatting on the frontend

## Reasoning

I had to change how entitlements are initialized to match the `codersdk`
[generated
types](33d6261922/site/src/api/typesGenerated.ts (L727)),
which the frontend assumes are correct, and doesn't run additional
checks on.

- `features: Record<FeatureName, Feature>`: this type signifies that
every `FeatureName` is present in the record, but on `main`, that's not
true if there's a telemetry required error
- `warnings: readonly string[];` and `errors: readonly string[];`: these
types mean that the fields are not `null`, but that's not always true

With a valid license, the [`LicensesEntitlements`
function](33d6261922/enterprise/coderd/license/license.go (L92))
ensures that all features are present in the entitlements. It's called
by the [`Entitlements`
function](33d6261922/enterprise/coderd/license/license.go (L42)),
which is called by
[`api.updateEnittlements`](33d6261922/enterprise/coderd/coderd.go (L687)).
However, when a license requires telemetry and telemetry is disabled,
the entitlements with all features [are
discarded](33d6261922/enterprise/coderd/coderd.go (L704))
in an early exit from the same function. By initializing entitlements
with all the features from the get go, we avoid this problem.

## License issue banner after the changes

<img width="1512" alt="Screenshot 2025-02-23 at 20 25 42"
src="https://github.com/user-attachments/assets/ee0134b3-f745-45d9-8333-bfa1661e33d2"
/>
2025-02-24 16:02:33 +01:00

164 lines
4.5 KiB
Go

package entitlements
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
type Set struct {
entitlementsMu sync.RWMutex
entitlements codersdk.Entitlements
// right2Update works like a semaphore. Reading from the chan gives the right to update the set,
// and you send on the chan when you are done. We only allow one simultaneous update, so this
// serve to serialize them. You MUST NOT attempt to read from this channel while holding the
// entitlementsMu lock. It is permissible to acquire the entitlementsMu lock while holding the
// right2Update token.
right2Update chan struct{}
}
func New() *Set {
s := &Set{
// Some defaults for an unlicensed instance.
// These will be updated when coderd is initialized.
entitlements: codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{},
Warnings: []string{},
Errors: []string{},
HasLicense: false,
Trial: false,
RequireTelemetry: false,
RefreshedAt: time.Time{},
},
right2Update: make(chan struct{}, 1),
}
// Ensure all features are present in the entitlements. Our frontend
// expects this.
for _, featureName := range codersdk.FeatureNames {
s.entitlements.AddFeature(featureName, codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: false,
})
}
s.right2Update <- struct{}{} // one token, serialized updates
return s
}
// ErrLicenseRequiresTelemetry is an error returned by a fetch passed to Update to indicate that the
// fetched license cannot be used because it requires telemetry.
var ErrLicenseRequiresTelemetry = xerrors.New(codersdk.LicenseTelemetryRequiredErrorText)
func (l *Set) Update(ctx context.Context, fetch func(context.Context) (codersdk.Entitlements, error)) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-l.right2Update:
defer func() {
l.right2Update <- struct{}{}
}()
}
ents, err := fetch(ctx)
if xerrors.Is(err, ErrLicenseRequiresTelemetry) {
// We can't fail because then the user couldn't remove the offending
// license w/o a restart.
//
// We don't simply append to entitlement.Errors since we don't want any
// enterprise features enabled.
l.Modify(func(entitlements *codersdk.Entitlements) {
entitlements.Errors = []string{err.Error()}
})
return nil
}
if err != nil {
return err
}
l.entitlementsMu.Lock()
defer l.entitlementsMu.Unlock()
l.entitlements = ents
return nil
}
// AllowRefresh returns whether the entitlements are allowed to be refreshed.
// If it returns false, that means it was recently refreshed and the caller should
// wait the returned duration before trying again.
func (l *Set) AllowRefresh(now time.Time) (bool, time.Duration) {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
diff := now.Sub(l.entitlements.RefreshedAt)
if diff < time.Minute {
return false, time.Minute - diff
}
return true, 0
}
func (l *Set) Feature(name codersdk.FeatureName) (codersdk.Feature, bool) {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
f, ok := l.entitlements.Features[name]
return f, ok
}
func (l *Set) Enabled(feature codersdk.FeatureName) bool {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
f, ok := l.entitlements.Features[feature]
if !ok {
return false
}
return f.Enabled
}
// AsJSON is used to return this to the api without exposing the entitlements for
// mutation.
func (l *Set) AsJSON() json.RawMessage {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
b, _ := json.Marshal(l.entitlements)
return b
}
func (l *Set) Modify(do func(entitlements *codersdk.Entitlements)) {
l.entitlementsMu.Lock()
defer l.entitlementsMu.Unlock()
do(&l.entitlements)
}
func (l *Set) FeatureChanged(featureName codersdk.FeatureName, newFeature codersdk.Feature) (initial, changed, enabled bool) {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
oldFeature := l.entitlements.Features[featureName]
if oldFeature.Enabled != newFeature.Enabled {
return false, true, newFeature.Enabled
}
return false, false, newFeature.Enabled
}
func (l *Set) WriteEntitlementWarningHeaders(header http.Header) {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
for _, warning := range l.entitlements.Warnings {
header.Add(codersdk.EntitlementsWarningHeader, warning)
}
}
func (l *Set) Errors() []string {
l.entitlementsMu.RLock()
defer l.entitlementsMu.RUnlock()
return slices.Clone(l.entitlements.Errors)
}