Skip to content
Open
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
154 changes: 154 additions & 0 deletions internal/isolation/cleanup/execute.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2026 CtrlBoard.dev
// SPDX-License-Identifier: Apache-2.0

package cleanup

import (
"context"
"errors"
"fmt"
"time"

"github.com/thelostorbital/ctrldb/internal/isolation"
"github.com/thelostorbital/ctrldb/internal/redact"
)

var ErrWipeExecution = errors.New("test wipe execution failed")

// Journal is the minimal durable-record surface the wipe needs. Every method
// is create-only; the M1-05 control store adapter implements it without
// exposing deletion. Until that adapter lands, tests use an in-memory value.
type Journal interface {
RecordPlan(ctx context.Context, record WipeRecordV1) error
RecordDeletion(ctx context.Context, record DeletionRecordV1) error
}

// Deleter executes one sealed deletion. The provider adapter must refuse an
// unsealed deletion and must not expose any other delete surface.
type Deleter interface {
Delete(ctx context.Context, deletion Deletion) error
}

// DeletionOutcome is the typed, redacted result of one attempted deletion.
type DeletionOutcome struct {
Sequence int
Capability isolation.CleanupCapability
Identity isolation.ResourceIdentity
Attempt uint32
Status DeletionStatus
Failure redact.Text
}

// WipeResult is the typed, redacted outcome of one execution. Counts and
// identities are safe to log; failures are sanitized at construction.
type WipeResult struct {
Mode string
ProjectID string
RunNumber uint64
Disposition Disposition
InventoryRevision string
RecordIntegrity string
Planned int
Deleted int
Failed int
Retained int
Deferred int
Protected int
StartedAt time.Time
EndedAt time.Time
Deletions []DeletionOutcome
}

// Execute records the plan, then issues each sealed deletion in order,
// recording it before and after the provider call. It stops at the first
// failure so the next run converges from freshly observed state.
func Execute(ctx context.Context, plan WipePlan, journal Journal, deleter Deleter, clock func() time.Time) (WipeResult, error) {
if ctx == nil || journal == nil || deleter == nil || clock == nil {
return WipeResult{}, inputError("execution", "requires context, journal, deleter, and clock")
}
record, err := plan.Record()
if err != nil {
return WipeResult{}, err
}
startedAt := clock().UTC()
if err := validateNow(startedAt); err != nil {
return WipeResult{}, err
}
result := WipeResult{
Mode: plan.Mode, ProjectID: plan.ProjectID, RunNumber: plan.RunNumber, Disposition: plan.Disposition,
InventoryRevision: plan.Selection.InventoryRevision, RecordIntegrity: record.IntegritySHA256,
Planned: len(plan.Selection.Deletions), Retained: len(plan.Selection.Retained),
Deferred: len(plan.Selection.Deferred), Protected: len(plan.Selection.Protected), StartedAt: startedAt,
}
if err := journal.RecordPlan(ctx, record); err != nil {
result.EndedAt = clock().UTC()
return result, fmt.Errorf("%w: plan record was not persisted", ErrWipeExecution)
}
if plan.Disposition != DispositionDelete {
result.EndedAt = clock().UTC()
return result, nil
}
for _, deletion := range plan.Selection.Deletions {
outcome, err := executeDeletion(ctx, record.IntegritySHA256, deletion, journal, deleter, clock)
result.Deletions = append(result.Deletions, outcome)
if err != nil {
result.Failed++
result.EndedAt = clock().UTC()
return result, err
}
result.Deleted++
}
result.EndedAt = clock().UTC()
return result, nil
}

func executeDeletion(ctx context.Context, planIntegrity string, deletion Deletion, journal Journal, deleter Deleter, clock func() time.Time) (DeletionOutcome, error) {
outcome := DeletionOutcome{
Sequence: deletion.Sequence, Capability: deletion.Capability, Identity: deletion.Identity,
Attempt: deletion.Attempt, Status: DeletionFailed, Failure: redact.Sanitize(""),
}
if !deletion.Sealed() {
outcome.Failure = redact.Sanitize("deletion is not sealed")
return outcome, fmt.Errorf("%w: unsealed deletion", ErrWipeExecution)
}
if ctx.Err() != nil {
outcome.Failure = redact.Sanitize("execution canceled before issue")
return outcome, fmt.Errorf("%w: canceled", ErrWipeExecution)
}
issuedAt := clock().UTC()
issued, err := sealDeletionRecord(DeletionRecordV1{
PlanIntegrity: planIntegrity, Deletion: plannedDeletion(deletion), Status: DeletionIssued, IssuedAt: issuedAt, Failure: redact.Sanitize(""),
})
if err != nil {
return outcome, err
}
if err := journal.RecordDeletion(ctx, issued); err != nil {
outcome.Failure = redact.Sanitize("deletion intent was not persisted")
return outcome, fmt.Errorf("%w: deletion intent was not persisted", ErrWipeExecution)
}
deleteErr := deleter.Delete(ctx, deletion)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate each deletion immediately before issuing it

If Execute is called after the inventory window used by Plan has expired—or after a same-named resource was deleted and recreated—it sends the old sealed deletion directly to the provider without fresh discovery or comparison against current creation time, labels, attachments, or firewall lifetime evidence. A seal only proves that the in-memory plan was unchanged; it does not prove that provider state is unchanged, so this can delete a replacement resource selected by stale identity data. Require fresh provider revalidation at this boundary before calling Delete.

AGENTS.md reference: AGENTS.md:L20-L23

Useful? React with 👍 / 👎.

completedAt := clock().UTC()
final := issued
final.CompletedAt = &completedAt
if deleteErr != nil {
final.Status = DeletionFailed
final.Failure = redact.Sanitize(deleteErr.Error())
outcome.Failure = final.Failure
} else {
final.Status = DeletionDeleted
outcome.Status = DeletionDeleted
}
Comment on lines +137 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify provider state before recording a deletion as complete

When Deleter.Delete returns nil, the code immediately records DeletionDeleted and increments the successful count without any observed post-change state. A provider adapter that returns after accepting an asynchronous operation, or whose operation later fails, therefore leaves the resource present while the durable journal claims it was deleted; execution can then continue to dependent disk and firewall steps using a false result. Extend the execution boundary to obtain and validate fresh absence evidence before recording success.

AGENTS.md reference: AGENTS.md:L20-L23

Useful? React with 👍 / 👎.

final, err = sealDeletionRecord(final)
if err != nil {
return outcome, err
}
if err := journal.RecordDeletion(ctx, final); err != nil {
outcome.Status = DeletionFailed
outcome.Failure = redact.Sanitize("deletion outcome was not persisted")
return outcome, fmt.Errorf("%w: deletion outcome was not persisted", ErrWipeExecution)
}
if deleteErr != nil {
return outcome, fmt.Errorf("%w: deletion %d failed", ErrWipeExecution, deletion.Sequence)
}
return outcome, nil
}
Loading