Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
341 changes: 186 additions & 155 deletions tests/blackbox/executor.go

Large diffs are not rendered by default.

96 changes: 65 additions & 31 deletions tests/blackbox/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,28 +359,79 @@ func (h *TestHarness) latestSyncCommand() (*apimodel.Command, error) {
return &commands[0], nil
}

// WaitForNextSyncCommand waits for a new sync command created after the current
// latest sync command, then waits for that command to reach a terminal state.
// Returns nil,false if no new sync command appears within the timeout.
func (h *TestHarness) WaitForNextSyncCommand(timeout time.Duration) (*apimodel.Command, bool) {
// SyncCommandBaseline returns the ID of the most recent sync command, or ""
// when none exists. Capture it BEFORE triggering a sync: the triggered sync
// command can be persisted before the first post-trigger poll runs, and a
// baseline taken after the trigger would then absorb the very command being
// waited for.
func (h *TestHarness) SyncCommandBaseline() string {
before, err := h.latestSyncCommand()
if err != nil {
return nil, false
if err != nil || before == nil {
return ""
}
beforeID := ""
if before != nil {
beforeID = before.CommandID
return before.CommandID
}

// WaitForSyncCommandAfter waits up to `appearance` for a sync command
// different from the given baseline ID to be persisted, then up to
// `completion` for that command to finish. Returns nil,false if no new sync
// command appears within the appearance window.
//
// A sync command whose reads produce no changes is DELETED from the datastore
// on completion (the persister keeps only syncs that absorbed something), so
// a command that appears and then vanishes has completed as a no-change sync:
// that is reported as observed with a nil command. A no-change sync can also
// complete and be deleted entirely between polls, in which case it is
// indistinguishable from no sync at all — callers must only rely on a false
// return where a no-op sync and no sync are equivalent.
func (h *TestHarness) WaitForSyncCommandAfter(baselineID string, appearance, completion time.Duration) (*apimodel.Command, bool) {
appearDeadline := time.Now().Add(appearance)
for time.Now().Before(appearDeadline) {
current, err := h.latestSyncCommand()
if err == nil && current != nil && current.CommandID != "" && current.CommandID != baselineID {
return h.waitForSyncCommandDone(current.CommandID, completion)
}
time.Sleep(50 * time.Millisecond)
}
return nil, false
}

// waitForSyncCommandDone polls a known sync command until it reaches a
// terminal state or is deleted as a no-change sync (both count as observed).
func (h *TestHarness) waitForSyncCommandDone(commandID string, timeout time.Duration) (*apimodel.Command, bool) {
deadline := time.Now().Add(timeout)
seen := false
for time.Now().Before(deadline) {
current, err := h.latestSyncCommand()
if err == nil && current != nil && current.CommandID != "" && current.CommandID != beforeID {
return h.waitForCommandInDB(current.CommandID, time.Until(deadline))
cmd, err := h.commandFromDB(commandID)
if err == nil {
if cmd == nil {
if seen {
return nil, true // deleted on completion: a no-change sync
}
} else {
seen = true
h.ObserveCommandState(h.t, cmd.CommandID, cmd.State)
if isTerminalCommandState(cmd.State) {
return cmd, true
}
}
}
time.Sleep(50 * time.Millisecond)
}
return nil, false
// The row was observed at least once (latestSyncCommand returned it), so
// its disappearance without a terminal read still means it completed.
return nil, !seen
}

// WaitForNextSyncCommand waits for a new sync command created after the current
// latest sync command, then waits for that command to reach a terminal state.
// Returns nil,false if no new sync command appears within the timeout.
//
// The baseline is taken at call time, so this only observes syncs triggered
// AFTER the call starts; when waiting on a sync you already triggered, use
// SyncCommandBaseline + WaitForSyncCommandAfter around the trigger instead.
func (h *TestHarness) WaitForNextSyncCommand(timeout time.Duration) (*apimodel.Command, bool) {
return h.WaitForSyncCommandAfter(h.SyncCommandBaseline(), timeout, timeout)
}

// --- TestController communication (via Ergo cross-node calls) ---
Expand Down Expand Up @@ -614,7 +665,7 @@ func (h *TestHarness) KillAgent(t *testing.T) {
// RestartAgent starts a new agent subprocess with the same config (same SQLite DB).
// Reconstructs the plugin's cloud state from the agent's inventory and the OOB
// mirror, re-programs response sequences, then opens the gate.
func (h *TestHarness) RestartAgent(t *testing.T, timeout time.Duration, model ...*StateModel) {
func (h *TestHarness) RestartAgent(t *testing.T, timeout time.Duration) {
t.Helper()

// Start the agent but DON'T open the gate yet — ReRunIncompleteCommands
Expand All @@ -632,23 +683,12 @@ func (h *TestHarness) RestartAgent(t *testing.T, timeout time.Duration, model ..
// resolvables before re-injection, otherwise the plugin returns $res objects
// on Read, which corrupts the $ref.$value in mergeRefsPreservingUserRefs.
forma, err := h.client.ExtractResources("managed:true")
pendingManagedProps := map[string]string{}
pendingManagedDeletes := map[string]bool{}
if len(model) > 0 && model[0] != nil {
pendingManagedProps, pendingManagedDeletes = model[0].PendingManagedDriftCloudState()
}
if err == nil && forma != nil {
for _, res := range forma.Resources {
if res.NativeID == "" {
continue
}
if pendingManagedDeletes[res.NativeID] {
continue
}
flatProps := flattenPropertiesForCloud(res.Properties)
if driftProps, ok := pendingManagedProps[res.NativeID]; ok {
flatProps = driftProps
}
_, err := h.callTestController(testcontrol.PutCloudStateRequest{
NativeID: res.NativeID,
ResourceType: res.Type,
Expand All @@ -662,12 +702,6 @@ func (h *TestHarness) RestartAgent(t *testing.T, timeout time.Duration, model ..

// Re-inject OOB cloud state from the mirror (resources not in inventory).
for _, entry := range h.cloudStateMirror {
if _, ok := pendingManagedProps[entry.NativeID]; ok {
continue
}
if pendingManagedDeletes[entry.NativeID] {
continue
}
_, err := h.callTestController(testcontrol.PutCloudStateRequest{
NativeID: entry.NativeID,
ResourceType: entry.ResourceType,
Expand Down
75 changes: 2 additions & 73 deletions tests/blackbox/invariants.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type CommandState struct {
// 1. No phantom resources: every resource in inventory exists in cloud state
// 2. No orphaned cloud resources: every cloud resource is tracked in inventory
// 3. Property consistency: inventory properties match cloud state properties
func CheckInvariants(inventory []pkgmodel.Resource, cloudState map[string]testcontrol.CloudStateEntry, ignoreNativeIDs map[string]bool, ignoreManagedDriftNativeIDs map[string]bool) []Violation {
func CheckInvariants(inventory []pkgmodel.Resource, cloudState map[string]testcontrol.CloudStateEntry, ignoreNativeIDs map[string]bool) []Violation {
var violations []Violation

// Build lookup of inventory resources by NativeID.
Expand Down Expand Up @@ -87,9 +87,6 @@ func CheckInvariants(inventory []pkgmodel.Resource, cloudState map[string]testco
if res.NativeID == "" {
continue
}
if ignoreManagedDriftNativeIDs[res.NativeID] {
continue
}
if _, ok := cloudState[res.NativeID]; !ok {
violations = append(violations, Violation{
Kind: ViolationPhantomResource,
Expand All @@ -116,9 +113,6 @@ func CheckInvariants(inventory []pkgmodel.Resource, cloudState map[string]testco
// Invariant 3: Property consistency
// For resources present in both, properties should match
for nativeID, cloudEntry := range cloudState {
if ignoreManagedDriftNativeIDs[nativeID] {
continue
}
invRes, ok := inventoryByNativeID[nativeID]
if !ok {
continue // already reported as orphaned or ignored
Expand Down Expand Up @@ -518,9 +512,6 @@ func CheckModelVsInventory(model *StateModel, inventory []pkgmodel.Resource) []V
}
label := model.LabelForResource(s, idx)
resourceType := model.TypeForResource(idx)
if model.HasPendingManagedDriftAffectingSlot(s, idx) {
continue
}

key := stack.Label + "/" + label
invRes, existsInInventory := inventoryByKey[key]
Expand Down Expand Up @@ -581,10 +572,7 @@ func CheckModelVsInventory(model *StateModel, inventory []pkgmodel.Resource) []V
if expectedExistingKeys[key] {
continue
}
stackIdx, slotIdx, ok := model.findResourceSlot(res.Stack, res.Label)
if ok && model.HasPendingManagedDriftAffectingSlot(stackIdx, slotIdx) {
continue
}
_, slotIdx, ok := model.findResourceSlot(res.Stack, res.Label)
if ok && model.Pool != nil && model.Pool.IsCrossStack(slotIdx) {
continue
}
Expand Down Expand Up @@ -715,65 +703,6 @@ func CheckUnmanagedModelVsInventory(model *StateModel, inventory []pkgmodel.Reso
return violations
}

func CheckManagedDriftVsInventory(model *StateModel, inventory []pkgmodel.Resource) []Violation {
var violations []Violation
actualByNativeID := make(map[string]pkgmodel.Resource, len(inventory))
for _, res := range inventory {
if res.NativeID == "" {
continue
}
actualByNativeID[res.NativeID] = res
}

for nativeID, expected := range model.ManagedDriftedResources {
if expected.PendingSync {
continue
}
actual, ok := actualByNativeID[nativeID]
if !ok {
if expected.PresentInInventory {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s expected in inventory but missing", nativeID),
})
}
continue
}
if !actual.Managed {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s unexpectedly marked unmanaged in inventory", nativeID),
})
}
if expected.StackLabel != "" && actual.Stack != expected.StackLabel {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s stack=%s, expected %s", nativeID, actual.Stack, expected.StackLabel),
})
}
if expected.ResourceLabel != "" && actual.Label != expected.ResourceLabel {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s label=%s, expected %s", nativeID, actual.Label, expected.ResourceLabel),
})
}
if expected.ResourceType != "" && actual.Type != expected.ResourceType {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s type=%s, expected %s", nativeID, actual.Type, expected.ResourceType),
})
}
if expected.InventoryProperties != "" && !jsonEqual(string(actual.Properties), expected.InventoryProperties) {
violations = append(violations, Violation{
Kind: ViolationModelInventoryMismatch,
Message: fmt.Sprintf("managed drift resource %s inventory properties=%s but model expects %s", nativeID, string(actual.Properties), expected.InventoryProperties),
})
}
}

return violations
}

// CheckOperationLogInvariants validates stable plugin-side facts. These are
// intentionally conservative so they hold across crash recovery and restart
// paths where end-state existence cannot be inferred from the operation log
Expand Down
4 changes: 2 additions & 2 deletions tests/blackbox/invariants_rename_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestCheckInvariants_FlagsDuplicateNativeID(t *testing.T) {
{Ksuid: "K_B", NativeID: "i-0abc1234", Label: "new-vpc", Type: "AWS::EC2::Instance"},
}

violations := CheckInvariants(inventory, cloudState, nil, nil)
violations := CheckInvariants(inventory, cloudState, nil)

var foundDup bool
for _, v := range violations {
Expand All @@ -53,7 +53,7 @@ func TestCheckInvariants_SingleRowPerNativeID_OK(t *testing.T) {
{Ksuid: "K_A", NativeID: "i-0abc1234", Label: "app-server", Type: "AWS::EC2::Instance"},
}

violations := CheckInvariants(inventory, cloudState, nil, nil)
violations := CheckInvariants(inventory, cloudState, nil)

for _, v := range violations {
if v.Kind == ViolationDuplicateNativeID {
Expand Down
2 changes: 1 addition & 1 deletion tests/blackbox/property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func TestProperty_FullChaos(t *testing.T) {
// Sync cloud state with inventory. Chaos operations (cancel, crash,
// TTL cascade) can leave cloud entries that the ResourcePersister
// hasn't cleaned up yet, causing phantom violations.
h.TriggerSyncAndWait(t)
h.TriggerSyncAndWait(t, model)

h.AssertAllInvariants(t, model)
})
Expand Down
Loading
Loading