Skip to content

Commit 505b255

Browse files
authored
Merge pull request #35728 from hashicorp/jbardin/ephemeral-config
Add ephemeral resources to the Terraform language
2 parents 892172a + 62076c0 commit 505b255

10 files changed

Lines changed: 254 additions & 5 deletions

File tree

internal/configs/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ func (c *Config) addProviderRequirements(reqs providerreqs.Requirements, recurse
458458
}
459459
reqs[fqn] = nil
460460
}
461+
461462
for _, rc := range c.Module.DataResources {
462463
fqn := rc.Provider
463464
if _, exists := reqs[fqn]; exists {
@@ -467,6 +468,15 @@ func (c *Config) addProviderRequirements(reqs providerreqs.Requirements, recurse
467468
reqs[fqn] = nil
468469
}
469470

471+
for _, rc := range c.Module.EphemeralResources {
472+
fqn := rc.Provider
473+
if _, exists := reqs[fqn]; exists {
474+
// Explicit dependency already present
475+
continue
476+
}
477+
reqs[fqn] = nil
478+
}
479+
470480
// Import blocks that are generating config may have a custom provider
471481
// meta-argument. Like the provider meta-argument used in resource blocks,
472482
// we use this opportunity to load any implicit providers.

internal/configs/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/hashicorp/terraform/internal/addrs"
1919
"github.com/hashicorp/terraform/internal/depsfile"
2020
"github.com/hashicorp/terraform/internal/getproviders/providerreqs"
21+
22+
_ "github.com/hashicorp/terraform/internal/logging"
2123
)
2224

2325
func TestConfigProviderTypes(t *testing.T) {

internal/configs/module.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ type Module struct {
4646

4747
ModuleCalls map[string]*ModuleCall
4848

49-
ManagedResources map[string]*Resource
50-
DataResources map[string]*Resource
49+
ManagedResources map[string]*Resource
50+
DataResources map[string]*Resource
51+
EphemeralResources map[string]*Resource
5152

5253
Moved []*Moved
5354
Removed []*Removed
@@ -86,8 +87,9 @@ type File struct {
8687

8788
ModuleCalls []*ModuleCall
8889

89-
ManagedResources []*Resource
90-
DataResources []*Resource
90+
ManagedResources []*Resource
91+
DataResources []*Resource
92+
EphemeralResources []*Resource
9193

9294
Moved []*Moved
9395
Removed []*Removed
@@ -124,6 +126,7 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) {
124126
Outputs: map[string]*Output{},
125127
ModuleCalls: map[string]*ModuleCall{},
126128
ManagedResources: map[string]*Resource{},
129+
EphemeralResources: map[string]*Resource{},
127130
DataResources: map[string]*Resource{},
128131
Checks: map[string]*Check{},
129132
ProviderMetas: map[addrs.Provider]*ProviderMeta{},
@@ -192,6 +195,8 @@ func (m *Module) ResourceByAddr(addr addrs.Resource) *Resource {
192195
return m.ManagedResources[key]
193196
case addrs.DataResourceMode:
194197
return m.DataResources[key]
198+
case addrs.EphemeralResourceMode:
199+
return m.EphemeralResources[key]
195200
default:
196201
return nil
197202
}
@@ -372,6 +377,35 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
372377
m.DataResources[key] = r
373378
}
374379

380+
for _, r := range file.EphemeralResources {
381+
key := r.moduleUniqueKey()
382+
if existing, exists := m.EphemeralResources[key]; exists {
383+
diags = append(diags, &hcl.Diagnostic{
384+
Severity: hcl.DiagError,
385+
Summary: fmt.Sprintf("Duplicate ephemeral %q configuration", existing.Type),
386+
Detail: fmt.Sprintf("A %s ephemeral resource named %q was already declared at %s. Resource names must be unique per type in each module.", existing.Type, existing.Name, existing.DeclRange),
387+
Subject: &r.DeclRange,
388+
})
389+
continue
390+
}
391+
m.EphemeralResources[key] = r
392+
393+
// set the provider FQN for the resource
394+
if r.ProviderConfigRef != nil {
395+
r.Provider = m.ProviderForLocalConfig(r.ProviderConfigAddr())
396+
} else {
397+
// an invalid resource name (for e.g. "null resource" instead of
398+
// "null_resource") can cause a panic down the line in addrs:
399+
// https://github.com/hashicorp/terraform/issues/25560
400+
implied, err := addrs.ParseProviderPart(r.Addr().ImpliedProvider())
401+
if err == nil {
402+
r.Provider = m.ImpliedProviderForUnqualifiedType(implied)
403+
}
404+
// We don't return a diagnostic because the invalid resource name
405+
// will already have been caught.
406+
}
407+
}
408+
375409
for _, c := range file.Checks {
376410
if c.DataResource != nil {
377411
key := c.DataResource.moduleUniqueKey()

internal/configs/parser_config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ func parseConfigFile(body hcl.Body, diags hcl.Diagnostics, override, allowExperi
195195
file.DataResources = append(file.DataResources, cfg)
196196
}
197197

198+
case "ephemeral":
199+
cfg, cfgDiags := decodeEphemeralBlock(block, override)
200+
diags = append(diags, cfgDiags...)
201+
if cfg != nil {
202+
file.EphemeralResources = append(file.EphemeralResources, cfg)
203+
}
204+
198205
case "moved":
199206
cfg, cfgDiags := decodeMovedBlock(block)
200207
diags = append(diags, cfgDiags...)
@@ -310,6 +317,10 @@ var configFileSchema = &hcl.BodySchema{
310317
Type: "data",
311318
LabelNames: []string{"type", "name"},
312319
},
320+
{
321+
Type: "ephemeral",
322+
LabelNames: []string{"type", "name"},
323+
},
313324
{
314325
Type: "moved",
315326
},

internal/configs/resource.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,155 @@ func decodeResourceBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagno
358358
return r, diags
359359
}
360360

361+
func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagnostics) {
362+
var diags hcl.Diagnostics
363+
r := &Resource{
364+
Mode: addrs.EphemeralResourceMode,
365+
Type: block.Labels[0],
366+
Name: block.Labels[1],
367+
DeclRange: block.DefRange,
368+
TypeRange: block.LabelRanges[0],
369+
}
370+
371+
content, remain, moreDiags := block.Body.PartialContent(ephemeralBlockSchema)
372+
diags = append(diags, moreDiags...)
373+
r.Config = remain
374+
375+
if !hclsyntax.ValidIdentifier(r.Type) {
376+
diags = append(diags, &hcl.Diagnostic{
377+
Severity: hcl.DiagError,
378+
Summary: "Invalid ephemeral resource type",
379+
Detail: badIdentifierDetail,
380+
Subject: &block.LabelRanges[0],
381+
})
382+
}
383+
if !hclsyntax.ValidIdentifier(r.Name) {
384+
diags = append(diags, &hcl.Diagnostic{
385+
Severity: hcl.DiagError,
386+
Summary: "Invalid ephemeral resource name",
387+
Detail: badIdentifierDetail,
388+
Subject: &block.LabelRanges[1],
389+
})
390+
}
391+
392+
if attr, exists := content.Attributes["count"]; exists {
393+
r.Count = attr.Expr
394+
}
395+
396+
if attr, exists := content.Attributes["for_each"]; exists {
397+
r.ForEach = attr.Expr
398+
// Cannot have count and for_each on the same ephemeral block
399+
if r.Count != nil {
400+
diags = append(diags, &hcl.Diagnostic{
401+
Severity: hcl.DiagError,
402+
Summary: `Invalid combination of "count" and "for_each"`,
403+
Detail: `The "count" and "for_each" meta-arguments are mutually-exclusive, only one should be used to be explicit about the number of resources to be created.`,
404+
Subject: &attr.NameRange,
405+
})
406+
}
407+
}
408+
409+
if attr, exists := content.Attributes["provider"]; exists {
410+
var providerDiags hcl.Diagnostics
411+
r.ProviderConfigRef, providerDiags = decodeProviderConfigRef(attr.Expr, "provider")
412+
diags = append(diags, providerDiags...)
413+
}
414+
415+
if attr, exists := content.Attributes["depends_on"]; exists {
416+
deps, depsDiags := DecodeDependsOn(attr)
417+
diags = append(diags, depsDiags...)
418+
r.DependsOn = append(r.DependsOn, deps...)
419+
}
420+
421+
var seenEscapeBlock *hcl.Block
422+
var seenLifecycle *hcl.Block
423+
for _, block := range content.Blocks {
424+
switch block.Type {
425+
426+
case "_":
427+
if seenEscapeBlock != nil {
428+
diags = append(diags, &hcl.Diagnostic{
429+
Severity: hcl.DiagError,
430+
Summary: "Duplicate escaping block",
431+
Detail: fmt.Sprintf(
432+
"The special block type \"_\" can be used to force particular arguments to be interpreted as resource-type-specific rather than as meta-arguments, but each data block can have only one such block. The first escaping block was at %s.",
433+
seenEscapeBlock.DefRange,
434+
),
435+
Subject: &block.DefRange,
436+
})
437+
continue
438+
}
439+
seenEscapeBlock = block
440+
441+
// When there's an escaping block its content merges with the
442+
// existing config we extracted earlier, so later decoding
443+
// will see a blend of both.
444+
r.Config = hcl.MergeBodies([]hcl.Body{r.Config, block.Body})
445+
446+
case "lifecycle":
447+
if seenLifecycle != nil {
448+
diags = append(diags, &hcl.Diagnostic{
449+
Severity: hcl.DiagError,
450+
Summary: "Duplicate lifecycle block",
451+
Detail: fmt.Sprintf("This resource already has a lifecycle block at %s.", seenLifecycle.DefRange),
452+
Subject: block.DefRange.Ptr(),
453+
})
454+
continue
455+
}
456+
seenLifecycle = block
457+
458+
lcContent, lcDiags := block.Body.Content(resourceLifecycleBlockSchema)
459+
diags = append(diags, lcDiags...)
460+
461+
// All of the attributes defined for resource lifecycle are for
462+
// managed resources only, so we can emit a common error message
463+
// for any given attributes that HCL accepted.
464+
for name, attr := range lcContent.Attributes {
465+
diags = append(diags, &hcl.Diagnostic{
466+
Severity: hcl.DiagError,
467+
Summary: "Invalid ephemeral resource lifecycle argument",
468+
Detail: fmt.Sprintf("The lifecycle argument %q is defined only for managed resources (\"resource\" blocks), and is not valid for ephemeral resources.", name),
469+
Subject: attr.NameRange.Ptr(),
470+
})
471+
}
472+
473+
for _, block := range lcContent.Blocks {
474+
switch block.Type {
475+
case "precondition", "postcondition":
476+
cr, moreDiags := decodeCheckRuleBlock(block, override)
477+
diags = append(diags, moreDiags...)
478+
479+
moreDiags = cr.validateSelfReferences(block.Type, r.Addr())
480+
diags = append(diags, moreDiags...)
481+
482+
switch block.Type {
483+
case "precondition":
484+
r.Preconditions = append(r.Preconditions, cr)
485+
case "postcondition":
486+
r.Postconditions = append(r.Postconditions, cr)
487+
}
488+
default:
489+
// The cases above should be exhaustive for all block types
490+
// defined in the lifecycle schema, so this shouldn't happen.
491+
panic(fmt.Sprintf("unexpected lifecycle sub-block type %q", block.Type))
492+
}
493+
}
494+
495+
default:
496+
// Any other block types are ones we're reserving for future use,
497+
// but don't have any defined meaning today.
498+
diags = append(diags, &hcl.Diagnostic{
499+
Severity: hcl.DiagError,
500+
Summary: "Reserved block type name in ephemeral block",
501+
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
502+
Subject: block.TypeRange.Ptr(),
503+
})
504+
}
505+
}
506+
507+
return r, diags
508+
}
509+
361510
func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Diagnostics) {
362511
var diags hcl.Diagnostics
363512
r := &Resource{
@@ -783,6 +932,15 @@ var dataBlockSchema = &hcl.BodySchema{
783932
},
784933
}
785934

935+
var ephemeralBlockSchema = &hcl.BodySchema{
936+
Attributes: commonResourceAttributes,
937+
Blocks: []hcl.BlockHeaderSchema{
938+
{Type: "lifecycle"},
939+
{Type: "locals"}, // reserved for future use
940+
{Type: "_"}, // meta-argument escaping block
941+
},
942+
}
943+
786944
var resourceLifecycleBlockSchema = &hcl.BodySchema{
787945
// We tell HCL that these elements are all valid for both "resource"
788946
// and "data" lifecycle blocks, but the rules are actually more restrictive
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ephemeral "test_resource" "test" {
2+
lifecycle {
3+
create_before_destroy = true
4+
}
5+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ephemeral "test resource" "nope" {
2+
}

internal/configs/testdata/valid-files/resources.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,12 @@ resource "aws_instance" "depends" {
4747
replace_triggered_by = [ aws_instance.web[1], aws_security_group.firewall.id ]
4848
}
4949
}
50+
51+
ephemeral "aws_connect" "tunnel" {
52+
}
53+
54+
ephemeral "aws_secret" "auth" {
55+
for_each = local.auths
56+
input = each.value
57+
depends_on = [aws_instance.depends]
58+
}

internal/lang/eval.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl
283283
// that's redundant in the process of populating our values map.
284284
dataResources := map[string]map[string]cty.Value{}
285285
managedResources := map[string]map[string]cty.Value{}
286+
ephemeralResources := map[string]map[string]cty.Value{}
286287
wholeModules := map[string]cty.Value{}
287288
inputVariables := map[string]cty.Value{}
288289
localValues := map[string]cty.Value{}
@@ -365,6 +366,8 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl
365366
into = managedResources
366367
case addrs.DataResourceMode:
367368
into = dataResources
369+
case addrs.EphemeralResourceMode:
370+
into = ephemeralResources
368371
default:
369372
panic(fmt.Errorf("unsupported ResourceMode %s", subj.Mode))
370373
}
@@ -443,7 +446,7 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl
443446
vals[k] = v
444447
}
445448
vals["resource"] = cty.ObjectVal(buildResourceObjects(managedResources))
446-
449+
vals["ephemeral"] = cty.ObjectVal(buildResourceObjects(ephemeralResources))
447450
vals["data"] = cty.ObjectVal(buildResourceObjects(dataResources))
448451
vals["module"] = cty.ObjectVal(wholeModules)
449452
vals["var"] = cty.ObjectVal(inputVariables)

internal/lang/eval_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ func TestScopeEvalContext(t *testing.T) {
3737
"data.null_data_source.foo": cty.ObjectVal(map[string]cty.Value{
3838
"attr": cty.StringVal("bar"),
3939
}),
40+
"ephemeral.null_secret.foo": cty.ObjectVal(map[string]cty.Value{
41+
"attr": cty.StringVal("ephemeral"),
42+
}),
4043
"null_resource.multi": cty.TupleVal([]cty.Value{
4144
cty.ObjectVal(map[string]cty.Value{
4245
"attr": cty.StringVal("multi0"),
@@ -320,6 +323,18 @@ func TestScopeEvalContext(t *testing.T) {
320323
}),
321324
},
322325
},
326+
{
327+
Expr: `ephemeral.null_secret.foo`,
328+
Want: map[string]cty.Value{
329+
"ephemeral": cty.ObjectVal(map[string]cty.Value{
330+
"null_secret": cty.ObjectVal(map[string]cty.Value{
331+
"foo": cty.ObjectVal(map[string]cty.Value{
332+
"attr": cty.StringVal("ephemeral"),
333+
}),
334+
}),
335+
}),
336+
},
337+
},
323338
{
324339
Expr: `module.foo`,
325340
Want: map[string]cty.Value{

0 commit comments

Comments
 (0)