diff --git a/internal/deferred_test_helpers_test.go b/internal/deferred_test_helpers_test.go new file mode 100644 index 0000000..ddd7320 --- /dev/null +++ b/internal/deferred_test_helpers_test.go @@ -0,0 +1,74 @@ +package internal + +// Test-only fixtures for the deferred-update flush path. Used by +// iacserver_finalize_test.go (workflow#695 Phase 2.5 FinalizeApply +// regression suite). Extracted from provider_deferred_test.go in the +// Phase 3 cleanup that deleted the v1-wrapper DOProvider.Apply tests +// (docs/plans/2026-05-17-phase2.5-cleanup-bundle.md Task 5) — the +// fixtures themselves remain in-package so the v2-dispatch tests +// continue to exercise the canonical DatabaseDriver mock surface +// without coupling to the (now-stub) v1 Apply wrapper. + +import ( + "context" + + "github.com/digitalocean/godo" +) + +// fakeAppForDeferred constructs a minimal *godo.App for deferred-test mocks. +func fakeAppForDeferred(name, id string) *godo.App { + return &godo.App{ID: id, Spec: &godo.AppSpec{Name: name}} +} + +// minimalDBMock satisfies drivers.DatabaseClient for the FinalizeApply +// deferred-flush tests. Stores the last UpdateFirewallRules request so +// the test can assert the flush occurred. The optional flushErr field — +// when non-nil — is returned from UpdateFirewallRules so tests can +// exercise the per-driver error-attribution path (workflow#695 Phase 2.5 +// FinalizeApply regression test). +type minimalDBMock struct { + db *godo.Database + lastFirewallReq *godo.DatabaseUpdateFirewallRulesRequest + flushErr error +} + +func (m *minimalDBMock) Create(_ context.Context, _ *godo.DatabaseCreateRequest) (*godo.Database, *godo.Response, error) { + return m.db, nil, nil +} +func (m *minimalDBMock) Get(_ context.Context, _ string) (*godo.Database, *godo.Response, error) { + return m.db, nil, nil +} +func (m *minimalDBMock) List(_ context.Context, _ *godo.ListOptions) ([]godo.Database, *godo.Response, error) { + if m.db == nil { + return nil, nil, nil + } + return []godo.Database{*m.db}, nil, nil +} +func (m *minimalDBMock) Resize(_ context.Context, _ string, _ *godo.DatabaseResizeRequest) (*godo.Response, error) { + return nil, nil +} +func (m *minimalDBMock) Delete(_ context.Context, _ string) (*godo.Response, error) { + return nil, nil +} +func (m *minimalDBMock) UpdateFirewallRules(_ context.Context, _ string, req *godo.DatabaseUpdateFirewallRulesRequest) (*godo.Response, error) { + m.lastFirewallReq = req + if m.flushErr != nil { + return nil, m.flushErr + } + return nil, nil +} + +// sequencedAppsForDeferred returns empty on the first call, then the app list. +// Simulates: DB create call (app absent) → flush call (app present). +type sequencedAppsForDeferred struct { + apps []*godo.App + callCount int +} + +func (m *sequencedAppsForDeferred) List(_ context.Context, _ *godo.ListOptions) ([]*godo.App, *godo.Response, error) { + m.callCount++ + if m.callCount == 1 { + return []*godo.App{}, nil, nil // first call: app doesn't exist yet + } + return m.apps, nil, nil +} diff --git a/internal/iacserver.go b/internal/iacserver.go index a6d364d..cf09e59 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -183,13 +183,24 @@ func (s *doIaCServer) Capabilities(_ context.Context, _ *pb.CapabilitiesRequest) }, nil } +// deferredUpdater is the per-driver opt-in interface for resources that +// accumulate updates that cannot be applied until all plan creates +// complete. The canonical case is DatabaseDriver deferring type=app +// trusted_sources entries that reference apps created later in the +// same plan. doIaCServer.FinalizeApply iterates the driver registry +// and calls FlushDeferredUpdates on every driver that opted in. +// Per workflow#695 Phase 2.5 (relocated from provider.go in Phase 3). +type deferredUpdater interface { + HasDeferredUpdates() bool + FlushDeferredUpdates(ctx context.Context) error +} + // FinalizeApply implements pb.IaCProviderFinalizerServer for the v2 -// dispatch path. Inlines the per-driver flush loop from -// DOProvider.Apply (internal/provider.go's post-loop block iterating -// p.drivers and calling deferredUpdater.FlushDeferredUpdates) since -// DOProvider does not expose a public FlushDeferredUpdates method — -// the flush iteration was inline in the v1 Apply wrapper. Per -// workflow#695 Phase 2.5. +// dispatch path. Iterates the driver registry and calls +// FlushDeferredUpdates on every driver that satisfies deferredUpdater +// (declared above). Originally inline in v1 DOProvider.Apply's post-loop +// block; hoisted here for the v2 dispatch path. Per workflow#695 +// Phase 2.5. // // Per-driver error attribution is preserved by returning ActionError // entries on the response, mirroring the v1 wrapper shape that wfctl diff --git a/internal/provider.go b/internal/provider.go index f32404c..519f85d 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -12,7 +12,6 @@ import ( "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/steps" - "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/platform" "github.com/digitalocean/godo" @@ -34,6 +33,11 @@ func (t *tokenSource) Token() (*oauth2.Token, error) { // the legacy doPlugin / NewDOPlugin entrypoint was deleted (Task 9). var Version = "dev" +// ErrApplyV1Removed is the sentinel returned by DOProvider.Apply when +// called post-Phase-3 cleanup. Callers can errors.Is for diagnostic +// classification. +var ErrApplyV1Removed = errors.New("DOProvider.Apply: v1 dispatch removed since DO v1.4.0 (workflow#695 Phase 2.5 + Phase 3); upgrade wfctl to v0.55.0+ for v2 dispatch via wfctlhelpers.ApplyPlanWithHooks + IaCProviderFinalizer.FinalizeApply") + // DOProvider implements interfaces.IaCProvider for DigitalOcean. type DOProvider struct { client *godo.Client @@ -225,90 +229,17 @@ func (p *DOProvider) Plan(ctx context.Context, desired []interfaces.ResourceSpec return &plan, err } -// deferredUpdater is an optional interface for ResourceDrivers that accumulate -// resource-level updates that cannot be applied until all plan creates complete. -// The canonical case is DatabaseDriver deferring type=app trusted_sources entries -// that reference apps created later in the same plan. Apply calls -// FlushDeferredUpdates once after the main dispatch loop; errors are appended to -// ApplyResult.Errors so the failure is visible to the operator. -// -// This is a DO-plugin-specific extension point not yet hoisted into -// wfctlhelpers.ApplyPlan; the second-pass flush below preserves the regression -// gate while the v2 dispatch handles the per-action loop. -type deferredUpdater interface { - HasDeferredUpdates() bool - FlushDeferredUpdates(ctx context.Context) error -} - -// Apply executes the plan via wfctlhelpers.ApplyPlan, then runs the -// DO-specific deferred-update second pass. -// -// PR P-DO TP2: under iacProvider.computePlanVersion: v2 wfctl dispatches -// directly through wfctlhelpers.ApplyPlan and does not call this method. -// The implementation here remains for legacy v1 callers (wfctl < v0.21.0 -// or any in-process embedder of the gRPC plugin). v2 callers route the -// deferred-flush through the FinalizeApply RPC (see iacserver.go); the -// workflow engine invokes it via the ApplyPlanHooks.OnPlanComplete hook. -// Per workflow#695 Phase 2.5. -// -// Per-action upsert recovery, JIT substitution, the Replace cascade, and -// the input-drift postcondition all live in wfctlhelpers.ApplyPlan now — -// drivers that opt into the upsert recovery path implement -// interfaces.UpsertSupporter (DO drivers AppPlatform, VPC, Firewall, -// Database all do; signature: SupportsUpsert() bool). The local -// upsertSupporter interface previously declared here is no longer needed: -// its SupportsUpsert() bool method is structurally identical to -// interfaces.UpsertSupporter, so the existing driver implementations -// satisfy the canonical interface without code change. -// -// wfctl:skip-iac-codemod -// -// The body intentionally wraps wfctlhelpers.ApplyPlan rather than -// matching the codemod's canonical single-statement -// `return wfctlhelpers.ApplyPlan(ctx, p, plan)` shape: the -// post-helper deferred-update flush below is a DO-plugin-specific -// regression gate (see provider_deferred_test.go and CHANGELOG entry -// for staging-deploy-blockers Blocker 2) that wfctlhelpers does not -// hoist. The skip marker tells the codemod's -// AssertApplyDelegatesToHelper analyzer this deviation is intentional. -// When wfctlhelpers grows a deferred-update lifecycle hook, the -// wrapper can collapse and the marker can drop. -func (p *DOProvider) Apply(ctx context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - result, err := wfctlhelpers.ApplyPlan(ctx, p, plan) - if err != nil { - // ApplyPlan only returns a top-level error on context cancellation - // — per-action failures land on result.Errors. Surface the - // cancellation alongside whatever partial result is in hand so - // callers can still inspect any actions that completed. - return result, err - } - - // Second pass: flush deferred updates accumulated by drivers during the - // main action loop. These arise when a resource's config (e.g. DB - // trusted_sources with type=app) references another resource provisioned - // later in the same plan. By this point all plan creates have completed - // and the referenced resources exist. - // - // Iterate the driver registry directly (not plan.Actions) so that orphaned - // deferred entries are flushed even when their resource type no longer - // appears in the current plan (e.g. after a transient flush failure on a - // prior Apply run). Each driver is checked at most once regardless of how - // many actions reference it. - for resourceType, d := range p.drivers { - du, ok := d.(deferredUpdater) - if !ok || !du.HasDeferredUpdates() { - continue - } - if flushErr := du.FlushDeferredUpdates(ctx); flushErr != nil { - result.Errors = append(result.Errors, interfaces.ActionError{ - Resource: resourceType, - Action: "deferred_update", - Error: flushErr.Error(), - }) - } - } - - return result, nil +// Apply returns ErrApplyV1Removed unconditionally. v1 dispatch was +// removed in DO v1.4.0 (workflow#695 Phase 3 cleanup). wfctl bypasses +// this method when ComputePlanVersion="v2" is declared in Capabilities; +// deferred-flush behavior moved to doIaCServer.FinalizeApply via +// IaCProviderFinalizer RPC. Reaching this method indicates a +// misconfigured caller (in-process embedder using a pre-Phase-2.5 +// wfctl tag, OR gRPC consumer that opted out of v2 dispatch). +// Stub preserves interfaces.IaCProvider contract per ADR 0024; +// interface segregation deferred to separate refactor design. +func (p *DOProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return nil, ErrApplyV1Removed } // EnumerateByTag implements the opt-in interfaces.Enumerator interface. diff --git a/internal/provider_apply_stub_test.go b/internal/provider_apply_stub_test.go new file mode 100644 index 0000000..5a2d565 --- /dev/null +++ b/internal/provider_apply_stub_test.go @@ -0,0 +1,22 @@ +package internal + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestDOProvider_Apply_ReturnsRemovedError pins the Phase 3 cleanup +// invariant: DOProvider.Apply is dead code post-Phase-2.5 v2 cutover; +// reaching it indicates a misconfigured caller. Method returns sentinel +// ErrApplyV1Removed; callers can errors.Is for diagnostic classification. +// Per workflow#695 Phase 3 cleanup + docs/plans/2026-05-17-phase2.5-cleanup-bundle.md. +func TestDOProvider_Apply_ReturnsRemovedError(t *testing.T) { + p := &DOProvider{} + _, err := p.Apply(context.Background(), &interfaces.IaCPlan{}) + if !errors.Is(err, ErrApplyV1Removed) { + t.Errorf("expected ErrApplyV1Removed; got: %v", err) + } +} diff --git a/internal/provider_apply_test.go b/internal/provider_apply_test.go deleted file mode 100644 index c640ed3..0000000 --- a/internal/provider_apply_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package internal - -import ( - "context" - "errors" - "testing" - - "github.com/GoCodeAlone/workflow/interfaces" -) - -// deleteFakeDriver records calls to Delete so tests can assert dispatch. -type deleteFakeDriver struct { - deleteCalls int - deletedRef interfaces.ResourceRef - deleteReturn error -} - -func (f *deleteFakeDriver) Create(_ context.Context, s interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - return &interfaces.ResourceOutput{Name: s.Name, Type: s.Type, ProviderID: "created-id"}, nil -} -func (f *deleteFakeDriver) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *deleteFakeDriver) Update(_ context.Context, _ interfaces.ResourceRef, s interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - return &interfaces.ResourceOutput{Name: s.Name, Type: s.Type, ProviderID: "updated-id"}, nil -} -func (f *deleteFakeDriver) Delete(_ context.Context, ref interfaces.ResourceRef) error { - f.deleteCalls++ - f.deletedRef = ref - return f.deleteReturn -} -func (f *deleteFakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return nil, nil -} -func (f *deleteFakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} -func (f *deleteFakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *deleteFakeDriver) SensitiveKeys() []string { return nil } - -// TestDOProvider_Apply_DeleteAction verifies that a "delete" plan action: -// 1. dispatches to d.Delete with a ref built from action.Current (ProviderID) -// 2. returns no error on success -// 3. does NOT add a resource entry to ApplyResult.Resources (deleted resources -// have no post-apply output) -func TestDOProvider_Apply_DeleteAction(t *testing.T) { - fake := &deleteFakeDriver{} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.firewall": fake}} - - plan := &interfaces.IaCPlan{ - ID: "plan-delete", - Actions: []interfaces.PlanAction{{ - Action: "delete", - Resource: interfaces.ResourceSpec{Name: "bmw-staging-firewall", Type: "infra.firewall"}, - Current: &interfaces.ResourceState{ - Name: "bmw-staging-firewall", - Type: "infra.firewall", - ProviderID: "do-firewall-abc123", - }, - }}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply returned non-nil error: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply returned action errors: %v", result.Errors) - } - if fake.deleteCalls != 1 { - t.Fatalf("Delete called %d times, want 1", fake.deleteCalls) - } - if fake.deletedRef.ProviderID != "do-firewall-abc123" { - t.Errorf("Delete called with ProviderID %q, want do-firewall-abc123", fake.deletedRef.ProviderID) - } - if fake.deletedRef.Name != "bmw-staging-firewall" { - t.Errorf("Delete called with Name %q, want bmw-staging-firewall", fake.deletedRef.Name) - } - // Deleted resource should NOT appear in Resources (it no longer exists). - if len(result.Resources) != 0 { - t.Errorf("ApplyResult.Resources = %d entries, want 0 for a delete action", len(result.Resources)) - } -} - -// TestDOProvider_Apply_DeleteAction_MissingCurrent verifies the v2-dispatch -// contract for a delete action with action.Current == nil: -// wfctlhelpers.ApplyPlan dispatches Delete with an empty-ProviderID -// ResourceRef (the driver is the authority on what an empty ProviderID -// means — see wfctlhelpers/apply.go::doUpdate's analogous comment). The -// v1-era pre-flight precondition error has been retired with the v2 -// migration (PR P-DO TP2): the response when ProviderID is empty is now -// driver-dependent rather than centrally synthesised. -// -// Copilot review #11 (round 3): driver behavior on an empty-ProviderID -// delete varies — FirewallDriver.Delete, for example, resolves by name -// when ProviderID is empty (uses ListByTag/ListByDroplet to locate the -// firewall) and may succeed; other drivers (DatabaseDriver, -// VolumeDriver) require ProviderID and surface a typed error. The v2 -// contract is "the driver knows what an empty ProviderID means for its -// resource shape", not "all drivers reject empty ProviderID". -func TestDOProvider_Apply_DeleteAction_MissingCurrent(t *testing.T) { - fake := &deleteFakeDriver{} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.firewall": fake}} - - plan := &interfaces.IaCPlan{ - ID: "plan-delete-no-current", - Actions: []interfaces.PlanAction{{ - Action: "delete", - Resource: interfaces.ResourceSpec{Name: "orphan-firewall", Type: "infra.firewall"}, - Current: nil, // v2 contract: dispatched with empty ProviderID - }}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply returned top-level error: %v", err) - } - // v2 contract: the dispatch IS made with an empty ProviderID; this - // stub fake accepts it and returns nil. Real drivers handle the - // empty case driver-by-driver (some resolve-by-name, some error). - if fake.deleteCalls != 1 { - t.Errorf("Delete dispatched %d times, want 1 (v2 dispatch contract)", fake.deleteCalls) - } - if fake.deletedRef.ProviderID != "" { - t.Errorf("Delete called with ProviderID %q, want empty (Current was nil)", fake.deletedRef.ProviderID) - } - // No top-level error and no per-action error because the fake driver - // accepted the empty-ProviderID delete. (Real drivers like FirewallDriver - // would emit an error here; the fake's job is to verify dispatch only.) - if len(result.Errors) != 0 { - t.Errorf("expected no per-action errors for fake driver dispatch, got %v", result.Errors) - } -} - -// TestDOProvider_Apply_DeleteAction_DriverError verifies that a driver error -// on Delete is collected in result.Errors (Apply itself still returns nil). -func TestDOProvider_Apply_DeleteAction_DriverError(t *testing.T) { - driverErr := errors.New("DO API: firewall not found") - fake := &deleteFakeDriver{deleteReturn: driverErr} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.firewall": fake}} - - plan := &interfaces.IaCPlan{ - ID: "plan-delete-err", - Actions: []interfaces.PlanAction{{ - Action: "delete", - Resource: interfaces.ResourceSpec{Name: "bmw-staging-firewall", Type: "infra.firewall"}, - Current: &interfaces.ResourceState{ - Name: "bmw-staging-firewall", Type: "infra.firewall", ProviderID: "do-firewall-abc123", - }, - }}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) == 0 { - t.Fatal("expected error in result.Errors for failed delete, got none") - } - if result.Errors[0].Action != "delete" { - t.Errorf("ActionError.Action = %q, want delete", result.Errors[0].Action) - } -} - -// TestDOProvider_Apply_DeleteAndCreate_NilOutGuard verifies that a plan with -// a delete followed by a create does not panic: the delete produces nil output -// and the create produces non-nil output. Both must land correctly in result. -func TestDOProvider_Apply_DeleteAndCreate_NilOutGuard(t *testing.T) { - fake := &deleteFakeDriver{} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.firewall": fake}} - - plan := &interfaces.IaCPlan{ - ID: "plan-delete-then-create", - Actions: []interfaces.PlanAction{ - { - Action: "delete", - Resource: interfaces.ResourceSpec{Name: "old-firewall", Type: "infra.firewall"}, - Current: &interfaces.ResourceState{ - Name: "old-firewall", Type: "infra.firewall", ProviderID: "old-fw-id", - }, - }, - { - Action: "create", - Resource: interfaces.ResourceSpec{Name: "new-firewall", Type: "infra.firewall"}, - }, - }, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply errors: %v", result.Errors) - } - // Only the create result should be in Resources (1 entry, not 2). - if len(result.Resources) != 1 { - t.Errorf("ApplyResult.Resources = %d entries, want 1 (delete produces no output)", len(result.Resources)) - } - if len(result.Resources) > 0 && result.Resources[0].Name != "new-firewall" { - t.Errorf("Resources[0].Name = %q, want new-firewall", result.Resources[0].Name) - } -} - -// TestDOProvider_Apply_DeleteAction_ResourceTypePopulated locks in the invariant -// that for delete actions, action.Resource.Type IS populated (required for driver -// lookup) while action.Resource.Config is nil (desired state is "absent"). -// This protects against a future regression where wfctl omits Resource.Type on -// delete actions, causing the driver lookup to fail with "unsupported resource type". -func TestDOProvider_Apply_DeleteAction_ResourceTypePopulated(t *testing.T) { - fake := &deleteFakeDriver{} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.firewall": fake}} - - plan := &interfaces.IaCPlan{ - ID: "plan-delete-type-check", - Actions: []interfaces.PlanAction{{ - Action: "delete", - Resource: interfaces.ResourceSpec{ - Name: "bmw-staging-firewall", - Type: "infra.firewall", - Config: nil, // explicitly nil — desired state is "absent" - }, - Current: &interfaces.ResourceState{ - Name: "bmw-staging-firewall", - Type: "infra.firewall", - ProviderID: "do-firewall-abc123", - }, - }}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply errors for delete with nil Config: %v", result.Errors) - } - if fake.deleteCalls != 1 { - t.Fatalf("Delete called %d times, want 1", fake.deleteCalls) - } - // The ref used for Delete must be built from Current, not Resource. - if fake.deletedRef.ProviderID != "do-firewall-abc123" { - t.Errorf("Delete ref.ProviderID = %q, want do-firewall-abc123", fake.deletedRef.ProviderID) - } -} diff --git a/internal/provider_deferred_test.go b/internal/provider_deferred_test.go deleted file mode 100644 index 6beb336..0000000 --- a/internal/provider_deferred_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package internal - -// TDD test for DOProvider.Apply second-pass deferred flush. -// -// Regression gate: Apply must call FlushDeferredUpdates on any driver that -// accumulated pending updates during the main action loop. Without this, a -// DB created with deferred app-ref trusted_sources never gets the full rules -// applied, even after the app is provisioned in the same plan run. -// -// See docs/plans/2026-05-02-staging-deploy-blockers-design.md (Blocker 2). - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" - "github.com/GoCodeAlone/workflow/interfaces" - "github.com/digitalocean/godo" -) - -// fakeAppForDeferred constructs a minimal *godo.App for deferred-test mocks. -func fakeAppForDeferred(name, id string) *godo.App { - return &godo.App{ID: id, Spec: &godo.AppSpec{Name: name}} -} - -// minimalDBMock satisfies drivers.DatabaseClient for the provider-level -// deferred flush test. Stores the last UpdateFirewallRules request so the -// test can assert the flush occurred. The optional flushErr field — when -// non-nil — is returned from UpdateFirewallRules so tests can exercise -// the per-driver error-attribution path (workflow#695 Phase 2.5 -// FinalizeApply regression test). -type minimalDBMock struct { - db *godo.Database - lastFirewallReq *godo.DatabaseUpdateFirewallRulesRequest - flushErr error -} - -func (m *minimalDBMock) Create(_ context.Context, _ *godo.DatabaseCreateRequest) (*godo.Database, *godo.Response, error) { - return m.db, nil, nil -} -func (m *minimalDBMock) Get(_ context.Context, _ string) (*godo.Database, *godo.Response, error) { - return m.db, nil, nil -} -func (m *minimalDBMock) List(_ context.Context, _ *godo.ListOptions) ([]godo.Database, *godo.Response, error) { - if m.db == nil { - return nil, nil, nil - } - return []godo.Database{*m.db}, nil, nil -} -func (m *minimalDBMock) Resize(_ context.Context, _ string, _ *godo.DatabaseResizeRequest) (*godo.Response, error) { - return nil, nil -} -func (m *minimalDBMock) Delete(_ context.Context, _ string) (*godo.Response, error) { - return nil, nil -} -func (m *minimalDBMock) UpdateFirewallRules(_ context.Context, _ string, req *godo.DatabaseUpdateFirewallRulesRequest) (*godo.Response, error) { - m.lastFirewallReq = req - if m.flushErr != nil { - return nil, m.flushErr - } - return nil, nil -} - -// sequencedAppsForDeferred returns empty on the first call, then the app list. -// Simulates: DB create call (app absent) → flush call (app present). -type sequencedAppsForDeferred struct { - apps []*godo.App - callCount int -} - -func (m *sequencedAppsForDeferred) List(_ context.Context, _ *godo.ListOptions) ([]*godo.App, *godo.Response, error) { - m.callCount++ - if m.callCount == 1 { - return []*godo.App{}, nil, nil // first call: app doesn't exist yet - } - return m.apps, nil, nil -} - -// TestDOProvider_Apply_FlushesDeferred_AfterAllCreates verifies the end-to-end -// deferred-update flow through DOProvider.Apply: -// -// 1. Plan has one "create" action for an infra.database with a type=app -// trusted_sources ref. -// 2. During Create the app doesn't exist yet — driver defers. -// 3. After the main action loop, Apply calls FlushDeferredUpdates. -// 4. The flush calls UpdateFirewallRules with the full rule set (app UUID). -func TestDOProvider_Apply_FlushesDeferred_AfterAllCreates(t *testing.T) { - const appUUID = "f8b6200c-3bba-48a7-8bf1-7a3e3a885eb5" - - dbMock := &minimalDBMock{db: &godo.Database{ - ID: "db-abc", - Name: "coredump-staging-db", - Connection: &godo.DatabaseConnection{ - Host: "host.db.ondigitalocean.com", - Port: 5432, - }, - }} - appsSeq := &sequencedAppsForDeferred{ - apps: []*godo.App{fakeAppForDeferred("coredump-staging", appUUID)}, - } - dbDriver := drivers.NewDatabaseDriverWithClients(dbMock, appsSeq, "nyc3") - - p := &DOProvider{ - drivers: map[string]interfaces.ResourceDriver{ - "infra.database": dbDriver, - }, - } - - plan := &interfaces.IaCPlan{ - ID: "test-plan", - Actions: []interfaces.PlanAction{ - { - Action: "create", - Resource: interfaces.ResourceSpec{ - Name: "coredump-staging-db", - Type: "infra.database", - Config: map[string]any{ - "engine": "pg", - "trusted_sources": []any{ - map[string]any{"type": "app", "value": "coredump-staging"}, - }, - }, - }, - }, - }, - } - - result, err := p.Apply(context.Background(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply result errors: %+v", result.Errors) - } - if len(result.Resources) != 1 { - t.Fatalf("expected 1 created resource, got %d", len(result.Resources)) - } - - // The deferred flush should have called UpdateFirewallRules with app UUID. - if dbMock.lastFirewallReq == nil { - t.Fatal("UpdateFirewallRules was never called — deferred flush did not run") - } - if len(dbMock.lastFirewallReq.Rules) != 1 { - t.Fatalf("expected 1 rule in deferred flush, got %d: %+v", - len(dbMock.lastFirewallReq.Rules), dbMock.lastFirewallReq.Rules) - } - rule := dbMock.lastFirewallReq.Rules[0] - if rule.Type != "app" || rule.Value != appUUID { - t.Errorf("deferred flush rule = {%s %s}, want {app %s}", rule.Type, rule.Value, appUUID) - } -} - -// TestDOProvider_Apply_FlushesDeferred_WhenTypeAbsentFromPlan verifies that -// the deferred-flush second pass iterates the driver registry (not plan.Actions), -// so orphaned deferred entries are flushed even when their resource type does -// not appear in the current plan. -// -// Scenario: -// 1. Seed the database driver with a deferred update by calling Create directly -// (simulating a prior Apply run where the flush failed transiently). -// 2. Run Apply with a plan that has NO infra.database actions. -// 3. Expect FlushDeferredUpdates to still run and call UpdateFirewallRules. -func TestDOProvider_Apply_FlushesDeferred_WhenTypeAbsentFromPlan(t *testing.T) { - const appUUID = "f8b6200c-3bba-48a7-8bf1-7a3e3a885eb5" - - dbMock := &minimalDBMock{db: &godo.Database{ - ID: "db-abc", - Name: "coredump-staging-db", - Connection: &godo.DatabaseConnection{ - Host: "host.db.ondigitalocean.com", - Port: 5432, - }, - }} - // First call (Create, called directly below) returns empty — app absent; - // subsequent calls (FlushDeferredUpdates, called via Apply) return the app. - appSeq := &sequencedAppsForDeferred{ - apps: []*godo.App{fakeAppForDeferred("coredump-staging", appUUID)}, - } - dbDriver := drivers.NewDatabaseDriverWithClients(dbMock, appSeq, "nyc3") - - // Seed the driver with a deferred update by calling Create directly. - // The first Apps.List call returns empty → deferred update queued. - _, err := dbDriver.Create(context.Background(), interfaces.ResourceSpec{ - Name: "coredump-staging-db", - Config: map[string]any{ - "engine": "pg", - "trusted_sources": []any{ - map[string]any{"type": "app", "value": "coredump-staging"}, - }, - }, - }) - if err != nil { - t.Fatalf("seed Create: %v", err) - } - if !dbDriver.HasDeferredUpdates() { - t.Fatal("expected deferred update queued after seed Create") - } - - p := &DOProvider{ - drivers: map[string]interfaces.ResourceDriver{ - "infra.database": dbDriver, - }, - } - - // Apply with an empty plan — no infra.database actions. - // The deferred flush must still run because Apply iterates p.drivers. - plan := &interfaces.IaCPlan{ - ID: "test-plan-no-db-actions", - Actions: []interfaces.PlanAction{}, - } - - result, err := p.Apply(context.Background(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply result errors: %+v", result.Errors) - } - - // Flush must have called UpdateFirewallRules with app UUID. - if dbMock.lastFirewallReq == nil { - t.Fatal("UpdateFirewallRules was never called — orphaned deferred entry was not flushed") - } - if len(dbMock.lastFirewallReq.Rules) != 1 { - t.Fatalf("expected 1 rule in deferred flush, got %d: %+v", - len(dbMock.lastFirewallReq.Rules), dbMock.lastFirewallReq.Rules) - } - rule := dbMock.lastFirewallReq.Rules[0] - if rule.Type != "app" || rule.Value != appUUID { - t.Errorf("deferred flush rule = {%s %s}, want {app %s}", rule.Type, rule.Value, appUUID) - } - if dbDriver.HasDeferredUpdates() { - t.Error("HasDeferredUpdates() should be false after successful flush") - } -} diff --git a/internal/provider_test.go b/internal/provider_test.go index ff0510f..d0da51d 100644 --- a/internal/provider_test.go +++ b/internal/provider_test.go @@ -3,7 +3,6 @@ package internal import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -35,10 +34,6 @@ func TestMain(m *testing.M) { // compile-time interface check var _ interfaces.IaCProvider = (*DOProvider)(nil) -func providerGodoStatusErr(code int) error { - return &godo.ErrorResponse{Response: &http.Response{StatusCode: code, Status: http.StatusText(code)}} -} - func TestDOProvider_Name(t *testing.T) { p := NewDOProvider() if p.Name() != "digitalocean" { @@ -556,73 +551,6 @@ func TestDOProvider_Plan_DNSDriverDiffNoopsWhenImportedRecordStateMatches(t *tes } } -func TestDOProvider_Apply_DNSCreateIsIdempotentForExistingDomain(t *testing.T) { - mock := &providerDNSMock{ - domain: &godo.Domain{Name: "example.com"}, - records: []godo.DomainRecord{{ID: 10, Type: "A", Name: "@", Data: "1.2.3.4", TTL: 300}}, - } - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{ - "infra.dns": drivers.NewDNSDriverWithClient(mock), - }} - spec := interfaces.ResourceSpec{ - Name: "site-dns", - Type: "infra.dns", - Config: map[string]any{ - "domain": "example.com", - "records": []any{ - map[string]any{"type": "A", "name": "@", "data": "1.2.3.4", "ttl": 300}, - }, - }, - } - - result, err := p.Apply(t.Context(), &interfaces.IaCPlan{ID: "plan-dns", Actions: []interfaces.PlanAction{{Action: "create", Resource: spec}}}) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) != 0 { - t.Fatalf("Apply errors = %#v", result.Errors) - } - if mock.createCalls != 0 { - t.Fatalf("domain create calls = %d, want 0 for existing domain", mock.createCalls) - } - if len(mock.createRecordNs) != 0 { - t.Fatalf("record create calls = %d, want 0 for matching existing record", len(mock.createRecordNs)) - } - if len(result.Resources) != 1 || result.Resources[0].ProviderID != "example.com" { - t.Fatalf("resources = %#v, want adopted DNS output", result.Resources) - } -} - -func TestDOProvider_Apply_DNSCreateAdoptsDomainAfterCreateConflict(t *testing.T) { - mock := &providerDNSMock{ - domain: &godo.Domain{Name: "example.com"}, - getErrs: []error{providerGodoStatusErr(http.StatusNotFound), nil}, - createErr: providerGodoStatusErr(http.StatusConflict), - } - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{ - "infra.dns": drivers.NewDNSDriverWithClient(mock), - }} - spec := interfaces.ResourceSpec{ - Name: "site-dns", - Type: "infra.dns", - Config: map[string]any{"domain": "example.com"}, - } - - result, err := p.Apply(t.Context(), &interfaces.IaCPlan{ID: "plan-dns", Actions: []interfaces.PlanAction{{Action: "create", Resource: spec}}}) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) != 0 { - t.Fatalf("Apply errors = %#v", result.Errors) - } - if mock.createCalls != 1 { - t.Fatalf("domain create calls = %d, want one conflicted create attempt", mock.createCalls) - } - if len(result.Resources) != 1 || result.Resources[0].ProviderID != "example.com" { - t.Fatalf("resources = %#v, want adopted DNS output", result.Resources) - } -} - func TestDOProvider_Import_DNSIncludesExistingRecords(t *testing.T) { mock := &providerDNSMock{ domain: &godo.Domain{Name: "example.com"}, @@ -732,374 +660,6 @@ func TestDOProvider_Plan_TreatsNoopDriverDiffAsAuthoritative(t *testing.T) { } } -type replaceFakeDriver struct { - calls []string - deleteRef interfaces.ResourceRef -} - -func (f *replaceFakeDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.calls = append(f.calls, "create") - return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "new-provider-id"}, nil -} -func (f *replaceFakeDriver) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *replaceFakeDriver) Update(_ context.Context, _ interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.calls = append(f.calls, "update") - return nil, nil -} -func (f *replaceFakeDriver) Delete(_ context.Context, ref interfaces.ResourceRef) error { - f.calls = append(f.calls, "delete") - f.deleteRef = ref - return nil -} -func (f *replaceFakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return nil, nil -} -func (f *replaceFakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} -func (f *replaceFakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *replaceFakeDriver) SensitiveKeys() []string { return nil } - -func TestDOProvider_Apply_ReplaceDeletesThenCreates(t *testing.T) { - fake := &replaceFakeDriver{} - p := &DOProvider{drivers: map[string]interfaces.ResourceDriver{"infra.vpc": fake}} - - spec := interfaces.ResourceSpec{Name: "example-vpc", Type: "infra.vpc", Config: map[string]any{"ip_range": "10.20.0.0/16"}} - plan := &interfaces.IaCPlan{ - ID: "plan-replace", - Actions: []interfaces.PlanAction{{ - Action: "replace", - Resource: spec, - Current: &interfaces.ResourceState{Name: "example-vpc", Type: "infra.vpc", ProviderID: "old-provider-id"}, - }}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply returned errors: %v", result.Errors) - } - if got, want := strings.Join(fake.calls, ","), "delete,create"; got != want { - t.Fatalf("calls = %s, want %s", got, want) - } - if fake.deleteRef.ProviderID != "old-provider-id" { - t.Fatalf("delete ProviderID = %q, want old-provider-id", fake.deleteRef.ProviderID) - } - if len(result.Resources) != 1 || result.Resources[0].ProviderID != "new-provider-id" { - t.Fatalf("result resources = %+v, want new provider ID", result.Resources) - } -} - -// ── fake driver for Apply upsert test ───────────────────────────────────────── - -// upsertFakeDriver is a minimal ResourceDriver that: -// - always returns ErrResourceAlreadyExists on Create -// - returns a fixed ResourceOutput on Read (simulating discovery by name) -// - records the ref passed to Read and Update so the test can assert the -// upsert path was taken with the correct arguments -type upsertFakeDriver struct { - createCalls int - updateCalls int - readCalls int - lastReadRef interfaces.ResourceRef - updatedRef interfaces.ResourceRef -} - -func (f *upsertFakeDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.createCalls++ - return nil, fmt.Errorf("create conflict: %w", interfaces.ErrResourceAlreadyExists) -} - -func (f *upsertFakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - f.readCalls++ - f.lastReadRef = ref - return &interfaces.ResourceOutput{ - Name: ref.Name, - Type: ref.Type, - ProviderID: "discovered-provider-id", - }, nil -} - -func (f *upsertFakeDriver) Update(_ context.Context, ref interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.updateCalls++ - f.updatedRef = ref - return &interfaces.ResourceOutput{ - Name: ref.Name, - Type: ref.Type, - ProviderID: ref.ProviderID, - }, nil -} - -func (f *upsertFakeDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } -func (f *upsertFakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return nil, nil -} -func (f *upsertFakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} -func (f *upsertFakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *upsertFakeDriver) SensitiveKeys() []string { return nil } - -// SupportsUpsert opts this fake into the upsert path, mirroring AppPlatformDriver. -func (f *upsertFakeDriver) SupportsUpsert() bool { return true } - -// TestDOProvider_Apply_UpsertOnAlreadyExists verifies that when a create action -// hits ErrResourceAlreadyExists, Apply: -// 1. Gates on SupportsUpsert — only proceeds for drivers that opt in. -// 2. Calls Read (by name, empty ProviderID) to discover the existing ProviderID. -// 3. Validates existing.ProviderID is non-empty before calling Update. -// 4. Calls Update with the discovered ProviderID. -// 5. Returns the resource in ApplyResult.Resources (no errors). -func TestDOProvider_Apply_UpsertOnAlreadyExists(t *testing.T) { - fake := &upsertFakeDriver{} - p := &DOProvider{ - drivers: map[string]interfaces.ResourceDriver{ - "infra.container_service": fake, - }, - } - - spec := interfaces.ResourceSpec{ - Name: "bmw-app", - Type: "infra.container_service", - Config: map[string]any{"image": "registry/bmw:latest"}, - } - plan := &interfaces.IaCPlan{ - ID: "plan-test", - Actions: []interfaces.PlanAction{{Action: "create", Resource: spec}}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply returned errors: %v", result.Errors) - } - - // Create was attempted once. - if fake.createCalls != 1 { - t.Errorf("createCalls = %d, want 1", fake.createCalls) - } - // Read was called with empty ProviderID (triggers name-based lookup in drivers). - if fake.readCalls != 1 { - t.Errorf("readCalls = %d, want 1", fake.readCalls) - } - if fake.lastReadRef.ProviderID != "" { - t.Errorf("Read called with non-empty ProviderID %q; want empty for name-based lookup", fake.lastReadRef.ProviderID) - } - if fake.lastReadRef.Name != spec.Name || fake.lastReadRef.Type != spec.Type { - t.Errorf("Read ref = {%q, %q}, want {%q, %q}", fake.lastReadRef.Name, fake.lastReadRef.Type, spec.Name, spec.Type) - } - // Update was called with the discovered ProviderID. - if fake.updateCalls != 1 { - t.Errorf("updateCalls = %d, want 1", fake.updateCalls) - } - if fake.updatedRef.ProviderID != "discovered-provider-id" { - t.Errorf("updatedRef.ProviderID = %q, want %q", fake.updatedRef.ProviderID, "discovered-provider-id") - } - - // Resource appears in result. - if len(result.Resources) != 1 || result.Resources[0].Name != "bmw-app" { - t.Errorf("result.Resources = %v, want [{bmw-app ...}]", result.Resources) - } -} - -// noUpsertFakeDriver is a ResourceDriver that returns ErrResourceAlreadyExists -// on Create but does NOT implement SupportsUpsert. It simulates drivers like -// VPC/database/firewall that require ProviderID for Read. -// SupportsUpsert is intentionally absent so it does not satisfy upsertSupporter. -type noUpsertFakeDriver struct { - createCalls int - readCalls int - updateCalls int -} - -func (f *noUpsertFakeDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.createCalls++ - return nil, fmt.Errorf("create conflict: %w", interfaces.ErrResourceAlreadyExists) -} -func (f *noUpsertFakeDriver) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - f.readCalls++ - return nil, nil -} -func (f *noUpsertFakeDriver) Update(_ context.Context, _ interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.updateCalls++ - return nil, nil -} -func (f *noUpsertFakeDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } -func (f *noUpsertFakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return nil, nil -} -func (f *noUpsertFakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} -func (f *noUpsertFakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *noUpsertFakeDriver) SensitiveKeys() []string { return nil } - -// TestDOProvider_Apply_NoUpsertForUnsupportedDriver verifies that when a driver -// does not implement SupportsUpsert, Apply does NOT call Read or Update — it -// surfaces the original ErrResourceAlreadyExists as an action error. -func TestDOProvider_Apply_NoUpsertForUnsupportedDriver(t *testing.T) { - fake := &noUpsertFakeDriver{} - p := &DOProvider{ - drivers: map[string]interfaces.ResourceDriver{ - "infra.database": fake, - }, - } - - spec := interfaces.ResourceSpec{ - Name: "bmw-db", - Type: "infra.database", - Config: map[string]any{"engine": "postgres"}, - } - plan := &interfaces.IaCPlan{ - ID: "plan-test", - Actions: []interfaces.PlanAction{{Action: "create", Resource: spec}}, - } - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - - // Create was attempted once before the conflict was detected. - if fake.createCalls != 1 { - t.Errorf("createCalls = %d, want 1", fake.createCalls) - } - // Apply must return an action error — upsert is not available. - if len(result.Errors) != 1 { - t.Fatalf("expected 1 action error, got %d: %v", len(result.Errors), result.Errors) - } - if !strings.Contains(result.Errors[0].Error, interfaces.ErrResourceAlreadyExists.Error()) { - t.Errorf("action error should mention ErrResourceAlreadyExists, got: %s", result.Errors[0].Error) - } - // Read and Update must not have been called. - if fake.readCalls != 0 { - t.Errorf("readCalls = %d, want 0 (no upsert for unsupported driver)", fake.readCalls) - } - if fake.updateCalls != 0 { - t.Errorf("updateCalls = %d, want 0", fake.updateCalls) - } -} - -// ── integration: upsert across all four driver types ───────────────────────── - -// multiUpsertFakeDriver is a per-resource-type fake that always returns -// ErrResourceAlreadyExists on Create, implements SupportsUpsert, and records -// all call counts and the ProviderID passed to Update so assertions can verify -// the full upsert path. -type multiUpsertFakeDriver struct { - providerID string - createCalls int - readCalls int - updateCalls int - updatedProviderID string -} - -func (f *multiUpsertFakeDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.createCalls++ - return nil, fmt.Errorf("already exists: %w", interfaces.ErrResourceAlreadyExists) -} -func (f *multiUpsertFakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - f.readCalls++ - return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: f.providerID}, nil -} -func (f *multiUpsertFakeDriver) Update(_ context.Context, ref interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - f.updateCalls++ - f.updatedProviderID = ref.ProviderID - return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil -} -func (f *multiUpsertFakeDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } -func (f *multiUpsertFakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return nil, nil -} -func (f *multiUpsertFakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} -func (f *multiUpsertFakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} -func (f *multiUpsertFakeDriver) SensitiveKeys() []string { return nil } -func (f *multiUpsertFakeDriver) SupportsUpsert() bool { return true } - -// TestDOProvider_Apply_UpsertAllDrivers verifies that when VPC, Firewall, -// Database, and container_service resources all pre-exist (Create returns -// ErrResourceAlreadyExists), Apply upserts every one of them: Read is called -// with empty ProviderID, then Update is called with the discovered ProviderID. -func TestDOProvider_Apply_UpsertAllDrivers(t *testing.T) { - resources := []struct { - rtype string - name string - providerID string - }{ - {"infra.vpc", "bmw-vpc", "vpc-aaa"}, - {"infra.firewall", "bmw-fw", "fw-bbb"}, - {"infra.database", "bmw-db", "db-ccc"}, - {"infra.container_service", "bmw-app", "app-ddd"}, - } - - fakes := make(map[string]*multiUpsertFakeDriver, len(resources)) - driverMap := make(map[string]interfaces.ResourceDriver, len(resources)) - for _, r := range resources { - f := &multiUpsertFakeDriver{providerID: r.providerID} - fakes[r.rtype] = f - driverMap[r.rtype] = f - } - - p := &DOProvider{drivers: driverMap} - - actions := make([]interfaces.PlanAction, 0, len(resources)) - for _, r := range resources { - actions = append(actions, interfaces.PlanAction{ - Action: "create", - Resource: interfaces.ResourceSpec{ - Name: r.name, - Type: r.rtype, - Config: map[string]any{}, - }, - }) - } - plan := &interfaces.IaCPlan{ID: "plan-multi", Actions: actions} - - result, err := p.Apply(t.Context(), plan) - if err != nil { - t.Fatalf("Apply: %v", err) - } - if len(result.Errors) > 0 { - t.Fatalf("Apply returned errors: %v", result.Errors) - } - if len(result.Resources) != len(resources) { - t.Errorf("result.Resources len = %d, want %d", len(result.Resources), len(resources)) - } - - for _, r := range resources { - f := fakes[r.rtype] - if f.createCalls != 1 { - t.Errorf("%s: createCalls = %d, want 1", r.rtype, f.createCalls) - } - if f.readCalls != 1 { - t.Errorf("%s: readCalls = %d, want 1", r.rtype, f.readCalls) - } - if f.updateCalls != 1 { - t.Errorf("%s: updateCalls = %d, want 1", r.rtype, f.updateCalls) - } - // Verify the discovered ProviderID from Read was propagated into Update. - if f.updatedProviderID != r.providerID { - t.Errorf("%s: Update called with ProviderID %q, want %q", r.rtype, f.updatedProviderID, r.providerID) - } - } -} - // newDOProviderForTest builds a *DOProvider whose godo client points at the // given httptest server. Mirrors newProviderForEnumeratorTest in // provider_enumerator_test.go but takes the server (not just a URL) so the diff --git a/plugin.json b/plugin.json index f134b82..371bb66 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-digitalocean", - "version": "1.3.0", + "version": "1.4.0", "description": "DigitalOcean IaC provider: App Platform, DOKS, databases, Redis cache, load balancers, VPC, firewall, DNS, Spaces, DOCR, certificates, Droplets, Block Storage volumes, IAM (declared), and API gateway", "author": "GoCodeAlone", "license": "MIT", @@ -23,22 +23,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.3.0/workflow-plugin-digitalocean-linux-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.4.0/workflow-plugin-digitalocean-linux-amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.3.0/workflow-plugin-digitalocean-linux-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.4.0/workflow-plugin-digitalocean-linux-arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.3.0/workflow-plugin-digitalocean-darwin-amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.4.0/workflow-plugin-digitalocean-darwin-amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.3.0/workflow-plugin-digitalocean-darwin-arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-digitalocean/releases/download/v1.4.0/workflow-plugin-digitalocean-darwin-arm64.tar.gz" } ], "iacProvider": {