Skip to content

Commit f20b393

Browse files
committed
🤖 feat: surface entitlements and gate provisioner reconciliation
Add CoderControlPlane status fields for license tier and external provisioner entitlements, reconcile them from coderd, and gate CoderProvisioner reconciliation when external provisioner daemons are not entitled. Also add a provisioner condition for entitlement state, wire a control-plane watch/index for faster rechecks, and extend bootstrap/tests for entitlements. Follow-ups: - avoid control-plane entitlements status churn by updating `status.entitlementsLastChecked` only when first populated or when derived entitlement/tier values change - avoid no-op status writes on provisioner entitlement requeue paths so the configured backoff is respected in unlicensed environments - when control-plane entitlement state is `not_entitled`, re-check via API once the cached status is stale so license upgrades unblock provisioners --- _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 450fb4e commit f20b393

14 files changed

Lines changed: 898 additions & 1 deletion

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