Skip to content

Commit 453dd1d

Browse files
authored
🤖 feat: surface entitlements and gate provisioner reconciliation (#67)
## Summary Surface license tier and entitlements on `CoderControlPlane.status`, and gate `CoderProvisioner` reconciliation on the `external_provisioner_daemons` entitlement so unlicensed setups short-circuit cleanly. ## Background External provisioner daemons are license-gated in Coder. Before this change, `CoderProvisioner` reconciliation could attempt key/deployment work even when the deployment was not entitled, causing noisy failures and wasted reconcile work. ## Implementation - Added control-plane status fields for: - `licenseTier` - `entitlementsLastChecked` - `externalProvisionerDaemonsEntitlement` - Added control-plane entitlement reconciliation via `/api/v2/entitlements` using operator credentials, with best-effort tier derivation. - Added `CoderProvisionerConditionExternalProvisionersEntitled` and enforced an entitlement gate in provisioner reconciliation: - fast-path from control-plane status - fallback API query via bootstrap client when status is unavailable - Extended bootstrap client interface/SDK implementation with `Entitlements(ctx, coderURL, sessionToken)`. - Added a `CoderControlPlane` watch + `spec.controlPlaneRef.name` index in the provisioner controller for quicker entitlement re-evaluation after control plane status changes. - Updated API docs and regenerated code/manifests for status schema updates. ## Validation - `make codegen` - `make manifests` - `make docs-reference` - `make verify-vendor` - `make test` - `make build` - `make lint` - `make docs-build` ## Risks - Moderate: reconciliation flow changed in both control-plane and provisioner controllers. - Main risk is false-negative entitlement gating if entitlement APIs are unavailable; mitigated by explicit condition reasons, retries, and fallback query behavior. --- <details> <summary>📋 Implementation Plan</summary> # Plan: Surface license tier/entitlements on `CoderControlPlane` and gate `CoderProvisioner` ## Context / Why External provisioner daemons are a paid Coder feature. Today, `CoderProvisionerReconciler` will attempt to create provisioner keys and deploy `provisionerd` pods even when the target Coder deployment is not entitled, leading to confusing failures and wasted reconciliation work. We recently merged license reconciliation into the `CoderControlPlane` controller (it already tracks when a license was last applied). We can extend this to also surface **license tier/type** and the **external provisioner entitlement** on `CoderControlPlane.status`, then use that as a fast path for provisioner reconciliation. Goals: - `CoderControlPlane`: publish `status.licenseTier` (best-effort) and `status.externalProvisionerDaemonsEntitlement` by querying `/api/v2/entitlements` with the operator token. - `CoderProvisioner`: short-circuit early when the control plane reports `external_provisioner_daemons` is **not entitled**, and re-evaluate automatically once it becomes entitled. - Keep a fallback path where the provisioner controller queries entitlements directly if the control plane hasn’t published them yet (or operator access is disabled). Non-goals (initially): deleting already-created provisioner Deployments/keys when a license is removed; we can decide policy later. ## Evidence (repo + upstream SDK) - `CoderControlPlane` already has license reconciliation support and records: - `status.licenseLastApplied` and `status.licenseLastAppliedHash` (`api/v1alpha1/codercontrolplane_types.go`). - `reconcileLicense(...)` uploads license via `codersdk.AddLicense` and sets `LicenseApplied` condition (`internal/controller/codercontrolplane_controller.go`). - `CoderProvisionerReconciler` currently: - Fetches `CoderControlPlane`, reads bootstrap session token, then calls `BootstrapClient.EnsureProvisionerKey(...)` and creates Secret/RBAC/Deployment (`internal/controller/coderprovisioner_controller.go`). - Does **not** check entitlements today. - Vendored `codersdk` exposes entitlements: - `Client.Entitlements(ctx) (codersdk.Entitlements, error)`. - Feature constant `codersdk.FeatureExternalProvisionerDaemons = "external_provisioner_daemons"`. - `Entitlement.Entitled()` is true for `entitled` and `grace_period`. - Coder docs confirm Premium-only features that we can use as tier signals: - **Custom Roles** are Premium (`docs/admin/users/groups-roles.md` → maps to `codersdk.FeatureCustomRoles`). - **Multiple Organizations** are Premium (`docs/admin/users/organizations.md` → maps to `codersdk.FeatureMultipleOrganizations`). ## Implementation details ### 1) Surface license tier/type + provisioner entitlement on `CoderControlPlane.status` (fast path) **Files:** - `api/v1alpha1/codercontrolplane_types.go` - `internal/controller/codercontrolplane_controller.go` - `internal/app/controllerapp/controllerapp.go` - `internal/controller/codercontrolplane_controller_test.go` **API additions (status fields)** Add new, user-visible status fields so other controllers can make fast decisions without extra Coder API calls: ```go type CoderControlPlaneStatus struct { ... // LicenseTier is a best-effort classification of the currently-applied license. // Values: none, trial, enterprise, premium, unknown. // +optional LicenseTier string `json:"licenseTier,omitempty"` // EntitlementsLastChecked is when the operator last queried /api/v2/entitlements. // +optional EntitlementsLastChecked *metav1.Time `json:"entitlementsLastChecked,omitempty"` // ExternalProvisionerDaemonsEntitlement is the entitlement value for // feature "external_provisioner_daemons". // Values: entitled, grace_period, not_entitled, unknown. // +optional ExternalProvisionerDaemonsEntitlement string `json:"externalProvisionerDaemonsEntitlement,omitempty"` } ``` **Controller logic** - Introduce an `EntitlementsInspector` (or similar) interface on `CoderControlPlaneReconciler` with an SDK-backed implementation using `codersdk.Client.Entitlements`. - Wire the SDK-backed implementation in `controllerapp.SetupControllers` so production always reports these fields. - Add a `reconcileEntitlements(...)` step (called after `reconcileLicense(...)` so the values reflect the most recently applied license) that: - Runs even when `spec.licenseSecretRef` is nil (so status reflects licenses applied out-of-band). - Requires: `nextStatus.Phase == Ready`, `nextStatus.OperatorAccessReady`, `nextStatus.OperatorTokenSecretRef != nil`, and `nextStatus.URL != ""`. - Reads the operator token Secret and calls `/api/v2/entitlements`. - Stamps `EntitlementsLastChecked=now`, `ExternalProvisionerDaemonsEntitlement`, and `LicenseTier`. Tier derivation should be explicit and documented. A practical best-effort heuristic is: ```go func licenseTierFromEntitlements(ent codersdk.Entitlements) string { if !ent.HasLicense { return "none" } if ent.Trial { return "trial" } // Premium-only signals per docs: custom roles + multiple organizations. for _, f := range []codersdk.FeatureName{codersdk.FeatureCustomRoles, codersdk.FeatureMultipleOrganizations} { if feat, ok := ent.Features[f]; ok && feat.Entitlement.Entitled() { return "premium" } } return "enterprise" } ``` Error handling guidance: - Treat transient API failures as **Unknown** (don’t hard-block forever); keep last-known values when available. - Requeue after `operatorAccessRetryInterval` when entitlements cannot be queried, so the status eventually updates when a license is installed later. > Note: adding status fields requires regenerating deepcopy + CRDs (see Validation section). ### 2) Add a provisioner status condition for entitlement **File:** `api/v1alpha1/coderprovisioner_types.go` Add a new condition constant (name bikesheddable; keep it explicit): ```go const ( ... // CoderProvisionerConditionExternalProvisionersEntitled indicates whether the // referenced Coder deployment is entitled to run external provisioner daemons. CoderProvisionerConditionExternalProvisionersEntitled = "ExternalProvisionersEntitled" ) ``` Rationale: keeps licensing failures distinct from bootstrap secret / key failures. ### 3) Add an entitlements fetch helper to `internal/coderbootstrap` **Files:** - `internal/coderbootstrap/client.go` (interface + SDK implementation) - `internal/controller/*_test.go` fakes implementing the interface Extend `coderbootstrap.Client` with an entitlements method: ```go type Client interface { EnsureWorkspaceProxy(context.Context, RegisterWorkspaceProxyRequest) (RegisterWorkspaceProxyResponse, error) EnsureProvisionerKey(context.Context, EnsureProvisionerKeyRequest) (EnsureProvisionerKeyResponse, error) DeleteProvisionerKey(ctx context.Context, coderURL, sessionToken, orgName, keyName string) error // Entitlements returns the deployment entitlements from /api/v2/entitlements. Entitlements(ctx context.Context, coderURL, sessionToken string) (codersdk.Entitlements, error) } ``` Implement on `SDKClient` using the existing authenticated-client helper (reusing timeout + optional rate-limit bypass): ```go func (c *SDKClient) Entitlements(ctx context.Context, coderURL, sessionToken string) (codersdk.Entitlements, error) { if coderURL == "" { return codersdk.Entitlements{}, xerrors.New("coder URL is required") } if sessionToken == "" { return codersdk.Entitlements{}, xerrors.New("session token is required") } client, err := newAuthenticatedClient(coderURL, sessionToken) if err != nil { return codersdk.Entitlements{}, err } ent, err := withOptionalRateLimitBypass(ctx, func(requestCtx context.Context) (codersdk.Entitlements, error) { return client.Entitlements(requestCtx) }) if err != nil { return codersdk.Entitlements{}, xerrors.Errorf("get entitlements: %w", err) } if ent.Features == nil { // Fail-fast: the API contract should always include a features map. return codersdk.Entitlements{}, xerrors.New("assertion failed: entitlements.features is nil") } return ent, nil } ``` Why do this instead of direct `codersdk.New(...)` in the reconciler? - Keeps all Coder-API concerns (timeouts, bypass ratelimit header) in one place. - Makes unit tests easy by stubbing `BootstrapClient.Entitlements`. ### 4) Short-circuit in `CoderProvisionerReconciler.Reconcile` **File:** `internal/controller/coderprovisioner_controller.go` Add a new entitlement gate immediately after we have: - `controlPlane.Status.URL` (already required), and - `sessionToken` (bootstrap credentials). Fast path: - If `controlPlane.status.entitlementsLastChecked` is set and `controlPlane.status.externalProvisionerDaemonsEntitlement` is present (and optionally not stale), use it to decide without calling the Coder API. Fallback: - Otherwise call `r.BootstrapClient.Entitlements(...)` and evaluate `external_provisioner_daemons` directly. Pseudo-flow: ```go // 1) Fast path: trust ControlPlane status if present. if controlPlane.Status.EntitlementsLastChecked != nil && controlPlane.Status.ExternalProvisionerDaemonsEntitlement != "" { switch controlPlane.Status.ExternalProvisionerDaemonsEntitlement { case "entitled", "grace_period": setCondition(provisioner, coderv1alpha1.CoderProvisionerConditionExternalProvisionersEntitled, metav1.ConditionTrue, "Entitled", "Coder deployment is entitled to external provisioner daemons") // Proceed. case "not_entitled": setCondition(provisioner, coderv1alpha1.CoderProvisionerConditionExternalProvisionersEntitled, metav1.ConditionFalse, "NotEntitled", "Coder deployment is not entitled to external provisioner daemons") _ = r.Status().Update(ctx, provisioner) return ctrl.Result{RequeueAfter: 2 * time.Minute}, nil default: // Unknown → fall through to API check. } } // 2) Fallback: query coderd. ent, err := r.BootstrapClient.Entitlements(ctx, controlPlane.Status.URL, sessionToken) if err != nil { // Set ExternalProvisionersEntitled=False, Reason=EntitlementsQueryFailed and retry. } feature, ok := ent.Features[codersdk.FeatureExternalProvisionerDaemons] if !ok || !feature.Entitlement.Entitled() { // Not entitled → short-circuit with RequeueAfter. } setCondition(... True, "Entitled", "Coder deployment is entitled to external provisioner daemons") ``` Recommended condition reasons/messages: - **NotEntitled**: “Coder deployment is not entitled to external provisioner daemons; install a Premium/Enterprise license to enable external provisioners.” - **EntitlementsQueryFailed**: “Failed to query Coder entitlements; retrying.” - **NotSupported** (HTTP 404): “Coder deployment does not expose /api/v2/entitlements; cannot verify license.” (treat as blocked, requeue) - **Forbidden** (HTTP 401/403): “Bootstrap token is not authorized to read entitlements; retrying.” Re-evaluation requirement: - Use `RequeueAfter` when not entitled so the controller will automatically re-check. - (Optional) Add jitter to the requeue interval to avoid synchronized thundering-herd. Placement note: - Keep the check **out of the deletion path** so finalizer cleanup continues to be best-effort even in unlicensed states. ### 5) (Optional but recommended) Watch `CoderControlPlane` changes to reconcile faster **File:** `internal/controller/coderprovisioner_controller.go` (`SetupWithManager`) Add a watch on `CoderControlPlane` that enqueues `CoderProvisioner` objects in the same namespace whose `spec.controlPlaneRef.name` matches the updated control plane. This reduces time-to-recover after a license install from “up to RequeueAfter” to “immediate”. Implementation sketch: - Add an index field on `CoderProvisioner.spec.controlPlaneRef.name`. - In `SetupWithManager`, add `Watches(&coderv1alpha1.CoderControlPlane{}, handler.EnqueueRequestsFromMapFunc(...))`. (If we do this, keep the periodic `RequeueAfter` anyway; it’s the safety net.) ## Tests ### Unit/envtest: `CoderControlPlane` surfaces license tier + entitlements **File:** `internal/controller/codercontrolplane_controller_test.go` Add coverage for the new status fields: - Use a fake `EntitlementsInspector` (or whatever interface we introduce) that returns a controlled `codersdk.Entitlements` payload. - Assert `CoderControlPlane.status` is populated after reconcile when the control plane is Ready and operator access is available: - `entitlementsLastChecked` is set. - `externalProvisionerDaemonsEntitlement` matches the entitlements payload. - `licenseTier` is derived correctly (e.g., `none` when `has_license=false`, `trial` when `trial=true`, `premium` when `custom_roles` or `multiple_organizations` are entitled, else `enterprise`). ### Unit/envtest: `CoderProvisioner` short-circuit behavior **File:** `internal/controller/coderprovisioner_controller_test.go` Extend the shared `fakeBootstrapClient` (currently in `coderworkspaceproxy_controller_test.go`) to implement: ```go Entitlements(ctx context.Context, coderURL, sessionToken string) (codersdk.Entitlements, error) ``` Add test cases: 1. **Not entitled (fast path)** - Set `controlPlane.status.entitlementsLastChecked` and `controlPlane.status.externalProvisionerDaemonsEntitlement=not_entitled`. - Assert: - `BootstrapClient.Entitlements` is **not** called. - `EnsureProvisionerKey` is **not** called. - No key Secret / Deployment is created. - Condition `ExternalProvisionersEntitled=False`. - Reconcile returns `RequeueAfter > 0`. 2. **Not entitled (fallback API)** - Leave the control plane entitlement fields unset/unknown. - Fake entitlements returns `features[external_provisioner_daemons].entitlement=not_entitled`. - Assert the same short-circuit behavior. 3. **Entitled** - Either set control plane status to `entitled/grace_period` or have the fake entitlements return `entitled`. - Assert existing behavior unchanged (key created, resources created) and condition `ExternalProvisionersEntitled=True`. 4. **Entitlements API forbidden / error** - Fake entitlements returns a `codersdk.Error` with 401/403 (or generic error). - Assert condition is set to False with `Forbidden`/`EntitlementsQueryFailed` and the reconcile retries. 5. (If we implement the optional watch) verify indexer + mapping function enqueues provisioners. ## Validation / Rollout - Run: `make test`, `make build`, `make lint`. - Because API types/CRDs change (new `CoderControlPlaneStatus` fields), run `make codegen` and `make manifests`, and commit generated changes. </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: $1.23_ <!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=1.23 -->
1 parent 939c739 commit 453dd1d

14 files changed

Lines changed: 958 additions & 2 deletions

api/v1alpha1/codercontrolplane_types.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ const (
1212
CoderControlPlanePhaseReady = "Ready"
1313
// CoderControlPlaneConditionLicenseApplied indicates whether the operator uploaded the configured license.
1414
CoderControlPlaneConditionLicenseApplied = "LicenseApplied"
15+
16+
// CoderControlPlaneLicenseTierNone indicates no license is currently installed.
17+
CoderControlPlaneLicenseTierNone = "none"
18+
// CoderControlPlaneLicenseTierTrial indicates a trial license is currently installed.
19+
CoderControlPlaneLicenseTierTrial = "trial"
20+
// CoderControlPlaneLicenseTierEnterprise indicates an enterprise license is currently installed.
21+
CoderControlPlaneLicenseTierEnterprise = "enterprise"
22+
// CoderControlPlaneLicenseTierPremium indicates a premium license is currently installed.
23+
CoderControlPlaneLicenseTierPremium = "premium"
24+
// CoderControlPlaneLicenseTierUnknown indicates the controller could not determine the current license tier.
25+
CoderControlPlaneLicenseTierUnknown = "unknown"
26+
27+
// CoderControlPlaneEntitlementUnknown indicates the controller could not determine a feature entitlement.
28+
CoderControlPlaneEntitlementUnknown = "unknown"
1529
)
1630

1731
// CoderControlPlaneSpec defines the desired state of a CoderControlPlane.
@@ -71,6 +85,18 @@ type CoderControlPlaneStatus struct {
7185
// that LicenseLastApplied refers to.
7286
// +optional
7387
LicenseLastAppliedHash string `json:"licenseLastAppliedHash,omitempty"`
88+
// LicenseTier is a best-effort classification of the currently applied license.
89+
// Values: none, trial, enterprise, premium, unknown.
90+
// +optional
91+
LicenseTier string `json:"licenseTier,omitempty"`
92+
// EntitlementsLastChecked is when the operator last queried coderd entitlements.
93+
// +optional
94+
EntitlementsLastChecked *metav1.Time `json:"entitlementsLastChecked,omitempty"`
95+
// ExternalProvisionerDaemonsEntitlement is the entitlement value for feature
96+
// "external_provisioner_daemons".
97+
// Values: entitled, grace_period, not_entitled, unknown.
98+
// +optional
99+
ExternalProvisionerDaemonsEntitlement string `json:"externalProvisionerDaemonsEntitlement,omitempty"`
74100
// Phase is a high-level readiness indicator.
75101
Phase string `json:"phase,omitempty"`
76102
// Conditions are Kubernetes-standard conditions for this resource.

api/v1alpha1/coderprovisioner_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const (
1919
CoderProvisionerConditionProvisionerKeyReady = "ProvisionerKeyReady"
2020
// CoderProvisionerConditionProvisionerKeySecretReady indicates whether the provisioner key secret is populated.
2121
CoderProvisionerConditionProvisionerKeySecretReady = "ProvisionerKeySecretReady"
22+
// CoderProvisionerConditionExternalProvisionersEntitled indicates whether the
23+
// referenced Coder deployment is entitled to run external provisioner daemons.
24+
CoderProvisionerConditionExternalProvisionersEntitled = "ExternalProvisionersEntitled"
2225
// CoderProvisionerConditionDeploymentReady indicates whether the provisioner deployment has ready replicas.
2326
CoderProvisionerConditionDeploymentReady = "DeploymentReady"
2427

api/v1alpha1/zz_generated.deepcopy.go

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

config/crd/bases/coder.com_codercontrolplanes.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,17 @@ spec:
345345
- type
346346
type: object
347347
type: array
348+
entitlementsLastChecked:
349+
description: EntitlementsLastChecked is when the operator last queried
350+
coderd entitlements.
351+
format: date-time
352+
type: string
353+
externalProvisionerDaemonsEntitlement:
354+
description: |-
355+
ExternalProvisionerDaemonsEntitlement is the entitlement value for feature
356+
"external_provisioner_daemons".
357+
Values: entitled, grace_period, not_entitled, unknown.
358+
type: string
348359
licenseLastApplied:
349360
description: |-
350361
LicenseLastApplied is the timestamp of the most recent successful
@@ -356,6 +367,11 @@ spec:
356367
LicenseLastAppliedHash is the SHA-256 hex hash of the trimmed license JWT
357368
that LicenseLastApplied refers to.
358369
type: string
370+
licenseTier:
371+
description: |-
372+
LicenseTier is a best-effort classification of the currently applied license.
373+
Values: none, trial, enterprise, premium, unknown.
374+
type: string
359375
observedGeneration:
360376
description: ObservedGeneration tracks the spec generation this status
361377
reflects.

docs/reference/api/codercontrolplane.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
| `operatorAccessReady` | boolean | OperatorAccessReady reports whether operator API access bootstrap succeeded. |
3434
| `licenseLastApplied` | [Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta) | LicenseLastApplied is the timestamp of the most recent successful operator-managed license upload. |
3535
| `licenseLastAppliedHash` | string | LicenseLastAppliedHash is the SHA-256 hex hash of the trimmed license JWT that LicenseLastApplied refers to. |
36+
| `licenseTier` | string | LicenseTier is a best-effort classification of the currently applied license. Values: none, trial, enterprise, premium, unknown. |
37+
| `entitlementsLastChecked` | [Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta) | EntitlementsLastChecked is when the operator last queried coderd entitlements. |
38+
| `externalProvisionerDaemonsEntitlement` | string | ExternalProvisionerDaemonsEntitlement is the entitlement value for feature "external_provisioner_daemons". Values: entitled, grace_period, not_entitled, unknown. |
3639
| `phase` | string | Phase is a high-level readiness indicator. |
3740
| `conditions` | [Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array | Conditions are Kubernetes-standard conditions for this resource. |
3841

internal/app/controllerapp/controllerapp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func SetupControllers(mgr manager.Manager) error {
9292
Scheme: managerScheme,
9393
OperatorAccessProvisioner: coderbootstrap.NewPostgresOperatorAccessProvisioner(),
9494
LicenseUploader: controller.NewSDKLicenseUploader(),
95+
EntitlementsInspector: controller.NewSDKEntitlementsInspector(),
9596
}
9697
if err := reconciler.SetupWithManager(mgr); err != nil {
9798
return fmt.Errorf("unable to create controller: %w", err)

internal/coderbootstrap/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type Client interface {
9595
EnsureWorkspaceProxy(context.Context, RegisterWorkspaceProxyRequest) (RegisterWorkspaceProxyResponse, error)
9696
EnsureProvisionerKey(context.Context, EnsureProvisionerKeyRequest) (EnsureProvisionerKeyResponse, error)
9797
DeleteProvisionerKey(ctx context.Context, coderURL, sessionToken, orgName, keyName string) error
98+
Entitlements(ctx context.Context, coderURL, sessionToken string) (codersdk.Entitlements, error)
9899
}
99100

100101
// SDKClient uses codersdk to perform bootstrap operations.

internal/coderbootstrap/provisionerkeys.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,33 @@ func (c *SDKClient) DeleteProvisionerKey(ctx context.Context, coderURL, sessionT
138138
return xerrors.Errorf("delete provisioner key %q: %w", keyName, err)
139139
}
140140

141+
// Entitlements returns deployment entitlements for the given coderd instance.
142+
func (c *SDKClient) Entitlements(ctx context.Context, coderURL, sessionToken string) (codersdk.Entitlements, error) {
143+
if coderURL == "" {
144+
return codersdk.Entitlements{}, xerrors.New("coder URL is required")
145+
}
146+
if sessionToken == "" {
147+
return codersdk.Entitlements{}, xerrors.New("session token is required")
148+
}
149+
150+
client, err := newAuthenticatedClient(coderURL, sessionToken)
151+
if err != nil {
152+
return codersdk.Entitlements{}, err
153+
}
154+
155+
entitlements, err := withOptionalRateLimitBypass(ctx, func(requestCtx context.Context) (codersdk.Entitlements, error) {
156+
return client.Entitlements(requestCtx)
157+
})
158+
if err != nil {
159+
return codersdk.Entitlements{}, xerrors.Errorf("get entitlements: %w", err)
160+
}
161+
if entitlements.Features == nil {
162+
return codersdk.Entitlements{}, xerrors.New("assertion failed: entitlements.features is nil")
163+
}
164+
165+
return entitlements, nil
166+
}
167+
141168
func validateProvisionerKeyInputs(coderURL, sessionToken, keyName string) error {
142169
if coderURL == "" {
143170
return xerrors.New("coder URL is required")

internal/coderbootstrap/provisionerkeys_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,55 @@ func TestEnsureProvisionerKey_ValidationErrors(t *testing.T) {
315315
}
316316
}
317317

318+
func TestEntitlements_Success(t *testing.T) {
319+
t.Parallel()
320+
321+
requestCount := 0
322+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
323+
require.Equal(t, http.MethodGet, r.Method)
324+
require.Equal(t, "/api/v2/entitlements", r.URL.Path)
325+
requestCount++
326+
writeJSONResponse(t, w, http.StatusOK, map[string]any{
327+
"features": map[string]any{
328+
string(codersdk.FeatureExternalProvisionerDaemons): map[string]any{
329+
"entitlement": string(codersdk.EntitlementEntitled),
330+
"enabled": true,
331+
},
332+
},
333+
"warnings": []string{},
334+
"errors": []string{},
335+
"has_license": true,
336+
"trial": false,
337+
"require_telemetry": false,
338+
"refreshed_at": time.Now().UTC().Format(time.RFC3339),
339+
})
340+
}))
341+
defer server.Close()
342+
343+
client := coderbootstrap.NewSDKClient()
344+
entitlements, err := client.Entitlements(context.Background(), server.URL, "session-token")
345+
require.NoError(t, err)
346+
require.Equal(t, 1, requestCount)
347+
348+
feature, ok := entitlements.Features[codersdk.FeatureExternalProvisionerDaemons]
349+
require.True(t, ok)
350+
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
351+
}
352+
353+
func TestEntitlements_ValidationErrors(t *testing.T) {
354+
t.Parallel()
355+
356+
client := coderbootstrap.NewSDKClient()
357+
358+
_, err := client.Entitlements(context.Background(), "", "session-token")
359+
require.Error(t, err)
360+
require.Contains(t, err.Error(), "coder URL is required")
361+
362+
_, err = client.Entitlements(context.Background(), "https://coder.example.com", "")
363+
require.Error(t, err)
364+
require.Contains(t, err.Error(), "session token is required")
365+
}
366+
318367
func TestDeleteProvisionerKey_Success(t *testing.T) {
319368
t.Parallel()
320369

0 commit comments

Comments
 (0)