Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/provider/group_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/group_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions internal/provider/license_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)...)
}
Expand Down Expand Up @@ -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)
}
}
41 changes: 39 additions & 2 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strings"
"sync/atomic"

"cdr.dev/slog/v3"
"github.com/google/uuid"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions internal/provider/template_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/workspace_proxy_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions internal/provider/workspace_proxy_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down