Skip to content

Commit 7520f46

Browse files
Merge pull request #35367 from hashicorp/TF-10864
stacks: implement PlanTimestamp method on ExpressionScope
2 parents af498dd + ae05a0d commit 7520f46

29 files changed

+1028
-248
lines changed

internal/rpcapi/stacks_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"io"
99
"testing"
10+
"time"
1011

1112
"github.com/google/go-cmp/cmp"
1213
"github.com/hashicorp/go-slug/sourceaddrs"
@@ -290,6 +291,17 @@ func TestStacksPlanStackChanges(t *testing.T) {
290291
},
291292
},
292293
},
294+
{
295+
Event: &terraform1.PlanStackChanges_Event_PlannedChange{
296+
PlannedChange: &terraform1.PlannedChange{
297+
Raw: []*anypb.Any{
298+
mustMarshalAnyPb(&tfstackdata1.PlanTimestamp{
299+
PlanTimestamp: time.Now().UTC().Format(time.RFC3339),
300+
}),
301+
},
302+
},
303+
},
304+
},
293305
{
294306
Event: &terraform1.PlanStackChanges_Event_PlannedChange{
295307
PlannedChange: &terraform1.PlannedChange{

internal/stacks/stackplan/from_proto.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ func LoadFromProto(msgs []*anypb.Any) (*Plan, error) {
5656
case *tfstackdata1.PlanApplyable:
5757
ret.Applyable = msg.Applyable
5858

59+
case *tfstackdata1.PlanTimestamp:
60+
err = ret.PlanTimestamp.UnmarshalText([]byte(msg.PlanTimestamp))
61+
if err != nil {
62+
return nil, fmt.Errorf("invalid plan timestamp %q", msg.PlanTimestamp)
63+
}
64+
5965
case *tfstackdata1.PlanRootInputValue:
6066
addr := stackaddrs.InputVariable{
6167
Name: msg.Name,

internal/stacks/stackplan/plan.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package stackplan
55

66
import (
7+
"time"
8+
79
"github.com/zclconf/go-cty/cty"
810
"google.golang.org/protobuf/types/known/anypb"
911

@@ -50,6 +52,9 @@ type Plan struct {
5052
// instances defined in the overall stack configuration, including any
5153
// nested component instances from embedded stacks.
5254
Components collections.Map[stackaddrs.AbsComponentInstance, *Component]
55+
56+
// PlanTimestamp is the time at which the plan was created.
57+
PlanTimestamp time.Time
5358
}
5459

5560
// RequiredProviderInstances returns a description of all of the provider

internal/stacks/stackplan/planned_change.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,29 @@ func (pc *PlannedChangeHeader) PlannedChangeProto() (*terraform1.PlannedChange,
590590
}, nil
591591
}
592592

593+
// PlannedChangePlannedTimestamp is a special change type we emit to record the timestamp
594+
// of when the plan was generated. This is being used in the plantimestamp function.
595+
type PlannedChangePlannedTimestamp struct {
596+
PlannedTimestamp time.Time
597+
}
598+
599+
var _ PlannedChange = (*PlannedChangePlannedTimestamp)(nil)
600+
601+
// PlannedChangeProto implements PlannedChange.
602+
func (pc *PlannedChangePlannedTimestamp) PlannedChangeProto() (*terraform1.PlannedChange, error) {
603+
var raw anypb.Any
604+
err := anypb.MarshalFrom(&raw, &tfstackdata1.PlanTimestamp{
605+
PlanTimestamp: pc.PlannedTimestamp.Format(time.RFC3339),
606+
}, proto.MarshalOptions{})
607+
if err != nil {
608+
return nil, err
609+
}
610+
611+
return &terraform1.PlannedChange{
612+
Raw: []*anypb.Any{&raw},
613+
}, nil
614+
}
615+
593616
// PlannedChangeApplyable is a special change type we typically append at the
594617
// end of the raw plan stream to represent that the planning process ran to
595618
// completion without encountering any errors, and therefore the plan could

internal/stacks/stackruntime/apply_test.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package stackruntime
55

66
import (
77
"context"
8+
"fmt"
89
"path"
910
"sort"
11+
"strings"
1012
"testing"
1113
"time"
1214

@@ -501,6 +503,241 @@ func TestApplyWithCheckableObjects(t *testing.T) {
501503
}
502504
}
503505

506+
func TestApplyWithForcePlanTimestamp(t *testing.T) {
507+
ctx := context.Background()
508+
cfg := loadMainBundleConfigForTest(t, "with-plantimestamp")
509+
510+
forcedPlanTimestamp := "1991-08-25T20:57:08Z"
511+
fakePlanTimestamp, err := time.Parse(time.RFC3339, forcedPlanTimestamp)
512+
if err != nil {
513+
t.Fatal(err)
514+
}
515+
516+
changesCh := make(chan stackplan.PlannedChange)
517+
diagsCh := make(chan tfdiags.Diagnostic)
518+
req := PlanRequest{
519+
Config: cfg,
520+
ProviderFactories: map[addrs.Provider]providers.Factory{
521+
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
522+
return stacks_testing_provider.NewProvider(), nil
523+
},
524+
},
525+
ForcePlanTimestamp: &fakePlanTimestamp,
526+
}
527+
resp := PlanResponse{
528+
PlannedChanges: changesCh,
529+
Diagnostics: diagsCh,
530+
}
531+
532+
go Plan(ctx, &req, &resp)
533+
planChanges, diags := collectPlanOutput(changesCh, diagsCh)
534+
if len(diags) > 0 {
535+
t.Fatalf("expected no diagnostics, got %s", diags.ErrWithWarnings())
536+
}
537+
// Sanity check that the plan timestamp was set correctly
538+
output := expectOutput(t, "plantimestamp", planChanges)
539+
plantimestampValue, err := output.NewValue.Decode(cty.String)
540+
if err != nil {
541+
t.Fatal(err)
542+
}
543+
544+
if plantimestampValue.AsString() != forcedPlanTimestamp {
545+
t.Errorf("expected plantimestamp to be %q, got %q", forcedPlanTimestamp, plantimestampValue.AsString())
546+
}
547+
548+
var raw []*anypb.Any
549+
for _, change := range planChanges {
550+
proto, err := change.PlannedChangeProto()
551+
if err != nil {
552+
t.Fatal(err)
553+
}
554+
raw = append(raw, proto.Raw...)
555+
}
556+
557+
applyReq := ApplyRequest{
558+
Config: cfg,
559+
RawPlan: raw,
560+
ProviderFactories: map[addrs.Provider]providers.Factory{
561+
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
562+
return stacks_testing_provider.NewProvider(), nil
563+
},
564+
},
565+
}
566+
567+
applyChangesCh := make(chan stackstate.AppliedChange)
568+
diagsCh = make(chan tfdiags.Diagnostic)
569+
570+
applyResp := ApplyResponse{
571+
AppliedChanges: applyChangesCh,
572+
Diagnostics: diagsCh,
573+
}
574+
575+
go Apply(ctx, &applyReq, &applyResp)
576+
applyChanges, applyDiags := collectApplyOutput(applyChangesCh, diagsCh)
577+
if len(applyDiags) > 0 {
578+
t.Fatalf("expected no diagnostics, got %s", applyDiags.ErrWithWarnings())
579+
}
580+
581+
wantChanges := []stackstate.AppliedChange{
582+
&stackstate.AppliedChangeComponentInstance{
583+
ComponentAddr: stackaddrs.AbsComponent{
584+
Item: stackaddrs.Component{
585+
Name: "second-self",
586+
},
587+
},
588+
ComponentInstanceAddr: stackaddrs.AbsComponentInstance{
589+
Item: stackaddrs.ComponentInstance{
590+
Component: stackaddrs.Component{
591+
Name: "second-self",
592+
},
593+
},
594+
},
595+
OutputValues: map[addrs.OutputValue]cty.Value{
596+
// We want to make sure the plantimestamp is set correctly
597+
{Name: "input"}: cty.StringVal(forcedPlanTimestamp),
598+
// plantimestamp should also be set for the module runtime used in the components
599+
{Name: "out"}: cty.StringVal(fmt.Sprintf("module-output-%s", forcedPlanTimestamp)),
600+
},
601+
},
602+
&stackstate.AppliedChangeComponentInstance{
603+
ComponentAddr: stackaddrs.AbsComponent{
604+
Item: stackaddrs.Component{
605+
Name: "self",
606+
},
607+
},
608+
ComponentInstanceAddr: stackaddrs.AbsComponentInstance{
609+
Item: stackaddrs.ComponentInstance{
610+
Component: stackaddrs.Component{
611+
Name: "self",
612+
},
613+
},
614+
},
615+
OutputValues: map[addrs.OutputValue]cty.Value{
616+
// We want to make sure the plantimestamp is set correctly
617+
{Name: "input"}: cty.StringVal(forcedPlanTimestamp),
618+
// plantimestamp should also be set for the module runtime used in the components
619+
{Name: "out"}: cty.StringVal(fmt.Sprintf("module-output-%s", forcedPlanTimestamp)),
620+
},
621+
},
622+
}
623+
624+
sort.SliceStable(applyChanges, func(i, j int) bool {
625+
return appliedChangeSortKey(applyChanges[i]) < appliedChangeSortKey(applyChanges[j])
626+
})
627+
628+
if diff := cmp.Diff(wantChanges, applyChanges, ctydebug.CmpOptions, cmpCollectionsSet); diff != "" {
629+
t.Errorf("wrong changes\n%s", diff)
630+
}
631+
}
632+
633+
func TestApplyWithDefaultPlanTimestamp(t *testing.T) {
634+
ctx := context.Background()
635+
cfg := loadMainBundleConfigForTest(t, "with-plantimestamp")
636+
637+
dayOfWritingThisTest := "2024-06-21T06:37:08Z"
638+
dayOfWritingThisTestTime, err := time.Parse(time.RFC3339, dayOfWritingThisTest)
639+
if err != nil {
640+
t.Fatal(err)
641+
}
642+
643+
changesCh := make(chan stackplan.PlannedChange)
644+
diagsCh := make(chan tfdiags.Diagnostic)
645+
req := PlanRequest{
646+
Config: cfg,
647+
ProviderFactories: map[addrs.Provider]providers.Factory{
648+
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
649+
return stacks_testing_provider.NewProvider(), nil
650+
},
651+
},
652+
}
653+
resp := PlanResponse{
654+
PlannedChanges: changesCh,
655+
Diagnostics: diagsCh,
656+
}
657+
658+
go Plan(ctx, &req, &resp)
659+
planChanges, diags := collectPlanOutput(changesCh, diagsCh)
660+
if len(diags) > 0 {
661+
t.Fatalf("expected no diagnostics, got %s", diags.ErrWithWarnings())
662+
}
663+
// Sanity check that the plan timestamp was set correctly
664+
output := expectOutput(t, "plantimestamp", planChanges)
665+
plantimestampValue, err := output.NewValue.Decode(cty.String)
666+
if err != nil {
667+
t.Fatal(err)
668+
}
669+
670+
plantimestamp, err := time.Parse(time.RFC3339, plantimestampValue.AsString())
671+
if err != nil {
672+
t.Fatal(err)
673+
}
674+
675+
if plantimestamp.Before(dayOfWritingThisTestTime) {
676+
t.Errorf("expected plantimestamp to be later than %q, got %q", dayOfWritingThisTest, plantimestampValue.AsString())
677+
}
678+
679+
var raw []*anypb.Any
680+
for _, change := range planChanges {
681+
proto, err := change.PlannedChangeProto()
682+
if err != nil {
683+
t.Fatal(err)
684+
}
685+
raw = append(raw, proto.Raw...)
686+
}
687+
688+
applyReq := ApplyRequest{
689+
Config: cfg,
690+
RawPlan: raw,
691+
ProviderFactories: map[addrs.Provider]providers.Factory{
692+
addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) {
693+
return stacks_testing_provider.NewProvider(), nil
694+
},
695+
},
696+
}
697+
698+
applyChangesCh := make(chan stackstate.AppliedChange)
699+
diagsCh = make(chan tfdiags.Diagnostic)
700+
701+
applyResp := ApplyResponse{
702+
AppliedChanges: applyChangesCh,
703+
Diagnostics: diagsCh,
704+
}
705+
706+
go Apply(ctx, &applyReq, &applyResp)
707+
applyChanges, applyDiags := collectApplyOutput(applyChangesCh, diagsCh)
708+
if len(applyDiags) > 0 {
709+
t.Fatalf("expected no diagnostics, got %s", applyDiags.ErrWithWarnings())
710+
}
711+
712+
for _, x := range applyChanges {
713+
if v, ok := x.(*stackstate.AppliedChangeComponentInstance); ok {
714+
if actualTimestampValue, ok := v.OutputValues[addrs.OutputValue{
715+
Name: "input",
716+
}]; ok {
717+
actualTimestamp, err := time.Parse(time.RFC3339, actualTimestampValue.AsString())
718+
if err != nil {
719+
t.Fatalf("Could not parse component output value: %q", err)
720+
}
721+
if actualTimestamp.Before(dayOfWritingThisTestTime) {
722+
t.Error("Timestamp is before day of writing this test, that should be incorrect.")
723+
}
724+
}
725+
726+
if actualTimestampValue, ok := v.OutputValues[addrs.OutputValue{
727+
Name: "out",
728+
}]; ok {
729+
actualTimestamp, err := time.Parse(time.RFC3339, strings.ReplaceAll(actualTimestampValue.AsString(), "module-output-", ""))
730+
if err != nil {
731+
t.Fatalf("Could not parse component output value: %q", err)
732+
}
733+
if actualTimestamp.Before(dayOfWritingThisTestTime) {
734+
t.Error("Timestamp is before day of writing this test, that should be incorrect.")
735+
}
736+
}
737+
}
738+
}
739+
}
740+
504741
func collectApplyOutput(changesCh <-chan stackstate.AppliedChange, diagsCh <-chan tfdiags.Diagnostic) ([]stackstate.AppliedChange, tfdiags.Diagnostics) {
505742
var changes []stackstate.AppliedChange
506743
var diags tfdiags.Diagnostics

internal/stacks/stackruntime/helper_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string {
202202
// There should only be a single applyable marker in a plan, so we can
203203
// just return a static string here.
204204
return "applyable"
205+
case *stackplan.PlannedChangePlannedTimestamp:
206+
// There should only be a single timestamp in a plan, so we can
207+
// just return a static string here.
208+
return "planned-timestamp"
205209
default:
206210
// This is only going to happen during tests, so we can panic here.
207211
panic(fmt.Errorf("unrecognized planned change type: %T", change))

internal/stacks/stackruntime/internal/stackeval/applying_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"sync"
1212
"testing"
13+
"time"
1314

1415
"github.com/davecgh/go-spew/spew"
1516
"github.com/google/go-cmp/cmp"
@@ -128,6 +129,7 @@ func TestApply_componentOrdering(t *testing.T) {
128129
}, nil
129130
},
130131
},
132+
PlanTimestamp: time.Now().UTC(),
131133
})
132134

133135
outp, outpTester := testPlanOutput(t)
@@ -287,6 +289,7 @@ func TestApply_componentOrdering(t *testing.T) {
287289
}, nil
288290
},
289291
},
292+
PlanTimestamp: time.Now().UTC(),
290293
})
291294

292295
outp, outpTester := testPlanOutput(t)

internal/stacks/stackruntime/internal/stackeval/component_config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package stackeval
66
import (
77
"context"
88
"fmt"
9+
"time"
910

1011
"github.com/apparentlymart/go-versions/versions"
1112
"github.com/hashicorp/go-slug/sourceaddrs"
@@ -449,6 +450,12 @@ func (c *ComponentConfig) ResolveExpressionReference(ctx context.Context, ref st
449450
return c.StackConfig(ctx).resolveExpressionReference(ctx, ref, nil, repetition)
450451
}
451452

453+
// PlanTimestamp implements ExpressionScope, providing the timestamp at which
454+
// the current plan is being run.
455+
func (c *ComponentConfig) PlanTimestamp() time.Time {
456+
return c.main.PlanTimestamp()
457+
}
458+
452459
func (c *ComponentConfig) checkValid(ctx context.Context, phase EvalPhase) tfdiags.Diagnostics {
453460
diags, err := c.validate.Do(ctx, func(ctx context.Context) (tfdiags.Diagnostics, error) {
454461
var diags tfdiags.Diagnostics

0 commit comments

Comments
 (0)