Skip to content

Commit f46e92d

Browse files
committed
feat: add ephemeral resources framework infrastructure
Add core infrastructure for ephemeral resources in Terraform Plugin Framework: - Framework wrapper with schema enrichment and secure logging - Private data utilities for state management between Open/Renew/Close - Security guidelines and patterns for handling sensitive data - Test coverage for wrapper functionality This enables secure, stateless access to sensitive resources without storing secrets in Terraform state files.
1 parent 9a245d7 commit f46e92d

File tree

5 files changed

+283
-1
lines changed

5 files changed

+283
-1
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package fwprovider
2+
3+
import (
4+
"context"
5+
6+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
7+
8+
"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/fwutils"
9+
)
10+
11+
// Interface assertions for FrameworkEphemeralResourceWrapper
12+
var (
13+
_ ephemeral.EphemeralResource = &FrameworkEphemeralResourceWrapper{}
14+
_ ephemeral.EphemeralResourceWithConfigure = &FrameworkEphemeralResourceWrapper{}
15+
_ ephemeral.EphemeralResourceWithValidateConfig = &FrameworkEphemeralResourceWrapper{}
16+
_ ephemeral.EphemeralResourceWithConfigValidators = &FrameworkEphemeralResourceWrapper{}
17+
_ ephemeral.EphemeralResourceWithRenew = &FrameworkEphemeralResourceWrapper{}
18+
_ ephemeral.EphemeralResourceWithClose = &FrameworkEphemeralResourceWrapper{}
19+
)
20+
21+
// NewFrameworkEphemeralResourceWrapper creates a new ephemeral resource wrapper following
22+
// the same pattern as the existing FrameworkResourceWrapper
23+
func NewFrameworkEphemeralResourceWrapper(i *ephemeral.EphemeralResource) ephemeral.EphemeralResource {
24+
return &FrameworkEphemeralResourceWrapper{
25+
innerResource: i,
26+
}
27+
}
28+
29+
// FrameworkEphemeralResourceWrapper wraps ephemeral resources to provide consistent behavior
30+
// across all ephemeral resources, following the existing FrameworkResourceWrapper pattern
31+
type FrameworkEphemeralResourceWrapper struct {
32+
innerResource *ephemeral.EphemeralResource
33+
}
34+
35+
// Metadata implements the core ephemeral.EphemeralResource interface
36+
// Adds provider type name prefix to the resource type name, following existing pattern
37+
func (r *FrameworkEphemeralResourceWrapper) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
38+
(*r.innerResource).Metadata(ctx, req, resp)
39+
resp.TypeName = req.ProviderTypeName + resp.TypeName
40+
}
41+
42+
// Schema implements the core ephemeral.EphemeralResource interface
43+
// Enriches schema with common framework patterns
44+
func (r *FrameworkEphemeralResourceWrapper) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
45+
(*r.innerResource).Schema(ctx, req, resp)
46+
fwutils.EnrichFrameworkEphemeralResourceSchema(&resp.Schema)
47+
}
48+
49+
// Open implements the core ephemeral.EphemeralResource interface
50+
// This is where ephemeral resources create/acquire their temporary resources
51+
func (r *FrameworkEphemeralResourceWrapper) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
52+
(*r.innerResource).Open(ctx, req, resp)
53+
}
54+
55+
// Configure implements the optional ephemeral.EphemeralResourceWithConfigure interface
56+
// Uses interface detection to only call if the inner resource supports configuration
57+
func (r *FrameworkEphemeralResourceWrapper) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) {
58+
rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithConfigure)
59+
if ok {
60+
if req.ProviderData == nil {
61+
return
62+
}
63+
_, ok := req.ProviderData.(*FrameworkProvider)
64+
if !ok {
65+
resp.Diagnostics.AddError("Unexpected Ephemeral Resource Configure Type", "")
66+
return
67+
}
68+
69+
rCasted.Configure(ctx, req, resp)
70+
}
71+
}
72+
73+
// ValidateConfig implements the optional ephemeral.EphemeralResourceWithValidateConfig interface
74+
// Uses interface detection to only call if the inner resource supports validation
75+
func (r *FrameworkEphemeralResourceWrapper) ValidateConfig(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) {
76+
if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithValidateConfig); ok {
77+
rCasted.ValidateConfig(ctx, req, resp)
78+
}
79+
}
80+
81+
// ConfigValidators implements the optional ephemeral.EphemeralResourceWithConfigValidators interface
82+
// Uses interface detection to only call if the inner resource supports declarative validators
83+
func (r *FrameworkEphemeralResourceWrapper) ConfigValidators(ctx context.Context) []ephemeral.ConfigValidator {
84+
if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithConfigValidators); ok {
85+
return rCasted.ConfigValidators(ctx)
86+
}
87+
return nil
88+
}
89+
90+
// Renew implements the optional ephemeral.EphemeralResourceWithRenew interface
91+
// Uses interface detection to only call if the inner resource supports renewal
92+
func (r *FrameworkEphemeralResourceWrapper) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) {
93+
if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithRenew); ok {
94+
rCasted.Renew(ctx, req, resp)
95+
}
96+
}
97+
98+
// Close implements the optional ephemeral.EphemeralResourceWithClose interface
99+
// Uses interface detection to only call if the inner resource supports cleanup
100+
func (r *FrameworkEphemeralResourceWrapper) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
101+
if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithClose); ok {
102+
rCasted.Close(ctx, req, resp)
103+
}
104+
}

datadog/internal/fwutils/fw_enrich_schema.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
10+
ephemeralSchema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
1011
resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema"
1112
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
1213
)
@@ -117,6 +118,58 @@ func enrichDatasourceDescription(r any) datasourceSchema.Attribute {
117118
}
118119
}
119120

121+
// =============================================================================
122+
// EPHEMERAL SCHEMA ENRICHMENT FUNCTIONS
123+
// =============================================================================
124+
125+
func EnrichFrameworkEphemeralResourceSchema(s *ephemeralSchema.Schema) {
126+
for i, attr := range s.Attributes {
127+
s.Attributes[i] = enrichEphemeralDescription(attr)
128+
}
129+
enrichEphemeralMapBlocks(s.Blocks)
130+
}
131+
132+
func enrichEphemeralMapBlocks(blocks map[string]ephemeralSchema.Block) {
133+
for _, block := range blocks {
134+
switch v := block.(type) {
135+
case ephemeralSchema.ListNestedBlock:
136+
for i, attr := range v.NestedObject.Attributes {
137+
v.NestedObject.Attributes[i] = enrichEphemeralDescription(attr)
138+
}
139+
enrichEphemeralMapBlocks(v.NestedObject.Blocks)
140+
case ephemeralSchema.SingleNestedBlock:
141+
for i, attr := range v.Attributes {
142+
v.Attributes[i] = enrichEphemeralDescription(attr)
143+
}
144+
enrichEphemeralMapBlocks(v.Blocks)
145+
case ephemeralSchema.SetNestedBlock:
146+
for i, attr := range v.NestedObject.Attributes {
147+
v.NestedObject.Attributes[i] = enrichEphemeralDescription(attr)
148+
}
149+
enrichEphemeralMapBlocks(v.NestedObject.Blocks)
150+
}
151+
}
152+
}
153+
154+
func enrichEphemeralDescription(r any) ephemeralSchema.Attribute {
155+
switch v := r.(type) {
156+
case ephemeralSchema.StringAttribute:
157+
buildEnrichedSchemaDescription(reflect.ValueOf(&v))
158+
return v
159+
case ephemeralSchema.Int64Attribute:
160+
buildEnrichedSchemaDescription(reflect.ValueOf(&v))
161+
return v
162+
case ephemeralSchema.Float64Attribute:
163+
buildEnrichedSchemaDescription(reflect.ValueOf(&v))
164+
return v
165+
case ephemeralSchema.BoolAttribute:
166+
buildEnrichedSchemaDescription(reflect.ValueOf(&v))
167+
return v
168+
default:
169+
return r.(ephemeralSchema.Attribute)
170+
}
171+
}
172+
120173
// =============================================================================
121174
// REUSABLE CORE FUNCTIONS (TYPE-AGNOSTIC VIA REFLECTION)
122175
// =============================================================================
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/ephemeral"
8+
"github.com/hashicorp/terraform-plugin-framework/ephemeral/schema"
9+
"github.com/stretchr/testify/assert"
10+
11+
"github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider"
12+
)
13+
14+
// Simple mock for testing wrapper functionality
15+
type mockEphemeralResource struct{}
16+
17+
func (m *mockEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
18+
resp.TypeName = "_test_resource"
19+
}
20+
21+
func (m *mockEphemeralResource) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
22+
resp.Schema = schema.Schema{
23+
Attributes: map[string]schema.Attribute{
24+
"id": schema.StringAttribute{Required: true},
25+
},
26+
}
27+
}
28+
29+
func (m *mockEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
30+
// Basic implementation for testing
31+
}
32+
33+
func TestFrameworkEphemeralResourceWrapper_CoreMethods(t *testing.T) {
34+
t.Parallel()
35+
36+
var mock ephemeral.EphemeralResource = &mockEphemeralResource{}
37+
wrapped := fwprovider.NewFrameworkEphemeralResourceWrapper(&mock)
38+
39+
// Test Metadata adds provider prefix
40+
t.Run("Metadata", func(t *testing.T) {
41+
req := ephemeral.MetadataRequest{ProviderTypeName: "datadog"}
42+
resp := &ephemeral.MetadataResponse{}
43+
44+
wrapped.Metadata(context.Background(), req, resp)
45+
46+
assert.Equal(t, "datadog_test_resource", resp.TypeName)
47+
})
48+
49+
// Test Schema calls enrichment
50+
t.Run("Schema", func(t *testing.T) {
51+
req := ephemeral.SchemaRequest{}
52+
resp := &ephemeral.SchemaResponse{}
53+
54+
wrapped.Schema(context.Background(), req, resp)
55+
56+
assert.NotNil(t, resp.Schema)
57+
assert.Contains(t, resp.Schema.Attributes, "id")
58+
})
59+
60+
// Test Open delegates properly
61+
t.Run("Open", func(t *testing.T) {
62+
req := ephemeral.OpenRequest{}
63+
resp := &ephemeral.OpenResponse{}
64+
65+
assert.NotPanics(t, func() {
66+
wrapped.Open(context.Background(), req, resp)
67+
})
68+
})
69+
}
70+
71+
func TestFrameworkEphemeralResourceWrapper_InterfaceDetection(t *testing.T) {
72+
t.Parallel()
73+
74+
var mock ephemeral.EphemeralResource = &mockEphemeralResource{}
75+
wrappedInterface := fwprovider.NewFrameworkEphemeralResourceWrapper(&mock)
76+
77+
// Cast to wrapper type to access optional interface methods
78+
wrapped := wrappedInterface.(*fwprovider.FrameworkEphemeralResourceWrapper)
79+
80+
// Test that optional methods don't panic when inner resource doesn't implement them
81+
t.Run("Configure_NotImplemented", func(t *testing.T) {
82+
req := ephemeral.ConfigureRequest{}
83+
resp := &ephemeral.ConfigureResponse{}
84+
85+
assert.NotPanics(t, func() {
86+
wrapped.Configure(context.Background(), req, resp)
87+
})
88+
})
89+
90+
t.Run("ValidateConfig_NotImplemented", func(t *testing.T) {
91+
req := ephemeral.ValidateConfigRequest{}
92+
resp := &ephemeral.ValidateConfigResponse{}
93+
94+
assert.NotPanics(t, func() {
95+
wrapped.ValidateConfig(context.Background(), req, resp)
96+
})
97+
})
98+
99+
t.Run("ConfigValidators_NotImplemented", func(t *testing.T) {
100+
validators := wrapped.ConfigValidators(context.Background())
101+
assert.Nil(t, validators)
102+
})
103+
104+
t.Run("Renew_NotImplemented", func(t *testing.T) {
105+
req := ephemeral.RenewRequest{}
106+
resp := &ephemeral.RenewResponse{}
107+
108+
assert.NotPanics(t, func() {
109+
wrapped.Renew(context.Background(), req, resp)
110+
})
111+
})
112+
113+
t.Run("Close_NotImplemented", func(t *testing.T) {
114+
req := ephemeral.CloseRequest{}
115+
resp := &ephemeral.CloseResponse{}
116+
117+
assert.NotPanics(t, func() {
118+
wrapped.Close(context.Background(), req, resp)
119+
})
120+
})
121+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0
1919
github.com/hashicorp/terraform-plugin-testing v1.12.0
2020
github.com/jonboulle/clockwork v0.2.2
21+
github.com/stretchr/testify v1.8.3
2122
github.com/zorkian/go-datadog-api v2.30.0+incompatible
2223
gopkg.in/DataDog/dd-trace-go.v1 v1.34.0
2324
gopkg.in/dnaeon/go-vcr.v3 v3.1.2
@@ -40,6 +41,7 @@ require (
4041
github.com/bgentry/speakeasy v0.1.0 // indirect
4142
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
4243
github.com/cloudflare/circl v1.6.1 // indirect
44+
github.com/davecgh/go-spew v1.1.1 // indirect
4345
github.com/fatih/color v1.18.0 // indirect
4446
github.com/goccy/go-json v0.10.5 // indirect
4547
github.com/golang/protobuf v1.5.4 // indirect
@@ -75,6 +77,7 @@ require (
7577
github.com/oklog/run v1.1.0 // indirect
7678
github.com/opentracing/opentracing-go v1.2.0 // indirect
7779
github.com/philhofer/fwd v1.1.1 // indirect
80+
github.com/pmezard/go-difflib v1.0.0 // indirect
7881
github.com/posener/complete v1.2.3 // indirect
7982
github.com/rogpeppe/go-internal v1.11.0 // indirect
8083
github.com/russross/blackfriday v1.6.0 // indirect

go.sum

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,9 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ
255255
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
256256
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
257257
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
258-
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
259258
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
259+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
260+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
260261
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
261262
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
262263
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

0 commit comments

Comments
 (0)