Feature branch: Ephemeral resources (#2852)

Signed-off-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
This commit is contained in:
Andrei Ciobanu
2025-08-04 16:39:12 +03:00
committed by GitHub
parent 1d38fd69d8
commit 4077c3d84f
166 changed files with 8044 additions and 565 deletions

View File

@@ -82,6 +82,17 @@ func ParseModuleInstanceStr(str string) (ModuleInstance, tfdiags.Diagnostics) {
return addr, diags
}
// MustParseModuleInstanceStr is a wrapper around ParseModuleInstanceStr that panics if
// it returns an error.
// This is mainly meant for being used in unit tests.
func MustParseModuleInstanceStr(str string) ModuleInstance {
result, diags := ParseModuleInstanceStr(str)
if diags.HasErrors() {
panic(diags.Err().Error())
}
return result
}
// parseModuleInstancePrefix parses a module instance address from the given
// traversal, returning the module instance address and the remaining
// traversal.

View File

@@ -299,3 +299,18 @@ func (e *MoveEndpoint) internalAddrType() TargetableAddrType {
panic(fmt.Sprintf("unsupported address type %T", addr))
}
}
// SubjectAllowed is validating what types of resource can be used with the moved block.
// At the time of writing, it was only ensuring that the moved blocks cannot be used against ephemeral resources.
// This can later be expanded with more rules
func (e *MoveEndpoint) SubjectAllowed() bool {
if e == nil {
return false
}
switch addr := e.relSubject.(type) {
case AbsMoveableResource:
return addr.AffectedAbsResource().Resource.Mode != EphemeralResourceMode
default:
return true
}
}

View File

@@ -635,3 +635,55 @@ func TestMoveEndpointConfigMoveable(t *testing.T) {
})
}
}
func TestSubjectAllowed(t *testing.T) {
tests := map[string]struct {
target AbsMoveable
want bool
}{
"ephemeral resource": {
AbsResource{Resource: Resource{Mode: EphemeralResourceMode}},
false,
},
"managed resource": {
AbsResource{Resource: Resource{Mode: ManagedResourceMode}},
true,
},
"data source": {
AbsResource{Resource: Resource{Mode: DataResourceMode}},
true,
},
"ephemeral resource instance": {
AbsResourceInstance{Resource: ResourceInstance{Resource: Resource{Mode: EphemeralResourceMode}}},
false,
},
"managed resource instance": {
AbsResourceInstance{Resource: ResourceInstance{Resource: Resource{Mode: ManagedResourceMode}}},
true,
},
"data source instance": {
AbsResourceInstance{Resource: ResourceInstance{Resource: Resource{Mode: DataResourceMode}}},
true,
},
"module instance": {
ModuleInstance{},
true,
},
"module": {
ModuleInstance{},
true,
},
"module call": {
AbsModuleCall{},
true,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
m := &MoveEndpoint{relSubject: tt.target}
if got, want := m.SubjectAllowed(), tt.want; got != want {
t.Errorf("unexpected allowed resource for a moved block. expected: %t; got: %t", want, got)
}
})
}
}

View File

@@ -188,6 +188,18 @@ func parseRef(traversal hcl.Traversal) (*Reference, tfdiags.Diagnostics) {
}
remain := traversal[1:] // trim off "data" so we can use our shared resource reference parser
return parseResourceRef(DataResourceMode, rootRange, remain)
case "ephemeral":
if len(traversal) < 3 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference",
Detail: `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and its name.`,
Subject: traversal.SourceRange().Ptr(),
})
return nil, diags
}
remain := traversal[1:] // trim off "ephemeral" so we can use our shared resource reference parser
return parseResourceRef(EphemeralResourceMode, rootRange, remain)
case "resource":
// This is an alias for the normal case of just using a managed resource
// type as a top-level symbol, which will serve as an escape mechanism
@@ -303,14 +315,16 @@ func parseResourceRef(mode ResourceMode, startRange hcl.Range, traversal hcl.Tra
var what string
switch mode {
case DataResourceMode:
what = "data source"
what = "a data source"
case EphemeralResourceMode:
what = "an ephemeral resource"
default:
what = "resource type"
what = "a resource type"
}
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference",
Detail: fmt.Sprintf(`A reference to a %s must be followed by at least one attribute access, specifying the resource name.`, what),
Detail: fmt.Sprintf(`A reference to %s must be followed by at least one attribute access, specifying the resource name.`, what),
Subject: traversal[1].SourceRange().Ptr(),
})
return nil, diags

View File

@@ -320,6 +320,104 @@ func TestParseRef(t *testing.T) {
`The "data" object must be followed by two attribute names: the data source type and the resource name.`,
},
// ephemeral
{
`ephemeral.external.foo`,
&Reference{
Subject: Resource{
Mode: EphemeralResourceMode,
Type: "external",
Name: "foo",
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 23, Byte: 22},
},
},
``,
},
{
`ephemeral.external.foo.bar`,
&Reference{
Subject: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "external",
Name: "foo",
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 23, Byte: 22},
},
Remaining: hcl.Traversal{
hcl.TraverseAttr{
Name: "bar",
SrcRange: hcl.Range{
Start: hcl.Pos{Line: 1, Column: 23, Byte: 22},
End: hcl.Pos{Line: 1, Column: 27, Byte: 26},
},
},
},
},
``,
},
{
`ephemeral.external.foo["baz"].bar`,
&Reference{
Subject: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "external",
Name: "foo",
},
Key: StringKey("baz"),
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29},
},
Remaining: hcl.Traversal{
hcl.TraverseAttr{
Name: "bar",
SrcRange: hcl.Range{
Start: hcl.Pos{Line: 1, Column: 30, Byte: 29},
End: hcl.Pos{Line: 1, Column: 34, Byte: 33},
},
},
},
},
``,
},
{
`ephemeral.external.foo["baz"]`,
&Reference{
Subject: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "external",
Name: "foo",
},
Key: StringKey("baz"),
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29},
},
},
``,
},
{
`ephemeral`,
nil,
`The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and its name.`,
},
{
`ephemeral.external`,
nil,
`The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and its name.`,
},
// local
{
`local.foo`,

View File

@@ -74,9 +74,13 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Trav
var diags tfdiags.Diagnostics
mode := ManagedResourceMode
if remain.RootName() == "data" {
switch remain.RootName() {
case "data":
mode = DataResourceMode
remain = remain[1:]
case "ephemeral":
mode = EphemeralResourceMode
remain = remain[1:]
}
typeName, name, diags := parseResourceTypeAndName(remain, mode)
@@ -135,9 +139,14 @@ func parseResourceUnderModule(moduleAddr Module, remain hcl.Traversal) (ConfigRe
var diags tfdiags.Diagnostics
mode := ManagedResourceMode
if remain.RootName() == "data" {
switch remain.RootName() {
case "data":
mode = DataResourceMode
remain = remain[1:]
case "ephemeral":
mode = EphemeralResourceMode
remain = remain[1:]
}
typeName, name, diags := parseResourceTypeAndName(remain, mode)
@@ -205,6 +214,13 @@ func parseResourceTypeAndName(remain hcl.Traversal, mode ResourceMode) (typeName
Detail: "A data source name is required.",
Subject: remain[0].SourceRange().Ptr(),
})
case EphemeralResourceMode:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid address",
Detail: "An ephemeral resource name is required.",
Subject: remain[0].SourceRange().Ptr(),
})
default:
panic("unknown mode")
}

View File

@@ -323,7 +323,158 @@ func TestParseTarget(t *testing.T) {
},
``,
},
// ephemeral
{
`ephemeral.aws_instance.baz`,
&Target{
Subject: AbsResource{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Module: RootModuleInstance,
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 27, Byte: 26},
},
},
``,
},
{
`ephemeral.aws_instance.baz[1]`,
&Target{
Subject: AbsResourceInstance{
Resource: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Key: IntKey(1),
},
Module: RootModuleInstance,
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 30, Byte: 29},
},
},
``,
},
{
`module.foo.ephemeral.aws_instance.baz`,
&Target{
Subject: AbsResource{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Module: ModuleInstance{
{Name: "foo"},
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 38, Byte: 37},
},
},
``,
},
{
`module.foo.module.bar.ephemeral.aws_instance.baz`,
&Target{
Subject: AbsResource{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Module: ModuleInstance{
{Name: "foo"},
{Name: "bar"},
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 49, Byte: 48},
},
},
``,
},
{
`module.foo.module.bar[0].ephemeral.aws_instance.baz`,
&Target{
Subject: AbsResource{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Module: ModuleInstance{
{Name: "foo", InstanceKey: NoKey},
{Name: "bar", InstanceKey: IntKey(0)},
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 52, Byte: 51},
},
},
``,
},
{
`module.foo.module.bar["a"].ephemeral.aws_instance.baz["hello"]`,
&Target{
Subject: AbsResourceInstance{
Resource: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Key: StringKey("hello"),
},
Module: ModuleInstance{
{Name: "foo", InstanceKey: NoKey},
{Name: "bar", InstanceKey: StringKey("a")},
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 63, Byte: 62},
},
},
``,
},
{
`module.foo.module.bar.ephemeral.aws_instance.baz["hello"]`,
&Target{
Subject: AbsResourceInstance{
Resource: ResourceInstance{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "aws_instance",
Name: "baz",
},
Key: StringKey("hello"),
},
Module: ModuleInstance{
{Name: "foo"},
{Name: "bar"},
},
},
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 58, Byte: 57},
},
},
``,
},
// errors
{
`aws_instance`,
nil,

View File

@@ -70,6 +70,16 @@ func ParseRemoveEndpoint(traversal hcl.Traversal) (*RemoveEndpoint, tfdiags.Diag
return nil, diags
}
if riAddr.Resource.Mode == EphemeralResourceMode {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Ephemeral resource address is not allowed",
Detail: "Ephemeral resources cannot be destroyed, and therefore, 'removed' blocks are not allowed to target them. To remove ephemeral resources from the state, you should remove the ephemeral resource block from the configuration.",
Subject: traversal.SourceRange().Ptr(),
})
return nil, diags
}
return &RemoveEndpoint{
RelSubject: riAddr,

View File

@@ -177,6 +177,52 @@ func TestParseRemoveEndpoint(t *testing.T) {
nil,
`Invalid address: A resource name is required.`,
},
// ephemeral
{
`ephemeral.foo.bar`,
nil,
`Ephemeral resource address is not allowed: Ephemeral resources cannot be destroyed, and therefore, 'removed' blocks are not allowed to target them. To remove ephemeral resources from the state, you should remove the ephemeral resource block from the configuration.`,
},
{
`ephemeral.foo.bar[0]`,
nil,
`Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
{
`ephemeral.foo.bar["a"]`,
nil,
`Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
{
`module.boop.ephemeral.foo.bar[0]`,
nil,
`Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
{
`module.boop.ephemeral.foo.bar["a"]`,
nil,
`Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
{
`module.foo.ephemeral`,
nil,
`Invalid address: Resource specification must include a resource type and name.`,
},
{
`module.foo.ephemeral.bar`,
nil,
`Invalid address: Resource specification must include a resource type and name.`,
},
{
`module.foo.ephemeral[0]`,
nil,
`Invalid address: Resource specification must include a resource type and name.`,
},
{
`module.foo.ephemeral.bar[0]`,
nil,
`Invalid address: A resource name is required.`,
},
}
for _, test := range tests {

View File

@@ -29,6 +29,8 @@ func (r Resource) String() string {
return fmt.Sprintf("%s.%s", r.Type, r.Name)
case DataResourceMode:
return fmt.Sprintf("data.%s.%s", r.Type, r.Name)
case EphemeralResourceMode:
return fmt.Sprintf("ephemeral.%s.%s", r.Type, r.Name)
default:
// Should never happen, but we'll return a string here rather than
// crashing just in case it does.
@@ -43,7 +45,7 @@ func (r Resource) Equal(o Resource) bool {
func (r Resource) Less(o Resource) bool {
switch {
case r.Mode != o.Mode:
return r.Mode == DataResourceMode
return ResourceModeLess(r.Mode, o.Mode)
case r.Type != o.Type:
return r.Type < o.Type
@@ -511,4 +513,38 @@ const (
// DataResourceMode indicates a data resource, as defined by
// "data" blocks in configuration.
DataResourceMode ResourceMode = 'D'
// EphemeralResourceMode indicates an ephemeral resource, as defined by
// the "ephemeral" blocks in configuration.
EphemeralResourceMode ResourceMode = 'E'
)
// ResourceModeLess is comparing two ResourceMode.
// The ranking is as follows: EphemeralResourceMode, DataResourceMode, ManagedResourceMode.
func ResourceModeLess(a, b ResourceMode) bool {
switch a {
case ManagedResourceMode:
return false // No other mode should be after ManagedResourceMode
case DataResourceMode:
return b == ManagedResourceMode // DataResourceMode is always lower than ManagedResourceMode
case EphemeralResourceMode:
return b == ManagedResourceMode || b == DataResourceMode // EphemeralResourceMode is always lower than ManagedResourceMode and DataResourceMode
}
return false
}
// ResourceModeBlockName returns the name of the block that the given ResourceMode is mapped to.
// At the time of writing this, the string values returned from this one are hardcoded all over the place so this is not
// the source of truth for the name of those blocks.
func ResourceModeBlockName(rm ResourceMode) string {
switch rm {
case ManagedResourceMode:
return "resource"
case DataResourceMode:
return "data"
case EphemeralResourceMode:
return "ephemeral"
default:
return "unknown"
}
}

View File

@@ -7,6 +7,7 @@ package addrs
import (
"fmt"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
@@ -26,6 +27,11 @@ func TestResourceEqual_true(t *testing.T) {
Type: "a",
Name: "b",
},
{
Mode: EphemeralResourceMode,
Type: "a",
Name: "b",
},
}
for _, r := range resources {
t.Run(r.String(), func(t *testing.T) {
@@ -53,6 +59,10 @@ func TestResourceEqual_false(t *testing.T) {
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "a", Name: "c"},
},
{
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "c"},
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s = %s", tc.left, tc.right), func(t *testing.T) {
@@ -85,6 +95,14 @@ func TestResourceInstanceEqual_true(t *testing.T) {
},
Key: StringKey("x"),
},
{
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "a",
Name: "b",
},
Key: StringKey("x"),
},
}
for _, r := range resources {
t.Run(r.String(), func(t *testing.T) {
@@ -110,6 +128,16 @@ func TestResourceInstanceEqual_false(t *testing.T) {
Key: IntKey(0),
},
},
{
ResourceInstance{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Key: IntKey(0),
},
ResourceInstance{
Resource: Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Key: IntKey(0),
},
},
{
ResourceInstance{
Resource: Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
@@ -140,6 +168,26 @@ func TestResourceInstanceEqual_false(t *testing.T) {
Key: StringKey("0"),
},
},
{
ResourceInstance{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Key: IntKey(0),
},
ResourceInstance{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Key: StringKey("0"),
},
},
{
ResourceInstance{
Resource: Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
Key: IntKey(0),
},
ResourceInstance{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Key: IntKey(0),
},
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s = %s", tc.left, tc.right), func(t *testing.T) {
@@ -157,6 +205,7 @@ func TestResourceInstanceEqual_false(t *testing.T) {
func TestAbsResourceInstanceEqual_true(t *testing.T) {
managed := Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"}
data := Resource{Mode: DataResourceMode, Type: "a", Name: "b"}
ephemeral := Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"}
foo, diags := ParseModuleInstanceStr("module.foo")
if len(diags) > 0 {
@@ -170,7 +219,9 @@ func TestAbsResourceInstanceEqual_true(t *testing.T) {
instances := []AbsResourceInstance{
managed.Instance(IntKey(0)).Absolute(foo),
data.Instance(IntKey(0)).Absolute(foo),
ephemeral.Instance(IntKey(0)).Absolute(foo),
managed.Instance(StringKey("a")).Absolute(foobar),
ephemeral.Instance(IntKey(0)).Absolute(foobar),
}
for _, r := range instances {
t.Run(r.String(), func(t *testing.T) {
@@ -184,6 +235,7 @@ func TestAbsResourceInstanceEqual_true(t *testing.T) {
func TestAbsResourceInstanceEqual_false(t *testing.T) {
managed := Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"}
data := Resource{Mode: DataResourceMode, Type: "a", Name: "b"}
ephemeral := Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"}
foo, diags := ParseModuleInstanceStr("module.foo")
if len(diags) > 0 {
@@ -210,6 +262,14 @@ func TestAbsResourceInstanceEqual_false(t *testing.T) {
managed.Instance(IntKey(0)).Absolute(foo),
managed.Instance(StringKey("0")).Absolute(foo),
},
{
ephemeral.Instance(IntKey(0)).Absolute(foo),
ephemeral.Instance(IntKey(0)).Absolute(foobar),
},
{
ephemeral.Instance(StringKey("0")).Absolute(foo),
ephemeral.Instance(IntKey(0)).Absolute(foo),
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s = %s", tc.left, tc.right), func(t *testing.T) {
@@ -305,6 +365,10 @@ func TestConfigResourceEqual_true(t *testing.T) {
Resource: Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
Module: RootModule,
},
{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Module: RootModule,
},
{
Resource: Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Module: Module{"foo"},
@@ -313,6 +377,10 @@ func TestConfigResourceEqual_true(t *testing.T) {
Resource: Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
Module: Module{"foo"},
},
{
Resource: Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Module: Module{"foo"},
},
}
for _, r := range resources {
t.Run(r.String(), func(t *testing.T) {
@@ -326,6 +394,7 @@ func TestConfigResourceEqual_true(t *testing.T) {
func TestConfigResourceEqual_false(t *testing.T) {
managed := Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"}
data := Resource{Mode: DataResourceMode, Type: "a", Name: "b"}
ephemeral := Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"}
foo := Module{"foo"}
foobar := Module{"foobar"}
@@ -337,10 +406,22 @@ func TestConfigResourceEqual_false(t *testing.T) {
ConfigResource{Resource: managed, Module: foo},
ConfigResource{Resource: data, Module: foo},
},
{
ConfigResource{Resource: managed, Module: foo},
ConfigResource{Resource: ephemeral, Module: foo},
},
{
ConfigResource{Resource: data, Module: foo},
ConfigResource{Resource: ephemeral, Module: foo},
},
{
ConfigResource{Resource: managed, Module: foo},
ConfigResource{Resource: managed, Module: foobar},
},
{
ConfigResource{Resource: ephemeral, Module: foo},
ConfigResource{Resource: ephemeral, Module: foobar},
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s = %s", tc.left, tc.right), func(t *testing.T) {
@@ -385,6 +466,17 @@ func TestParseConfigResource(t *testing.T) {
},
},
},
{
Input: "ephemeral.a.b",
WantConfigResource: ConfigResource{
Module: RootModule,
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "a",
Name: "b",
},
},
},
{
Input: "module.a.b.c",
WantConfigResource: ConfigResource{
@@ -407,6 +499,17 @@ func TestParseConfigResource(t *testing.T) {
},
},
},
{
Input: "module.a.ephemeral.b.c",
WantConfigResource: ConfigResource{
Module: []string{"a"},
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "b",
Name: "c",
},
},
},
{
Input: "module.a.module.b.c.d",
WantConfigResource: ConfigResource{
@@ -429,6 +532,17 @@ func TestParseConfigResource(t *testing.T) {
},
},
},
{
Input: "module.a.module.b.ephemeral.c.d",
WantConfigResource: ConfigResource{
Module: []string{"a", "b"},
Resource: Resource{
Mode: EphemeralResourceMode,
Type: "c",
Name: "d",
},
},
},
{
Input: "module.a.module.b",
WantErr: "Module address is not allowed: Expected reference to either resource or data block. Provided reference appears to be a module.",
@@ -449,6 +563,10 @@ func TestParseConfigResource(t *testing.T) {
Input: "module.a.module.b.data.c.d[0]",
WantErr: `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
{
Input: "module.a.module.b.ephemeral.c.d[0]",
WantErr: `Resource instance address with keys is not allowed: Resource address cannot be a resource instance (e.g. "null_resource.a[0]"), it must be a resource instead (e.g. "null_resource.a").`,
},
}
for _, test := range tests {
@@ -485,3 +603,82 @@ func TestParseConfigResource(t *testing.T) {
})
}
}
func TestResourceLess(t *testing.T) {
tests := []struct {
left, right Resource
want bool
}{
{
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
false,
},
{
Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
true,
},
{
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
false,
},
{
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "a", Name: "c"},
true,
},
{
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "b", Name: "b"},
true,
},
{
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
true,
},
{
Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"},
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
false,
},
{
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
true,
},
{
Resource{Mode: DataResourceMode, Type: "a", Name: "b"},
Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"},
false,
},
}
for _, tt := range tests {
wantComparison := ">"
if tt.want {
wantComparison = "<"
}
t.Run(fmt.Sprintf("%s %s %s", tt.left, wantComparison, tt.right), func(t *testing.T) {
if got, want := tt.left.Less(tt.right), tt.want; got != want {
t.Fatalf("wrong expectation between %q and %q. want: %t; got: %t", tt.left, tt.right, want, got)
}
})
}
}
func TestResourceSort(t *testing.T) {
managed := Resource{Mode: ManagedResourceMode, Type: "a", Name: "b"}
data := Resource{Mode: DataResourceMode, Type: "a", Name: "b"}
ephemeral := Resource{Mode: EphemeralResourceMode, Type: "a", Name: "b"}
got := []Resource{managed, data, ephemeral, managed, ephemeral, data}
sort.SliceStable(got, func(i, j int) bool { return got[i].Less(got[j]) })
want := []Resource{ephemeral, ephemeral, data, data, managed, managed}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("expected no diff meaning that sorting is not working properly.\ndiff: %s", diff)
}
}

View File

@@ -11,20 +11,26 @@ func _() {
_ = x[InvalidResourceMode-0]
_ = x[ManagedResourceMode-77]
_ = x[DataResourceMode-68]
_ = x[EphemeralResourceMode-69]
}
const (
_ResourceMode_name_0 = "InvalidResourceMode"
_ResourceMode_name_1 = "DataResourceMode"
_ResourceMode_name_1 = "DataResourceModeEphemeralResourceMode"
_ResourceMode_name_2 = "ManagedResourceMode"
)
var (
_ResourceMode_index_1 = [...]uint8{0, 16, 37}
)
func (i ResourceMode) String() string {
switch {
case i == 0:
return _ResourceMode_name_0
case i == 68:
return _ResourceMode_name_1
case 68 <= i && i <= 69:
i -= 68
return _ResourceMode_name_1[_ResourceMode_index_1[i]:_ResourceMode_index_1[i+1]]
case i == 77:
return _ResourceMode_name_2
default:

View File

@@ -78,6 +78,11 @@ func (p *Provider) ValidateDataResourceConfig(_ context.Context, req providers.V
return res
}
// ValidateEphemeralConfig is used to validate the ephemeral resource configuration values.
func (p *Provider) ValidateEphemeralConfig(context.Context, providers.ValidateEphemeralConfigRequest) providers.ValidateEphemeralConfigResponse {
panic("Should not be called directly, special case for terraform_remote_state")
}
// Configure configures and initializes the provider.
func (p *Provider) ConfigureProvider(context.Context, providers.ConfigureProviderRequest) providers.ConfigureProviderResponse {
// At this moment there is nothing to configure for the terraform provider,
@@ -125,6 +130,22 @@ func (p *Provider) ReadDataSourceEncrypted(ctx context.Context, req providers.Re
return res
}
// OpenEphemeralResource opens an ephemeral resource returning the ephemeral value returned from the provider.
func (p *Provider) OpenEphemeralResource(context.Context, providers.OpenEphemeralResourceRequest) providers.OpenEphemeralResourceResponse {
panic("Should not be called directly, special case for terraform_remote_state")
}
// RenewEphemeralResource is renewing an ephemeral resource returning only the private information from the provider.
func (p *Provider) RenewEphemeralResource(context.Context, providers.RenewEphemeralResourceRequest) providers.RenewEphemeralResourceResponse {
panic("Should not be called directly, special case for terraform_remote_state")
}
// CloseEphemeralResource is closing an ephemeral resource to allow the provider to clean up any possible remote information
// bound to the previously opened ephemeral resource.
func (p *Provider) CloseEphemeralResource(context.Context, providers.CloseEphemeralResourceRequest) providers.CloseEphemeralResourceResponse {
panic("Should not be called directly, special case for terraform_remote_state")
}
// Stop is called when the provider should halt any in-flight actions.
func (p *Provider) Stop(_ context.Context) error {
log.Println("[DEBUG] terraform provider cannot Stop")

View File

@@ -53,6 +53,7 @@ func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
Required: true,
},
},
Ephemeral: true,
}
resp.Provisioner = schema
return resp

View File

@@ -67,6 +67,7 @@ func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
Optional: true,
},
},
Ephemeral: true,
}
resp.Provisioner = schema

View File

@@ -55,6 +55,7 @@ func (p *provisioner) GetSchema() (resp provisioners.GetSchemaResponse) {
Optional: true,
},
},
Ephemeral: true,
}
resp.Provisioner = schema

View File

@@ -35,6 +35,10 @@ func collectInitialStatuses(into addrs.Map[addrs.ConfigCheckable, *configCheckab
addr := rc.Addr().InModule(moduleAddr)
collectInitialStatusForResource(into, addr, rc)
}
for _, rc := range cfg.Module.EphemeralResources {
addr := rc.Addr().InModule(moduleAddr)
collectInitialStatusForResource(into, addr, rc)
}
for _, oc := range cfg.Module.Outputs {
addr := oc.Addr().InModule(moduleAddr)

View File

@@ -65,6 +65,16 @@ func TestChecksHappyPath(t *testing.T) {
Type: "null_resource",
Name: "c",
}.InModule(moduleChild)
dataFoo := addrs.Resource{
Mode: addrs.DataResourceMode,
Type: "aws_s3_object",
Name: "foo",
}.InModule(moduleChild)
ephemeralBar := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "aws_secretsmanager_secret_version",
Name: "bar",
}.InModule(moduleChild)
childOutput := addrs.OutputValue{
Name: "b",
}.InModule(moduleChild)
@@ -80,6 +90,15 @@ func TestChecksHappyPath(t *testing.T) {
if addr := resourceB; cfg.Children["child"].Module.ResourceByAddr(addr.Resource) == nil {
t.Fatalf("configuration does not include %s", addr)
}
if addr := resourceC; cfg.Children["child"].Module.ResourceByAddr(addr.Resource) == nil {
t.Fatalf("configuration does not include %s", addr)
}
if addr := dataFoo; cfg.Children["child"].Module.ResourceByAddr(addr.Resource) == nil {
t.Fatalf("configuration does not include %s", addr)
}
if addr := ephemeralBar; cfg.Children["child"].Module.ResourceByAddr(addr.Resource) == nil {
t.Fatalf("configuration does not include %s", addr)
}
if addr := resourceNoChecks; cfg.Module.ResourceByAddr(addr.Resource) == nil {
t.Fatalf("configuration does not include %s", addr)
}
@@ -107,6 +126,14 @@ func TestChecksHappyPath(t *testing.T) {
t.Errorf("checks not detected for %s", addr)
missing++
}
if addr := dataFoo; !checks.ConfigHasChecks(addr) {
t.Errorf("checks not detected for %s", addr)
missing++
}
if addr := ephemeralBar; !checks.ConfigHasChecks(addr) {
t.Errorf("checks not detected for %s", addr)
missing++
}
if addr := rootOutput; !checks.ConfigHasChecks(addr) {
t.Errorf("checks not detected for %s", addr)
missing++
@@ -138,6 +165,8 @@ func TestChecksHappyPath(t *testing.T) {
resourceA,
resourceB,
resourceC,
dataFoo,
ephemeralBar,
rootOutput,
childOutput,
checkBlock,
@@ -168,6 +197,8 @@ func TestChecksHappyPath(t *testing.T) {
moduleChildInst := addrs.RootModuleInstance.Child("child", addrs.NoKey)
resourceInstB := resourceB.Resource.Absolute(moduleChildInst).Instance(addrs.NoKey)
resourceInstC0 := resourceC.Resource.Absolute(moduleChildInst).Instance(addrs.IntKey(0))
dataInstFoo := dataFoo.Resource.Absolute(moduleChildInst).Instance(addrs.NoKey)
ephemeralInstBar := ephemeralBar.Resource.Absolute(moduleChildInst).Instance(addrs.NoKey)
resourceInstC1 := resourceC.Resource.Absolute(moduleChildInst).Instance(addrs.IntKey(1))
childOutputInst := childOutput.OutputValue.Absolute(moduleChildInst)
checkBlockInst := checkBlock.Check.Absolute(addrs.RootModuleInstance)
@@ -184,6 +215,12 @@ func TestChecksHappyPath(t *testing.T) {
checks.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, StatusPass)
checks.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, StatusPass)
checks.ReportCheckableObjects(dataFoo, addrs.MakeSet[addrs.Checkable](dataInstFoo))
checks.ReportCheckResult(dataInstFoo, addrs.ResourcePrecondition, 0, StatusPass)
checks.ReportCheckableObjects(ephemeralBar, addrs.MakeSet[addrs.Checkable](ephemeralInstBar))
checks.ReportCheckResult(ephemeralInstBar, addrs.ResourcePrecondition, 0, StatusPass)
checks.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst))
checks.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, StatusPass)
@@ -206,7 +243,7 @@ func TestChecksHappyPath(t *testing.T) {
t.Errorf("incorrect final aggregate check status for %s: %s, but want %s", configAddr, got, want)
}
}
if got, want := configCount, 6; got != want {
if got, want := configCount, 8; got != want {
t.Errorf("incorrect number of known config addresses %d; want %d", got, want)
}
}
@@ -218,6 +255,8 @@ func TestChecksHappyPath(t *testing.T) {
resourceInstB,
resourceInstC0,
resourceInstC1,
dataInstFoo,
ephemeralInstBar,
childOutputInst,
checkBlockInst,
)

View File

@@ -1,3 +1,11 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.81.0"
}
}
}
resource "null_resource" "b" {
lifecycle {
precondition {
@@ -18,6 +26,27 @@ resource "null_resource" "c" {
}
}
data "aws_s3_object" "foo" {
lifecycle {
precondition {
condition = self.id == ""
error_message = "Impossible data."
}
}
bucket = "test-bucket"
key = "test-key"
}
ephemeral "aws_secretsmanager_secret_version" "bar" {
lifecycle {
precondition {
condition = self.id == ""
error_message = "Impossible ephemeral."
}
}
secret_id = "secret-manager-id"
}
output "b" {
value = null_resource.b.id
@@ -26,4 +55,3 @@ output "b" {
error_message = "B has no id."
}
}

View File

@@ -6,14 +6,20 @@
package e2etest
import (
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"slices"
"sort"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/e2e"
"github.com/opentofu/opentofu/internal/getproviders"
"github.com/opentofu/opentofu/internal/plans"
"github.com/zclconf/go-cty/cty"
)
@@ -232,3 +238,287 @@ func TestPrimaryChdirOption(t *testing.T) {
t.Errorf("incorrect destroy tally; want 0 destroyed:\n%s", stdout)
}
}
// This test is checking the workflow of the ephemeral resources.
// Check also the configuration files for comments. The idea is that at the time of
// writing, the configuration was done in such a way to fail later when the
// marks will be introduced for ephemeral values. Therefore, this test will
// fail later and will require adjustments.
//
// We want to validate that the plan file, state file and the output contain
// only the things that are needed:
// - The plan file needs to contain **only** the stubs of the ephemeral resources
// and not the values that it generated. This is needed for `tofu apply planfile`
// to be able to generate the execution node graphs correctly.
// - The state file must not contain the ephemeral resources changes.
// - The output should contain no changes related to ephemeral resources, but only
// the status update of their execution.
func TestEphemeralWorkflowAndOutput(t *testing.T) {
t.Parallel()
skipIfCannotAccessNetwork(t)
pluginVersionRunner := func(t *testing.T, testdataPath string, providerBuilderFunc func(*testing.T, string)) {
tf := e2e.NewBinary(t, tofuBin, testdataPath)
providerBuilderFunc(t, tf.WorkDir())
{ //// INIT
_, stderr, err := tf.Run("init", "-plugin-dir=cache")
if err != nil {
t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr)
}
}
{ //// PLAN
stdout, stderr, err := tf.Run("plan", "-out=tfplan")
if err != nil {
t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr)
}
// TODO ephemeral - this "value_wo" should be shown something like (write-only attribute). This will be handled during the work on the write-only attributes.
// TODO ephemeral - "out_ephemeral" should fail later when the marking of the outputs is implemented fully, so that should not be visible in the output
expectedChangesOutput := `OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
OpenTofu will perform the following actions:
# data.simple_resource.test_data2 will be read during apply
# (depends on a resource or a module with changes pending)
<= data "simple_resource" "test_data2" {
+ id = (known after apply)
+ value = "test"
}
# simple_resource.test_res will be created
+ resource "simple_resource" "test_res" {
+ value = "test value"
}
# simple_resource.test_res_second_provider will be created
+ resource "simple_resource" "test_res_second_provider" {
+ value = "just a simple resource to ensure that the second provider it's working fine"
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ final_output = "just a simple resource to ensure that the second provider it's working fine"
+ out_ephemeral = "rawvalue"`
expectedResourcesUpdates := map[string]bool{
"data.simple_resource.test_data1: Reading...": true,
"data.simple_resource.test_data1: Read complete after": true,
"ephemeral.simple_resource.test_ephemeral[0]: Opening...": true,
"ephemeral.simple_resource.test_ephemeral[0]: Open complete after": true,
"ephemeral.simple_resource.test_ephemeral[1]: Opening...": true,
"ephemeral.simple_resource.test_ephemeral[1]: Open complete after": true,
"ephemeral.simple_resource.test_ephemeral[0]: Closing...": true,
"ephemeral.simple_resource.test_ephemeral[0]: Close complete after": true,
"ephemeral.simple_resource.test_ephemeral[1]: Closing...": true,
"ephemeral.simple_resource.test_ephemeral[1]: Close complete after": true,
}
out := stripAnsi(stdout)
if !strings.Contains(out, expectedChangesOutput) {
t.Errorf("wrong plan output:\nstdout:%s\nstderr:%s", stdout, stderr)
}
for reg, required := range expectedResourcesUpdates {
if strings.Contains(out, reg) {
continue
}
if required {
t.Errorf("plan output does not contain required content %q\nout:%s", reg, out)
} else {
// We don't want to fail the test for outputs that are performance and time dependent
// as the renew status updates
t.Logf("plan output does not contain %q\nout:%s", reg, out)
}
}
// assert plan file content
plan, err := tf.Plan("tfplan")
if err != nil {
t.Fatalf("failed to read the plan file: %s", err)
}
idx := slices.IndexFunc(plan.Changes.Resources, func(src *plans.ResourceInstanceChangeSrc) bool {
return src.Addr.Resource.Resource.Mode == addrs.EphemeralResourceMode
})
if idx < 0 {
t.Fatalf("no ephemeral resource found in the plan file")
}
res := plan.Changes.Resources[idx]
if res.Before != nil {
t.Errorf("ephemeral resource %q from plan contains before value but it shouldn't: %s", res.Addr.String(), res.Before)
}
if res.After != nil {
t.Errorf("ephemeral resource %q from plan contains after value but it shouldn't: %s", res.Addr.String(), res.After)
}
if got, want := res.Action, plans.Open; got != want {
t.Errorf("ephemeral resource %q from plan contains wrong actions. want %q; got %q", res.Addr.String(), want, got)
}
}
{ //// APPLY
stdout, stderr, err := tf.Run("apply", "tfplan")
if err != nil {
t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr)
}
state, err := tf.LocalState()
if err != nil {
t.Fatalf("failed to read local state: %s", err)
}
expectedResources := map[string]bool{
"data.simple_resource.test_data1": true,
"data.simple_resource.test_data2": true,
"simple_resource.test_res": true,
"simple_resource.test_res_second_provider": true,
"ephemeral.simple_resource.test_ephemeral": false,
}
for res, exists := range expectedResources {
_, ok := state.RootModule().Resources[res]
if ok != exists {
t.Errorf("expected resource %q existence to be %t but got %t", res, exists, ok)
}
}
expectedChangesOutput := `Apply complete! Resources: 2 added, 0 changed, 0 destroyed.`
// NOTE: the non-required ones are dependent on the performance of the platform that this test is running on.
// In CI, if we would make this as required, this test might be flaky.
expectedResourcesUpdates := map[string]bool{
"ephemeral.simple_resource.test_ephemeral[0]: Opening...": true,
"ephemeral.simple_resource.test_ephemeral[0]: Open complete after": true,
"ephemeral.simple_resource.test_ephemeral[1]: Opening...": true,
"ephemeral.simple_resource.test_ephemeral[1]: Open complete after": true,
"data.simple_resource.test_data2: Reading...": true,
"data.simple_resource.test_data2: Read complete after": true,
"simple_resource.test_res: Creating...": true,
"simple_resource.test_res_second_provider: Creating...": true,
"simple_resource.test_res_second_provider: Creation complete after": true,
"ephemeral.simple_resource.test_ephemeral[0]: Renewing...": false,
"ephemeral.simple_resource.test_ephemeral[0]: Renew complete after": false,
"ephemeral.simple_resource.test_ephemeral[1]: Renewing...": false,
"ephemeral.simple_resource.test_ephemeral[1]: Renew complete after": false,
"simple_resource.test_res: Creation complete after": true,
"ephemeral.simple_resource.test_ephemeral[0]: Closing...": true,
"ephemeral.simple_resource.test_ephemeral[0]: Close complete after": true,
"ephemeral.simple_resource.test_ephemeral[1]: Closing...": true,
"simple_resource.test_res: Provisioning with 'local-exec'...": true,
`simple_resource.test_res (local-exec): Executing: ["/bin/sh" "-c" "echo \"visible test value\""]`: true,
"simple_resource.test_res (local-exec): visible test value": true,
"simple_resource.test_res (local-exec): (output suppressed due to ephemeral value in config)": true,
}
out := stripAnsi(stdout)
if !strings.Contains(out, expectedChangesOutput) {
t.Errorf("wrong apply output:\nstdout:%s\nstderr%s", stdout, stderr)
}
for reg, required := range expectedResourcesUpdates {
if strings.Contains(out, reg) {
continue
}
if required {
t.Errorf("apply output does not contain required content %q\nout:%s", reg, out)
} else {
// We don't want to fail the test for outputs that are performance and time dependent
// as the renew status updates
t.Logf("apply output does not contain %q\nout:%s", reg, out)
}
}
}
{ //// DESTROY
stdout, stderr, err := tf.Run("destroy", "-auto-approve")
if err != nil {
t.Fatalf("unexpected destroy error: %s\nstderr:\n%s", err, stderr)
}
if !strings.Contains(stdout, "Resources: 2 destroyed") {
t.Errorf("incorrect destroy tally; want 2 destroyed:\n%s", stdout)
}
state, err := tf.LocalState()
if err != nil {
t.Fatalf("failed to read state file after destroy: %s", err)
}
stateResources := state.RootModule().Resources
if len(stateResources) != 0 {
t.Errorf("wrong resources in state after destroy; want none, but still have:%s", spew.Sdump(stateResources))
}
}
}
cases := map[string]struct {
protoBinBuilder func(t *testing.T, workdir string)
}{
"proto version 5": {
protoBinBuilder: func(t *testing.T, workdir string) {
buildSimpleProvider(t, "5", workdir, "simple")
},
},
"proto version 6": {
protoBinBuilder: func(t *testing.T, workdir string) {
buildSimpleProvider(t, "6", workdir, "simple")
},
},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
pluginVersionRunner(t, "testdata/ephemeral-workflow", tt.protoBinBuilder)
})
}
}
// This function builds and moves to a directory called "cache" inside the workdir,
// the version of the provider passed as argument.
// Instead of using this function directly, the pre-configured functions buildV5TestProvider and
// buildV6TestProvider can be used.
func buildSimpleProvider(t *testing.T, version string, workdir string, buildOutName string) {
if !canRunGoBuild {
// We're running in a separate-build-then-run context, so we can't
// currently execute this test which depends on being able to build
// new executable at runtime.
//
// (See the comment on canRunGoBuild's declaration for more information.)
t.Skip("can't run without building a new provider executable")
}
var (
providerBinFileName string
implPkgName string
)
switch version {
case "5":
providerBinFileName = "simple"
implPkgName = "provider-simple"
case "6":
providerBinFileName = "simple6"
implPkgName = "provider-simple-v6"
default:
t.Fatalf("invalid version for simple provider")
}
if buildOutName != "" {
providerBinFileName = buildOutName
}
providerBuildOutDir := filepath.Join(workdir, fmt.Sprintf("terraform-provider-%s", providerBinFileName))
providerTmpBinPath := e2e.GoBuild(fmt.Sprintf("github.com/opentofu/opentofu/internal/%s/main", implPkgName), providerBuildOutDir)
extension := ""
if runtime.GOOS == "windows" {
extension = ".exe"
}
// Move the provider binaries into a directory that we will point tofu
// to using the -plugin-dir cli flag.
platform := getproviders.CurrentPlatform.String()
hashiDir := "cache/registry.opentofu.org/hashicorp/"
providerCacheDir := filepath.Join(workdir, hashiDir, fmt.Sprintf("%s/0.0.1/", providerBinFileName), platform)
if err := os.MkdirAll(providerCacheDir, os.ModePerm); err != nil {
t.Fatal(err)
}
providerFinalBinaryFilePath := filepath.Join(workdir, hashiDir, fmt.Sprintf("%s/0.0.1/", providerBinFileName), platform, fmt.Sprintf("terraform-provider-%s", providerBinFileName)) + extension
if err := os.Rename(providerTmpBinPath, providerFinalBinaryFilePath); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,89 @@
// the provider-plugin tests uses the -plugin-cache flag so terraform pulls the
// test binaries instead of reaching out to the registry.
terraform {
required_providers {
simple = {
source = "registry.opentofu.org/hashicorp/simple"
}
}
}
provider "simple" {
alias = "s1"
}
data "simple_resource" "test_data1" {
provider = simple.s1
value = "initial data value"
}
ephemeral "simple_resource" "test_ephemeral" {
count = 2
provider = simple.s1
// Having that "-with-renew" suffix, later when this value will be passed into "simple_resource.test_res.value_wo",
// the plugin will delay the response on some requests to allow ephemeral Renew calls to be performed.
value = "${data.simple_resource.test_data1.value}-with-renew"
}
resource "simple_resource" "test_res" {
provider = simple.s1
value = "test value"
// NOTE write-only arguments can reference ephemeral values.
value_wo = ephemeral.simple_resource.test_ephemeral[0].value
provisioner "local-exec" {
command = "echo \"visible ${self.value}\""
}
provisioner "local-exec" {
command = "echo \"not visible ${ephemeral.simple_resource.test_ephemeral[0].value}\""
}
// NOTE: value_wo cannot be used in a provisioner because it is returned as null by the provider so the interpolation fails
}
data "simple_resource" "test_data2" {
provider = simple.s1
value = "test"
lifecycle {
precondition {
// NOTE: precondition blocks can reference ephemeral values
condition = ephemeral.simple_resource.test_ephemeral[0].value != null
error_message = "test message"
}
}
}
locals{
simple_provider_cfg = ephemeral.simple_resource.test_ephemeral[0].value
}
provider "simple" {
alias = "s2"
// NOTE: Ensure that ephemeral values can be used to configure a provider.
// This is needed in two cases: during plan/apply and also during destroy.
// This test has been updated when DestroyEdgeTransformer was updated to
// not create dependencies between ephemeral resources and the destroy nodes.
// The "i_depend_on" field is just a simple configuration attribute of the provider
// to allow creation of dependencies between a resources from a previously
// initialized provider and the provider that is configured here.
// The "i_depend_on" field is having no functionality behind, in the provider context,
// but it's just a way for the "provider" block to create depedencies
// to other blocks.
i_depend_on = local.simple_provider_cfg
}
resource "simple_resource" "test_res_second_provider" {
provider = simple.s2
value = "just a simple resource to ensure that the second provider it's working fine"
}
module "call" {
source = "./mod"
in = ephemeral.simple_resource.test_ephemeral[0].value // NOTE: because variable "in" is marked as ephemeral, this should work as expected.
}
output "out_ephemeral" {
value = module.call.out2 // TODO: Because the output ephemeral marking is not done yet entirely, this is working now but remove this output once the marking of outputs are done completely.
}
output "final_output" {
value = simple_resource.test_res_second_provider.value
}

View File

@@ -0,0 +1,15 @@
variable "in" {
type = string
description = "Variable that is marked as ephemeral and doesn't matter what value is given in, ephemeral or not, the value evaluated for this variable will be marked as ephemeral"
ephemeral = true
}
output "out1" {
value = var.in
ephemeral = true // NOTE: because
}
output "out2" {
value = "rawvalue" // TODO ephemeral - this is returning a raw value and since incomplete work, the evaluated value is not marked as ephemeral. Once this will be fixed, the test should fail
ephemeral = true
}

View File

@@ -7,7 +7,6 @@ package command
import (
"context"
"errors"
"fmt"
"log"
"os"
@@ -87,7 +86,16 @@ func (c *ImportCommand) Run(args []string) int {
}
if addr.Resource.Resource.Mode != addrs.ManagedResourceMode {
diags = diags.Append(errors.New("A managed resource address is required. Importing into a data resource is not allowed."))
var what string
switch addr.Resource.Resource.Mode {
case addrs.DataResourceMode:
what = "a data resource"
case addrs.EphemeralResourceMode:
what = "an ephemeral resource"
default:
what = "a resource type"
}
diags = diags.Append(fmt.Errorf("A managed resource address is required. Importing into %s is not allowed.", what))
c.showDiagnostics(diags)
return 1
}

View File

@@ -883,7 +883,7 @@ func TestImportModuleInputVariableEvaluation(t *testing.T) {
}
}
func TestImport_dataResource(t *testing.T) {
func TestImport_nonManagedResource(t *testing.T) {
t.Chdir(testFixturePath("import-missing-resource-config"))
statePath := testTempFile(t)
@@ -899,19 +899,36 @@ func TestImport_dataResource(t *testing.T) {
},
}
args := []string{
"-state", statePath,
"data.test_data_source.foo",
"bar",
}
code := c.Run(args)
if code != 1 {
t.Fatalf("import succeeded; expected failure")
cases := []struct {
resAddr string
expectedErrMsg string
}{
{
resAddr: "data.test_data_source.foo",
expectedErrMsg: "A managed resource address is required. Importing into a data resource is not allowed.",
},
{
resAddr: "ephemeral.test_data_source.foo",
expectedErrMsg: "A managed resource address is required. Importing into an ephemeral resource is not allowed.",
},
}
for _, tt := range cases {
t.Run(tt.resAddr, func(t *testing.T) {
args := []string{
"-state", statePath,
tt.resAddr,
"bar",
}
code := c.Run(args)
if code != 1 {
t.Fatalf("import succeeded; expected failure")
}
msg := ui.ErrorWriter.String()
if want := `A managed resource address is required`; !strings.Contains(msg, want) {
t.Errorf("incorrect message\nwant substring: %s\ngot:\n%s", want, msg)
msg := ui.ErrorWriter.String()
if want := tt.expectedErrMsg; !strings.Contains(msg, want) {
t.Errorf("incorrect message\nwant substring: %s\ngot:\n%s", want, msg)
}
})
}
}

View File

@@ -43,6 +43,16 @@ func TestMarshalCheckStates(t *testing.T) {
outputBInstAddr := addrs.Checkable(addrs.OutputValue{Name: "b"}.Absolute(moduleChildAddr))
checkBlockAAddr := addrs.ConfigCheckable(addrs.Check{Name: "a"}.InModule(addrs.RootModule))
checkBlockAInstAddr := addrs.Checkable(addrs.Check{Name: "a"}.Absolute(addrs.RootModuleInstance))
ephemeralAAddr := addrs.ConfigCheckable(addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test",
Name: "a",
}.InModule(addrs.RootModule))
ephemeralAInstAddr := addrs.Checkable(addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance))
tests := map[string]struct {
Input *states.CheckResults
@@ -108,6 +118,17 @@ func TestMarshalCheckStates(t *testing.T) {
}),
),
}),
addrs.MakeMapElem(ephemeralAAddr, &states.CheckResultAggregate{
Status: checks.StatusFail,
ObjectResults: addrs.MakeMap(
addrs.MakeMapElem(ephemeralAInstAddr, &states.CheckResultObject{
Status: checks.StatusFail,
FailureMessages: []string{
"foo",
},
}),
),
}),
),
},
[]any{
@@ -132,6 +153,29 @@ func TestMarshalCheckStates(t *testing.T) {
},
"status": "fail",
},
map[string]any{
"address": map[string]any{
"kind": "resource",
"mode": "ephemeral",
"name": "a",
"to_display": "ephemeral.test.a",
"type": "test",
},
"instances": []any{
map[string]any{
"address": map[string]any{
"to_display": `ephemeral.test.a`,
},
"problems": []any{
map[string]any{
"message": "foo",
},
},
"status": "fail",
},
},
"status": "fail",
},
map[string]any{
"address": map[string]any{
"kind": "output_value",

View File

@@ -31,6 +31,8 @@ func makeStaticObjectAddr(addr addrs.ConfigCheckable) staticObjectAddr {
ret["mode"] = "managed"
case addrs.DataResourceMode:
ret["mode"] = "data"
case addrs.EphemeralResourceMode:
ret["mode"] = "ephemeral"
default:
panic(fmt.Sprintf("unsupported resource mode %#v", addr.Resource.Mode))
}

View File

@@ -341,8 +341,13 @@ func marshalModule(c *configs.Config, schemas *tofu.Schemas, addr string) (modul
if err != nil {
return module, err
}
ephemeralResources, err := marshalResources(c.Module.EphemeralResources, schemas, addr)
if err != nil {
return module, err
}
rs = append(managedResources, dataResources...)
rs = append(rs, ephemeralResources...)
module.Resources = rs
outputs := make(map[string]output)
@@ -520,6 +525,8 @@ func marshalResources(resources map[string]*configs.Resource, schemas *tofu.Sche
r.Mode = "managed"
case addrs.DataResourceMode:
r.Mode = "data"
case addrs.EphemeralResourceMode:
r.Mode = "ephemeral"
default:
return rs, fmt.Errorf("resource %s has an unsupported mode %s", r.Address, v.Mode.String())
}

View File

@@ -10,11 +10,13 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/tofu"
"github.com/zclconf/go-cty/cty"
)
func TestFindSourceProviderConfig(t *testing.T) {
@@ -114,6 +116,20 @@ func TestFindSourceProviderConfig(t *testing.T) {
func TestMarshalModule(t *testing.T) {
emptySchemas := &tofu.Schemas{}
providerAddr := addrs.NewProvider("host", "namespace", "type")
resSchema := map[string]providers.Schema{
"test_type": {
Version: 0,
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {
Type: cty.String,
Optional: true,
},
},
},
},
}
tests := map[string]struct {
Input *configs.Config
@@ -249,6 +265,90 @@ func TestMarshalModule(t *testing.T) {
},
},
},
"resources": {
Input: &configs.Config{
Module: &configs.Module{
ManagedResources: map[string]*configs.Resource{
"test_res": {
Mode: addrs.ManagedResourceMode,
Name: "test_res",
Type: "test_type",
Config: &hclsyntax.Body{
Attributes: map[string]*hclsyntax.Attribute{},
},
Provider: providerAddr,
},
},
DataResources: map[string]*configs.Resource{
"test_data": {
Mode: addrs.DataResourceMode,
Name: "test_data",
Type: "test_type",
Config: &hclsyntax.Body{
Attributes: map[string]*hclsyntax.Attribute{},
},
Provider: providerAddr,
},
},
EphemeralResources: map[string]*configs.Resource{
"test_ephemeral": {
Mode: addrs.EphemeralResourceMode,
Name: "test_ephemeral",
Type: "test_type",
Config: &hclsyntax.Body{
Attributes: map[string]*hclsyntax.Attribute{},
},
Provider: providerAddr,
},
},
},
},
Schemas: &tofu.Schemas{
Providers: map[addrs.Provider]providers.ProviderSchema{
providerAddr: {
ResourceTypes: resSchema,
EphemeralResources: resSchema,
DataSources: resSchema,
},
},
},
Want: module{
Outputs: map[string]output{},
ModuleCalls: map[string]moduleCall{},
Resources: []resource{
{
Address: "test_type.test_res",
Mode: "managed",
Type: "test_type",
Name: "test_res",
ProviderConfigKey: "test",
SchemaVersion: ptrTo[uint64](0),
Provisioners: nil,
Expressions: make(map[string]any),
},
{
Address: "data.test_type.test_data",
Mode: "data",
Type: "test_type",
Name: "test_data",
ProviderConfigKey: "test",
SchemaVersion: ptrTo[uint64](0),
Provisioners: nil,
Expressions: make(map[string]any),
},
{
Address: "ephemeral.test_type.test_ephemeral",
Mode: "ephemeral",
Type: "test_type",
Name: "test_ephemeral",
ProviderConfigKey: "test",
SchemaVersion: ptrTo[uint64](0),
Provisioners: nil,
Expressions: make(map[string]any),
},
},
},
},
// TODO: More test cases covering things other than input variables.
// (For now the other details are mainly tested in package command,
// as part of the tests for "tofu show".)
@@ -275,3 +375,12 @@ func TestMarshalModule(t *testing.T) {
})
}
}
// ptrTo is a helper to compensate for the fact that Go doesn't allow
// using the '&' operator unless the operand is directly addressable.
//
// Instead then, this function returns a pointer to a copy of the given
// value.
func ptrTo[T any](v T) *T {
return &v
}

View File

@@ -73,6 +73,7 @@ const (
ActionDelete ChangeAction = "delete"
ActionImport ChangeAction = "import"
ActionForget ChangeAction = "remove"
ActionOpen ChangeAction = "open"
)
func ParseChangeAction(action plans.Action) ChangeAction {
@@ -91,6 +92,9 @@ func ParseChangeAction(action plans.Action) ChangeAction {
return ActionDelete
case plans.Forget:
return ActionForget
case plans.Open:
return ActionOpen
// NOTE: Renew and Close missing on purpose since those are not meant to be stored
default:
return ActionNoOp
}

View File

@@ -43,6 +43,8 @@ func (plan Plan) getSchema(change jsonplan.ResourceChange) *jsonprovider.Schema
return plan.ProviderSchemas[change.ProviderName].ResourceSchemas[change.Type]
case jsonstate.DataResourceMode:
return plan.ProviderSchemas[change.ProviderName].DataSourceSchemas[change.Type]
case jsonstate.EphemeralResourceMode:
return plan.ProviderSchemas[change.ProviderName].EphemeralResourceSchemas[change.Type]
default:
panic("found unrecognized resource mode: " + change.Mode)
}
@@ -76,6 +78,10 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q
// Don't render anything for deleted data sources.
continue
}
if diff.change.Mode == jsonstate.EphemeralResourceMode {
// Do not render ephemeral changes. // TODO ephemeral add e2e test for this
continue
}
changes = append(changes, diff)
@@ -361,6 +367,10 @@ func renderHumanDiffDrift(renderer Renderer, diffs diffs, mode plans.Mode) bool
}
func renderHumanDiff(renderer Renderer, diff diff, cause string) (string, bool) {
if diff.change.Mode == jsonstate.EphemeralResourceMode {
// render nothing for ephemeral resources
return "", false
}
// Internally, our computed diffs can't tell the difference between a
// replace action (eg. CreateThenDestroy, DestroyThenCreate) and a simple
@@ -569,6 +579,8 @@ func actionDescription(action plans.Action) string {
return " [cyan]<=[reset] read (data resources)"
case plans.Forget:
return " [red].[reset] forget"
case plans.Open:
panic("ephemeral changes are not meant to be printed")
default:
panic(fmt.Sprintf("unrecognized change type: %s", action.String()))

View File

@@ -1069,6 +1069,22 @@ new line`),
# (2 unchanged attributes hidden)
}`,
},
"open ephemeral": {
Action: plans.Open,
Mode: addrs.EphemeralResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("name"),
}),
After: cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("name"),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"name": {Type: cty.String, Optional: true},
},
},
ExpectedOutput: ``,
},
}
runTestCases(t, testCases)
@@ -1685,6 +1701,26 @@ func TestResourceChange_JSON(t *testing.T) {
)
}`,
},
"ephemeral resource creation": {
Action: plans.Create,
Mode: addrs.EphemeralResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("ad4d04ed-ac40-43fc-ad4f-d2fc89b80793"),
"json_field": cty.StringVal(`{"secret_value": "8f6fb348-949d-4fa3-98a4-da9e66088257"}`),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("ad4d04ed-ac40-43fc-ad4f-d2fc89b80793"),
"json_field": cty.StringVal(`{"secret_value": "f8b90277-7b0b-4f15-9c31-aed5a642b274"}`),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"json_field": {Type: cty.String, Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ``,
},
}
runTestCases(t, testCases)
}
@@ -2290,6 +2326,74 @@ func TestResourceChange_primitiveSet(t *testing.T) {
# (1 unchanged attribute hidden)
}`,
},
"fails when ephemeral in the after marks": {
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"ami": cty.StringVal("ami-STATIC"),
"set_field": cty.NullVal(cty.Set(cty.String)),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.UnknownVal(cty.String),
"ami": cty.StringVal("ami-STATIC"),
"set_field": cty.SetVal([]cty.Value{
cty.StringVal("new-element"),
}),
}),
AfterValMarks: []cty.PathValueMarks{
{
Path: cty.GetAttrPath("set_field").IndexInt(0),
Marks: map[interface{}]struct{}{
marks.Ephemeral: {},
},
},
},
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
"set_field": {Type: cty.Set(cty.String), Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ``,
ExpectedErr: fmt.Errorf("test_instance.example: ephemeral marks found at the following paths:\n.set_field[0]"),
},
"fails when ephemeral in the before marks": {
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"ami": cty.StringVal("ami-STATIC"),
"set_field": cty.NullVal(cty.Set(cty.String)),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.UnknownVal(cty.String),
"ami": cty.StringVal("ami-STATIC"),
"set_field": cty.SetVal([]cty.Value{
cty.StringVal("new-element"),
}),
}),
BeforeValMarks: []cty.PathValueMarks{
{
Path: cty.GetAttrPath("set_field").IndexInt(0),
Marks: map[interface{}]struct{}{
marks.Ephemeral: {},
},
},
},
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
"set_field": {Type: cty.Set(cty.String), Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ``,
ExpectedErr: fmt.Errorf("test_instance.example: ephemeral marks found at the following paths:\n.set_field[0]"),
},
"in-place update - first insertion": {
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
@@ -7172,6 +7276,7 @@ type testCase struct {
RequiredReplace cty.PathSet
ExpectedOutput string
PrevRunAddr addrs.AbsResourceInstance
ExpectedErr error
}
func runTestCases(t *testing.T, testCases map[string]testCase) {
@@ -7253,23 +7358,41 @@ func runTestCases(t *testing.T, testCases map[string]testCase) {
Block: tc.Schema,
},
},
EphemeralResources: map[string]providers.Schema{
src.Addr.Resource.Resource.Type: {
Block: tc.Schema,
},
},
},
},
}
jsonchanges, err := jsonplan.MarshalResourceChanges([]*plans.ResourceInstanceChangeSrc{src}, tfschemas)
if err != nil {
t.Errorf("failed to marshal resource changes: %s", err.Error())
return
if tc.ExpectedErr == nil {
t.Errorf("failed to marshal resource changes.\ngot err:\n%s\nbut no expected err", err)
} else {
gotErr := err.Error()
wantErr := tc.ExpectedErr.Error()
if gotErr != wantErr {
t.Errorf("failed to marshal resource changes.\ngot err:\n%s\nexpected err:\n%s", gotErr, wantErr)
}
}
} else if tc.ExpectedErr != nil {
t.Errorf("failed to marshal resource changes.\nwant err:\n%s\nbut got none", tc.ExpectedErr)
}
jsonschemas := jsonprovider.MarshalForRenderer(tfschemas)
change := structured.FromJsonChange(jsonchanges[0].Change, attribute_path.AlwaysMatcher())
renderer := Renderer{Colorize: color}
diff := diff{
change: jsonchanges[0],
diff: differ.ComputeDiffForBlock(change, jsonschemas[jsonchanges[0].ProviderName].ResourceSchemas[jsonchanges[0].Type].Block),
var output string
if len(jsonchanges) > 0 {
change := structured.FromJsonChange(jsonchanges[0].Change, attribute_path.AlwaysMatcher())
renderer := Renderer{Colorize: color}
diff := diff{
change: jsonchanges[0],
diff: differ.ComputeDiffForBlock(change, jsonschemas[jsonchanges[0].ProviderName].ResourceSchemas[jsonchanges[0].Type].Block),
}
output, _ = renderHumanDiff(renderer, diff, proposedChange)
}
output, _ := renderHumanDiff(renderer, diff, proposedChange)
if diff := cmp.Diff(output, tc.ExpectedOutput); diff != "" {
t.Errorf("wrong output\nexpected:\n%s\nactual:\n%s\ndiff:\n%s\n", tc.ExpectedOutput, output, diff)
}

View File

@@ -6,6 +6,7 @@
package jsonformat
import (
"fmt"
"sort"
ctyjson "github.com/zclconf/go-cty/cty/json"
@@ -36,6 +37,8 @@ func (state State) GetSchema(resource jsonstate.Resource) *jsonprovider.Schema {
return state.ProviderSchemas[resource.ProviderName].ResourceSchemas[resource.Type]
case jsonstate.DataResourceMode:
return state.ProviderSchemas[resource.ProviderName].DataSourceSchemas[resource.Type]
case jsonstate.EphemeralResourceMode:
panic(fmt.Errorf("ephemeral resources are not meant to be stored in the state file but schema for ephemeral %s.%s has been requested", resource.Type, resource.Name))
default:
panic("found unrecognized resource mode: " + resource.Mode)
}
@@ -74,6 +77,8 @@ func (state State) renderHumanStateModule(renderer Renderer, module jsonstate.Mo
case jsonstate.DataResourceMode:
change := structured.FromJsonResource(resource)
renderer.Streams.Printf("data %q %q %s", resource.Type, resource.Name, differ.ComputeDiffForBlock(change, schema.Block).RenderHuman(0, opts))
case jsonstate.EphemeralResourceMode:
panic(fmt.Errorf("ephemeral resource %s %s not allowed to be stored in the state", resource.Type, resource.Name))
default:
panic("found unrecognized resource mode: " + resource.Mode)
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
@@ -396,6 +397,16 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
r.PreviousAddress = rc.PrevRunAddr.String()
}
if addr.Resource.Resource.Mode == addrs.EphemeralResourceMode {
// We need to write ephemeral resources to the plan file to be able to build
// the apply graph on `tofu apply <planfile>`.
// The DiffTransformer needs the changes from the plan to be able to generate
// executable resource instance graph nodes, so we are adding the ephemeral resources too.
// Even though we are writing these, the actual values of the ephemeral *must not*
// be written to the plan so nullify these.
rc.ChangeSrc.Before = nil
rc.ChangeSrc.After = nil
}
dataSource := addr.Resource.Resource.Mode == addrs.DataResourceMode
// We create "delete" actions for data resources so we can clean up
// their entries in state, but this is an implementation detail that
@@ -431,11 +442,14 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
if err != nil {
return nil, err
}
marks := rc.BeforeValMarks
if schema.ContainsSensitive() {
marks = append(marks, schema.ValueMarks(changeV.Before, nil)...)
valMarks := rc.BeforeValMarks
if schema.ContainsMarks() {
valMarks = append(valMarks, schema.ValueMarks(changeV.Before, nil)...)
}
bs := jsonstate.SensitiveAsBoolWithPathValueMarks(changeV.Before, marks)
if err := ensureEphemeralMarksAreValid(addr, valMarks); err != nil {
return nil, err
}
bs := jsonstate.SensitiveAsBoolWithPathValueMarks(changeV.Before, valMarks)
beforeSensitive, err = ctyjson.Marshal(bs, bs.Type())
if err != nil {
return nil, err
@@ -460,11 +474,14 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
}
afterUnknown = unknownAsBool(changeV.After)
}
marks := rc.AfterValMarks
if schema.ContainsSensitive() {
marks = append(marks, schema.ValueMarks(changeV.After, nil)...)
valMarks := rc.AfterValMarks
if schema.ContainsMarks() {
valMarks = append(valMarks, schema.ValueMarks(changeV.After, nil)...)
}
as := jsonstate.SensitiveAsBoolWithPathValueMarks(changeV.After, marks)
if err := ensureEphemeralMarksAreValid(addr, valMarks); err != nil {
return nil, err
}
as := jsonstate.SensitiveAsBoolWithPathValueMarks(changeV.After, valMarks)
afterSensitive, err = ctyjson.Marshal(as, as.Type())
if err != nil {
return nil, err
@@ -514,6 +531,8 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
r.Mode = jsonstate.ManagedResourceMode
case addrs.DataResourceMode:
r.Mode = jsonstate.DataResourceMode
case addrs.EphemeralResourceMode:
r.Mode = jsonstate.EphemeralResourceMode
default:
return nil, fmt.Errorf("resource %s has an unsupported mode %s", r.Address, addr.Resource.Resource.Mode.String())
}
@@ -562,6 +581,18 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
return ret, nil
}
func ensureEphemeralMarksAreValid(addr addrs.AbsResourceInstance, valMarks []cty.PathValueMarks) error {
// ephemeral resources will have the ephemeral mark at the root of the value, got from schema.ValueMarks
// so we don't want to error for those particular ones
if addr.Resource.Resource.Mode == addrs.EphemeralResourceMode {
return nil
}
if err := marks.EnsureNoEphemeralMarks(valMarks); err != nil {
return fmt.Errorf("%s: %w", addr, err)
}
return nil
}
// GenerateChange is used to receive two values and calculate the difference
// between them in order to return a Change struct
func GenerateChange(beforeVal, afterVal cty.Value) (*Change, error) {
@@ -868,6 +899,8 @@ func actionString(action string) []string {
return []string{"delete", "create"}
case "Forget":
return []string{"forget"}
case "Open":
return []string{"open"}
default:
return []string{action}
}
@@ -899,6 +932,8 @@ func UnmarshalActions(actions []string) plans.Action {
return plans.NoOp
case "forget":
return plans.Forget
case "open":
return plans.Open
}
}

View File

@@ -205,6 +205,8 @@ func marshalPlanResources(changeMap map[string]*plans.ResourceInstanceChangeSrc,
resource.Mode = "managed"
case addrs.DataResourceMode:
resource.Mode = "data"
case addrs.EphemeralResourceMode:
resource.Mode = "ephemeral"
default:
return nil, fmt.Errorf("resource %s has an unsupported mode %s",
r.Addr.String(),

View File

@@ -24,10 +24,11 @@ type Providers struct {
}
type Provider struct {
Provider *Schema `json:"provider,omitempty"`
ResourceSchemas map[string]*Schema `json:"resource_schemas,omitempty"`
DataSourceSchemas map[string]*Schema `json:"data_source_schemas,omitempty"`
Functions map[string]*Function `json:"functions,omitempty"`
Provider *Schema `json:"provider,omitempty"`
ResourceSchemas map[string]*Schema `json:"resource_schemas,omitempty"`
DataSourceSchemas map[string]*Schema `json:"data_source_schemas,omitempty"`
EphemeralResourceSchemas map[string]*Schema `json:"ephemeral_resource_schemas,omitempty"`
Functions map[string]*Function `json:"functions,omitempty"`
}
func newProviders() *Providers {
@@ -59,9 +60,10 @@ func Marshal(s *tofu.Schemas) ([]byte, error) {
func marshalProvider(tps providers.ProviderSchema) *Provider {
return &Provider{
Provider: marshalSchema(tps.Provider),
ResourceSchemas: marshalSchemas(tps.ResourceTypes),
DataSourceSchemas: marshalSchemas(tps.DataSources),
Functions: marshalFunctions(tps.Functions),
Provider: marshalSchema(tps.Provider),
ResourceSchemas: marshalSchemas(tps.ResourceTypes),
DataSourceSchemas: marshalSchemas(tps.DataSources),
EphemeralResourceSchemas: marshalSchemas(tps.EphemeralResources),
Functions: marshalFunctions(tps.Functions),
}
}

View File

@@ -25,10 +25,11 @@ func TestMarshalProvider(t *testing.T) {
{
providers.ProviderSchema{},
&Provider{
Provider: &Schema{},
ResourceSchemas: map[string]*Schema{},
DataSourceSchemas: map[string]*Schema{},
Functions: map[string]*Function{},
Provider: &Schema{},
ResourceSchemas: map[string]*Schema{},
DataSourceSchemas: map[string]*Schema{},
EphemeralResourceSchemas: map[string]*Schema{},
Functions: map[string]*Function{},
},
},
{
@@ -147,6 +148,47 @@ func TestMarshalProvider(t *testing.T) {
},
},
},
EphemeralResourceSchemas: map[string]*Schema{
"test_ephemeral_resource": {
Version: 4,
Block: &Block{
Attributes: map[string]*Attribute{
"id": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
Computed: true,
DescriptionKind: "plain",
},
"secret": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
DescriptionKind: "plain",
},
},
BlockTypes: map[string]*BlockType{
"notes": {
Block: &Block{
Attributes: map[string]*Attribute{
"secret1": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
DescriptionKind: "plain",
},
"secret2": {
AttributeType: json.RawMessage(`"string"`),
Optional: true,
DescriptionKind: "plain",
},
},
DescriptionKind: "plain",
},
NestingMode: "list",
},
},
DescriptionKind: "plain",
},
},
},
Functions: map[string]*Function{},
},
},
@@ -225,6 +267,28 @@ func testProvider() providers.ProviderSchema {
},
},
},
EphemeralResources: map[string]providers.Schema{
"test_ephemeral_resource": {
Version: 4,
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"secret": {Type: cty.String, Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"notes": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"secret1": {Type: cty.String, Optional: true},
"secret2": {Type: cty.String, Optional: true},
},
},
},
},
},
},
},
Functions: map[string]providers.FunctionSpec{},
}
}

View File

@@ -27,8 +27,9 @@ const (
// consuming parser.
FormatVersion = "1.0"
ManagedResourceMode = "managed"
DataResourceMode = "data"
ManagedResourceMode = "managed"
DataResourceMode = "data"
EphemeralResourceMode = "ephemeral"
)
// State is the top-level representation of the json format of a tofu
@@ -365,8 +366,9 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
resAddr := r.Addr.Resource
instAddr := r.Addr.Instance(k)
current := Resource{
Address: r.Addr.Instance(k).String(),
Address: instAddr.String(),
Type: resAddr.Type,
Name: resAddr.Name,
ProviderName: r.ProviderConfig.Provider.String(),
@@ -384,6 +386,8 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
current.Mode = ManagedResourceMode
case addrs.DataResourceMode:
current.Mode = DataResourceMode
case addrs.EphemeralResourceMode:
return ret, fmt.Errorf("ephemeral resource %q detected in the current state. This is an error in OpenTofu", resAddr.String())
default:
return ret, fmt.Errorf("resource %s has an unsupported mode %s",
resAddr.String(),
@@ -415,11 +419,18 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
current.AttributeValues = marshalAttributeValues(riObj.Value)
value, marks := riObj.Value.UnmarkDeepWithPaths()
if schema.ContainsSensitive() {
marks = append(marks, schema.ValueMarks(value, nil)...)
value, valMarks := riObj.Value.UnmarkDeepWithPaths()
if schema.ContainsMarks() {
valMarks = append(valMarks, schema.ValueMarks(value, nil)...)
}
s := SensitiveAsBoolWithPathValueMarks(value, marks)
// NOTE: Even though at this point, the resources that are processed here
// should have no ephemeral mark, we want to validate that before having
// these written to the state.
if err := marks.EnsureNoEphemeralMarks(valMarks); err != nil {
return nil, fmt.Errorf("%s: %w", instAddr, err)
}
s := SensitiveAsBoolWithPathValueMarks(value, valMarks)
v, err := ctyjson.Marshal(s, s.Type())
if err != nil {
return nil, err
@@ -466,11 +477,17 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module
deposed.AttributeValues = marshalAttributeValues(riObj.Value)
value, marks := riObj.Value.UnmarkDeepWithPaths()
if schema.ContainsSensitive() {
marks = append(marks, schema.ValueMarks(value, nil)...)
value, valMarks := riObj.Value.UnmarkDeepWithPaths()
if schema.ContainsMarks() {
valMarks = append(valMarks, schema.ValueMarks(value, nil)...)
}
s := SensitiveAsBool(value.MarkWithPaths(marks))
// NOTE: Even though at this point, the resources that are processed here
// should have no ephemeral mark, we want to validate that before having
// these written to the state.
if err := marks.EnsureNoEphemeralMarks(valMarks); err != nil {
return nil, fmt.Errorf("%s: %w", instAddr, err)
}
s := SensitiveAsBool(value.MarkWithPaths(valMarks))
v, err := ctyjson.Marshal(s, s.Type())
if err != nil {
return nil, err

View File

@@ -8,6 +8,7 @@ package jsonstate
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -203,13 +204,13 @@ func TestMarshalResources(t *testing.T) {
Resources map[string]*states.Resource
Schemas *tofu.Schemas
Want []Resource
Err bool
ErrMsg string
}{
"nil": {
nil,
nil,
nil,
false,
"",
},
"single resource": {
map[string]*states.Resource{
@@ -251,7 +252,49 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"single data source": {
map[string]*states.Resource{
"test_thing.baz": {
Addr: addrs.AbsResource{
Resource: addrs.Resource{
Mode: addrs.DataResourceMode,
Type: "test_thing",
Name: "bar",
},
},
Instances: map[addrs.InstanceKey]*states.ResourceInstance{
addrs.NoKey: {
Current: &states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"foo":"baz"}`),
},
},
},
ProviderConfig: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
},
},
testSchemas(),
[]Resource{
{
Address: "data.test_thing.bar",
Mode: "data",
Type: "test_thing",
Name: "bar",
Index: nil,
ProviderName: "registry.opentofu.org/hashicorp/test",
AttributeValues: AttributeValues{
"foo": json.RawMessage(`"baz"`),
"bar": json.RawMessage(`null`),
},
SensitiveValues: json.RawMessage("{\"bar\":true}"),
},
},
"",
},
"single resource_with_sensitive": {
map[string]*states.Resource{
@@ -293,9 +336,9 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"resource with marks": {
"resource with sensitive marks": {
map[string]*states.Resource{
"test_thing.bar": {
Addr: addrs.AbsResource{
@@ -339,7 +382,39 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage(`{"foozles":true}`),
},
},
false,
"",
},
"resource with ephemeral": {
map[string]*states.Resource{
"test_thing.bar": {
Addr: addrs.AbsResource{
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "bar",
},
},
Instances: map[addrs.InstanceKey]*states.ResourceInstance{
addrs.NoKey: {
Current: &states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"foozles":"confuzles"}`),
AttrSensitivePaths: []cty.PathValueMarks{{
Path: cty.Path{cty.GetAttrStep{Name: "foozles"}},
Marks: cty.NewValueMarks(marks.Ephemeral)},
},
},
},
},
ProviderConfig: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
},
},
testSchemas(),
nil,
"test_thing.bar: ephemeral marks found at the following paths:\n.foozles",
},
"single resource wrong schema": {
map[string]*states.Resource{
@@ -368,7 +443,7 @@ func TestMarshalResources(t *testing.T) {
},
testSchemas(),
nil,
true,
"schema version 1 for test_thing.bar in state does not match version 0 from the provider",
},
"resource with count": {
map[string]*states.Resource{
@@ -410,7 +485,7 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"resource with for_each": {
map[string]*states.Resource{
@@ -452,7 +527,7 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"deposed resource": {
map[string]*states.Resource{
@@ -497,7 +572,7 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"deposed and current resource": {
map[string]*states.Resource{
@@ -559,7 +634,7 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage("{\"foozles\":true}"),
},
},
false,
"",
},
"resource with marked map attr": {
map[string]*states.Resource{
@@ -604,17 +679,48 @@ func TestMarshalResources(t *testing.T) {
SensitiveValues: json.RawMessage(`{"data":true}`),
},
},
false,
``,
},
"single ephemeral resource": {
map[string]*states.Resource{
"test_thing.baz": {
Addr: addrs.AbsResource{
Resource: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "bar",
},
},
Instances: map[addrs.InstanceKey]*states.ResourceInstance{
addrs.NoKey: {
Current: &states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"foo":"baz"}`),
},
},
},
ProviderConfig: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
},
},
testSchemas(),
nil,
`ephemeral resource "ephemeral.test_thing.bar" detected in the current state. This is an error in OpenTofu`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, err := marshalResources(test.Resources, addrs.RootModuleInstance, test.Schemas)
if test.Err {
if test.ErrMsg != "" {
if err == nil {
t.Fatal("succeeded; want error")
}
if !strings.Contains(err.Error(), test.ErrMsg) {
t.Fatalf("expected msg %q in error %q", test.ErrMsg, err.Error())
}
return
} else if err != nil {
t.Fatalf("unexpected error: %s", err)
@@ -862,6 +968,26 @@ func testSchemas() *tofu.Schemas {
},
},
},
DataSources: map[string]providers.Schema{
"test_thing": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true, Computed: true},
"bar": {Type: cty.String, Optional: true, Sensitive: true},
},
},
},
},
EphemeralResources: map[string]providers.Schema{
"test_thing": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {Type: cty.String, Optional: true, Computed: true},
"bar": {Type: cty.String, Optional: true, Sensitive: true},
},
},
},
},
},
},
}

View File

@@ -493,8 +493,19 @@ func (c *StateMvCommand) sourceObjectAddrs(state *states.State, matched addrs.Ta
func (c *StateMvCommand) validateResourceMove(addrFrom, addrTo addrs.AbsResource) tfdiags.Diagnostics {
const msgInvalidRequest = "Invalid state move request"
var diags tfdiags.Diagnostics
if addrFrom.Resource.Mode == addrs.EphemeralResourceMode || addrTo.Resource.Mode == addrs.EphemeralResourceMode {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
msgInvalidRequest,
"Ephemeral resources cannot be used as sources or targets for the move action. Just update your configuration accordingly.",
),
)
return diags
}
if addrFrom.Resource.Mode != addrTo.Resource.Mode {
switch addrFrom.Resource.Mode {
case addrs.ManagedResourceMode:
@@ -509,6 +520,7 @@ func (c *StateMvCommand) validateResourceMove(addrFrom, addrTo addrs.AbsResource
msgInvalidRequest,
fmt.Sprintf("Cannot move %s to %s: a data resource can be moved only to another data resource address.", addrFrom, addrTo),
))
// NOTE: No need for the ephemeral resource in this switch block since it is handled at the top of the method.
default:
// In case a new mode is added in future, this unhelpful error is better than nothing.
diags = diags.Append(tfdiags.Sourceless(

View File

@@ -14,6 +14,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/mitchellh/cli"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/states"
@@ -1819,6 +1820,107 @@ func TestStateMv_checkRequiredVersion(t *testing.T) {
}
}
func TestValidateResourceMove(t *testing.T) {
var (
c = &StateMvCommand{}
managedRes = addrs.AbsResource{Resource: addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_type", Name: "test_name"}}
dataRes = addrs.AbsResource{Resource: addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_type", Name: "test_name"}}
ephemeralRes = addrs.AbsResource{Resource: addrs.Resource{Mode: addrs.EphemeralResourceMode, Type: "test_type", Name: "test_name"}}
)
tests := map[string]struct {
src, target addrs.AbsResource
wantDiags tfdiags.Diagnostics
}{
"resource to resource": {
managedRes,
managedRes,
tfdiags.Diagnostics{},
},
"data to data": {
dataRes,
dataRes,
tfdiags.Diagnostics{},
},
"ephemeral to ephemeral": {
ephemeralRes,
ephemeralRes,
tfdiags.Diagnostics{tfdiags.Sourceless(
tfdiags.Error,
"Invalid state move request",
"Ephemeral resources cannot be used as sources or targets for the move action. Just update your configuration accordingly.",
)},
},
"resource to data": {
managedRes,
dataRes,
tfdiags.Diagnostics{tfdiags.Sourceless(
tfdiags.Error,
"Invalid state move request",
fmt.Sprintf("Cannot move %s to %s: a managed resource can be moved only to another managed resource address.", managedRes, dataRes),
)},
},
"resource to ephemeral": {
managedRes,
ephemeralRes,
tfdiags.Diagnostics{tfdiags.Sourceless(
tfdiags.Error,
"Invalid state move request",
"Ephemeral resources cannot be used as sources or targets for the move action. Just update your configuration accordingly.",
)},
},
"data to resource": {
dataRes,
managedRes,
tfdiags.Diagnostics{tfdiags.Sourceless(
tfdiags.Error,
"Invalid state move request",
fmt.Sprintf("Cannot move %s to %s: a data resource can be moved only to another data resource address.", dataRes, managedRes),
)},
},
"data to ephemeral": {
dataRes,
ephemeralRes,
tfdiags.Diagnostics{tfdiags.Sourceless(
tfdiags.Error,
"Invalid state move request",
"Ephemeral resources cannot be used as sources or targets for the move action. Just update your configuration accordingly.",
)},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
diags := c.validateResourceMove(tt.src, tt.target)
if got, want := len(diags), len(tt.wantDiags); got != want {
t.Fatalf("expected to have exactly %d diagnostic(s). got: %d", want, got)
}
for i, wantDiag := range tt.wantDiags {
sameDiagnostic(t, diags[i], wantDiag)
}
})
}
}
func sameDiagnostic(t *testing.T, gotD, wantD tfdiags.Diagnostic) {
if got, want := gotD.Severity(), wantD.Severity(); got != want {
t.Errorf("wrong severity. got %q; want %q", got, want)
}
if got, want := gotD.Description().Address, wantD.Description().Address; got != want {
t.Errorf("wrong description. got %q; want %q", got, want)
}
if got, want := gotD.Description().Detail, wantD.Description().Detail; got != want {
t.Errorf("wrong detail. got %q; want %q", got, want)
}
if got, want := gotD.Description().Summary, wantD.Description().Summary; got != want {
t.Errorf("wrong summary. got %q; want %q", got, want)
}
if got, want := gotD.ExtraInfo(), wantD.ExtraInfo(); got != want {
t.Errorf("wrong extra info. got %q; want %q", got, want)
}
}
const testStateMvOutputOriginal = `
test_instance.baz:
ID = foo

View File

@@ -65,6 +65,7 @@ var (
},
},
},
// TODO ephemeral - when implementing testing support for ephemeral resources, consider configuring ephemeral schema here
}
)

View File

@@ -96,8 +96,9 @@ func (h *countHook) PostDiff(addr addrs.AbsResourceInstance, gen states.Generati
h.Lock()
defer h.Unlock()
// We don't count anything for data resources
if addr.Resource.Resource.Mode == addrs.DataResourceMode {
// We don't count anything for data resources and neither for the ephemeral ones.
// TODO ephemeral - test this after the ephemeral resources are introduced entirely
if addr.Resource.Resource.Mode == addrs.DataResourceMode || addr.Resource.Resource.Mode == addrs.EphemeralResourceMode {
return tofu.HookActionContinue, nil
}

View File

@@ -265,6 +265,38 @@ func TestCountHookPostDiff_DataSource(t *testing.T) {
}
}
func TestCountHookPostDiff_Ephemeral(t *testing.T) {
h := new(countHook)
resources := map[string]plans.Action{
"foo": plans.Delete,
"bar": plans.NoOp,
"lorem": plans.Update,
"ipsum": plans.Delete,
}
for k, a := range resources {
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: k,
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
_, _ = h.PostDiff(addr, states.CurrentGen, a, cty.DynamicVal, cty.DynamicVal)
}
expected := new(countHook)
expected.ToAdd = 0
expected.ToChange = 0
expected.ToRemoveAndAdd = 0
expected.ToRemove = 0
if !reflect.DeepEqual(expected, h) {
t.Fatalf("Expected %#v, got %#v instead.",
expected, h)
}
}
func TestCountHookApply_ChangeOnly(t *testing.T) {
h := new(countHook)

View File

@@ -174,3 +174,33 @@ func (h *jsonHook) PostRefresh(addr addrs.AbsResourceInstance, gen states.Genera
h.view.Hook(json.NewRefreshComplete(addr, idKey, idValue))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PreOpen(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStart(addr, "Opening..."))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PostOpen(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStop(addr, "Open complete"))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PreRenew(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStart(addr, "Renewing..."))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PostRenew(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStop(addr, "Renew complete"))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PreClose(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStart(addr, "Closing..."))
return tofu.HookActionContinue, nil
}
func (h *jsonHook) PostClose(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
h.view.Hook(json.NewEphemeralStop(addr, "Close complete"))
return tofu.HookActionContinue, nil
}

View File

@@ -339,6 +339,190 @@ func TestJSONHook_refresh(t *testing.T) {
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_ephemeral(t *testing.T) {
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
cases := []struct {
name string
preF func(hook tofu.Hook) (tofu.HookAction, error)
postF func(hook tofu.Hook) (tofu.HookAction, error)
want []map[string]interface{}
}{
{
name: "opening",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreOpen(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostOpen(addr, nil)
},
want: []map[string]interface{}{
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Opening...",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Opening...",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_started",
},
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Open complete",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Open complete",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_complete",
},
},
},
{
name: "renewing",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreRenew(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostRenew(addr, nil)
},
want: []map[string]interface{}{
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Renewing...",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Renewing...",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_started",
},
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Renew complete",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Renew complete",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_complete",
},
},
},
{
name: "closing",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreClose(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostClose(addr, nil)
},
want: []map[string]interface{}{
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Closing...",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Closing...",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_started",
},
{
"@level": "info",
"@message": "ephemeral.test_instance.foo: Close complete",
"@module": "tofu.ui",
"hook": map[string]any{
"Msg": "Close complete",
"resource": map[string]any{
"addr": "ephemeral.test_instance.foo",
"implied_provider": "test",
"module": "",
"resource": "ephemeral.test_instance.foo",
"resource_key": nil,
"resource_name": "foo",
"resource_type": "test_instance",
},
},
"type": "ephemeral_action_complete",
},
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
h := newJSONHook(NewJSONView(NewView(streams)))
action, err := tt.preF(h)
if err != nil {
t.Fatal(err)
}
if action != tofu.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
<-time.After(1100 * time.Millisecond)
// call postF that will stop the waiting for the action
action, err = tt.postF(h)
if err != nil {
t.Fatal(err)
}
if action != tofu.HookActionContinue {
t.Errorf("Expected hook to continue, given: %#v", action)
}
testJSONViewOutputEquals(t, done(t).Stdout(), tt.want)
})
}
}
func testHookReturnValues(t *testing.T, action tofu.HookAction, err error) {
t.Helper()

View File

@@ -78,6 +78,8 @@ const (
uiResourceDestroy
uiResourceRead
uiResourceNoOp
// NOTE: Ephemeral hooks are implemented separately,
// so there are no uiResource entries for Open/Renew/Close actions.
)
func (h *UiHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (tofu.HookAction, error) {
@@ -339,6 +341,120 @@ func (h *UiHook) PostApplyImport(addr addrs.AbsResourceInstance, importing plans
return tofu.HookActionContinue, nil
}
func (h *UiHook) Deferred(addr addrs.AbsResourceInstance, reason string) (tofu.HookAction, error) {
id := addr.String()
msg := fmt.Sprintf("Deferred due to %s", reason)
colorized := fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s"),
id, msg)
h.println(colorized)
return tofu.HookActionContinue, nil
}
func (h *UiHook) PreOpen(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
return h.preEphemeral(addr, "Opening...", "Still opening...")
}
func (h *UiHook) PostOpen(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
return h.postEphemeral(addr, "Open complete")
}
func (h *UiHook) PreRenew(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
return h.preEphemeral(addr, "Renewing...", "Still renewing...")
}
func (h *UiHook) PostRenew(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
return h.postEphemeral(addr, "Renew complete")
}
func (h *UiHook) PreClose(addr addrs.AbsResourceInstance) (tofu.HookAction, error) {
return h.preEphemeral(addr, "Closing...", "Still closing...")
}
func (h *UiHook) PostClose(addr addrs.AbsResourceInstance, _ error) (tofu.HookAction, error) {
return h.postEphemeral(addr, "Close complete")
}
// preEphemeral is the hook implementation that is used before actions like Renew and Close.
// These are specific for ephemeral resources, and we are not using hook methods used for
// the rest of the resource types because these particular 2 operations have no action
// associated.
func (h *UiHook) preEphemeral(addr addrs.AbsResourceInstance, startMsg, stillRunningMsg string) (tofu.HookAction, error) {
dispAddr := addr.String()
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s[reset]"),
dispAddr,
startMsg,
))
key := addr.String()
uiState := uiResourceState{
DispAddr: key,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
}
h.resourcesLock.Lock()
h.resources[key] = uiState
h.resourcesLock.Unlock()
go func() {
defer close(uiState.done)
for {
select {
case <-uiState.DoneCh:
return
case <-time.After(h.periodicUiTimer):
// Timer up, show status
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s [%s elapsed][reset]"),
uiState.DispAddr,
stillRunningMsg,
time.Now().Round(time.Second).Sub(uiState.Start),
))
}
}()
return tofu.HookActionContinue, nil
}
// postEphemeral is the hook implementation that is used after actions like Renew and Close.
// These are specific for ephemeral resources, and we are not using hook methods used for
// the rest of the resource types because these particular 2 operations have no action
// associated.
func (h *UiHook) postEphemeral(addr addrs.AbsResourceInstance, msg string) (tofu.HookAction, error) {
id := addr.String()
h.resourcesLock.Lock()
state := h.resources[id]
if state.DoneCh != nil {
close(state.DoneCh)
}
delete(h.resources, id)
h.resourcesLock.Unlock()
addrStr := addr.String()
colorized := fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s after %s"),
addrStr,
msg,
time.Now().Round(time.Second).Sub(state.Start),
)
h.println(colorized)
return tofu.HookActionContinue, nil
}
// Wrap calls to the view so that concurrent calls do not interleave println.
func (h *UiHook) println(s string) {
h.viewLock.Lock()

View File

@@ -144,6 +144,104 @@ test_instance\.foo: Still modifying... \[id=test, \ds elapsed\]
}
}
// Test the ephemeral specific hooks
func TestUiHook_ephemeral(t *testing.T) {
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
cases := []struct {
name string
preF func(hook tofu.Hook) (tofu.HookAction, error)
postF func(hook tofu.Hook) (tofu.HookAction, error)
wantOutput string
}{
{
name: "opening",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreOpen(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostOpen(addr, nil)
},
wantOutput: `ephemeral\.test_instance\.foo: Opening\.\.\.
ephemeral\.test_instance\.foo: Still opening\.\.\. \[\ds elapsed\]
`,
},
{
name: "renewing",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreRenew(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostRenew(addr, nil)
},
wantOutput: `ephemeral\.test_instance\.foo: Renewing\.\.\.
ephemeral\.test_instance\.foo: Still renewing\.\.\. \[\ds elapsed\]
ephemeral\.test_instance\.foo: Renew complete after \ds
`,
},
{
name: "closing",
preF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PreClose(addr)
},
postF: func(hook tofu.Hook) (tofu.HookAction, error) {
return hook.PostClose(addr, nil)
},
wantOutput: `ephemeral\.test_instance\.foo: Closing\.\.\.
ephemeral\.test_instance\.foo: Still closing\.\.\. \[\ds elapsed\]
ephemeral\.test_instance\.foo: Close complete after \ds
`,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
h := NewUiHook(view)
h.periodicUiTimer = 1 * time.Second
action, err := tt.preF(h)
if err != nil {
t.Fatal(err)
}
if action != tofu.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
<-time.After(1100 * time.Millisecond)
// stop the background writer
uiState := h.resources[addr.String()]
// call postF that will stop the waiting for the action
action, err = tt.postF(h)
if err != nil {
t.Fatal(err)
}
if action != tofu.HookActionContinue {
t.Errorf("Expected hook to continue, given: %#v", action)
}
// wait for the waiting to stop completely
<-uiState.done
result := done(t)
output := result.Stdout()
if matched, _ := regexp.MatchString(tt.wantOutput, output); !matched {
t.Fatalf("Output didn't match.\nExpected: %q\nGiven: %q", tt.wantOutput, output)
}
expectedErrOutput := ""
errOutput := result.Stderr()
if errOutput != expectedErrOutput {
t.Fatalf("Error output didn't match.\nExpected: %q\nGiven: %q", expectedErrOutput, errOutput)
}
})
}
}
// Test the PreApply hook's destroy path, including passing a deposed key as
// the gen argument.
func TestUiHookPreApply_destroy(t *testing.T) {

View File

@@ -304,8 +304,56 @@ func NewRefreshComplete(addr addrs.AbsResourceInstance, idKey, idValue string) H
}
}
// EphemeralStart: triggered by PreOpen, PreRenew and PreClose hooks
type ephemeralStart struct {
Resource jsonentities.ResourceAddr `json:"resource"`
Msg string
}
var _ Hook = (*ephemeralStart)(nil)
func (h *ephemeralStart) HookType() MessageType {
return MessageEphemeralActionStart
}
func (h *ephemeralStart) String() string {
return fmt.Sprintf("%s: %s", h.Resource.Addr, h.Msg)
}
func NewEphemeralStart(addr addrs.AbsResourceInstance, startMsg string) Hook {
return &ephemeralStart{
Resource: jsonentities.NewResourceAddr(addr),
Msg: startMsg,
}
}
// EphemeralStop: triggered by PostOpen, PostRenew and PostClose hooks
type ephemeralStop struct {
Resource jsonentities.ResourceAddr `json:"resource"`
Msg string
}
var _ Hook = (*ephemeralStop)(nil)
func (h *ephemeralStop) HookType() MessageType {
return MessageEphemeralActionComplete
}
func (h *ephemeralStop) String() string {
return fmt.Sprintf("%s: %s", h.Resource.Addr, h.Msg)
}
func NewEphemeralStop(addr addrs.AbsResourceInstance, startMsg string) Hook {
return &ephemeralStop{
Resource: jsonentities.NewResourceAddr(addr),
Msg: startMsg,
}
}
// Convert the subset of plans.Action values we expect to receive into a
// present-tense verb for the applyStart hook message.
//
// NOTE: Open, Renew and Close missing on purpose since those have their own dedicated hooks.
func startActionVerb(action plans.Action) string {
switch action {
case plans.Create:
@@ -334,6 +382,8 @@ func startActionVerb(action plans.Action) string {
// Convert the subset of plans.Action values we expect to receive into a
// present-tense verb for the applyProgress hook message. This will be
// prefixed with "Still ", so it is lower-case.
//
// NOTE: Open, Renew and Close missing on purpose since those have their own dedicated hooks.
func progressActionVerb(action plans.Action) string {
switch action {
case plans.Create:
@@ -362,6 +412,8 @@ func progressActionVerb(action plans.Action) string {
// Convert the subset of plans.Action values we expect to receive into a
// noun for the applyComplete and applyErrored hook messages. This will be
// combined into a phrase like "Creation complete after 1m4s".
//
// NOTE: Open, Renew and Close missing on purpose since those have their own dedicated hooks.
func actionNoun(action plans.Action) string {
switch action {
case plans.Create:

View File

@@ -20,16 +20,18 @@ const (
MessageOutputs MessageType = "outputs"
// Hook-driven messages
MessageApplyStart MessageType = "apply_start"
MessageApplyProgress MessageType = "apply_progress"
MessageApplyComplete MessageType = "apply_complete"
MessageApplyErrored MessageType = "apply_errored"
MessageProvisionStart MessageType = "provision_start"
MessageProvisionProgress MessageType = "provision_progress"
MessageProvisionComplete MessageType = "provision_complete"
MessageProvisionErrored MessageType = "provision_errored"
MessageRefreshStart MessageType = "refresh_start"
MessageRefreshComplete MessageType = "refresh_complete"
MessageApplyStart MessageType = "apply_start"
MessageApplyProgress MessageType = "apply_progress"
MessageApplyComplete MessageType = "apply_complete"
MessageApplyErrored MessageType = "apply_errored"
MessageProvisionStart MessageType = "provision_start"
MessageProvisionProgress MessageType = "provision_progress"
MessageProvisionComplete MessageType = "provision_complete"
MessageProvisionErrored MessageType = "provision_errored"
MessageRefreshStart MessageType = "refresh_start"
MessageRefreshComplete MessageType = "refresh_complete"
MessageEphemeralActionStart MessageType = "ephemeral_action_started"
MessageEphemeralActionComplete MessageType = "ephemeral_action_complete"
// Test messages
MessageTestAbstract MessageType = "test_abstract"

View File

@@ -277,6 +277,10 @@ func (v *OperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) {
// Avoid rendering data sources on deletion
return
}
if change.Addr.Resource.Resource.Mode == addrs.EphemeralResourceMode {
// Ephemeral changes should not be rendered
return
}
v.view.PlannedChange(jsonentities.NewResourceInstanceChange(change))
}

View File

@@ -435,6 +435,34 @@ Plan: 1 to add, 0 to change, 0 to destroy.
}
}
func TestOperation_planWithEphemeral(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewOperation(arguments.ViewHuman, true, NewView(streams))
plan := testPlanWithEphemeral(t)
schemas := testSchemas()
v.Plan(plan, schemas)
want := `
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
OpenTofu will perform the following actions:
# test_resource.foo will be created
+ resource "test_resource" "foo" {
+ foo = "bar"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
`
if got := done(t).Stdout(); got != want {
t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
}
}
func TestOperation_planNextStep(t *testing.T) {
testCases := map[string]struct {
path string
@@ -482,6 +510,15 @@ func TestOperationJSON_logs(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := &OperationJSON{view: NewJSONView(NewView(streams))}
// Added an ephemeral resource change to double-check that it's not
// shown.
v.PlannedChange(&plans.ResourceInstanceChangeSrc{
Addr: addrs.AbsResourceInstance{
Resource: addrs.ResourceInstance{
Resource: addrs.Resource{Mode: addrs.EphemeralResourceMode},
},
},
})
v.Cancelled(plans.NormalMode)
v.Cancelled(plans.DestroyMode)
v.Stopping()

View File

@@ -133,6 +133,45 @@ func testPlanWithDatasource(t *testing.T) *plans.Plan {
return plan
}
func testPlanWithEphemeral(t *testing.T) *plans.Plan {
plan := testPlan(t)
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_ephemeral_resource",
Name: "bar",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
ephemeralVal := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("C6743020-40BD-4591-81E6-CD08494341D3"),
"foo": cty.StringVal("baz"),
})
priorValRaw, err := plans.NewDynamicValue(cty.NullVal(ephemeralVal.Type()), ephemeralVal.Type())
if err != nil {
t.Fatal(err)
}
plannedValRaw, err := plans.NewDynamicValue(ephemeralVal, ephemeralVal.Type())
if err != nil {
t.Fatal(err)
}
plan.Changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{
Addr: addr,
PrevRunAddr: addr,
ProviderAddr: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
ChangeSrc: plans.ChangeSrc{
Action: plans.Open,
Before: priorValRaw,
After: plannedValRaw,
},
})
return plan
}
func testSchemas() *tofu.Schemas {
provider := testProvider()
return &tofu.Schemas{
@@ -178,5 +217,15 @@ func testProviderSchema() *providers.GetProviderSchemaResponse {
},
},
},
EphemeralResources: map[string]providers.Schema{
"test_ephemeral_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Required: true},
"foo": {Type: cty.String, Optional: true},
},
},
},
},
}
}

View File

@@ -144,6 +144,7 @@ var ConnectionBlockSupersetSchema = &configschema.Block{
Optional: true,
},
},
Ephemeral: true,
}
// IpFormat formats the IP correctly, so we don't provide IPv6 address in an IPv4 format during node communication. We return the ip parameter as is if it's an IPv4 address or a hostname.

View File

@@ -419,47 +419,9 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, qualifs
// Each resource in the configuration creates an *implicit* provider
// dependency, though we'll only record it if there isn't already
// an explicit dependency on the same provider.
for _, rc := range c.Module.ManagedResources {
fqn := rc.Provider
if _, exists := reqs[fqn]; exists {
// If this is called for a child module, and the provider was added from another implicit reference and not
// from a top level required_provider, we need to collect the reference of this resource as well as implicit provider.
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
// Explicit dependency already present
continue
}
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
reqs[fqn] = nil
}
for _, rc := range c.Module.DataResources {
fqn := rc.Provider
if _, exists := reqs[fqn]; exists {
// If this is called for a child module, and the provider was added from another implicit reference and not
// from a top level required_provider, we need to collect the reference of this resource as well as implicit provider.
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
// Explicit dependency already present
continue
}
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
reqs[fqn] = nil
}
c.collectImplicitProviders(c.Module.ManagedResources, reqs, qualifs)
c.collectImplicitProviders(c.Module.DataResources, reqs, qualifs)
c.collectImplicitProviders(c.Module.EphemeralResources, reqs, qualifs)
// Import blocks that are generating config may also have a custom provider
// meta argument. Like the provider meta argument used in resource blocks,
@@ -573,6 +535,31 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, qualifs
return diags
}
// collectImplicitProviders is checking the provider configuration of each resource.
// For the resources whose required provider is not explicitly configured, an implicit one is collected.
// This is mainly used for enabling warnings when OpenTofu fails to resolve the implicitly generated provider.
func (c *Config) collectImplicitProviders(resources map[string]*Resource, reqs getproviders.Requirements, qualifs *getproviders.ProvidersQualification) {
for _, rc := range resources {
fqn := rc.Provider
if _, exists := reqs[fqn]; exists {
// If this is called for a child module, and the provider was added from another implicit reference and not
// from a top level required_provider, we need to collect the reference of this resource as well as implicit provider.
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
continue
}
qualifs.AddImplicitProvider(fqn, getproviders.ResourceRef{
CfgRes: rc.Addr().InModule(c.Path),
Ref: tfdiags.SourceRangeFromHCL(rc.DeclRange),
ProviderAttribute: rc.ProviderConfigRef != nil,
})
reqs[fqn] = nil
}
}
func (c *Config) addProviderRequirementsFromProviderBlock(reqs getproviders.Requirements, provider *Provider) hcl.Diagnostics {
var diags hcl.Diagnostics
@@ -1067,11 +1054,12 @@ func (c *Config) transformOverriddenResourcesForTest(run *TestRun, file *TestFil
}
if res.Mode != overrideRes.Mode {
// TODO ephemeral - include also the ephemeral resource and the test_file.go#override_ephemeral
blockName, targetMode := blockNameOverrideResource, "data"
if overrideRes.Mode == addrs.DataResourceMode {
blockName, targetMode = blockNameOverrideData, "resource"
}
// It could be a warning, but for the sake of consistent UX let's make it an error
//It could be a warning, but for the sake of consistent UX let's make it an error
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unsupported `%v` target in `%v` block", targetMode, blockName),

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -718,6 +719,81 @@ func TestConfigAddProviderRequirements(t *testing.T) {
qualifs := new(getproviders.ProvidersQualification)
diags = cfg.addProviderRequirements(reqs, qualifs, true, false)
assertNoDiagnostics(t, diags)
if got, want := len(qualifs.Explicit), 1; got != want {
t.Fatalf("expected to have %d explicit provider requirement but got %d", want, got)
}
if got, want := len(qualifs.Implicit), 4; got != want {
t.Fatalf("expected to have %d explicit provider requirement but got %d", want, got)
}
checks := []struct {
key addrs.Provider
want []addrs.Resource
}{
{
// check registry.opentofu.org/hashicorp/aws
key: addrs.NewProvider("registry.opentofu.org", "hashicorp", "aws"),
want: []addrs.Resource{
cfg.Path.Resource(addrs.ManagedResourceMode, "aws_instance", "foo").Resource,
cfg.Path.Resource(addrs.DataResourceMode, "aws_s3_object", "baz").Resource,
cfg.Path.Resource(addrs.EphemeralResourceMode, "aws_secret", "bar").Resource,
},
},
{
// check registry.opentofu.org/hashicorp/null
key: addrs.NewProvider("registry.opentofu.org", "hashicorp", "null"),
want: []addrs.Resource{
cfg.Path.Resource(addrs.ManagedResourceMode, "null_resource", "foo").Resource,
},
},
{
// check registry.opentofu.org/hashicorp/local
key: addrs.NewProvider("registry.opentofu.org", "hashicorp", "local"),
want: []addrs.Resource{
cfg.Path.Resource(addrs.ManagedResourceMode, "local_file", "foo").Resource,
},
},
{
// check registry.opentofu.org/hashicorp/template
key: addrs.NewProvider("registry.opentofu.org", "hashicorp", "template"),
want: []addrs.Resource{
cfg.Path.Resource(addrs.ManagedResourceMode, "local_file", "bar").Resource,
},
},
}
for _, c := range checks {
t.Run(c.key.String(), func(t *testing.T) {
refs := qualifs.Implicit[c.key]
if got, want := len(refs), len(c.want); got != want {
t.Fatalf("expected to find %d implicit references for provider %q but got %d", want, c.key, got)
}
var refsAddrs []addrs.Resource
for _, ref := range refs {
refsAddrs = append(refsAddrs, ref.CfgRes.Resource)
}
sort.Slice(refsAddrs, func(i, j int) bool {
return refsAddrs[i].Less(refsAddrs[j])
})
sort.Slice(c.want, func(i, j int) bool {
return c.want[i].Less(c.want[j])
})
if diff := cmp.Diff(refsAddrs, c.want); diff != "" {
t.Fatalf("expected to find specific resources to implicitly reference the provider %s. diff:\n%s", c.key, diff)
}
})
}
wantReqs := getproviders.Requirements{
addrs.NewProvider("registry.opentofu.org", "hashicorp", "template"): nil,
addrs.NewProvider("registry.opentofu.org", "hashicorp", "local"): nil,
addrs.NewProvider("registry.opentofu.org", "hashicorp", "null"): nil,
addrs.NewProvider("registry.opentofu.org", "hashicorp", "aws"): nil,
addrs.NewProvider("registry.opentofu.org", "hashicorp", "test"): nil,
}
if diff := cmp.Diff(wantReqs, reqs); diff != "" {
t.Fatalf("unexected returned providers qualifications: %s", diff)
}
}
func TestConfigImportProviderClashesWithModules(t *testing.T) {
@@ -1088,5 +1164,68 @@ func TestIsCallFromRemote(t *testing.T) {
}
})
}
}
func TestParseEphemeralBlocks(t *testing.T) {
p := NewParser(nil)
f, diags := p.LoadConfigFile("testdata/ephemeral-blocks/main.tf")
// check diags
{
if len(diags) != 6 { // 4 lifecycle unallowed attributes, unallowed connection block and unallowed provisioner block
t.Fatalf("expected 6 diagnostics but got only: %d", len(diags))
}
containsExpectedKeywords := func(diagContent string) bool {
for _, k := range []string{"ignore_changes", "prevent_destroy", "create_before_destroy", "replace_triggered_by", "connection", "provisioner"} {
if strings.Contains(diagContent, k) {
return true
}
}
return false
}
for _, diag := range diags {
if content := diag.Error(); !containsExpectedKeywords(content) {
t.Fatalf("expected diagnostic to contain at least one of the keywords: %s", content)
}
}
}
{
if len(f.EphemeralResources) != 2 {
t.Fatalf("expected 2 ephemeral resources but got only: %d", len(f.EphemeralResources))
}
for _, er := range f.EphemeralResources {
switch er.Name {
case "foo":
if er.ForEach == nil {
t.Errorf("expected to have a for_each expression but got nothing")
}
case "bar":
attrs, _ := er.Config.JustAttributes()
if _, ok := attrs["attribute"]; !ok {
t.Errorf("expected to have \"attribute\" but could not find it")
}
if _, ok := attrs["attribute2"]; !ok {
t.Errorf("expected to have \"attribute\" but could not find it")
}
if er.Count == nil {
t.Errorf("expected to have a count expression but got nothing")
}
if er.ProviderConfigRef == nil || er.ProviderConfigRef.Addr().String() != "provider.test.name" {
t.Errorf("expected to have \"provider.test.name\" provider alias configured but instead it was: %+v", er.ProviderConfigRef)
}
if len(er.Preconditions) != 1 {
t.Errorf("expected to have one precondition but got %d", len(er.Preconditions))
}
if len(er.Postconditions) != 1 {
t.Errorf("expected to have one postcondition but got %d", len(er.Postconditions))
}
if len(er.DependsOn) != 1 {
t.Errorf("expected to have a depends_on traversal but got %d", len(er.Postconditions))
}
if er.Managed != nil {
t.Errorf("error in the parsing code. Ephemeral resources are not meant to have a managed object")
}
}
}
}
}

View File

@@ -61,6 +61,24 @@ func (b *Block) ContainsSensitive() bool {
return false
}
// ContainsMarks is a wrapper around Block.ContainsSensitive which adds
// another check for the ephemeral nature of the block.
// The schema attributes cannot be marked as ephemeral, only the whole block
// can have that mark.
// Therefore, we don't need to check the schema recursively.
//
// NOTE: It's important to make the distinction between "schema attributes" and
// "value attributes".
// A schema attribute cannot have the ephemeral mark, but a value attribute
// can be marked as ephemeral if it's referencing attribute(s) from another
// ephemeral block.
func (b *Block) ContainsMarks() bool {
if b.Ephemeral {
return true
}
return b.ContainsSensitive()
}
// ImpliedType returns the cty.Type that would result from decoding a Block's
// ImpliedType and getting the resulting AttributeType.
//

View File

@@ -27,6 +27,16 @@ func copyAndExtendPath(path cty.Path, nextSteps ...cty.PathStep) cty.Path {
func (b *Block) ValueMarks(val cty.Value, path cty.Path) []cty.PathValueMarks {
var pvm []cty.PathValueMarks
// When the block is marked as ephemeral, the whole value needs to be marked accordingly.
// Inner attributes should carry no ephemeral mark.
// The ephemerality of the attributes is given by the mark on the val and not by individual marks
// as it's the case for the sensitive mark.
if b.Ephemeral {
pvm = append(pvm, cty.PathValueMarks{
Path: path, // raw received path is indicating that the whole value needs to be marked as ephemeral.
Marks: cty.NewValueMarks(marks.Ephemeral),
})
}
// We can mark attributes as sensitive even if the value is null
for name, attrS := range b.Attributes {
if attrS.Sensitive {
@@ -156,3 +166,33 @@ func (o *Object) ValueMarks(val cty.Value, path cty.Path) []cty.PathValueMarks {
}
return pvm
}
// RemoveEphemeralFromWriteOnly gets the value and for the attributes that are
// configured as write-only removes the marks.Ephemeral mark.
// Write-only arguments are only available in managed resources.
// Write-only arguments are the only managed resource's attribute type
// that can reference ephemeral values.
// Also, the provider framework sdk is responsible with nullify these attributes
// before returning back to OpenTofu.
//
// Therefore, before writing the changes/state of a managed resource to its store,
// we want to be sure that the nil value of the attribute is not marked as ephemeral
// in case it got its value from evaluating an expression where an ephemeral value has
// been involved.
func (b *Block) RemoveEphemeralFromWriteOnly(v cty.Value) cty.Value {
unmarkedV, valMarks := v.UnmarkDeepWithPaths()
for _, pathMark := range valMarks {
if _, ok := pathMark.Marks[marks.Ephemeral]; !ok {
continue
}
attr := b.AttributeByPath(pathMark.Path)
if attr == nil {
continue
}
if !attr.WriteOnly {
continue
}
delete(pathMark.Marks, marks.Ephemeral)
}
return unmarkedV.MarkWithPaths(valMarks)
}

View File

@@ -230,5 +230,4 @@ func TestObject_AttributeByPath(t *testing.T) {
}
})
}
}

View File

@@ -38,6 +38,11 @@ type Block struct {
DescriptionKind StringKind
Deprecated bool
// Ephemeral is a flag indicating that this is an ephemeral block marking it as an "ephemeral context".
// There are multiple places where this is set to "true". Generally speaking,
// any Block that is meant to accept ephemeral values should have this set as "true".
Ephemeral bool
}
// Attribute represents a configuration attribute, within a block.
@@ -94,6 +99,8 @@ type Attribute struct {
Sensitive bool
Deprecated bool
WriteOnly bool
}
// Object represents the embedding of a structural object inside an Attribute.

View File

@@ -50,8 +50,9 @@ type Module struct {
ModuleCalls map[string]*ModuleCall
ManagedResources map[string]*Resource
DataResources map[string]*Resource
ManagedResources map[string]*Resource
DataResources map[string]*Resource
EphemeralResources map[string]*Resource
Moved []*Moved
Import []*Import
@@ -105,8 +106,9 @@ type File struct {
ModuleCalls []*ModuleCall
ManagedResources []*Resource
DataResources []*Resource
ManagedResources []*Resource
DataResources []*Resource
EphemeralResources []*Resource
Moved []*Moved
Import []*Import
@@ -178,6 +180,7 @@ func NewModule(primaryFiles, overrideFiles []*File, call StaticModuleCall, sourc
ModuleCalls: map[string]*ModuleCall{},
ManagedResources: map[string]*Resource{},
DataResources: map[string]*Resource{},
EphemeralResources: map[string]*Resource{},
Checks: map[string]*Check{},
ProviderMetas: map[addrs.Provider]*ProviderMeta{},
Tests: map[string]*TestFile{},
@@ -273,6 +276,8 @@ func (m *Module) ResourceByAddr(addr addrs.Resource) *Resource {
return m.ManagedResources[key]
case addrs.DataResourceMode:
return m.DataResources[key]
case addrs.EphemeralResourceMode:
return m.EphemeralResources[key]
default:
return nil
}
@@ -466,6 +471,35 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
m.DataResources[key] = r
}
for _, r := range file.EphemeralResources {
key := r.moduleUniqueKey()
if existing, exists := m.EphemeralResources[key]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Duplicate ephemeral resource %q configuration", existing.Type),
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),
Subject: &r.DeclRange,
})
continue
}
m.EphemeralResources[key] = r
// set the provider FQN for the resource
if r.ProviderConfigRef != nil {
r.Provider = m.ProviderForLocalConfig(r.ProviderConfigAddr())
} else {
// an invalid resource name (for e.g. "null resource" instead of
// "null_resource") can cause a panic down the line in addrs:
// https://github.com/hashicorp/terraform/issues/25560
implied, err := addrs.ParseProviderPart(r.Addr().ImpliedProvider())
if err == nil {
r.Provider = m.ImpliedProviderForUnqualifiedType(implied)
}
// We don't return a diagnostic because the invalid resource name
// will already have been caught.
}
}
for _, c := range file.Checks {
if c.DataResource != nil {
key := c.DataResource.moduleUniqueKey()
@@ -736,6 +770,22 @@ func (m *Module) mergeFile(file *File) hcl.Diagnostics {
diags = append(diags, mergeDiags...)
}
for _, r := range file.EphemeralResources {
key := r.moduleUniqueKey()
existing, exists := m.EphemeralResources[key]
if !exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing ephemeral resource to override",
Detail: fmt.Sprintf("There is no %s ephemeral resource named %q. An override file can only override an ephemeral block defined in a primary configuration file.", r.Type, r.Name),
Subject: &r.DeclRange,
})
continue
}
mergeDiags := existing.merge(r, m.ProviderRequirements.RequiredProviders)
diags = append(diags, mergeDiags...)
}
for _, m := range file.Moved {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,

View File

@@ -54,6 +54,10 @@ func (v *Variable) merge(ov *Variable) hcl.Diagnostics {
if ov.Deprecated != "" {
v.Deprecated = ov.Deprecated
}
if ov.EphemeralSet {
v.EphemeralSet = ov.EphemeralSet
v.Ephemeral = ov.Ephemeral
}
if ov.Default != cty.NilVal {
v.Default = ov.Default
}
@@ -156,6 +160,10 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics {
if oo.Deprecated != "" {
o.Deprecated = oo.Deprecated
}
if oo.EphemeralSet {
o.EphemeralSet = oo.EphemeralSet
o.Ephemeral = oo.Ephemeral
}
// We don't allow depends_on to be overridden because that is likely to
// cause confusing misbehavior.

View File

@@ -12,6 +12,7 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/zclconf/go-cty/cty"
)
@@ -33,6 +34,8 @@ func TestModuleOverrideVariable(t *testing.T) {
Deprecated: "b_override deprecated",
Nullable: false,
NullableSet: true,
Ephemeral: false,
EphemeralSet: true,
Type: cty.String,
ConstraintType: cty.String,
ParsingMode: VariableParseLiteral,
@@ -79,6 +82,125 @@ func TestModuleOverrideVariable(t *testing.T) {
assertResultDeepEqual(t, got, want)
}
func TestModuleOverrideOutput(t *testing.T) {
mod, diags := testModuleFromDir("testdata/valid-modules/override-output")
assertNoDiagnostics(t, diags)
if mod == nil {
t.Fatalf("module is nil")
}
got := mod.Outputs
want := map[string]*Output{
"fully_overridden": {
Name: "fully_overridden",
Description: "b_override description",
DescriptionSet: true,
Expr: &hclsyntax.TemplateExpr{
Parts: []hclsyntax.Expression{
&hclsyntax.LiteralValueExpr{
Val: cty.StringVal("b_override"),
SrcRange: hcl.Range{
Filename: "testdata/valid-modules/override-output/b_override.tf",
Start: hcl.Pos{
Line: 2,
Column: 12,
Byte: 39,
},
End: hcl.Pos{
Line: 2,
Column: 22,
Byte: 49,
},
},
},
},
SrcRange: hcl.Range{
Filename: "testdata/valid-modules/override-output/b_override.tf",
Start: hcl.Pos{
Line: 2,
Column: 11,
Byte: 38,
},
End: hcl.Pos{
Line: 2,
Column: 23,
Byte: 50,
},
},
},
Deprecated: "b_override deprecated",
Ephemeral: false,
EphemeralSet: true,
DeclRange: hcl.Range{
Filename: filepath.FromSlash("testdata/valid-modules/override-output/primary.tf"),
Start: hcl.Pos{
Line: 1,
Column: 1,
Byte: 0,
},
End: hcl.Pos{
Line: 1,
Column: 26,
Byte: 25,
},
},
},
"partially_overridden": {
Name: "partially_overridden",
Description: "base description",
DescriptionSet: true,
Expr: &hclsyntax.TemplateExpr{
Parts: []hclsyntax.Expression{
&hclsyntax.LiteralValueExpr{
Val: cty.StringVal("b_override partial"),
SrcRange: hcl.Range{
Filename: "testdata/valid-modules/override-output/b_override.tf",
Start: hcl.Pos{
Line: 9,
Column: 12,
Byte: 197,
},
End: hcl.Pos{
Line: 9,
Column: 30,
Byte: 215,
},
},
},
},
SrcRange: hcl.Range{
Filename: "testdata/valid-modules/override-output/b_override.tf",
Start: hcl.Pos{
Line: 9,
Column: 11,
Byte: 196,
},
End: hcl.Pos{
Line: 9,
Column: 31,
Byte: 216,
},
},
},
Deprecated: "b_override deprecated",
DeclRange: hcl.Range{
Filename: filepath.FromSlash("testdata/valid-modules/override-output/primary.tf"),
Start: hcl.Pos{
Line: 6,
Column: 1,
Byte: 83,
},
End: hcl.Pos{
Line: 6,
Column: 30,
Byte: 112,
},
},
},
}
assertResultDeepEqual(t, got, want)
}
func TestModuleOverrideModule(t *testing.T) {
mod, diags := testModuleFromDir("testdata/valid-modules/override-module")
assertNoDiagnostics(t, diags)

View File

@@ -69,6 +69,15 @@ func TestNewModule_resource_providers(t *testing.T) {
wantBar := addrs.NewProvider(addrs.DefaultProviderRegistryHost, "bar", "test")
// root module
if got, want := len(cfg.Module.ManagedResources), 2; got != want {
t.Fatalf("expected to have %d managed resources in the root module but got %d", want, got)
}
if got, want := len(cfg.Module.DataResources), 1; got != want {
t.Fatalf("expected to have %d data sources in the root module but got %d", want, got)
}
if got, want := len(cfg.Module.EphemeralResources), 2; got != want {
t.Fatalf("expected to have %d ephemeral resources in the root module but got %d", want, got)
}
if !cfg.Module.ManagedResources["test_instance.explicit"].Provider.Equals(wantFoo) {
t.Fatalf("wrong provider for \"test_instance.explicit\"\ngot: %s\nwant: %s",
cfg.Module.ManagedResources["test_instance.explicit"].Provider,
@@ -90,8 +99,31 @@ func TestNewModule_resource_providers(t *testing.T) {
)
}
// ephemeral resources test
if !cfg.Module.EphemeralResources["ephemeral.test_ephemeral.explicit"].Provider.Equals(wantFoo) {
t.Fatalf("wrong provider for \"test_ephemeral.explicit\"\ngot: %s\nwant: %s",
cfg.Module.EphemeralResources["test_ephemeral.explicit"].Provider,
wantFoo,
)
}
if !cfg.Module.EphemeralResources["ephemeral.test_ephemeral.implicit"].Provider.Equals(wantImplicit) {
t.Fatalf("wrong provider for \"test_ephemeral.implicit\"\ngot: %s\nwant: %s",
cfg.Module.EphemeralResources["test_instance.implicit"].Provider,
wantImplicit,
)
}
// child module
cm := cfg.Children["child"].Module
if got, want := len(cm.ManagedResources), 3; got != want {
t.Fatalf("expected to have %d managed resources in the child module but got %d", want, got)
}
if got, want := len(cm.DataResources), 0; got != want {
t.Fatalf("expected to have %d data sources in the child module but got %d", want, got)
}
if got, want := len(cm.EphemeralResources), 2; got != want {
t.Fatalf("expected to have %d ephemeral resources in the child module but got %d", want, got)
}
if !cm.ManagedResources["test_instance.explicit"].Provider.Equals(wantBar) {
t.Fatalf("wrong provider for \"module.child.test_instance.explicit\"\ngot: %s\nwant: %s",
cfg.Module.ManagedResources["test_instance.explicit"].Provider,
@@ -104,6 +136,19 @@ func TestNewModule_resource_providers(t *testing.T) {
wantImplicit,
)
}
// ephemeral
if !cm.EphemeralResources["ephemeral.test_ephemeral.other_explicit"].Provider.Equals(wantFoo) {
t.Fatalf("wrong provider for \"module.child.ephemeral.test_ephemeral.other_explicit\"\ngot: %s\nwant: %s",
cfg.Module.EphemeralResources["ephemeral.test_ephemeral.other_explicit"].Provider,
wantFoo,
)
}
if !cm.EphemeralResources["ephemeral.test_ephemeral.other_implicit"].Provider.Equals(wantImplicit) {
t.Fatalf("wrong provider for \"module.child.ephemeral.test_ephemeral.other_implicit\"\ngot: %s\nwant: %s",
cfg.Module.EphemeralResources["ephemeral.test_ephemeral.other_implicit"].Provider,
wantFoo,
)
}
}
func TestProviderForLocalConfig(t *testing.T) {
@@ -291,15 +336,21 @@ func TestModule_implied_provider(t *testing.T) {
}{
{"foo_resource.a", foo},
{"data.foo_resource.b", foo},
{"bar_resource.c", bar},
{"data.bar_resource.d", bar},
{"whatever_resource.e", whatever},
{"data.whatever_resource.f", whatever},
{"ephemeral.foo_resource.c", foo},
{"bar_resource.d", bar},
{"data.bar_resource.e", bar},
{"ephemeral.bar_resource.f", bar},
{"whatever_resource.g", whatever},
{"data.whatever_resource.h", whatever},
{"ephemeral.whatever_resource.i", whatever},
}
for _, test := range tests {
resources := mod.ManagedResources
if strings.HasPrefix(test.Address, "data.") {
switch test.Address[:strings.Index(test.Address, ".")+1] {
case "data.":
resources = mod.DataResources
case "ephemeral.":
resources = mod.EphemeralResources
}
resource, exists := resources[test.Address]
if !exists {
@@ -443,3 +494,30 @@ func TestModule_cloud_duplicate_overrides(t *testing.T) {
t.Fatalf("expected module error to contain %q\nerror was:\n%s", want, got)
}
}
func TestResourceByAddr(t *testing.T) {
managedResource := &Resource{Mode: addrs.ManagedResourceMode, Name: "name", Type: "test_resource"}
dataResource := &Resource{Mode: addrs.DataResourceMode, Name: "name", Type: "test_data"}
ephemeralResource := &Resource{Mode: addrs.EphemeralResourceMode, Name: "name", Type: "test_ephemeral"}
m := Module{
ManagedResources: map[string]*Resource{
managedResource.Addr().String(): managedResource,
},
DataResources: map[string]*Resource{
dataResource.Addr().String(): dataResource,
},
EphemeralResources: map[string]*Resource{
ephemeralResource.Addr().String(): ephemeralResource,
},
}
if got, want := m.ResourceByAddr(managedResource.Addr()), managedResource; got != want {
t.Fatalf("expected resource %+v but got %+v", want, got)
}
if got, want := m.ResourceByAddr(dataResource.Addr()), dataResource; got != want {
t.Fatalf("expected resource %+v but got %+v", want, got)
}
if got, want := m.ResourceByAddr(ephemeralResource.Addr()), ephemeralResource; got != want {
t.Fatalf("expected resource %+v but got %+v", want, got)
}
}

View File

@@ -45,6 +45,23 @@ func decodeMovedBlock(block *hcl.Block) (*Moved, hcl.Diagnostics) {
moved.To = to
}
}
// ensure that the moved block is not used against ephemeral resources since there is no use against those
if !moved.From.SubjectAllowed() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid \"moved\" address",
Detail: "The resource referenced by the \"from\" attribute is not allowed to be moved.",
Subject: &moved.DeclRange,
})
}
if !moved.To.SubjectAllowed() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid \"moved\" address",
Detail: "The resource referenced by the \"to\" attribute is not allowed to be moved.",
Subject: &moved.DeclRange,
})
}
// we can only move from a module to a module, resource to resource, etc.
if !diags.HasErrors() {

View File

@@ -30,6 +30,8 @@ func TestMovedBlock_decode(t *testing.T) {
mod_foo_expr := hcltest.MockExprTraversalSrc("module.foo")
mod_bar_expr := hcltest.MockExprTraversalSrc("module.bar")
ephemeral_ref_expr := hcltest.MockExprTraversalSrc("ephemeral.test.test")
tests := map[string]struct {
input *hcl.Block
want *Moved
@@ -150,6 +152,30 @@ func TestMovedBlock_decode(t *testing.T) {
},
"Invalid \"moved\" addresses",
},
"error: ephemeral not allowed": {
&hcl.Block{
Type: "moved",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"to": {
Name: "to",
Expr: ephemeral_ref_expr,
},
"from": {
Name: "from",
Expr: ephemeral_ref_expr,
},
},
}),
DefRange: blockRange,
},
&Moved{
To: mustMoveEndpointFromExpr(ephemeral_ref_expr),
From: mustMoveEndpointFromExpr(ephemeral_ref_expr),
DeclRange: blockRange,
},
"Invalid \"moved\" address",
},
}
for name, test := range tests {

View File

@@ -40,9 +40,11 @@ type Variable struct {
Validations []*CheckRule
Sensitive bool
Deprecated string
Ephemeral bool
DescriptionSet bool
SensitiveSet bool
EphemeralSet bool
// Nullable indicates that null is a valid value for this variable. Setting
// Nullable to false means that the module can expect this variable to
@@ -138,6 +140,12 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
}
}
if attr, exists := content.Attributes["ephemeral"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Ephemeral)
diags = append(diags, valDiags...)
v.EphemeralSet = true
}
if attr, exists := content.Attributes["nullable"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable)
diags = append(diags, valDiags...)
@@ -418,11 +426,13 @@ type Output struct {
DependsOn []hcl.Traversal
Sensitive bool
Deprecated string
Ephemeral bool
Preconditions []*CheckRule
DescriptionSet bool
SensitiveSet bool
EphemeralSet bool
DeclRange hcl.Range
@@ -490,6 +500,12 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic
}
}
if attr, exists := content.Attributes["ephemeral"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Ephemeral)
diags = append(diags, valDiags...)
o.EphemeralSet = true
}
if attr, exists := content.Attributes["depends_on"]; exists {
deps, depsDiags := decodeDependsOn(attr)
diags = append(diags, depsDiags...)
@@ -523,6 +539,17 @@ func (o *Output) Addr() addrs.OutputValue {
return addrs.OutputValue{Name: o.Name}
}
// UsageRange returns the location where the output value is configured, but if the expression is not configured
// then it returns the output definition location.
// Useful for generating diagnostics.
func (o *Output) UsageRange() hcl.Range {
subj := o.DeclRange
if o.Expr != nil {
subj = o.Expr.Range()
}
return subj
}
// Local represents a single entry from a "locals" block in a module or file.
// The "locals" block itself is not represented, because it serves only to
// provide context for us to interpret its contents.
@@ -581,6 +608,9 @@ var variableBlockSchema = &hcl.BodySchema{
{
Name: "sensitive",
},
{
Name: "ephemeral",
},
{
Name: "deprecated",
},
@@ -610,6 +640,9 @@ var outputBlockSchema = &hcl.BodySchema{
{
Name: "sensitive",
},
{
Name: "ephemeral",
},
{
Name: "deprecated",
},

View File

@@ -183,6 +183,13 @@ func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnost
file.DataResources = append(file.DataResources, cfg)
}
case "ephemeral":
cfg, cfgDiags := decodeEphemeralBlock(block, override)
diags = append(diags, cfgDiags...)
if cfg != nil {
file.EphemeralResources = append(file.EphemeralResources, cfg)
}
case "moved":
cfg, cfgDiags := decodeMovedBlock(block)
diags = append(diags, cfgDiags...)
@@ -298,6 +305,10 @@ var configFileSchema = &hcl.BodySchema{
Type: "data",
LabelNames: []string{"type", "name"},
},
{
Type: "ephemeral",
LabelNames: []string{"type", "name"},
},
{
Type: "moved",
},

View File

@@ -458,6 +458,7 @@ func validateProviderConfigs(parentCall *ModuleCall, cfg *Config, noProviderConf
}
checkImpliedProviderNames(mod.ManagedResources)
checkImpliedProviderNames(mod.DataResources)
checkImpliedProviderNames(mod.EphemeralResources)
// collect providers passed from the parent
if parentCall != nil {
@@ -523,6 +524,7 @@ func validateProviderConfigs(parentCall *ModuleCall, cfg *Config, noProviderConf
}
checkProviderKeys(mod.ManagedResources)
checkProviderKeys(mod.DataResources)
checkProviderKeys(mod.EphemeralResources)
// Verify that any module calls only refer to named providers, and that
// those providers will have a configuration at runtime. This way we can

View File

@@ -547,6 +547,176 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di
return r, diags
}
func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagnostics) {
var diags hcl.Diagnostics
r := &Resource{
Mode: addrs.EphemeralResourceMode,
Type: block.Labels[0],
Name: block.Labels[1],
DeclRange: block.DefRange,
TypeRange: block.LabelRanges[0],
}
content, remain, moreDiags := block.Body.PartialContent(ResourceBlockSchema)
diags = append(diags, moreDiags...)
r.Config = remain
if !hclsyntax.ValidIdentifier(r.Type) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid ephemeral resource type name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[0],
})
}
if !hclsyntax.ValidIdentifier(r.Name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid ephemeral resource name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[1],
})
}
if attr, exists := content.Attributes["count"]; exists {
r.Count = attr.Expr
}
if attr, exists := content.Attributes["for_each"]; exists {
r.ForEach = attr.Expr
// Cannot have count and for_each on the same resource block
if r.Count != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid combination of "count" and "for_each"`,
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.`,
Subject: &attr.NameRange,
})
}
}
if attr, exists := content.Attributes["provider"]; exists {
var providerDiags hcl.Diagnostics
r.ProviderConfigRef, providerDiags = decodeProviderConfigRef(attr.Expr, "provider")
diags = append(diags, providerDiags...)
}
if attr, exists := content.Attributes["depends_on"]; exists {
deps, depsDiags := decodeDependsOn(attr)
diags = append(diags, depsDiags...)
r.DependsOn = append(r.DependsOn, deps...)
}
invalidEphemeralLifecycleAttributeDiag := func(field string) *hcl.Diagnostic {
return &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid lifecycle configuration for ephemeral resource",
Detail: fmt.Sprintf("The lifecycle argument %q cannot be used in ephemeral resources. This is meant to be used strictly in \"resource\" blocks.", field),
Subject: &block.DefRange,
}
}
invalidEphemeralBlockDiag := func(field string) *hcl.Diagnostic {
return &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid configuration block for ephemeral resource",
Detail: fmt.Sprintf("The block type %q cannot be used in ephemeral resources. This is meant to be used strictly in \"resource\" blocks.", field),
Subject: &block.DefRange,
}
}
var seenLifecycle *hcl.Block
var seenEscapeBlock *hcl.Block
for _, block := range content.Blocks {
switch block.Type {
case "lifecycle":
if seenLifecycle != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate lifecycle block",
Detail: fmt.Sprintf("This ephemeral resource already has a lifecycle block at %s.", seenLifecycle.DefRange),
Subject: &block.DefRange,
})
continue
}
seenLifecycle = block
lcContent, lcDiags := block.Body.Content(resourceLifecycleBlockSchema)
diags = append(diags, lcDiags...)
if _, exists := lcContent.Attributes["create_before_destroy"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("create_before_destroy"))
}
if _, exists := lcContent.Attributes["prevent_destroy"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("prevent_destroy"))
}
if _, exists := lcContent.Attributes["replace_triggered_by"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("replace_triggered_by"))
}
if _, exists := lcContent.Attributes["ignore_changes"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("ignore_changes"))
}
for _, block := range lcContent.Blocks {
switch block.Type {
case "precondition", "postcondition":
cr, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
moreDiags = cr.validateSelfReferences(block.Type, r.Addr())
diags = append(diags, moreDiags...)
switch block.Type {
case "precondition":
r.Preconditions = append(r.Preconditions, cr)
case "postcondition":
r.Postconditions = append(r.Postconditions, cr)
}
default:
// The cases above should be exhaustive for all block types
// defined in the lifecycle schema, so this shouldn't happen.
panic(fmt.Sprintf("unexpected lifecycle sub-block type %q", block.Type))
}
}
case "connection":
diags = append(diags, invalidEphemeralBlockDiag("connection"))
case "provisioner":
diags = append(diags, invalidEphemeralBlockDiag("provisioner"))
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.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Reserved block type name in ephemeral block",
Detail: fmt.Sprintf("The block type name %q is reserved for use by OpenTofu in a future version.", block.Type),
Subject: &block.TypeRange,
})
}
}
return r, diags
}
// decodeReplaceTriggeredBy decodes and does basic validation of the
// replace_triggered_by expressions, ensuring they only contains references to
// a single resource, and the only extra variables are count.index or each.key.

View File

@@ -260,6 +260,7 @@ type TestRunOptions struct {
const (
blockNameOverrideResource = "override_resource"
blockNameOverrideData = "override_data"
//blockNameOverrideEphemeral = "override_ephemeral" // TODO ephemeral uncomment this when testing support will be added for ephemerals
)
// OverrideResource contains information about a resource or data block to be overridden.

View File

@@ -0,0 +1,40 @@
provider test {
alias = "name"
}
ephemeral "test_resource" "foo" {
for_each = toset(["a"])
}
ephemeral "test_resource" "bar" {
depends_on = [
test_resource.foo["a"]
]
provider = test.name
count = 1
attribute = "test value"
attribute2 = "test value"
connection {
// connection blocks are not allowed so we are expecting an error on this
}
provisioner "local-exec" {
// provisioner blocks are not allowed so we are expecting an error on this
}
lifecycle {
// standard attributes in the lifecycle block are not allowed so we are expecting 4 errors on this
create_before_destroy = true
prevent_destroy = true
replace_triggered_by = true
ignore_changes = true
// precondition and postconditions are allowed in ephemeral resources
precondition {
condition = ephemeral.test_resource.foo.id == ""
error_message = "precondition error"
}
postcondition {
condition = ephemeral.test_resource.foo.id == ""
error_message = "postcondition error"
}
}
}

View File

@@ -14,6 +14,10 @@ resource "null_resource" "foo" {
}
ephemeral "aws_secret" "bar" {}
data "aws_s3_object" "baz" {}
import {
id = "directory/filename"
to = local_file.foo

View File

@@ -1,12 +1,15 @@
// These resources map to the configured "foo" provider"
resource foo_resource "a" {}
data foo_resource "b" {}
ephemeral foo_resource "c" {}
// These resources map to a default "hashicorp/bar" provider
resource bar_resource "c" {}
data bar_resource "d" {}
resource bar_resource "d" {}
data bar_resource "e" {}
ephemeral bar_resource "f" {}
// These resources map to the configured "whatever" provider, which has FQN
// "acme/something".
resource whatever_resource "e" {}
data whatever_resource "f" {}
resource whatever_resource "g" {}
data whatever_resource "h" {}
ephemeral whatever_resource "i" {}

View File

@@ -23,3 +23,12 @@ resource "test_instance" "implicit" {
resource "test_instance" "other" {
provider = foo-test.other
}
ephemeral "test_ephemeral" "other_explicit" {
provider = foo-test.other
}
ephemeral "test_ephemeral" "other_implicit" {
// since the provider type name "test" does not match an entry in
// required_providers, the default provider "test" should be used
}

View File

@@ -23,6 +23,15 @@ data "test_resource" "explicit" {
provider = foo-test
}
ephemeral "test_ephemeral" "explicit" {
provider = foo-test
}
ephemeral "test_ephemeral" "implicit" {
// since the provider type name "test" does not match an entry in
// required_providers, the default provider "test" should be used
}
resource "test_instance" "implicit" {
// since the provider type name "test" does not match an entry in
// required_providers, the default provider "test" should be used

View File

@@ -0,0 +1,10 @@
output "fully_overridden" {
value = "a_override"
description = "a_override description"
deprecated = "a_override deprecated"
ephemeral = true
}
output "partially_overridden" {
value = "a_override partial"
}

View File

@@ -0,0 +1,11 @@
output "fully_overridden" {
value = "b_override"
description = "b_override description"
deprecated = "b_override deprecated"
ephemeral = false
}
output "partially_overridden" {
value = "b_override partial"
deprecated = "b_override deprecated"
}

View File

@@ -0,0 +1,9 @@
output "fully_overridden" {
value = "base"
description = "base description"
}
output "partially_overridden" {
value = "base"
description = "base description"
}

View File

@@ -3,6 +3,7 @@ variable "fully_overridden" {
description = "a_override description"
deprecated = "a_override deprecated"
type = string
ephemeral = true
}
variable "partially_overridden" {

View File

@@ -4,6 +4,7 @@ variable "fully_overridden" {
description = "b_override description"
deprecated = "b_override deprecated"
type = string
ephemeral = false
}
variable "partially_overridden" {

View File

@@ -14,6 +14,7 @@ import (
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/zclconf/go-cty/cty/msgpack"
"google.golang.org/protobuf/types/known/timestamppb"
)
// New wraps a providers.Interface to implement a grpc ProviderServer.
@@ -33,8 +34,9 @@ type provider struct {
func (p *provider) GetSchema(_ context.Context, req *tfplugin5.GetProviderSchema_Request) (*tfplugin5.GetProviderSchema_Response, error) {
resp := &tfplugin5.GetProviderSchema_Response{
ResourceSchemas: make(map[string]*tfplugin5.Schema),
DataSourceSchemas: make(map[string]*tfplugin5.Schema),
ResourceSchemas: make(map[string]*tfplugin5.Schema),
DataSourceSchemas: make(map[string]*tfplugin5.Schema),
EphemeralResourceSchemas: make(map[string]*tfplugin5.Schema),
}
resp.Provider = &tfplugin5.Schema{
@@ -63,6 +65,12 @@ func (p *provider) GetSchema(_ context.Context, req *tfplugin5.GetProviderSchema
Block: convert.ConfigSchemaToProto(dat.Block),
}
}
for typ, dat := range p.schema.EphemeralResources {
resp.EphemeralResourceSchemas[typ] = &tfplugin5.Schema{
Version: dat.Version,
Block: convert.ConfigSchemaToProto(dat.Block),
}
}
resp.ServerCapabilities = &tfplugin5.ServerCapabilities{
PlanDestroy: p.schema.ServerCapabilities.PlanDestroy,
@@ -131,6 +139,26 @@ func (p *provider) ValidateDataSourceConfig(ctx context.Context, req *tfplugin5.
return resp, nil
}
// ValidateEphemeralResourceConfig implements tfplugin5.ProviderServer.
func (p *provider) ValidateEphemeralResourceConfig(ctx context.Context, req *tfplugin5.ValidateEphemeralResourceConfig_Request) (*tfplugin5.ValidateEphemeralResourceConfig_Response, error) {
resp := &tfplugin5.ValidateEphemeralResourceConfig_Response{}
ty := p.schema.EphemeralResources[req.TypeName].Block.ImpliedType()
configVal, err := decodeDynamicValue(req.Config, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
validateResp := p.provider.ValidateEphemeralConfig(ctx, providers.ValidateEphemeralConfigRequest{
TypeName: req.TypeName,
Config: configVal,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, validateResp.Diagnostics)
return resp, nil
}
func (p *provider) UpgradeResourceState(ctx context.Context, req *tfplugin5.UpgradeResourceState_Request) (*tfplugin5.UpgradeResourceState_Response, error) {
resp := &tfplugin5.UpgradeResourceState_Response{}
ty := p.schema.ResourceTypes[req.TypeName].Block.ImpliedType()
@@ -392,24 +420,69 @@ func (p *provider) ReadDataSource(ctx context.Context, req *tfplugin5.ReadDataSo
return resp, nil
}
// CloseEphemeralResource implements tfplugin5.ProviderServer.
func (p *provider) CloseEphemeralResource(context.Context, *tfplugin5.CloseEphemeralResource_Request) (*tfplugin5.CloseEphemeralResource_Response, error) {
panic("unimplemented")
}
// OpenEphemeralResource implements tfplugin5.ProviderServer.
func (p *provider) OpenEphemeralResource(context.Context, *tfplugin5.OpenEphemeralResource_Request) (*tfplugin5.OpenEphemeralResource_Response, error) {
panic("unimplemented")
func (p *provider) OpenEphemeralResource(ctx context.Context, req *tfplugin5.OpenEphemeralResource_Request) (*tfplugin5.OpenEphemeralResource_Response, error) {
resp := &tfplugin5.OpenEphemeralResource_Response{}
ty := p.schema.EphemeralResources[req.TypeName].Block.ImpliedType()
configVal, err := decodeDynamicValue(req.Config, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
openResp := p.provider.OpenEphemeralResource(ctx, providers.OpenEphemeralResourceRequest{
TypeName: req.TypeName,
Config: configVal,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, openResp.Diagnostics)
if openResp.Diagnostics.HasErrors() {
return resp, nil
}
resp.Result, err = encodeDynamicValue(openResp.Result, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
resp.Private = openResp.Private
if openResp.RenewAt != nil {
resp.RenewAt = timestamppb.New(*openResp.RenewAt)
}
return resp, nil
}
// RenewEphemeralResource implements tfplugin5.ProviderServer.
func (p *provider) RenewEphemeralResource(context.Context, *tfplugin5.RenewEphemeralResource_Request) (*tfplugin5.RenewEphemeralResource_Response, error) {
panic("unimplemented")
func (p *provider) RenewEphemeralResource(ctx context.Context, req *tfplugin5.RenewEphemeralResource_Request) (*tfplugin5.RenewEphemeralResource_Response, error) {
resp := &tfplugin5.RenewEphemeralResource_Response{}
renewResp := p.provider.RenewEphemeralResource(ctx, providers.RenewEphemeralResourceRequest{
TypeName: req.TypeName,
Private: req.Private,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, renewResp.Diagnostics)
if renewResp.Diagnostics.HasErrors() {
return resp, nil
}
resp.Private = renewResp.Private
if renewResp.RenewAt != nil {
resp.RenewAt = timestamppb.New(*renewResp.RenewAt)
}
return resp, nil
}
// ValidateEphemeralResourceConfig implements tfplugin5.ProviderServer.
func (p *provider) ValidateEphemeralResourceConfig(context.Context, *tfplugin5.ValidateEphemeralResourceConfig_Request) (*tfplugin5.ValidateEphemeralResourceConfig_Response, error) {
panic("unimplemented")
// CloseEphemeralResource implements tfplugin5.ProviderServer.
func (p *provider) CloseEphemeralResource(ctx context.Context, req *tfplugin5.CloseEphemeralResource_Request) (*tfplugin5.CloseEphemeralResource_Response, error) {
resp := &tfplugin5.CloseEphemeralResource_Response{}
renewResp := p.provider.CloseEphemeralResource(ctx, providers.CloseEphemeralResourceRequest{
TypeName: req.TypeName,
Private: req.Private,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, renewResp.Diagnostics)
return resp, nil
}
func (p *provider) Stop(ctx context.Context, _ *tfplugin5.Stop_Request) (*tfplugin5.Stop_Response, error) {

View File

@@ -14,6 +14,7 @@ import (
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/zclconf/go-cty/cty/msgpack"
"google.golang.org/protobuf/types/known/timestamppb"
)
// New wraps a providers.Interface to implement a grpc ProviderServer using
@@ -33,8 +34,9 @@ type provider6 struct {
func (p *provider6) GetProviderSchema(_ context.Context, req *tfplugin6.GetProviderSchema_Request) (*tfplugin6.GetProviderSchema_Response, error) {
resp := &tfplugin6.GetProviderSchema_Response{
ResourceSchemas: make(map[string]*tfplugin6.Schema),
DataSourceSchemas: make(map[string]*tfplugin6.Schema),
ResourceSchemas: make(map[string]*tfplugin6.Schema),
DataSourceSchemas: make(map[string]*tfplugin6.Schema),
EphemeralResourceSchemas: make(map[string]*tfplugin6.Schema),
}
resp.Provider = &tfplugin6.Schema{
@@ -63,6 +65,12 @@ func (p *provider6) GetProviderSchema(_ context.Context, req *tfplugin6.GetProvi
Block: convert.ConfigSchemaToProto(dat.Block),
}
}
for typ, dat := range p.schema.EphemeralResources {
resp.EphemeralResourceSchemas[typ] = &tfplugin6.Schema{
Version: dat.Version,
Block: convert.ConfigSchemaToProto(dat.Block),
}
}
resp.ServerCapabilities = &tfplugin6.ServerCapabilities{
PlanDestroy: p.schema.ServerCapabilities.PlanDestroy,
@@ -131,6 +139,26 @@ func (p *provider6) ValidateDataResourceConfig(ctx context.Context, req *tfplugi
return resp, nil
}
// ValidateEphemeralResourceConfig implements tfplugin6.ProviderServer.
func (p *provider6) ValidateEphemeralResourceConfig(ctx context.Context, req *tfplugin6.ValidateEphemeralResourceConfig_Request) (*tfplugin6.ValidateEphemeralResourceConfig_Response, error) {
resp := &tfplugin6.ValidateEphemeralResourceConfig_Response{}
ty := p.schema.EphemeralResources[req.TypeName].Block.ImpliedType()
configVal, err := decodeDynamicValue6(req.Config, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
validateResp := p.provider.ValidateEphemeralConfig(ctx, providers.ValidateEphemeralConfigRequest{
TypeName: req.TypeName,
Config: configVal,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, validateResp.Diagnostics)
return resp, nil
}
func (p *provider6) UpgradeResourceState(ctx context.Context, req *tfplugin6.UpgradeResourceState_Request) (*tfplugin6.UpgradeResourceState_Response, error) {
resp := &tfplugin6.UpgradeResourceState_Response{}
ty := p.schema.ResourceTypes[req.TypeName].Block.ImpliedType()
@@ -392,24 +420,69 @@ func (p *provider6) ReadDataSource(ctx context.Context, req *tfplugin6.ReadDataS
return resp, nil
}
// CloseEphemeralResource implements tfplugin6.ProviderServer.
func (p *provider6) CloseEphemeralResource(context.Context, *tfplugin6.CloseEphemeralResource_Request) (*tfplugin6.CloseEphemeralResource_Response, error) {
panic("unimplemented")
}
// OpenEphemeralResource implements tfplugin6.ProviderServer.
func (p *provider6) OpenEphemeralResource(context.Context, *tfplugin6.OpenEphemeralResource_Request) (*tfplugin6.OpenEphemeralResource_Response, error) {
panic("unimplemented")
func (p *provider6) OpenEphemeralResource(ctx context.Context, req *tfplugin6.OpenEphemeralResource_Request) (*tfplugin6.OpenEphemeralResource_Response, error) {
resp := &tfplugin6.OpenEphemeralResource_Response{}
ty := p.schema.EphemeralResources[req.TypeName].Block.ImpliedType()
configVal, err := decodeDynamicValue6(req.Config, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
openResp := p.provider.OpenEphemeralResource(ctx, providers.OpenEphemeralResourceRequest{
TypeName: req.TypeName,
Config: configVal,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, openResp.Diagnostics)
if openResp.Diagnostics.HasErrors() {
return resp, nil
}
resp.Result, err = encodeDynamicValue6(openResp.Result, ty)
if err != nil {
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, err)
return resp, nil
}
resp.Private = openResp.Private
if openResp.RenewAt != nil {
resp.RenewAt = timestamppb.New(*openResp.RenewAt)
}
return resp, nil
}
// RenewEphemeralResource implements tfplugin6.ProviderServer.
func (p *provider6) RenewEphemeralResource(context.Context, *tfplugin6.RenewEphemeralResource_Request) (*tfplugin6.RenewEphemeralResource_Response, error) {
panic("unimplemented")
func (p *provider6) RenewEphemeralResource(ctx context.Context, req *tfplugin6.RenewEphemeralResource_Request) (*tfplugin6.RenewEphemeralResource_Response, error) {
resp := &tfplugin6.RenewEphemeralResource_Response{}
renewResp := p.provider.RenewEphemeralResource(ctx, providers.RenewEphemeralResourceRequest{
TypeName: req.TypeName,
Private: req.Private,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, renewResp.Diagnostics)
if renewResp.Diagnostics.HasErrors() {
return resp, nil
}
resp.Private = renewResp.Private
if renewResp.RenewAt != nil {
resp.RenewAt = timestamppb.New(*renewResp.RenewAt)
}
return resp, nil
}
// ValidateEphemeralResourceConfig implements tfplugin6.ProviderServer.
func (p *provider6) ValidateEphemeralResourceConfig(context.Context, *tfplugin6.ValidateEphemeralResourceConfig_Request) (*tfplugin6.ValidateEphemeralResourceConfig_Response, error) {
panic("unimplemented")
// CloseEphemeralResource implements tfplugin6.ProviderServer.
func (p *provider6) CloseEphemeralResource(ctx context.Context, req *tfplugin6.CloseEphemeralResource_Request) (*tfplugin6.CloseEphemeralResource_Response, error) {
resp := &tfplugin6.CloseEphemeralResource_Response{}
renewResp := p.provider.CloseEphemeralResource(ctx, providers.CloseEphemeralResourceRequest{
TypeName: req.TypeName,
Private: req.Private,
})
resp.Diagnostics = convert.AppendProtoDiag(resp.Diagnostics, renewResp.Diagnostics)
return resp, nil
}
func (p *provider6) StopProvider(ctx context.Context, _ *tfplugin6.StopProvider_Request) (*tfplugin6.StopProvider_Response, error) {

View File

@@ -8,6 +8,7 @@ package lang
import (
"context"
"fmt"
"log"
"maps"
"reflect"
"strings"
@@ -16,16 +17,15 @@ import (
"github.com/hashicorp/hcl/v2/ext/dynblock"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/instances"
"github.com/opentofu/opentofu/internal/lang/blocktoattr"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
)
// ExpandBlock expands any "dynamic" blocks present in the given body. The
@@ -80,6 +80,8 @@ func (s *Scope) EvalBlock(ctx context.Context, body hcl.Body, schema *configsche
val, evalDiags := hcldec.Decode(body, spec, hclCtx)
diags = diags.Append(enhanceFunctionDiags(evalDiags))
diags = diags.Append(validEphemeralReferences(schema, val))
val, depDiags := marks.ExtractDeprecationDiagnosticsWithBody(val, body)
diags = diags.Append(depDiags)
@@ -398,35 +400,37 @@ func (s *Scope) evalContext(ctx context.Context, parent *hcl.EvalContext, refs [
type evalVarBuilder struct {
s *Scope
dataResources map[string]map[string]cty.Value
managedResources map[string]map[string]cty.Value
wholeModules map[string]cty.Value
inputVariables map[string]cty.Value
localValues map[string]cty.Value
outputValues map[string]cty.Value
pathAttrs map[string]cty.Value
terraformAttrs map[string]cty.Value
countAttrs map[string]cty.Value
forEachAttrs map[string]cty.Value
checkBlocks map[string]cty.Value
self cty.Value
dataResources map[string]map[string]cty.Value
managedResources map[string]map[string]cty.Value
ephemeralResources map[string]map[string]cty.Value
wholeModules map[string]cty.Value
inputVariables map[string]cty.Value
localValues map[string]cty.Value
outputValues map[string]cty.Value
pathAttrs map[string]cty.Value
terraformAttrs map[string]cty.Value
countAttrs map[string]cty.Value
forEachAttrs map[string]cty.Value
checkBlocks map[string]cty.Value
self cty.Value
}
func (s *Scope) newEvalVarBuilder() *evalVarBuilder {
return &evalVarBuilder{
s: s,
dataResources: map[string]map[string]cty.Value{},
managedResources: map[string]map[string]cty.Value{},
wholeModules: map[string]cty.Value{},
inputVariables: map[string]cty.Value{},
localValues: map[string]cty.Value{},
outputValues: map[string]cty.Value{},
pathAttrs: map[string]cty.Value{},
terraformAttrs: map[string]cty.Value{},
countAttrs: map[string]cty.Value{},
forEachAttrs: map[string]cty.Value{},
checkBlocks: map[string]cty.Value{},
dataResources: map[string]map[string]cty.Value{},
ephemeralResources: map[string]map[string]cty.Value{},
managedResources: map[string]map[string]cty.Value{},
wholeModules: map[string]cty.Value{},
inputVariables: map[string]cty.Value{},
localValues: map[string]cty.Value{},
outputValues: map[string]cty.Value{},
pathAttrs: map[string]cty.Value{},
terraformAttrs: map[string]cty.Value{},
countAttrs: map[string]cty.Value{},
forEachAttrs: map[string]cty.Value{},
checkBlocks: map[string]cty.Value{},
}
}
@@ -549,6 +553,8 @@ func (b *evalVarBuilder) putResourceValue(ctx context.Context, res addrs.Resourc
into = b.managedResources
case addrs.DataResourceMode:
into = b.dataResources
case addrs.EphemeralResourceMode:
into = b.ephemeralResources
case addrs.InvalidResourceMode:
panic("BUG: got invalid resource mode")
default:
@@ -577,6 +583,7 @@ func (b *evalVarBuilder) buildAllVariablesInto(vals map[string]cty.Value) {
vals["resource"] = cty.ObjectVal(buildResourceObjects(b.managedResources))
vals["data"] = cty.ObjectVal(buildResourceObjects(b.dataResources))
vals["ephemeral"] = cty.ObjectVal(buildResourceObjects(b.ephemeralResources))
vals["module"] = cty.ObjectVal(b.wholeModules)
vals["var"] = cty.ObjectVal(b.inputVariables)
vals["local"] = cty.ObjectVal(b.localValues)
@@ -619,3 +626,72 @@ func normalizeRefValue(val cty.Value, diags tfdiags.Diagnostics) (cty.Value, tfd
}
return val, diags
}
// validEphemeralReferences is checking if val is containing ephemeral marks.
// The schema argument is used to figure out if the value is for an ephemeral
// context. If it is, then we don't even validate ephemeral marks.
// If val contains any ephemeral mark, we check if the attribute containing
// an ephemeral value is a write-only one. If not, we generate a diagnostic.
// The diagnostics returned by this method need to go through InConfigBody
// by the caller of the evaluator to append additional context to the diagnostic
// for an enhanced feedback to the user.
//
// A nil schema will handle the value as unable to hold any ephemeral mark.
func validEphemeralReferences(schema *configschema.Block, val cty.Value) (diags tfdiags.Diagnostics) {
// Ephemeral resources can reference values with any mark, so ignore this validation for ephemeral blocks
if schema != nil && schema.Ephemeral {
return diags
}
// This is the function for schema != nil.
// In the case of schema == nil, the function is recreated below.
//
// In cases of DynamicPseudoType attribute in the schema, the attribute that is actually
// referencing an ephemeral value might be missing from the schema.
// Therefore, we search for the first ancestor that exists in the schema.
attrFromSchema := func(path cty.Path) (*configschema.Attribute, cty.Path) {
attrPath := path
attr := schema.AttributeByPath(attrPath)
for attr == nil {
if len(attrPath) == 0 {
log.Printf("[WARN] no valid path found in schema for path \"%#v\"", path)
return nil, path
}
attrPath = attrPath[:len(attrPath)-1]
attr = schema.AttributeByPath(attrPath)
}
return attr, attrPath
}
// We recreate the attribute search in the schema here purely for being sure
// that the logic below can run even when the schema is nil.
// When there is no schema, there should be no ephemeral value in the block.
if schema == nil {
attrFromSchema = func(path cty.Path) (*configschema.Attribute, cty.Path) {
return nil, path
}
}
_, valueMarks := val.UnmarkDeepWithPaths()
for _, pathMark := range valueMarks {
_, ok := pathMark.Marks[marks.Ephemeral]
if !ok {
continue
}
// If the block is not ephemeral, then only its write-only attributes can reference ephemeral values.
// To figure it out, we need to find the attribute by the mark path.
attr, foundPath := attrFromSchema(pathMark.Path)
if attr != nil && attr.WriteOnly {
continue
}
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Ephemeral value used in non-ephemeral context",
fmt.Sprintf("Attribute %q is referencing an ephemeral value but ephemeral values can be referenced only by other ephemeral attributes or by write-only ones.", tfdiags.FormatCtyPath(foundPath)),
foundPath,
))
}
return diags
}

View File

@@ -8,12 +8,16 @@ package lang
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/instances"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcldec"
@@ -59,6 +63,9 @@ func TestScopeEvalContext(t *testing.T) {
"null_resource.multi[1]": cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("multi1"),
}),
"ephemeral.foo_ephemeral.bar": cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("baz"),
}),
},
LocalValues: map[string]cty.Value{
"foo": cty.StringVal("bar"),
@@ -373,6 +380,18 @@ func TestScopeEvalContext(t *testing.T) {
}),
},
},
{
`ephemeral.foo_ephemeral.bar`,
map[string]cty.Value{
"ephemeral": cty.ObjectVal(map[string]cty.Value{
"foo_ephemeral": cty.ObjectVal(map[string]cty.Value{
"bar": cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("baz"),
}),
}),
}),
},
},
}
for _, test := range tests {
@@ -1024,3 +1043,199 @@ func Test_enhanceFunctionDiags(t *testing.T) {
})
}
}
func TestValidEphemeralReference(t *testing.T) {
schema := &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
},
"secret": {
Type: cty.String,
},
"secret_wo": {
Type: cty.String,
WriteOnly: true,
},
},
BlockTypes: map[string]*configschema.NestedBlock{
"nested_simple": {
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{},
BlockTypes: map[string]*configschema.NestedBlock{
"inner_nested_simple": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"inner_nested_simple_attr": {
Type: cty.DynamicPseudoType,
WriteOnly: true,
},
},
},
},
},
},
Nesting: configschema.NestingSingle,
},
"nested_set": {
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{},
BlockTypes: map[string]*configschema.NestedBlock{
"inner_nested_set": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"inner_nested_set_attr": {
Type: cty.DynamicPseudoType,
WriteOnly: true,
},
},
},
},
},
},
Nesting: configschema.NestingSet,
},
},
}
tests := map[string]struct {
schema *configschema.Block
val cty.Value
want tfdiags.Diagnostics
}{
"nil schema with no ephemeral mark": {
nil,
cty.UnknownVal(cty.String),
nil,
},
"nil schema with ephemeral mark": {
nil,
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("id value"),
"secret": cty.StringVal("secret value"),
"secret_wo": cty.StringVal("secret value").Mark(marks.Ephemeral),
}),
tfdiags.Diagnostics{}.Append(
tfdiags.AttributeValue(
tfdiags.Error,
"Ephemeral value used in non-ephemeral context",
fmt.Sprintf("Attribute %q is referencing an ephemeral value but ephemeral values can be referenced only by other ephemeral attributes or by write-only ones.", ".secret_wo"),
cty.Path{cty.GetAttrStep{Name: "secret_wo"}},
),
),
},
"schema is ephemeral": {
&configschema.Block{
Ephemeral: true,
},
cty.UnknownVal(cty.String),
nil,
},
"no checks if the value contains no ephemeral": {
schema,
cty.StringVal("test"),
nil,
},
"write only argument is referencing ephemeral value": {
schema,
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("id value"),
"secret": cty.StringVal("secret value"),
"secret_wo": cty.StringVal("secret value").Mark(marks.Ephemeral),
}),
nil,
},
"error when an write-only and a non-write-only contain ephemeral": {
schema,
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("id value"),
"secret": cty.StringVal("secret value").Mark(marks.Ephemeral),
"secret_wo": cty.StringVal("secret value").Mark(marks.Ephemeral),
}),
tfdiags.Diagnostics{}.Append(
tfdiags.AttributeValue(
tfdiags.Error,
"Ephemeral value used in non-ephemeral context",
fmt.Sprintf("Attribute %q is referencing an ephemeral value but ephemeral values can be referenced only by other ephemeral attributes or by write-only ones.", ".secret"),
cty.Path{cty.GetAttrStep{Name: "secret"}},
),
),
},
"find the right DynamicPseudoType attribute": {
schema,
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("id value"),
"secret_wo": cty.StringVal("secret value").Mark(marks.Ephemeral),
"nested_simple": cty.ObjectVal(map[string]cty.Value{
"inner_nested_simple": cty.ObjectVal(map[string]cty.Value{
"inner_nested_simple_attr": cty.ObjectVal(map[string]cty.Value{
"attribute_not_in_schema": cty.StringVal("test val").Mark(marks.Ephemeral),
}),
}),
}),
}),
nil,
},
"error when attribute is not in the schema": {
schema,
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("id value"),
"secret_wo": cty.StringVal("secret value").Mark(marks.Ephemeral),
"nested_simple": cty.ObjectVal(map[string]cty.Value{
"inner_nested_simple": cty.ObjectVal(map[string]cty.Value{
"block_not_in_schema": cty.ObjectVal(map[string]cty.Value{
"attribute_not_in_schema": cty.StringVal("test val").Mark(marks.Ephemeral),
}),
}),
}),
}),
tfdiags.Diagnostics{}.Append(
tfdiags.AttributeValue(
tfdiags.Error,
"Ephemeral value used in non-ephemeral context",
fmt.Sprintf(
`Attribute %q is referencing an ephemeral value but ephemeral values can be referenced only by other ephemeral attributes or by write-only ones.`,
".nested_simple.inner_nested_simple.block_not_in_schema.attribute_not_in_schema",
),
cty.GetAttrPath("nested_simple").GetAttr("inner_nested_simple").GetAttr("block_not_in_schema").GetAttr("attribute_not_in_schema"),
),
),
},
}
lookupAttributeDiag := func(forPath cty.Path, in tfdiags.Diagnostics) tfdiags.Diagnostic {
for _, i := range in {
p := tfdiags.GetAttribute(i)
if p.Equals(forPath) {
return i
}
}
return nil
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
diags := validEphemeralReferences(tt.schema, tt.val)
if want, got := len(tt.want), len(diags); want != got {
t.Errorf("wrong number of diags. want: %d; got: %d", want, got)
}
for _, d := range diags {
attributePath := tfdiags.GetAttribute(d)
wantDiag := lookupAttributeDiag(attributePath, tt.want)
if wantDiag == nil {
t.Errorf("got a diagnostic with a path (%s) that is not expected: %s", attributePath, d)
continue
}
gotDesc := d.Description()
wantDesc := wantDiag.Description()
if diff := cmp.Diff(wantDesc, gotDesc); diff != "" {
t.Errorf("%s: unexpected diff in diagnostic description:\n%s", attributePath, diff)
}
if want, got := d.Severity(), wantDiag.Severity(); want != got {
t.Errorf("%s: wrong severity. want %q; got %q", attributePath, want, got)
}
}
})
}
}

View File

@@ -48,6 +48,10 @@ func Contains(val cty.Value, mark valueMark) bool {
// OpenTofu.
const Sensitive = valueMark("Sensitive")
// Ephemeral indicates that this value is marked as ephemeral in the context of
// OpenTofu.
const Ephemeral = valueMark("Ephemeral")
// TypeType is used to indicate that the value contains a representation of
// another value's type. This is part of the implementation of the console-only
// `type` function.
@@ -226,3 +230,20 @@ func RemoveDeepDeprecated(val cty.Value) cty.Value {
val, _ = unmarkDeepWithPathsDeprecated(val)
return val
}
// EnsureNoEphemeralMarks checks all the given paths for the Ephemeral mark.
// If there is at least one path marked as such, this method will return
// an error containing the marked paths.
func EnsureNoEphemeralMarks(pvms []cty.PathValueMarks) error {
var res []string
for _, pvm := range pvms {
if _, ok := pvm.Marks[Ephemeral]; ok {
res = append(res, tfdiags.FormatCtyPath(pvm.Path))
}
}
if len(res) > 0 {
return fmt.Errorf("ephemeral marks found at the following paths:\n%s", strings.Join(res, "\n"))
}
return nil
}

View File

@@ -126,6 +126,9 @@ func NewInstanceInfo(addr addrs.AbsResourceInstance) *InstanceInfo {
if addr.Resource.Resource.Mode == addrs.DataResourceMode {
id = "data." + id
}
if addr.Resource.Resource.Mode == addrs.EphemeralResourceMode {
panic("ephemeral resources are not meant to be processed by this function. Are you sure that this code should be reused?")
}
if addr.Resource.Key != addrs.NoKey {
switch k := addr.Resource.Key.(type) {
case addrs.IntKey:

View File

@@ -140,6 +140,10 @@ func (r *ResourceAddress) MatchesResourceConfig(path addrs.Module, rc *configs.R
if rc.Mode != addrs.DataResourceMode {
return false
}
default:
// NOTE: Even though the ephemeral resources are not supported in the legacy form, we want to be sure
// that it is not handled as the other type of resources.
return false
}
if r.Type != rc.Type || r.Name != rc.Name {
return false
@@ -300,6 +304,10 @@ func NewLegacyResourceAddress(addr addrs.AbsResource) *ResourceAddress {
case addrs.DataResourceMode:
ret.Mode = DataResourceMode
default:
// This is also covering the unlikely situation when an ephemeral resource will end up in here.
// This is not meant to happen. However, since this method is not used anymore, we want it to panic
// in case somebody starts using it again in the future. This is to indicate that this is legacy code and that
// is not meant to work with new features without putting additional work into it, if ever needed.
panic(fmt.Errorf("cannot shim %s to legacy ResourceMode value", addr.Resource.Mode))
}
@@ -338,6 +346,10 @@ func NewLegacyResourceInstanceAddress(addr addrs.AbsResourceInstance) *ResourceA
case addrs.DataResourceMode:
ret.Mode = DataResourceMode
default:
// This is also covering the unlikely situation when an ephemeral resource will end up in here.
// This is not meant to happen. However, since this method is not used anymore, we want it to panic
// in case somebody starts using it again in the future. This is to indicate that this is legacy code and that
// is not meant to work with new features without putting additional work into it, if ever needed.
panic(fmt.Errorf("cannot shim %s to legacy ResourceMode value", addr.Resource.Resource.Mode))
}
@@ -403,6 +415,8 @@ func (addr *ResourceAddress) AbsResourceInstanceAddr() addrs.AbsResourceInstance
case DataResourceMode:
ret.Resource.Resource.Mode = addrs.DataResourceMode
default:
// This case is also covering situations when ephemeral resources are getting here.
// This shouldn't be possible, so let this panic.
panic(fmt.Errorf("cannot shim %s to addrs.ResourceMode value", addr.Mode))
}

View File

@@ -167,6 +167,7 @@ func loadProviderSchemas(schemas map[addrs.Provider]*ProviderSchema, config *con
)
}
}
// NOTE: No ephemeral resources schema for the legacy code
schemas[fqn] = s
@@ -271,6 +272,8 @@ func (ps *ProviderSchema) SchemaForResourceType(mode addrs.ResourceMode, typeNam
case addrs.DataResourceMode:
// Data resources don't have schema versions right now, since state is discarded for each refresh
return ps.DataSources[typeName], 0
case addrs.EphemeralResourceMode:
panic("ephemeral resource is not meant to be in the schema for legacy providers")
default:
// Shouldn't happen, because the above cases are comprehensive.
return nil, 0

View File

@@ -1084,6 +1084,9 @@ func (m *ModuleState) Orphans(c *configs.Module) []addrs.ResourceInstance {
for _, r := range c.DataResources {
inConfig[r.Addr().String()] = struct{}{}
}
for _, r := range c.EphemeralResources {
log.Printf("[ERROR] ephemeral resources detected in legacy state: %s", r.Addr().String())
}
}
var result []addrs.ResourceInstance

View File

@@ -16,6 +16,10 @@ const (
CreateThenDelete Action = '±'
Delete Action = '-'
Forget Action = '.'
Open Action = '⁐'
// NOTE: Renew and Close missing on purpose.
// Those are not meant to be stored in the plan.
// Instead, we have hooks for those to show progress.
)
//go:generate go run golang.org/x/tools/cmd/stringer -type Action

View File

@@ -16,6 +16,7 @@ func _() {
_ = x[CreateThenDelete-177]
_ = x[Delete-45]
_ = x[Forget-46]
_ = x[Open-8272]
}
const (
@@ -24,8 +25,9 @@ const (
_Action_name_2 = "DeleteForget"
_Action_name_3 = "Update"
_Action_name_4 = "CreateThenDelete"
_Action_name_5 = "Read"
_Action_name_6 = "DeleteThenCreate"
_Action_name_5 = "Open"
_Action_name_6 = "Read"
_Action_name_7 = "DeleteThenCreate"
)
var (
@@ -45,10 +47,12 @@ func (i Action) String() string {
return _Action_name_3
case i == 177:
return _Action_name_4
case i == 8592:
case i == 8272:
return _Action_name_5
case i == 8723:
case i == 8592:
return _Action_name_6
case i == 8723:
return _Action_name_7
default:
return "Action(" + strconv.FormatInt(int64(i), 10) + ")"
}

View File

@@ -37,8 +37,24 @@ func NewChanges() *Changes {
return &Changes{}
}
// BuildChanges is a helper -- primarily intended for tests -- to build a state
// using imperative code against the StateSync type while still acting as
// an expression of type *State to assign into a containing struct.
func BuildChanges(cb func(sync *ChangesSync)) *Changes {
c := NewChanges()
cb(c.SyncWrapper())
return c
}
func (c *Changes) Empty() bool {
for _, res := range c.Resources {
// We ignore Open actions which are specific to ephemeral resources.
// A configuration containing ephemeral resources will always have changes planned,
// but if there is no other change recorded, there is no need for a prompt
// on the user.
if res.Action == Open {
continue
}
if res.Action != NoOp || res.Moved() {
return false
}

View File

@@ -41,6 +41,22 @@ func TestChangesEmpty(t *testing.T) {
Action: Update,
},
},
// but an ephemeral resources will not impact the "emptiness" of the plan
{
Addr: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: Open,
},
},
},
},
false,
@@ -119,6 +135,28 @@ func TestChangesEmpty(t *testing.T) {
},
true,
},
"ephemeral resource change": {
&Changes{
Resources: []*ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: Open,
},
},
},
},
true,
},
}
for name, tc := range testCases {

View File

@@ -88,6 +88,7 @@ const (
Action_DELETE_THEN_CREATE Action = 6
Action_CREATE_THEN_DELETE Action = 7
Action_FORGET Action = 8
Action_OPEN Action = 9
)
// Enum value maps for Action.
@@ -101,6 +102,7 @@ var (
6: "DELETE_THEN_CREATE",
7: "CREATE_THEN_DELETE",
8: "FORGET",
9: "OPEN",
}
Action_value = map[string]int32{
"NOOP": 0,
@@ -111,6 +113,7 @@ var (
"DELETE_THEN_CREATE": 6,
"CREATE_THEN_DELETE": 7,
"FORGET": 8,
"OPEN": 9,
}
)
@@ -1520,49 +1523,49 @@ var file_planfile_proto_rawDesc = []byte{
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x2a, 0x31, 0x0a, 0x04, 0x4d,
0x6f, 0x64, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x4e, 0x4f, 0x52, 0x4d, 0x41, 0x4c, 0x10, 0x00, 0x12,
0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c,
0x52, 0x45, 0x46, 0x52, 0x45, 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x7c,
0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4f, 0x50,
0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x08,
0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41,
0x54, 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05,
0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f,
0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x52, 0x45, 0x41,
0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x07,
0x12, 0x0a, 0x0a, 0x06, 0x46, 0x4f, 0x52, 0x47, 0x45, 0x54, 0x10, 0x08, 0x2a, 0xc8, 0x03, 0x0a,
0x1c, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63,
0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a,
0x04, 0x4e, 0x4f, 0x4e, 0x45, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41,
0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54,
0x45, 0x44, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f,
0x42, 0x59, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d,
0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f,
0x43, 0x41, 0x4e, 0x4e, 0x4f, 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12,
0x25, 0x0a, 0x21, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53,
0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f,
0x4e, 0x46, 0x49, 0x47, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45,
0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52,
0x45, 0x50, 0x45, 0x54, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44,
0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f,
0x55, 0x4e, 0x54, 0x5f, 0x49, 0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44,
0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41,
0x43, 0x48, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45,
0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f,
0x44, 0x55, 0x4c, 0x45, 0x10, 0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43,
0x45, 0x5f, 0x42, 0x59, 0x5f, 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12,
0x1f, 0x0a, 0x1b, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f,
0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a,
0x12, 0x23, 0x0a, 0x1f, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45,
0x5f, 0x44, 0x45, 0x50, 0x45, 0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44,
0x49, 0x4e, 0x47, 0x10, 0x0b, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45,
0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54,
0x45, 0x44, 0x10, 0x0d, 0x12, 0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42,
0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54,
0x41, 0x52, 0x47, 0x45, 0x54, 0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75,
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f,
0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f,
0x70, 0x6c, 0x61, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
0x52, 0x45, 0x46, 0x52, 0x45, 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x86,
0x01, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4f,
0x50, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12,
0x08, 0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44,
0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10,
0x05, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e,
0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x52, 0x45,
0x41, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10,
0x07, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x4f, 0x52, 0x47, 0x45, 0x54, 0x10, 0x08, 0x12, 0x08, 0x0a,
0x04, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x09, 0x2a, 0xc8, 0x03, 0x0a, 0x1c, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4e, 0x45,
0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45,
0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12,
0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f, 0x52, 0x45,
0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x45, 0x50, 0x4c, 0x41,
0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x4e, 0x4f,
0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x25, 0x0a, 0x21, 0x44, 0x45,
0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f,
0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x10,
0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41,
0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52, 0x45, 0x50, 0x45, 0x54, 0x49,
0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45,
0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x5f, 0x49,
0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45,
0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41, 0x43, 0x48, 0x5f, 0x4b, 0x45,
0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45,
0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x44, 0x55, 0x4c, 0x45, 0x10,
0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f,
0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12, 0x1f, 0x0a, 0x1b, 0x52, 0x45,
0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49,
0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a, 0x12, 0x23, 0x0a, 0x1f, 0x52,
0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x44, 0x45, 0x50, 0x45,
0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x0b,
0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45,
0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54, 0x45, 0x44, 0x10, 0x0d, 0x12,
0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53,
0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54,
0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f,
0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e,
0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@@ -122,6 +122,7 @@ enum Action {
DELETE_THEN_CREATE = 6;
CREATE_THEN_DELETE = 7;
FORGET = 8;
OPEN = 9;
}
// Change represents a change made to some object, transforming it from an old

View File

@@ -73,6 +73,16 @@ func PlannedDataResourceObject(schema *configschema.Block, config cty.Value) cty
return proposedNew(schema, prior, config)
}
// PlannedEphemeralResourceObject is exactly as PlannedDataResourceObject, but we
// want to have a different copy of it to emphasize the special handling of
// this type of resource.
// Ephemeral resources are not stored into the state, so every newly planned value
// is based only on the configuration and its schema.
func PlannedEphemeralResourceObject(schema *configschema.Block, config cty.Value) cty.Value {
prior := cty.UnknownVal(schema.ImpliedType())
return proposedNew(schema, prior, config)
}
func proposedNew(schema *configschema.Block, prior, config cty.Value) cty.Value {
if config.IsNull() || !config.IsKnown() {
// A block config should never be null at this point. The only nullable

View File

@@ -313,6 +313,9 @@ func assertPlannedValueValid(attrS *configschema.Attribute, priorV, configV, pla
return assertPlannedObjectValid(attrS.NestedType, priorV, configV, plannedV, path)
}
if !configV.IsNull() && plannedV.IsNull() && attrS.WriteOnly {
return errs // TODO ephemeral - check other places that might need a validation like this (part of the write-only attributes work)
}
// If none of the above conditions match, the provider has made an invalid
// change to this attribute.
if priorV.IsNull() {

View File

@@ -410,6 +410,8 @@ func changeFromTfplan(rawChange *planproto.Change) (*plans.ChangeSrc, error) {
ret.Action = plans.DeleteThenCreate
beforeIdx = 0
afterIdx = 1
case planproto.Action_OPEN:
ret.Action = plans.Open
default:
return nil, fmt.Errorf("invalid change action %s", rawChange.Action)
}
@@ -819,6 +821,15 @@ func changeToTfplan(change *plans.ChangeSrc) (*planproto.Change, error) {
case plans.CreateThenDelete:
ret.Action = planproto.Action_CREATE_THEN_DELETE
ret.Values = []*planproto.DynamicValue{before, after}
case plans.Open:
ret.Action = planproto.Action_OPEN
// We need to write ephemeral resources to the plan file to be able to build
// the apply graph on `tofu apply <planfile>`.
// The DiffTransformer needs the changes from the plan to be able to generate
// executable resource instance graph nodes so we are adding the ephemeral resources too.
// Even though we are writing these, the actual values of the ephemeral *must not*
// be written to the plan so set nothing here.
ret.Values = []*planproto.DynamicValue{}
default:
return nil, fmt.Errorf("invalid change action %s", change.Action)
}

View File

@@ -7,6 +7,7 @@ package planfile
import (
"bytes"
"slices"
"testing"
"github.com/go-test/deep"
@@ -173,6 +174,32 @@ func TestTFPlanRoundTrip(t *testing.T) {
GeneratedConfig: "resource \\\"test_thing\\\" \\\"importing\\\" {}",
},
},
{
Addr: addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_thing",
Name: "testeph",
}.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "testeph",
}.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance),
ProviderAddr: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
ChangeSrc: plans.ChangeSrc{
Action: plans.Open,
Before: mustNewDynamicValue(cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("testing"),
}), objTy),
After: mustNewDynamicValue(cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("testing"),
}), objTy),
GeneratedConfig: "ephemeral \\\"test_thing\\\" \\\"testeph\\\" {}",
},
},
},
},
DriftedResources: []*plans.ResourceInstanceChangeSrc{
@@ -297,6 +324,14 @@ func TestTFPlanRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
{
// nullify the ephemeral values from the initial plan since those must be nil in the plan file
i := slices.IndexFunc(plan.Changes.Resources, func(src *plans.ResourceInstanceChangeSrc) bool {
return src.Addr.Resource.Resource.Mode == addrs.EphemeralResourceMode
})
plan.Changes.Resources[i].After = nil
plan.Changes.Resources[i].Before = nil
}
newPlan, err := readTfplan(&buf)
if err != nil {

View File

@@ -36,6 +36,7 @@ func ConfigSchemaToProto(b *configschema.Block) *proto.Schema_Block {
Required: a.Required,
Sensitive: a.Sensitive,
Deprecated: a.Deprecated,
WriteOnly: a.WriteOnly,
}
ty, err := json.Marshal(a.Type)
@@ -98,6 +99,15 @@ func ProtoToProviderSchema(s *proto.Schema) providers.Schema {
}
}
// ProtoToEphemeralProviderSchema takes a proto.Schema and converts it to a providers.Schema
// marking it as being able to work with ephemeral values.
func ProtoToEphemeralProviderSchema(s *proto.Schema) providers.Schema {
ret := ProtoToProviderSchema(s)
ret.Block.Ephemeral = true
return ret
}
// ProtoToConfigSchema takes the Schema_Block from a grpc response and converts it
// to a tofu *configschema.Block.
func ProtoToConfigSchema(b *proto.Schema_Block) *configschema.Block {
@@ -119,6 +129,7 @@ func ProtoToConfigSchema(b *proto.Schema_Block) *configschema.Block {
Computed: a.Computed,
Sensitive: a.Sensitive,
Deprecated: a.Deprecated,
WriteOnly: a.WriteOnly,
}
if err := json.Unmarshal(a.Type, &attr.Type); err != nil {

Some files were not shown because too many files have changed in this diff Show More