Skip to content

Commit fd31710

Browse files
authored
fix(discovery): prevent resources from becoming unmanaged after sync
1 parent df2c68f commit fd31710

File tree

5 files changed

+263
-6
lines changed

5 files changed

+263
-6
lines changed

internal/metastructure/discovery/discovery.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -485,17 +485,20 @@ func synchronizeResources(op ListOperation, namespace string, target pkgmodel.Ta
485485

486486
resourceFilters := (*plugin).GetResourceFilters()
487487

488-
var resourcesToSynchronize []pkgmodel.Resource
488+
var nativeIDs []string
489489
for _, resource := range resources {
490-
// We initially set the label to the native ID. Since not every List API reliably returns tags we need to overwrite the label
491-
// in the resource_updater where we sync (read) the resource.
490+
nativeIDs = append(nativeIDs, resource.NativeID)
491+
}
492+
493+
var resourcesToSynchronize []pkgmodel.Resource
494+
for _, nativeID := range nativeIDs {
492495
resourcesToSynchronize = append(resourcesToSynchronize, pkgmodel.Resource{
493-
Label: url.QueryEscape(resource.NativeID),
496+
Label: url.QueryEscape(nativeID),
494497
Type: op.ResourceType,
495498
Stack: constants.UnmanagedStack,
496499
Target: target.Label,
497-
NativeID: resource.NativeID,
498-
Properties: injectResolvables(resource.Properties, op),
500+
NativeID: nativeID,
501+
Properties: injectResolvables("{}", op),
499502
Schema: schema,
500503
Managed: false,
501504
Ksuid: util.NewID(),

internal/metastructure/resource_persister/resource_persister.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,13 @@ func (rp *ResourcePersister) processResourceUpdate(commandID string, stack pkgmo
329329
if !util.JsonEqualRaw(currentResource.Properties, rc.Resource.Properties) ||
330330
!util.JsonEqualRaw(currentResource.ReadOnlyProperties, rc.Resource.ReadOnlyProperties) {
331331

332+
// Preserve the current stack and managed state during sync READ operations
333+
// to prevent stale sync data from overwriting recent stack changes
334+
secretSafeResource.Stack = currentResource.Stack
335+
secretSafeResource.Managed = currentResource.Managed
336+
secretSafeResource.Schema.Discoverable = currentResource.Schema.Discoverable
337+
secretSafeResource.Schema.Extractable = currentResource.Schema.Extractable
338+
332339
currentResource.Properties = secretSafeResource.Properties
333340
currentResource.ReadOnlyProperties = secretSafeResource.ReadOnlyProperties
334341

internal/metastructure/resource_persister/resource_persister_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,96 @@ func TestResourcePersister_ValidateFields(t *testing.T) {
982982
})
983983
}
984984

985+
func TestResourcePersister_ReadPreservesCurrentStack(t *testing.T) {
986+
persister, sender, ds, err := newResourcePersisterForTest(t)
987+
assert.NoError(t, err)
988+
989+
resourceKsuid := util.NewID()
990+
991+
initialResource := resource_update.ResourceUpdate{
992+
Resource: pkgmodel.Resource{
993+
Label: "test-vpc",
994+
Type: "AWS::EC2::VPC",
995+
Properties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16"}`),
996+
Stack: "managed-stack",
997+
Ksuid: resourceKsuid,
998+
Managed: true,
999+
},
1000+
ResourceTarget: pkgmodel.Target{
1001+
Label: "test-target",
1002+
Namespace: "aws",
1003+
},
1004+
State: resource_update.ResourceUpdateStateSuccess,
1005+
StackLabel: "managed-stack",
1006+
ProgressResult: []resource.ProgressResult{
1007+
{
1008+
Operation: resource.OperationCreate,
1009+
OperationStatus: resource.OperationStatusSuccess,
1010+
NativeID: "vpc-123",
1011+
ResourceType: "AWS::EC2::VPC",
1012+
ResourceProperties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16"}`),
1013+
StartTs: util.TimeNow(),
1014+
ModifiedTs: util.TimeNow(),
1015+
},
1016+
},
1017+
}
1018+
1019+
createResult := persister.Call(sender, resource_update.PersistResourceUpdate{
1020+
CommandID: "create-cmd",
1021+
ResourceOperation: resource_update.OperationCreate,
1022+
PluginOperation: resource.OperationCreate,
1023+
ResourceUpdate: initialResource,
1024+
})
1025+
assert.NoError(t, createResult.Error)
1026+
1027+
syncUpdate := resource_update.ResourceUpdate{
1028+
Resource: pkgmodel.Resource{
1029+
Label: "test-vpc",
1030+
Type: "AWS::EC2::VPC",
1031+
Properties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16","VpcId":"vpc-123"}`),
1032+
Stack: "$unmanaged",
1033+
Ksuid: resourceKsuid,
1034+
Managed: false,
1035+
},
1036+
ResourceTarget: pkgmodel.Target{
1037+
Label: "test-target",
1038+
Namespace: "aws",
1039+
},
1040+
State: resource_update.ResourceUpdateStateSuccess,
1041+
StackLabel: "$unmanaged",
1042+
ProgressResult: []resource.ProgressResult{
1043+
{
1044+
Operation: resource.OperationRead,
1045+
OperationStatus: resource.OperationStatusSuccess,
1046+
NativeID: "vpc-123",
1047+
ResourceType: "AWS::EC2::VPC",
1048+
ResourceProperties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16","VpcId":"vpc-123"}`),
1049+
StartTs: util.TimeNow(),
1050+
ModifiedTs: util.TimeNow(),
1051+
},
1052+
},
1053+
}
1054+
1055+
readResult := persister.Call(sender, resource_update.PersistResourceUpdate{
1056+
CommandID: "sync-cmd",
1057+
ResourceOperation: resource_update.OperationRead,
1058+
PluginOperation: resource.OperationRead,
1059+
ResourceUpdate: syncUpdate,
1060+
})
1061+
assert.NoError(t, readResult.Error)
1062+
1063+
loadedStack, err := ds.LoadStack("managed-stack")
1064+
assert.NoError(t, err)
1065+
assert.NotNil(t, loadedStack, "Resource should remain in managed-stack, not move to $unmanaged")
1066+
assert.Equal(t, 1, len(loadedStack.Resources))
1067+
assert.Equal(t, "test-vpc", loadedStack.Resources[0].Label)
1068+
assert.True(t, loadedStack.Resources[0].Managed, "Resource should remain managed")
1069+
1070+
unmanagedStack, err := ds.LoadStack("$unmanaged")
1071+
assert.NoError(t, err)
1072+
assert.Nil(t, unmanagedStack, "Resource should not appear in $unmanaged stack")
1073+
}
1074+
9851075
// newResourcePersisterForTest creates a ResourcePersister actor for testing.
9861076
// This follows the same pattern as FormaCommandPersister tests.
9871077
func newResourcePersisterForTest(t *testing.T) (*unit.TestActor, gen.PID, datastore.Datastore, error) {

internal/workflow_tests/discovery_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,3 +782,62 @@ func TestDiscovery_NoDiscoverableTargets_CompletesImmediately(t *testing.T) {
782782
assert.Nil(t, stack, "No unmanaged stack should be created when no discoverable targets exist")
783783
})
784784
}
785+
786+
func TestDiscovery_ListPropertiesNotPersistedOnlyReadProperties(t *testing.T) {
787+
testutil.RunTestFromProjectRoot(t, func(t *testing.T) {
788+
overrides := &plugin.ResourcePluginOverrides{
789+
List: func(req *resource.ListRequest) (*resource.ListResult, error) {
790+
if req.ResourceType != "FakeAWS::S3::Bucket" {
791+
return &resource.ListResult{}, nil
792+
}
793+
return &resource.ListResult{
794+
Resources: []resource.Resource{
795+
{
796+
NativeID: "test-bucket",
797+
Properties: `{"ListOnlyProp": "should-not-persist", "BucketName": "list-value"}`,
798+
},
799+
},
800+
}, nil
801+
},
802+
Read: func(req *resource.ReadRequest) (*resource.ReadResult, error) {
803+
return &resource.ReadResult{
804+
ResourceType: "FakeAWS::S3::Bucket",
805+
Properties: `{"BucketName": "read-value", "ReadOnlyProp": "from-read"}`,
806+
}, nil
807+
},
808+
}
809+
810+
cfg := test_helpers.NewTestMetastructureConfig()
811+
m, def, err := test_helpers.NewTestMetastructureWithConfig(t, overrides, cfg)
812+
defer def()
813+
require.NoError(t, err)
814+
815+
target := &pkgmodel.Target{
816+
Label: "us-east-1",
817+
Namespace: "FakeAWS",
818+
Config: json.RawMessage(`{"region":"us-east-1"}`),
819+
Discoverable: true,
820+
}
821+
_, err = m.Datastore.CreateTarget(target)
822+
require.NoError(t, err)
823+
824+
_, err = testutil.StartTestHelperActor(m.Node, make(chan any, 1))
825+
require.NoError(t, err)
826+
827+
err = testutil.Send(m.Node, "Discovery", discovery.Discover{})
828+
require.NoError(t, err)
829+
830+
var stack *pkgmodel.Forma
831+
require.Eventually(t, func() bool {
832+
stack, err = m.Datastore.LoadStack("$unmanaged")
833+
return err == nil && stack != nil && len(stack.Resources) == 1
834+
}, 5*time.Second, 100*time.Millisecond)
835+
836+
res := stack.Resources[0]
837+
var props map[string]any
838+
require.NoError(t, json.Unmarshal(res.Properties, &props))
839+
840+
assert.Equal(t, "read-value", props["BucketName"], "BucketName should come from READ, not LIST")
841+
assert.NotContains(t, props, "ListOnlyProp", "LIST-only properties should not be persisted")
842+
})
843+
}

internal/workflow_tests/synchronizer_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,3 +804,101 @@ func TestSynchronizer_ExcludesResourcesBeingUpdatedByApply(t *testing.T) {
804804
}
805805
})
806806
}
807+
808+
// TestSynchronizer_SyncDoesNotOverwriteApplyStackChange verifies that sync preserves
809+
// user-controlled state (Stack, Managed, Schema.Discoverable, Schema.Extractable) when
810+
// a race occurs between apply and sync operations.
811+
func TestSynchronizer_SyncDoesNotOverwriteApplyStackChange(t *testing.T) {
812+
testutil.RunTestFromProjectRoot(t, func(t *testing.T) {
813+
readStarted := make(chan struct{})
814+
readCanComplete := make(chan struct{})
815+
readCount := 0
816+
var mu sync.Mutex
817+
818+
nativeID := "vpc-" + uuid.New().String()
819+
820+
overrides := &plugin.ResourcePluginOverrides{
821+
Read: func(request *resource.ReadRequest) (*resource.ReadResult, error) {
822+
mu.Lock()
823+
readCount++
824+
currentRead := readCount
825+
mu.Unlock()
826+
827+
if currentRead == 1 {
828+
select {
829+
case readStarted <- struct{}{}:
830+
default:
831+
}
832+
<-readCanComplete
833+
}
834+
835+
return &resource.ReadResult{
836+
ResourceType: request.ResourceType,
837+
Properties: `{"CidrBlock":"10.0.0.0/16","VpcId":"` + nativeID + `"}`,
838+
}, nil
839+
},
840+
}
841+
842+
cfg := test_helpers.NewTestMetastructureConfig()
843+
cfg.Agent.Synchronization.Enabled = false
844+
m, def, err := test_helpers.NewTestMetastructureWithConfig(t, overrides, cfg)
845+
defer def()
846+
require.NoError(t, err)
847+
848+
unmanagedResource := &pkgmodel.Resource{
849+
Ksuid: util.NewID(),
850+
NativeID: nativeID,
851+
Stack: "$unmanaged",
852+
Type: "FakeAWS::EC2::VPC",
853+
Label: nativeID,
854+
Target: "test-target",
855+
Properties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16"}`),
856+
Schema: pkgmodel.Schema{Discoverable: false, Extractable: false},
857+
Managed: false,
858+
}
859+
_, err = m.Datastore.StoreResource(unmanagedResource, "discovery-cmd")
860+
require.NoError(t, err)
861+
862+
_, err = m.Datastore.CreateTarget(&pkgmodel.Target{Label: "test-target", Discoverable: true})
863+
require.NoError(t, err)
864+
865+
go func() { _ = m.ForceSync() }()
866+
<-readStarted
867+
868+
managedStack := "my-managed-stack"
869+
f := &pkgmodel.Forma{
870+
Stacks: []pkgmodel.Stack{{Label: managedStack}},
871+
Resources: []pkgmodel.Resource{{
872+
Ksuid: unmanagedResource.Ksuid,
873+
Label: nativeID,
874+
Type: "FakeAWS::EC2::VPC",
875+
NativeID: nativeID,
876+
Properties: json.RawMessage(`{"CidrBlock":"10.0.0.0/16"}`),
877+
Stack: managedStack,
878+
Schema: pkgmodel.Schema{Fields: []string{"CidrBlock"}, Discoverable: true, Extractable: true},
879+
Target: "test-target",
880+
Managed: true,
881+
}},
882+
Targets: []pkgmodel.Target{},
883+
}
884+
_, err = m.ApplyForma(f, &config.FormaCommandConfig{Mode: pkgmodel.FormaApplyModeReconcile}, "test")
885+
require.NoError(t, err)
886+
887+
require.Eventually(t, func() bool {
888+
res, err := m.Datastore.LoadResourceByNativeID(nativeID, "FakeAWS::EC2::VPC")
889+
return err == nil && res != nil && res.Stack == managedStack && res.Managed
890+
}, 5*time.Second, 100*time.Millisecond)
891+
892+
// Unblock sync's READ
893+
close(readCanComplete)
894+
time.Sleep(2 * time.Second)
895+
896+
res, err := m.Datastore.LoadResourceByNativeID(nativeID, "FakeAWS::EC2::VPC")
897+
require.NoError(t, err)
898+
require.NotNil(t, res)
899+
assert.Equal(t, managedStack, res.Stack, "Stack should be preserved")
900+
assert.True(t, res.Managed, "Managed should be preserved")
901+
assert.True(t, res.Schema.Discoverable, "Schema.Discoverable should be preserved")
902+
assert.True(t, res.Schema.Extractable, "Schema.Extractable should be preserved")
903+
})
904+
}

0 commit comments

Comments
 (0)