configs: Meta-argument escaping blocks

Several top-level block types in the Terraform language have a body where
two different schemas are overlayed on top of one another: Terraform first
looks for "meta-arguments" that are built into the language, and then
evaluates all of the remaining arguments against some externally-defined
schema whose content is not fully controlled by Terraform.

So far we've been cautiously adding new meta-arguments in these namespaces
after research shows us that there are relatively few existing providers
or modules that would have functionality masked by those additions, but
that isn't really a viable path forward as we prepare to make stronger
compatibility promises.

In an earlier commit we've introduced the foundational parts of a new
language versioning mechanism called "editions" which should allow us to
make per-module-opt-in breaking changes in the future, but these shared
namespaces remain a liability because it would be annoying if adopting a
new edition made it impossible to use a feature of a third-party provider
or module that was already using a name that has now become reserved in
the new edition.

This commit introduces a new syntax intended to be a rarely-used escape
hatch for that situation. When we're designing new editions we will do our
best to choose names that don't conflict with commonly-used providers and
modules, but there are many providers and modules that we cannot see and
so there is a risk that any name we might choose could collide with at
least one existing provider or module. The automatic migration tool to
upgrade an existing module to a new edition should therefore detect that
situation and make use of this escaping block syntax in order to retain
the existing functionality until all the called providers or modules are
updated to no longer use conflicting names.

Although we can't put in technical constraints on using this feature for
other purposes (because we don't know yet what future editions will add),
this mechanism is intentionally not documented for now because it serves
no immediate purpose. In effect, this change is just squatting on the
syntax of a special block type named "_" so that later editions can make
use of it without it _also_ conflicting, creating a confusing nested
escaping situation. However, the first time a new edition actually makes
use of this syntax we should then document alongside the meta-arguments
so folks can understand the meaning of escaping blocks produced by
edition upgrade tools.
This commit is contained in:
Martin Atkins 2021-05-14 16:09:51 -07:00
parent 91a8a8137c
commit 27ad9861ce
10 changed files with 582 additions and 16 deletions

View File

@ -0,0 +1,308 @@
package configs
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
)
// "Escaping Blocks" are a special mechanism we have inside our block types
// that accept a mixture of meta-arguments and externally-defined arguments,
// which allow an author to force particular argument names to be interpreted
// as externally-defined even if they have the same name as a meta-argument.
//
// An escaping block is a block with the special type name "_" (just an
// underscore), and is allowed at the top-level of any resource, data, or
// module block. It intentionally has a rather "odd" look so that it stands
// out as something special and rare.
//
// This is not something we expect to see used a lot, but it's an important
// part of our strategy to evolve the Terraform language in future using
// editions, so that later editions can define new meta-arguments without
// blocking access to externally-defined arguments of the same name.
//
// We should still define new meta-arguments with care to avoid squatting on
// commonly-used names, but we can't see all modules and all providers in
// the world and so this is an escape hatch for edge cases. Module migration
// tools for future editions that define new meta-arguments should detect
// collisions and automatically migrate existing arguments into an escaping
// block.
func TestEscapingBlockResource(t *testing.T) {
// (this also tests escaping blocks in provisioner blocks, because
// they only appear nested inside resource blocks.)
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir("testdata/escaping-blocks/resource")
assertNoDiagnostics(t, diags)
if mod == nil {
t.Fatal("got nil root module; want non-nil")
}
rc := mod.ManagedResources["foo.bar"]
if rc == nil {
t.Fatal("no managed resource named foo.bar")
}
t.Run("resource body", func(t *testing.T) {
if got := rc.Count; got == nil {
t.Errorf("count not set; want count = 2")
} else {
got, diags := got.Value(nil)
assertNoDiagnostics(t, diags)
if want := cty.NumberIntVal(2); !want.RawEquals(got) {
t.Errorf("wrong count\ngot: %#v\nwant: %#v", got, want)
}
}
if got, want := rc.ForEach, hcl.Expression(nil); got != want {
// Shouldn't have any count because our test fixture only has
// for_each in the escaping block.
t.Errorf("wrong for_each\ngot: %#v\nwant: %#v", got, want)
}
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "normal", Required: true},
{Name: "count", Required: true},
{Name: "for_each", Required: true},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "normal_block"},
{Type: "lifecycle"},
{Type: "_"},
},
}
content, diags := rc.Config.Content(schema)
assertNoDiagnostics(t, diags)
normalVal, diags := content.Attributes["normal"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := normalVal, cty.StringVal("yes"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
countVal, diags := content.Attributes["count"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := countVal, cty.StringVal("not actually count"); !want.RawEquals(got) {
t.Errorf("wrong value for 'count'\ngot: %#v\nwant: %#v", got, want)
}
var gotBlockTypes []string
for _, block := range content.Blocks {
gotBlockTypes = append(gotBlockTypes, block.Type)
}
wantBlockTypes := []string{"normal_block", "lifecycle", "_"}
if diff := cmp.Diff(gotBlockTypes, wantBlockTypes); diff != "" {
t.Errorf("wrong block types\n%s", diff)
}
})
t.Run("provisioner body", func(t *testing.T) {
if got, want := len(rc.Managed.Provisioners), 1; got != want {
t.Fatalf("wrong number of provisioners %d; want %d", got, want)
}
pc := rc.Managed.Provisioners[0]
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "when", Required: true},
{Name: "normal", Required: true},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "normal_block"},
{Type: "lifecycle"},
{Type: "_"},
},
}
content, diags := pc.Config.Content(schema)
assertNoDiagnostics(t, diags)
normalVal, diags := content.Attributes["normal"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := normalVal, cty.StringVal("yep"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
whenVal, diags := content.Attributes["when"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := whenVal, cty.StringVal("hell freezes over"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
})
}
func TestEscapingBlockData(t *testing.T) {
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir("testdata/escaping-blocks/data")
assertNoDiagnostics(t, diags)
if mod == nil {
t.Fatal("got nil root module; want non-nil")
}
rc := mod.DataResources["data.foo.bar"]
if rc == nil {
t.Fatal("no data resource named data.foo.bar")
}
if got := rc.Count; got == nil {
t.Errorf("count not set; want count = 2")
} else {
got, diags := got.Value(nil)
assertNoDiagnostics(t, diags)
if want := cty.NumberIntVal(2); !want.RawEquals(got) {
t.Errorf("wrong count\ngot: %#v\nwant: %#v", got, want)
}
}
if got, want := rc.ForEach, hcl.Expression(nil); got != want {
// Shouldn't have any count because our test fixture only has
// for_each in the escaping block.
t.Errorf("wrong for_each\ngot: %#v\nwant: %#v", got, want)
}
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "normal", Required: true},
{Name: "count", Required: true},
{Name: "for_each", Required: true},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "normal_block"},
{Type: "lifecycle"},
{Type: "_"},
},
}
content, diags := rc.Config.Content(schema)
assertNoDiagnostics(t, diags)
normalVal, diags := content.Attributes["normal"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := normalVal, cty.StringVal("yes"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
countVal, diags := content.Attributes["count"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := countVal, cty.StringVal("not actually count"); !want.RawEquals(got) {
t.Errorf("wrong value for 'count'\ngot: %#v\nwant: %#v", got, want)
}
var gotBlockTypes []string
for _, block := range content.Blocks {
gotBlockTypes = append(gotBlockTypes, block.Type)
}
wantBlockTypes := []string{"normal_block", "lifecycle", "_"}
if diff := cmp.Diff(gotBlockTypes, wantBlockTypes); diff != "" {
t.Errorf("wrong block types\n%s", diff)
}
}
func TestEscapingBlockModule(t *testing.T) {
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir("testdata/escaping-blocks/module")
assertNoDiagnostics(t, diags)
if mod == nil {
t.Fatal("got nil root module; want non-nil")
}
mc := mod.ModuleCalls["foo"]
if mc == nil {
t.Fatal("no module call named foo")
}
if got := mc.Count; got == nil {
t.Errorf("count not set; want count = 2")
} else {
got, diags := got.Value(nil)
assertNoDiagnostics(t, diags)
if want := cty.NumberIntVal(2); !want.RawEquals(got) {
t.Errorf("wrong count\ngot: %#v\nwant: %#v", got, want)
}
}
if got, want := mc.ForEach, hcl.Expression(nil); got != want {
// Shouldn't have any count because our test fixture only has
// for_each in the escaping block.
t.Errorf("wrong for_each\ngot: %#v\nwant: %#v", got, want)
}
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "normal", Required: true},
{Name: "count", Required: true},
{Name: "for_each", Required: true},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "normal_block"},
{Type: "lifecycle"},
{Type: "_"},
},
}
content, diags := mc.Config.Content(schema)
assertNoDiagnostics(t, diags)
normalVal, diags := content.Attributes["normal"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := normalVal, cty.StringVal("yes"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
countVal, diags := content.Attributes["count"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := countVal, cty.StringVal("not actually count"); !want.RawEquals(got) {
t.Errorf("wrong value for 'count'\ngot: %#v\nwant: %#v", got, want)
}
var gotBlockTypes []string
for _, block := range content.Blocks {
gotBlockTypes = append(gotBlockTypes, block.Type)
}
wantBlockTypes := []string{"normal_block", "lifecycle", "_"}
if diff := cmp.Diff(gotBlockTypes, wantBlockTypes); diff != "" {
t.Errorf("wrong block types\n%s", diff)
}
}
func TestEscapingBlockProvider(t *testing.T) {
parser := NewParser(nil)
mod, diags := parser.LoadConfigDir("testdata/escaping-blocks/provider")
assertNoDiagnostics(t, diags)
if mod == nil {
t.Fatal("got nil root module; want non-nil")
}
pc := mod.ProviderConfigs["foo.bar"]
if pc == nil {
t.Fatal("no provider configuration named foo.bar")
}
if got, want := pc.Alias, "bar"; got != want {
t.Errorf("wrong alias\ngot: %#v\nwant: %#v", got, want)
}
schema := &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "normal", Required: true},
{Name: "alias", Required: true},
{Name: "version", Required: true},
},
}
content, diags := pc.Config.Content(schema)
assertNoDiagnostics(t, diags)
normalVal, diags := content.Attributes["normal"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := normalVal, cty.StringVal("yes"); !want.RawEquals(got) {
t.Errorf("wrong value for 'normal'\ngot: %#v\nwant: %#v", got, want)
}
aliasVal, diags := content.Attributes["alias"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := aliasVal, cty.StringVal("not actually alias"); !want.RawEquals(got) {
t.Errorf("wrong value for 'alias'\ngot: %#v\nwant: %#v", got, want)
}
versionVal, diags := content.Attributes["version"].Expr.Value(nil)
assertNoDiagnostics(t, diags)
if got, want := versionVal, cty.StringVal("not actually version"); !want.RawEquals(got) {
t.Errorf("wrong value for 'version'\ngot: %#v\nwant: %#v", got, want)
}
}

View File

@ -125,14 +125,38 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno
}
}
// Reserved block types (all of them)
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in module block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
Subject: &block.TypeRange,
})
switch block.Type {
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate escaping block",
Detail: fmt.Sprintf(
"The special block type \"_\" can be used to force particular arguments to be interpreted as module input variables rather than as meta-arguments, but each module block can have only one such block. The first escaping block was at %s.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
})
continue
}
seenEscapeBlock = block
// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
mc.Config = hcl.MergeBodies([]hcl.Body{mc.Config, block.Body})
default:
// All of the other block types in our schema are reserved.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in module block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
Subject: &block.TypeRange,
})
}
}
return mc, diags
@ -168,6 +192,8 @@ var moduleBlockSchema = &hcl.BodySchema{
},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "_"}, // meta-argument escaping block
// These are all reserved for future use.
{Type: "lifecycle"},
{Type: "locals"},

View File

@ -92,14 +92,39 @@ func decodeProviderBlock(block *hcl.Block) (*Provider, hcl.Diagnostics) {
}
}
// Reserved block types (all of them)
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in provider block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
Subject: &block.TypeRange,
})
switch block.Type {
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate escaping block",
Detail: fmt.Sprintf(
"The special block type \"_\" can be used to force particular arguments to be interpreted as provider-specific rather than as meta-arguments, but each provider block can have only one such block. The first escaping block was at %s.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
})
continue
}
seenEscapeBlock = block
// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
provider.Config = hcl.MergeBodies([]hcl.Body{provider.Config, block.Body})
default:
// All of the other block types in our schema are reserved for
// future expansion.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in provider block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by Terraform in a future version.", block.Type),
Subject: &block.TypeRange,
})
}
}
return provider, diags
@ -215,7 +240,9 @@ var providerBlockSchema = &hcl.BodySchema{
{Name: "source"},
},
Blocks: []hcl.BlockHeaderSchema{
// _All_ of these are reserved for future expansion.
{Type: "_"}, // meta-argument escaping block
// The rest of these are reserved for future expansion.
{Type: "lifecycle"},
{Type: "locals"},
},

View File

@ -86,8 +86,28 @@ func decodeProvisionerBlock(block *hcl.Block) (*Provisioner, hcl.Diagnostics) {
}
var seenConnection *hcl.Block
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
switch block.Type {
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate escaping block",
Detail: fmt.Sprintf(
"The special block type \"_\" can be used to force particular arguments to be interpreted as provisioner-typpe-specific rather than as meta-arguments, but each provisioner block can have only one such block. The first escaping block was at %s.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
})
continue
}
seenEscapeBlock = block
// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
pv.Config = hcl.MergeBodies([]hcl.Body{pv.Config, block.Body})
case "connection":
if seenConnection != nil {
@ -209,6 +229,8 @@ var provisionerBlockSchema = &hcl.BodySchema{
{Name: "on_failure"},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "_"}, // meta-argument escaping block
{Type: "connection"},
{Type: "lifecycle"}, // reserved for future use
},

View File

@ -144,6 +144,7 @@ func decodeResourceBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) {
var seenLifecycle *hcl.Block
var seenConnection *hcl.Block
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
switch block.Type {
case "lifecycle":
@ -260,6 +261,26 @@ func decodeResourceBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) {
r.Managed.Provisioners = append(r.Managed.Provisioners, pv)
}
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate escaping block",
Detail: fmt.Sprintf(
"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 resource block can have only one such block. The first escaping block was at %s.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
})
continue
}
seenEscapeBlock = block
// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
r.Config = hcl.MergeBodies([]hcl.Body{r.Config, block.Body})
default:
// Any other block types are ones we've reserved for future use,
// so they get a generic message.
@ -346,9 +367,31 @@ func decodeDataBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) {
r.DependsOn = append(r.DependsOn, deps...)
}
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
// All of the block types we accept are just reserved for future use, but some get a specialized error message.
switch block.Type {
case "_":
if seenEscapeBlock != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate escaping block",
Detail: fmt.Sprintf(
"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.",
seenEscapeBlock.DefRange,
),
Subject: &block.DefRange,
})
continue
}
seenEscapeBlock = block
// When there's an escaping block its content merges with the
// existing config we extracted earlier, so later decoding
// will see a blend of both.
r.Config = hcl.MergeBodies([]hcl.Body{r.Config, block.Body})
// The rest of these are just here to reserve block type names for future use.
case "lifecycle":
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
@ -356,6 +399,7 @@ func decodeDataBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) {
Detail: "Data resources do not have lifecycle settings, so a lifecycle block is not allowed.",
Subject: &block.DefRange,
})
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
@ -500,6 +544,7 @@ var resourceBlockSchema = &hcl.BodySchema{
{Type: "lifecycle"},
{Type: "connection"},
{Type: "provisioner", LabelNames: []string{"type"}},
{Type: "_"}, // meta-argument escaping block
},
}
@ -508,6 +553,7 @@ var dataBlockSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: "lifecycle"}, // reserved for future use
{Type: "locals"}, // reserved for future use
{Type: "_"}, // meta-argument escaping block
},
}

View File

@ -0,0 +1,35 @@
data "foo" "bar" {
count = 2
normal = "yes"
normal_block {}
_ {
# This "escaping block" is an escape hatch for when a resource
# type declares argument names that collide with meta-argument
# names. The examples below are not really realistic because they
# are long-standing names that predate the need for escaping,
# but we're using them as a proxy for new meta-arguments we might
# add in future language editions which might collide with
# names defined in pre-existing providers.
# note that count is set both as a meta-argument above _and_ as
# an resource-type-specific argument here, which is valid and
# should result in both being populated.
count = "not actually count"
# for_each is only set in here, not as a meta-argument
for_each = "not actually for_each"
lifecycle {
# This is a literal lifecycle block, not a meta-argument block
}
_ {
# It would be pretty weird for a resource type to define its own
# "_" block type, but that's valid to escape in here too.
}
}
}

View File

@ -0,0 +1,36 @@
module "foo" {
source = "./child"
count = 2
normal = "yes"
normal_block {}
_ {
# This "escaping block" is an escape hatch for when a module
# declares input variable names that collide with meta-argument
# names. The examples below are not really realistic because they
# are long-standing names that predate the need for escaping,
# but we're using them as a proxy for new meta-arguments we might
# add in future language editions which might collide with
# names defined in pre-existing modules.
# note that count is set both as a meta-argument above _and_ as
# an resource-type-specific argument here, which is valid and
# should result in both being populated.
count = "not actually count"
# for_each is only set in here, not as a meta-argument
for_each = "not actually for_each"
lifecycle {
# This is a literal lifecycle block, not a meta-argument block
}
_ {
# It would be pretty weird for a resource type to define its own
# "_" block type, but that's valid to escape in here too.
}
}
}

View File

@ -0,0 +1,23 @@
provider "foo" {
alias = "bar"
normal = "yes"
_ {
# This "escaping block" is an escape hatch for when a provider
# declares argument names that collide with meta-argument
# names. The examples below are not really realistic because they
# are long-standing names that predate the need for escaping,
# but we're using them as a proxy for new meta-arguments we might
# add in future language editions which might collide with
# names defined in pre-existing providers.
# alias is set both as a meta-argument above _and_
# as a provider-type-specific argument
alias = "not actually alias"
# version is only set in here, not as a meta-argument
version = "not actually version"
}
}

View File

@ -0,0 +1,43 @@
resource "foo" "bar" {
count = 2
normal = "yes"
normal_block {}
_ {
# This "escaping block" is an escape hatch for when a resource
# type declares argument names that collide with meta-argument
# names. The examples below are not really realistic because they
# are long-standing names that predate the need for escaping,
# but we're using them as a proxy for new meta-arguments we might
# add in future language editions which might collide with
# names defined in pre-existing providers.
# note that count is set both as a meta-argument above _and_ as
# an resource-type-specific argument here, which is valid and
# should result in both being populated.
count = "not actually count"
# for_each is only set in here, not as a meta-argument
for_each = "not actually for_each"
lifecycle {
# This is a literal lifecycle block, not a meta-argument block
}
_ {
# It would be pretty weird for a resource type to define its own
# "_" block type, but that's valid to escape in here too.
}
}
provisioner "boop" {
when = destroy
_ {
when = "hell freezes over"
}
normal = "yep"
}
}