Skip to content

Commit acad6da

Browse files
fix: refresh entitlements after license create/delete (#306)
## Problem When deploying a `coderd_workspace_proxy` (or other entitlement-gated resource) immediately after `coderd_license` in the same `terraform apply`, the proxy creation fails with: ``` Error: Feature not enabled Your license is not entitled to create workspace proxies. ``` This happens because the provider fetches entitlements once during `Configure()` (before any resources are created) and never refreshes them. After `coderd_license` adds the license to the server, subsequent resources still see the stale pre-license feature flags. ## Fix After `LicenseResource.Create()` and `.Delete()` succeed, re-fetch deployment entitlements and update the shared `CoderdProviderData.Features` map. Since all resources share the same pointer, they immediately see up-to-date entitlements. The refresh is best-effort: if re-fetching entitlements fails, a warning is emitted but the license operation itself still succeeds. ## Regression test Adds `TestAccWorkspaceProxyResourceAfterLicenseInSameApply` which starts an unlicensed Coder instance and applies a config that creates both `coderd_license` and `coderd_workspace_proxy` (with `depends_on`) in a single step — the exact scenario from the bug report. Closes #303
1 parent 0f783c2 commit acad6da

7 files changed

Lines changed: 128 additions & 7 deletions

internal/provider/group_data_source.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest,
164164
return
165165
}
166166

167-
resp.Diagnostics.Append(CheckGroupEntitlements(ctx, d.data.Features)...)
167+
resp.Diagnostics.Append(CheckGroupEntitlements(ctx, d.data.Features())...)
168168
if resp.Diagnostics.HasError() {
169169
return
170170
}

internal/provider/group_resource.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest,
150150
return
151151
}
152152

153-
resp.Diagnostics.Append(CheckGroupEntitlements(ctx, r.data.Features)...)
153+
resp.Diagnostics.Append(CheckGroupEntitlements(ctx, r.data.Features())...)
154154
if resp.Diagnostics.HasError() {
155155
return
156156
}

internal/provider/license_resource.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ func (r *LicenseResource) Create(ctx context.Context, req resource.CreateRequest
120120
}
121121
data.ExpiresAt = types.Int64Value(expiresAt.Unix())
122122

123+
entitlements, err := client.Entitlements(ctx)
124+
if err != nil {
125+
resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to refresh deployment entitlements after adding license, got error: %s", err))
126+
} else {
127+
r.data.SetFeatures(entitlements.Features)
128+
}
129+
123130
// Save data into Terraform state
124131
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
125132
}
@@ -195,4 +202,11 @@ func (r *LicenseResource) Delete(ctx context.Context, req resource.DeleteRequest
195202
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete license, got error: %s", err))
196203
return
197204
}
205+
206+
entitlements, err := client.Entitlements(ctx)
207+
if err != nil {
208+
resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to refresh deployment entitlements after deleting license, got error: %s", err))
209+
} else {
210+
r.data.SetFeatures(entitlements.Features)
211+
}
198212
}

internal/provider/provider.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/url"
77
"os"
88
"strings"
9+
"sync/atomic"
910

1011
"cdr.dev/slog/v3"
1112
"github.com/google/uuid"
@@ -32,10 +33,46 @@ type CoderdProvider struct {
3233
version string
3334
}
3435

36+
// featureSnapshot is an immutable container for the feature map,
37+
// used with atomic.Pointer for lock-free concurrent access.
38+
type featureSnapshot struct {
39+
features map[codersdk.FeatureName]codersdk.Feature
40+
}
41+
3542
type CoderdProviderData struct {
3643
Client *codersdk.Client
3744
DefaultOrganizationID uuid.UUID
38-
Features map[codersdk.FeatureName]codersdk.Feature
45+
features atomic.Pointer[featureSnapshot]
46+
}
47+
48+
// SetFeatures atomically replaces the cached feature entitlements.
49+
// The input map is copied so callers may continue to mutate it safely.
50+
func (d *CoderdProviderData) SetFeatures(in map[codersdk.FeatureName]codersdk.Feature) {
51+
copied := make(map[codersdk.FeatureName]codersdk.Feature, len(in))
52+
for k, v := range in {
53+
copied[k] = v
54+
}
55+
d.features.Store(&featureSnapshot{features: copied})
56+
}
57+
58+
// Features returns the current feature entitlements snapshot.
59+
// Callers must not mutate the returned map.
60+
func (d *CoderdProviderData) Features() map[codersdk.FeatureName]codersdk.Feature {
61+
snap := d.features.Load()
62+
if snap == nil {
63+
return nil
64+
}
65+
return snap.features
66+
}
67+
68+
// FeatureEnabled reports whether the named feature is enabled in the
69+
// current entitlements snapshot.
70+
func (d *CoderdProviderData) FeatureEnabled(name codersdk.FeatureName) bool {
71+
feats := d.Features()
72+
if feats == nil {
73+
return false
74+
}
75+
return feats[name].Enabled
3976
}
4077

4178
// CoderdProviderModel describes the provider data model.
@@ -135,8 +172,8 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
135172
providerData := &CoderdProviderData{
136173
Client: client,
137174
DefaultOrganizationID: data.DefaultOrganizationID.ValueUUID(),
138-
Features: entitlements.Features,
139175
}
176+
providerData.SetFeatures(entitlements.Features)
140177
resp.DataSourceData = providerData
141178
resp.ResourceData = providerData
142179
}

internal/provider/template_resource.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
523523
data.DisplayName = data.Name
524524
}
525525

526-
resp.Diagnostics.Append(data.CheckEntitlements(ctx, r.data.Features)...)
526+
resp.Diagnostics.Append(data.CheckEntitlements(ctx, r.data.Features())...)
527527
if resp.Diagnostics.HasError() {
528528
return
529529
}
@@ -741,7 +741,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
741741
newState.DisplayName = newState.Name
742742
}
743743

744-
resp.Diagnostics.Append(newState.CheckEntitlements(ctx, r.data.Features)...)
744+
resp.Diagnostics.Append(newState.CheckEntitlements(ctx, r.data.Features())...)
745745
if resp.Diagnostics.HasError() {
746746
return
747747
}

internal/provider/workspace_proxy_resource.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (r *WorkspaceProxyResource) Create(ctx context.Context, req resource.Create
103103
return
104104
}
105105

106-
if !r.data.Features[codersdk.FeatureWorkspaceProxy].Enabled {
106+
if !r.data.FeatureEnabled(codersdk.FeatureWorkspaceProxy) {
107107
resp.Diagnostics.AddError("Feature not enabled", "Your license is not entitled to create workspace proxies.")
108108
return
109109
}

internal/provider/workspace_proxy_resource_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,76 @@ func TestAccWorkspaceProxyResourceAGPL(t *testing.T) {
8585

8686
}
8787

88+
func TestAccWorkspaceProxyResourceAfterLicenseInSameApply(t *testing.T) {
89+
t.Parallel()
90+
if os.Getenv("TF_ACC") == "" {
91+
t.Skip("Acceptance tests are disabled.")
92+
}
93+
license := os.Getenv("CODER_ENTERPRISE_LICENSE")
94+
if license == "" {
95+
t.Skip("No license found for workspace proxy regression test, skipping")
96+
}
97+
98+
ctx := t.Context()
99+
client := integration.StartCoder(ctx, t, "ws_proxy_after_license_acc")
100+
101+
cfg := testAccWorkspaceProxyAfterLicenseConfig{
102+
URL: client.URL.String(),
103+
Token: client.SessionToken(),
104+
License: license,
105+
}
106+
107+
resource.Test(t, resource.TestCase{
108+
IsUnitTest: true,
109+
PreCheck: func() { testAccPreCheck(t) },
110+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
111+
Steps: []resource.TestStep{
112+
{
113+
Config: cfg.String(t),
114+
Check: resource.ComposeAggregateTestCheckFunc(
115+
resource.TestCheckResourceAttrSet("coderd_workspace_proxy.test", "session_token"),
116+
),
117+
},
118+
},
119+
})
120+
}
121+
122+
type testAccWorkspaceProxyAfterLicenseConfig struct {
123+
URL string
124+
Token string
125+
License string
126+
}
127+
128+
func (c testAccWorkspaceProxyAfterLicenseConfig) String(t *testing.T) string {
129+
t.Helper()
130+
tpl := `
131+
provider coderd {
132+
url = "{{.URL}}"
133+
token = "{{.Token}}"
134+
}
135+
136+
resource "coderd_license" "enterprise" {
137+
license = "{{.License}}"
138+
}
139+
140+
resource "coderd_workspace_proxy" "test" {
141+
depends_on = [coderd_license.enterprise]
142+
name = "example-after-license"
143+
display_name = "Example After License"
144+
icon = "/emojis/1f407.png"
145+
}
146+
`
147+
148+
buf := strings.Builder{}
149+
tmpl, err := template.New("workspaceProxyAfterLicense").Parse(tpl)
150+
require.NoError(t, err)
151+
152+
err = tmpl.Execute(&buf, c)
153+
require.NoError(t, err)
154+
155+
return buf.String()
156+
}
157+
88158
type testAccWorkspaceProxyResourceConfig struct {
89159
URL string
90160
Token string

0 commit comments

Comments
 (0)