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
4 changes: 2 additions & 2 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ func (p *pluginDeployProvider) Deploy(ctx context.Context, cfg DeployConfig) err
case readErr == nil && readOut != nil && readOut.ProviderID != "":
ref.ProviderID = readOut.ProviderID
log.Printf("plugin deploy %q: found existing resource (id=%s)", p.resourceName, ref.ProviderID)
case readErr != nil && errors.Is(readErr, interfaces.ErrResourceNotFound):
case readErr != nil && interfaces.IsErrResourceNotFound(readErr):
// Resource confirmed absent — skip Update, go straight to Create.
return p.doCreate(ctx, driver, ref, spec, imageStr)
case readErr != nil:
Expand All @@ -724,7 +724,7 @@ func (p *pluginDeployProvider) Deploy(ctx context.Context, cfg DeployConfig) err
fmt.Printf(" plugin deploy: updated %q at %s (id=%s)\n", p.resourceName, imageStr, out.ProviderID)
return nil
}
if !errors.Is(updateErr, interfaces.ErrResourceNotFound) {
if !interfaces.IsErrResourceNotFound(updateErr) {
return deployOpError(p.resourceName, "update", updateErr)
}
// Resource does not exist yet — fall back to Create.
Expand Down
22 changes: 3 additions & 19 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -917,23 +917,7 @@ func driverSupportsConfigAdoption(driver interfaces.ResourceDriver) bool {
}

func isIaCNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, interfaces.ErrResourceNotFound) {
return true
}
var platformNotFound *platform.ResourceNotFoundError
if errors.As(err, &platformNotFound) {
return true
}
// gRPC fallback: typed adapter loses sentinel identity across the
// wire. The message survives as a wrapped string. Match on the
// literal ErrResourceNotFound.Error() value so adoption can still
// detect "not present yet, fall back to create" against remote
// plugin drivers (workflow-plugin-digitalocean v2+ database
// adoption is the original repro path).
return strings.Contains(err.Error(), interfaces.ErrResourceNotFound.Error())
return interfaces.IsErrResourceNotFound(err)
}

func resourceStateFromLiveOutput(spec interfaces.ResourceSpec, providerType string, live *interfaces.ResourceOutput) (interfaces.ResourceState, error) {
Expand Down Expand Up @@ -1268,7 +1252,7 @@ func compensateAfterValidationFailure(driver interfaces.ResourceDriver, rs inter
if rs.ProviderID != "" {
idRef := interfaces.ResourceRef{Name: rs.Name, Type: rs.Type, ProviderID: rs.ProviderID}
if delErr := driver.Delete(ctx, idRef); delErr != nil {
if !errors.Is(delErr, interfaces.ErrResourceNotFound) {
if !interfaces.IsErrResourceNotFound(delErr) {
errs = append(errs, fmt.Errorf("driver.Delete(%s): %w", rs.ProviderID, delErr))
}
} else {
Expand All @@ -1277,7 +1261,7 @@ func compensateAfterValidationFailure(driver interfaces.ResourceDriver, rs inter
}
nameRef := interfaces.ResourceRef{Name: rs.Name, Type: rs.Type}
if delErr := driver.Delete(ctx, nameRef); delErr != nil {
if !errors.Is(delErr, interfaces.ErrResourceNotFound) {
if !interfaces.IsErrResourceNotFound(delErr) {
errs = append(errs, fmt.Errorf("driver.Delete(name-only): %w", delErr))
}
return errors.Join(errs...)
Expand Down
3 changes: 1 addition & 2 deletions iac/refreshoutputs/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ package refreshoutputs

import (
"context"
"errors"
"fmt"
"maps"
"reflect"
Expand Down Expand Up @@ -94,7 +93,7 @@ func refreshOne(ctx context.Context, p interfaces.IaCProvider, dst *interfaces.R
ref := interfaces.ResourceRef{Name: src.Name, Type: src.Type, ProviderID: src.ProviderID}
live, err := d.Read(ctx, ref)
if err != nil {
if errors.Is(err, interfaces.ErrResourceNotFound) {
if interfaces.IsErrResourceNotFound(err) {
// Ghost: cloud reports the resource does not exist. Explicitly keep
// dst.Outputs aligned with src so refreshOne is self-contained and
// does not rely on the caller having pre-copied src into dst.
Expand Down
20 changes: 20 additions & 0 deletions interfaces/iac_resource_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package interfaces
import (
"context"
"errors"
"strings"
"time"
)

Expand Down Expand Up @@ -56,6 +57,25 @@ var (
ErrProviderMethodUnimplemented = errors.New("iac: provider method unimplemented")
)

// IsErrResourceNotFound reports whether err is or wraps ErrResourceNotFound,
// including the case where the sentinel was stringified across a gRPC plugin
// boundary (where errors.Is fails because structpb does not preserve sentinel
// identity). Matches the ErrImageNotInRegistry precedent above. The message
// string of ErrResourceNotFound is load-bearing for this match; do not change
// it without updating TestErrResourceNotFound_MessageStringStable.
//
// For non-gRPC callers (e.g., SQL-backed tenant lookups in module/),
// errors.Is fires and strings.Contains is never reached.
func IsErrResourceNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrResourceNotFound) {
return true
}
return strings.Contains(err.Error(), ErrResourceNotFound.Error())
}

// ResourceDriver handles CRUD for a single resource type within a provider.
type ResourceDriver interface {
Create(ctx context.Context, spec ResourceSpec) (*ResourceOutput, error)
Expand Down
29 changes: 29 additions & 0 deletions interfaces/iac_resource_driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,32 @@ func TestErrImageNotInRegistry_MessageStringStable(t *testing.T) {
t.Fatalf("ErrImageNotInRegistry.Error() = %q; want %q (load-bearing for gRPC string-match fallback)", got, want)
}
}

func TestErrResourceNotFound_MessageStringStable(t *testing.T) {
const expected = "iac: resource not found"
if got := interfaces.ErrResourceNotFound.Error(); got != expected {
t.Fatalf("ErrResourceNotFound message changed (was %q, want %q) — load-bearing for cross-process matching", got, expected)
}
}

func TestIsErrResourceNotFound(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"direct", interfaces.ErrResourceNotFound, true},
{"wrapped with fmt.Errorf %w", fmt.Errorf("driver: %w", interfaces.ErrResourceNotFound), true},
{"stringified across gRPC (no sentinel)", errors.New("plugin: " + interfaces.ErrResourceNotFound.Error()), true},
{"unrelated NotFound", errors.New("file: not found"), false},
{"empty error", errors.New(""), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := interfaces.IsErrResourceNotFound(tc.err); got != tc.want {
t.Errorf("IsErrResourceNotFound(%v) = %v; want %v", tc.err, got, tc.want)
}
})
}
}
4 changes: 2 additions & 2 deletions module/tenant_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,15 @@ func (r *TenantContextResolver) lookup(key string) (interfaces.Tenant, error) {
if err == nil {
return t, nil
}
if !errors.Is(err, interfaces.ErrResourceNotFound) {
if !interfaces.IsErrResourceNotFound(err) {
return interfaces.Tenant{}, fmt.Errorf("registry lookup by slug %q: %w", key, err)
}
// Fall back to domain lookup.
t, err = r.cfg.Registry.GetByDomain(key)
if err == nil {
return t, nil
}
if errors.Is(err, interfaces.ErrResourceNotFound) {
if interfaces.IsErrResourceNotFound(err) {
return interfaces.Tenant{}, nil
}
return interfaces.Tenant{}, fmt.Errorf("registry lookup by domain %q: %w", key, err)
Expand Down
5 changes: 2 additions & 3 deletions module/tenants.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io/fs"
"strings"
Expand Down Expand Up @@ -194,7 +193,7 @@ func (r *SQLTenantRegistry) Ensure(spec interfaces.TenantSpec) (interfaces.Tenan
if err == nil {
return existing, nil
}
if !errors.Is(err, interfaces.ErrResourceNotFound) {
if !interfaces.IsErrResourceNotFound(err) {
return interfaces.Tenant{}, fmt.Errorf("ensure tenant: lookup: %w", err)
}

Expand Down Expand Up @@ -372,7 +371,7 @@ func (r *SQLTenantRegistry) List(filter interfaces.TenantFilter) ([]interfaces.T
func (r *SQLTenantRegistry) Update(id string, patch interfaces.TenantPatch) (interfaces.Tenant, error) {
// Fetch existing so we can invalidate stale domain cache keys if domains changed.
existing, fetchErr := r.GetByID(id)
if fetchErr != nil && !errors.Is(fetchErr, interfaces.ErrResourceNotFound) {
if fetchErr != nil && !interfaces.IsErrResourceNotFound(fetchErr) {
return interfaces.Tenant{}, fmt.Errorf("update tenant: fetch existing: %w", fetchErr)
}

Expand Down
3 changes: 1 addition & 2 deletions platform/differ.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
Expand Down Expand Up @@ -384,7 +383,7 @@ func classifyCreate(ctx context.Context, p interfaces.IaCProvider, spec interfac
}
current, err := driver.Read(ctx, ref)
if err != nil {
if errors.Is(err, interfaces.ErrResourceNotFound) {
if interfaces.IsErrResourceNotFound(err) {
return create, nil
}
return nil, fmt.Errorf("provider.Read(%q/%q): %w", spec.Type, spec.Name, err)
Expand Down
13 changes: 12 additions & 1 deletion platform/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package platform

import "fmt"
import (
"fmt"

"github.com/GoCodeAlone/workflow/interfaces"
)

// ConstraintViolationError is returned when a capability declaration violates
// a constraint imposed by a parent tier.
Expand Down Expand Up @@ -86,6 +90,13 @@ func (e *ResourceNotFoundError) Error() string {
return fmt.Sprintf("resource %q not found", e.Name)
}

// Is reports whether target is interfaces.ErrResourceNotFound. Lets
// errors.Is(err, interfaces.ErrResourceNotFound) natively match this
// typed error without callers needing both errors.Is and errors.As.
func (e *ResourceNotFoundError) Is(target error) bool {
return target == interfaces.ErrResourceNotFound
}

// PlanConflictError is returned when a plan conflicts with another plan
// that is currently being applied or was recently applied.
type PlanConflictError struct {
Expand Down
19 changes: 19 additions & 0 deletions platform/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package platform_test

import (
"errors"
"testing"

"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/platform"
)

func TestResourceNotFoundError_IsErrResourceNotFound(t *testing.T) {
structErr := &platform.ResourceNotFoundError{Name: "alpha", Provider: "stub"}
if !errors.Is(structErr, interfaces.ErrResourceNotFound) {
t.Errorf("errors.Is(*ResourceNotFoundError, ErrResourceNotFound) = false; want true after Is method added")
}
if errors.Is(structErr, interfaces.ErrImageNotInRegistry) {
t.Errorf("errors.Is(*ResourceNotFoundError, ErrImageNotInRegistry) = true; want false (different sentinel)")
}
}
Loading