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
20 changes: 19 additions & 1 deletion cmd/wfctl/iac_typed_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -811,11 +811,29 @@ func (d *typedResourceDriver) Read(ctx context.Context, ref interfaces.ResourceR
}

func (d *typedResourceDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
return d.update(ctx, ref, spec, nil)
}

func (d *typedResourceDriver) UpdateWithState(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
if err := interfaces.ValidateUpdatePriorState(ref, spec, prior); err != nil {
return nil, err
}
return d.update(ctx, ref, spec, prior)
}

func (d *typedResourceDriver) update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
pbSpec, err := specToPB(spec)
if err != nil {
return nil, fmt.Errorf("typed driver %s: encode Update spec: %w", d.resourceType, err)
}
resp, err := d.client.Update(ctx, &pb.ResourceUpdateRequest{ResourceType: d.resourceType, Ref: refToPB(ref), Spec: pbSpec})
var pbPrior *pb.ResourceState
if prior != nil {
pbPrior, err = stateToPB(prior)
if err != nil {
return nil, fmt.Errorf("typed driver: encode Update prior state: %w", err)
}
}
resp, err := d.client.Update(ctx, &pb.ResourceUpdateRequest{ResourceType: d.resourceType, Ref: refToPB(ref), Spec: pbSpec, PriorState: pbPrior})
if err != nil {
return nil, err
}
Expand Down
73 changes: 73 additions & 0 deletions cmd/wfctl/iac_typed_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"context"
"errors"
"net"
"reflect"
"strings"
"testing"

Expand All @@ -33,8 +34,80 @@ import (

"github.com/GoCodeAlone/workflow/interfaces"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"github.com/GoCodeAlone/workflow/plugin/external/sdk"
)

func TestTypedResourceDriver_UpdateWithState_OptionalContract(t *testing.T) {
driver := &typedResourceDriver{resourceType: "infra.fixture"}
if _, ok := any(driver).(interfaces.ResourceStateUpdater); !ok {
t.Fatal("wfctl driver omits the optional state-aware update contract")
}
}

type updateStateTypedProvider struct {
pb.UnimplementedIaCProviderRequiredServer
pb.UnimplementedResourceDriverServer
prior *interfaces.ResourceState
stateCalls int
legacyCalls int
}

func (p *updateStateTypedProvider) Update(_ context.Context, req *pb.ResourceUpdateRequest) (*pb.ResourceUpdateResponse, error) {
p.legacyCalls++
return &pb.ResourceUpdateResponse{Output: &pb.ResourceOutput{Name: req.GetRef().GetName(), Type: req.GetRef().GetType(), ProviderId: req.GetRef().GetProviderId()}}, nil
}

func (p *updateStateTypedProvider) UpdateWithState(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
if prior == nil {
p.legacyCalls++
return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID}, nil
}
p.stateCalls++
p.prior = prior
return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID, Outputs: prior.Outputs}, nil
}

func TestTypedResourceDriver_UpdateWithState_SDKRoundTrip(t *testing.T) {
provider := &updateStateTypedProvider{}
srv := grpc.NewServer()
if err := sdk.RegisterAllIaCProviderServices(srv, provider); err != nil {
t.Fatal(err)
}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = lis.Close() })
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
adapter := newTypedIaCAdapter(conn, map[string]bool{iacServiceResourceDriver: true})
driver, err := adapter.ResourceDriver("infra.fixture")
if err != nil {
t.Fatal(err)
}
prior := &interfaces.ResourceState{ID: "fixture", Name: "fixture", Type: "infra.fixture", Provider: "fixture", ProviderRef: "provider-instance", ProviderID: "fixture-id", ConfigHash: "fixture-hash", AppliedConfigSource: "apply", Dependencies: []string{"dependency"}, AppliedConfig: map[string]any{"credential_ref": "secrets://fixture/credential"}, Outputs: map[string]any{"rotation_generation": float64(9)}}
ref := interfaces.ResourceRef{Name: prior.Name, Type: prior.Type, ProviderID: prior.ProviderID}
spec := interfaces.ResourceSpec{Name: prior.Name, Type: prior.Type}
updater := driver.(interfaces.ResourceStateUpdater)
out, err := updater.UpdateWithState(t.Context(), ref, spec, prior)
if err != nil || out == nil || !reflect.DeepEqual(provider.prior, prior) || !reflect.DeepEqual(out.Outputs, prior.Outputs) || provider.stateCalls != 1 || provider.legacyCalls != 0 {
t.Fatalf("wfctl/SDK state round trip failed: err=%v state_calls=%d legacy_calls=%d", err, provider.stateCalls, provider.legacyCalls)
}
wrong := *prior
wrong.ProviderID = "other-resource"
if _, err := updater.UpdateWithState(t.Context(), ref, spec, &wrong); !errors.Is(err, interfaces.ErrValidation) || provider.stateCalls != 1 {
t.Fatal("mismatched state reached the wfctl Update RPC")
}
if _, err := driver.Update(t.Context(), ref, spec); err != nil || provider.legacyCalls != 1 || provider.stateCalls != 1 {
t.Fatal("wfctl legacy Update did not preserve absent-state dispatch")
}
}

// TestTypedAdapter_SatisfiesIaCProvider asserts the adapter's Go
// interface conformance at runtime so refactors that drop a method
// fail tests rather than relying on the package-scope compile guard.
Expand Down
23 changes: 23 additions & 0 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,29 @@ func statePersistenceHooks(
cleanupOptions ...cleanupProviderOptions,
) wfctlhelpers.ApplyPlanHooks {
hooks := wfctlhelpers.ApplyPlanHooks{
LookupCurrentState: func(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceState, error) {
states, err := store.ListResources(ctx)
if err != nil {
return nil, err
}
var prior *interfaces.ResourceState
for i := range states {
if states[i].Name != ref.Name {
continue
}
if prior != nil {
return nil, fmt.Errorf("%w: update prior resource identity is ambiguous", interfaces.ErrValidation)
}
prior = &states[i]
}
if prior == nil {
return nil, fmt.Errorf("%w: update prior resource state unavailable", interfaces.ErrResourceNotFound)
}
if err := interfaces.ValidateUpdatePriorState(ref, interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type}, prior); err != nil {
return nil, err
}
return prior, nil
},
ResolveSecret: func(ctx context.Context, key string) (string, error) {
if value, ok := hydratedOut[key]; ok {
return value, nil
Expand Down
131 changes: 131 additions & 0 deletions cmd/wfctl/infra_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -18,6 +19,136 @@ import (
"github.com/GoCodeAlone/workflow/interfaces"
)

type updatePriorCLIDriver struct {
readDriver
prior *interfaces.ResourceState
calls int
}

func (d *updatePriorCLIDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
return nil, errors.New("state-aware CLI fixture used legacy Update")
}

func (d *updatePriorCLIDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) {
return nil, errors.New("saved-plan fixture called Diff")
}

func (d *updatePriorCLIDriver) UpdateWithState(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
d.calls++
d.prior = prior
return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID, Outputs: prior.Outputs}, nil
}

type updatePriorCLIProvider struct {
applyCapture
driver *updatePriorCLIDriver
}

func (p *updatePriorCLIProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) {
return p.driver, nil
}

// The durable store advances immediately after the initial reference snapshot.
// The update must reread it rather than reuse that captured slice.
type advancingPriorStateStore struct {
infraStateStore
latest interfaces.ResourceState
lists int
}

func (s *advancingPriorStateStore) ListResources(ctx context.Context) ([]interfaces.ResourceState, error) {
s.lists++
states, err := s.infraStateStore.ListResources(ctx)
if err == nil && s.lists == 1 {
err = s.SaveResource(ctx, s.latest)
}
return states, err
}

func TestApplyPrecomputedPlan_UpdateWithState_LatestPersistedState(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "state.yaml")
stateDir := filepath.Join(dir, "state")
configData := fmt.Sprintf("modules:\n - name: state\n type: iac.state\n config:\n backend: filesystem\n directory: %q\n", stateDir)
if err := os.WriteFile(configFile, []byte(configData), 0o600); err != nil {
t.Fatal(err)
}
store, err := resolveStateStore(configFile, "")
if err != nil {
t.Fatal(err)
}
stale := interfaces.ResourceState{ID: "fixture", Name: "fixture", Type: "infra.fixture", Provider: "fixture", ProviderID: "fixture-id", Outputs: map[string]any{"rotation_generation": float64(1)}}
encoded, err := json.Marshal(interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "update", Resource: interfaces.ResourceSpec{Name: stale.Name, Type: stale.Type}, Current: &stale}}})
if err != nil {
t.Fatal(err)
}
current := stale
current.Outputs = map[string]any{"rotation_generation": float64(7)}
if err := store.SaveResource(t.Context(), current); err != nil {
t.Fatal(err)
}
restarted, err := resolveStateStore(configFile, "")
if err != nil {
t.Fatal(err)
}
latest := stale
latest.Outputs = map[string]any{"rotation_generation": float64(9)}
advancing := &advancingPriorStateStore{infraStateStore: restarted, latest: latest}
var plan interfaces.IaCPlan
if err := json.Unmarshal(encoded, &plan); err != nil {
t.Fatal(err)
}
driver := &updatePriorCLIDriver{}
provider := &updatePriorCLIProvider{driver: driver}
if err := applyPrecomputedPlanWithStore(t.Context(), plan, provider, "fixture", advancing, io.Discard, "", "", nil); err != nil {
t.Fatal(err)
}
if driver.calls != 1 || driver.prior == nil || driver.prior.Outputs["rotation_generation"] != float64(9) || advancing.lists < 2 {
t.Fatal("saved-plan CLI update reused a stale plan or captured reference-state slice")
}
}

func TestStatePersistenceHooks_UpdateWithState_RejectsMissingOrWrongIdentity(t *testing.T) {
for _, name := range []string{"missing", "wrong_name", "wrong_type", "wrong_id", "duplicate"} {
t.Run(name, func(t *testing.T) {
state := interfaces.ResourceState{Name: "fixture", Type: "infra.fixture", ProviderID: "fixture-id"}
store := &fakeStateStore{}
switch name {
case "missing":
case "wrong_name":
state.Name = "other"
case "wrong_type":
state.Type = "infra.other"
case "wrong_id":
state.ProviderID = "other-id"
case "duplicate":
// A named backend can return ambiguous records; reject them.
}
if name != "missing" {
store.saved = []interfaces.ResourceState{state}
}
var lookupStore infraStateStore = store
if name == "duplicate" {
lookupStore = &duplicatePriorStateStore{state: state}
}
hooks := statePersistenceHooks(lookupStore, nil, &applyCapture{}, "fixture", "fixture-plan", nil)
_, err := hooks.LookupCurrentState(t.Context(), interfaces.ResourceRef{Name: "fixture", Type: "infra.fixture", ProviderID: "fixture-id"})
if err == nil {
t.Fatal("missing or mismatched state was accepted")
}
})
}
}

type duplicatePriorStateStore struct {
noopStateStore
state interfaces.ResourceState
}

func (s duplicatePriorStateStore) ListResources(context.Context) ([]interfaces.ResourceState, error) {
return []interfaces.ResourceState{s.state, s.state}, nil
}

// ── fakes ──────────────────────────────────────────────────────────────────────

// applyCapture is a minimal IaCProvider that records Plan and Apply calls.
Expand Down
20 changes: 20 additions & 0 deletions docs/WFCTL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2124,6 +2124,26 @@ Provider plugins map ownership to their native mechanism, such as `managed-by:<o

Reconcile cloud infrastructure to match the desired state declared in the config. Computes a diff plan via each `iac.provider` and dispatches creates/updates/replaces/deletes through the loaded provider plugin. State is persisted after every successful action so the next run sees the cloud-truth.

State-aware resource updates reread the configured state backend at dispatch
time, including when `--plan` skips `ComputePlan`. Neither the saved plan's
`Current` record nor an earlier `Diff` supplies authoritative update state.
Missing, ambiguous, or mismatched resource identity prevents the update before
provider mutation. Persisted config and outputs remain reference-only; this
does not persist or reconstruct credential values.

Plugin authors can opt into `interfaces.ResourceStateUpdater.UpdateWithState`
without changing the existing `ResourceDriver.Update` interface. The typed
Update RPC carries additive `prior_state`; both wfctl and the reusable
providerclient adapter support it. The SDK detects `ResourceStateUpdater` on
the typed provider service and dispatches that method after identity validation.
Its other RPC methods are unchanged. Legacy providers retain their original
Update dispatch. A mixed provider's native updater may receive nil state from
a legacy RPC and delegate non-state-dependent updates. It must reject updates
that lack required prior state rather than using a private `Diff` cache.
Existing public adapter callers without `LookupCurrentState` retain the legacy
Update path; a state-dependent provider must reject that call before mutation.
Supplying the hook enables strict prior-state lookup and identity validation.

Provider-declared sensitive input paths must contain references, not literal
credentials. Plans, hashes, persistence hooks, and applied config retain those
references. Only the driver receives a deep runtime-resolved copy, including
Expand Down
20 changes: 20 additions & 0 deletions iac/providerclient/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,34 @@ func (r *resourceDriverAdapter) Read(ctx context.Context, ref interfaces.Resourc

// Update calls ResourceDriver.Update with the resource ref and desired spec.
func (r *resourceDriverAdapter) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
return r.update(ctx, ref, spec, nil)
}

// UpdateWithState sends dispatch-time persisted state on the existing Update RPC.
func (r *resourceDriverAdapter) UpdateWithState(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
if err := interfaces.ValidateUpdatePriorState(ref, spec, prior); err != nil {
return nil, err
}
return r.update(ctx, ref, spec, prior)
}

func (r *resourceDriverAdapter) update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) {
pbSpec, err := specToPB(spec)
if err != nil {
return nil, fmt.Errorf("providerclient: encode Update spec: %w", err)
}
var pbPrior *pb.ResourceState
if prior != nil {
pbPrior, err = stateToPB(prior)
if err != nil {
return nil, fmt.Errorf("providerclient: encode Update prior state: %w", err)
}
}
resp, err := r.client.Update(ctx, &pb.ResourceUpdateRequest{
ResourceType: r.resourceType,
Ref: refToPB(ref),
Spec: pbSpec,
PriorState: pbPrior,
})
if err != nil {
return nil, mapResourceDriverGRPCError(err, "Update")
Expand Down
34 changes: 34 additions & 0 deletions iac/providerclient/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ import (
// Compile-time guard: *Adapter must satisfy interfaces.IaCProvider.
var _ interfaces.IaCProvider = (*providerclient.Adapter)(nil)

func TestAdapter_UpdateWithState_OptionalContract(t *testing.T) {
srv := grpc.NewServer()
server := &fakeResourceDriverServer{}
pb.RegisterResourceDriverServer(srv, server)
adapter := providerclient.New(startFakeServer(t, srv), map[string]bool{providerclient.IaCServiceResourceDriver: true})
driver, err := adapter.ResourceDriver("infra.fixture")
if err != nil {
t.Fatal(err)
}
updater, ok := driver.(interfaces.ResourceStateUpdater)
if !ok {
t.Fatal("providerclient driver omits the optional state-aware update contract")
}
prior := &interfaces.ResourceState{Name: "fixture", Type: "infra.fixture", ProviderID: "fixture-id", Outputs: map[string]any{"rotation_generation": float64(7)}}
_, err = updater.UpdateWithState(t.Context(), interfaces.ResourceRef{Name: prior.Name, Type: prior.Type, ProviderID: prior.ProviderID}, interfaces.ResourceSpec{Name: prior.Name, Type: prior.Type}, prior)
if err != nil || server.lastUpdateReq == nil {
t.Fatalf("state-aware Update RPC failed: %v", err)
}
state := server.lastUpdateReq.GetPriorState()
var outputs map[string]any
if state == nil || json.Unmarshal(state.GetOutputsJson(), &outputs) != nil || outputs["rotation_generation"] != float64(7) || state.GetProviderId() != prior.ProviderID || state.GetName() != prior.Name || state.GetType() != prior.Type {
t.Fatal("Update RPC discarded prior state")
}
server.lastUpdateReq = nil
wrong := *prior
wrong.ProviderID = "other-resource"
if _, err := updater.UpdateWithState(t.Context(), interfaces.ResourceRef{Name: prior.Name, Type: prior.Type, ProviderID: prior.ProviderID}, interfaces.ResourceSpec{Name: prior.Name, Type: prior.Type}, &wrong); !errors.Is(err, interfaces.ErrValidation) || server.lastUpdateReq != nil {
t.Fatal("mismatched state reached the providerclient Update RPC")
}
if _, err := driver.Update(t.Context(), interfaces.ResourceRef{Name: prior.Name, Type: prior.Type, ProviderID: prior.ProviderID}, interfaces.ResourceSpec{Name: prior.Name, Type: prior.Type}); err != nil || server.lastUpdateReq == nil || server.lastUpdateReq.GetPriorState() != nil {
t.Fatal("providerclient legacy Update did not preserve absent-state RPC")
}
}

// *Adapter must NOT unconditionally satisfy interfaces.IaCProviderRegionLister —
// that interface is gated behind the RegionListerProvider accessor. The
// negative assertion is enforced by TestAdapter_RegionLister_Unadvertised below
Expand Down
Loading
Loading