diff --git a/internal/provider/group_data_source.go b/internal/provider/group_data_source.go index 9e3639f..1e94abe 100644 --- a/internal/provider/group_data_source.go +++ b/internal/provider/group_data_source.go @@ -164,7 +164,7 @@ func (d *GroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, return } - resp.Diagnostics.Append(CheckGroupEntitlements(ctx, d.data.Features)...) + resp.Diagnostics.Append(CheckGroupEntitlements(ctx, d.data.Features())...) if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go index fa370a0..334774d 100644 --- a/internal/provider/group_resource.go +++ b/internal/provider/group_resource.go @@ -150,7 +150,7 @@ func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, return } - resp.Diagnostics.Append(CheckGroupEntitlements(ctx, r.data.Features)...) + resp.Diagnostics.Append(CheckGroupEntitlements(ctx, r.data.Features())...) if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/license_resource.go b/internal/provider/license_resource.go index 5cb4778..f2b85d0 100644 --- a/internal/provider/license_resource.go +++ b/internal/provider/license_resource.go @@ -120,6 +120,13 @@ func (r *LicenseResource) Create(ctx context.Context, req resource.CreateRequest } data.ExpiresAt = types.Int64Value(expiresAt.Unix()) + entitlements, err := client.Entitlements(ctx) + if err != nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to refresh deployment entitlements after adding license, got error: %s", err)) + } else { + r.data.SetFeatures(entitlements.Features) + } + // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -195,4 +202,11 @@ func (r *LicenseResource) Delete(ctx context.Context, req resource.DeleteRequest resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete license, got error: %s", err)) return } + + entitlements, err := client.Entitlements(ctx) + if err != nil { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to refresh deployment entitlements after deleting license, got error: %s", err)) + } else { + r.data.SetFeatures(entitlements.Features) + } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5777aa3..6a45f5e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "strings" + "sync/atomic" "cdr.dev/slog/v3" "github.com/google/uuid" @@ -32,10 +33,46 @@ type CoderdProvider struct { version string } +// featureSnapshot is an immutable container for the feature map, +// used with atomic.Pointer for lock-free concurrent access. +type featureSnapshot struct { + features map[codersdk.FeatureName]codersdk.Feature +} + type CoderdProviderData struct { Client *codersdk.Client DefaultOrganizationID uuid.UUID - Features map[codersdk.FeatureName]codersdk.Feature + features atomic.Pointer[featureSnapshot] +} + +// SetFeatures atomically replaces the cached feature entitlements. +// The input map is copied so callers may continue to mutate it safely. +func (d *CoderdProviderData) SetFeatures(in map[codersdk.FeatureName]codersdk.Feature) { + copied := make(map[codersdk.FeatureName]codersdk.Feature, len(in)) + for k, v := range in { + copied[k] = v + } + d.features.Store(&featureSnapshot{features: copied}) +} + +// Features returns the current feature entitlements snapshot. +// Callers must not mutate the returned map. +func (d *CoderdProviderData) Features() map[codersdk.FeatureName]codersdk.Feature { + snap := d.features.Load() + if snap == nil { + return nil + } + return snap.features +} + +// FeatureEnabled reports whether the named feature is enabled in the +// current entitlements snapshot. +func (d *CoderdProviderData) FeatureEnabled(name codersdk.FeatureName) bool { + feats := d.Features() + if feats == nil { + return false + } + return feats[name].Enabled } // CoderdProviderModel describes the provider data model. @@ -135,8 +172,8 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe providerData := &CoderdProviderData{ Client: client, DefaultOrganizationID: data.DefaultOrganizationID.ValueUUID(), - Features: entitlements.Features, } + providerData.SetFeatures(entitlements.Features) resp.DataSourceData = providerData resp.ResourceData = providerData } diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go index ace421f..84de0f1 100644 --- a/internal/provider/template_resource.go +++ b/internal/provider/template_resource.go @@ -523,7 +523,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques data.DisplayName = data.Name } - resp.Diagnostics.Append(data.CheckEntitlements(ctx, r.data.Features)...) + resp.Diagnostics.Append(data.CheckEntitlements(ctx, r.data.Features())...) if resp.Diagnostics.HasError() { return } @@ -741,7 +741,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques newState.DisplayName = newState.Name } - resp.Diagnostics.Append(newState.CheckEntitlements(ctx, r.data.Features)...) + resp.Diagnostics.Append(newState.CheckEntitlements(ctx, r.data.Features())...) if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/workspace_proxy_resource.go b/internal/provider/workspace_proxy_resource.go index 211c778..8cb16a7 100644 --- a/internal/provider/workspace_proxy_resource.go +++ b/internal/provider/workspace_proxy_resource.go @@ -103,7 +103,7 @@ func (r *WorkspaceProxyResource) Create(ctx context.Context, req resource.Create return } - if !r.data.Features[codersdk.FeatureWorkspaceProxy].Enabled { + if !r.data.FeatureEnabled(codersdk.FeatureWorkspaceProxy) { resp.Diagnostics.AddError("Feature not enabled", "Your license is not entitled to create workspace proxies.") return } diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go index aeab117..4829f85 100644 --- a/internal/provider/workspace_proxy_resource_test.go +++ b/internal/provider/workspace_proxy_resource_test.go @@ -85,6 +85,76 @@ func TestAccWorkspaceProxyResourceAGPL(t *testing.T) { } +func TestAccWorkspaceProxyResourceAfterLicenseInSameApply(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + license := os.Getenv("CODER_ENTERPRISE_LICENSE") + if license == "" { + t.Skip("No license found for workspace proxy regression test, skipping") + } + + ctx := t.Context() + client := integration.StartCoder(ctx, t, "ws_proxy_after_license_acc") + + cfg := testAccWorkspaceProxyAfterLicenseConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + License: license, + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_workspace_proxy.test", "session_token"), + ), + }, + }, + }) +} + +type testAccWorkspaceProxyAfterLicenseConfig struct { + URL string + Token string + License string +} + +func (c testAccWorkspaceProxyAfterLicenseConfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_license" "enterprise" { + license = "{{.License}}" +} + +resource "coderd_workspace_proxy" "test" { + depends_on = [coderd_license.enterprise] + name = "example-after-license" + display_name = "Example After License" + icon = "/emojis/1f407.png" +} +` + + buf := strings.Builder{} + tmpl, err := template.New("workspaceProxyAfterLicense").Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + + return buf.String() +} + type testAccWorkspaceProxyResourceConfig struct { URL string Token string