blob: e9091fc75fb66490bbb5139a7edec76c4bdc47db [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package rpcapi
import (
"context"
"io"
"maps"
"slices"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-slug/sourceaddrs"
"github.com/hashicorp/go-slug/sourcebundle"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/zclconf/go-cty/cty"
ctymsgpack "github.com/zclconf/go-cty/cty/msgpack"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/anypb"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/getproviders/providerreqs"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/rpcapi/terraform1"
"github.com/hashicorp/terraform/internal/rpcapi/terraform1/dependencies"
"github.com/hashicorp/terraform/internal/rpcapi/terraform1/stacks"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackmigrate"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
stacks_testing_provider "github.com/hashicorp/terraform/internal/stacks/stackruntime/testing"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/stacks/tfstackdata1"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/version"
)
func TestStacksOpenCloseStackConfiguration(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
// In normal use a client would have previously opened a source bundle
// using Dependencies.OpenSourceBundle, so we'll simulate the effect
// of that here.
var sourcesHnd handle[*sourcebundle.Bundle]
{
sources, err := sourcebundle.OpenDir("testdata/sourcebundle")
if err != nil {
t.Fatal(err)
}
sourcesHnd = handles.NewSourceBundle(sources)
}
openResp, err := stacksServer.OpenStackConfiguration(ctx, &stacks.OpenStackConfiguration_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
SourceAddress: &terraform1.SourceAddress{
Source: "git::https://example.com/foo.git",
},
})
if err != nil {
t.Fatal(err)
}
// A client wouldn't normally be able to interact directly with the
// stack configuration, but we're doing that here to simulate what would
// happen in another service that takes configuration handles as input.
{
hnd := handle[*stackconfig.Config](openResp.StackConfigHandle)
cfg := handles.StackConfig(hnd)
if cfg == nil {
t.Fatal("returned stack config handle is invalid")
}
}
// A hypothetical attempt to close the underlying source bundle while
// the stack configuration is active should fail.
{
depsServer := newDependenciesServer(handles, disco.New())
_, err := depsServer.CloseSourceBundle(ctx, &dependencies.CloseSourceBundle_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
})
if err == nil {
t.Fatal("successfully closed source bundle while stack config was using it; should have failed to close")
}
protoStatus, ok := status.FromError(err)
if !ok {
t.Fatal("error is not a protobuf status code")
}
if got, want := protoStatus.Code(), codes.InvalidArgument; got != want {
t.Errorf("wrong error status\ngot: %s\nwant: %s", got, want)
}
if got, want := protoStatus.Message(), "handle is in use by another open handle"; got != want {
t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want)
}
}
_, err = stacksServer.CloseStackConfiguration(ctx, &stacks.CloseStackConfiguration_Request{
StackConfigHandle: openResp.StackConfigHandle,
})
if err != nil {
t.Fatal(err)
}
// Should be able to close the source bundle now too.
{
depsServer := newDependenciesServer(handles, disco.New())
_, err := depsServer.CloseSourceBundle(ctx, &dependencies.CloseSourceBundle_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
})
if err != nil {
t.Fatalf("failed to close the source bundle: %s", err.Error())
}
}
}
func TestStacksFindStackConfigurationComponents(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
// In normal use a client would have previously opened a source bundle
// using Dependencies.OpenSourceBundle, so we'll simulate the effect
// of that here.
var sourcesHnd handle[*sourcebundle.Bundle]
{
sources, err := sourcebundle.OpenDir("testdata/sourcebundle")
if err != nil {
t.Fatal(err)
}
sourcesHnd = handles.NewSourceBundle(sources)
}
t.Run("empty config", func(t *testing.T) {
openResp, err := stacksServer.OpenStackConfiguration(ctx, &stacks.OpenStackConfiguration_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
SourceAddress: &terraform1.SourceAddress{
Source: "git::https://example.com/foo.git",
},
})
if err != nil {
t.Fatal(err)
}
if len(openResp.Diagnostics) != 0 {
t.Error("empty configuration generated diagnostics; expected none")
if openResp.StackConfigHandle == 0 {
return // Our later operations will fail if given the nil handle
}
}
cmpntResp, err := stacksServer.FindStackConfigurationComponents(ctx, &stacks.FindStackConfigurationComponents_Request{
StackConfigHandle: openResp.StackConfigHandle,
})
if err != nil {
t.Fatal(err)
}
got := cmpntResp.Config
want := &stacks.FindStackConfigurationComponents_StackConfig{
// Intentionally empty, because the configuration we've loaded
// is itself empty.
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("non-empty config", func(t *testing.T) {
openResp, err := stacksServer.OpenStackConfiguration(ctx, &stacks.OpenStackConfiguration_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
SourceAddress: &terraform1.SourceAddress{
Source: "git::https://example.com/foo.git//non-empty-stack",
},
})
if err != nil {
t.Fatal(err)
}
if len(openResp.Diagnostics) != 0 {
t.Error("empty configuration generated diagnostics; expected none")
if openResp.StackConfigHandle == 0 {
return // Our later operations will fail if given the nil handle
}
}
cmpntResp, err := stacksServer.FindStackConfigurationComponents(ctx, &stacks.FindStackConfigurationComponents_Request{
StackConfigHandle: openResp.StackConfigHandle,
})
if err != nil {
t.Fatal(err)
}
got := cmpntResp.Config
want := &stacks.FindStackConfigurationComponents_StackConfig{
Components: map[string]*stacks.FindStackConfigurationComponents_Component{
"single": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/empty-module",
ComponentAddr: "component.single",
},
"for_each": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/empty-module",
Instances: stacks.FindStackConfigurationComponents_FOR_EACH,
ComponentAddr: "component.for_each",
},
},
EmbeddedStacks: map[string]*stacks.FindStackConfigurationComponents_EmbeddedStack{
"single": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/child",
Config: &stacks.FindStackConfigurationComponents_StackConfig{
Components: map[string]*stacks.FindStackConfigurationComponents_Component{
"foo": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/empty-module",
ComponentAddr: "stack.single.component.foo",
},
},
},
},
"for_each": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/child",
Instances: stacks.FindStackConfigurationComponents_FOR_EACH,
Config: &stacks.FindStackConfigurationComponents_StackConfig{
Components: map[string]*stacks.FindStackConfigurationComponents_Component{
"foo": {
SourceAddr: "git::https://example.com/foo.git//non-empty-stack/empty-module",
ComponentAddr: "stack.for_each.component.foo",
},
},
},
},
},
InputVariables: map[string]*stacks.FindStackConfigurationComponents_InputVariable{
"unused": {Optional: false},
"unused_with_default": {Optional: true},
"sensitive": {Sensitive: true},
"ephemeral": {Ephemeral: true},
},
OutputValues: map[string]*stacks.FindStackConfigurationComponents_OutputValue{
"normal": {},
"sensitive": {Sensitive: true},
"ephemeral": {Ephemeral: true},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
}
func TestStacksOpenState(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
stacksClient := stacks.NewStacksClient(grpcClient)
stream, err := stacksClient.OpenState(ctx)
if err != nil {
t.Fatal(err)
}
send := func(t *testing.T, key string, msg proto.Message) {
rawMsg, err := anypb.New(msg)
if err != nil {
t.Fatalf("failed to encode %T message %q: %s", msg, key, err)
}
err = stream.Send(&stacks.OpenStackState_RequestItem{
Raw: &stacks.AppliedChange_RawChange{
Key: key,
Value: rawMsg,
},
})
if err != nil {
t.Fatalf("failed to send %T message %q: %s", msg, key, err)
}
}
send(t, "CMPTcomponent.foo", &tfstackdata1.StateComponentInstanceV1{})
resp, err := stream.CloseAndRecv()
if err != nil {
t.Fatal(err)
}
hnd := handle[*stackstate.State](resp.StateHandle)
state := handles.StackState(hnd)
if state == nil {
t.Fatalf("returned handle %d does not refer to a stack prior state", resp.StateHandle)
}
// The state should know about component.foo from the message we sent above.
wantComponentInstAddr := stackaddrs.AbsComponentInstance{
Stack: stackaddrs.RootStackInstance,
Item: stackaddrs.ComponentInstance{
Component: stackaddrs.Component{
Name: "foo",
},
},
}
if !state.HasComponentInstance(wantComponentInstAddr) {
t.Errorf("state does not track %s", wantComponentInstAddr)
}
_, err = stacksClient.CloseState(ctx, &stacks.CloseStackState_Request{
StateHandle: resp.StateHandle,
})
if err != nil {
t.Errorf("failed to close the prior state handle: %s", err)
}
}
func TestStacksOpenPlan(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
stacksClient := stacks.NewStacksClient(grpcClient)
stream, err := stacksClient.OpenPlan(ctx)
if err != nil {
t.Fatal(err)
}
send := func(t *testing.T, msg proto.Message) {
rawMsg, err := anypb.New(msg)
if err != nil {
t.Fatalf("failed to encode %T message: %s", msg, err)
}
err = stream.Send(&stacks.OpenStackPlan_RequestItem{
Raw: rawMsg,
})
if err != nil {
t.Fatalf("failed to send %T message: %s", msg, err)
}
}
send(t, &tfstackdata1.PlanHeader{
TerraformVersion: version.SemVer.String(),
})
send(t, &tfstackdata1.PlanPriorStateElem{
// We don't actually analyze or validate these items while
// just loading a plan, so we can safely just put simple
// garbage in here for testing.
Key: "test-foo",
})
send(t, &tfstackdata1.PlanApplyable{
Applyable: true,
})
resp, err := stream.CloseAndRecv()
if err != nil {
t.Fatal(err)
}
hnd := handle[*stackplan.Plan](resp.PlanHandle)
plan := handles.StackPlan(hnd)
if plan == nil {
t.Fatalf("returned handle %d does not refer to a stack plan", resp.PlanHandle)
}
if !plan.Applyable {
t.Error("plan is not applyable; should've been")
}
if _, exists := plan.PrevRunStateRaw["test-foo"]; !exists {
t.Error("plan is missing the raw state entry for 'test-foo'")
}
_, err = stacksClient.ClosePlan(ctx, &stacks.CloseStackPlan_Request{
PlanHandle: resp.PlanHandle,
})
if err != nil {
t.Errorf("failed to close the plan handle: %s", err)
}
}
func TestStacksPlanStackChanges(t *testing.T) {
ctx := context.Background()
fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z")
if err != nil {
t.Fatal(err)
}
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
stacksServer.planTimestampOverride = &fakePlanTimestamp
fakeSourceBundle := &sourcebundle.Bundle{}
bundleHnd := handles.NewSourceBundle(fakeSourceBundle)
emptyConfig := &stackconfig.Config{
Root: &stackconfig.ConfigNode{
Stack: &stackconfig.Stack{
SourceAddr: sourceaddrs.MustParseSource("git::https://example.com/foo.git").(sourceaddrs.RemoteSource),
},
},
}
configHnd, err := handles.NewStackConfig(emptyConfig, bundleHnd)
if err != nil {
t.Fatal(err)
}
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
stacksClient := stacks.NewStacksClient(grpcClient)
events, err := stacksClient.PlanStackChanges(ctx, &stacks.PlanStackChanges_Request{
PlanMode: stacks.PlanMode_NORMAL,
StackConfigHandle: configHnd.ForProtobuf(),
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
wantEvents := splitStackOperationEvents([]*stacks.PlanStackChanges_Event{
{
Event: &stacks.PlanStackChanges_Event_PlannedChange{
PlannedChange: &stacks.PlannedChange{
Raw: []*anypb.Any{
mustMarshalAnyPb(&tfstackdata1.PlanHeader{
TerraformVersion: version.SemVer.String(),
}),
},
},
},
},
{
Event: &stacks.PlanStackChanges_Event_PlannedChange{
PlannedChange: &stacks.PlannedChange{
Raw: []*anypb.Any{
mustMarshalAnyPb(&tfstackdata1.PlanTimestamp{
PlanTimestamp: fakePlanTimestamp.Format(time.RFC3339),
}),
},
},
},
},
{
Event: &stacks.PlanStackChanges_Event_PlannedChange{
PlannedChange: &stacks.PlannedChange{
Raw: []*anypb.Any{
mustMarshalAnyPb(&tfstackdata1.PlanApplyable{
Applyable: true,
}),
},
Descriptions: []*stacks.PlannedChange_ChangeDescription{
{
Description: &stacks.PlannedChange_ChangeDescription_PlanApplyable{
PlanApplyable: true,
},
},
},
},
},
},
})
var gotEventsAll []*stacks.PlanStackChanges_Event
for {
event, err := events.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
gotEventsAll = append(gotEventsAll, event)
}
gotEvents := splitStackOperationEvents(gotEventsAll)
if diff := cmp.Diff(wantEvents, gotEvents, protocmp.Transform()); diff != "" {
t.Errorf("wrong events\n%s", diff)
}
}
func TestStackChangeProgress(t *testing.T) {
tcs := map[string]struct {
source string
store *stacks_testing_provider.ResourceStore
state []stackstate.AppliedChange
inputs map[string]cty.Value
want []*stacks.StackChangeProgress
diagnostics []*terraform1.Diagnostic
}{
"deferred_changes": {
source: "git::https://example.com/bar.git",
want: []*stacks.StackChangeProgress{
{
Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{
ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.deferred",
ComponentInstanceAddr: "component.deferred",
},
Total: 1,
Defer: 1,
},
},
},
{
Event: &stacks.StackChangeProgress_DeferredResourceInstancePlannedChange_{
DeferredResourceInstancePlannedChange: &stacks.StackChangeProgress_DeferredResourceInstancePlannedChange{
Deferred: &stacks.Deferred{
Reason: stacks.Deferred_RESOURCE_CONFIG_UNKNOWN,
},
Change: &stacks.StackChangeProgress_ResourceInstancePlannedChange{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.deferred",
ResourceInstanceAddr: "testing_deferred_resource.resource",
},
Actions: []stacks.ChangeType{stacks.ChangeType_CREATE},
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.deferred",
ResourceInstanceAddr: "testing_deferred_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNING,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.deferred",
ResourceInstanceAddr: "testing_deferred_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNED,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ComponentInstanceStatus_{
ComponentInstanceStatus: &stacks.StackChangeProgress_ComponentInstanceStatus{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.deferred",
ComponentInstanceAddr: "component.deferred",
},
Status: stacks.StackChangeProgress_ComponentInstanceStatus_DEFERRED,
},
},
},
},
},
"moved": {
source: "git::https://example.com/moved.git",
store: stacks_testing_provider.NewResourceStoreBuilder().
AddResource("before", cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("before"),
"value": cty.NullVal(cty.String),
})).
Build(),
state: []stackstate.AppliedChange{
&stackstate.AppliedChangeComponentInstance{
ComponentAddr: mustAbsComponent(t, "component.self"),
ComponentInstanceAddr: mustAbsComponentInstance(t, "component.self"),
},
&stackstate.AppliedChangeResourceInstanceObject{
ResourceInstanceObjectAddr: mustAbsResourceInstanceObject(t, "component.self.testing_resource.before"),
NewStateSrc: &states.ResourceInstanceObjectSrc{
AttrsJSON: mustMarshalJSONAttrs(map[string]interface{}{
"id": "before",
"value": nil,
}),
Status: states.ObjectReady,
},
ProviderConfigAddr: mustDefaultRootProvider("testing"),
Schema: stacks_testing_provider.TestingResourceSchema,
},
},
want: []*stacks.StackChangeProgress{
{
Event: &stacks.StackChangeProgress_ResourceInstancePlannedChange_{
ResourceInstancePlannedChange: &stacks.StackChangeProgress_ResourceInstancePlannedChange{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.after",
},
Actions: []stacks.ChangeType{
stacks.ChangeType_NOOP,
},
Moved: &stacks.StackChangeProgress_ResourceInstancePlannedChange_Moved{
PrevAddr: &stacks.ResourceInstanceInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.before",
},
},
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{
ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.self",
ComponentInstanceAddr: "component.self",
},
Total: 1,
Move: 1,
},
},
},
},
},
"import": {
source: "git::https://example.com/import.git",
store: stacks_testing_provider.NewResourceStoreBuilder().
AddResource("self", cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("self"),
"value": cty.NullVal(cty.String),
})).
Build(),
inputs: map[string]cty.Value{
"unknown": cty.UnknownVal(cty.String),
},
want: []*stacks.StackChangeProgress{
{
Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{
ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.unknown",
ComponentInstanceAddr: "component.unknown",
},
Total: 1,
Defer: 1,
},
},
},
{
Event: &stacks.StackChangeProgress_DeferredResourceInstancePlannedChange_{
DeferredResourceInstancePlannedChange: &stacks.StackChangeProgress_DeferredResourceInstancePlannedChange{
Deferred: &stacks.Deferred{
Reason: stacks.Deferred_RESOURCE_CONFIG_UNKNOWN,
},
Change: &stacks.StackChangeProgress_ResourceInstancePlannedChange{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.unknown",
ResourceInstanceAddr: "testing_resource.resource",
},
Actions: []stacks.ChangeType{stacks.ChangeType_CREATE},
Imported: &stacks.StackChangeProgress_ResourceInstancePlannedChange_Imported{
Unknown: true,
},
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.unknown",
ResourceInstanceAddr: "testing_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNING,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.unknown",
ResourceInstanceAddr: "testing_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNED,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
}, {
Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{
ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.self",
ComponentInstanceAddr: "component.self",
},
Total: 1,
Import: 1,
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstancePlannedChange_{
ResourceInstancePlannedChange: &stacks.StackChangeProgress_ResourceInstancePlannedChange{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.resource",
},
Actions: []stacks.ChangeType{stacks.ChangeType_NOOP},
Imported: &stacks.StackChangeProgress_ResourceInstancePlannedChange_Imported{
ImportId: "self",
},
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNING,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ResourceInstanceStatus_{
ResourceInstanceStatus: &stacks.StackChangeProgress_ResourceInstanceStatus{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.resource",
},
Status: stacks.StackChangeProgress_ResourceInstanceStatus_PLANNED,
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
},
},
"removed": {
source: "git::https://example.com/removed.git",
store: stacks_testing_provider.NewResourceStoreBuilder().
AddResource("resource", cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("resource"),
"value": cty.NullVal(cty.String),
})).
Build(),
state: []stackstate.AppliedChange{
&stackstate.AppliedChangeComponentInstance{
ComponentAddr: mustAbsComponent(t, "component.self"),
ComponentInstanceAddr: mustAbsComponentInstance(t, "component.self"),
},
&stackstate.AppliedChangeResourceInstanceObject{
ResourceInstanceObjectAddr: mustAbsResourceInstanceObject(t, "component.self.testing_resource.resource"),
NewStateSrc: &states.ResourceInstanceObjectSrc{
AttrsJSON: mustMarshalJSONAttrs(map[string]interface{}{
"id": "resource",
"value": nil,
}),
Status: states.ObjectReady,
},
ProviderConfigAddr: mustDefaultRootProvider("testing"),
Schema: stacks_testing_provider.TestingResourceSchema,
},
},
want: []*stacks.StackChangeProgress{
{
Event: &stacks.StackChangeProgress_ResourceInstancePlannedChange_{
ResourceInstancePlannedChange: &stacks.StackChangeProgress_ResourceInstancePlannedChange{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_resource.resource",
},
Actions: []stacks.ChangeType{
stacks.ChangeType_FORGET,
},
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{
ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{
Addr: &stacks.ComponentInstanceInStackAddr{
ComponentAddr: "component.self",
ComponentInstanceAddr: "component.self",
},
Total: 1,
Forget: 1,
},
},
},
},
diagnostics: []*terraform1.Diagnostic{
{
Severity: terraform1.Diagnostic_WARNING,
Summary: "Some objects will no longer be managed by Terraform",
Detail: "If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them:\n - testing_resource.resource\n\nAfter applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again.",
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
// For this test, we do actually want to use a "real" provider. We'll
// use the providerCacheOverride to side-load the testing provider.
stacksServer.providerCacheOverride = make(map[addrs.Provider]providers.Factory)
stacksServer.providerCacheOverride[addrs.NewDefaultProvider("testing")] = func() (providers.Interface, error) {
return stacks_testing_provider.NewProviderWithData(t, tc.store), nil
}
lock := depsfile.NewLocks()
lock.SetProvider(
addrs.NewDefaultProvider("testing"),
providerreqs.MustParseVersion("0.0.0"),
providerreqs.MustParseVersionConstraints("=0.0.0"),
providerreqs.PreferredHashes([]providerreqs.Hash{}),
)
stacksServer.providerDependencyLockOverride = lock
sb, err := sourcebundle.OpenDir("testdata/sourcebundle")
if err != nil {
t.Fatal(err)
}
hnd := handles.NewSourceBundle(sb)
client, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
stacksClient := stacks.NewStacksClient(client)
open, err := stacksClient.OpenStackConfiguration(ctx, &stacks.OpenStackConfiguration_Request{
SourceBundleHandle: hnd.ForProtobuf(),
SourceAddress: &terraform1.SourceAddress{
Source: tc.source,
},
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
defer stacksClient.CloseStackConfiguration(ctx, &stacks.CloseStackConfiguration_Request{
StackConfigHandle: open.StackConfigHandle,
})
resp, err := stacksClient.PlanStackChanges(ctx, &stacks.PlanStackChanges_Request{
PlanMode: stacks.PlanMode_NORMAL,
StackConfigHandle: open.StackConfigHandle,
PreviousState: appliedChangeToRawState(t, tc.state),
InputValues: func() map[string]*stacks.DynamicValueWithSource {
values := make(map[string]*stacks.DynamicValueWithSource)
for name, value := range tc.inputs {
values[name] = &stacks.DynamicValueWithSource{
Value: &stacks.DynamicValue{
Msgpack: mustMsgpack(t, value, value.Type()),
},
SourceRange: &terraform1.SourceRange{
Start: &terraform1.SourcePos{},
End: &terraform1.SourcePos{},
},
}
}
return values
}(),
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
wantEvents := splitStackOperationEvents(func() []*stacks.PlanStackChanges_Event {
events := make([]*stacks.PlanStackChanges_Event, 0, len(tc.want))
for _, want := range tc.want {
events = append(events, &stacks.PlanStackChanges_Event{
Event: &stacks.PlanStackChanges_Event_Progress{
Progress: want,
},
})
}
return events
}())
gotEvents := splitStackOperationEvents(func() []*stacks.PlanStackChanges_Event {
var events []*stacks.PlanStackChanges_Event
for {
event, err := resp.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
events = append(events, event)
}
return events
}())
// First, validate the diagnostics. Most of the tests are either
// expecting a specific single diagnostic so we do actually check
// everything.
diagIx := 0
for ; diagIx < len(tc.diagnostics); diagIx++ {
if diagIx >= len(gotEvents.Diagnostics) {
// Then we have more expected diagnostics than we got.
t.Errorf("missing expected diagnostic: %v", tc.diagnostics[diagIx])
continue
}
diag := gotEvents.Diagnostics[diagIx].Event.(*stacks.PlanStackChanges_Event_Diagnostic).Diagnostic
if diff := cmp.Diff(tc.diagnostics[diagIx], diag, protocmp.Transform()); diff != "" {
// Then we have a diagnostic that doesn't match what we
// expected.
t.Errorf("wrong diagnostic\n%s", diff)
}
}
for ; diagIx < len(gotEvents.Diagnostics); diagIx++ {
// Then we have more diagnostics than we expected.
t.Errorf("unexpected diagnostic: %v", gotEvents.Diagnostics[diagIx])
}
// Now we're going to manually verify the existence of some key events.
// We're not looking for every event because (a) the exact ordering of
// events is not guaranteed and (b) we don't want to start failing every
// time a new event is added.
WantPlannedChange:
for _, want := range wantEvents.PlannedChanges {
for _, got := range gotEvents.PlannedChanges {
if len(cmp.Diff(want, got, protocmp.Transform())) == 0 {
continue WantPlannedChange
}
}
t.Errorf("missing expected planned change: %v", want)
}
WantMiscHook:
for _, want := range wantEvents.MiscHooks {
for _, got := range gotEvents.MiscHooks {
if len(cmp.Diff(want, got, protocmp.Transform())) == 0 {
continue WantMiscHook
}
}
t.Errorf("missing expected event: %v", want)
}
if t.Failed() {
// if the test failed, let's print out all the events we got to help
// with debugging.
for _, evt := range gotEvents.MiscHooks {
t.Logf(" returned event: %s", evt.String())
}
for _, evt := range gotEvents.PlannedChanges {
t.Logf(" returned event: %s", evt.String())
}
}
})
}
}
func TestStacksOpenTerraformState_ConfigPath(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
s := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"bar","foo":"value","bar":"value"}`),
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
})
statePath := stackmigrate.TestStateFile(t, s)
stacksClient := stacks.NewStacksClient(grpcClient)
resp, err := stacksClient.OpenTerraformState(ctx, &stacks.OpenTerraformState_Request{
State: &stacks.OpenTerraformState_Request_ConfigPath{
ConfigPath: strings.TrimSuffix(statePath, "/terraform.tfstate"),
},
})
if err != nil {
t.Fatal(err)
}
hnd := handle[*states.State](resp.StateHandle)
state := handles.TerraformState(hnd)
if state == nil {
t.Fatalf("returned handle %d does not refer to a Terraform state", resp.StateHandle)
}
if !statefile.StatesMarshalEqual(s, state) {
t.Fatalf("loaded state does not match original state")
}
}
func TestStacksOpenTerraformState_Raw(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
s := []byte(`{
"version": 4,
"terraform_version": "1.12.0",
"serial": 0,
"lineage": "fake-for-testing",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "test_instance",
"name": "foo",
"provider": "provider[\"registry.terraform.io/hashicorp/test\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "bar",
"foo": "value",
"bar": "value"
},
"sensitive_attributes": []
}
]
}
],
"check_results": null
}
`)
stacksClient := stacks.NewStacksClient(grpcClient)
resp, err := stacksClient.OpenTerraformState(ctx, &stacks.OpenTerraformState_Request{
State: &stacks.OpenTerraformState_Request_Raw{
Raw: s,
},
})
if err != nil {
t.Fatal(err)
}
hnd := handle[*states.State](resp.StateHandle)
state := handles.TerraformState(hnd)
if state == nil {
t.Fatalf("returned handle %d does not refer to a Terraform state", resp.StateHandle)
}
if !slices.Contains(slices.Collect(maps.Keys(state.Modules[""].Resources)), "test_instance.foo") {
t.Fatalf("loaded state does not contain expected resource")
}
}
func TestStacksMigrateTerraformState(t *testing.T) {
ctx := context.Background()
handles := newHandleTable()
stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{})
grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
stacks.RegisterStacksServer(srv, stacksServer)
})
defer close()
s := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "testing_deferred_resource",
Name: "resource",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"hello","value":"world","deferred":false}`),
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("testing"),
Module: addrs.RootModule,
},
)
})
statePath := stackmigrate.TestStateFile(t, s)
stacksClient := stacks.NewStacksClient(grpcClient)
resp, err := stacksClient.OpenTerraformState(ctx, &stacks.OpenTerraformState_Request{
State: &stacks.OpenTerraformState_Request_ConfigPath{
ConfigPath: strings.TrimSuffix(statePath, "/terraform.tfstate"),
},
})
if err != nil {
t.Fatal(err)
}
hnd := handle[*states.State](resp.StateHandle)
state := handles.TerraformState(hnd)
if state == nil {
t.Fatalf("returned handle %d does not refer to a Terraform state", resp.StateHandle)
}
if !statefile.StatesMarshalEqual(s, state) {
t.Fatalf("loaded state does not match original state")
}
// up until now is basically what we did in TestStacksOpenTerraformState_ConfigPath
// now we're going to migrate the state and check that the migration worked
// In normal use a client would have previously opened a source bundle
// using Dependencies.OpenSourceBundle, so we'll simulate the effect
// of that here.
sources, err := sourcebundle.OpenDir("testdata/sourcebundle")
if err != nil {
t.Fatal(err)
}
sourcesHnd := handles.NewSourceBundle(sources)
openResp, err := stacksServer.OpenStackConfiguration(ctx, &stacks.OpenStackConfiguration_Request{
SourceBundleHandle: sourcesHnd.ForProtobuf(),
SourceAddress: &terraform1.SourceAddress{
Source: "git::https://example.com/baz.git",
},
})
if err != nil {
t.Fatalf("unable to open stack configuration: %s", err)
}
// For this test, we do actually want to use a "real" provider. We'll
// use the providerCacheOverride to side-load the testing provider.
stacksServer.providerCacheOverride = make(map[addrs.Provider]providers.Factory)
stacksServer.providerCacheOverride[addrs.NewDefaultProvider("testing")] = func() (providers.Interface, error) {
return stacks_testing_provider.NewProvider(t), nil
}
lock := depsfile.NewLocks()
lock.SetProvider(
addrs.NewDefaultProvider("testing"),
providerreqs.MustParseVersion("0.0.0"),
providerreqs.MustParseVersionConstraints("=0.0.0"),
providerreqs.PreferredHashes([]providerreqs.Hash{}),
)
lockHandle := handles.NewDependencyLocks(lock)
stream, err := stacksClient.MigrateTerraformState(ctx, &stacks.MigrateTerraformState_Request{
StateHandle: resp.StateHandle,
ConfigHandle: openResp.StackConfigHandle,
DependencyLocksHandle: lockHandle.ForProtobuf(),
Mapping: &stacks.MigrateTerraformState_Request_Simple{
Simple: &stacks.MigrateTerraformState_Request_Mapping{
ResourceAddressMap: map[string]string{
"testing_deferred_resource.resource": "self",
},
},
},
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
gotEvents := []*stacks.MigrateTerraformState_Event{}
for {
event, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
gotEvents = append(gotEvents, event)
}
wantChanges := []*stacks.AppliedChange_ChangeDescription{
{
Key: "RSRCcomponent.self,testing_deferred_resource.resource,cur",
Description: &stacks.AppliedChange_ChangeDescription_ResourceInstance{
ResourceInstance: &stacks.AppliedChange_ResourceInstance{
Addr: &stacks.ResourceInstanceObjectInStackAddr{
ComponentInstanceAddr: "component.self",
ResourceInstanceAddr: "testing_deferred_resource.resource",
},
NewValue: &stacks.DynamicValue{
Msgpack: mustMsgpack(t, cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("hello"),
"value": cty.StringVal("world"),
"deferred": cty.False,
}), cty.Object(map[string]cty.Type{"id": cty.String, "value": cty.String, "deferred": cty.Bool})),
},
ResourceMode: stacks.ResourceMode_MANAGED,
ResourceType: "testing_deferred_resource",
ProviderAddr: "registry.terraform.io/hashicorp/testing",
},
},
},
{
Key: "CMPTcomponent.self",
Description: &stacks.AppliedChange_ChangeDescription_ComponentInstance{
ComponentInstance: &stacks.AppliedChange_ComponentInstance{
ComponentAddr: "component.self",
ComponentInstanceAddr: "component.self",
},
},
},
}
if len(gotEvents) != len(wantChanges) {
t.Fatalf("expected %d events, got %d", len(wantChanges), len(gotEvents))
}
gotChanges := make([]*stacks.AppliedChange_ChangeDescription, len(gotEvents))
for i, evt := range gotEvents {
gotChanges[i] = evt.GetAppliedChange().Descriptions[0]
}
if diff := cmp.Diff(wantChanges, gotChanges, protocmp.Transform()); diff != "" {
t.Fatalf("wrong changes\n%s", diff)
}
}
// stackOperationEventStreams represents the three different kinds of events
// whose emission is independent from one another and so the relative ordering
// between them is not guaranteed between runs. For easier comparison in
// tests, use splitStackOperationEvents to obtain a value of this type.
//
// Note that even after splitting the streams will not be directly comparable
// for most non-trivial operations, because a typical configuration only
// forces a partial order of operations. Except in carefully-crafted tests
// that are explicitly testing an explicit ordering, it may be better to
// just scan the entire event stream and cherry-pick particular events of
// interest, which will also avoid the need to update every test whenever we
// add something entirely new to the even stream.
type stackOperationEventStreams struct {
PlannedChanges []*stacks.PlanStackChanges_Event
Diagnostics []*stacks.PlanStackChanges_Event
// MiscHooks is the "everything else" category where the detailed begin/end
// events for individual Terraform Core operations appear.
MiscHooks []*stacks.PlanStackChanges_Event
}
func splitStackOperationEvents(all []*stacks.PlanStackChanges_Event) stackOperationEventStreams {
ret := stackOperationEventStreams{}
for _, evt := range all {
switch evt.Event.(type) {
case *stacks.PlanStackChanges_Event_PlannedChange:
ret.PlannedChanges = append(ret.PlannedChanges, evt)
case *stacks.PlanStackChanges_Event_Diagnostic:
ret.Diagnostics = append(ret.Diagnostics, evt)
default:
ret.MiscHooks = append(ret.MiscHooks, evt)
}
}
return ret
}
func mustMsgpack(t *testing.T, v cty.Value, ty cty.Type) []byte {
t.Helper()
ret, err := ctymsgpack.Marshal(v, ty)
if err != nil {
t.Fatalf("error marshalling %#v: %s", v, err)
}
return ret
}