From f756a7354c08c21030c94c533bb01141334a6b7c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 2 Oct 2026 23:01:54 -0400 Subject: [PATCH 1/5] feat(iac): declare sensitive input paths Discover sensitive inputs through an optional typed gRPC service. Validate real leaf values without custom lookup dispatch; reject literal credentials before provider execution. --- buf.yaml | 15 + go.mod | 2 +- iac/providerclient/adapter.go | 49 +- iac/sensitiveinputs/sensitiveinputs.go | 154 ++++++ iac/sensitiveinputs/sensitiveinputs_test.go | 113 ++++ interfaces/iac_resource_driver.go | 1 + interfaces/iac_sensitive_inputs.go | 13 + interfaces/iac_sensitive_inputs_test.go | 28 + plugin/external/proto/iac.pb.go | 526 +++++++++++-------- plugin/external/proto/iac.proto | 15 + plugin/external/proto/iac_grpc.pb.go | 111 ++++ plugin/external/sdk/iacserver.go | 4 + plugin/external/sdk/sensitive_inputs_test.go | 92 ++++ wftest/bdd/strict_iac.go | 4 + 14 files changed, 902 insertions(+), 225 deletions(-) create mode 100644 iac/sensitiveinputs/sensitiveinputs.go create mode 100644 iac/sensitiveinputs/sensitiveinputs_test.go create mode 100644 interfaces/iac_sensitive_inputs.go create mode 100644 interfaces/iac_sensitive_inputs_test.go create mode 100644 plugin/external/sdk/sensitive_inputs_test.go diff --git a/buf.yaml b/buf.yaml index a02c59eb..61bd5bfd 100644 --- a/buf.yaml +++ b/buf.yaml @@ -9,6 +9,21 @@ lint: - RPC_REQUEST_RESPONSE_UNIQUE - RPC_REQUEST_STANDARD_NAME - RPC_RESPONSE_STANDARD_NAME + # Preserve established public wire names and the colocated contract files. + ignore_only: + DIRECTORY_SAME_PACKAGE: + - plugin/external/proto/iac.proto + - plugin/external/proto/plugin.proto + - plugin/external/proto/sandbox_exec.proto + PACKAGE_VERSION_SUFFIX: + - plugin/external/proto/iac.proto + - plugin/external/proto/sandbox_exec.proto + SERVICE_SUFFIX: + - plugin/external/proto/iac.proto + ENUM_ZERO_VALUE_SUFFIX: + - plugin/external/proto/iac.proto + ENUM_VALUE_PREFIX: + - plugin/external/proto/iac.proto breaking: use: - FILE diff --git a/go.mod b/go.mod index 474f8f6f..ad956ec7 100644 --- a/go.mod +++ b/go.mod @@ -128,7 +128,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.24.0 // indirect + github.com/go-openapi/jsonpointer v0.24.0 github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/swag v0.27.0 // indirect github.com/go-openapi/swag/cmdutils v0.27.0 // indirect diff --git a/iac/providerclient/adapter.go b/iac/providerclient/adapter.go index 1764a3ff..c288b385 100644 --- a/iac/providerclient/adapter.go +++ b/iac/providerclient/adapter.go @@ -26,6 +26,7 @@ import ( "sync" "time" + "github.com/GoCodeAlone/workflow/iac/sensitiveinputs" "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "google.golang.org/grpc" @@ -54,6 +55,8 @@ const ( // returns a per-resource-type bridge that routes Create/Read/Update/Delete/ // Diff/HealthCheck/Scale/SensitiveKeys through the plugin's gRPC process. IaCServiceResourceDriver = "workflow.plugin.external.iac.ResourceDriver" + // IaCServiceSensitiveInputDeclarer declares sensitive Config leaf pointers. + IaCServiceSensitiveInputDeclarer = "workflow.plugin.external.iac.ResourceSensitiveInputDeclarer" ) // RegionListerProvider is a capability-discovery interface implemented by @@ -214,8 +217,29 @@ type ResourceDriverProvider interface { // resource_type field is carried on every RPC so the plugin can route to the // correct per-type implementation (the DO plugin's 14-driver pattern). type resourceDriverAdapter struct { - client pb.ResourceDriverClient - resourceType string + client pb.ResourceDriverClient + resourceType string + sensitiveInputs pb.ResourceSensitiveInputDeclarerClient +} + +var _ interfaces.ResourceSensitiveInputDeclarer = (*resourceDriverAdapter)(nil) + +func (r *resourceDriverAdapter) SensitiveInputPaths(ctx context.Context) ([]string, error) { + if r.sensitiveInputs == nil { + return nil, fmt.Errorf("%w: ResourceSensitiveInputDeclarer not advertised", interfaces.ErrProviderMethodUnimplemented) + } + resp, err := r.sensitiveInputs.SensitiveInputPaths(ctx, &pb.ResourceSensitiveInputPathsRequest{ResourceType: r.resourceType}) + if err != nil { + if status.Code(err) == codes.Unimplemented { + return nil, fmt.Errorf("%w: ResourceSensitiveInputDeclarer not implemented", interfaces.ErrProviderMethodUnimplemented) + } + return nil, err + } + paths := append([]string(nil), resp.GetPaths()...) + if err := sensitiveinputs.ValidatePaths(paths); err != nil { + return nil, err + } + return paths, nil } // Create calls ResourceDriver.Create with JSON-encoded spec.Config. @@ -511,12 +535,13 @@ var ( // // Compile-time guards are in adapter_test.go. type Adapter struct { - conn grpc.ClientConnInterface - required pb.IaCProviderRequiredClient - regionLister *regionListerImpl // nil when IaCServiceRegionLister not advertised - drift *driftDetectorAdapter // nil when IaCServiceDriftDetector not advertised - runner *runnerAdapter // nil when IaCServiceRunner not advertised - resourceDriver pb.ResourceDriverClient // nil when IaCServiceResourceDriver not advertised + conn grpc.ClientConnInterface + required pb.IaCProviderRequiredClient + regionLister *regionListerImpl // nil when IaCServiceRegionLister not advertised + drift *driftDetectorAdapter // nil when IaCServiceDriftDetector not advertised + runner *runnerAdapter // nil when IaCServiceRunner not advertised + resourceDriver pb.ResourceDriverClient // nil when IaCServiceResourceDriver not advertised + sensitiveInputs pb.ResourceSensitiveInputDeclarerClient // Capabilities cache. Populated on first call to fetchCapabilities via // capsOnce; reused for the adapter's lifetime (capabilities don't change @@ -577,6 +602,9 @@ func New(conn grpc.ClientConnInterface, advertisedServices map[string]bool) *Ada if advertisedServices[IaCServiceResourceDriver] { a.resourceDriver = pb.NewResourceDriverClient(conn) } + if advertisedServices[IaCServiceSensitiveInputDeclarer] { + a.sensitiveInputs = pb.NewResourceSensitiveInputDeclarerClient(conn) + } return a } @@ -769,8 +797,9 @@ func (a *Adapter) ResourceDriver(resourceType string) (interfaces.ResourceDriver interfaces.ErrProviderMethodUnimplemented) } return &resourceDriverAdapter{ - client: a.resourceDriver, - resourceType: resourceType, + client: a.resourceDriver, + resourceType: resourceType, + sensitiveInputs: a.sensitiveInputs, }, nil } diff --git a/iac/sensitiveinputs/sensitiveinputs.go b/iac/sensitiveinputs/sensitiveinputs.go new file mode 100644 index 00000000..44d379c0 --- /dev/null +++ b/iac/sensitiveinputs/sensitiveinputs.go @@ -0,0 +1,154 @@ +// Package sensitiveinputs validates provider-declared sensitive Config leaves. +package sensitiveinputs + +import ( + "fmt" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" + "github.com/go-openapi/jsonpointer" +) + +var referencePattern = regexp.MustCompile(`^\$\{[^{}[:space:]]+\}$`) + +// ValidatePaths requires non-root canonical RFC 6901 pointers. Escapes are +// validated by re-encoding the parser's decoded tokens; malformed '~' escapes +// must not alias an otherwise valid key. +func ValidatePaths(paths []string) error { + for _, path := range paths { + p, err := jsonpointer.New(path) + if err != nil || p.IsEmpty() { + return fmt.Errorf("%w: sensitive input path must be a non-root JSON pointer", interfaces.ErrValidation) + } + var canonical strings.Builder + for _, token := range p.DecodedTokens() { + canonical.WriteByte('/') + canonical.WriteString(jsonpointer.Escape(token)) + } + if canonical.String() != path { + return fmt.Errorf("%w: sensitive input path has noncanonical escaping", interfaces.ErrValidation) + } + } + return nil +} + +// Expand returns deterministic unique concrete leaf pointers. Missing optional +// fields have no match; present containers or scalar traversal are errors. +func Expand(config map[string]any, paths []string) ([]string, error) { + var result []string + err := walkLeaves(config, paths, func(path string, _ reflect.Value) error { + result = append(result, path) + return nil + }) + if err != nil { + return nil, err + } + slices.Sort(result) + return slices.Compact(result), nil +} + +func walkLeaves(config map[string]any, paths []string, visitLeaf func(string, reflect.Value) error) error { + if err := ValidatePaths(paths); err != nil { + return err + } + for _, path := range paths { + p, _ := jsonpointer.New(path) // Already validated above. + if err := expand(reflect.ValueOf(config), p.DecodedTokens(), "", visitLeaf); err != nil { + return err + } + } + return nil +} + +func expand(node reflect.Value, tokens []string, path string, visitLeaf func(string, reflect.Value) error) error { + for node.IsValid() && node.Kind() == reflect.Interface { + node = node.Elem() + } + if len(tokens) == 0 { + if node.IsValid() && (node.Kind() == reflect.Map || node.Kind() == reflect.Slice || node.Kind() == reflect.Array) { + return fmt.Errorf("%w: sensitive input %s is not a leaf", interfaces.ErrValidation, path) + } + return visitLeaf(path, node) + } + if !node.IsValid() { + return fmt.Errorf("%w: sensitive input %s traverses a null value", interfaces.ErrValidation, path) + } + segment := tokens[0] + visit := func(key string, child reflect.Value) error { + return expand(child, tokens[1:], path+"/"+jsonpointer.Escape(key), visitLeaf) + } + switch node.Kind() { + case reflect.Map: + if node.Type().Key().Kind() != reflect.String { + return fmt.Errorf("%w: sensitive input %s requires string map keys", interfaces.ErrValidation, path) + } + if segment == "*" { + keys := make([]string, 0, node.Len()) + for _, key := range node.MapKeys() { + keys = append(keys, key.String()) + } + slices.Sort(keys) + for _, key := range keys { + if err := visit(key, node.MapIndex(reflect.ValueOf(key).Convert(node.Type().Key()))); err != nil { + return err + } + } + return nil + } + child := node.MapIndex(reflect.ValueOf(segment).Convert(node.Type().Key())) + if !child.IsValid() { + return nil + } + return visit(segment, child) + case reflect.Slice, reflect.Array: + if segment == "*" { + for i := 0; i < node.Len(); i++ { + if err := visit(strconv.Itoa(i), node.Index(i)); err != nil { + return err + } + } + return nil + } + index, err := strconv.Atoi(segment) + if err != nil || index < 0 || strconv.Itoa(index) != segment { + return fmt.Errorf("%w: sensitive input %s has a noncanonical list index", interfaces.ErrValidation, path) + } + if index >= node.Len() { + return nil + } + return visit(segment, node.Index(index)) + default: + return fmt.Errorf("%w: sensitive input %s traverses a scalar", interfaces.ErrValidation, path) + } +} + +// ValidateReferences rejects literal sensitive values without including them +// in diagnostics. It validates declarative inputs, never consumer artifacts. +func ValidateReferences(config map[string]any, paths []string) error { + return walkLeaves(config, paths, func(path string, value reflect.Value) error { + if !value.IsValid() || value.Kind() != reflect.String || !isReference(value.String()) { + return fmt.Errorf("%w: sensitive input %s must be a reference", interfaces.ErrValidation, path) + } + return nil + }) +} + +func isReference(s string) bool { + for _, prefix := range []string{secrets.SecretPrefix, sensitive.PlaceholderPrefix} { + if strings.HasPrefix(s, prefix) { + return len(s) > len(prefix) && !strings.ContainsAny(s, "\r\n\t ") + } + } + if !referencePattern.MatchString(s) { + return false + } + body := s[2 : len(s)-1] + module, field, dotted := strings.Cut(body, ".") + return !dotted || (module != "" && field != "") +} diff --git a/iac/sensitiveinputs/sensitiveinputs_test.go b/iac/sensitiveinputs/sensitiveinputs_test.go new file mode 100644 index 00000000..3c69c967 --- /dev/null +++ b/iac/sensitiveinputs/sensitiveinputs_test.go @@ -0,0 +1,113 @@ +package sensitiveinputs + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +type sensitiveInputKey string +type sensitiveInputReference string +type misleadingSensitiveInputLookup map[string]any + +func (misleadingSensitiveInputLookup) JSONLookup(string) (any, error) { + return "${TOKEN}", nil +} + +func TestSensitiveInputPathsCanonical(t *testing.T) { + for _, path := range []string{"/password", "/env/*/value", "/a~1b/~0key", "/", "/list/0"} { + if err := ValidatePaths([]string{path}); err != nil { + t.Errorf("valid path %q: %v", path, err) + } + } + for _, path := range []string{"", "password", "#/password", "/bad~", "/bad~2"} { + if err := ValidatePaths([]string{path}); err == nil { + t.Errorf("noncanonical path %q accepted", path) + } + } +} + +func TestSensitiveInputPathsExpandNestedMapsAndLists(t *testing.T) { + config := map[string]any{ + "env": []any{ + map[string]any{"value": "${FIRST}"}, + map[string]any{"value": "${SECOND}"}, + }, + "a/b": map[string]string{"~key": "secret://key"}, + "credentials": map[string]any{ + "z": map[string]any{"token": "${LAST}"}, + "a": map[string]any{"token": "${FIRST}"}, + }, + } + got, err := Expand(config, []string{"/env/*/value", "/a~1b/~0key", "/credentials/*/token", "/absent", "/env/0/value"}) + if err != nil { + t.Fatal(err) + } + want := []string{"/a~1b/~0key", "/credentials/a/token", "/credentials/z/token", "/env/0/value", "/env/1/value"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("paths=%v want=%v", got, want) + } +} + +func TestSensitiveInputPathsRejectNonLeavesAndBadListIndexes(t *testing.T) { + config := map[string]any{"env": []any{map[string]any{"value": "${TOKEN}"}}, "literal": "known-sensitive-marker"} + for _, path := range []string{"/env", "/env/*", "/env/01/value", "/env/-/value", "/literal/child"} { + _, err := Expand(config, []string{path}) + if err == nil { + t.Errorf("path %q accepted non-leaf or malformed traversal", path) + } else if strings.Contains(err.Error(), "known-sensitive-marker") { + t.Fatalf("path error contains consumer value: %v", err) + } + } +} + +func TestSensitiveInputReferencesRejectLiteralsWithoutLeaking(t *testing.T) { + for _, value := range []any{"${TOKEN}", "${database.uri}", "secret://scope/key", "secret_ref://KEY"} { + if err := ValidateReferences(map[string]any{"password": value}, []string{"/password"}); err != nil { + t.Errorf("reference %q: %v", value, err) + } + } + for _, value := range []any{"known-sensitive-marker", "", "${}", "${.uri}", "${database.}", "${TOKEN}known-sensitive-marker", "secret://", 7, nil} { + err := ValidateReferences(map[string]any{"password": value}, []string{"/password"}) + if err == nil { + t.Errorf("sensitive literal %q accepted", value) + } else if strings.Contains(err.Error(), "known-sensitive-marker") { + t.Fatalf("validation error leaked sensitive bytes: %v", err) + } + } + if err := ValidateReferences(map[string]any{}, []string{"/password"}); err != nil { + t.Fatalf("absent optional input: %v", err) + } +} + +func TestSensitiveInputReferencesValidateActualNativeLeaves(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]any + path string + }{ + {"defined map key", map[string]any{"env": map[sensitiveInputKey]string{"token": "${TOKEN}"}}, "/env/token"}, + {"fixed array", map[string]any{"env": [1]string{"${TOKEN}"}}, "/env/*"}, + {"defined reference string", map[string]any{"password": sensitiveInputReference("${TOKEN}")}, "/password"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateReferences(tc.config, []string{tc.path}); err != nil { + t.Fatalf("valid native reference shape rejected: %v", err) + } + }) + } +} + +func TestSensitiveInputReferencesCannotUseCustomLookupToHideLiteral(t *testing.T) { + const marker = "private-native-literal" + config := map[string]any{"env": misleadingSensitiveInputLookup{"token": marker}} + data, err := json.Marshal(config) + if err != nil || !strings.Contains(string(data), marker) { + t.Fatalf("fixture must serialize its actual stored literal: %s err=%v", data, err) + } + err = ValidateReferences(config, []string{"/env/token"}) + if err == nil || strings.Contains(err.Error(), marker) { + t.Fatalf("custom lookup approved or exposed stored literal: %v", err) + } +} diff --git a/interfaces/iac_resource_driver.go b/interfaces/iac_resource_driver.go index 8a297586..debd9b39 100644 --- a/interfaces/iac_resource_driver.go +++ b/interfaces/iac_resource_driver.go @@ -86,6 +86,7 @@ type ResourceDriver interface { HealthCheck(ctx context.Context, ref ResourceRef) (*HealthResult, error) Scale(ctx context.Context, ref ResourceRef, replicas int) (*ResourceOutput, error) // SensitiveKeys returns output keys whose values should be masked in logs and plan output. + // Sensitive Config inputs use the optional ResourceSensitiveInputDeclarer. SensitiveKeys() []string } diff --git a/interfaces/iac_sensitive_inputs.go b/interfaces/iac_sensitive_inputs.go new file mode 100644 index 00000000..9ab114eb --- /dev/null +++ b/interfaces/iac_sensitive_inputs.go @@ -0,0 +1,13 @@ +package interfaces + +import "context" + +// ResourceSensitiveInputDeclarer is an optional driver capability declaring +// sensitive Config leaves as canonical RFC 6901 pointers. A whole "*" segment +// matches one map key or list index. Declarations contain no secret values. +// The engine validates present leaves as references before runtime resolution. +// Remote drivers return ErrProviderMethodUnimplemented when the capability is +// absent; other errors must stop dispatch rather than omit input validation. +type ResourceSensitiveInputDeclarer interface { + SensitiveInputPaths(ctx context.Context) ([]string, error) +} diff --git a/interfaces/iac_sensitive_inputs_test.go b/interfaces/iac_sensitive_inputs_test.go new file mode 100644 index 00000000..4e2485a1 --- /dev/null +++ b/interfaces/iac_sensitive_inputs_test.go @@ -0,0 +1,28 @@ +package interfaces + +import ( + "context" + "reflect" + "testing" +) + +type sensitiveInputTestDriver struct{ ResourceDriver } + +func (*sensitiveInputTestDriver) SensitiveInputPaths(context.Context) ([]string, error) { + return []string{"/password", "/env/*/value"}, nil +} + +func TestResourceSensitiveInputDeclarerIsOptional(t *testing.T) { + if _, required := reflect.TypeFor[ResourceDriver]().MethodByName("SensitiveInputPaths"); required { + t.Fatal("sensitive input declarations must not break existing ResourceDriver implementations") + } + var driver ResourceDriver = &sensitiveInputTestDriver{} + declarer, ok := driver.(ResourceSensitiveInputDeclarer) + if !ok { + t.Fatal("opt-in driver must satisfy ResourceSensitiveInputDeclarer") + } + paths, err := declarer.SensitiveInputPaths(t.Context()) + if err != nil || len(paths) != 2 { + t.Fatalf("paths=%v err=%v", paths, err) + } +} diff --git a/plugin/external/proto/iac.pb.go b/plugin/external/proto/iac.pb.go index fcc665b5..80e40738 100644 --- a/plugin/external/proto/iac.pb.go +++ b/plugin/external/proto/iac.pb.go @@ -7577,6 +7577,94 @@ func (x *ListBackendNamesResponse) GetBackendNames() []string { return nil } +type ResourceSensitiveInputPathsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResourceType string `protobuf:"bytes,1,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceSensitiveInputPathsRequest) Reset() { + *x = ResourceSensitiveInputPathsRequest{} + mi := &file_iac_proto_msgTypes[123] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceSensitiveInputPathsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceSensitiveInputPathsRequest) ProtoMessage() {} + +func (x *ResourceSensitiveInputPathsRequest) ProtoReflect() protoreflect.Message { + mi := &file_iac_proto_msgTypes[123] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceSensitiveInputPathsRequest.ProtoReflect.Descriptor instead. +func (*ResourceSensitiveInputPathsRequest) Descriptor() ([]byte, []int) { + return file_iac_proto_rawDescGZIP(), []int{123} +} + +func (x *ResourceSensitiveInputPathsRequest) GetResourceType() string { + if x != nil { + return x.ResourceType + } + return "" +} + +type ResourceSensitiveInputPathsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Paths []string `protobuf:"bytes,1,rep,name=paths,proto3" json:"paths,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceSensitiveInputPathsResponse) Reset() { + *x = ResourceSensitiveInputPathsResponse{} + mi := &file_iac_proto_msgTypes[124] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceSensitiveInputPathsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceSensitiveInputPathsResponse) ProtoMessage() {} + +func (x *ResourceSensitiveInputPathsResponse) ProtoReflect() protoreflect.Message { + mi := &file_iac_proto_msgTypes[124] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceSensitiveInputPathsResponse.ProtoReflect.Descriptor instead. +func (*ResourceSensitiveInputPathsResponse) Descriptor() ([]byte, []int) { + return file_iac_proto_rawDescGZIP(), []int{124} +} + +func (x *ResourceSensitiveInputPathsResponse) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + var File_iac_proto protoreflect.FileDescriptor const file_iac_proto_rawDesc = "" + @@ -8082,7 +8170,11 @@ const file_iac_proto_rawDesc = "" + "\x0eUnlockResponse\"\x19\n" + "\x17ListBackendNamesRequest\"?\n" + "\x18ListBackendNamesResponse\x12#\n" + - "\rbackend_names\x18\x01 \x03(\tR\fbackendNames*m\n" + + "\rbackend_names\x18\x01 \x03(\tR\fbackendNames\"I\n" + + "\"ResourceSensitiveInputPathsRequest\x12#\n" + + "\rresource_type\x18\x01 \x01(\tR\fresourceType\";\n" + + "#ResourceSensitiveInputPathsResponse\x12\x14\n" + + "\x05paths\x18\x01 \x03(\tR\x05paths*m\n" + "\n" + "DriftClass\x12\x17\n" + "\x13DRIFT_CLASS_UNKNOWN\x10\x00\x12\x17\n" + @@ -8211,7 +8303,9 @@ const file_iac_proto_rawDesc = "" + "\vDeleteState\x120.workflow.plugin.external.iac.DeleteStateRequest\x1a1.workflow.plugin.external.iac.DeleteStateResponse\x12]\n" + "\x04Lock\x12).workflow.plugin.external.iac.LockRequest\x1a*.workflow.plugin.external.iac.LockResponse\x12c\n" + "\x06Unlock\x12+.workflow.plugin.external.iac.UnlockRequest\x1a,.workflow.plugin.external.iac.UnlockResponse\x12\x81\x01\n" + - "\x10ListBackendNames\x125.workflow.plugin.external.iac.ListBackendNamesRequest\x1a6.workflow.plugin.external.iac.ListBackendNamesResponseB=Z;github.com/GoCodeAlone/workflow/plugin/external/proto;protob\x06proto3" + "\x10ListBackendNames\x125.workflow.plugin.external.iac.ListBackendNamesRequest\x1a6.workflow.plugin.external.iac.ListBackendNamesResponse2\xbd\x01\n" + + "\x1eResourceSensitiveInputDeclarer\x12\x9a\x01\n" + + "\x13SensitiveInputPaths\x12@.workflow.plugin.external.iac.ResourceSensitiveInputPathsRequest\x1aA.workflow.plugin.external.iac.ResourceSensitiveInputPathsResponseB=Z;github.com/GoCodeAlone/workflow/plugin/external/proto;protob\x06proto3" var ( file_iac_proto_rawDescOnce sync.Once @@ -8226,160 +8320,162 @@ func file_iac_proto_rawDescGZIP() []byte { } var file_iac_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 134) +var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 136) var file_iac_proto_goTypes = []any{ - (DriftClass)(0), // 0: workflow.plugin.external.iac.DriftClass - (RequirementKind)(0), // 1: workflow.plugin.external.iac.RequirementKind - (RequirementRuntime)(0), // 2: workflow.plugin.external.iac.RequirementRuntime - (TelemetrySignal)(0), // 3: workflow.plugin.external.iac.TelemetrySignal - (ObservabilityBackend)(0), // 4: workflow.plugin.external.iac.ObservabilityBackend - (DeploymentMode)(0), // 5: workflow.plugin.external.iac.DeploymentMode - (PlanDiagnosticSeverity)(0), // 6: workflow.plugin.external.iac.PlanDiagnosticSeverity - (ActionStatus)(0), // 7: workflow.plugin.external.iac.ActionStatus - (LogCaptureType)(0), // 8: workflow.plugin.external.iac.LogCaptureType - (JobState)(0), // 9: workflow.plugin.external.iac.JobState - (*ResourceSpec)(nil), // 10: workflow.plugin.external.iac.ResourceSpec - (*ResourceRef)(nil), // 11: workflow.plugin.external.iac.ResourceRef - (*ResourceHints)(nil), // 12: workflow.plugin.external.iac.ResourceHints - (*ProviderSizing)(nil), // 13: workflow.plugin.external.iac.ProviderSizing - (*IaCCapabilityDeclaration)(nil), // 14: workflow.plugin.external.iac.IaCCapabilityDeclaration - (*ResourceState)(nil), // 15: workflow.plugin.external.iac.ResourceState - (*ResourceOutput)(nil), // 16: workflow.plugin.external.iac.ResourceOutput - (*ResourceStatus)(nil), // 17: workflow.plugin.external.iac.ResourceStatus - (*FieldChange)(nil), // 18: workflow.plugin.external.iac.FieldChange - (*DiffResult)(nil), // 19: workflow.plugin.external.iac.DiffResult - (*IaCRequirement)(nil), // 20: workflow.plugin.external.iac.IaCRequirement - (*DiscoverRequirementsRequest)(nil), // 21: workflow.plugin.external.iac.DiscoverRequirementsRequest - (*RequirementContext)(nil), // 22: workflow.plugin.external.iac.RequirementContext - (*ModuleRef)(nil), // 23: workflow.plugin.external.iac.ModuleRef - (*DiscoverRequirementsResponse)(nil), // 24: workflow.plugin.external.iac.DiscoverRequirementsResponse - (*MapRequirementsRequest)(nil), // 25: workflow.plugin.external.iac.MapRequirementsRequest - (*DerivedModuleSpec)(nil), // 26: workflow.plugin.external.iac.DerivedModuleSpec - (*RequirementDiagnostic)(nil), // 27: workflow.plugin.external.iac.RequirementDiagnostic - (*RequirementNote)(nil), // 28: workflow.plugin.external.iac.RequirementNote - (*MapRequirementsResponse)(nil), // 29: workflow.plugin.external.iac.MapRequirementsResponse - (*DriftResult)(nil), // 30: workflow.plugin.external.iac.DriftResult - (*DriftEntry)(nil), // 31: workflow.plugin.external.iac.DriftEntry - (*HealthResult)(nil), // 32: workflow.plugin.external.iac.HealthResult - (*Diagnostic)(nil), // 33: workflow.plugin.external.iac.Diagnostic - (*PlanDiagnostic)(nil), // 34: workflow.plugin.external.iac.PlanDiagnostic - (*PlanAction)(nil), // 35: workflow.plugin.external.iac.PlanAction - (*IaCPlan)(nil), // 36: workflow.plugin.external.iac.IaCPlan - (*ActionError)(nil), // 37: workflow.plugin.external.iac.ActionError - (*DestroyResult)(nil), // 38: workflow.plugin.external.iac.DestroyResult - (*BootstrapResult)(nil), // 39: workflow.plugin.external.iac.BootstrapResult - (*MigrationRepairRequest)(nil), // 40: workflow.plugin.external.iac.MigrationRepairRequest - (*MigrationRepairResult)(nil), // 41: workflow.plugin.external.iac.MigrationRepairResult - (*InitializeRequest)(nil), // 42: workflow.plugin.external.iac.InitializeRequest - (*InitializeResponse)(nil), // 43: workflow.plugin.external.iac.InitializeResponse - (*NameRequest)(nil), // 44: workflow.plugin.external.iac.NameRequest - (*NameResponse)(nil), // 45: workflow.plugin.external.iac.NameResponse - (*VersionRequest)(nil), // 46: workflow.plugin.external.iac.VersionRequest - (*VersionResponse)(nil), // 47: workflow.plugin.external.iac.VersionResponse - (*CapabilitiesRequest)(nil), // 48: workflow.plugin.external.iac.CapabilitiesRequest - (*CapabilitiesResponse)(nil), // 49: workflow.plugin.external.iac.CapabilitiesResponse - (*PlanRequest)(nil), // 50: workflow.plugin.external.iac.PlanRequest - (*PlanResponse)(nil), // 51: workflow.plugin.external.iac.PlanResponse - (*DestroyRequest)(nil), // 52: workflow.plugin.external.iac.DestroyRequest - (*DestroyResponse)(nil), // 53: workflow.plugin.external.iac.DestroyResponse - (*StatusRequest)(nil), // 54: workflow.plugin.external.iac.StatusRequest - (*StatusResponse)(nil), // 55: workflow.plugin.external.iac.StatusResponse - (*ImportRequest)(nil), // 56: workflow.plugin.external.iac.ImportRequest - (*ImportResponse)(nil), // 57: workflow.plugin.external.iac.ImportResponse - (*ResolveSizingRequest)(nil), // 58: workflow.plugin.external.iac.ResolveSizingRequest - (*ResolveSizingResponse)(nil), // 59: workflow.plugin.external.iac.ResolveSizingResponse - (*BootstrapStateBackendRequest)(nil), // 60: workflow.plugin.external.iac.BootstrapStateBackendRequest - (*BootstrapStateBackendResponse)(nil), // 61: workflow.plugin.external.iac.BootstrapStateBackendResponse - (*EnumerateAllRequest)(nil), // 62: workflow.plugin.external.iac.EnumerateAllRequest - (*EnumerateAllResponse)(nil), // 63: workflow.plugin.external.iac.EnumerateAllResponse - (*EnumerateByTagRequest)(nil), // 64: workflow.plugin.external.iac.EnumerateByTagRequest - (*EnumerateByTagResponse)(nil), // 65: workflow.plugin.external.iac.EnumerateByTagResponse - (*DetectDriftRequest)(nil), // 66: workflow.plugin.external.iac.DetectDriftRequest - (*DetectDriftResponse)(nil), // 67: workflow.plugin.external.iac.DetectDriftResponse - (*DetectDriftWithSpecsRequest)(nil), // 68: workflow.plugin.external.iac.DetectDriftWithSpecsRequest - (*DetectDriftWithSpecsResponse)(nil), // 69: workflow.plugin.external.iac.DetectDriftWithSpecsResponse - (*RevokeProviderCredentialRequest)(nil), // 70: workflow.plugin.external.iac.RevokeProviderCredentialRequest - (*RevokeProviderCredentialResponse)(nil), // 71: workflow.plugin.external.iac.RevokeProviderCredentialResponse - (*ListRegionsRequest)(nil), // 72: workflow.plugin.external.iac.ListRegionsRequest - (*ProviderRegion)(nil), // 73: workflow.plugin.external.iac.ProviderRegion - (*ListRegionsResponse)(nil), // 74: workflow.plugin.external.iac.ListRegionsResponse - (*GetOwnerRequest)(nil), // 75: workflow.plugin.external.iac.GetOwnerRequest - (*GetOwnerResponse)(nil), // 76: workflow.plugin.external.iac.GetOwnerResponse - (*SetOwnerRequest)(nil), // 77: workflow.plugin.external.iac.SetOwnerRequest - (*SetOwnerResponse)(nil), // 78: workflow.plugin.external.iac.SetOwnerResponse - (*ListOwnersRequest)(nil), // 79: workflow.plugin.external.iac.ListOwnersRequest - (*OwnedResource)(nil), // 80: workflow.plugin.external.iac.OwnedResource - (*ListOwnersResponse)(nil), // 81: workflow.plugin.external.iac.ListOwnersResponse - (*FinalizeApplyRequest)(nil), // 82: workflow.plugin.external.iac.FinalizeApplyRequest - (*FinalizeApplyResponse)(nil), // 83: workflow.plugin.external.iac.FinalizeApplyResponse - (*RepairDirtyMigrationRequest)(nil), // 84: workflow.plugin.external.iac.RepairDirtyMigrationRequest - (*RepairDirtyMigrationResponse)(nil), // 85: workflow.plugin.external.iac.RepairDirtyMigrationResponse - (*ValidatePlanRequest)(nil), // 86: workflow.plugin.external.iac.ValidatePlanRequest - (*ValidatePlanResponse)(nil), // 87: workflow.plugin.external.iac.ValidatePlanResponse - (*DetectDriftConfigRequest)(nil), // 88: workflow.plugin.external.iac.DetectDriftConfigRequest - (*DetectDriftConfigResponse)(nil), // 89: workflow.plugin.external.iac.DetectDriftConfigResponse - (*CaptureLogsRequest)(nil), // 90: workflow.plugin.external.iac.CaptureLogsRequest - (*LogChunk)(nil), // 91: workflow.plugin.external.iac.LogChunk - (*JobSpec)(nil), // 92: workflow.plugin.external.iac.JobSpec - (*JobTerminationSpec)(nil), // 93: workflow.plugin.external.iac.JobTerminationSpec - (*JobAlertSpec)(nil), // 94: workflow.plugin.external.iac.JobAlertSpec - (*JobLogDestinationSpec)(nil), // 95: workflow.plugin.external.iac.JobLogDestinationSpec - (*JobHandle)(nil), // 96: workflow.plugin.external.iac.JobHandle - (*JobStatusReply)(nil), // 97: workflow.plugin.external.iac.JobStatusReply - (*ResourceCreateRequest)(nil), // 98: workflow.plugin.external.iac.ResourceCreateRequest - (*ResourceCreateResponse)(nil), // 99: workflow.plugin.external.iac.ResourceCreateResponse - (*ResourceReadRequest)(nil), // 100: workflow.plugin.external.iac.ResourceReadRequest - (*ResourceReadResponse)(nil), // 101: workflow.plugin.external.iac.ResourceReadResponse - (*ResourceUpdateRequest)(nil), // 102: workflow.plugin.external.iac.ResourceUpdateRequest - (*ResourceUpdateResponse)(nil), // 103: workflow.plugin.external.iac.ResourceUpdateResponse - (*ResourceDeleteRequest)(nil), // 104: workflow.plugin.external.iac.ResourceDeleteRequest - (*ResourceDeleteResponse)(nil), // 105: workflow.plugin.external.iac.ResourceDeleteResponse - (*ResourceDiffRequest)(nil), // 106: workflow.plugin.external.iac.ResourceDiffRequest - (*ResourceDiffResponse)(nil), // 107: workflow.plugin.external.iac.ResourceDiffResponse - (*ResourceScaleRequest)(nil), // 108: workflow.plugin.external.iac.ResourceScaleRequest - (*ResourceScaleResponse)(nil), // 109: workflow.plugin.external.iac.ResourceScaleResponse - (*ResourceHealthCheckRequest)(nil), // 110: workflow.plugin.external.iac.ResourceHealthCheckRequest - (*ResourceHealthCheckResponse)(nil), // 111: workflow.plugin.external.iac.ResourceHealthCheckResponse - (*SensitiveKeysRequest)(nil), // 112: workflow.plugin.external.iac.SensitiveKeysRequest - (*SensitiveKeysResponse)(nil), // 113: workflow.plugin.external.iac.SensitiveKeysResponse - (*TroubleshootRequest)(nil), // 114: workflow.plugin.external.iac.TroubleshootRequest - (*TroubleshootResponse)(nil), // 115: workflow.plugin.external.iac.TroubleshootResponse - (*IaCState)(nil), // 116: workflow.plugin.external.iac.IaCState - (*ConfigureRequest)(nil), // 117: workflow.plugin.external.iac.ConfigureRequest - (*ConfigureResponse)(nil), // 118: workflow.plugin.external.iac.ConfigureResponse - (*GetStateRequest)(nil), // 119: workflow.plugin.external.iac.GetStateRequest - (*GetStateResponse)(nil), // 120: workflow.plugin.external.iac.GetStateResponse - (*SaveStateRequest)(nil), // 121: workflow.plugin.external.iac.SaveStateRequest - (*SaveStateResponse)(nil), // 122: workflow.plugin.external.iac.SaveStateResponse - (*ListStatesRequest)(nil), // 123: workflow.plugin.external.iac.ListStatesRequest - (*ListStatesResponse)(nil), // 124: workflow.plugin.external.iac.ListStatesResponse - (*DeleteStateRequest)(nil), // 125: workflow.plugin.external.iac.DeleteStateRequest - (*DeleteStateResponse)(nil), // 126: workflow.plugin.external.iac.DeleteStateResponse - (*LockRequest)(nil), // 127: workflow.plugin.external.iac.LockRequest - (*LockResponse)(nil), // 128: workflow.plugin.external.iac.LockResponse - (*UnlockRequest)(nil), // 129: workflow.plugin.external.iac.UnlockRequest - (*UnlockResponse)(nil), // 130: workflow.plugin.external.iac.UnlockResponse - (*ListBackendNamesRequest)(nil), // 131: workflow.plugin.external.iac.ListBackendNamesRequest - (*ListBackendNamesResponse)(nil), // 132: workflow.plugin.external.iac.ListBackendNamesResponse - nil, // 133: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry - nil, // 134: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry - nil, // 135: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - nil, // 136: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry - nil, // 137: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry - nil, // 138: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry - nil, // 139: workflow.plugin.external.iac.JobSpec.EnvVarsEntry - nil, // 140: workflow.plugin.external.iac.JobSpec.EnvVarsSecretEntry - nil, // 141: workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry - nil, // 142: workflow.plugin.external.iac.JobHandle.MetadataEntry - nil, // 143: workflow.plugin.external.iac.ListStatesRequest.FilterEntry - (*timestamppb.Timestamp)(nil), // 144: google.protobuf.Timestamp + (DriftClass)(0), // 0: workflow.plugin.external.iac.DriftClass + (RequirementKind)(0), // 1: workflow.plugin.external.iac.RequirementKind + (RequirementRuntime)(0), // 2: workflow.plugin.external.iac.RequirementRuntime + (TelemetrySignal)(0), // 3: workflow.plugin.external.iac.TelemetrySignal + (ObservabilityBackend)(0), // 4: workflow.plugin.external.iac.ObservabilityBackend + (DeploymentMode)(0), // 5: workflow.plugin.external.iac.DeploymentMode + (PlanDiagnosticSeverity)(0), // 6: workflow.plugin.external.iac.PlanDiagnosticSeverity + (ActionStatus)(0), // 7: workflow.plugin.external.iac.ActionStatus + (LogCaptureType)(0), // 8: workflow.plugin.external.iac.LogCaptureType + (JobState)(0), // 9: workflow.plugin.external.iac.JobState + (*ResourceSpec)(nil), // 10: workflow.plugin.external.iac.ResourceSpec + (*ResourceRef)(nil), // 11: workflow.plugin.external.iac.ResourceRef + (*ResourceHints)(nil), // 12: workflow.plugin.external.iac.ResourceHints + (*ProviderSizing)(nil), // 13: workflow.plugin.external.iac.ProviderSizing + (*IaCCapabilityDeclaration)(nil), // 14: workflow.plugin.external.iac.IaCCapabilityDeclaration + (*ResourceState)(nil), // 15: workflow.plugin.external.iac.ResourceState + (*ResourceOutput)(nil), // 16: workflow.plugin.external.iac.ResourceOutput + (*ResourceStatus)(nil), // 17: workflow.plugin.external.iac.ResourceStatus + (*FieldChange)(nil), // 18: workflow.plugin.external.iac.FieldChange + (*DiffResult)(nil), // 19: workflow.plugin.external.iac.DiffResult + (*IaCRequirement)(nil), // 20: workflow.plugin.external.iac.IaCRequirement + (*DiscoverRequirementsRequest)(nil), // 21: workflow.plugin.external.iac.DiscoverRequirementsRequest + (*RequirementContext)(nil), // 22: workflow.plugin.external.iac.RequirementContext + (*ModuleRef)(nil), // 23: workflow.plugin.external.iac.ModuleRef + (*DiscoverRequirementsResponse)(nil), // 24: workflow.plugin.external.iac.DiscoverRequirementsResponse + (*MapRequirementsRequest)(nil), // 25: workflow.plugin.external.iac.MapRequirementsRequest + (*DerivedModuleSpec)(nil), // 26: workflow.plugin.external.iac.DerivedModuleSpec + (*RequirementDiagnostic)(nil), // 27: workflow.plugin.external.iac.RequirementDiagnostic + (*RequirementNote)(nil), // 28: workflow.plugin.external.iac.RequirementNote + (*MapRequirementsResponse)(nil), // 29: workflow.plugin.external.iac.MapRequirementsResponse + (*DriftResult)(nil), // 30: workflow.plugin.external.iac.DriftResult + (*DriftEntry)(nil), // 31: workflow.plugin.external.iac.DriftEntry + (*HealthResult)(nil), // 32: workflow.plugin.external.iac.HealthResult + (*Diagnostic)(nil), // 33: workflow.plugin.external.iac.Diagnostic + (*PlanDiagnostic)(nil), // 34: workflow.plugin.external.iac.PlanDiagnostic + (*PlanAction)(nil), // 35: workflow.plugin.external.iac.PlanAction + (*IaCPlan)(nil), // 36: workflow.plugin.external.iac.IaCPlan + (*ActionError)(nil), // 37: workflow.plugin.external.iac.ActionError + (*DestroyResult)(nil), // 38: workflow.plugin.external.iac.DestroyResult + (*BootstrapResult)(nil), // 39: workflow.plugin.external.iac.BootstrapResult + (*MigrationRepairRequest)(nil), // 40: workflow.plugin.external.iac.MigrationRepairRequest + (*MigrationRepairResult)(nil), // 41: workflow.plugin.external.iac.MigrationRepairResult + (*InitializeRequest)(nil), // 42: workflow.plugin.external.iac.InitializeRequest + (*InitializeResponse)(nil), // 43: workflow.plugin.external.iac.InitializeResponse + (*NameRequest)(nil), // 44: workflow.plugin.external.iac.NameRequest + (*NameResponse)(nil), // 45: workflow.plugin.external.iac.NameResponse + (*VersionRequest)(nil), // 46: workflow.plugin.external.iac.VersionRequest + (*VersionResponse)(nil), // 47: workflow.plugin.external.iac.VersionResponse + (*CapabilitiesRequest)(nil), // 48: workflow.plugin.external.iac.CapabilitiesRequest + (*CapabilitiesResponse)(nil), // 49: workflow.plugin.external.iac.CapabilitiesResponse + (*PlanRequest)(nil), // 50: workflow.plugin.external.iac.PlanRequest + (*PlanResponse)(nil), // 51: workflow.plugin.external.iac.PlanResponse + (*DestroyRequest)(nil), // 52: workflow.plugin.external.iac.DestroyRequest + (*DestroyResponse)(nil), // 53: workflow.plugin.external.iac.DestroyResponse + (*StatusRequest)(nil), // 54: workflow.plugin.external.iac.StatusRequest + (*StatusResponse)(nil), // 55: workflow.plugin.external.iac.StatusResponse + (*ImportRequest)(nil), // 56: workflow.plugin.external.iac.ImportRequest + (*ImportResponse)(nil), // 57: workflow.plugin.external.iac.ImportResponse + (*ResolveSizingRequest)(nil), // 58: workflow.plugin.external.iac.ResolveSizingRequest + (*ResolveSizingResponse)(nil), // 59: workflow.plugin.external.iac.ResolveSizingResponse + (*BootstrapStateBackendRequest)(nil), // 60: workflow.plugin.external.iac.BootstrapStateBackendRequest + (*BootstrapStateBackendResponse)(nil), // 61: workflow.plugin.external.iac.BootstrapStateBackendResponse + (*EnumerateAllRequest)(nil), // 62: workflow.plugin.external.iac.EnumerateAllRequest + (*EnumerateAllResponse)(nil), // 63: workflow.plugin.external.iac.EnumerateAllResponse + (*EnumerateByTagRequest)(nil), // 64: workflow.plugin.external.iac.EnumerateByTagRequest + (*EnumerateByTagResponse)(nil), // 65: workflow.plugin.external.iac.EnumerateByTagResponse + (*DetectDriftRequest)(nil), // 66: workflow.plugin.external.iac.DetectDriftRequest + (*DetectDriftResponse)(nil), // 67: workflow.plugin.external.iac.DetectDriftResponse + (*DetectDriftWithSpecsRequest)(nil), // 68: workflow.plugin.external.iac.DetectDriftWithSpecsRequest + (*DetectDriftWithSpecsResponse)(nil), // 69: workflow.plugin.external.iac.DetectDriftWithSpecsResponse + (*RevokeProviderCredentialRequest)(nil), // 70: workflow.plugin.external.iac.RevokeProviderCredentialRequest + (*RevokeProviderCredentialResponse)(nil), // 71: workflow.plugin.external.iac.RevokeProviderCredentialResponse + (*ListRegionsRequest)(nil), // 72: workflow.plugin.external.iac.ListRegionsRequest + (*ProviderRegion)(nil), // 73: workflow.plugin.external.iac.ProviderRegion + (*ListRegionsResponse)(nil), // 74: workflow.plugin.external.iac.ListRegionsResponse + (*GetOwnerRequest)(nil), // 75: workflow.plugin.external.iac.GetOwnerRequest + (*GetOwnerResponse)(nil), // 76: workflow.plugin.external.iac.GetOwnerResponse + (*SetOwnerRequest)(nil), // 77: workflow.plugin.external.iac.SetOwnerRequest + (*SetOwnerResponse)(nil), // 78: workflow.plugin.external.iac.SetOwnerResponse + (*ListOwnersRequest)(nil), // 79: workflow.plugin.external.iac.ListOwnersRequest + (*OwnedResource)(nil), // 80: workflow.plugin.external.iac.OwnedResource + (*ListOwnersResponse)(nil), // 81: workflow.plugin.external.iac.ListOwnersResponse + (*FinalizeApplyRequest)(nil), // 82: workflow.plugin.external.iac.FinalizeApplyRequest + (*FinalizeApplyResponse)(nil), // 83: workflow.plugin.external.iac.FinalizeApplyResponse + (*RepairDirtyMigrationRequest)(nil), // 84: workflow.plugin.external.iac.RepairDirtyMigrationRequest + (*RepairDirtyMigrationResponse)(nil), // 85: workflow.plugin.external.iac.RepairDirtyMigrationResponse + (*ValidatePlanRequest)(nil), // 86: workflow.plugin.external.iac.ValidatePlanRequest + (*ValidatePlanResponse)(nil), // 87: workflow.plugin.external.iac.ValidatePlanResponse + (*DetectDriftConfigRequest)(nil), // 88: workflow.plugin.external.iac.DetectDriftConfigRequest + (*DetectDriftConfigResponse)(nil), // 89: workflow.plugin.external.iac.DetectDriftConfigResponse + (*CaptureLogsRequest)(nil), // 90: workflow.plugin.external.iac.CaptureLogsRequest + (*LogChunk)(nil), // 91: workflow.plugin.external.iac.LogChunk + (*JobSpec)(nil), // 92: workflow.plugin.external.iac.JobSpec + (*JobTerminationSpec)(nil), // 93: workflow.plugin.external.iac.JobTerminationSpec + (*JobAlertSpec)(nil), // 94: workflow.plugin.external.iac.JobAlertSpec + (*JobLogDestinationSpec)(nil), // 95: workflow.plugin.external.iac.JobLogDestinationSpec + (*JobHandle)(nil), // 96: workflow.plugin.external.iac.JobHandle + (*JobStatusReply)(nil), // 97: workflow.plugin.external.iac.JobStatusReply + (*ResourceCreateRequest)(nil), // 98: workflow.plugin.external.iac.ResourceCreateRequest + (*ResourceCreateResponse)(nil), // 99: workflow.plugin.external.iac.ResourceCreateResponse + (*ResourceReadRequest)(nil), // 100: workflow.plugin.external.iac.ResourceReadRequest + (*ResourceReadResponse)(nil), // 101: workflow.plugin.external.iac.ResourceReadResponse + (*ResourceUpdateRequest)(nil), // 102: workflow.plugin.external.iac.ResourceUpdateRequest + (*ResourceUpdateResponse)(nil), // 103: workflow.plugin.external.iac.ResourceUpdateResponse + (*ResourceDeleteRequest)(nil), // 104: workflow.plugin.external.iac.ResourceDeleteRequest + (*ResourceDeleteResponse)(nil), // 105: workflow.plugin.external.iac.ResourceDeleteResponse + (*ResourceDiffRequest)(nil), // 106: workflow.plugin.external.iac.ResourceDiffRequest + (*ResourceDiffResponse)(nil), // 107: workflow.plugin.external.iac.ResourceDiffResponse + (*ResourceScaleRequest)(nil), // 108: workflow.plugin.external.iac.ResourceScaleRequest + (*ResourceScaleResponse)(nil), // 109: workflow.plugin.external.iac.ResourceScaleResponse + (*ResourceHealthCheckRequest)(nil), // 110: workflow.plugin.external.iac.ResourceHealthCheckRequest + (*ResourceHealthCheckResponse)(nil), // 111: workflow.plugin.external.iac.ResourceHealthCheckResponse + (*SensitiveKeysRequest)(nil), // 112: workflow.plugin.external.iac.SensitiveKeysRequest + (*SensitiveKeysResponse)(nil), // 113: workflow.plugin.external.iac.SensitiveKeysResponse + (*TroubleshootRequest)(nil), // 114: workflow.plugin.external.iac.TroubleshootRequest + (*TroubleshootResponse)(nil), // 115: workflow.plugin.external.iac.TroubleshootResponse + (*IaCState)(nil), // 116: workflow.plugin.external.iac.IaCState + (*ConfigureRequest)(nil), // 117: workflow.plugin.external.iac.ConfigureRequest + (*ConfigureResponse)(nil), // 118: workflow.plugin.external.iac.ConfigureResponse + (*GetStateRequest)(nil), // 119: workflow.plugin.external.iac.GetStateRequest + (*GetStateResponse)(nil), // 120: workflow.plugin.external.iac.GetStateResponse + (*SaveStateRequest)(nil), // 121: workflow.plugin.external.iac.SaveStateRequest + (*SaveStateResponse)(nil), // 122: workflow.plugin.external.iac.SaveStateResponse + (*ListStatesRequest)(nil), // 123: workflow.plugin.external.iac.ListStatesRequest + (*ListStatesResponse)(nil), // 124: workflow.plugin.external.iac.ListStatesResponse + (*DeleteStateRequest)(nil), // 125: workflow.plugin.external.iac.DeleteStateRequest + (*DeleteStateResponse)(nil), // 126: workflow.plugin.external.iac.DeleteStateResponse + (*LockRequest)(nil), // 127: workflow.plugin.external.iac.LockRequest + (*LockResponse)(nil), // 128: workflow.plugin.external.iac.LockResponse + (*UnlockRequest)(nil), // 129: workflow.plugin.external.iac.UnlockRequest + (*UnlockResponse)(nil), // 130: workflow.plugin.external.iac.UnlockResponse + (*ListBackendNamesRequest)(nil), // 131: workflow.plugin.external.iac.ListBackendNamesRequest + (*ListBackendNamesResponse)(nil), // 132: workflow.plugin.external.iac.ListBackendNamesResponse + (*ResourceSensitiveInputPathsRequest)(nil), // 133: workflow.plugin.external.iac.ResourceSensitiveInputPathsRequest + (*ResourceSensitiveInputPathsResponse)(nil), // 134: workflow.plugin.external.iac.ResourceSensitiveInputPathsResponse + nil, // 135: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + nil, // 136: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + nil, // 137: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + nil, // 138: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + nil, // 139: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + nil, // 140: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + nil, // 141: workflow.plugin.external.iac.JobSpec.EnvVarsEntry + nil, // 142: workflow.plugin.external.iac.JobSpec.EnvVarsSecretEntry + nil, // 143: workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry + nil, // 144: workflow.plugin.external.iac.JobHandle.MetadataEntry + nil, // 145: workflow.plugin.external.iac.ListStatesRequest.FilterEntry + (*timestamppb.Timestamp)(nil), // 146: google.protobuf.Timestamp } var file_iac_proto_depIdxs = []int32{ 12, // 0: workflow.plugin.external.iac.ResourceSpec.hints:type_name -> workflow.plugin.external.iac.ResourceHints - 144, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp - 144, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp - 144, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp - 133, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + 146, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp + 146, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp + 146, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp + 135, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry 18, // 5: workflow.plugin.external.iac.DiffResult.changes:type_name -> workflow.plugin.external.iac.FieldChange 1, // 6: workflow.plugin.external.iac.IaCRequirement.kind:type_name -> workflow.plugin.external.iac.RequirementKind 2, // 7: workflow.plugin.external.iac.IaCRequirement.runtimes:type_name -> workflow.plugin.external.iac.RequirementRuntime @@ -8395,17 +8491,17 @@ var file_iac_proto_depIdxs = []int32{ 26, // 17: workflow.plugin.external.iac.MapRequirementsResponse.modules:type_name -> workflow.plugin.external.iac.DerivedModuleSpec 28, // 18: workflow.plugin.external.iac.MapRequirementsResponse.notes:type_name -> workflow.plugin.external.iac.RequirementNote 0, // 19: workflow.plugin.external.iac.DriftResult.class:type_name -> workflow.plugin.external.iac.DriftClass - 144, // 20: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp + 146, // 20: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp 6, // 21: workflow.plugin.external.iac.PlanDiagnostic.severity:type_name -> workflow.plugin.external.iac.PlanDiagnosticSeverity 10, // 22: workflow.plugin.external.iac.PlanAction.resource:type_name -> workflow.plugin.external.iac.ResourceSpec 15, // 23: workflow.plugin.external.iac.PlanAction.current:type_name -> workflow.plugin.external.iac.ResourceState 18, // 24: workflow.plugin.external.iac.PlanAction.changes:type_name -> workflow.plugin.external.iac.FieldChange 35, // 25: workflow.plugin.external.iac.IaCPlan.actions:type_name -> workflow.plugin.external.iac.PlanAction - 144, // 26: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp - 134, // 27: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + 146, // 26: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp + 136, // 27: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry 37, // 28: workflow.plugin.external.iac.DestroyResult.errors:type_name -> workflow.plugin.external.iac.ActionError - 135, // 29: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - 136, // 30: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + 137, // 29: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + 138, // 30: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry 33, // 31: workflow.plugin.external.iac.MigrationRepairResult.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic 14, // 32: workflow.plugin.external.iac.CapabilitiesResponse.capabilities:type_name -> workflow.plugin.external.iac.IaCCapabilityDeclaration 10, // 33: workflow.plugin.external.iac.PlanRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec @@ -8424,7 +8520,7 @@ var file_iac_proto_depIdxs = []int32{ 11, // 46: workflow.plugin.external.iac.DetectDriftRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef 30, // 47: workflow.plugin.external.iac.DetectDriftResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 11, // 48: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 137, // 49: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + 139, // 49: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry 30, // 50: workflow.plugin.external.iac.DetectDriftWithSpecsResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 73, // 51: workflow.plugin.external.iac.ListRegionsResponse.regions:type_name -> workflow.plugin.external.iac.ProviderRegion 11, // 52: workflow.plugin.external.iac.GetOwnerRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef @@ -8437,16 +8533,16 @@ var file_iac_proto_depIdxs = []int32{ 36, // 59: workflow.plugin.external.iac.ValidatePlanRequest.plan:type_name -> workflow.plugin.external.iac.IaCPlan 34, // 60: workflow.plugin.external.iac.ValidatePlanResponse.diagnostics:type_name -> workflow.plugin.external.iac.PlanDiagnostic 11, // 61: workflow.plugin.external.iac.DetectDriftConfigRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 138, // 62: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + 140, // 62: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry 30, // 63: workflow.plugin.external.iac.DetectDriftConfigResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 8, // 64: workflow.plugin.external.iac.CaptureLogsRequest.log_type:type_name -> workflow.plugin.external.iac.LogCaptureType - 139, // 65: workflow.plugin.external.iac.JobSpec.env_vars:type_name -> workflow.plugin.external.iac.JobSpec.EnvVarsEntry - 140, // 66: workflow.plugin.external.iac.JobSpec.env_vars_secret:type_name -> workflow.plugin.external.iac.JobSpec.EnvVarsSecretEntry + 141, // 65: workflow.plugin.external.iac.JobSpec.env_vars:type_name -> workflow.plugin.external.iac.JobSpec.EnvVarsEntry + 142, // 66: workflow.plugin.external.iac.JobSpec.env_vars_secret:type_name -> workflow.plugin.external.iac.JobSpec.EnvVarsSecretEntry 93, // 67: workflow.plugin.external.iac.JobSpec.termination:type_name -> workflow.plugin.external.iac.JobTerminationSpec 94, // 68: workflow.plugin.external.iac.JobSpec.alerts:type_name -> workflow.plugin.external.iac.JobAlertSpec 95, // 69: workflow.plugin.external.iac.JobSpec.log_destinations:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec - 141, // 70: workflow.plugin.external.iac.JobLogDestinationSpec.headers:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry - 142, // 71: workflow.plugin.external.iac.JobHandle.metadata:type_name -> workflow.plugin.external.iac.JobHandle.MetadataEntry + 143, // 70: workflow.plugin.external.iac.JobLogDestinationSpec.headers:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry + 144, // 71: workflow.plugin.external.iac.JobHandle.metadata:type_name -> workflow.plugin.external.iac.JobHandle.MetadataEntry 96, // 72: workflow.plugin.external.iac.JobStatusReply.handle:type_name -> workflow.plugin.external.iac.JobHandle 9, // 73: workflow.plugin.external.iac.JobStatusReply.state:type_name -> workflow.plugin.external.iac.JobState 10, // 74: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec @@ -8468,7 +8564,7 @@ var file_iac_proto_depIdxs = []int32{ 33, // 90: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic 116, // 91: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState 116, // 92: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState - 143, // 93: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry + 145, // 93: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry 116, // 94: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState 10, // 95: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec 10, // 96: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec @@ -8518,54 +8614,56 @@ var file_iac_proto_depIdxs = []int32{ 127, // 140: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest 129, // 141: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest 131, // 142: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest - 43, // 143: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse - 45, // 144: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse - 47, // 145: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse - 49, // 146: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse - 51, // 147: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse - 53, // 148: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse - 55, // 149: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse - 57, // 150: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse - 59, // 151: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse - 61, // 152: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse - 63, // 153: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse - 65, // 154: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse - 67, // 155: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse - 69, // 156: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse - 71, // 157: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse - 74, // 158: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:output_type -> workflow.plugin.external.iac.ListRegionsResponse - 76, // 159: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:output_type -> workflow.plugin.external.iac.GetOwnerResponse - 78, // 160: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:output_type -> workflow.plugin.external.iac.SetOwnerResponse - 81, // 161: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:output_type -> workflow.plugin.external.iac.ListOwnersResponse - 83, // 162: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse - 85, // 163: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse - 87, // 164: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse - 89, // 165: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse - 91, // 166: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:output_type -> workflow.plugin.external.iac.LogChunk - 96, // 167: workflow.plugin.external.iac.IaCProviderRunner.RunJob:output_type -> workflow.plugin.external.iac.JobHandle - 97, // 168: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:output_type -> workflow.plugin.external.iac.JobStatusReply - 91, // 169: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:output_type -> workflow.plugin.external.iac.LogChunk - 24, // 170: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:output_type -> workflow.plugin.external.iac.DiscoverRequirementsResponse - 29, // 171: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:output_type -> workflow.plugin.external.iac.MapRequirementsResponse - 99, // 172: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse - 101, // 173: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse - 103, // 174: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse - 105, // 175: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse - 107, // 176: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse - 109, // 177: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse - 111, // 178: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse - 113, // 179: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse - 115, // 180: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse - 118, // 181: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse - 120, // 182: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse - 122, // 183: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse - 124, // 184: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse - 126, // 185: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse - 128, // 186: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse - 130, // 187: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse - 132, // 188: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse - 143, // [143:189] is the sub-list for method output_type - 97, // [97:143] is the sub-list for method input_type + 133, // 143: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:input_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsRequest + 43, // 144: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse + 45, // 145: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse + 47, // 146: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse + 49, // 147: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse + 51, // 148: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse + 53, // 149: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse + 55, // 150: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse + 57, // 151: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse + 59, // 152: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse + 61, // 153: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse + 63, // 154: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse + 65, // 155: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse + 67, // 156: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse + 69, // 157: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse + 71, // 158: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse + 74, // 159: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:output_type -> workflow.plugin.external.iac.ListRegionsResponse + 76, // 160: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:output_type -> workflow.plugin.external.iac.GetOwnerResponse + 78, // 161: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:output_type -> workflow.plugin.external.iac.SetOwnerResponse + 81, // 162: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:output_type -> workflow.plugin.external.iac.ListOwnersResponse + 83, // 163: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse + 85, // 164: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse + 87, // 165: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse + 89, // 166: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse + 91, // 167: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:output_type -> workflow.plugin.external.iac.LogChunk + 96, // 168: workflow.plugin.external.iac.IaCProviderRunner.RunJob:output_type -> workflow.plugin.external.iac.JobHandle + 97, // 169: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:output_type -> workflow.plugin.external.iac.JobStatusReply + 91, // 170: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:output_type -> workflow.plugin.external.iac.LogChunk + 24, // 171: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:output_type -> workflow.plugin.external.iac.DiscoverRequirementsResponse + 29, // 172: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:output_type -> workflow.plugin.external.iac.MapRequirementsResponse + 99, // 173: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse + 101, // 174: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse + 103, // 175: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse + 105, // 176: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse + 107, // 177: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse + 109, // 178: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse + 111, // 179: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse + 113, // 180: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse + 115, // 181: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse + 118, // 182: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse + 120, // 183: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse + 122, // 184: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse + 124, // 185: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse + 126, // 186: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse + 128, // 187: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse + 130, // 188: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse + 132, // 189: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse + 134, // 190: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:output_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsResponse + 144, // [144:191] is the sub-list for method output_type + 97, // [97:144] is the sub-list for method input_type 97, // [97:97] is the sub-list for extension type_name 97, // [97:97] is the sub-list for extension extendee 0, // [0:97] is the sub-list for field type_name @@ -8582,9 +8680,9 @@ func file_iac_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iac_proto_rawDesc), len(file_iac_proto_rawDesc)), NumEnums: 10, - NumMessages: 134, + NumMessages: 136, NumExtensions: 0, - NumServices: 16, + NumServices: 17, }, GoTypes: file_iac_proto_goTypes, DependencyIndexes: file_iac_proto_depIdxs, diff --git a/plugin/external/proto/iac.proto b/plugin/external/proto/iac.proto index 930ba011..ee6941f3 100644 --- a/plugin/external/proto/iac.proto +++ b/plugin/external/proto/iac.proto @@ -1023,3 +1023,18 @@ message UnlockResponse {} // plugin.json capabilities.iacStateBackends (PluginManifest.IaCStateBackends). message ListBackendNamesRequest {} message ListBackendNamesResponse { repeated string backend_names = 1; } + +// ResourceSensitiveInputDeclarer is optional. Paths describe sensitive Config +// leaves as canonical RFC 6901 pointers with one-segment '*' wildcards. No +// Config values cross this discovery boundary. Old plugins omit this service. +service ResourceSensitiveInputDeclarer { + rpc SensitiveInputPaths(ResourceSensitiveInputPathsRequest) returns (ResourceSensitiveInputPathsResponse); +} + +message ResourceSensitiveInputPathsRequest { + string resource_type = 1; +} + +message ResourceSensitiveInputPathsResponse { + repeated string paths = 1; +} diff --git a/plugin/external/proto/iac_grpc.pb.go b/plugin/external/proto/iac_grpc.pb.go index 882445a8..38eb9602 100644 --- a/plugin/external/proto/iac_grpc.pb.go +++ b/plugin/external/proto/iac_grpc.pb.go @@ -2933,3 +2933,114 @@ var IaCStateBackend_ServiceDesc = grpc.ServiceDesc{ Streams: []grpc.StreamDesc{}, Metadata: "iac.proto", } + +const ( + ResourceSensitiveInputDeclarer_SensitiveInputPaths_FullMethodName = "/workflow.plugin.external.iac.ResourceSensitiveInputDeclarer/SensitiveInputPaths" +) + +// ResourceSensitiveInputDeclarerClient is the client API for ResourceSensitiveInputDeclarer service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ResourceSensitiveInputDeclarer is optional. Paths describe sensitive Config +// leaves as canonical RFC 6901 pointers with one-segment '*' wildcards. No +// Config values cross this discovery boundary. Old plugins omit this service. +type ResourceSensitiveInputDeclarerClient interface { + SensitiveInputPaths(ctx context.Context, in *ResourceSensitiveInputPathsRequest, opts ...grpc.CallOption) (*ResourceSensitiveInputPathsResponse, error) +} + +type resourceSensitiveInputDeclarerClient struct { + cc grpc.ClientConnInterface +} + +func NewResourceSensitiveInputDeclarerClient(cc grpc.ClientConnInterface) ResourceSensitiveInputDeclarerClient { + return &resourceSensitiveInputDeclarerClient{cc} +} + +func (c *resourceSensitiveInputDeclarerClient) SensitiveInputPaths(ctx context.Context, in *ResourceSensitiveInputPathsRequest, opts ...grpc.CallOption) (*ResourceSensitiveInputPathsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceSensitiveInputPathsResponse) + err := c.cc.Invoke(ctx, ResourceSensitiveInputDeclarer_SensitiveInputPaths_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ResourceSensitiveInputDeclarerServer is the server API for ResourceSensitiveInputDeclarer service. +// All implementations must embed UnimplementedResourceSensitiveInputDeclarerServer +// for forward compatibility. +// +// ResourceSensitiveInputDeclarer is optional. Paths describe sensitive Config +// leaves as canonical RFC 6901 pointers with one-segment '*' wildcards. No +// Config values cross this discovery boundary. Old plugins omit this service. +type ResourceSensitiveInputDeclarerServer interface { + SensitiveInputPaths(context.Context, *ResourceSensitiveInputPathsRequest) (*ResourceSensitiveInputPathsResponse, error) + mustEmbedUnimplementedResourceSensitiveInputDeclarerServer() +} + +// UnimplementedResourceSensitiveInputDeclarerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedResourceSensitiveInputDeclarerServer struct{} + +func (UnimplementedResourceSensitiveInputDeclarerServer) SensitiveInputPaths(context.Context, *ResourceSensitiveInputPathsRequest) (*ResourceSensitiveInputPathsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SensitiveInputPaths not implemented") +} +func (UnimplementedResourceSensitiveInputDeclarerServer) mustEmbedUnimplementedResourceSensitiveInputDeclarerServer() { +} +func (UnimplementedResourceSensitiveInputDeclarerServer) testEmbeddedByValue() {} + +// UnsafeResourceSensitiveInputDeclarerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ResourceSensitiveInputDeclarerServer will +// result in compilation errors. +type UnsafeResourceSensitiveInputDeclarerServer interface { + mustEmbedUnimplementedResourceSensitiveInputDeclarerServer() +} + +func RegisterResourceSensitiveInputDeclarerServer(s grpc.ServiceRegistrar, srv ResourceSensitiveInputDeclarerServer) { + // If the following call panics, it indicates UnimplementedResourceSensitiveInputDeclarerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ResourceSensitiveInputDeclarer_ServiceDesc, srv) +} + +func _ResourceSensitiveInputDeclarer_SensitiveInputPaths_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResourceSensitiveInputPathsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceSensitiveInputDeclarerServer).SensitiveInputPaths(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceSensitiveInputDeclarer_SensitiveInputPaths_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceSensitiveInputDeclarerServer).SensitiveInputPaths(ctx, req.(*ResourceSensitiveInputPathsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ResourceSensitiveInputDeclarer_ServiceDesc is the grpc.ServiceDesc for ResourceSensitiveInputDeclarer service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ResourceSensitiveInputDeclarer_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "workflow.plugin.external.iac.ResourceSensitiveInputDeclarer", + HandlerType: (*ResourceSensitiveInputDeclarerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SensitiveInputPaths", + Handler: _ResourceSensitiveInputDeclarer_SensitiveInputPaths_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "iac.proto", +} diff --git a/plugin/external/sdk/iacserver.go b/plugin/external/sdk/iacserver.go index b6db9801..6a3037ef 100644 --- a/plugin/external/sdk/iacserver.go +++ b/plugin/external/sdk/iacserver.go @@ -44,6 +44,7 @@ import ( // pb.IaCRequirementDiscoveryServer // pb.IaCProviderRequirementMapperServer // pb.IaCStateBackendServer +// pb.ResourceSensitiveInputDeclarerServer // // ResourceDriver: // @@ -202,6 +203,9 @@ func registerIaCServicesOnly(s *grpc.Server, provider any) error { if v, ok := provider.(pb.ResourceDriverServer); ok { pb.RegisterResourceDriverServer(s, v) } + if v, ok := provider.(pb.ResourceSensitiveInputDeclarerServer); ok { + pb.RegisterResourceSensitiveInputDeclarerServer(s, v) + } // Per decisions/0035 (Amendment A2): IaCStateBackend is an optional // service auto-detected exactly like the IaCProvider* optionals — a // plugin whose provider type also implements pb.IaCStateBackendServer diff --git a/plugin/external/sdk/sensitive_inputs_test.go b/plugin/external/sdk/sensitive_inputs_test.go new file mode 100644 index 00000000..597d3b32 --- /dev/null +++ b/plugin/external/sdk/sensitive_inputs_test.go @@ -0,0 +1,92 @@ +package sdk_test + +import ( + "context" + "errors" + "net" + "slices" + "testing" + + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type sensitiveInputContractProvider struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedResourceDriverServer + pb.UnimplementedResourceSensitiveInputDeclarerServer + paths []string + err error + typeID string +} + +func (p *sensitiveInputContractProvider) SensitiveInputPaths(_ context.Context, req *pb.ResourceSensitiveInputPathsRequest) (*pb.ResourceSensitiveInputPathsResponse, error) { + p.typeID = req.GetResourceType() + if p.err != nil { + return nil, p.err + } + return &pb.ResourceSensitiveInputPathsResponse{Paths: p.paths}, nil +} + +type legacySensitiveInputProvider struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedResourceDriverServer +} + +func TestSensitiveInputContractSDKRoundTripAndLegacyAbsence(t *testing.T) { + for _, tc := range []struct { + name string + provider any + want []string + wantErr error + wantCode codes.Code + }{ + {"declared", &sensitiveInputContractProvider{paths: []string{"/password", "/env/*/value"}}, []string{"/password", "/env/*/value"}, nil, codes.OK}, + {"legacy absent", &legacySensitiveInputProvider{}, nil, interfaces.ErrProviderMethodUnimplemented, codes.Unknown}, + {"unimplemented", &sensitiveInputContractProvider{err: status.Error(codes.Unimplemented, "old provider")}, nil, interfaces.ErrProviderMethodUnimplemented, codes.Unknown}, + {"malformed declaration", &sensitiveInputContractProvider{paths: []string{"/bad~2"}}, nil, interfaces.ErrValidation, codes.Unknown}, + {"permission denied", &sensitiveInputContractProvider{err: status.Error(codes.PermissionDenied, "denied")}, nil, nil, codes.PermissionDenied}, + {"unavailable", &sensitiveInputContractProvider{err: status.Error(codes.Unavailable, "unavailable")}, nil, nil, codes.Unavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(srv, tc.provider); err != nil { + t.Fatal(err) + } + listener := bufconn.Listen(1024 * 1024) + t.Cleanup(srv.Stop) + go func() { _ = srv.Serve(listener) }() + conn, err := grpc.NewClient("passthrough:///sensitive-input-test", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + advertised := serviceNamesFromRegistry(sdk.BuildContractRegistry(srv)) + adapter := providerclient.New(conn, advertised) + driver, err := adapter.ResourceDriver("stub.database") + if err != nil { + t.Fatal(err) + } + declarer, ok := driver.(interfaces.ResourceSensitiveInputDeclarer) + if !ok { + t.Fatal("remote driver must expose declaration discovery") + } + got, err := declarer.SensitiveInputPaths(t.Context()) + if (tc.wantErr != nil && !errors.Is(err, tc.wantErr)) || status.Code(err) != tc.wantCode || !slices.Equal(got, tc.want) { + t.Fatalf("paths=%v err=%v, want=%v err=%v", got, err, tc.want, tc.wantErr) + } + if p, ok := tc.provider.(*sensitiveInputContractProvider); ok && p.typeID != "stub.database" { + t.Fatalf("RPC resource type=%q", p.typeID) + } + }) + } +} diff --git a/wftest/bdd/strict_iac.go b/wftest/bdd/strict_iac.go index 27bdfa31..fff98bec 100644 --- a/wftest/bdd/strict_iac.go +++ b/wftest/bdd/strict_iac.go @@ -81,6 +81,10 @@ var iacServiceChecks = []iacServiceCheck{ _, ok := p.(pb.ResourceDriverServer) return ok }}, + {"workflow.plugin.external.iac.ResourceSensitiveInputDeclarer", func(p any) bool { + _, ok := p.(pb.ResourceSensitiveInputDeclarerServer) + return ok + }}, {"workflow.plugin.external.iac.IaCStateBackend", func(p any) bool { _, ok := p.(pb.IaCStateBackendServer) return ok From b65310346ce44d16cedd1a4b2b16dc0e544e113e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 2 Oct 2026 23:58:31 -0400 Subject: [PATCH 2/5] fix(iac): isolate runtime secrets from plans Keep credential references in plans, hooks and durable state. Resolve a separate driver copy, preserve cross-provider references and fail closed on discovery errors. Redact known credentials only in diagnostic surfaces, not consumer payloads. --- cmd/wfctl/iac_typed_adapter.go | 86 ++++-- cmd/wfctl/iac_typed_sensitive_inputs_test.go | 221 ++++++++++++++ cmd/wfctl/infra.go | 17 +- cmd/wfctl/infra_apply.go | 66 ++-- cmd/wfctl/infra_apply_test.go | 12 +- cmd/wfctl/infra_declarative_apply_test.go | 260 ++++++++++++++++ cmd/wfctl/infra_declarative_plan.go | 73 +++++ cmd/wfctl/infra_plan_provider.go | 4 +- cmd/wfctl/infra_resolve_state.go | 12 +- docs/WFCTL.md | 8 + iac/jitsubst/jitsubst.go | 226 +++++++++----- iac/jitsubst/native_collections_test.go | 98 ++++++ iac/sensitiveinputs/diagnostics.go | 40 +++ iac/sensitiveinputs/diagnostics_test.go | 21 ++ iac/wfctlhelpers/apply.go | 117 ++++++-- .../apply_sensitive_inputs_test.go | 281 ++++++++++++++++++ interfaces/iac_state.go | 6 +- 17 files changed, 1381 insertions(+), 167 deletions(-) create mode 100644 cmd/wfctl/iac_typed_sensitive_inputs_test.go create mode 100644 cmd/wfctl/infra_declarative_apply_test.go create mode 100644 cmd/wfctl/infra_declarative_plan.go create mode 100644 iac/jitsubst/native_collections_test.go create mode 100644 iac/sensitiveinputs/diagnostics.go create mode 100644 iac/sensitiveinputs/diagnostics_test.go create mode 100644 iac/wfctlhelpers/apply_sensitive_inputs_test.go diff --git a/cmd/wfctl/iac_typed_adapter.go b/cmd/wfctl/iac_typed_adapter.go index 36c7bc0b..a95e6ad4 100644 --- a/cmd/wfctl/iac_typed_adapter.go +++ b/cmd/wfctl/iac_typed_adapter.go @@ -41,6 +41,7 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/GoCodeAlone/workflow/iac/sensitiveinputs" "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) @@ -50,19 +51,20 @@ import ( // each name (Task 5). Constants are declared at package scope so callers // (e.g. the loader) can reference the same strings without duplication. const ( - iacServiceRequired = "workflow.plugin.external.iac.IaCProviderRequired" - iacServiceEnumerator = "workflow.plugin.external.iac.IaCProviderEnumerator" - iacServiceDriftDetector = "workflow.plugin.external.iac.IaCProviderDriftDetector" - iacServiceCredentialRevoker = "workflow.plugin.external.iac.IaCProviderCredentialRevoker" - iacServiceRegionLister = "workflow.plugin.external.iac.IaCProviderRegionLister" - iacServiceOwnership = "workflow.plugin.external.iac.IaCProviderOwnership" - iacServiceMigrationRepairer = "workflow.plugin.external.iac.IaCProviderMigrationRepairer" - iacServiceValidator = "workflow.plugin.external.iac.IaCProviderValidator" - iacServiceDriftConfigDetect = "workflow.plugin.external.iac.IaCProviderDriftConfigDetector" - iacServiceLogCapture = "workflow.plugin.external.iac.IaCProviderLogCapture" - iacServiceFinalizer = "workflow.plugin.external.iac.IaCProviderFinalizer" - iacServiceResourceDriver = "workflow.plugin.external.iac.ResourceDriver" - iacServiceRequirementMapper = "workflow.plugin.external.iac.IaCProviderRequirementMapper" + iacServiceRequired = "workflow.plugin.external.iac.IaCProviderRequired" + iacServiceEnumerator = "workflow.plugin.external.iac.IaCProviderEnumerator" + iacServiceDriftDetector = "workflow.plugin.external.iac.IaCProviderDriftDetector" + iacServiceCredentialRevoker = "workflow.plugin.external.iac.IaCProviderCredentialRevoker" + iacServiceRegionLister = "workflow.plugin.external.iac.IaCProviderRegionLister" + iacServiceOwnership = "workflow.plugin.external.iac.IaCProviderOwnership" + iacServiceMigrationRepairer = "workflow.plugin.external.iac.IaCProviderMigrationRepairer" + iacServiceValidator = "workflow.plugin.external.iac.IaCProviderValidator" + iacServiceDriftConfigDetect = "workflow.plugin.external.iac.IaCProviderDriftConfigDetector" + iacServiceLogCapture = "workflow.plugin.external.iac.IaCProviderLogCapture" + iacServiceFinalizer = "workflow.plugin.external.iac.IaCProviderFinalizer" + iacServiceResourceDriver = "workflow.plugin.external.iac.ResourceDriver" + iacServiceRequirementMapper = "workflow.plugin.external.iac.IaCProviderRequirementMapper" + iacServiceSensitiveInputDeclarer = "workflow.plugin.external.iac.ResourceSensitiveInputDeclarer" ) // typedIaCAdapter implements interfaces.IaCProvider on top of the typed @@ -79,19 +81,20 @@ const ( type typedIaCAdapter struct { conn *grpc.ClientConn - required pb.IaCProviderRequiredClient - enumerator pb.IaCProviderEnumeratorClient - drift pb.IaCProviderDriftDetectorClient - revoker pb.IaCProviderCredentialRevokerClient - regionLister pb.IaCProviderRegionListerClient - ownership pb.IaCProviderOwnershipClient - repairer pb.IaCProviderMigrationRepairerClient - validator pb.IaCProviderValidatorClient - driftCfg pb.IaCProviderDriftConfigDetectorClient - logCapture pb.IaCProviderLogCaptureClient - finalizer pb.IaCProviderFinalizerClient - resourceDriv pb.ResourceDriverClient - reqMapper pb.IaCProviderRequirementMapperClient + required pb.IaCProviderRequiredClient + enumerator pb.IaCProviderEnumeratorClient + drift pb.IaCProviderDriftDetectorClient + revoker pb.IaCProviderCredentialRevokerClient + regionLister pb.IaCProviderRegionListerClient + ownership pb.IaCProviderOwnershipClient + repairer pb.IaCProviderMigrationRepairerClient + validator pb.IaCProviderValidatorClient + driftCfg pb.IaCProviderDriftConfigDetectorClient + logCapture pb.IaCProviderLogCaptureClient + finalizer pb.IaCProviderFinalizerClient + resourceDriv pb.ResourceDriverClient + reqMapper pb.IaCProviderRequirementMapperClient + sensitiveInputs pb.ResourceSensitiveInputDeclarerClient // cachedCaps memoizes the plugin's CapabilitiesResponse. Access via // fetchCapabilities — never read this field directly. @@ -147,6 +150,9 @@ func newTypedIaCAdapter(conn *grpc.ClientConn, registered map[string]bool) *type if registered[iacServiceRequirementMapper] { a.reqMapper = pb.NewIaCProviderRequirementMapperClient(conn) } + if registered[iacServiceSensitiveInputDeclarer] { + a.sensitiveInputs = pb.NewResourceSensitiveInputDeclarerClient(conn) + } return a } @@ -485,7 +491,11 @@ func (a *typedIaCAdapter) ResourceDriver(resourceType string) (interfaces.Resour if a.resourceDriv == nil { return nil, unimplementedOptional(iacServiceResourceDriver) } - return &typedResourceDriver{client: a.resourceDriv, resourceType: resourceType}, nil + return &typedResourceDriver{ + client: a.resourceDriv, + resourceType: resourceType, + sensitiveInputs: a.sensitiveInputs, + }, nil } // SupportedCanonicalKeys returns the canonical IaC config keys this @@ -758,8 +768,26 @@ func (a *typedIaCAdapter) CaptureLogs(ctx context.Context, req interfaces.LogCap // the per-type driver implementation (DO plugin's 14-driver router in // Task 11). type typedResourceDriver struct { - client pb.ResourceDriverClient - resourceType string + client pb.ResourceDriverClient + resourceType string + sensitiveInputs pb.ResourceSensitiveInputDeclarerClient +} + +var _ interfaces.ResourceSensitiveInputDeclarer = (*typedResourceDriver)(nil) + +func (d *typedResourceDriver) SensitiveInputPaths(ctx context.Context) ([]string, error) { + if d.sensitiveInputs == nil { + return nil, unimplementedOptional(iacServiceSensitiveInputDeclarer) + } + resp, err := d.sensitiveInputs.SensitiveInputPaths(ctx, &pb.ResourceSensitiveInputPathsRequest{ResourceType: d.resourceType}) + if err != nil { + return nil, translateRPCErr(err) + } + paths := append([]string(nil), resp.GetPaths()...) + if err := sensitiveinputs.ValidatePaths(paths); err != nil { + return nil, err + } + return paths, nil } func (d *typedResourceDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { diff --git a/cmd/wfctl/iac_typed_sensitive_inputs_test.go b/cmd/wfctl/iac_typed_sensitive_inputs_test.go new file mode 100644 index 00000000..80e6e8e9 --- /dev/null +++ b/cmd/wfctl/iac_typed_sensitive_inputs_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "context" + "errors" + "net" + "slices" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type typedSensitiveInputLegacyProvider struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedResourceDriverServer +} + +type typedSensitiveInputProvider struct { + typedSensitiveInputLegacyProvider + pb.UnimplementedResourceSensitiveInputDeclarerServer + paths []string + err error + requests chan string + canceled chan struct{} +} + +func (p *typedSensitiveInputProvider) SensitiveInputPaths(ctx context.Context, req *pb.ResourceSensitiveInputPathsRequest) (*pb.ResourceSensitiveInputPathsResponse, error) { + p.requests <- req.GetResourceType() + if p.canceled != nil { + <-ctx.Done() + close(p.canceled) + return nil, status.FromContextError(ctx.Err()).Err() + } + if p.err != nil { + return nil, p.err + } + return &pb.ResourceSensitiveInputPathsResponse{Paths: p.paths}, nil +} + +func typedSensitiveInputConnection(t *testing.T, provider any, opts ...grpc.DialOption) (*grpc.ClientConn, map[string]bool) { + t.Helper() + server := grpc.NewServer() + t.Cleanup(server.Stop) + if err := sdk.RegisterAllIaCProviderServices(server, provider); err != nil { + t.Fatal(err) + } + advertised := make(map[string]bool) + for _, contract := range sdk.BuildContractRegistry(server).GetContracts() { + if contract.GetKind() == pb.ContractKind_CONTRACT_KIND_SERVICE { + advertised[contract.GetServiceName()] = true + } + } + listener := bufconn.Listen(1024 * 1024) + t.Cleanup(func() { _ = listener.Close() }) + go func() { _ = server.Serve(listener) }() + opts = append(opts, + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + conn, err := grpc.NewClient("passthrough:///typed-sensitive-input-test", opts...) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn, advertised +} + +func typedSensitiveInputDeclarer(t *testing.T, adapter *typedIaCAdapter, resourceType string) interfaces.ResourceSensitiveInputDeclarer { + t.Helper() + driver, err := adapter.ResourceDriver(resourceType) + if err != nil { + t.Fatal(err) + } + if _, ok := driver.(*typedResourceDriver); !ok { + t.Fatalf("driver = %T, want real typed CLI driver", driver) + } + declarer, ok := driver.(interfaces.ResourceSensitiveInputDeclarer) + if !ok { + t.Fatal("typed CLI driver must expose optional sensitive input declarations") + } + return declarer +} + +func TestTypedResourceDriver_SensitiveInputPaths(t *testing.T) { + service := pb.ResourceSensitiveInputDeclarer_ServiceDesc.ServiceName + for _, tc := range []struct { + name string + legacy bool + unadvertised bool + paths []string + rpcErr error + want []string + wantErr error + wantCode codes.Code + }{ + {name: "legacy absence", legacy: true, wantErr: interfaces.ErrProviderMethodUnimplemented, wantCode: codes.Unknown}, + {name: "registered but not advertised", unadvertised: true, paths: []string{"/password"}, wantErr: interfaces.ErrProviderMethodUnimplemented, wantCode: codes.Unknown}, + {name: "advertised unimplemented", rpcErr: status.Error(codes.Unimplemented, "old provider"), wantErr: interfaces.ErrProviderMethodUnimplemented, wantCode: codes.Unimplemented}, + {name: "permission denied", rpcErr: status.Error(codes.PermissionDenied, "denied"), wantCode: codes.PermissionDenied}, + {name: "unavailable", rpcErr: status.Error(codes.Unavailable, "unavailable"), wantCode: codes.Unavailable}, + {name: "malformed escape", paths: []string{"/password", "/bad~2"}, wantErr: interfaces.ErrValidation, wantCode: codes.Unknown}, + {name: "root pointer", paths: []string{""}, wantErr: interfaces.ErrValidation, wantCode: codes.Unknown}, + {name: "relative pointer", paths: []string{"env/*/value"}, wantErr: interfaces.ErrValidation, wantCode: codes.Unknown}, + {name: "nested wildcard", paths: []string{"/password", "/services/*/env/*/value", "/escaped~1key/~0token"}, want: []string{"/password", "/services/*/env/*/value", "/escaped~1key/~0token"}}, + {name: "empty declaration"}, + } { + t.Run(tc.name, func(t *testing.T) { + provider := &typedSensitiveInputProvider{paths: tc.paths, err: tc.rpcErr, requests: make(chan string, 1)} + var registeredProvider any = provider + if tc.legacy { + registeredProvider = &typedSensitiveInputLegacyProvider{} + } + // Retain the actual decoded RPC response to verify defensive copying. + var response *pb.ResourceSensitiveInputPathsResponse + conn, advertised := typedSensitiveInputConnection(t, registeredProvider, + grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + err := invoker(ctx, method, req, reply, cc, opts...) + response, _ = reply.(*pb.ResourceSensitiveInputPathsResponse) + return err + }), + ) + if advertised[service] == tc.legacy { + t.Fatalf("SDK advertised sensitive input service = %v, legacy = %v", advertised[service], tc.legacy) + } + if tc.unadvertised { + delete(advertised, service) + } + declarer := typedSensitiveInputDeclarer(t, newTypedIaCAdapter(conn, advertised), "stub.database") + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + got, err := declarer.SensitiveInputPaths(ctx) + if !slices.Equal(got, tc.want) || !errors.Is(err, tc.wantErr) && tc.wantErr != nil || status.Code(err) != tc.wantCode { + t.Fatalf("paths = %v, err = %v (%v); want %v, %v (%v)", got, err, status.Code(err), tc.want, tc.wantErr, tc.wantCode) + } + if tc.wantErr == nil && errors.Is(err, interfaces.ErrProviderMethodUnimplemented) { + t.Fatalf("RPC failure misclassified as optional absence: %v", err) + } + if tc.wantCode == codes.OK && err != nil { + t.Fatalf("unexpected declaration error: %v", err) + } + if err != nil && got != nil { + t.Fatalf("failed declaration returned partial paths: %v", got) + } + if tc.rpcErr != nil && tc.wantErr == nil && status.Convert(err).Message() != status.Convert(tc.rpcErr).Message() { + t.Fatalf("RPC failure message changed: %v", err) + } + select { + case resourceType := <-provider.requests: + if tc.unadvertised || tc.legacy { + t.Fatal("unadvertised service received an RPC") + } + if resourceType != "stub.database" { + t.Fatalf("RPC resource type = %q, want stub.database", resourceType) + } + default: + if !tc.unadvertised && !tc.legacy { + t.Fatal("advertised service did not receive an RPC") + } + } + if len(got) > 0 { + got[0] = "/caller-change" + if response.GetPaths()[0] != tc.want[0] { + t.Fatal("returned paths alias the decoded RPC response") + } + response.Paths[0] = "/response-change" + if got[0] != "/caller-change" { + t.Fatal("decoded RPC response aliases the returned paths") + } + } + }) + } +} + +func TestTypedResourceDriver_SensitiveInputPathsCallerCancellation(t *testing.T) { + provider := &typedSensitiveInputProvider{requests: make(chan string, 1), canceled: make(chan struct{})} + conn, advertised := typedSensitiveInputConnection(t, provider) + declarer := typedSensitiveInputDeclarer(t, newTypedIaCAdapter(conn, advertised), "infra.container_service") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + type result struct { + paths []string + err error + } + results := make(chan result, 1) + go func() { + paths, err := declarer.SensitiveInputPaths(ctx) + results <- result{paths: paths, err: err} + }() + select { + case resourceType := <-provider.requests: + if resourceType != "infra.container_service" { + t.Fatalf("RPC resource type = %q", resourceType) + } + case <-time.After(5 * time.Second): + t.Fatal("RPC did not reach the provider") + } + cancel() + select { + case got := <-results: + if got.paths != nil || status.Code(got.err) != codes.Canceled || errors.Is(got.err, interfaces.ErrProviderMethodUnimplemented) { + t.Fatalf("paths = %v, err = %v; want caller cancellation", got.paths, got.err) + } + case <-time.After(5 * time.Second): + t.Fatal("caller cancellation did not stop the RPC") + } + select { + case <-provider.canceled: + case <-time.After(5 * time.Second): + t.Fatal("caller cancellation did not reach the provider context") + } +} diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 5bbcc1ac..a50ef7aa 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -316,7 +316,7 @@ func runInfraPlan(args []string) error { if cfgLoadErr != nil { return fmt.Errorf("load config for plan-time resolver: %w", cfgLoadErr) } - desired, resolutionDiags, err = resolveSpecsAgainstState(desired, current, wfCfgForResolver, envName) + _, resolutionDiags, err = resolveSpecsAgainstState(desired, current, wfCfgForResolver, envName) if err != nil { return fmt.Errorf("resolve specs against state: %w", err) } @@ -1562,11 +1562,8 @@ func runInfraApply(args []string) error { return inputsnapshot.NewStaleError(drift) } } - // Mirror the plan-time resolver: apply resolveSpecsAgainstState before - // hashing so that DesiredHash is computed on post-resolution specs, matching - // what runInfraPlan recorded in plan.DesiredHash. Without this step, any ref - // that resolved at plan time would cause a currentHash != plan.DesiredHash - // mismatch on every --plan apply. + // Stale checks hash the same declarative inputs recorded at plan time. + // Resolving here would make credential bytes part of the hash contract. { currentState, stateErr := loadCurrentState(cfgFile, envName) if stateErr != nil { @@ -1577,14 +1574,6 @@ func runInfraApply(args []string) error { } desired = filterSpecsByInclude(desired, planIncludeSet) currentState = filterStatesByInclude(currentState, planIncludeSet) - planApplyCfg, cfgErr := config.LoadFromFile(cfgFile) - if cfgErr != nil { - return fmt.Errorf("load config for stale-check: %w", cfgErr) - } - desired, _, err = resolveSpecsAgainstState(desired, currentState, planApplyCfg, envName) - if err != nil { - return fmt.Errorf("resolve specs for stale-check: %w", err) - } } currentHash := desiredStateHash(desired) if plan.DesiredHash != currentHash { diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 8881a016..42a011f9 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -209,15 +209,6 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) (map[string return nil, fmt.Errorf("load config: %w", err) } - // Plan-time JIT resolution (PR-1): substitute ${MODULE.field} and - // ${SECRET} refs against current state so driver.Diff sees real - // values instead of literal templates. Apply does not print the - // diagnostics — they're plan-output sugar only. - infraSpecs, _, err = resolveSpecsAgainstState(infraSpecs, current, cfg, envName) - if err != nil { - return nil, fmt.Errorf("resolve specs against state: %w", err) - } - // Build a lookup table of iac.provider module name → (providerType, providerCfg). // Also track which providers are explicitly disabled for this env so we can // emit a precise error if an infra module references one. @@ -415,18 +406,28 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi // group; applyInfraModules does that before invoking this helper. var err error - current, err = adoptExistingResources(ctx, provider, providerType, specs, current, store, secretsProvider, hydratedOut) - if err != nil { - return err - } - // Compute the diff plan via the loaded provider so platform.ComputePlan // can dispatch ResourceDriver.Diff over the live plugin process for // honest Replace-action classification (T3.6e). Indirected through // computeInfraPlan so tests can spy on the provider arg without // standing up a real gRPC plugin (var-seam pattern matches // resolveIaCProvider/loadIaCPlugin in deploy_providers.go). - plan, err := computeInfraPlan(ctx, provider, specs, current) + var planConfig *config.WorkflowConfig + if cfgFile != "" { + planConfig, err = config.LoadFromFile(cfgFile) + if err != nil { + return fmt.Errorf("load planning config: %w", err) + } + } + planning, err := prepareDeclarativePlanningSpecs(ctx, provider, specs, current, planConfig, envName) + if err != nil { + return fmt.Errorf("prepare planning inputs: %w", err) + } + current, err = adoptExistingResources(ctx, provider, providerType, specs, planning, current, store, secretsProvider, hydratedOut) + if err != nil { + return err + } + plan, err := computeDeclarativeInfraPlan(ctx, provider, specs, current, planConfig, envName) if err != nil { return fmt.Errorf("compute plan: %w", err) } @@ -459,6 +460,15 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi // goes through wfctlhelpers.ApplyPlanWithHooks (Replace + drift // postcondition + IaCProviderFinalizer fan-out). hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + // Reconciliation remains provider-scoped, but references may point at an + // unchanged or just-applied resource owned by another provider group. + hooks.CurrentState, err = store.ListResources(ctx) + if err != nil { + return fmt.Errorf("load reference state: %w", err) + } + if isNoopStateStore(store) { + hooks.CurrentState = current + } wireApplyProgressIntoHooks(&hooks, plan.Actions) wireOwnershipGateIntoHooks(&hooks, provider) result, err := applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) @@ -662,6 +672,15 @@ func statePersistenceHooks( hydratedOut map[string]string, ) wfctlhelpers.ApplyPlanHooks { return wfctlhelpers.ApplyPlanHooks{ + ResolveSecret: func(ctx context.Context, key string) (string, error) { + if value, ok := hydratedOut[key]; ok { + return value, nil + } + if secretsProvider == nil { + return "", fmt.Errorf("%w: no secrets provider configured", interfaces.ErrValidation) + } + return secretsProvider.Get(ctx, key) + }, OnResourceApplied: func(ctx context.Context, driver interfaces.ResourceDriver, action interfaces.PlanAction, out interfaces.ResourceOutput) error { hyd, persistErr := persistAppliedResourceOutput(ctx, store, secretsProvider, provider, providerType, driver, action, out) if persistErr != nil { @@ -817,7 +836,7 @@ func normalizeAppliedOutputIdentity(spec interfaces.ResourceSpec, out interfaces return out, nil } -func adoptExistingResources(ctx context.Context, provider interfaces.IaCProvider, providerType string, specs []interfaces.ResourceSpec, current []interfaces.ResourceState, store infraStateStore, secretsProvider secrets.Provider, hydratedOut map[string]string) ([]interfaces.ResourceState, error) { +func adoptExistingResources(ctx context.Context, provider interfaces.IaCProvider, providerType string, specs, planning []interfaces.ResourceSpec, current []interfaces.ResourceState, store infraStateStore, secretsProvider secrets.Provider, hydratedOut map[string]string) ([]interfaces.ResourceState, error) { if len(specs) == 0 { return current, nil } @@ -841,6 +860,10 @@ func adoptExistingResources(ctx context.Context, provider interfaces.IaCProvider } drivers := make(map[string]interfaces.ResourceDriver) + planningByName := make(map[string]interfaces.ResourceSpec, len(planning)) + for _, spec := range planning { + planningByName[spec.Name] = spec + } for _, spec := range specs { if _, exists := currentByName[spec.Name]; exists { continue @@ -864,7 +887,11 @@ func adoptExistingResources(ctx context.Context, provider interfaces.IaCProvider } drivers[spec.Type] = driver } - ref, adoptable, err := adoptionRefForSpec(driver, spec) + adoptionSpec := spec + if prepared, ok := planningByName[spec.Name]; ok { + adoptionSpec = prepared + } + ref, adoptable, err := adoptionRefForSpec(driver, adoptionSpec) if err != nil { return nil, err } @@ -1628,6 +1655,11 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions)) // v2 is the only supported dispatch per ADR 0024 + workflow#699. hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + currentState, stateErr := store.ListResources(ctx) + if stateErr != nil { + return fmt.Errorf("load state for runtime references: %w", stateErr) + } + hooks.CurrentState = currentState wireApplyProgressIntoHooks(&hooks, plan.Actions) wireOwnershipGateIntoHooks(&hooks, provider) result, err := applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go index 17286fc4..2ebcd6c7 100644 --- a/cmd/wfctl/infra_apply_test.go +++ b/cmd/wfctl/infra_apply_test.go @@ -318,14 +318,6 @@ func (p *readBackedFailingApplyProvider) installAsV2Dispatch(t testing.TB) { t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) } -type noDriverApplyProvider struct { - applyCapture -} - -func (p *noDriverApplyProvider) ResourceDriver(resourceType string) (interfaces.ResourceDriver, error) { - return nil, fmt.Errorf("no driver for %s", resourceType) -} - type waitHealthDriver struct { readDriver results []interfaces.HealthResult @@ -1111,7 +1103,7 @@ func TestAdoptExistingResources_AdoptionRoutingSaveFailureCleansSecretsOnly(t *t store := &fakeStateStore{saveErr: errors.New("state unavailable")} secretsProvider := newEnvTestProvider() - _, err := adoptExistingResources(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, secretsProvider, map[string]string{}) + _, err := adoptExistingResources(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, nil, store, secretsProvider, map[string]string{}) if err == nil { t.Fatalf("adoptExistingResources succeeded, want state persistence error") } @@ -1459,7 +1451,7 @@ func TestApplyWithProvider_SkipsAdoptionWhenAppDriverHasNoLocator(t *testing.T) Type: "infra.container_service", Config: map[string]any{"image": "example/app:latest"}, } - provider := &noDriverApplyProvider{} + provider := &readBackedProvider{driver: &stubSensitiveDriver{}} provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil) diff --git a/cmd/wfctl/infra_declarative_apply_test.go b/cmd/wfctl/infra_declarative_apply_test.go new file mode 100644 index 00000000..5bba7e54 --- /dev/null +++ b/cmd/wfctl/infra_declarative_apply_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" +) + +type declarativeCLIDriver struct { + stubSensitiveDriver + passwords []string +} + +func (*declarativeCLIDriver) SensitiveInputPaths(context.Context) ([]string, error) { + return []string{"/password"}, nil +} + +func (d *declarativeCLIDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.passwords = append(d.passwords, spec.Config["password"].(string)) + spec.Config["password"] = "driver-mutation" + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "database-id"}, nil +} + +type declarativeCLIProvider struct { + applyCapture + driver interfaces.ResourceDriver + driverErr error +} + +type sensitiveAdoptionCLIDriver struct { + declarativeCLIDriver + reads int +} + +func (*sensitiveAdoptionCLIDriver) AdoptionRef(spec interfaces.ResourceSpec) (interfaces.ResourceRef, bool, error) { + return interfaces.ResourceRef{Name: spec.Name, Type: spec.Type, ProviderID: "existing-database"}, true, nil +} + +func (d *sensitiveAdoptionCLIDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + d.reads++ + return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil +} + +func TestApplyWithProvider_SensitiveRoutingRejectsBeforeAdoption(t *testing.T) { + driver := &sensitiveAdoptionCLIDriver{} + provider := &declarativeCLIProvider{driver: driver} + store := &stubInfraStore{} + err := applyWithProviderAndStore(t.Context(), provider, "test-cloud", []interfaces.ResourceSpec{{Name: "database", Type: "infra.database", Config: map[string]any{"password": "known-rejected-private-literal"}}}, nil, store, io.Discard, "", "", nil) + if !errors.Is(err, interfaces.ErrValidation) || driver.reads != 0 || len(store.saved) != 0 { + t.Fatalf("validation must precede adoption/persistence: err=%v reads=%d saved=%d", err, driver.reads, len(store.saved)) + } + if strings.Contains(err.Error(), "known-rejected-private-literal") { + t.Fatal("validation disclosed rejected credential") + } +} + +func (p *declarativeCLIProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return p.driver, p.driverErr +} + +func TestDeclarativePlanning_DiscoveryFailureStopsBeforePlan(t *testing.T) { + for _, discoveryErr := range []error{interfaces.ErrForbidden, interfaces.ErrTransient, context.Canceled} { + t.Run(discoveryErr.Error(), func(t *testing.T) { + provider := &declarativeCLIProvider{driverErr: discoveryErr} + plan, err := computeDeclarativeInfraPlan(t.Context(), provider, []interfaces.ResourceSpec{{Name: "database", Type: "infra.database", Config: map[string]any{"password": "rejected-unvalidated-literal"}}}, nil, nil, "") + if !errors.Is(err, discoveryErr) || len(plan.Actions) != 0 { + t.Fatalf("discovery failure produced an unvalidated plan: err=%v actions=%v", err, plan.Actions) + } + }) + } +} + +type crossProviderCLIDriver struct{ declarativeCLIDriver } + +func (*crossProviderCLIDriver) SensitiveInputPaths(context.Context) ([]string, error) { + return []string{"/env_vars/*"}, nil +} + +func (d *crossProviderCLIDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.passwords = append(d.passwords, spec.Config["env_vars"].(map[string]any)["PASSWORD"].(string)) + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "child-id"}, nil +} + +func TestInfraCLI_DeclarativeCrossProviderReferenceState(t *testing.T) { + const marker = "known-cross-provider-runtime-value" + t.Setenv(strings.ToUpper(sensitive.SecretKey("parent", "password")), marker) + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o750); err != nil { + t.Fatal(err) + } + writeStateFile(t, stateDir, "parent", "infra.database", "parent-id", map[string]any{"password": sensitive.Placeholder("parent", "password")}, map[string]any{"provider": "parent-provider"}) + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `infra: {auto_bootstrap: false} +modules: + - name: parent-provider + type: iac.provider + config: {provider: test-cloud} + - name: child-provider + type: iac.provider + config: {provider: test-cloud} + - name: state + type: iac.state + config: {backend: filesystem, directory: ` + stateDir + `} + - name: parent + type: infra.database + config: {provider: parent-provider} + - name: child + type: infra.database + config: {provider: child-provider, env_vars: {PASSWORD: "${parent.password}"}} +secrets: {provider: env} +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + driver := &crossProviderCLIDriver{} + provider := &declarativeCLIProvider{driver: driver} + original := resolveIaCProvider + resolveIaCProvider = func(context.Context, string, map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return provider, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = original }) + if _, err := applyInfraModules(t.Context(), cfgPath, ""); err != nil { + t.Fatal(err) + } + if len(driver.passwords) != 1 || driver.passwords[0] != marker { + t.Fatalf("cross-provider parent was not hydrated: %v", driver.passwords) + } + state, err := loadCurrentState(cfgPath, "") + if err != nil || len(state) != 2 { + t.Fatalf("ownership reconciliation changed parent: state=%v err=%v", state, err) + } + for _, resource := range state { + if resource.Name == "parent" && resource.ProviderID != "parent-id" { + t.Fatal("parent ownership was mutated") + } + if resource.Name == "child" && resource.AppliedConfig["env_vars"].(map[string]any)["PASSWORD"] != "${parent.password}" { + t.Fatal("cross-provider config was not declarative") + } + } +} + +func TestInfraCLI_KnownValueAbsentFromPlanAndAppliedState(t *testing.T) { + const marker = "known-cli-private-runtime-value" + const ref = "${WFCTL_CLI_DECLARATIVE_TOKEN}" + t.Setenv("WFCTL_CLI_DECLARATIVE_TOKEN", marker) + t.Setenv("WFCTL_CLI_PUBLIC_REGION", "public-region") + for _, mode := range []string{"direct", "persisted"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o750); err != nil { + t.Fatal(err) + } + writeStateFile(t, stateDir, "parent", "infra.database", "parent-id", + map[string]any{"password": sensitive.Placeholder("parent", "password")}, + map[string]any{"provider": "provider", "region": "public-region"}) + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `infra: + auto_bootstrap: false +modules: + - name: provider + type: iac.provider + config: {provider: test-cloud} + - name: state + type: iac.state + config: {backend: filesystem, directory: ` + stateDir + `} + - name: database + type: infra.database + config: + provider: provider + password: "${WFCTL_CLI_DECLARATIVE_TOKEN}" + region: "${WFCTL_CLI_PUBLIC_REGION}" + - name: parent + type: infra.database + config: {provider: provider, region: public-region} +secrets: + provider: env + generate: + - key: WFCTL_CLI_DECLARATIVE_TOKEN + type: infra_output + source: parent.password +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + driver := &declarativeCLIDriver{} + provider := &declarativeCLIProvider{driver: driver} + originalResolver := resolveIaCProvider + resolveIaCProvider = func(context.Context, string, map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return provider, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = originalResolver }) + originalCompute := computeInfraPlan + var observedPlan []byte + computeInfraPlan = func(ctx context.Context, p interfaces.IaCProvider, specs []interfaces.ResourceSpec, current []interfaces.ResourceState) (interfaces.IaCPlan, error) { + plan, err := platform.ComputePlan(ctx, p, specs, current) + observedPlan, _ = json.Marshal(plan) + return plan, err + } + t.Cleanup(func() { computeInfraPlan = originalCompute }) + if mode == "direct" { + if _, err := applyInfraModules(t.Context(), cfgPath, ""); err != nil { + t.Fatal(err) + } + } else { + planPath := filepath.Join(dir, "plan.json") + if err := runInfraPlan([]string{"--config", cfgPath, "-o", planPath}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(planPath) + if err != nil || strings.Contains(string(data), marker) { + t.Fatalf("persisted plan leaked known value: err=%v plan=%s", err, data) + } + plan, err := loadPlanFromFile(planPath) + if err != nil { + t.Fatal(err) + } + if plan.Actions[0].Resource.Config["password"] != ref || plan.Actions[0].ResolvedConfigHash != platform.ConfigHash(plan.Actions[0].Resource.Config) { + t.Fatal("plan config/hash must remain declarative") + } + if err := runInfraApply([]string{"--config", cfgPath, "--auto-approve", "--plan", planPath}); err != nil { + t.Fatal(err) + } + } + if strings.Contains(string(observedPlan), marker) { + t.Fatalf("plan computation already contained resolved credential: %s", observedPlan) + } + if len(driver.passwords) != 1 || driver.passwords[0] != marker { + t.Fatalf("driver must receive resolved credential once: %v", driver.passwords) + } + state, err := loadCurrentState(cfgPath, "") + if err != nil || len(state) != 2 { + t.Fatalf("load persisted state: state=%v err=%v", state, err) + } + data, err := json.Marshal(state) + if err != nil || strings.Contains(string(data), marker) || strings.Contains(string(data), "driver-mutation") { + t.Fatalf("state leaked resolved or driver-mutated config: %s err=%v", data, err) + } + var applied interfaces.ResourceState + for _, resource := range state { + if resource.Name == "database" { + applied = resource + } + } + if applied.AppliedConfig["password"] != ref || applied.ConfigHash != platform.ConfigHash(applied.AppliedConfig) || applied.AppliedConfig["region"] != "public-region" { + t.Fatalf("declarative state/hash or public resolution lost: %+v", applied) + } + }) + } +} diff --git a/cmd/wfctl/infra_declarative_plan.go b/cmd/wfctl/infra_declarative_plan.go new file mode 100644 index 00000000..56727c1e --- /dev/null +++ b/cmd/wfctl/infra_declarative_plan.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/sensitiveinputs" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" +) + +// Drivers compare a separate planning copy. Sensitive leaves stay references +// even there; actions and hashes always retain the declarative source config. +func computeDeclarativeInfraPlan(ctx context.Context, provider interfaces.IaCProvider, specs []interfaces.ResourceSpec, current []interfaces.ResourceState, cfg *config.WorkflowConfig, envName string) (interfaces.IaCPlan, error) { + planning, err := prepareDeclarativePlanningSpecs(ctx, provider, specs, current, cfg, envName) + if err != nil { + return interfaces.IaCPlan{}, err + } + plan, err := computeInfraPlan(ctx, provider, planning, current) + if err != nil { + return interfaces.IaCPlan{}, err + } + byName := make(map[string]interfaces.ResourceSpec, len(specs)) + for _, spec := range specs { + byName[spec.Name] = spec + } + for i := range plan.Actions { + action := &plan.Actions[i] + if spec, ok := byName[action.Resource.Name]; ok { + action.Resource = spec + action.ResolvedConfigHash = platform.ConfigHash(spec.Config) + } + } + return plan, nil +} + +func prepareDeclarativePlanningSpecs(ctx context.Context, provider interfaces.IaCProvider, specs []interfaces.ResourceSpec, current []interfaces.ResourceState, cfg *config.WorkflowConfig, envName string) ([]interfaces.ResourceSpec, error) { + preserved := make(map[string][]string, len(specs)) + for _, spec := range specs { + if provider == nil { + continue + } + driver, err := provider.ResourceDriver(spec.Type) + if err != nil { + if errors.Is(err, interfaces.ErrProviderMethodUnimplemented) { + continue + } + return nil, fmt.Errorf("%s/%s: resolve resource driver: %w", spec.Type, spec.Name, err) + } + declarer, ok := driver.(interfaces.ResourceSensitiveInputDeclarer) + if !ok { + continue + } + paths, err := declarer.SensitiveInputPaths(ctx) + if errors.Is(err, interfaces.ErrProviderMethodUnimplemented) { + continue + } + if err != nil { + return nil, fmt.Errorf("%s/%s: sensitive input discovery: %w", spec.Type, spec.Name, err) + } + if err := sensitiveinputs.ValidateReferences(spec.Config, paths); err != nil { + return nil, fmt.Errorf("%s/%s: %w", spec.Type, spec.Name, err) + } + preserved[spec.Name], err = sensitiveinputs.Expand(spec.Config, paths) + if err != nil { + return nil, err + } + } + planning, _, err := resolveSpecsAgainstStatePreserving(specs, current, cfg, envName, preserved) + return planning, err +} diff --git a/cmd/wfctl/infra_plan_provider.go b/cmd/wfctl/infra_plan_provider.go index 8fcf1e8e..4d8be807 100644 --- a/cmd/wfctl/infra_plan_provider.go +++ b/cmd/wfctl/infra_plan_provider.go @@ -43,7 +43,7 @@ func computePlanForInfraSpecs(ctx context.Context, cfgFile, envName string, desi // keeps minimal/legacy fixtures working without forcing them to // declare a provider just to render a plan. if len(providerDefs) == 0 { - return computeInfraPlan(ctx, nil, desired, current) + return computeDeclarativeInfraPlan(ctx, nil, desired, current, cfg, envName) } groupOrder, groups, err := groupSpecsByProviderRef(desired, providerDefs, disabledProviders, envName) @@ -103,7 +103,7 @@ func computePlanForInfraSpecs(ctx context.Context, cfgFile, envName string, desi allowProviderTypeFallback := providerTypeCounts[g.provType] == 1 scopedCurrent := filterCurrentStateForProvider(current, g.provType, ref, g.specs, allowProviderTypeFallback) - sub, err := computeInfraPlan(ctx, provider, g.specs, scopedCurrent) + sub, err := computeDeclarativeInfraPlan(ctx, provider, g.specs, scopedCurrent, cfg, envName) if err != nil { return nil, fmt.Errorf("provider %q: compute plan: %w", ref, err) } diff --git a/cmd/wfctl/infra_resolve_state.go b/cmd/wfctl/infra_resolve_state.go index c389559c..cafb0f3b 100644 --- a/cmd/wfctl/infra_resolve_state.go +++ b/cmd/wfctl/infra_resolve_state.go @@ -58,6 +58,16 @@ func resolveSpecsAgainstState( current []interfaces.ResourceState, cfg *config.WorkflowConfig, envName string, +) ([]interfaces.ResourceSpec, []ResolutionDiagnostic, error) { + return resolveSpecsAgainstStatePreserving(specs, current, cfg, envName, nil) +} + +func resolveSpecsAgainstStatePreserving( + specs []interfaces.ResourceSpec, + current []interfaces.ResourceState, + cfg *config.WorkflowConfig, + envName string, + preserved map[string][]string, ) ([]interfaces.ResourceSpec, []ResolutionDiagnostic, error) { syncedOutputs := buildSyncedOutputsFromState(current) resolvedSecrets := buildResolvedSecretsFromState(cfg, current, envName) @@ -67,7 +77,7 @@ func resolveSpecsAgainstState( out := make([]interfaces.ResourceSpec, len(specs)) var diags []ResolutionDiagnostic for i, spec := range specs { - resolved, unresolved, err := jitsubst.TryResolveSpec(spec, nil, syncedOutputs, envLookup) + resolved, unresolved, err := jitsubst.TryResolveSpecPreservingPaths(spec, nil, syncedOutputs, envLookup, preserved[spec.Name]) if err != nil { return nil, nil, fmt.Errorf("resolve %q: %w", spec.Name, err) } diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 2487c99d..d0ef8407 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2124,6 +2124,14 @@ Provider plugins map ownership to their native mechanism, such as `managed-by:/: ". result.Errors = append(result.Errors, interfaces.ActionError{ Resource: "", Action: "finalize", - Error: hookErr.Error(), + Error: redactDiagnostic(hookErr.Error()), }) // Outer err carries the "plan finalize:" prefix because the // outer-err caller path lacks the structured Resource/Action @@ -245,7 +261,7 @@ func applyPlanWithEnvProviderAndHooks( defer func() { if r := recover(); r != nil { result.InputDriftReport = nil - log.Printf("warning: input-drift postcondition panicked: %v", r) + log.Printf("warning: input-drift postcondition panicked: %s", redactDiagnostic(fmt.Sprint(r))) } }() // Resolve the apply-time env provider lazily so the production @@ -275,6 +291,12 @@ func applyPlanWithEnvProviderAndHooks( // new outputs are written per-resource on success and become visible // to later actions in the same plan). syncedOutputs := buildInitialSyncedOutputs(plan.Actions) + for i := range hooks.CurrentState { + state := &hooks.CurrentState[i] + if _, exists := syncedOutputs[state.Name]; !exists { + syncedOutputs[state.Name] = flattenStateOutputs(state) + } + } if deleteHookActive { if err := preflightProviderOwnedReplaceWithDeleteHooks(p, plan); err != nil { @@ -317,7 +339,7 @@ func applyPlanWithEnvProviderAndHooks( } errStr := "" if iterErr != nil { - errStr = iterErr.Error() + errStr = redactDiagnostic(iterErr.Error()) } outcome := interfaces.ActionOutcome{ //nolint:gosec // ActionIndex is loop counter bound by len(plan.Actions); G115 false positive. @@ -330,7 +352,7 @@ func applyPlanWithEnvProviderAndHooks( func() { defer func() { if r := recover(); r != nil { - log.Printf("warning: OnActionComplete panicked for %s/%s: %v", action.Resource.Type, action.Resource.Name, r) + log.Printf("warning: OnActionComplete panicked for %s/%s: %s", action.Resource.Type, action.Resource.Name, redactDiagnostic(fmt.Sprint(r))) } }() hooks.OnActionComplete(ctx, action, outcome) @@ -367,6 +389,35 @@ func applyPlanWithEnvProviderAndHooks( return } } + d, err := p.ResourceDriver(action.Resource.Type) + if err != nil { + result.Errors = append(result.Errors, interfaces.ActionError{ + Resource: action.Resource.Name, Action: action.Action, Error: fmt.Sprintf("resolve driver: %v", err), + }) + iterErr = fmt.Errorf("resolve driver: %w", err) + iterStatus = statusForPreDispatchSkip() + return + } + var sensitivePaths []string + if declarer, ok := d.(interfaces.ResourceSensitiveInputDeclarer); ok { + sensitivePaths, err = declarer.SensitiveInputPaths(ctx) + if errors.Is(err, interfaces.ErrProviderMethodUnimplemented) { + sensitivePaths, err = nil, nil + } + if err != nil { + result.Errors = append(result.Errors, interfaces.ActionError{ + Resource: action.Resource.Name, Action: action.Action, Error: fmt.Sprintf("sensitive input discovery: %v", err), + }) + iterErr = fmt.Errorf("sensitive input discovery: %w", err) + iterStatus = statusForPreDispatchSkip() + return + } + } + if err := sensitiveinputs.ValidateReferences(action.Resource.Config, sensitivePaths); err != nil { + result.Errors = append(result.Errors, interfaces.ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) + iterErr, iterStatus = err, statusForPreDispatchSkip() + return + } // Per-action JIT substitution — resolve ${VAR} / ${MODULE.field} // / ${MODULE.id} in action.Resource.Config against // result.ReplaceIDMap (this-apply Replace ProviderIDs) and @@ -378,7 +429,11 @@ func applyPlanWithEnvProviderAndHooks( // production env source; nil-safe inside ResolveSpec — refs that // only need replaceIDMap / syncedOutputs still resolve. Phase 2.3 // (#698): JIT-fail is pre-dispatch — no driver call yet. - resolved, err := jitsubst.ResolveSpec(action.Resource, result.ReplaceIDMap, syncedOutputs, os.LookupEnv) + var secretLookup func(string) (string, error) + if hooks.ResolveSecret != nil { + secretLookup = func(key string) (string, error) { return hooks.ResolveSecret(ctx, key) } + } + resolved, err := jitsubst.ResolveSpecWithSecretLookup(action.Resource, result.ReplaceIDMap, syncedOutputs, os.LookupEnv, secretLookup) if err != nil { result.Errors = append(result.Errors, interfaces.ActionError{ Resource: action.Resource.Name, @@ -389,27 +444,28 @@ func applyPlanWithEnvProviderAndHooks( iterStatus = statusForPreDispatchSkip() return } - action.Resource = resolved - // Phase 2.3 (#698): driver-resolve-fail is pre-dispatch — no - // driver method has been called yet. - d, err := p.ResourceDriver(action.Resource.Type) + resolved.DependsOn = slices.Clone(action.Resource.DependsOn) + if resolved.Hints != nil { + hints := *resolved.Hints + resolved.Hints = &hints + } + runtimeAction := action + runtimeAction.Resource = resolved + runtimeRedactor, err := sensitiveinputs.DiagnosticRedactor(resolved.Config, sensitivePaths) if err != nil { - result.Errors = append(result.Errors, interfaces.ActionError{ - Resource: action.Resource.Name, - Action: action.Action, - Error: fmt.Sprintf("resolve driver: %v", err), - }) - iterErr = fmt.Errorf("resolve driver: %v", err) - iterStatus = statusForPreDispatchSkip() + result.Errors = append(result.Errors, interfaces.ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) + iterErr, iterStatus = err, statusForPreDispatchSkip() return } + priorRedactor := redactDiagnostic + redactDiagnostic = func(message string) string { return runtimeRedactor(priorRedactor(message)) } // Capture result.Resources length pre-dispatch so we can identify // the entry (if any) that this action appended and propagate its // outputs into syncedOutputs for subsequent actions. doCreate / // doUpdate / doReplace each append on success; doDelete does not. preLen := len(result.Resources) actionHooks := hooks - actionHooks.OnResourceDeleted = func(ctx context.Context, action interfaces.PlanAction) error { + actionHooks.OnResourceDeleted = func(ctx context.Context, _ interfaces.PlanAction) error { if hooks.OnResourceDeleted != nil { if err := hooks.OnResourceDeleted(ctx, action); err != nil { return err @@ -418,7 +474,7 @@ func applyPlanWithEnvProviderAndHooks( delete(syncedOutputs, action.Resource.Name) return nil } - if err := dispatchAction(ctx, d, action, result, actionHooks, deleteHookActive); err != nil { + if err := dispatchAction(ctx, d, runtimeAction, result, actionHooks, deleteHookActive); err != nil { var hookErr hookDispatchError if errors.As(err, &hookErr) { // Phase 2.3 (#698): hookDispatchError wraps a hook @@ -436,7 +492,7 @@ func applyPlanWithEnvProviderAndHooks( result.Errors = append(result.Errors, interfaces.ActionError{ Resource: action.Resource.Name, Action: action.Action, - Error: err.Error(), + Error: redactDiagnostic(err.Error()), }) iterErr = err iterStatus = statusForDispatchError(action.Action) @@ -494,6 +550,25 @@ func applyPlanWithEnvProviderAndHooks( return result, nil } +type redactedApplyError struct { + cause error + message string +} + +func (e redactedApplyError) Error() string { return e.message } +func (e redactedApplyError) Unwrap() error { return e.cause } + +func redactApplyError(err error, redact func(string) string) error { + if err == nil { + return nil + } + message := err.Error() + if safe := redact(message); safe != message { + return redactedApplyError{cause: err, message: safe} + } + return err +} + // Phase 2.3 (workflow#698): replaced single mapDispatchErrToStatus with // 3 phase-specific helpers — each per-action exit path now assigns its // status directly from the call site (clearer than late-mapping in defer). diff --git a/iac/wfctlhelpers/apply_sensitive_inputs_test.go b/iac/wfctlhelpers/apply_sensitive_inputs_test.go new file mode 100644 index 00000000..c61cc740 --- /dev/null +++ b/iac/wfctlhelpers/apply_sensitive_inputs_test.go @@ -0,0 +1,281 @@ +package wfctlhelpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" +) + +type sensitiveDispatchDriver struct { + *fakeDriver + paths []string + declarationErr error + dispatchErr error + mutate bool + seenPasswords []string +} + +func (d *sensitiveDispatchDriver) SensitiveInputPaths(context.Context) ([]string, error) { + return d.paths, d.declarationErr +} + +func (d *sensitiveDispatchDriver) record(spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.seenPasswords = append(d.seenPasswords, spec.Config["password"].(string)) + if d.mutate { + spec.Config["password"] = "driver-only-change" + spec.Config["nested"].(map[string]any)["values"].([]any)[0] = "driver-only-change" + spec.DependsOn[0] = "driver-only-change" + spec.Hints.CPU = "driver-only-change" + } + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "provider-id"}, d.dispatchErr +} + +func (d *sensitiveDispatchDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return d.record(spec) +} + +func (d *sensitiveDispatchDriver) Update(_ context.Context, _ interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return d.record(spec) +} + +type sensitiveDispatchProvider struct { + *fakeProvider + driver interfaces.ResourceDriver +} + +type failingDiscoveryProvider struct { + *sensitiveDispatchProvider + err error +} + +func (p *failingDiscoveryProvider) ResourceDriver(resourceType string) (interfaces.ResourceDriver, error) { + if resourceType == "infra.unavailable" { + return nil, p.err + } + return p.sensitiveDispatchProvider.ResourceDriver(resourceType) +} + +func TestApplyPlan_KnownValueAbsentFromLaterDiscoveryErrors(t *testing.T) { + const marker = "known-private-prior-action-value" + t.Setenv("WFCTL_SENSITIVE_DISPATCH_TEST", marker) + for _, source := range []string{"driver", "declaration"} { + t.Run(source, func(t *testing.T) { + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password"}} + provider := &failingDiscoveryProvider{sensitiveDispatchProvider: &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver}, err: fmt.Errorf("discovery rejected %s", marker)} + secondType := "infra.unavailable" + hooks := ApplyPlanHooks{} + if source == "declaration" { + secondType = "infra.database" + hooks.OnResourceApplied = func(context.Context, interfaces.ResourceDriver, interfaces.PlanAction, interfaces.ResourceOutput) error { + driver.declarationErr = provider.err + return nil + } + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: specWithConfig("first", "infra.database", map[string]any{"password": "${WFCTL_SENSITIVE_DISPATCH_TEST}"})}, + {Action: "create", Resource: specWithConfig("second", secondType, map[string]any{"password": "${WFCTL_SENSITIVE_DISPATCH_TEST}"})}, + }} + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, hooks) + if err != nil || len(result.Errors) != 1 || len(result.Actions) != 2 || result.Actions[1].Status != interfaces.ActionStatusSkipped { + t.Fatalf("best-effort discovery semantics lost: result=%+v err=%v", result, err) + } + data, err := json.Marshal(result) + if err != nil || strings.Contains(string(data), marker) { + t.Fatalf("prior resolved credential leaked in discovery metadata: %s err=%v", data, err) + } + }) + } +} + +func (p *sensitiveDispatchProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + +func TestApplyPlan_DeclarativeHooksAndResolvedCopy(t *testing.T) { + const marker = "known-private-runtime-value" + t.Setenv("WFCTL_SENSITIVE_DISPATCH_TEST", marker) + for _, actionKind := range []string{"create", "update", "replace"} { + t.Run(actionKind, func(t *testing.T) { + config := map[string]any{ + "password": "${WFCTL_SENSITIVE_DISPATCH_TEST}", + "nested": map[string]any{"values": []any{"${WFCTL_SENSITIVE_DISPATCH_TEST}"}}, + } + spec := interfaces.ResourceSpec{ + Name: "database", Type: "infra.database", Config: config, + Hints: &interfaces.ResourceHints{CPU: "2"}, DependsOn: []string{"parent"}, + } + current := &interfaces.ResourceState{Name: spec.Name, Type: spec.Type, ProviderID: "prior-id", AppliedConfig: config} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: actionKind, Resource: spec, Current: current}}} + before, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password", "/nested/values/*"}, mutate: true} + provider := &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver} + store := &FSStateStore{dir: t.TempDir()} + var observed []json.RawMessage + capture := func(action interfaces.PlanAction) { + data, err := json.Marshal(action) + if err != nil { + t.Fatal(err) + } + observed = append(observed, data) + } + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{ + OnBeforeAction: func(_ context.Context, action interfaces.PlanAction) error { capture(action); return nil }, + OnResourceApplied: func(ctx context.Context, _ interfaces.ResourceDriver, action interfaces.PlanAction, out interfaces.ResourceOutput) error { + capture(action) + return store.SaveResource(ctx, interfaces.ResourceState{ID: spec.Name, Name: spec.Name, Type: spec.Type, + ProviderID: out.ProviderID, AppliedConfig: action.Resource.Config, ConfigHash: platform.ConfigHash(action.Resource.Config)}) + }, + OnResourceDeleted: func(_ context.Context, action interfaces.PlanAction) error { capture(action); return nil }, + OnActionComplete: func(_ context.Context, action interfaces.PlanAction, _ interfaces.ActionOutcome) { capture(action) }, + }) + if err != nil || len(result.Errors) != 0 { + t.Fatalf("apply err=%v result=%+v", err, result) + } + if len(driver.seenPasswords) != 1 || driver.seenPasswords[0] != marker { + t.Fatalf("driver must receive the resolved value once: %v", driver.seenPasswords) + } + for _, data := range observed { + if strings.Contains(string(data), marker) || strings.Contains(string(data), "driver-only-change") { + t.Fatalf("declarative hook received runtime values: %s", data) + } + } + after, err := json.Marshal(plan) + if err != nil || string(after) != string(before) { + t.Fatalf("driver changed original plan/current state: %s -> %s (err=%v)", before, after, err) + } + persisted, err := os.ReadFile(filepath.Join(store.dir, spec.Name+".json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(persisted), marker) || strings.Contains(string(persisted), "driver-only-change") { + t.Fatalf("persisted runtime config: %s", persisted) + } + state, err := store.GetResource(t.Context(), spec.Name) + if err != nil || state.ConfigHash != platform.ConfigHash(config) || state.AppliedConfig["password"] != "${WFCTL_SENSITIVE_DISPATCH_TEST}" { + t.Fatalf("declarative state/hash lost: state=%+v err=%v", state, err) + } + }) + } +} + +func TestApplyPlan_KnownValueAbsentFromDispatchErrors(t *testing.T) { + const marker = "known-private-runtime-error-value" + t.Setenv("WFCTL_SENSITIVE_DISPATCH_TEST", marker) + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password"}, dispatchErr: fmt.Errorf("provider rejected %s", marker)} + provider := &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: specWithConfig("database", "infra.database", map[string]any{"password": "${WFCTL_SENSITIVE_DISPATCH_TEST}"})}}} + var hookOutcome interfaces.ActionOutcome + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{ + OnActionComplete: func(_ context.Context, _ interfaces.PlanAction, outcome interfaces.ActionOutcome) { + hookOutcome = outcome + }, + }) + if err != nil || len(result.Errors) != 1 || hookOutcome.Status != interfaces.ActionStatusError { + t.Fatalf("default error propagation lost: err=%v result=%+v outcome=%+v", err, result, hookOutcome) + } + data, err := json.Marshal(struct { + Result *interfaces.ApplyResult + Outcome interfaces.ActionOutcome + }{result, hookOutcome}) + if err != nil || strings.Contains(string(data), marker) { + t.Fatalf("runtime credential in diagnostics: %s err=%v", data, err) + } + if len(driver.seenPasswords) != 1 || driver.seenPasswords[0] != marker { + t.Fatal("driver did not receive the real runtime value") + } +} + +func TestApplyPlan_SensitiveRoutingRejectsBeforeDispatch(t *testing.T) { + for _, tc := range []struct { + name string + value string + declarationErr error + wantDispatch bool + }{ + {"literal", "unapproved-private-literal", nil, false}, + {"discovery denied", "${WFCTL_SENSITIVE_DISPATCH_TEST}", errors.New("discovery denied"), false}, + {"legacy unimplemented", "${WFCTL_SENSITIVE_DISPATCH_TEST}", interfaces.ErrProviderMethodUnimplemented, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("WFCTL_SENSITIVE_DISPATCH_TEST", "resolved-fixture") + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password"}, declarationErr: tc.declarationErr} + provider := &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: specWithConfig("database", "infra.database", map[string]any{"password": tc.value})}}} + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{}) + if err != nil || (len(driver.seenPasswords) != 0) != tc.wantDispatch || (len(result.Errors) == 0) != tc.wantDispatch { + t.Fatalf("declaration gate: err=%v result=%+v calls=%d", err, result, len(driver.seenPasswords)) + } + }) + } +} + +func TestApplyPlan_DeclarativeReferencesUseUnchangedSiblingState(t *testing.T) { + const marker = "known-private-sibling-runtime-value" + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password"}} + provider := &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: specWithConfig("consumer", "infra.database", map[string]any{"password": "${parent.password}"})}}} + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{ + CurrentState: []interfaces.ResourceState{{Name: "parent", Type: "infra.database", Outputs: map[string]any{"password": "secret_ref://parent_password"}}}, + ResolveSecret: func(_ context.Context, key string) (string, error) { + if key != "parent_password" { + t.Fatalf("lookup key=%q", key) + } + return marker, nil + }, + }) + if err != nil || len(result.Errors) != 0 || len(driver.seenPasswords) != 1 || driver.seenPasswords[0] != marker { + t.Fatalf("sibling resolution failed: err=%v result=%+v driver=%v", err, result, driver.seenPasswords) + } + if plan.Actions[0].Resource.Config["password"] != "${parent.password}" { + t.Fatal("sibling resolution modified plan") + } +} + +func TestApplyPlan_KnownValueAbsentFromFatalHooksPreservesCause(t *testing.T) { + const marker = "known-private-runtime-hook-value" + t.Setenv("WFCTL_SENSITIVE_DISPATCH_TEST", marker) + for _, source := range []string{"applied", "finalize"} { + t.Run(source, func(t *testing.T) { + cause := errors.New("durable hook failure") + hookErr := fmt.Errorf("cannot persist %s: %w", marker, cause) + driver := &sensitiveDispatchDriver{fakeDriver: &fakeDriver{}, paths: []string{"/password"}} + provider := &sensitiveDispatchProvider{fakeProvider: newFakeProvider(), driver: driver} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: specWithConfig("database", "infra.database", map[string]any{"password": "${WFCTL_SENSITIVE_DISPATCH_TEST}"})}}} + var outcome interfaces.ActionOutcome + hooks := ApplyPlanHooks{ + OnActionComplete: func(_ context.Context, _ interfaces.PlanAction, observed interfaces.ActionOutcome) { + outcome = observed + }, + } + if source == "applied" { + hooks.OnResourceApplied = func(context.Context, interfaces.ResourceDriver, interfaces.PlanAction, interfaces.ResourceOutput) error { + return hookErr + } + } else { + hooks.OnPlanComplete = func(context.Context) error { return hookErr } + } + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, hooks) + if !errors.Is(err, cause) || strings.Contains(err.Error(), marker) { + t.Fatalf("redacted fatal error must retain its cause: %v", err) + } + data, marshalErr := json.Marshal(struct { + Result *interfaces.ApplyResult + Outcome interfaces.ActionOutcome + }{result, outcome}) + if marshalErr != nil || strings.Contains(string(data), marker) { + t.Fatalf("fatal hook diagnostic leaked: %s err=%v", data, marshalErr) + } + }) + } +} diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index 7d78eb53..7b19f0cc 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -122,8 +122,10 @@ type PlanAction struct { Current *ResourceState `json:"current,omitempty"` Changes []FieldChange `json:"changes,omitempty"` - // ResolvedConfigHash is the SHA-256 of POST-substitution Resource.Config, - // computed via platform.ConfigHash. Encoded as lower-case hex (no + // ResolvedConfigHash is the SHA-256 of the action's Resource.Config, + // computed via platform.ConfigHash. wfctl preserves declarative references + // here; runtime-resolved driver copies are never hashed into persisted plans. + // The legacy field/wire name is retained for compatibility. Lower-case hex (no // "sha256:" prefix); empty string when the config map is empty // (platform.ConfigHash short-circuit). The field uses `omitempty`, so the // empty-string case is ABSENT from plan.json — consumers should treat From cb2df54a7df28b474bea86313b2a621e579589a2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 3 Oct 2026 01:29:20 -0400 Subject: [PATCH 3/5] feat(iac): persist cleanup and bound jobs Journal exact secret keys and namespaces before writes; retain deletion debt until absence is verified after restart. Only actual creations permit compensation; create-conflict upserts retain existing resources. Add optional targeted, bounded provider jobs and independent cancellation. Lifecycle JSONB migration is additive; legacy state remains readable and metadata-dropping backends fail closed. --- cmd/wfctl/infra.go | 20 +- cmd/wfctl/infra_apply.go | 128 ++-- cmd/wfctl/infra_apply_refresh.go | 34 +- cmd/wfctl/infra_apply_refresh_test.go | 46 ++ .../infra_apply_sensitive_routing_test.go | 53 +- cmd/wfctl/infra_apply_test.go | 10 +- cmd/wfctl/infra_output_secret_journal_test.go | 544 ++++++++++++++ cmd/wfctl/infra_output_secrets.go | 125 ++-- cmd/wfctl/infra_secret_aliases.go | 86 +++ cmd/wfctl/infra_secret_cleanup.go | 398 +++++++++++ cmd/wfctl/infra_secret_cleanup_test.go | 669 ++++++++++++++++++ cmd/wfctl/infra_state_lifecycle_test.go | 133 ++++ cmd/wfctl/infra_state_store.go | 31 +- docs/WFCTL.md | 24 + iac/providerclient/adapter.go | 53 +- iac/providerclient/job_contract_test.go | 382 ++++++++++ iac/sensitive/route.go | 88 ++- iac/sensitive/route_test.go | 107 ++- iac/wfctlhelpers/apply.go | 61 +- iac/wfctlhelpers/apply_update_delete_test.go | 69 ++ iac/wfctlhelpers/state.go | 41 +- iac/wfctlhelpers/state_invariants_test.go | 38 + interfaces/iac_canonical_types.go | 36 +- interfaces/iac_provider.go | 9 + interfaces/iac_provider_jobs_test.go | 92 +++ interfaces/iac_state.go | 49 +- module/iac_state.go | 33 +- module/iac_state_fs.go | 72 +- module/iac_state_fs_durability_test.go | 141 ++++ module/iac_state_fs_nonwindows.go | 18 + module/iac_state_fs_windows.go | 34 + module/iac_state_fs_windows_test.go | 66 ++ module/iac_state_grpc_client.go | 125 +++- module/iac_state_lifecycle_grpc_test.go | 349 +++++++++ ...ate_lifecycle_postgres_integration_test.go | 103 +++ module/iac_state_lifecycle_test.go | 249 +++++++ module/iac_state_memory.go | 23 +- module/iac_state_postgres.go | 43 +- module/iac_state_postgres_test.go | 14 +- platform/differ.go | 13 + platform/differ_test.go | 18 + plugin/external/adapter.go | 10 +- plugin/external/iac_jobs_launch_test.go | 262 +++++++ plugin/external/proto/iac.pb.go | 337 +++++---- plugin/external/proto/iac.proto | 15 + plugin/external/proto/iac_grpc.pb.go | 110 +++ .../external/proto/iac_job_contract_test.go | 32 + plugin/external/proto/iac_proto_test.go | 2 + plugin/external/sdk/iac_jobs.go | 158 +++++ plugin/external/sdk/iac_jobs_test.go | 44 ++ plugin/external/sdk/iacserver.go | 14 + .../external/testdata/iac-job-plugin/main.go | 115 +++ wftest/bdd/strict_iac.go | 9 +- wftest/bdd/strict_iac_test.go | 46 ++ 54 files changed, 5352 insertions(+), 429 deletions(-) create mode 100644 cmd/wfctl/infra_output_secret_journal_test.go create mode 100644 cmd/wfctl/infra_secret_aliases.go create mode 100644 cmd/wfctl/infra_secret_cleanup.go create mode 100644 cmd/wfctl/infra_secret_cleanup_test.go create mode 100644 cmd/wfctl/infra_state_lifecycle_test.go create mode 100644 iac/providerclient/job_contract_test.go create mode 100644 interfaces/iac_provider_jobs_test.go create mode 100644 module/iac_state_fs_durability_test.go create mode 100644 module/iac_state_fs_nonwindows.go create mode 100644 module/iac_state_fs_windows.go create mode 100644 module/iac_state_fs_windows_test.go create mode 100644 module/iac_state_lifecycle_grpc_test.go create mode 100644 module/iac_state_lifecycle_postgres_integration_test.go create mode 100644 module/iac_state_lifecycle_test.go create mode 100644 plugin/external/iac_jobs_launch_test.go create mode 100644 plugin/external/proto/iac_job_contract_test.go create mode 100644 plugin/external/sdk/iac_jobs.go create mode 100644 plugin/external/sdk/iac_jobs_test.go create mode 100644 plugin/external/testdata/iac-job-plugin/main.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index a50ef7aa..5dcaf4a3 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "sort" "strings" @@ -1479,6 +1480,10 @@ func runInfraApply(args []string) error { return fmt.Errorf("list state for refresh: %w", statesErr) } groups, groupOrder := groupStatesByProvider(states, cfgFile, envName) + secretProvider, secretErr := loadSecretsProviderForRouting(cfgFile) + if secretErr != nil { + return secretErr + } // Wrap each group in a helper so the deferred closer fires after the // group finishes, not at runInfraApply exit. Without this, a config // with N provider groups would hold N connections open throughout the @@ -1498,7 +1503,7 @@ func runInfraApply(args []string) error { }() } return runInfraApplyRefreshPhase(ctx, provider, g.refs, store, - *autoApprove, allowProtectedPruneFlag, states, os.Stdout, os.Stderr) + *autoApprove, allowProtectedPruneFlag, states, os.Stdout, os.Stderr, secretProvider) } for _, moduleRef := range groupOrder { if refreshErr := refreshGroup(moduleRef, groups[moduleRef]); refreshErr != nil { @@ -1646,9 +1651,16 @@ func runInfraApply(args []string) error { if err != nil { return fmt.Errorf("resolve secrets provider for infra_output sync: %w", err) } - states, err := loadCurrentState(cfgFile, envName) + outputStateStore, err := resolveStateStore(cfgFile, envName) + if err != nil { + return cleanupStateError{operation: "open infra_output journal", cause: err} + } + if closer, ok := outputStateStore.(io.Closer); ok { + defer func() { _ = closer.Close() }() + } + states, err := outputStateStore.ListResources(ctx) if err != nil { - return fmt.Errorf("load current state for infra_output sync: %w", err) + return cleanupStateError{operation: "read infra_output journal", cause: err} } // Only reload the workflow config when routing or env resolution is needed: // store-scoped generators need secretStores, and --env needs module @@ -1666,7 +1678,7 @@ func runInfraApply(args []string) error { } } } - return syncInfraOutputSecretsScoped(ctx, secretsCfg, secretsProvider, states, wfCfg, envName, runHydrated, refreshOutputsFlag, infraOutputSourceScope) + return syncInfraOutputSecretsScoped(ctx, secretsCfg, secretsProvider, states, wfCfg, envName, runHydrated, refreshOutputsFlag, infraOutputSourceScope, outputStateStore) } func runInfraStatus(args []string) error { diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 42a011f9..d72849e9 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -459,7 +459,7 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi // IaCProvider.Apply was hard-deleted from the interface; all routing // goes through wfctlhelpers.ApplyPlanWithHooks (Replace + drift // postcondition + IaCProviderFinalizer fan-out). - hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut, cleanupProviderOptions{ConfigFile: cfgFile, Environment: envName}) // Reconciliation remains provider-scoped, but references may point at an // unchanged or just-applied resource owned by another provider group. hooks.CurrentState, err = store.ListResources(ctx) @@ -670,8 +670,9 @@ func statePersistenceHooks( providerType string, planID string, hydratedOut map[string]string, + cleanupOptions ...cleanupProviderOptions, ) wfctlhelpers.ApplyPlanHooks { - return wfctlhelpers.ApplyPlanHooks{ + hooks := wfctlhelpers.ApplyPlanHooks{ ResolveSecret: func(ctx context.Context, key string) (string, error) { if value, ok := hydratedOut[key]; ok { return value, nil @@ -752,6 +753,12 @@ func statePersistenceHooks( return nil }, } + var options cleanupProviderOptions + if len(cleanupOptions) > 0 { + options = cleanupOptions[0] + } + wireResourceSecretCleanup(&hooks, store, secretsProvider, options) + return hooks } func deleteStateAfterCloudDelete(store infraStateStore, name string) error { @@ -819,6 +826,9 @@ func persistAppliedResourceOutput( } func actionCreatesReplacementResource(action interfaces.PlanAction) bool { + if action.CreationOwned != nil { + return *action.CreationOwned + } return action.Action == "create" || action.Action == "replace" } @@ -1004,6 +1014,11 @@ func resourceStateFromLiveOutput(spec interfaces.ResourceSpec, providerType stri return interfaces.ResourceState{}, fmt.Errorf("%s/%s: live resource returned empty ProviderID; state not persisted", spec.Type, spec.Name) } appliedConfig := liveConfigFromOutputs(live.Outputs) + for key, flagged := range live.Sensitive { + if flagged { + delete(appliedConfig, key) + } + } now := time.Now().UTC() return interfaces.ResourceState{ ID: spec.Name, @@ -1154,26 +1169,49 @@ func persistApplyMode( out interfaces.ResourceOutput, compensate bool, ) (map[string]string, error) { + var journaled bool + var intentErr error + rs, journaled, intentErr = prepareResourceRoutingIntent(ctx, store, provider, rs, out, compensate) + if intentErr != nil { + if !compensate { + return nil, intentErr + } + compErr := compensateCreatedResourceWithJournal(store, provider, driver, rs) + if compErr != nil { + return nil, fmt.Errorf("%s/%s: persist routing intent: %w (compensating delete failed: %v)", rs.Type, rs.Name, intentErr, compErr) + } + return nil, fmt.Errorf("%s/%s: persist routing intent: %w (compensating delete succeeded)", rs.Type, rs.Name, intentErr) + } sanitized, hydrated, err := sensitive.Route(ctx, provider, rs.Name, &out) if err != nil { if !compensate { return nil, fmt.Errorf("%s/%s: route sensitive outputs: %w", rs.Type, rs.Name, err) } - compErr := compensateAfterSaveFailure(provider, driver, rs, hydrated) + compErr := compensateCreatedResourceWithJournal(store, provider, driver, rs) if compErr != nil { return nil, fmt.Errorf("%s/%s: route sensitive outputs: %w (compensating delete failed: %v)", rs.Type, rs.Name, err, compErr) } return nil, fmt.Errorf("%s/%s: route sensitive outputs: %w (compensating delete succeeded)", rs.Type, rs.Name, err) } rs.Outputs = sanitized - if saveErr := store.SaveResource(ctx, rs); saveErr != nil { + if journaled { + rs.Lifecycle.Phase = interfaces.ResourcePhaseActive + rs.Lifecycle.RoutingCreated = false + } + var saveErr error + if rs.Lifecycle != nil { + saveErr = saveCleanupResource(ctx, store, rs) + } else { + saveErr = store.SaveResource(ctx, rs) + } + if saveErr != nil { if !compensate { return nil, fmt.Errorf("%s/%s: persist state after apply: %w", rs.Type, rs.Name, saveErr) } // Compensating Delete: the matching cloud resource is real but // the state record didn't land. Roll back so a re-Apply doesn't // double-create. - compErr := compensateAfterSaveFailure(provider, driver, rs, hydrated) + compErr := compensateCreatedResourceWithJournal(store, provider, driver, rs) if compErr != nil { return nil, fmt.Errorf("%s/%s: persist state after apply: %w (compensating delete failed: %v)", rs.Type, rs.Name, saveErr, compErr) } @@ -1189,21 +1227,9 @@ func persistAdoptRouteMode( rs interfaces.ResourceState, out interfaces.ResourceOutput, ) (map[string]string, error) { - sanitized, hydrated, err := sensitive.Route(ctx, provider, rs.Name, &out) - if err != nil { - if compErr := cleanupRoutedSecrets(provider, hydrated); compErr != nil { - return nil, fmt.Errorf("%s/%s: route sensitive outputs: %w (routed-secret cleanup failed: %v)", rs.Type, rs.Name, err, compErr) - } - return nil, fmt.Errorf("%s/%s: route sensitive outputs: %w", rs.Type, rs.Name, err) - } - rs.Outputs = sanitized - if saveErr := store.SaveResource(ctx, rs); saveErr != nil { - if compErr := cleanupRoutedSecrets(provider, hydrated); compErr != nil { - return nil, fmt.Errorf("%s/%s: persist adopted state: %w (routed-secret cleanup failed: %v)", rs.Type, rs.Name, saveErr, compErr) - } - return nil, fmt.Errorf("%s/%s: persist adopted state: %w", rs.Type, rs.Name, saveErr) - } - return hydrated, nil + // Adoption does not own cloud creation, so retain durable routing debt + // on failure without deleting the pre-existing cloud resource. + return persistApplyMode(ctx, store, provider, nil, rs, out, false) } func persistReadMode( @@ -1232,6 +1258,9 @@ func persistReadMode( } } } + if prior != nil { + rs.Lifecycle = cloneResourceLifecycle(prior.Lifecycle) + } sanitized := make(map[string]any, len(out.Outputs)) for k, v := range out.Outputs { sanitized[k] = v @@ -1259,60 +1288,17 @@ func persistReadMode( return nil } -func cleanupRoutedSecrets(provider secrets.Provider, hydrated map[string]string) error { - if provider == nil || len(hydrated) == 0 { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - var errs []error - for secretName := range hydrated { - if delErr := provider.Delete(ctx, secretName); delErr != nil && !errors.Is(delErr, secrets.ErrNotFound) { - errs = append(errs, fmt.Errorf("provider.Delete(%s): %w", secretName, delErr)) - } - } - return errors.Join(errs...) -} - -// compensateAfterSaveFailure rolls back routed secrets and the underlying -// cloud resource after an apply-mode failure where the just-mutated resource is -// known to be newly created or replacement-created. Uses a fresh 30-second -// context: the apply context may already be canceled (operator Ctrl-C), but -// compensation must proceed to avoid orphaning cloud resources + routed -// secrets. -func compensateAfterSaveFailure( - provider secrets.Provider, - driver interfaces.ResourceDriver, - rs interfaces.ResourceState, - hydrated map[string]string, -) error { +// An intent write failed before any credential writes. Roll back only the +// exact creation identity; name fallback could delete an unrelated resource. +func compensateUnjournaledCreation(driver interfaces.ResourceDriver, rs interfaces.ResourceState) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - var errs []error if driver == nil { - errs = append(errs, errors.New("driver.Delete unavailable")) - } else { - ref := interfaces.ResourceRef{Name: rs.Name, Type: rs.Type, ProviderID: rs.ProviderID} - if delErr := driver.Delete(ctx, ref); delErr != nil { - if rs.ProviderID == "" { - errs = append(errs, fmt.Errorf("driver.Delete: %w", delErr)) - } else { - nameRef := interfaces.ResourceRef{Name: rs.Name, Type: rs.Type} - if nameDelErr := driver.Delete(ctx, nameRef); nameDelErr != nil { - errs = append(errs, fmt.Errorf("driver.Delete: %w", errors.Join(delErr, nameDelErr))) - } - } - } + return fmt.Errorf("%w: failed-create cleanup driver missing", interfaces.ErrValidation) } - if provider != nil { - for secretName := range hydrated { - if delErr := provider.Delete(ctx, secretName); delErr != nil && !errors.Is(delErr, secrets.ErrNotFound) { - errs = append(errs, fmt.Errorf("provider.Delete(%s): %w", secretName, delErr)) - } - } - } - if len(errs) > 0 { - return errors.Join(errs...) + ref := interfaces.ResourceRef{Name: rs.Name, Type: rs.Type, ProviderID: rs.ProviderID} + if err := driver.Delete(ctx, ref); err != nil && !interfaces.IsErrResourceNotFound(err) { + return cleanupStateError{operation: "delete failed creation", name: rs.Name, cause: err} } return nil } @@ -1654,7 +1640,7 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, validateInputProviderIDs(provider, &plan) fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions)) // v2 is the only supported dispatch per ADR 0024 + workflow#699. - hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut, cleanupProviderOptions{ConfigFile: cfgFile, Environment: envName}) currentState, stateErr := store.ListResources(ctx) if stateErr != nil { return fmt.Errorf("load state for runtime references: %w", stateErr) diff --git a/cmd/wfctl/infra_apply_refresh.go b/cmd/wfctl/infra_apply_refresh.go index 7456166c..c56d2d1f 100644 --- a/cmd/wfctl/infra_apply_refresh.go +++ b/cmd/wfctl/infra_apply_refresh.go @@ -23,6 +23,7 @@ import ( "time" "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" ) // runInfraApplyRefreshPhase detects drift against the given provider and prunes @@ -32,7 +33,8 @@ import ( // Behavior: // - For each DriftClassGhost result: if autoApprove is false, prints a dry-run // "would prune" line. If autoApprove is true, calls store.DeleteResource and -// emits an audit log line to stderr. +// emits an audit log line to stderr. Routed resources instead persist confirmed +// cloud deletion so normal apply cleans their exact keys before recreation. // - Protected resources (protected: true in state Outputs) are blocked unless // allowProtectedPrune is also set. Without that flag, an error is returned and // no prunes happen. @@ -55,6 +57,7 @@ func runInfraApplyRefreshPhase( states []interfaces.ResourceState, stdout io.Writer, stderr io.Writer, + secretProviders ...secrets.Provider, ) error { if len(refs) == 0 { fmt.Fprintln(stdout, "Refresh: no state to check.") @@ -115,6 +118,35 @@ func runInfraApplyRefreshPhase( } isProtected := isRefProtected(states, r.Name) + var retained *interfaces.ResourceState + for i := range states { + state := &states[i] + if state.Name == r.Name && (state.Lifecycle != nil || hasRoutedPlaceholders(state.Outputs)) { + copy := *state + retained = © + break + } + } + if retained != nil { + if !autoApprove { + fmt.Fprintf(stdout, "Refresh: would retain %s for routed-secret cleanup during apply.\n", r.Name) + continue + } + var secretProvider secrets.Provider + if len(secretProviders) != 0 { + secretProvider = secretProviders[0] + } + prepared, err := prepareResourceSecretDeletion(ctx, store, secretProvider, interfaces.PlanAction{Resource: interfaces.ResourceSpec{Name: retained.Name, Type: retained.Type}, Current: retained}) + if err != nil { + return err + } + prepared.Lifecycle.Phase = interfaces.ResourcePhaseCloudDeletedSecretCleanupPending + if err := saveCleanupResource(ctx, store, *prepared); err != nil { + return err + } + fmt.Fprintf(stdout, "Refresh: retained %s for routed-secret cleanup during apply.\n", r.Name) + continue + } if !autoApprove { // Dry-run: report what would happen without mutating. diff --git a/cmd/wfctl/infra_apply_refresh_test.go b/cmd/wfctl/infra_apply_refresh_test.go index 99cf4136..17294166 100644 --- a/cmd/wfctl/infra_apply_refresh_test.go +++ b/cmd/wfctl/infra_apply_refresh_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" ) // ── fixtures for refresh tests ────────────────────────────────────────────────── @@ -112,6 +113,51 @@ func TestApplyRefresh_DryRunPrintsPrunesWithoutMutating(t *testing.T) { } } +func TestApplyRefresh_RoutedCleanupDebtCannotBePruned(t *testing.T) { + for _, journaled := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy-placeholder", true: "journal"}[journaled], func(t *testing.T) { + provider := newRefreshDriftFixture(t, []interfaces.DriftResult{{Name: "database", Type: "infra.database", Class: interfaces.DriftClassGhost}}, nil) + store := cleanupStateStore(t) + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "deleted-id"} + if journaled { + state.Lifecycle = &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} + } else { + state.Outputs = map[string]any{"password": "secret_ref://exact-key"} + } + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + err := runInfraApplyRefreshPhase(t.Context(), provider, []interfaces.ResourceRef{{Name: state.Name, Type: state.Type}}, store, true, true, []interfaces.ResourceState{state}, &stdout, &stderr, newEnvTestProvider()) + states, loadErr := store.ListResources(t.Context()) + if err != nil || loadErr != nil || len(states) != 1 || states[0].Lifecycle == nil || states[0].Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending || !strings.Contains(stdout.String(), "cleanup") { + t.Fatalf("refresh forgot routed-secret debt: states=%v err=%v load=%v output=%q", states, err, loadErr, stdout.String()) + } + }) + } +} + +func TestApplyRefresh_ConfirmedRoutedGhostMustRecreate(t *testing.T) { + store := cleanupStateStore(t) + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "deleted-id", Lifecycle: &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: interfaces.ResourcePhaseActive}} + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + provider := newRefreshDriftFixture(t, []interfaces.DriftResult{{Name: state.Name, Type: state.Type, Class: interfaces.DriftClassGhost}}, nil) + var stdout, stderr bytes.Buffer + if err := runInfraApplyRefreshPhase(t.Context(), provider, []interfaces.ResourceRef{{Name: state.Name, Type: state.Type, ProviderID: state.ProviderID}}, store, true, true, []interfaces.ResourceState{state}, &stdout, &stderr); err != nil { + t.Fatal(err) + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + t.Fatalf("confirmed ghost was not durably marked: states=%+v err=%v", states, err) + } + plan, err := platform.ComputePlan(t.Context(), &declarativeCLIProvider{driver: &stubSensitiveDriver{}}, []interfaces.ResourceSpec{{Name: state.Name, Type: state.Type}}, states) + if err != nil || len(plan.Actions) != 1 || plan.Actions[0].Action != "replace" { + t.Fatalf("confirmed ghost was declared converged: plan=%+v err=%v", plan, err) + } +} + func TestApplyRefresh_AutoApprovePrunesAndApplies(t *testing.T) { ghost := interfaces.DriftResult{ Name: "test-vpc", diff --git a/cmd/wfctl/infra_apply_sensitive_routing_test.go b/cmd/wfctl/infra_apply_sensitive_routing_test.go index 59cb30b4..16a26e51 100644 --- a/cmd/wfctl/infra_apply_sensitive_routing_test.go +++ b/cmd/wfctl/infra_apply_sensitive_routing_test.go @@ -55,15 +55,49 @@ type stubInfraStore struct { } func (s *stubInfraStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { - return s.saved, nil + return latestRecordedStates(s.saved, s.deleted), nil } func (s *stubInfraStore) SaveResource(_ context.Context, st interfaces.ResourceState) error { if s.saveErr != nil { return s.saveErr } + st.Lifecycle = cloneResourceLifecycle(st.Lifecycle) s.saved = append(s.saved, st) + s.deleted = removeRecordedDeletion(s.deleted, st.Name) return nil } + +func latestRecordedStates(saved []interfaces.ResourceState, deleted []string) []interfaces.ResourceState { + removed := make(map[string]bool, len(deleted)) + for _, name := range deleted { + removed[name] = true + } + positions := make(map[string]int) + var current []interfaces.ResourceState + for _, state := range saved { + if removed[state.Name] { + continue + } + state.Lifecycle = cloneResourceLifecycle(state.Lifecycle) + if pos, exists := positions[state.Name]; exists { + current[pos] = state + } else { + positions[state.Name] = len(current) + current = append(current, state) + } + } + return current +} + +func removeRecordedDeletion(deleted []string, name string) []string { + kept := deleted[:0] + for _, old := range deleted { + if old != name { + kept = append(kept, old) + } + } + return kept +} func (s *stubInfraStore) DeleteResource(_ context.Context, n string) error { s.deleted = append(s.deleted, n) return nil @@ -118,10 +152,10 @@ func TestPersistResourceWithSecretRouting_RoutesSensitiveAndSanitizesState(t *te if err != nil { t.Fatalf("persist: %v", err) } - if len(store.saved) != 1 { - t.Fatalf("expected 1 saved, got %d", len(store.saved)) + if len(store.saved) != 2 || store.saved[0].Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending || store.saved[1].Lifecycle.Phase != interfaces.ResourcePhaseActive { + t.Fatalf("expected routing intent then active state, got %+v", store.saved) } - state := store.saved[0] + state := store.saved[1] secretKey := sensitive.SecretKey("myres", "secret_key") accessKey := sensitive.SecretKey("myres", "access_key") if state.Outputs["secret_key"] != sensitive.Placeholder("myres", "secret_key") { @@ -169,7 +203,8 @@ func TestPersistResourceWithSecretRouting_NoProviderHardFails(t *testing.T) { func TestPersistResourceWithSecretRouting_SaveFailureCompensatesWithDelete(t *testing.T) { prov := newEnvTestProvider() - store := &stubInfraStore{saveErr: errors.New("disk full")} + cause := errors.New("disk full") + store := &stubInfraStore{saveErr: cause} drv := &stubSensitiveDriver{} out := interfaces.ResourceOutput{ Name: "myres", ProviderID: "AKIA", @@ -181,8 +216,8 @@ func TestPersistResourceWithSecretRouting_SaveFailureCompensatesWithDelete(t *te if err == nil { t.Fatal("expected error from SaveResource") } - if !strings.Contains(err.Error(), "disk full") { - t.Errorf("error should wrap original SaveResource err, got %q", err.Error()) + if !errors.Is(err, cause) { + t.Errorf("error should preserve original SaveResource cause, got %q", err.Error()) } if len(drv.deleteCalls) != 1 { t.Errorf("expected 1 compensating Delete call, got %d", len(drv.deleteCalls)) @@ -259,8 +294,8 @@ func TestPersistResourceWithSecretRouting_Idempotent(t *testing.T) { if prov.values[sensitive.SecretKey("myres", "secret_key")] != "SK" { t.Errorf("provider value lost on re-Apply: %v", prov.values) } - if len(store.saved) != 2 { - t.Errorf("expected 2 saved, got %d", len(store.saved)) + if len(store.saved) != 4 { + t.Errorf("expected intent and active save per apply, got %d", len(store.saved)) } } diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go index 2ebcd6c7..f1d4b04d 100644 --- a/cmd/wfctl/infra_apply_test.go +++ b/cmd/wfctl/infra_apply_test.go @@ -884,7 +884,7 @@ type fakeStateStore struct { func (f *fakeStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]interfaces.ResourceState(nil), f.saved...), nil + return latestRecordedStates(f.saved, f.deleted), nil } func (f *fakeStateStore) SaveResource(_ context.Context, s interfaces.ResourceState) error { f.mu.Lock() @@ -892,7 +892,9 @@ func (f *fakeStateStore) SaveResource(_ context.Context, s interfaces.ResourceSt if f.saveErr != nil { return f.saveErr } + s.Lifecycle = cloneResourceLifecycle(s.Lifecycle) f.saved = append(f.saved, s) + f.deleted = removeRecordedDeletion(f.deleted, s.Name) return nil } func (f *fakeStateStore) DeleteResource(_ context.Context, name string) error { @@ -1068,10 +1070,10 @@ func TestApplyWithProvider_AdoptionRoutesNewSensitiveOutputs(t *testing.T) { store.mu.Lock() defer store.mu.Unlock() - if len(store.saved) != 1 { - t.Fatalf("saved states = %d, want adopted state", len(store.saved)) + if len(store.saved) != 2 || store.saved[0].Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending || store.saved[1].Lifecycle.Phase != interfaces.ResourcePhaseActive { + t.Fatalf("saved states = %+v, want adoption routing intent and active state", store.saved) } - if got := store.saved[0].Outputs["uri"]; got != sensitive.Placeholder("adopted-db", "uri") { + if got := store.saved[1].Outputs["uri"]; got != sensitive.Placeholder("adopted-db", "uri") { t.Fatalf("adopted uri output = %#v, want routed placeholder", got) } if got := os.Getenv(strings.ToUpper(secretName)); got != rawURI { diff --git a/cmd/wfctl/infra_output_secret_journal_test.go b/cmd/wfctl/infra_output_secret_journal_test.go new file mode 100644 index 00000000..5a21a77c --- /dev/null +++ b/cmd/wfctl/infra_output_secret_journal_test.go @@ -0,0 +1,544 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" +) + +const aliasJournalMarker = "known-private-alias-journal-value" + +type aliasJournalDriver struct{ stubSensitiveDriver } + +func (*aliasJournalDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "cloud-database", + Outputs: map[string]any{"uri": aliasJournalMarker, "host": "consumer-payload"}, Sensitive: map[string]bool{"uri": true}}, nil +} + +func TestInfraOutputAliasJournal_CLIAndConfigRemoval(t *testing.T) { + dir := t.TempDir() + stateDir, routedDir, aliasDir := filepath.Join(dir, "state"), filepath.Join(dir, "routed"), filepath.Join(dir, "aliases") + cfgFile := filepath.Join(dir, "infra.yaml") + writeConfig := func(withAlias bool) { + t.Helper() + cfg := fmt.Sprintf("infra: {auto_bootstrap: false}\nmodules:\n - name: provider\n type: iac.provider\n config: {provider: test-cloud}\n - name: state\n type: iac.state\n config: {backend: filesystem, directory: %s}\n - name: database\n type: infra.database\n config: {provider: provider}\nsecretStores:\n named:\n provider: file\n config: {path: %s}\nsecrets:\n provider: file\n config: {path: %s}\n", stateDir, aliasDir, routedDir) + if withAlias { + cfg += " generate:\n - key: DATABASE_URL\n type: infra_output\n source: database.uri\n store: named\n" + } + if err := os.WriteFile(cfgFile, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + } + writeConfig(true) + provider := &declarativeCLIProvider{driver: &aliasJournalDriver{}} + original := resolveIaCProvider + resolveIaCProvider = func(context.Context, string, map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return provider, nil, nil + } + t.Cleanup(func() { resolveIaCProvider = original }) + if err := runInfraApply([]string{"--config", cfgFile, "--auto-approve"}); err != nil { + t.Fatal(err) + } + aliasProvider := secrets.NewFileProvider(aliasDir) + if value, err := aliasProvider.Get(t.Context(), "DATABASE_URL"); err != nil || value != aliasJournalMarker { + t.Fatalf("actual file provider did not receive unchanged output: err=%v", err) + } + store, err := resolveStateStore(cfgFile, "") + if err != nil { + t.Fatal(err) + } + state, err := loadCleanupResource(t.Context(), store, "database") + if err != nil || state == nil || state.Lifecycle == nil { + t.Fatalf("source journal missing: state=%v err=%v", state, err) + } + target := secrets.DescribeTarget(aliasProvider) + want := interfaces.RoutedSecretReference{Key: "DATABASE_URL", Store: "named", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + if !containsAliasReference(state.Lifecycle, want) || len(state.Lifecycle.Secrets) != 2 || state.Lifecycle.Generation == "" || state.Lifecycle.Phase != interfaces.ResourcePhaseActive { + t.Fatalf("durable journal lost alias/derived identifiers: %+v", state.Lifecycle) + } + assertAliasStateSafe(t, store) + if state.Outputs["host"] != "consumer-payload" || state.Outputs["uri"] != sensitive.Placeholder("database", "uri") { + t.Fatal("alias sync altered source outputs") + } + writeConfig(false) + removedCfg, err := config.LoadFromFile(cfgFile) + if err != nil { + t.Fatal(err) + } + removedSecrets, err := parseSecretsConfig(cfgFile) + if err != nil || len(removedSecrets.Generate) != 0 { + t.Fatalf("generator removal fixture invalid: err=%v", err) + } + routedProvider := secrets.NewFileProvider(routedDir) + if err := aliasProvider.Set(t.Context(), "UNRELATED", "consumer-payload"); err != nil { + t.Fatal(err) + } + prepared, err := prepareResourceSecretDeletion(t.Context(), store, routedProvider, interfaces.PlanAction{ + Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: state, + }) + if err != nil { + t.Fatal(err) + } + if err := reconcileResourceSecretDeletion(t.Context(), store, func(ref interfaces.RoutedSecretReference) (secrets.Provider, error) { + return providerForSecretGen(removedCfg, routedProvider, SecretGen{Key: ref.Key, Store: ref.Store}, "") + }, *prepared); err != nil { + t.Fatal(err) + } + if _, err := aliasProvider.Get(t.Context(), "DATABASE_URL"); !errors.Is(err, secrets.ErrNotFound) { + t.Fatal("named alias survived cleanup after generator removal") + } + if _, err := routedProvider.Get(t.Context(), sensitive.SecretKey("database", "uri")); !errors.Is(err, secrets.ErrNotFound) { + t.Fatal("derived output key survived cleanup") + } + if value, err := aliasProvider.Get(t.Context(), "UNRELATED"); err != nil || value != "consumer-payload" { + t.Fatal("cleanup changed unrelated secret") + } + if states, err := store.ListResources(t.Context()); err != nil || len(states) != 0 { + t.Fatalf("cleanup did not finalize state: err=%v count=%d", err, len(states)) + } +} + +func containsAliasReference(lifecycle *interfaces.ResourceLifecycle, want interfaces.RoutedSecretReference) bool { + if lifecycle == nil { + return false + } + for _, ref := range lifecycle.Secrets { + if reflect.DeepEqual(ref, want) { + return true + } + } + return false +} + +func assertAliasStateSafe(t *testing.T, store infraStateStore) { + t.Helper() + states, err := store.ListResources(t.Context()) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(states) + if err != nil || strings.Contains(string(data), aliasJournalMarker) { + t.Fatalf("journal/state exposed known output bytes: err=%v", err) + } +} + +type aliasJournalFixture struct { + store infraStateStore + state interfaces.ResourceState + workflow *config.WorkflowConfig + secrets *SecretsConfig + provider *secrets.FileProvider +} + +func newAliasJournalFixture(t *testing.T) aliasJournalFixture { + t.Helper() + store := cleanupStateStore(t) + provider := secrets.NewFileProvider(t.TempDir()) + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "cloud-id", + Outputs: map[string]any{"uri": sensitive.Placeholder("database", "uri"), "host": "consumer-payload"}, + Lifecycle: &interfaces.ResourceLifecycle{Generation: "existing-generation", Phase: interfaces.ResourcePhaseActive, + Secrets: []interfaces.RoutedSecretReference{{Key: "prior-derived-key", Provider: "file", Scope: "directory", Subject: "prior-store"}}}} + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + return aliasJournalFixture{store: store, state: state, provider: provider, + workflow: &config.WorkflowConfig{SecretStores: map[string]*config.SecretStoreConfig{ + "named": {Provider: "file", Config: map[string]any{"path": secrets.DescribeTarget(provider).Subject}}, + }}, + secrets: &SecretsConfig{Generate: []SecretGen{{Key: "DATABASE_URL", Type: "infra_output", Source: "database.uri", Store: "named"}}}} +} + +func (f aliasJournalFixture) sync(ctx context.Context, store infraStateStore, refresh bool) error { + return syncInfraOutputSecretsScoped(ctx, f.secrets, nil, []interfaces.ResourceState{f.state}, f.workflow, "", + map[string]string{sensitive.SecretKey("database", "uri"): aliasJournalMarker}, refresh, nil, store) +} + +type aliasAcknowledgmentStore struct { + infraStateStore + saves int + lists int + fault string + beforeSave func(interfaces.ResourceState) + ackObserved func() +} + +func (s *aliasAcknowledgmentStore) SaveResource(ctx context.Context, state interfaces.ResourceState) error { + s.saves++ + if s.beforeSave != nil { + s.beforeSave(state) + } + if s.fault == "save" { + return fmt.Errorf("%w: %s", interfaces.ErrTransient, aliasJournalMarker) + } + if s.fault == "drop-lifecycle" { + state.Lifecycle = nil + } + if s.fault == "drop-alias" { + state.Lifecycle = cloneResourceLifecycle(state.Lifecycle) + state.Lifecycle.Secrets = state.Lifecycle.Secrets[:1] + } + return s.infraStateStore.SaveResource(ctx, state) +} + +func (s *aliasAcknowledgmentStore) ListResources(ctx context.Context) ([]interfaces.ResourceState, error) { + s.lists++ + if s.fault == "read" || (s.fault == "ack-read" && s.saves > 0) || (s.fault == "ownership-read" && s.lists == 2) { + return nil, fmt.Errorf("%w: %s", interfaces.ErrTransient, aliasJournalMarker) + } + states, err := s.infraStateStore.ListResources(ctx) + if s.saves > 0 && s.ackObserved != nil { + s.ackObserved() + } + return states, err +} + +func TestInfraOutputAliasJournal_WriteAheadAndDedup(t *testing.T) { + for _, refresh := range []bool{false, true} { + t.Run(fmt.Sprintf("refresh=%t", refresh), func(t *testing.T) { + f := newAliasJournalFixture(t) + target := secrets.DescribeTarget(f.provider) + want := interfaces.RoutedSecretReference{Key: "DATABASE_URL", Store: "named", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + if refresh { + if err := f.provider.Set(t.Context(), want.Key, "old-value"); err != nil { + t.Fatal(err) + } + prior := want + prior.VerifiedAbsent = true + f.state.Lifecycle.Secrets = append(f.state.Lifecycle.Secrets, prior) + if err := f.store.SaveResource(t.Context(), f.state); err != nil { + t.Fatal(err) + } + } + assertNotWritten := func() { + value, err := f.provider.Get(t.Context(), want.Key) + if (!refresh && !errors.Is(err, secrets.ErrNotFound)) || (refresh && (err != nil || value != "old-value")) { + t.Fatal("alias Set preceded durable journal acknowledgment") + } + } + store := &aliasAcknowledgmentStore{infraStateStore: f.store, ackObserved: assertNotWritten} + store.beforeSave = func(state interfaces.ResourceState) { + assertNotWritten() + if !containsAliasReference(state.Lifecycle, want) { + t.Fatal("pre-write journal does not describe actual alias target") + } + } + if err := f.sync(t.Context(), store, refresh); err != nil { + t.Fatal(err) + } + if store.saves != 1 { + t.Fatalf("alias intent must be saved once before Set, got %d", store.saves) + } + state, err := loadCleanupResource(t.Context(), f.store, f.state.Name) + if err != nil || !containsAliasReference(state.Lifecycle, want) || len(state.Lifecycle.Secrets) != 2 || state.Lifecycle.Generation != "existing-generation" || state.Lifecycle.Phase != interfaces.ResourcePhaseActive { + t.Fatalf("alias replaced generation, lost prior ref, or retained absence evidence: state=%v err=%v", state, err) + } + if value, err := f.provider.Get(t.Context(), want.Key); err != nil || value != aliasJournalMarker { + t.Fatal("Set did not preserve output bytes") + } + assertAliasStateSafe(t, f.store) + }) + } +} + +func TestInfraOutputAliasJournal_SkippedAliasesNotClaimed(t *testing.T) { + for _, refresh := range []bool{false, true} { + t.Run(fmt.Sprintf("refresh=%t", refresh), func(t *testing.T) { + f := newAliasJournalFixture(t) + value := "pre-existing-value" + if refresh { + value = aliasJournalMarker + } + if err := f.provider.Set(t.Context(), "DATABASE_URL", value); err != nil { + t.Fatal(err) + } + store := &aliasAcknowledgmentStore{infraStateStore: f.store} + if err := f.sync(t.Context(), store, refresh); err != nil { + t.Fatal(err) + } + state, err := loadCleanupResource(t.Context(), f.store, f.state.Name) + if err != nil || store.saves != 0 || !reflect.DeepEqual(state.Lifecycle, f.state.Lifecycle) { + t.Fatalf("skipped user-managed alias was claimed: saves=%d err=%v", store.saves, err) + } + if got, err := f.provider.Get(t.Context(), "DATABASE_URL"); err != nil || got != value { + t.Fatal("skipped alias changed") + } + }) + } +} + +func TestInfraOutputAliasJournal_FailsClosedBeforeSet(t *testing.T) { + for _, fault := range []string{"save", "read", "ack-read", "drop-lifecycle", "drop-alias", "nil-store", "noop", "missing", "generation", "identity", "cleanup-pending"} { + t.Run(fault, func(t *testing.T) { + f := newAliasJournalFixture(t) + store := &aliasAcknowledgmentStore{infraStateStore: f.store, fault: fault} + var selected infraStateStore = store + switch fault { + case "nil-store": + selected = nil + case "noop": + selected = &noopStateStore{} + case "missing": + if err := f.store.DeleteResource(t.Context(), f.state.Name); err != nil { + t.Fatal(err) + } + case "generation", "identity", "cleanup-pending": + changed := f.state + changed.Lifecycle = cloneResourceLifecycle(changed.Lifecycle) + switch fault { + case "generation": + changed.Lifecycle.Generation = "other-generation" + case "identity": + changed.ProviderID = "other-cloud-resource" + default: + changed.Lifecycle.Phase = interfaces.ResourcePhaseCloudDeletedSecretCleanupPending + } + if err := f.store.SaveResource(t.Context(), changed); err != nil { + t.Fatal(err) + } + } + err := f.sync(t.Context(), selected, false) + if err == nil { + t.Fatal("unacknowledged/mismatched journal allowed alias Set") + } + if strings.Contains(err.Error(), aliasJournalMarker) { + t.Fatal("journal error disclosed known value") + } + if fault == "save" || fault == "read" || fault == "ack-read" { + if !errors.Is(err, interfaces.ErrTransient) { + t.Fatal("safe diagnostic lost backend cause") + } + } + if _, err := f.provider.Get(t.Context(), "DATABASE_URL"); !errors.Is(err, secrets.ErrNotFound) { + t.Fatal("alias written without metadata ACK") + } + assertAliasStateSafe(t, f.store) + }) + } +} + +type aliasFailingProvider struct { + *secrets.FileProvider + fault string +} + +func (p aliasFailingProvider) Get(ctx context.Context, key string) (string, error) { + if p.fault == "get" { + return "", fmt.Errorf("%w: %s", interfaces.ErrForbidden, aliasJournalMarker) + } + if p.fault == "list" { + return "", secrets.ErrUnsupported + } + return p.FileProvider.Get(ctx, key) +} + +func (p aliasFailingProvider) List(context.Context) ([]string, error) { + return nil, fmt.Errorf("%w: %s", interfaces.ErrForbidden, aliasJournalMarker) +} + +func (p aliasFailingProvider) Set(ctx context.Context, key, value string) error { + if err := p.FileProvider.Set(ctx, key, value); err != nil { + return err + } + return fmt.Errorf("%w: provider echoed %s", interfaces.ErrForbidden, value) +} + +func TestInfraOutputAliasJournal_ProviderErrorsSafeAndDebtRetained(t *testing.T) { + for _, fault := range []string{"get", "list", "set-after-write"} { + t.Run(fault, func(t *testing.T) { + f := newAliasJournalFixture(t) + f.secrets.Generate[0].Store = "" + provider := aliasFailingProvider{FileProvider: f.provider, fault: fault} + err := syncInfraOutputSecretsScoped(t.Context(), f.secrets, provider, []interfaces.ResourceState{f.state}, f.workflow, "", + map[string]string{sensitive.SecretKey("database", "uri"): aliasJournalMarker}, false, nil, f.store) + if err == nil || !errors.Is(err, interfaces.ErrForbidden) || strings.Contains(err.Error(), aliasJournalMarker) { + t.Fatalf("provider failure must preserve cause but hide bytes: err=%v", err) + } + state, stateErr := loadCleanupResource(t.Context(), f.store, f.state.Name) + if stateErr != nil { + t.Fatal(stateErr) + } + if fault == "set-after-write" { + target := secrets.DescribeTarget(provider) + want := interfaces.RoutedSecretReference{Key: "DATABASE_URL", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + if !containsAliasReference(state.Lifecycle, want) { + t.Fatal("post-write failure lost durable alias debt") + } + if value, err := f.provider.Get(t.Context(), want.Key); err != nil || value != aliasJournalMarker { + t.Fatal("provider payload bytes were changed") + } + } else if !reflect.DeepEqual(state.Lifecycle, f.state.Lifecycle) { + t.Fatal("pre-write read failure claimed an alias") + } + assertAliasStateSafe(t, f.store) + }) + } +} + +func TestInfraOutputAliasJournal_LegacyEnvSourceRetainsDerivedKeys(t *testing.T) { + f := newAliasJournalFixture(t) + if err := f.store.DeleteResource(t.Context(), f.state.Name); err != nil { + t.Fatal(err) + } + f.state.ID, f.state.Name = "database-staging", "database-staging" + f.state.Lifecycle = nil + f.state.Outputs["uri"] = sensitive.Placeholder(f.state.Name, "uri") + if err := f.store.SaveResource(t.Context(), f.state); err != nil { + t.Fatal(err) + } + f.workflow.Modules = []config.ModuleConfig{{Name: "database", Type: f.state.Type, + Environments: map[string]*config.InfraEnvironmentResolution{"staging": {Config: map[string]any{"name": f.state.Name}}}}} + f.secrets.Generate = append(f.secrets.Generate, + SecretGen{Key: "SECOND_ALIAS", Type: "infra_output", Source: "database.uri", Store: "named"}, + SecretGen{Key: "OUT_OF_SCOPE", Type: "infra_output", Source: "other.uri", Store: "named"}) + defaultProvider := secrets.NewFileProvider(t.TempDir()) + store := &aliasAcknowledgmentStore{infraStateStore: f.store} + var generation string + store.beforeSave = func(state interfaces.ResourceState) { + if generation == "" { + generation = state.Lifecycle.Generation + } + if generation == "" || state.Lifecycle.Generation != generation { + t.Fatal("aliases for one source changed generation") + } + } + err := syncInfraOutputSecretsScoped(t.Context(), f.secrets, defaultProvider, []interfaces.ResourceState{f.state}, f.workflow, "staging", + map[string]string{sensitive.SecretKey(f.state.Name, "uri"): aliasJournalMarker}, false, map[string]struct{}{f.state.Name: {}}, store) + if err != nil { + t.Fatal(err) + } + state, err := loadCleanupResource(t.Context(), f.store, f.state.Name) + if err != nil || state == nil || state.Lifecycle == nil { + t.Fatalf("env-resolved source journal missing: err=%v", err) + } + target := secrets.DescribeTarget(defaultProvider) + derived := interfaces.RoutedSecretReference{Key: sensitive.SecretKey(f.state.Name, "uri"), Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + if store.saves != 2 || len(state.Lifecycle.Secrets) != 3 || !containsAliasReference(state.Lifecycle, derived) { + t.Fatalf("legacy source lost derived cleanup refs: saves=%d lifecycle=%+v", store.saves, state.Lifecycle) + } + for _, key := range []string{"DATABASE_URL", "SECOND_ALIAS"} { + target := secrets.DescribeTarget(f.provider) + if !containsAliasReference(state.Lifecycle, interfaces.RoutedSecretReference{Key: key, Store: "named", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}) { + t.Fatal("named alias absent from env-resolved journal") + } + if value, err := f.provider.Get(t.Context(), key); err != nil || value != aliasJournalMarker { + t.Fatal("alias payload changed") + } + } + if _, err := f.provider.Get(t.Context(), "OUT_OF_SCOPE"); !errors.Is(err, secrets.ErrNotFound) { + t.Fatal("out-of-scope alias was claimed/written") + } + assertAliasStateSafe(t, f.store) +} + +func TestInfraOutputAliasJournal_ConflictingOwnerRejectsRefresh(t *testing.T) { + for _, ownerStore := range []string{"named", "renamed-store", ""} { + for _, phase := range []interfaces.ResourcePhase{interfaces.ResourcePhaseActive, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} { + t.Run(fmt.Sprintf("store=%q/phase=%s", ownerStore, phase), func(t *testing.T) { + f := newAliasJournalFixture(t) + target := secrets.DescribeTarget(f.provider) + owner := interfaces.ResourceState{ID: "sourceA", Name: "sourceA", Type: f.state.Type, ProviderID: "owner-cloud-id", + Lifecycle: &interfaces.ResourceLifecycle{Generation: "owner-generation", Phase: phase, + Secrets: []interfaces.RoutedSecretReference{{Key: "DATABASE_URL", Store: ownerStore, Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}}}} + if err := f.store.SaveResource(t.Context(), owner); err != nil { + t.Fatal(err) + } + if err := f.provider.Set(t.Context(), "DATABASE_URL", "owner-A-value"); err != nil { + t.Fatal(err) + } + before, err := f.store.ListResources(t.Context()) + if err != nil { + t.Fatal(err) + } + store := &aliasAcknowledgmentStore{infraStateStore: f.store} + // The observed slice contains only sourceB; ownership must come + // from the authoritative store, not the scoped apply snapshot. + err = f.sync(t.Context(), store, true) + value, getErr := f.provider.Get(t.Context(), "DATABASE_URL") + if !errors.Is(err, interfaces.ErrValidation) || store.saves != 0 || getErr != nil || value != "owner-A-value" { + t.Fatalf("conflicting owner allowed alias refresh: err=%v saves=%d overwritten=%t readErr=%v", err, store.saves, value != "owner-A-value", getErr) + } + if strings.Contains(err.Error(), aliasJournalMarker) || strings.Contains(err.Error(), "owner-A-value") { + t.Fatal("conflict diagnostic disclosed alias bytes") + } + after, err := f.store.ListResources(t.Context()) + if err != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("conflict changed source journals: err=%v", err) + } + assertAliasStateSafe(t, f.store) + }) + } + } +} + +func TestInfraOutputAliasJournal_NonconflictingOwnerAllowed(t *testing.T) { + for _, difference := range []string{"provider", "scope", "subject", "key"} { + t.Run(difference, func(t *testing.T) { + f := newAliasJournalFixture(t) + target := secrets.DescribeTarget(f.provider) + ref := interfaces.RoutedSecretReference{Key: "DATABASE_URL", Store: "named", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + switch difference { + case "provider": + ref.Provider = "other-provider" + case "scope": + ref.Scope = "other-scope" + case "subject": + ref.Subject = secrets.DescribeTarget(secrets.NewFileProvider(t.TempDir())).Subject + case "key": + ref.Key = "OTHER_KEY" + } + owner := interfaces.ResourceState{ID: "sourceA", Name: "sourceA", Type: f.state.Type, ProviderID: "owner-cloud-id", + Lifecycle: &interfaces.ResourceLifecycle{Generation: "owner-generation", Phase: interfaces.ResourcePhaseActive, + Secrets: []interfaces.RoutedSecretReference{ref}}} + if err := f.store.SaveResource(t.Context(), owner); err != nil { + t.Fatal(err) + } + before, err := loadCleanupResource(t.Context(), f.store, owner.Name) + if err != nil { + t.Fatal(err) + } + if err := f.provider.Set(t.Context(), "DATABASE_URL", "old-value"); err != nil { + t.Fatal(err) + } + store := &aliasAcknowledgmentStore{infraStateStore: f.store} + if err := f.sync(t.Context(), store, true); err != nil { + t.Fatal(err) + } + if value, err := f.provider.Get(t.Context(), "DATABASE_URL"); err != nil || value != aliasJournalMarker || store.saves != 1 { + t.Fatalf("nonconflicting alias write rejected or altered: err=%v saves=%d", err, store.saves) + } + after, err := loadCleanupResource(t.Context(), f.store, owner.Name) + if err != nil || !reflect.DeepEqual(after, before) { + t.Fatal("alias write changed another owner's journal") + } + assertAliasStateSafe(t, f.store) + }) + } +} + +func TestInfraOutputAliasJournal_OwnershipReadFailsBeforeMutation(t *testing.T) { + f := newAliasJournalFixture(t) + store := &aliasAcknowledgmentStore{infraStateStore: f.store, fault: "ownership-read"} + err := f.sync(t.Context(), store, false) + if !errors.Is(err, interfaces.ErrTransient) || strings.Contains(err.Error(), aliasJournalMarker) { + t.Fatalf("ownership read must fail closed with a safe cause: err=%v", err) + } + if store.saves != 0 { + t.Fatalf("ownership read failed after journal mutation: saves=%d", store.saves) + } + if _, err := f.provider.Get(t.Context(), "DATABASE_URL"); !errors.Is(err, secrets.ErrNotFound) { + t.Fatal("alias written despite unreadable ownership") + } + assertAliasStateSafe(t, f.store) +} diff --git a/cmd/wfctl/infra_output_secrets.go b/cmd/wfctl/infra_output_secrets.go index b2b6fd73..ae35bffc 100644 --- a/cmd/wfctl/infra_output_secrets.go +++ b/cmd/wfctl/infra_output_secrets.go @@ -43,34 +43,10 @@ func buildStateOutputsMap(states []interfaces.ResourceState) map[string]map[stri // the cold-start constraint (write-only providers cannot rehydrate // without same-process hand-off). func resolveInfraOutput(wfCfg *config.WorkflowConfig, source, envName string, stateOutputs map[string]map[string]any, hydrated map[string]string) (string, error) { - if source == "" { - return "", fmt.Errorf("infra_output: source is required (format: \"module.field\")") - } - dot := strings.Index(source, ".") - if dot < 1 || dot >= len(source)-1 { - return "", fmt.Errorf("infra_output: invalid source %q: expected \"module.field\" format", source) - } - moduleName := source[:dot] - field := source[dot+1:] - - // Apply env resolution: the state was persisted under the env-resolved name. - if envName != "" && wfCfg != nil { - for i := range wfCfg.Modules { - m := &wfCfg.Modules[i] - if m.Name != moduleName { - continue - } - resolved, ok := m.ResolveForEnv(envName) - if !ok { - return "", fmt.Errorf("infra_output: module %q is explicitly disabled for environment %q — cannot read infra_output from a disabled module", moduleName, envName) - } - if resolved.Name != "" { - moduleName = resolved.Name - } - break - } + moduleName, field, err := resolveInfraOutputSource(wfCfg, source, envName) + if err != nil { + return "", err } - if stateOutputs == nil { return "", fmt.Errorf("infra_output: state outputs not available for source %q — did infra apply succeed?", source) } @@ -101,6 +77,38 @@ func resolveInfraOutput(wfCfg *config.WorkflowConfig, source, envName string, st return s, nil } +func resolveInfraOutputSource(wfCfg *config.WorkflowConfig, source, envName string) (string, string, error) { + if source == "" { + return "", "", fmt.Errorf("infra_output: source is required (format: \"module.field\")") + } + dot := strings.Index(source, ".") + if dot < 1 || dot >= len(source)-1 { + return "", "", fmt.Errorf("infra_output: invalid source %q: expected \"module.field\" format", source) + } + moduleName := source[:dot] + field := source[dot+1:] + + // Apply env resolution: the state was persisted under the env-resolved name. + if envName != "" && wfCfg != nil { + for i := range wfCfg.Modules { + m := &wfCfg.Modules[i] + if m.Name != moduleName { + continue + } + resolved, ok := m.ResolveForEnv(envName) + if !ok { + return "", "", fmt.Errorf("infra_output: module %q is explicitly disabled for environment %q — cannot read infra_output from a disabled module", moduleName, envName) + } + if resolved.Name != "" { + moduleName = resolved.Name + } + break + } + } + + return moduleName, field, nil +} + // stateKeys returns the sorted keys of a state outputs map for error messages. func stateKeys(m map[string]map[string]any) []string { keys := make([]string, 0, len(m)) @@ -134,7 +142,10 @@ func syncInfraOutputSecrets(ctx context.Context, secretsCfg *SecretsConfig, prov return syncInfraOutputSecretsScoped(ctx, secretsCfg, provider, states, wfCfg, envName, hydrated, refreshOutputs, nil) } -func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig, provider secrets.Provider, states []interfaces.ResourceState, wfCfg *config.WorkflowConfig, envName string, hydrated map[string]string, refreshOutputs bool, sourceModuleScope map[string]struct{}) error { +func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig, provider secrets.Provider, states []interfaces.ResourceState, wfCfg *config.WorkflowConfig, envName string, hydrated map[string]string, refreshOutputs bool, sourceModuleScope map[string]struct{}, stateStores ...infraStateStore) error { + if len(stateStores) > 1 { + return fmt.Errorf("%w: infra_output sync requires at most one state store", interfaces.ErrValidation) + } if secretsCfg == nil { return nil } @@ -161,7 +172,10 @@ func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig for _, gen := range gens { genProvider, err := providerForSecretGen(wfCfg, provider, gen, envName) if err != nil { - return err + return cleanupStateError{operation: "resolve infra_output store", name: gen.Key, cause: err} + } + if genProvider == nil { + return fmt.Errorf("%w: infra_output secrets provider missing for %q", interfaces.ErrValidation, gen.Key) } lookupViaList := func(key string) (bool, error) { lookup, ok := listLookups[genProvider] @@ -190,10 +204,10 @@ func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig var listLookupErr error exists, listLookupErr = lookupViaList(gen.Key) if listLookupErr != nil { - return listLookupErr + return cleanupStateError{operation: "check infra_output alias", name: gen.Key, cause: listLookupErr} } default: - return fmt.Errorf("check secret %q: %w", gen.Key, getErr) + return cleanupStateError{operation: "check infra_output alias", name: gen.Key, cause: getErr} } if exists && !refreshOutputs { @@ -203,24 +217,26 @@ func syncInfraOutputSecretsScoped(ctx context.Context, secretsCfg *SecretsConfig newValue, resolveErr := resolveInfraOutput(wfCfg, gen.Source, envName, stateOutputs, hydrated) if resolveErr != nil { - return fmt.Errorf("generate infra_output secret %q: %w", gen.Key, resolveErr) + return cleanupStateError{operation: "resolve infra_output alias", name: gen.Key, cause: resolveErr} } - if exists { - // refreshOutputs is true here (guarded by the continue above). - // For readable providers skip the Set when the value is unchanged. - if isReadable && currentVal == newValue { - fmt.Printf(" secret %q: unchanged\n", gen.Key) - continue - } - if err := genProvider.Set(ctx, gen.Key, newValue); err != nil { - return fmt.Errorf("store secret %q: %w", gen.Key, err) + if exists && isReadable && currentVal == newValue { + fmt.Printf(" secret %q: unchanged\n", gen.Key) + continue + } + // Production always supplies a store; the optional form keeps narrow + // sync-only callers compatible without claiming durable cleanup. + if len(stateStores) != 0 { + if err := journalInfraOutputAlias(ctx, stateStores[0], states, wfCfg, envName, gen, genProvider, provider); err != nil { + return err } + } + if err := genProvider.Set(ctx, gen.Key, newValue); err != nil { + return cleanupStateError{operation: "store infra_output alias", name: gen.Key, cause: err} + } + if exists { fmt.Printf(" secret %q: updated from infra output\n", gen.Key) } else { - if err := genProvider.Set(ctx, gen.Key, newValue); err != nil { - return fmt.Errorf("store secret %q: %w", gen.Key, err) - } fmt.Printf(" secret %q: created from infra output\n", gen.Key) } } @@ -231,27 +247,10 @@ func infraOutputSourceInScope(wfCfg *config.WorkflowConfig, source, envName stri if len(scope) == 0 { return true } - dot := strings.Index(source, ".") - if dot < 1 { + moduleName, _, err := resolveInfraOutputSource(wfCfg, source, envName) + if err != nil { return false } - moduleName := source[:dot] - if envName != "" && wfCfg != nil { - for i := range wfCfg.Modules { - m := &wfCfg.Modules[i] - if m.Name != moduleName { - continue - } - resolved, ok := m.ResolveForEnv(envName) - if !ok { - return false - } - if resolved.Name != "" { - moduleName = resolved.Name - } - break - } - } _, ok := scope[moduleName] return ok } diff --git a/cmd/wfctl/infra_secret_aliases.go b/cmd/wfctl/infra_secret_aliases.go new file mode 100644 index 00000000..0c72cd45 --- /dev/null +++ b/cmd/wfctl/infra_secret_aliases.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" + "github.com/google/uuid" +) + +// Journal only aliases this apply is about to write. The durable source owns +// exact targets even if the generator is removed before the resource is deleted. +func journalInfraOutputAlias(ctx context.Context, store infraStateStore, observed []interfaces.ResourceState, cfg *config.WorkflowConfig, envName string, gen SecretGen, provider, defaultProvider secrets.Provider) error { + if store == nil || provider == nil || gen.Key == "" { + return fmt.Errorf("%w: infra_output alias journal requires a state store, provider, and key", interfaces.ErrValidation) + } + name, _, err := resolveInfraOutputSource(cfg, gen.Source, envName) + if err != nil { + return cleanupStateError{operation: "resolve alias source", name: gen.Key, cause: err} + } + var expected *interfaces.ResourceState + for i := range observed { + if observed[i].Name != name { + continue + } + if expected != nil { + return fmt.Errorf("%w: duplicate infra_output source %q", interfaces.ErrValidation, name) + } + expected = &observed[i] + } + state, err := loadCleanupResource(ctx, store, name) + if err != nil { + return err + } + if expected == nil || state == nil { + return fmt.Errorf("%w: durable infra_output source missing for %q", interfaces.ErrValidation, name) + } + if state.ProviderID != expected.ProviderID || state.Type != expected.Type || resourceStateProviderRef(*state) != resourceStateProviderRef(*expected) { + return fmt.Errorf("%w: infra_output source identity changed for %q", interfaces.ErrValidation, name) + } + if expected.Lifecycle != nil && (state.Lifecycle == nil || state.Lifecycle.Generation != expected.Lifecycle.Generation) { + return fmt.Errorf("%w: infra_output source generation changed for %q", interfaces.ErrValidation, name) + } + if state.Lifecycle == nil { + refs, err := routedReferencesFromState(*state, defaultProvider) + if err != nil { + return err + } + state.Lifecycle = &interfaces.ResourceLifecycle{Generation: uuid.NewString(), Phase: interfaces.ResourcePhaseActive, Secrets: refs} + } + if state.Lifecycle.Generation == "" || state.Lifecycle.Phase != interfaces.ResourcePhaseActive { + return fmt.Errorf("%w: unresolved infra_output source lifecycle for %q", interfaces.ErrValidation, name) + } + target := secrets.DescribeTarget(provider) + ref := interfaces.RoutedSecretReference{Key: gen.Key, Store: gen.Store, Provider: target.Provider, Scope: target.Scope, Subject: target.Subject} + owners, err := store.ListResources(ctx) + if err != nil { + return cleanupStateError{operation: "read alias ownership", name: gen.Key, cause: err} + } + for i := range owners { + owner := &owners[i] + if owner.Name == state.Name || owner.Lifecycle == nil { + continue + } + for _, owned := range owner.Lifecycle.Secrets { + // Store names are config aliases; ownership is the concrete namespace/key. + if owned.Key == ref.Key && owned.Provider == ref.Provider && owned.Scope == ref.Scope && owned.Subject == ref.Subject { + return fmt.Errorf("%w: infra_output alias %q is owned by resource %q", interfaces.ErrValidation, gen.Key, owner.Name) + } + } + } + found := false + for i, prior := range state.Lifecycle.Secrets { + if prior.Key == ref.Key && prior.Store == ref.Store && prior.Provider == ref.Provider && prior.Scope == ref.Scope && prior.Subject == ref.Subject { + state.Lifecycle.Secrets[i] = ref + found = true + break + } + } + if !found { + state.Lifecycle.Secrets = append(state.Lifecycle.Secrets, ref) + } + return saveCleanupResource(ctx, store, *state) +} diff --git a/cmd/wfctl/infra_secret_cleanup.go b/cmd/wfctl/infra_secret_cleanup.go new file mode 100644 index 00000000..291f0053 --- /dev/null +++ b/cmd/wfctl/infra_secret_cleanup.go @@ -0,0 +1,398 @@ +package main + +import ( + "context" + "fmt" + "reflect" + "slices" + "sort" + "strings" + "time" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" + "github.com/google/uuid" +) + +type cleanupSecretResolver func(interfaces.RoutedSecretReference) (secrets.Provider, error) + +type cleanupProviderOptions struct { + ConfigFile string + Environment string +} + +func wireResourceSecretCleanup(hooks *wfctlhelpers.ApplyPlanHooks, store infraStateStore, provider secrets.Provider, options cleanupProviderOptions) { + prepared := make(map[string]*interfaces.ResourceState) + priorBefore := hooks.OnBeforeAction + hooks.OnBeforeAction = func(ctx context.Context, action interfaces.PlanAction) error { + if priorBefore != nil { + if err := priorBefore(ctx, action); err != nil { + return err + } + } + if action.Action != "delete" && action.Action != "replace" { + return nil + } + state, err := prepareResourceSecretDeletion(ctx, store, provider, action) + if err != nil { + return err + } + prepared[action.Resource.Name] = state + return nil + } + hooks.ResourceDeletionComplete = func(ctx context.Context, action interfaces.PlanAction) (bool, error) { + state := prepared[action.Resource.Name] + if state == nil { + return false, nil + } + current, err := requireCleanupGeneration(ctx, store, *state) + if err != nil { + return false, err + } + return current.Lifecycle.Phase == interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, nil + } + resolver := func(ref interfaces.RoutedSecretReference) (secrets.Provider, error) { + if ref.Store == "" { + return provider, nil + } + cfg, err := config.LoadFromFile(options.ConfigFile) + if err != nil { + return nil, err + } + return providerForSecretGen(cfg, provider, SecretGen{Key: ref.Key, Store: ref.Store}, options.Environment) + } + priorDeleted := hooks.OnResourceDeleted + hooks.OnResourceDeleted = func(ctx context.Context, action interfaces.PlanAction) error { + state := prepared[action.Resource.Name] + if state == nil { + if priorDeleted != nil { + return priorDeleted(ctx, action) + } + return nil + } + // Cloud deletion has completed even if the caller was interrupted. + // Persist that fact with a bounded fresh context before any revoke. + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return reconcileResourceSecretDeletion(cleanupCtx, store, resolver, *state) + } +} + +func cloneResourceLifecycle(lifecycle *interfaces.ResourceLifecycle) *interfaces.ResourceLifecycle { + if lifecycle == nil { + return nil + } + copy := *lifecycle + copy.Secrets = slices.Clone(lifecycle.Secrets) + return © +} + +func loadCleanupResource(ctx context.Context, store infraStateStore, name string) (*interfaces.ResourceState, error) { + states, err := store.ListResources(ctx) + if err != nil { + return nil, cleanupStateError{operation: "read", name: name, cause: err} + } + var found *interfaces.ResourceState + for i := range states { + if states[i].Name != name { + continue + } + if found != nil { + return nil, fmt.Errorf("%w: duplicate cleanup resource %q", interfaces.ErrValidation, name) + } + copy := states[i] + copy.Lifecycle = cloneResourceLifecycle(copy.Lifecycle) + found = © + } + return found, nil +} + +// A save is not accepted until the backend reads the same journal back. Older +// plugin backends must fail closed instead of silently discarding new metadata. +func saveCleanupResource(ctx context.Context, store infraStateStore, state interfaces.ResourceState) error { + if state.ID == "" { + state.ID = state.Name + } + if err := store.SaveResource(ctx, state); err != nil { + return cleanupStateError{operation: "save", name: state.Name, cause: err} + } + stored, err := loadCleanupResource(ctx, store, state.Name) + if err != nil { + return err + } + if stored == nil || stored.ProviderID != state.ProviderID || !reflect.DeepEqual(stored.Lifecycle, state.Lifecycle) { + return fmt.Errorf("%w: state backend did not preserve cleanup journal for %q", interfaces.ErrValidation, state.Name) + } + return nil +} + +func prepareResourceSecretDeletion(ctx context.Context, store infraStateStore, provider secrets.Provider, action interfaces.PlanAction) (*interfaces.ResourceState, error) { + state, err := loadCleanupResource(ctx, store, action.Resource.Name) + if err != nil { + return nil, err + } + if state == nil { + if action.Current != nil && (action.Current.Lifecycle != nil || hasRoutedPlaceholders(action.Current.Outputs)) { + return nil, fmt.Errorf("%w: missing durable cleanup state for %q", interfaces.ErrValidation, action.Resource.Name) + } + return nil, nil + } + if action.Current != nil { + if state.ProviderID != action.Current.ProviderID || state.Type != action.Current.Type || resourceStateProviderRef(*state) != resourceStateProviderRef(*action.Current) { + return nil, fmt.Errorf("%w: resource identity changed before cleanup for %q", interfaces.ErrValidation, state.Name) + } + if action.Current.Lifecycle != nil && (state.Lifecycle == nil || state.Lifecycle.Generation != action.Current.Lifecycle.Generation) { + return nil, fmt.Errorf("%w: resource generation changed before cleanup for %q", interfaces.ErrValidation, state.Name) + } + } + if state.Lifecycle == nil { + refs, err := routedReferencesFromState(*state, provider) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return nil, nil + } + state.Lifecycle = &interfaces.ResourceLifecycle{Generation: uuid.NewString(), Phase: interfaces.ResourcePhaseActive, Secrets: refs} + } + if state.Lifecycle.Generation == "" { + return nil, fmt.Errorf("%w: missing cleanup generation for %q", interfaces.ErrValidation, state.Name) + } + if state.Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + state.Lifecycle.Phase = interfaces.ResourcePhaseCloudDeletePending + } + if err := saveCleanupResource(ctx, store, *state); err != nil { + return nil, err + } + return state, nil +} + +func routedReferencesFromState(state interfaces.ResourceState, provider secrets.Provider) ([]interfaces.RoutedSecretReference, error) { + var refs []interfaces.RoutedSecretReference + target := secrets.DescribeTarget(provider) + seen := make(map[string]struct{}) + for _, value := range state.Outputs { + if !sensitive.IsPlaceholder(value) { + continue + } + key := strings.TrimPrefix(value.(string), sensitive.PlaceholderPrefix) + if key == "" || provider == nil { + return nil, fmt.Errorf("%w: routed cleanup identifier/provider missing for %q", interfaces.ErrValidation, state.Name) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + refs = append(refs, interfaces.RoutedSecretReference{Key: key, Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}) + } + sort.Slice(refs, func(i, j int) bool { return refs[i].Key < refs[j].Key }) + return refs, nil +} + +func hasRoutedPlaceholders(outputs map[string]any) bool { + for _, value := range outputs { + if sensitive.IsPlaceholder(value) { + return true + } + } + return false +} + +func prepareResourceRoutingIntent(ctx context.Context, store infraStateStore, provider secrets.Provider, state interfaces.ResourceState, out interfaces.ResourceOutput, created bool) (interfaces.ResourceState, bool, error) { + prior, err := loadCleanupResource(ctx, store, state.Name) + if err != nil { + return state, false, err + } + if prior != nil && prior.Lifecycle != nil { + if prior.ProviderID != state.ProviderID || (prior.Lifecycle.Phase != interfaces.ResourcePhaseActive && prior.Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending) { + return state, false, fmt.Errorf("%w: unresolved lifecycle debt/identity for %q", interfaces.ErrValidation, state.Name) + } + state.Lifecycle = cloneResourceLifecycle(prior.Lifecycle) + } else if prior != nil && hasRoutedPlaceholders(prior.Outputs) { + if prior.ProviderID != state.ProviderID || prior.Type != state.Type || resourceStateProviderRef(*prior) != resourceStateProviderRef(state) { + return state, false, fmt.Errorf("%w: legacy routed resource identity changed for %q", interfaces.ErrValidation, state.Name) + } + refs, err := routedReferencesFromState(*prior, provider) + if err != nil { + return state, false, err + } + state.Lifecycle = &interfaces.ResourceLifecycle{Generation: uuid.NewString(), Phase: interfaces.ResourcePhaseActive, Secrets: refs} + } + sanitized := make(map[string]any, len(out.Outputs)) + for key, value := range out.Outputs { + sanitized[key] = value + } + var references []interfaces.RoutedSecretReference + target := secrets.DescribeTarget(provider) + for key, sensitiveFlag := range out.Sensitive { + if !sensitiveFlag { + continue + } + if _, present := out.Outputs[key]; !present { + continue + } + if provider == nil { + return state, false, fmt.Errorf("%w: no secrets provider for sensitive output of %q", interfaces.ErrValidation, state.Name) + } + identifier := sensitive.SecretKey(state.Name, key) + sanitized[key] = sensitive.PlaceholderPrefix + identifier + references = append(references, interfaces.RoutedSecretReference{Key: identifier, Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}) + } + if len(references) == 0 { + return state, false, nil + } + sort.Slice(references, func(i, j int) bool { return references[i].Key < references[j].Key }) + if state.Lifecycle == nil { + state.Lifecycle = &interfaces.ResourceLifecycle{} + } + state.Lifecycle.Generation = uuid.NewString() + state.Lifecycle.Phase = interfaces.ResourcePhaseSecretRoutingPending + state.Lifecycle.RoutingCreated = created + for _, ref := range references { + found := false + for i, old := range state.Lifecycle.Secrets { + if old.Key == ref.Key && old.Store == ref.Store && old.Provider == ref.Provider && old.Scope == ref.Scope && old.Subject == ref.Subject { + state.Lifecycle.Secrets[i] = ref + found = true + break + } + } + if !found { + state.Lifecycle.Secrets = append(state.Lifecycle.Secrets, ref) + } + } + state.Outputs = sanitized + if err := saveCleanupResource(ctx, store, state); err != nil { + return state, true, err + } + return state, true, nil +} + +func compensateCreatedResourceWithJournal(store infraStateStore, provider secrets.Provider, driver interfaces.ResourceDriver, state interfaces.ResourceState) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + current, err := loadCleanupResource(ctx, store, state.Name) + if err != nil { + return err + } + if current == nil || current.Lifecycle == nil { + // A journal write failed before any secret write. Legacy rollback is + // safe here only without routed values; it never revokes guessed keys. + return compensateUnjournaledCreation(driver, state) + } + if state.ProviderID != current.ProviderID || state.Lifecycle == nil || state.Lifecycle.Generation != current.Lifecycle.Generation { + return fmt.Errorf("%w: failed-create cleanup identity changed for %q", interfaces.ErrValidation, state.Name) + } + prepared, err := prepareResourceSecretDeletion(ctx, store, provider, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: current}) + if err != nil { + return err + } + if driver == nil { + return fmt.Errorf("%w: failed-create cleanup driver missing", interfaces.ErrValidation) + } + if err := driver.Delete(ctx, interfaces.ResourceRef{Name: state.Name, Type: state.Type, ProviderID: state.ProviderID}); err != nil && !interfaces.IsErrResourceNotFound(err) { + return cleanupStateError{operation: "delete failed creation", name: state.Name, cause: err} + } + return reconcileResourceSecretDeletion(ctx, store, func(ref interfaces.RoutedSecretReference) (secrets.Provider, error) { + if ref.Store != "" { + return nil, fmt.Errorf("%w: failed-create cleanup cannot resolve named store", interfaces.ErrValidation) + } + return provider, nil + }, *prepared) +} + +func requireCleanupGeneration(ctx context.Context, store infraStateStore, expected interfaces.ResourceState) (*interfaces.ResourceState, error) { + state, err := loadCleanupResource(ctx, store, expected.Name) + if err != nil { + return nil, err + } + if expected.Lifecycle == nil || expected.Lifecycle.Generation == "" || state == nil || state.Lifecycle == nil || state.Lifecycle.Generation != expected.Lifecycle.Generation || state.ProviderID != expected.ProviderID || state.Type != expected.Type { + return nil, fmt.Errorf("%w: cleanup generation/identity changed for %q", interfaces.ErrValidation, expected.Name) + } + return state, nil +} + +func resolveCleanupSecret(resolver cleanupSecretResolver, ref interfaces.RoutedSecretReference) (secrets.Provider, error) { + if resolver == nil || ref.Key == "" { + return nil, fmt.Errorf("%w: cleanup resolver/identifier missing", interfaces.ErrValidation) + } + provider, err := resolver(ref) + if err != nil { + return nil, cleanupStateError{operation: "resolve secret store", name: ref.Key, cause: err} + } + target := secrets.DescribeTarget(provider) + if provider == nil || target.Provider != ref.Provider || target.Scope != ref.Scope || target.Subject != ref.Subject { + return nil, fmt.Errorf("%w: cleanup secret namespace changed for %q", interfaces.ErrValidation, ref.Key) + } + return provider, nil +} + +// Called only after cloud deletion succeeds. A restart may repeat revokes, but +// cannot forget the resource until every exact identifier reads back absent. +func reconcileResourceSecretDeletion(ctx context.Context, store infraStateStore, resolver cleanupSecretResolver, expected interfaces.ResourceState) error { + state, err := requireCleanupGeneration(ctx, store, expected) + if err != nil { + return err + } + if state.Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletePending && state.Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + return fmt.Errorf("%w: cleanup was not prepared for %q", interfaces.ErrValidation, state.Name) + } + if state.Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + state.Lifecycle.Phase = interfaces.ResourcePhaseCloudDeletedSecretCleanupPending + if err := saveCleanupResource(ctx, store, *state); err != nil { + return err + } + } + for i, ref := range state.Lifecycle.Secrets { + if ref.VerifiedAbsent { + continue + } + if _, err := requireCleanupGeneration(ctx, store, *state); err != nil { + return err + } + provider, err := resolveCleanupSecret(resolver, ref) + if err != nil { + return err + } + if err := sensitive.RevokeKeys(ctx, provider, []string{ref.Key}); err != nil { + return err + } + if _, err := requireCleanupGeneration(ctx, store, *state); err != nil { + return err + } + state.Lifecycle.Secrets[i].VerifiedAbsent = true + if err := saveCleanupResource(ctx, store, *state); err != nil { + return err + } + } + for _, ref := range state.Lifecycle.Secrets { + provider, err := resolveCleanupSecret(resolver, ref) + if err != nil { + return err + } + if err := sensitive.VerifyAbsent(ctx, provider, ref.Key); err != nil { + return err + } + } + if _, err := requireCleanupGeneration(ctx, store, *state); err != nil { + return err + } + if err := store.DeleteResource(ctx, state.Name); err != nil { + return cleanupStateError{operation: "remove completed tombstone", name: state.Name, cause: err} + } + return nil +} + +type cleanupStateError struct { + operation string + name string + cause error +} + +func (e cleanupStateError) Error() string { + return fmt.Sprintf("resource cleanup %s failed for %q", e.operation, e.name) +} +func (e cleanupStateError) Unwrap() error { return e.cause } diff --git a/cmd/wfctl/infra_secret_cleanup_test.go b/cmd/wfctl/infra_secret_cleanup_test.go new file mode 100644 index 00000000..a0ef3b8a --- /dev/null +++ b/cmd/wfctl/infra_secret_cleanup_test.go @@ -0,0 +1,669 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" + "github.com/GoCodeAlone/workflow/secrets" +) + +type cleanupApplyDriver struct { + stubSensitiveDriver + store infraStateStore + secrets *envTestProvider + creates int +} + +type cleanupRestartDriver struct { + stubSensitiveDriver + provider secrets.Provider + creates int +} + +func (d *cleanupRestartDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + if err := sensitive.VerifyAbsent(ctx, d.provider, "WF_CLEANUP_RESTART_KEY"); err != nil { + return nil, err + } + d.creates++ + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "new-cloud-id", Outputs: map[string]any{"public": "consumer-payload"}}, nil +} + +func TestCleanupPending_DirectAndSavedApplyResumeDurableState(t *testing.T) { + for _, saved := range []bool{false, true} { + t.Run(map[bool]string{false: "direct", true: "saved"}[saved], func(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgFile, []byte("modules:\n - name: state\n type: iac.state\n config: {backend: filesystem, directory: "+filepath.Join(dir, "state")+"}\nsecrets: {provider: env}\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("WF_CLEANUP_RESTART_KEY", "known-restart-credential") + provider := secrets.NewEnvProvider("") + target := secrets.DescribeTarget(provider) + store, err := resolveStateStore(cfgFile, "") + if err != nil { + t.Fatal(err) + } + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", Provider: "test-cloud", ProviderID: "old-cloud-id", Lifecycle: &interfaces.ResourceLifecycle{Generation: "restart-generation", Phase: interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, Secrets: []interfaces.RoutedSecretReference{{Key: "WF_CLEANUP_RESTART_KEY", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}}}} + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + driver := &cleanupRestartDriver{provider: provider} + cloud := &declarativeCLIProvider{driver: driver} + specs := []interfaces.ResourceSpec{{Name: state.Name, Type: state.Type}} + if saved { + plan, planErr := platform.ComputePlan(t.Context(), cloud, specs, []interfaces.ResourceState{state}) + if planErr != nil { + t.Fatal(planErr) + } + data, marshalErr := json.Marshal(plan) + if marshalErr != nil { + t.Fatal(marshalErr) + } + var reloaded interfaces.IaCPlan + if err := json.Unmarshal(data, &reloaded); err != nil { + t.Fatal(err) + } + err = applyPrecomputedPlanWithStore(t.Context(), reloaded, cloud, "test-cloud", store, io.Discard, "", cfgFile, nil) + } else { + err = applyWithProviderAndStore(t.Context(), cloud, "test-cloud", specs, []interfaces.ResourceState{state}, store, io.Discard, "", cfgFile, nil) + } + states, loadErr := store.ListResources(t.Context()) + if err != nil || loadErr != nil || driver.creates != 1 || len(driver.deleteCalls) != 0 || len(states) != 1 || states[0].ProviderID != "new-cloud-id" { + t.Fatalf("durable restart was not applied: err=%v load=%v creates=%d deletes=%v states=%v", err, loadErr, driver.creates, driver.deleteCalls, states) + } + if err := sensitive.VerifyAbsent(t.Context(), provider, "WF_CLEANUP_RESTART_KEY"); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCleanupPending_UpdateImportsAllLegacyKeys(t *testing.T) { + for _, routes := range []bool{false, true} { + t.Run(map[bool]string{false: "no-new-sensitive-output", true: "partial-sensitive-output"}[routes], func(t *testing.T) { + store := cleanupStateStore(t) + provider := newEnvTestProvider() + state := interfaces.ResourceState{ID: "legacy", Name: "legacy", Type: "infra.key", ProviderID: "exact-id", Outputs: map[string]any{}} + for _, key := range []string{"access_key", "secret_key"} { + state.Outputs[key] = sensitive.Placeholder(state.Name, key) + provider.values[sensitive.SecretKey(state.Name, key)] = "known-legacy-credential" + } + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + out := interfaces.ResourceOutput{ProviderID: state.ProviderID, Outputs: map[string]any{"public": "consumer-payload"}} + if routes { + out.Outputs["access_key"] = "known-updated-credential" + out.Sensitive = map[string]bool{"access_key": true} + } + if _, err := persistApplyMode(t.Context(), store, provider, nil, state, out, false); err != nil { + t.Fatal(err) + } + updated, err := loadCleanupResource(t.Context(), store, state.Name) + if err != nil || updated == nil || updated.Lifecycle == nil || len(updated.Lifecycle.Secrets) != 2 { + t.Fatalf("update forgot previously routed keys: state=%+v err=%v", updated, err) + } + prepared, err := prepareResourceSecretDeletion(t.Context(), store, provider, interfaces.PlanAction{Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: updated}) + if err != nil { + t.Fatal(err) + } + if err := reconcileResourceSecretDeletion(t.Context(), store, func(interfaces.RoutedSecretReference) (secrets.Provider, error) { return provider, nil }, *prepared); err != nil { + t.Fatal(err) + } + if len(provider.values) != 0 { + t.Fatal("cleanup forgot a legacy credential") + } + }) + } +} + +type interruptedRoutingProvider struct{ *envTestProvider } + +func (*interruptedRoutingProvider) Set(context.Context, string, string) error { + panic("simulated controller interruption before secret write") +} + +type interruptedRoutingDriver struct { + stubSensitiveDriver + provider secrets.Provider + creates int +} + +type upsertRoutingDriver struct { + stubSensitiveDriver + creates int + updates int +} + +func (*upsertRoutingDriver) SupportsUpsert() bool { return true } + +func (d *upsertRoutingDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.creates++ + return nil, interfaces.ErrResourceAlreadyExists +} + +func (*upsertRoutingDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: "existing-upsert-id"}, nil +} + +func (d *upsertRoutingDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.updates++ + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID, Outputs: map[string]any{"password": "known-upsert-credential"}, Sensitive: map[string]bool{"password": true}}, nil +} + +func TestCleanupPending_UpsertRoutingCannotAuthorizeExistingDelete(t *testing.T) { + store := cleanupStateStore(t) + driver := &upsertRoutingDriver{} + cloud := &declarativeCLIProvider{driver: driver} + spec := interfaces.ResourceSpec{Name: "upsert-existing", Type: "infra.database"} + plan, err := platform.ComputePlan(t.Context(), cloud, []interfaces.ResourceSpec{spec}, nil) + if err != nil || len(plan.Actions) != 1 || plan.Actions[0].Action != "create" { + t.Fatalf("initial plan: %+v err=%v", plan, err) + } + var interruption any + func() { + defer func() { interruption = recover() }() + _, err = wfctlhelpers.ApplyPlanWithHooks(t.Context(), cloud, &plan, statePersistenceHooks(store, &interruptedRoutingProvider{newEnvTestProvider()}, cloud, "test-cloud", "initial", nil)) + }() + if interruption != "simulated controller interruption before secret write" || driver.updates != 1 { + t.Fatalf("real upsert routing was not interrupted: interruption=%v updates=%d err=%v", interruption, driver.updates, err) + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].ProviderID != "existing-upsert-id" || states[0].Lifecycle == nil || states[0].Lifecycle.RoutingCreated { + t.Fatalf("upsert falsely authorized creation rollback: states=%+v err=%v", states, err) + } + _, err = platform.ComputePlan(t.Context(), cloud, []interfaces.ResourceSpec{spec}, states) + if !errors.Is(err, interfaces.ErrValidation) || len(driver.deleteCalls) != 0 { + t.Fatalf("upsert routing did not block destructive recovery: err=%v deletes=%v", err, driver.deleteCalls) + } +} + +func TestCleanupPending_UpsertWriteFailureMustRetainExistingResource(t *testing.T) { + store := cleanupStateStore(t) + driver := &upsertRoutingDriver{} + cloud := &declarativeCLIProvider{driver: driver} + provider := &journalCheckedRoutingProvider{cleanupBoundaryProvider: &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider()}, store: store, failAfterWrite: true} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Name: "upsert-existing", Type: "infra.database"}}}} + _, err := wfctlhelpers.ApplyPlanWithHooks(t.Context(), cloud, plan, statePersistenceHooks(store, provider, cloud, "test-cloud", "initial", nil)) + if err == nil || !provider.checked || driver.updates != 1 || len(driver.deleteCalls) != 0 { + t.Fatalf("upsert routing error compensated an existing resource: err=%v checked=%v updates=%d deletes=%v", err, provider.checked, driver.updates, driver.deleteCalls) + } + state, err := loadCleanupResource(t.Context(), store, "upsert-existing") + if err != nil || state == nil || state.Lifecycle == nil || state.Lifecycle.RoutingCreated || state.Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending { + t.Fatalf("upsert error lost non-destructive routing debt: state=%+v err=%v", state, err) + } +} + +func TestCleanupPending_SavedPlanCannotAssertCreationOwnership(t *testing.T) { + owned := true + action := interfaces.PlanAction{Action: "create", CreationOwned: &owned, Resource: interfaces.ResourceSpec{Name: "database", Type: "infra.database"}} + data, err := json.Marshal(action) + if err != nil || strings.Contains(string(data), "CreationOwned") || strings.Contains(string(data), "creation_owned") { + t.Fatalf("runtime creation ownership entered a saved plan: %s err=%v", data, err) + } + var forged interfaces.PlanAction + if err := json.Unmarshal([]byte(`{"action":"create","creation_owned":true,"CreationOwned":true}`), &forged); err != nil || forged.CreationOwned != nil { + t.Fatalf("saved plan asserted runtime ownership: action=%+v err=%v", forged, err) + } +} + +func (d *interruptedRoutingDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + if err := sensitive.VerifyAbsent(ctx, d.provider, sensitive.SecretKey(spec.Name, "password")); err != nil { + return nil, err + } + d.creates++ + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "replacement-id", Outputs: map[string]any{"public": "consumer-payload"}}, nil +} + +func TestCleanupPending_InterruptedRoutingCannotConverge(t *testing.T) { + for _, created := range []bool{false, true} { + for _, saved := range []bool{false, true} { + t.Run(map[bool]string{false: "pre-existing", true: "created"}[created]+"/"+map[bool]string{false: "direct", true: "saved"}[saved], func(t *testing.T) { + store := cleanupStateStore(t) + provider := &interruptedRoutingProvider{newEnvTestProvider()} + state := interfaces.ResourceState{ID: "interrupted", Name: "interrupted", Type: "infra.key", ProviderID: "old-id"} + out := interfaces.ResourceOutput{ProviderID: state.ProviderID, Outputs: map[string]any{"password": "known-interrupted-credential"}, Sensitive: map[string]bool{"password": true}} + func() { + defer func() { + if recover() == nil { + t.Fatal("fixture did not interrupt after write-ahead persistence") + } + }() + _, _ = persistApplyMode(t.Context(), store, provider, &stubSensitiveDriver{}, state, out, created) + }() + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].Lifecycle == nil || states[0].Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending { + t.Fatalf("interrupted intent was not durable: states=%v err=%v", states, err) + } + driver := &interruptedRoutingDriver{provider: provider} + cloud := &declarativeCLIProvider{driver: driver} + plan, err := platform.ComputePlan(t.Context(), cloud, []interfaces.ResourceSpec{{Name: state.Name, Type: state.Type}}, states) + if !created { + if !errors.Is(err, interfaces.ErrValidation) || len(driver.deleteCalls) != 0 { + t.Fatalf("pre-existing routing debt was declared converged: plan=%+v err=%v", plan, err) + } + return + } + if err != nil || len(plan.Actions) != 1 || plan.Actions[0].Action != "replace" { + t.Fatalf("owned interrupted creation was declared converged: plan=%+v err=%v", plan, err) + } + if saved { + data, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &plan); err != nil { + t.Fatal(err) + } + } + hooks := statePersistenceHooks(store, provider, cloud, "test-cloud", "restart", nil) + result, err := wfctlhelpers.ApplyPlanWithHooks(t.Context(), cloud, &plan, hooks) + if err != nil || len(result.Errors) != 0 || driver.creates != 1 || len(driver.deleteCalls) != 1 || driver.deleteCalls[0].ProviderID != state.ProviderID { + t.Fatalf("owned routing debt was not recovered: result=%+v err=%v creates=%d deletes=%v", result, err, driver.creates, driver.deleteCalls) + } + }) + } + } +} + +func (d *cleanupApplyDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) error { + states, err := d.store.ListResources(ctx) + if err != nil { + return err + } + if len(states) != 1 || states[0].Lifecycle == nil || states[0].Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletePending { + return interfaces.ErrValidation + } + return d.stubSensitiveDriver.Delete(ctx, ref) +} + +func (d *cleanupApplyDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + if _, ok := d.secrets.values["exact-derived-key"]; ok { + return nil, interfaces.ErrValidation + } + if _, ok := d.secrets.values["NAMED_ALIAS"]; ok { + return nil, interfaces.ErrValidation + } + d.creates++ + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "replacement-id"}, nil +} + +func TestReplaceCleanup_RealApplyHooksRetainAndFinalizeDebt(t *testing.T) { + for _, kind := range []string{"delete", "replace"} { + for _, phase := range []interfaces.ResourcePhase{interfaces.ResourcePhaseActive, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} { + t.Run(kind+"/"+string(phase), func(t *testing.T) { + store := cleanupStateStore(t) + secretProvider := newEnvTestProvider() + secretProvider.values["exact-derived-key"], secretProvider.values["NAMED_ALIAS"] = "known-live-hook-marker", "known-live-hook-marker" + state := cleanupResourceFixture(t, store, secretProvider) + state.Lifecycle.Phase = phase + state.Lifecycle.Secrets[1].Store = "" + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + driver := &cleanupApplyDriver{store: store, secrets: secretProvider} + provider := &declarativeCLIProvider{driver: driver} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: kind, Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: &state}}} + hooks := statePersistenceHooks(store, secretProvider, provider, "test-cloud", "cleanup-plan", nil) + result, err := wfctlhelpers.ApplyPlanWithHooks(t.Context(), provider, plan, hooks) + wantDelete := 1 + if phase == interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + wantDelete = 0 + } + wantCreate := 0 + if kind == "replace" { + wantCreate = 1 + } + if err != nil || len(result.Errors) != 0 || len(driver.deleteCalls) != wantDelete || driver.creates != wantCreate { + t.Fatalf("real apply cleanup contract failed: err=%v result=%+v deletes=%v creates=%d", err, result, driver.deleteCalls, driver.creates) + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != wantCreate { + t.Fatalf("state not finalized: states=%v err=%v", states, err) + } + if len(secretProvider.values) != 0 { + t.Fatal("apply forgot state while secrets survived") + } + }) + } + } +} + +func cleanupStateStore(t *testing.T) infraStateStore { + t.Helper() + cfg := filepath.Join(t.TempDir(), "infra.yaml") + if err := os.WriteFile(cfg, []byte("modules:\n - name: state\n type: iac.state\n config:\n backend: filesystem\n directory: "+t.TempDir()+"\n"), 0o600); err != nil { + t.Fatal(err) + } + store, err := resolveStateStore(cfg, "") + if err != nil { + t.Fatal(err) + } + return store +} + +type cleanupBoundaryProvider struct { + *envTestProvider + deleteCount int + crashAt int + retain bool +} + +type journalCheckedRoutingProvider struct { + *cleanupBoundaryProvider + store infraStateStore + failAfterWrite bool + checked bool +} + +func (p *journalCheckedRoutingProvider) Set(ctx context.Context, key, value string) error { + states, err := p.store.ListResources(ctx) + if err != nil { + return err + } + if len(states) != 1 || states[0].Lifecycle == nil || states[0].Lifecycle.Phase != interfaces.ResourcePhaseSecretRoutingPending { + return interfaces.ErrValidation + } + found := false + for _, ref := range states[0].Lifecycle.Secrets { + if ref.Key == key { + found = true + } + } + data, err := json.Marshal(states) + if err != nil || !found || strings.Contains(string(data), value) { + return interfaces.ErrValidation + } + p.checked = true + if err := p.envTestProvider.Set(ctx, key, value); err != nil { + return err + } + if p.failAfterWrite { + return context.Canceled + } + return nil +} + +func TestCleanupPending_RoutingIntentPrecedesWriteAndFailedCreate(t *testing.T) { + for _, failedCreate := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "failed-create"}[failedCreate], func(t *testing.T) { + store := &cleanupBoundaryStore{infraStateStore: cleanupStateStore(t)} + provider := &journalCheckedRoutingProvider{cleanupBoundaryProvider: &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider(), retain: failedCreate}, store: store, failAfterWrite: failedCreate} + driver := &stubSensitiveDriver{} + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "cloud-id"} + out := interfaces.ResourceOutput{Name: state.Name, Type: state.Type, ProviderID: state.ProviderID, Outputs: map[string]any{"password": "known-writeahead-marker", "public": "consumer-payload"}, Sensitive: map[string]bool{"password": true}} + _, err := persistApplyMode(t.Context(), store, provider, driver, state, out, true) + if !provider.checked { + t.Fatal("secret was written before durable intent/readback") + } + states, stateErr := store.ListResources(t.Context()) + if stateErr != nil || len(states) != 1 || states[0].Lifecycle == nil { + t.Fatalf("routing or cleanup debt lost: states=%v err=%v", states, stateErr) + } + data, jsonErr := json.Marshal(states) + if jsonErr != nil || strings.Contains(string(data), "known-writeahead-marker") || states[0].Outputs["public"] != "consumer-payload" { + t.Fatalf("journal/state leaked or altered payload: %s err=%v", data, jsonErr) + } + if failedCreate { + if err == nil || !errors.Is(err, context.Canceled) || len(driver.deleteCalls) != 1 || states[0].Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + t.Fatalf("failed-create cleanup did not retain debt: err=%v deletes=%v state=%v", err, driver.deleteCalls, states) + } + provider.retain = false + if err := reconcileResourceSecretDeletion(t.Context(), store, func(interfaces.RoutedSecretReference) (secrets.Provider, error) { return provider, nil }, states[0]); err != nil { + t.Fatal(err) + } + states, err = store.ListResources(t.Context()) + if err != nil || len(states) != 0 || len(provider.values) != 0 { + t.Fatalf("failed-create restart did not finish: states=%v values=%v err=%v", states, provider.values, err) + } + } else if err != nil || len(driver.deleteCalls) != 0 || states[0].Lifecycle.Phase != interfaces.ResourcePhaseActive { + t.Fatalf("successful routing did not commit: err=%v deletes=%v states=%v", err, driver.deleteCalls, states) + } else if store.saves != 2 { + t.Fatalf("routing must save intent and active state once each, got %d saves", store.saves) + } + }) + } +} + +func TestCleanupPending_AdoptedRoutingPersistsIntentWithoutDeletingCloud(t *testing.T) { + for _, failAfterWrite := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "interrupted"}[failAfterWrite], func(t *testing.T) { + store := cleanupStateStore(t) + provider := &journalCheckedRoutingProvider{cleanupBoundaryProvider: &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider()}, store: store, failAfterWrite: failAfterWrite} + state := interfaces.ResourceState{ID: "adopted", Name: "adopted", Type: "infra.database", ProviderID: "existing-id"} + out := interfaces.ResourceOutput{Outputs: map[string]any{"password": "known-adopted-marker", "public": "consumer-payload"}, Sensitive: map[string]bool{"password": true}} + _, err := persistAdoptRouteMode(t.Context(), store, provider, state, out) + if !provider.checked { + t.Fatal("adoption wrote a credential without durable intent") + } + states, loadErr := store.ListResources(t.Context()) + if loadErr != nil || len(states) != 1 || states[0].Lifecycle == nil { + t.Fatalf("adopted routing debt disappeared: states=%v err=%v", states, loadErr) + } + wantPhase := interfaces.ResourcePhaseActive + if failAfterWrite { + wantPhase = interfaces.ResourcePhaseSecretRoutingPending + if !errors.Is(err, context.Canceled) { + t.Fatalf("interruption lost cause: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + data, jsonErr := json.Marshal(states) + if jsonErr != nil || strings.Contains(string(data), "known-adopted-marker") || states[0].Lifecycle.Phase != wantPhase || provider.deleteCount != 0 { + t.Fatalf("adoption altered or leaked retained debt: state=%s phase=%s revokes=%d err=%v", data, states[0].Lifecycle.Phase, provider.deleteCount, jsonErr) + } + }) + } +} + +func TestCleanupPending_AdoptionConfigCannotDuplicateSensitiveOutput(t *testing.T) { + for _, route := range []bool{false, true} { + t.Run(map[bool]string{false: "read", true: "route"}[route], func(t *testing.T) { + const marker = "known-adopt-config-credential" + store := cleanupStateStore(t) + provider := newEnvTestProvider() + out := interfaces.ResourceOutput{ProviderID: "existing-id", Outputs: map[string]any{"password": marker, "public": "consumer-payload"}, Sensitive: map[string]bool{"password": true}} + state, err := resourceStateFromLiveOutput(interfaces.ResourceSpec{Name: "adopted", Type: "infra.database"}, "test-cloud", &out) + if err != nil { + t.Fatal(err) + } + mode := persistModeRead + if route { + mode = persistModeAdoptRoute + } + if _, err := persistResourceWithSecretRouting(t.Context(), store, provider, nil, state, out, mode); err != nil { + t.Fatal(err) + } + states, err := store.ListResources(t.Context()) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(states) + if err != nil || strings.Contains(string(data), marker) || len(states) != 1 || states[0].AppliedConfig["public"] != "consumer-payload" || out.Outputs["password"] != marker { + t.Fatalf("adoption duplicated credential in config or altered payload: state=%s err=%v", data, err) + } + }) + } +} + +func TestCleanupPending_FailedIntentDoesNotDeleteByGuessedName(t *testing.T) { + store := &stubInfraStore{saveErr: context.Canceled} + provider := newEnvTestProvider() + driver := &stubSensitiveDriver{deleteErr: interfaces.ErrForbidden} + state := interfaces.ResourceState{Name: "database", Type: "infra.database", ProviderID: "exact-created-id"} + out := interfaces.ResourceOutput{Outputs: map[string]any{"password": "known-created-password"}, Sensitive: map[string]bool{"password": true}} + _, err := persistApplyMode(t.Context(), store, provider, driver, state, out, true) + if err == nil || len(driver.deleteCalls) != 1 || driver.deleteCalls[0].ProviderID != "exact-created-id" || len(provider.values) != 0 { + t.Fatalf("failed intent retried a guessed identity: err=%v deletes=%v secrets=%v", err, driver.deleteCalls, provider.values) + } +} + +func (p *cleanupBoundaryProvider) Delete(ctx context.Context, key string) error { + p.deleteCount++ + if !p.retain { + _ = p.envTestProvider.Delete(ctx, key) + } + if p.crashAt == p.deleteCount { + return context.Canceled + } + return nil +} + +type cleanupBoundaryStore struct { + infraStateStore + crashOnPhase bool + dropLifecycle bool + saves int +} + +func (s *cleanupBoundaryStore) SaveResource(ctx context.Context, state interfaces.ResourceState) error { + s.saves++ + if s.dropLifecycle { + state.Lifecycle = nil + } + if err := s.infraStateStore.SaveResource(ctx, state); err != nil { + return err + } + if s.crashOnPhase && state.Lifecycle != nil && state.Lifecycle.Phase == interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + return context.Canceled + } + return nil +} + +func cleanupResourceFixture(t *testing.T, store infraStateStore, provider secrets.Provider) interfaces.ResourceState { + t.Helper() + target := secrets.DescribeTarget(provider) + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "cloud-id", + Outputs: map[string]any{"password": sensitive.PlaceholderPrefix + "exact-derived-key"}, + Lifecycle: &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: interfaces.ResourcePhaseActive, + Secrets: []interfaces.RoutedSecretReference{ + {Key: "exact-derived-key", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}, + {Key: "NAMED_ALIAS", Store: "named", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject}, + }}} + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + return state +} + +func TestDeleteCrash_SecretCleanupResumesAtEveryBoundary(t *testing.T) { + for _, boundary := range []string{"cloud-delete", "tombstone-write", "first-revoke", "second-revoke"} { + t.Run(boundary, func(t *testing.T) { + store := cleanupStateStore(t) + provider := &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider()} + named := newEnvTestProvider() + provider.values["exact-derived-key"], provider.values["untouched"] = "known-cleanup-marker", "consumer-payload" + named.values["NAMED_ALIAS"] = "known-cleanup-marker" + state := cleanupResourceFixture(t, store, provider) + prepared, err := prepareResourceSecretDeletion(t.Context(), store, provider, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: &state}) + if err != nil { + t.Fatal(err) + } + resolver := func(ref interfaces.RoutedSecretReference) (secrets.Provider, error) { + if ref.Store == "named" { + return named, nil + } + return provider, nil + } + crashStore := &cleanupBoundaryStore{infraStateStore: store, crashOnPhase: boundary == "tombstone-write"} + if boundary == "first-revoke" { + provider.crashAt = 1 + } + if boundary == "second-revoke" { + // Both references deliberately share a backend here to interrupt + // after the second actual revoke rather than a synthetic timer. + provider.values["NAMED_ALIAS"] = named.values["NAMED_ALIAS"] + resolver = func(interfaces.RoutedSecretReference) (secrets.Provider, error) { return provider, nil } + provider.crashAt = 2 + } + if boundary != "cloud-delete" { + if err := reconcileResourceSecretDeletion(t.Context(), crashStore, resolver, *prepared); !errors.Is(err, context.Canceled) { + t.Fatalf("expected controller interruption: %v", err) + } + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].Lifecycle == nil { + t.Fatalf("cleanup forgot the resource: states=%v err=%v", states, err) + } + data, err := json.Marshal(states) + if err != nil || strings.Contains(string(data), "known-cleanup-marker") { + t.Fatalf("journal stored credential bytes: %s err=%v", data, err) + } + provider.crashAt = 0 + if err := reconcileResourceSecretDeletion(t.Context(), store, resolver, states[0]); err != nil { + t.Fatal(err) + } + states, err = store.ListResources(t.Context()) + if err != nil || len(states) != 0 { + t.Fatalf("restart did not finalize cleanup: states=%v err=%v", states, err) + } + if _, ok := provider.values["exact-derived-key"]; ok { + t.Fatal("derived key survived finalization") + } + if boundary == "second-revoke" { + if _, ok := provider.values["NAMED_ALIAS"]; ok { + t.Fatal("alias survived finalization") + } + } else if _, ok := named.values["NAMED_ALIAS"]; ok { + t.Fatal("alias survived finalization") + } + if provider.values["untouched"] != "consumer-payload" { + t.Fatal("cleanup modified unrelated payload") + } + }) + } +} + +func TestCleanupPending_FencesGenerationAndUnsupportedStores(t *testing.T) { + provider := &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider()} + store := cleanupStateStore(t) + state := cleanupResourceFixture(t, store, provider) + prepared, err := prepareResourceSecretDeletion(t.Context(), store, provider, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: &state}) + if err != nil { + t.Fatal(err) + } + replacement := state + replacement.Lifecycle = &interfaces.ResourceLifecycle{Generation: "generation-2", Phase: interfaces.ResourcePhaseActive} + if err := store.SaveResource(t.Context(), replacement); err != nil { + t.Fatal(err) + } + err = reconcileResourceSecretDeletion(t.Context(), store, func(interfaces.RoutedSecretReference) (secrets.Provider, error) { return provider, nil }, *prepared) + if err == nil || provider.deleteCount != 0 { + t.Fatalf("stale cleanup touched replacement: err=%v deletes=%d", err, provider.deleteCount) + } + state = cleanupResourceFixture(t, store, provider) + unsupported := &cleanupBoundaryStore{infraStateStore: store, dropLifecycle: true} + if _, err := prepareResourceSecretDeletion(t.Context(), unsupported, provider, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: &state}); err == nil { + t.Fatal("backend discarded tombstone but deletion was accepted") + } +} + +func TestCleanupPending_RetainedSecretPreventsStateRemoval(t *testing.T) { + provider := &cleanupBoundaryProvider{envTestProvider: newEnvTestProvider(), retain: true} + provider.values["exact-derived-key"] = "known-cleanup-marker" + store := cleanupStateStore(t) + state := cleanupResourceFixture(t, store, provider) + prepared, err := prepareResourceSecretDeletion(t.Context(), store, provider, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: state.Name, Type: state.Type}, Current: &state}) + if err != nil { + t.Fatal(err) + } + err = reconcileResourceSecretDeletion(t.Context(), store, func(interfaces.RoutedSecretReference) (secrets.Provider, error) { return provider, nil }, *prepared) + if err == nil || strings.Contains(err.Error(), "known-cleanup-marker") { + t.Fatalf("absence must fail closed with safe diagnostics: %v", err) + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].Lifecycle.Phase != interfaces.ResourcePhaseCloudDeletedSecretCleanupPending { + t.Fatalf("absence failure forgot cleanup debt: states=%v err=%v", states, err) + } +} diff --git a/cmd/wfctl/infra_state_lifecycle_test.go b/cmd/wfctl/infra_state_lifecycle_test.go new file mode 100644 index 00000000..09f382c6 --- /dev/null +++ b/cmd/wfctl/infra_state_lifecycle_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +func cleanupPendingState() interfaces.ResourceState { + return interfaces.ResourceState{ + ID: "database", Name: "database", Type: "infra.database", Provider: "test-cloud", ProviderID: "cloud-id", + AppliedConfig: map[string]any{"password": "${TOKEN}"}, Outputs: map[string]any{"password": "secret_ref://database_password"}, + Lifecycle: &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, + Secrets: []interfaces.RoutedSecretReference{{Key: "database_password", Store: "named-store", Provider: "file", Scope: "directory", Subject: "secret-dir"}, {Key: "alias", VerifiedAbsent: true}}}, + } +} + +func cleanupNativeSavers(dir string) map[string]func(context.Context, interfaces.ResourceState) error { + return map[string]func(context.Context, interfaces.ResourceState) error{ + "legacy CLI": (&fsWfctlStateStore{dir: dir}).SaveResource, + "shared helper": func(ctx context.Context, state interfaces.ResourceState) error { + store, err := wfctlhelpers.ResolveStateStore(filepath.Join(dir, "config.yaml"), "", "") + if err != nil { + return err + } + defer store.Close() + return store.SaveResource(ctx, state) + }, + "native module": func(ctx context.Context, state interfaces.ResourceState) error { + return module.NewFSIaCStateStore(dir).SaveState(ctx, resourceStateToIaCState(state)) + }, + } +} + +func writeCleanupStateConfig(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("modules:\n - name: state\n type: iac.state\n config: {backend: filesystem, directory: "+dir+"}\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestCleanupPending_LegacyAndNativeCompatibility(t *testing.T) { + for _, source := range []string{"legacy CLI", "shared helper", "native module"} { + t.Run(source, func(t *testing.T) { + dir := t.TempDir() + writeCleanupStateConfig(t, dir) + state := cleanupPendingState() + if err := cleanupNativeSavers(dir)[source](t.Context(), state); err != nil { + t.Fatal(err) + } + legacy, err := (&fsWfctlStateStore{dir: dir}).ListResources(t.Context()) + if err != nil || len(legacy) != 1 || !reflect.DeepEqual(legacy[0].Lifecycle, state.Lifecycle) { + t.Fatalf("legacy reader lost lifecycle: states=%v err=%v", legacy, err) + } + helper, err := wfctlhelpers.ResolveStateStore(filepath.Join(dir, "config.yaml"), "", "") + if err != nil { + t.Fatal(err) + } + defer helper.Close() + got, err := helper.GetResource(t.Context(), state.ID) + if err != nil || got == nil || !reflect.DeepEqual(got.Lifecycle, state.Lifecycle) { + t.Fatalf("shared reader lost lifecycle: state=%v err=%v", got, err) + } + converted := iacStateToResourceState(resourceStateToIaCState(state)) + if !reflect.DeepEqual(converted.Lifecycle, state.Lifecycle) { + t.Fatal("module converters lost cleanup identifiers") + } + data, err := os.ReadFile(filepath.Join(dir, "database.json")) + if err != nil || strings.Contains(string(data), "known-private-cleanup-value") { + t.Fatalf("state plaintext absence: err=%v", err) + } + var native module.IaCState + if err := json.Unmarshal(data, &native); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(iacStateToResourceState(&native).Lifecycle, state.Lifecycle) { + t.Fatal("native decoder lost cleanup journal") + } + }) + } +} + +func TestCleanupPending_StateFileAtomicReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix hard-link and mode invariant") + } + for _, source := range []string{"legacy CLI", "shared helper", "native module"} { + t.Run(source, func(t *testing.T) { + dir := t.TempDir() + writeCleanupStateConfig(t, dir) + path := filepath.Join(dir, "database.json") + old := []byte(`{"resource_id":"database","status":"active"}`) + if err := os.WriteFile(path, old, 0o644); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "previous-record") + if err := os.Link(path, alias); err != nil { + t.Fatal(err) + } + if err := cleanupNativeSavers(dir)[source](t.Context(), cleanupPendingState()); err != nil { + t.Fatal(err) + } + previous, err := os.ReadFile(alias) + if err != nil || string(previous) != string(old) { + t.Fatalf("save modified the old inode instead of atomically replacing it: err=%v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("replacement file must be 0600: info=%v err=%v", info, err) + } + if err := os.WriteFile(filepath.Join(dir, ".iac-state-crash"), []byte(`{"resource_id":"uncommitted-temp"}`), 0o600); err != nil { + t.Fatal(err) + } + states, err := (&fsWfctlStateStore{dir: dir}).ListResources(t.Context()) + if err != nil || len(states) != 1 || states[0].ID != "database" { + t.Fatalf("temporary state became a resource: states=%v err=%v", states, err) + } + native, err := module.NewFSIaCStateStore(dir).ListStates(t.Context(), nil) + if err != nil || len(native) != 1 || native[0].ResourceID != "database" { + t.Fatalf("native list exposed temporary state: states=%v err=%v", native, err) + } + }) + } +} diff --git a/cmd/wfctl/infra_state_store.go b/cmd/wfctl/infra_state_store.go index 661b5a9f..030588c2 100644 --- a/cmd/wfctl/infra_state_store.go +++ b/cmd/wfctl/infra_state_store.go @@ -209,18 +209,19 @@ type fsWfctlStateStore struct { // field names must stay stable to remain compatible with the existing // loadFSState reader and the importFromTFState / importFromPulumi writers. type iacStateRecord struct { - ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` - Provider string `json:"provider"` - ProviderRef string `json:"provider_ref,omitempty"` - ProviderID string `json:"provider_id,omitempty"` - ConfigHash string `json:"config_hash,omitempty"` - Status string `json:"status"` - Config map[string]any `json:"config"` - Outputs map[string]any `json:"outputs"` - Dependencies []string `json:"dependencies,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + Provider string `json:"provider"` + ProviderRef string `json:"provider_ref,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` + Status string `json:"status"` + Config map[string]any `json:"config"` + Outputs map[string]any `json:"outputs"` + Dependencies []string `json:"dependencies,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Lifecycle *interfaces.ResourceLifecycle `json:"lifecycle,omitempty"` } func (s *fsWfctlStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { @@ -265,6 +266,7 @@ func (s *fsWfctlStateStore) SaveResource(_ context.Context, state interfaces.Res Config: state.AppliedConfig, Outputs: state.Outputs, Dependencies: append([]string(nil), state.Dependencies...), + Lifecycle: cloneResourceLifecycle(state.Lifecycle), CreatedAt: now, UpdatedAt: now, } @@ -273,7 +275,7 @@ func (s *fsWfctlStateStore) SaveResource(_ context.Context, state interfaces.Res return fmt.Errorf("save state %q: marshal: %w", state.ID, err) } fname := filepath.Join(s.dir, sanitizeStateID(state.ID)+".json") - if err := os.WriteFile(fname, data, 0o600); err != nil { + if err := module.WriteIaCStateFile(fname, data); err != nil { return fmt.Errorf("save state %q: write: %w", state.ID, err) } return nil @@ -342,6 +344,7 @@ func iacRecordToResourceState(r iacStateRecord) interfaces.ResourceState { AppliedConfig: r.Config, Outputs: r.Outputs, Dependencies: append([]string(nil), r.Dependencies...), + Lifecycle: cloneResourceLifecycle(r.Lifecycle), } } @@ -365,6 +368,7 @@ func iacStateToResourceState(r *module.IaCState) interfaces.ResourceState { AppliedConfig: r.Config, Outputs: r.Outputs, Dependencies: append([]string(nil), r.Dependencies...), + Lifecycle: cloneResourceLifecycle(r.Lifecycle), } } @@ -381,6 +385,7 @@ func resourceStateToIaCState(state interfaces.ResourceState) *module.IaCState { Config: state.AppliedConfig, Outputs: state.Outputs, Dependencies: append([]string(nil), state.Dependencies...), + Lifecycle: cloneResourceLifecycle(state.Lifecycle), CreatedAt: now, UpdatedAt: now, } diff --git a/docs/WFCTL.md b/docs/WFCTL.md index d0ef8407..edb0b94a 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2132,6 +2132,21 @@ Known resolved sensitive values are masked in control-plane diagnostics, not in provider outputs or consumer artifacts. Regenerate older saved plans whose hashes were based on resolved values before applying them with this version. +Routed output credentials and written `infra_output` aliases have a durable +lifecycle journal containing exact keys, store namespaces, and a generation, +never credential values. Routing intent is saved and read back before writes. +Delete and replace retain state until every recorded key reads back absent +(write-only stores use list). Interrupted cleanup resumes on apply; changing a +store's namespace fails closed. Removing an alias generator does not remove its +cleanup record. Adoption never deletes the pre-existing cloud resource on a +routing failure. State backends that discard lifecycle metadata are rejected. +An interrupted owned creation is replaced only after its exact resource and +keys are cleaned up. Interrupted routing on a pre-existing resource blocks +convergence until its credentials are recovered; it never authorizes automatic +cloud deletion. Confirmed ghosts from `--refresh --auto-approve` retain their +cleanup journal and are recreated after cleanup. Legacy routed keys remain +tracked even if a later update omits those output fields. + ``` wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] [--refresh] [--allow-protected-prune] [--skip-refresh] @@ -2937,6 +2952,15 @@ For `provider-ephemeral`, `provider` is required and must name a registered in `env` are passed through for provider-side resolution; wfctl does not resolve them to plaintext before the provider job boundary. +The public `JobSpec` contract optionally targets an existing resource with its +exact name, type, and provider ID. Targeted jobs require `timeout_seconds` from +1 through 3600; negative or larger timeouts are rejected for every job. Untargeted +legacy jobs may omit the timeout. Providers can separately advertise +`IaCProviderJobCanceler`; it is not a required runner method. An absent or +UNIMPLEMENTED cancellation capability returns `ErrProviderMethodUnimplemented`, +while authorization, availability, and caller-context errors remain intact. +Cloud-specific execution and timeout enforcement belong to the provider plugin. + --- ### `ci init` diff --git a/iac/providerclient/adapter.go b/iac/providerclient/adapter.go index c288b385..b985e206 100644 --- a/iac/providerclient/adapter.go +++ b/iac/providerclient/adapter.go @@ -50,6 +50,8 @@ const ( // IaCServiceRunner is the gRPC service name for the optional // IaCProviderRunner service. IaCServiceRunner = "workflow.plugin.external.iac.IaCProviderRunner" + // IaCServiceJobCanceler is the independently advertised cancellation service. + IaCServiceJobCanceler = "workflow.plugin.external.iac.IaCProviderJobCanceler" // IaCServiceResourceDriver is the gRPC service name for the optional // ResourceDriver service. When advertised, Adapter.ResourceDriver(type) // returns a per-resource-type bridge that routes Create/Read/Update/Delete/ @@ -131,11 +133,32 @@ type RunnerProvider interface { Runner() interfaces.IaCProviderRunner } +// JobCancelerProvider discovers cancellation independently of Runner(). A nil +// result means the plugin did not advertise the optional cancellation service. +type JobCancelerProvider interface { + JobCanceler() interfaces.IaCProviderJobCanceler +} + +type jobCancelerAdapter struct { + client pb.IaCProviderJobCancelerClient +} + +func (c *jobCancelerAdapter) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + _, err := c.client.CancelJob(ctx, jobHandleToPB(handle)) + if status.Code(err) == codes.Unimplemented { + return fmt.Errorf("%w: %w", interfaces.ErrProviderMethodUnimplemented, err) + } + return err +} + type runnerAdapter struct { client pb.IaCProviderRunnerClient } func (r *runnerAdapter) RunJob(ctx context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + if err := spec.Validate(); err != nil { + return nil, err + } resp, err := r.client.RunJob(ctx, jobSpecToPB(spec)) if err != nil { if status.Code(err) == codes.Unimplemented { @@ -540,6 +563,7 @@ type Adapter struct { regionLister *regionListerImpl // nil when IaCServiceRegionLister not advertised drift *driftDetectorAdapter // nil when IaCServiceDriftDetector not advertised runner *runnerAdapter // nil when IaCServiceRunner not advertised + jobCanceler *jobCancelerAdapter // nil when IaCServiceJobCanceler not advertised resourceDriver pb.ResourceDriverClient // nil when IaCServiceResourceDriver not advertised sensitiveInputs pb.ResourceSensitiveInputDeclarerClient @@ -599,6 +623,9 @@ func New(conn grpc.ClientConnInterface, advertisedServices map[string]bool) *Ada if advertisedServices[IaCServiceRunner] { a.runner = &runnerAdapter{client: pb.NewIaCProviderRunnerClient(conn)} } + if advertisedServices[IaCServiceJobCanceler] { + a.jobCanceler = &jobCancelerAdapter{client: pb.NewIaCProviderJobCancelerClient(conn)} + } if advertisedServices[IaCServiceResourceDriver] { a.resourceDriver = pb.NewResourceDriverClient(conn) } @@ -638,6 +665,14 @@ func (a *Adapter) Runner() interfaces.IaCProviderRunner { return a.runner } +// JobCanceler returns the optional cancellation capability, or nil when absent. +func (a *Adapter) JobCanceler() interfaces.IaCProviderJobCanceler { + if a.jobCanceler == nil { + return nil + } + return a.jobCanceler +} + // ─── interfaces.IaCProvider ────────────────────────────────────────────────── // Name calls the IaCProviderRequired.Name RPC. Errors are logged and return "". @@ -1201,13 +1236,17 @@ func driftClassFromPB(c pb.DriftClass) interfaces.DriftClass { func jobSpecToPB(s interfaces.JobSpec) *pb.JobSpec { out := &pb.JobSpec{ - Name: s.Name, - Kind: s.Kind, - Image: s.Image, - RunCommand: s.RunCommand, - EnvVars: copyStringMap(s.EnvVars), - EnvVarsSecret: copyStringMap(s.EnvVarsSecret), - Cron: s.Cron, + Name: s.Name, + Kind: s.Kind, + Image: s.Image, + RunCommand: s.RunCommand, + EnvVars: copyStringMap(s.EnvVars), + EnvVarsSecret: copyStringMap(s.EnvVarsSecret), + Cron: s.Cron, + TimeoutSeconds: int32(s.TimeoutSeconds), //nolint:gosec // G115: validated to 0..3600 before conversion + } + if s.Target != nil { + out.Target = refToPB(*s.Target) } if s.Termination != nil { out.Termination = &pb.JobTerminationSpec{ diff --git a/iac/providerclient/job_contract_test.go b/iac/providerclient/job_contract_test.go new file mode 100644 index 00000000..be3eb42f --- /dev/null +++ b/iac/providerclient/job_contract_test.go @@ -0,0 +1,382 @@ +package providerclient_test + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/emptypb" +) + +const jobCancelerService = "workflow.plugin.external.iac.IaCProviderJobCanceler" + +// These fixtures implement native job contracts, not protobuf conversions. +type nativeJobProvider struct { + pb.UnimplementedIaCProviderRequiredServer + specs chan interfaces.JobSpec +} + +func (p *nativeJobProvider) RunJob(_ context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + p.specs <- spec + return &interfaces.JobHandle{ID: "job-id", Name: spec.Name, Provider: "native", Metadata: map[string]string{"parent_id": "app-id", "deployment_id": "deployment-id"}}, nil +} + +func (*nativeJobProvider) JobStatus(_ context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + return &interfaces.JobStatusReply{Handle: handle, State: interfaces.JobStateSucceeded, ExitCode: 0, Message: "done"}, nil +} + +func (*nativeJobProvider) JobLogs(_ context.Context, _ interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + if err := sink.WriteLogChunk(interfaces.LogChunk{Data: []byte("job output\n"), Source: "stdout"}); err != nil { + return err + } + return sink.WriteLogChunk(interfaces.LogChunk{EOF: true}) +} + +type nativeCancelableJobProvider struct { + *nativeJobProvider + handles chan interfaces.JobHandle + err error + canceled chan struct{} +} + +func (p *nativeCancelableJobProvider) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + p.handles <- handle + if p.canceled != nil { + <-ctx.Done() + close(p.canceled) + return ctx.Err() + } + return p.err +} + +type nativeJobCanceler interface { + CancelJob(context.Context, interfaces.JobHandle) error +} + +type nativeCancelOnlyProvider struct { + pb.UnimplementedIaCProviderRequiredServer + handles chan interfaces.JobHandle +} + +func (p *nativeCancelOnlyProvider) CancelJob(_ context.Context, handle interfaces.JobHandle) error { + p.handles <- handle + return nil +} + +func discoverJobCanceler(t *testing.T, adapter *providerclient.Adapter) nativeJobCanceler { + t.Helper() + accessor := reflect.ValueOf(adapter).MethodByName("JobCanceler") + if !accessor.IsValid() { + t.Fatal("providerclient adapter must expose advertisement-gated JobCanceler discovery") + } + value := accessor.Call(nil)[0] + if value.IsNil() { + return nil + } + canceler, ok := value.Interface().(nativeJobCanceler) + if !ok { + t.Fatalf("JobCanceler() = %T, want native cancellation contract", value.Interface()) + } + return canceler +} + +func nativeJobConnection(t *testing.T, provider any) (*grpc.ClientConn, map[string]bool) { + t.Helper() + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, provider); err != nil { + t.Fatal(err) + } + conn := startFakeServer(t, server) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + registry, err := pb.NewPluginServiceClient(conn).GetContractRegistry(ctx, &emptypb.Empty{}) + if err != nil { + t.Fatal(err) + } + advertised := make(map[string]bool) + for _, contract := range registry.GetContracts() { + if contract.GetKind() == pb.ContractKind_CONTRACT_KIND_SERVICE { + advertised[contract.GetServiceName()] = true + } + } + return conn, advertised +} + +func providerJobSpec(t *testing.T, target *interfaces.ResourceRef, timeout int) interfaces.JobSpec { + t.Helper() + spec := interfaces.JobSpec{ + Name: "migrate", Kind: interfaces.JobKindEphemeral, Image: "registry/app@sha256:fixture", + RunCommand: "app migrate", EnvVars: map[string]string{"MODE": "migration"}, + EnvVarsSecret: map[string]string{"DB_URI": "secret://migration-db"}, Cron: "", + Termination: &interfaces.TerminationSpec{DrainSeconds: 5, GracePeriodSeconds: 10}, + Alerts: []interfaces.AlertSpec{{Rule: "failed", Operator: ">", Value: 1, Window: "5m", Disabled: true}}, + LogDestinations: []interfaces.LogDestinationSpec{{Name: "logs", Endpoint: "https://logs.invalid", Headers: map[string]string{"source": "test"}, TLS: true}}, + } + value := reflect.ValueOf(&spec).Elem() + field := value.FieldByName("Target") + if !field.IsValid() || field.Type() != reflect.TypeFor[*interfaces.ResourceRef]() { + t.Fatal("JobSpec must carry a typed Target") + } + field.Set(reflect.ValueOf(target)) + field = value.FieldByName("TimeoutSeconds") + if !field.IsValid() || field.Kind() != reflect.Int { + t.Fatal("JobSpec must carry TimeoutSeconds") + } + field.SetInt(int64(timeout)) + return spec +} + +type jobContractLogSink struct{ chunks []interfaces.LogChunk } + +func (s *jobContractLogSink) WriteLogChunk(chunk interfaces.LogChunk) error { + s.chunks = append(s.chunks, chunk) + return nil +} + +func TestProviderJobNativeSDKRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + target *interfaces.ResourceRef + timeout int + }{ + {"targeted minimum", &interfaces.ResourceRef{Name: "app", Type: "infra.container_service", ProviderID: "app-id"}, 1}, + {"targeted maximum", &interfaces.ResourceRef{Name: "worker", Type: "other.parent", ProviderID: "parent-id"}, 3600}, + {"untargeted legacy zero", nil, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + provider := &nativeJobProvider{specs: make(chan interfaces.JobSpec, 1)} + conn, advertised := nativeJobConnection(t, provider) + adapter := providerclient.New(conn, advertised) + runner := adapter.Runner() + if runner == nil { + t.Fatal("SDK must register a native IaCProviderRunner implementation") + } + spec := providerJobSpec(t, tc.target, tc.timeout) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + handle, err := runner.RunJob(ctx, spec) + if err != nil { + t.Fatal(err) + } + got := <-provider.specs + if !reflect.DeepEqual(got, spec) { + t.Fatalf("native SDK round trip = %+v, want %+v", got, spec) + } + if handle == nil || handle.ID != "job-id" || handle.Metadata["deployment_id"] != "deployment-id" { + t.Fatalf("handle = %+v", handle) + } + if tc.target != nil { + gotTarget := reflect.ValueOf(got).FieldByName("Target").Interface().(*interfaces.ResourceRef) + gotTarget.ProviderID = "provider-change" + if tc.target.ProviderID == "provider-change" { + t.Fatal("target must not alias the caller across the SDK boundary") + } + } + got.EnvVarsSecret["DB_URI"] = "provider-change" + if spec.EnvVarsSecret["DB_URI"] != "secret://migration-db" { + t.Fatal("secret references must remain copied, unresolved references") + } + reply, err := runner.JobStatus(ctx, *handle) + if err != nil || reply == nil || !reflect.DeepEqual(reply.Handle, *handle) || reply.State != interfaces.JobStateSucceeded || reply.Message != "done" { + t.Fatalf("JobStatus = %+v, err = %v", reply, err) + } + sink := &jobContractLogSink{} + if err := runner.JobLogs(ctx, *handle, sink); err != nil { + t.Fatal(err) + } + if len(sink.chunks) != 2 || string(sink.chunks[0].Data) != "job output\n" || sink.chunks[0].Source != "stdout" || !sink.chunks[1].EOF { + t.Fatalf("native log stream = %+v", sink.chunks) + } + }) + } +} + +func TestProviderJobValidationBeforeClientAndNativeDispatch(t *testing.T) { + provider := &nativeJobProvider{specs: make(chan interfaces.JobSpec, 1)} + conn, advertised := nativeJobConnection(t, provider) + runner := providerclient.New(conn, advertised).Runner() + if runner == nil { + t.Fatal("SDK must register native runner") + } + target := &interfaces.ResourceRef{Name: "app", Type: "infra.container_service", ProviderID: "app-id"} + for _, timeout := range []int{-1, 0, 3601, int(^uint(0) >> 1)} { + spec := providerJobSpec(t, target, timeout) + handle, err := runner.RunJob(t.Context(), spec) + if handle != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("timeout %d: handle = %+v, err = %v; want client-side ErrValidation", timeout, handle, err) + } + } + for _, invalid := range []*interfaces.ResourceRef{ + {Type: target.Type, ProviderID: target.ProviderID}, + {Name: target.Name, ProviderID: target.ProviderID}, + {Name: target.Name, Type: target.Type}, + } { + handle, err := runner.RunJob(t.Context(), providerJobSpec(t, invalid, 600)) + if handle != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("incomplete target: handle = %+v, err = %v", handle, err) + } + } + // A direct generated client bypasses host validation; the real SDK must + // still reject invalid wire requests before the native provider runs. + for _, timeout := range []int64{-1, 0, 3601, 1<<31 - 1} { + request := &pb.JobSpec{Name: "migrate"} + message := request.ProtoReflect() + field := message.Descriptor().Fields().ByName("target") + if field == nil { + t.Fatal("protobuf target field missing") + } + message.Set(field, protoreflect.ValueOfMessage((&pb.ResourceRef{Name: target.Name, Type: target.Type, ProviderId: target.ProviderID}).ProtoReflect())) + field = message.Descriptor().Fields().ByName("timeout_seconds") + if field == nil { + t.Fatal("protobuf timeout field missing") + } + message.Set(field, protoreflect.ValueOfInt32(int32(timeout))) + _, err := pb.NewIaCProviderRunnerClient(conn).RunJob(t.Context(), request) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("wire timeout %d: %v, want InvalidArgument", timeout, err) + } + } + if len(provider.specs) != 0 { + t.Fatal("invalid request reached native provider") + } +} + +func TestProviderJobIndependentCancellationCapability(t *testing.T) { + provider := &nativeCancelOnlyProvider{handles: make(chan interfaces.JobHandle, 1)} + conn, advertised := nativeJobConnection(t, provider) + adapter := providerclient.New(conn, advertised) + if adapter.Runner() != nil || advertised[providerclient.IaCServiceRunner] { + t.Fatal("cancellation must not imply the runner capability") + } + canceler := discoverJobCanceler(t, adapter) + if canceler == nil { + t.Fatal("standalone native cancellation capability was not advertised") + } + handle := interfaces.JobHandle{ID: "job-id", Metadata: map[string]string{"component": "owned-job"}} + if err := canceler.CancelJob(t.Context(), handle); err != nil { + t.Fatal(err) + } + if got := <-provider.handles; !reflect.DeepEqual(got, handle) { + t.Fatalf("standalone cancellation handle = %+v, want %+v", got, handle) + } +} + +func TestProviderJobCancelerDiscoveryAndErrors(t *testing.T) { + for _, tc := range []struct { + name string + legacy bool + hidden bool + phantom bool + providerErr error + wantCode codes.Code + wantAbsent bool + wantUnimplemented bool + }{ + {name: "legacy absence", legacy: true, wantAbsent: true}, + {name: "registered but unadvertised", hidden: true, wantAbsent: true}, + {name: "success"}, + {name: "native unimplemented sentinel", providerErr: interfaces.ErrProviderMethodUnimplemented, wantCode: codes.Unimplemented, wantUnimplemented: true}, + {name: "native unimplemented status", providerErr: status.Error(codes.Unimplemented, "unsupported"), wantCode: codes.Unimplemented, wantUnimplemented: true}, + {name: "advertised missing service", legacy: true, phantom: true, wantCode: codes.Unimplemented, wantUnimplemented: true}, + {name: "denied", providerErr: status.Error(codes.PermissionDenied, "denied"), wantCode: codes.PermissionDenied}, + {name: "unavailable", providerErr: status.Error(codes.Unavailable, "unavailable"), wantCode: codes.Unavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + legacy := &nativeJobProvider{specs: make(chan interfaces.JobSpec, 1)} + provider := &nativeCancelableJobProvider{nativeJobProvider: legacy, handles: make(chan interfaces.JobHandle, 1), err: tc.providerErr} + var registered any = provider + if tc.legacy { + registered = legacy + } + conn, advertised := nativeJobConnection(t, registered) + if advertised[jobCancelerService] == tc.legacy { + t.Fatalf("SDK cancellation advertisement = %v, legacy = %v", advertised[jobCancelerService], tc.legacy) + } + if tc.hidden { + delete(advertised, jobCancelerService) + } + if tc.phantom { + advertised[jobCancelerService] = true + } + adapter := providerclient.New(conn, advertised) + if _, unconditional := any(adapter).(nativeJobCanceler); unconditional { + t.Fatal("adapter must not unconditionally implement cancellation") + } + canceler := discoverJobCanceler(t, adapter) + if (canceler == nil) != tc.wantAbsent { + t.Fatalf("canceler = %T, wantAbsent = %v", canceler, tc.wantAbsent) + } + if canceler == nil { + if len(provider.handles) != 0 { + t.Fatal("unadvertised canceler received a call") + } + return + } + handle := interfaces.JobHandle{ID: "job-id", Name: "migrate", Provider: "native", Metadata: map[string]string{"parent_id": "app-id", "component": "owned-job", "prior_spec_digest": "digest"}} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + err := canceler.CancelJob(ctx, handle) + if status.Code(err) != tc.wantCode || errors.Is(err, interfaces.ErrProviderMethodUnimplemented) != tc.wantUnimplemented { + t.Fatalf("CancelJob() = %v (%v), want code %v, unimplemented %v", err, status.Code(err), tc.wantCode, tc.wantUnimplemented) + } + if tc.providerErr != nil && !tc.wantUnimplemented && status.Convert(err).Message() != status.Convert(tc.providerErr).Message() { + t.Fatalf("provider error changed: %v", err) + } + if !tc.legacy { + got := <-provider.handles + if !reflect.DeepEqual(got, handle) { + t.Fatalf("native cancel handle = %+v, want %+v", got, handle) + } + got.Metadata["component"] = "provider-change" + if handle.Metadata["component"] != "owned-job" { + t.Fatal("cancellation metadata aliases caller") + } + } + }) + } +} + +func TestProviderJobCancelerCallerCancellation(t *testing.T) { + provider := &nativeCancelableJobProvider{ + nativeJobProvider: &nativeJobProvider{specs: make(chan interfaces.JobSpec, 1)}, + handles: make(chan interfaces.JobHandle, 1), canceled: make(chan struct{}), + } + conn, advertised := nativeJobConnection(t, provider) + canceler := discoverJobCanceler(t, providerclient.New(conn, advertised)) + if canceler == nil { + t.Fatal("SDK must register native cancellation") + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + results := make(chan error, 1) + go func() { results <- canceler.CancelJob(ctx, interfaces.JobHandle{ID: "job-id"}) }() + select { + case <-provider.handles: + case <-time.After(5 * time.Second): + t.Fatal("native cancellation call did not start") + } + cancel() + select { + case err := <-results: + if status.Code(err) != codes.Canceled || errors.Is(err, interfaces.ErrProviderMethodUnimplemented) { + t.Fatalf("caller cancellation = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("caller cancellation did not stop client RPC") + } + select { + case <-provider.canceled: + case <-time.After(5 * time.Second): + t.Fatal("caller cancellation did not reach native provider") + } +} diff --git a/iac/sensitive/route.go b/iac/sensitive/route.go index 307ba39a..95d734e2 100644 --- a/iac/sensitive/route.go +++ b/iac/sensitive/route.go @@ -156,7 +156,7 @@ func Route( } secretName := SecretKey(resourceName, k) if setErr := provider.Set(ctx, secretName, val); setErr != nil { - return nil, hydrated, fmt.Errorf("sensitive.Route: provider.Set(%q): %w", secretName, setErr) + return nil, hydrated, secretCleanupError{operation: "write", key: secretName, cause: setErr} } sanitized[k] = Placeholder(resourceName, k) hydrated[secretName] = val @@ -250,25 +250,87 @@ func Revoke( if resourceName == "" { return fmt.Errorf("sensitive.Revoke: resourceName is empty") } - // Sort for determinism (test stability + log readability). - sorted := append([]string(nil), mergedKeys...) - sort.Strings(sorted) + keys := make([]string, 0, len(mergedKeys)) + for _, key := range mergedKeys { + keys = append(keys, SecretKey(resourceName, key)) + } + return RevokeKeys(ctx, provider, keys) +} +// RevokeKeys deletes exact persisted identifiers and verifies absence. Unlike +// legacy Revoke, a missing provider is an error when cleanup debt exists. +func RevokeKeys(ctx context.Context, provider secrets.Provider, keys []string) error { + if len(keys) == 0 { + return nil + } + if provider == nil { + return fmt.Errorf("sensitive.RevokeKeys: secrets provider is unavailable") + } + unique := make(map[string]struct{}, len(keys)) + for _, key := range keys { + if key == "" { + return fmt.Errorf("sensitive.RevokeKeys: empty secret identifier") + } + unique[key] = struct{}{} + } + sorted := make([]string, 0, len(unique)) + for key := range unique { + sorted = append(sorted, key) + } + sort.Strings(sorted) var errs []error - for _, k := range sorted { - secretName := SecretKey(resourceName, k) - if delErr := provider.Delete(ctx, secretName); delErr != nil { - if errors.Is(delErr, secrets.ErrNotFound) { - continue + for _, key := range sorted { + if err := provider.Delete(ctx, key); err != nil && !errors.Is(err, secrets.ErrNotFound) { + errs = append(errs, secretCleanupError{operation: "delete", key: key, cause: err}) + continue + } + if err := VerifyAbsent(ctx, provider, key); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// VerifyAbsent checks readable stores directly and write-only stores through +// their key listing. Values and provider error payloads never enter diagnostics. +func VerifyAbsent(ctx context.Context, provider secrets.Provider, key string) error { + if provider == nil || key == "" { + return fmt.Errorf("sensitive.VerifyAbsent: provider and identifier are required") + } + _, err := provider.Get(ctx, key) + if errors.Is(err, secrets.ErrNotFound) { + return nil + } + if errors.Is(err, secrets.ErrUnsupported) { + keys, listErr := provider.List(ctx) + if listErr != nil { + return secretCleanupError{operation: "list", key: key, cause: listErr} + } + for _, present := range keys { + // Conservative for stores that normalize case: a case-equivalent + // key blocks absence acceptance rather than dropping cleanup debt. + if strings.EqualFold(present, key) { + return secretCleanupError{operation: "verify absence", key: key} } - errs = append(errs, fmt.Errorf("delete %q: %w", secretName, delErr)) } + return nil } - if len(errs) > 0 { - return errors.Join(errs...) + if err != nil { + return secretCleanupError{operation: "read", key: key, cause: err} } - return nil + return secretCleanupError{operation: "verify absence", key: key} +} + +type secretCleanupError struct { + operation string + key string + cause error +} + +func (e secretCleanupError) Error() string { + return fmt.Sprintf("secret cleanup %s failed for %q", e.operation, e.key) } +func (e secretCleanupError) Unwrap() error { return e.cause } // MaskSensitiveForDiff returns copies of desired and current with sensitive // keys elided from BOTH sides. A key is considered sensitive when: diff --git a/iac/sensitive/route_test.go b/iac/sensitive/route_test.go index 52ba2cda..4ac9b25f 100644 --- a/iac/sensitive/route_test.go +++ b/iac/sensitive/route_test.go @@ -3,6 +3,7 @@ package sensitive import ( "context" "errors" + "fmt" "regexp" "strings" "testing" @@ -56,6 +57,92 @@ func (p *fakeProvider) List(_ context.Context) ([]string, error) { return out, nil } +type cleanupProofProvider struct { + *fakeProvider + retainDeleted bool + getErr error + listErr error +} + +func (p *cleanupProofProvider) Delete(ctx context.Context, key string) error { + if p.retainDeleted { + p.delLog = append(p.delLog, key) + return nil + } + return p.fakeProvider.Delete(ctx, key) +} + +func (p *cleanupProofProvider) Get(ctx context.Context, key string) (string, error) { + if p.getErr != nil { + return "", p.getErr + } + return p.fakeProvider.Get(ctx, key) +} + +func (p *cleanupProofProvider) List(ctx context.Context) ([]string, error) { + if p.listErr != nil { + return nil, p.listErr + } + return p.fakeProvider.List(ctx) +} + +func TestRevokeKeys_ExactIdentifiersAndAbsenceProof(t *testing.T) { + const key = "prior-provider-exact-key" + for _, writeOnly := range []bool{false, true} { + t.Run(fmt.Sprint(writeOnly), func(t *testing.T) { + p := &cleanupProofProvider{fakeProvider: newFakeProvider()} + if writeOnly { + p.getErr = secrets.ErrUnsupported + } + p.values[key], p.values["other-key"] = "known-cleanup-secret", "unrelated-consumer-payload" + if err := RevokeKeys(t.Context(), p, []string{key, key}); err != nil { + t.Fatal(err) + } + if len(p.delLog) != 1 || p.delLog[0] != key || len(p.values) != 1 || p.values["other-key"] != "unrelated-consumer-payload" { + t.Fatalf("exact cleanup altered unrelated data: deletes=%v values=%v", p.delLog, p.values) + } + if err := RevokeKeys(t.Context(), p, []string{key}); err != nil { + t.Fatalf("idempotent retry: %v", err) + } + }) + } +} + +func TestRevokeKeys_FailsClosedAndKeepsSafeCause(t *testing.T) { + const marker = "known-cleanup-secret" + cause := errors.New("provider unavailable") + for _, mode := range []string{"nil", "empty-key", "retained", "delete-error", "read-error", "list-error"} { + t.Run(mode, func(t *testing.T) { + p := &cleanupProofProvider{fakeProvider: newFakeProvider()} + p.values["exact-key"] = marker + var provider secrets.Provider = p + keys := []string{"exact-key"} + switch mode { + case "nil": + provider = nil + case "empty-key": + keys = []string{""} + case "retained": + p.retainDeleted = true + case "delete-error": + p.delErr["exact-key"] = fmt.Errorf("rejected %s: %w", marker, cause) + case "read-error": + p.getErr = fmt.Errorf("rejected %s: %w", marker, cause) + case "list-error": + p.getErr = secrets.ErrUnsupported + p.listErr = fmt.Errorf("rejected %s: %w", marker, cause) + } + err := RevokeKeys(t.Context(), provider, keys) + if err == nil || strings.Contains(err.Error(), marker) { + t.Fatalf("cleanup must fail without exposing values: %v", err) + } + if strings.HasSuffix(mode, "error") && !errors.Is(err, cause) { + t.Fatalf("safe diagnostic lost cause: %v", err) + } + }) + } +} + func TestRoute_NoSensitive_PassesThrough(t *testing.T) { p := newFakeProvider() out := &interfaces.ResourceOutput{ @@ -77,6 +164,21 @@ func TestRoute_NoSensitive_PassesThrough(t *testing.T) { } } +func TestRoute_WriteFailureDoesNotExposeCredential(t *testing.T) { + const marker = "known-route-error-credential" + cause := fmt.Errorf("provider echoed %s: %w", marker, context.Canceled) + p := newFakeProvider() + p.setErr[SecretKey("database", "password")] = cause + out := &interfaces.ResourceOutput{Outputs: map[string]any{"password": marker, "public": "consumer-payload"}, Sensitive: map[string]bool{"password": true}} + _, _, err := Route(t.Context(), p, "database", out) + if err == nil || !errors.Is(err, context.Canceled) || strings.Contains(err.Error(), marker) { + t.Fatalf("routing diagnostic must keep cause without credential: %v", err) + } + if out.Outputs["password"] != marker || out.Outputs["public"] != "consumer-payload" { + t.Fatal("diagnostic protection altered consumer payload") + } +} + func TestRoute_SensitiveValuePresent_RoutesAndSanitizes(t *testing.T) { p := newFakeProvider() out := &interfaces.ResourceOutput{ @@ -297,9 +399,8 @@ func TestRevoke_AggregatesErrors(t *testing.T) { if err == nil { t.Fatal("expected aggregated error") } - msg := err.Error() - if !strings.Contains(msg, "boom1") || !strings.Contains(msg, "boom2") { - t.Errorf("aggregated error missing one or both: %q", msg) + if !errors.Is(err, p.delErr[SecretKey("r", "secret_key")]) || !errors.Is(err, p.delErr[SecretKey("r", "access_key")]) { + t.Errorf("aggregated error missing one or both causes: %v", err) } } diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index af1bba05..fb237cd8 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -96,6 +96,10 @@ type ApplyPlanHooks struct { CurrentState []interfaces.ResourceState // ResolveSecret retrieves a secret key only at the dispatch boundary. ResolveSecret func(context.Context, string) (string, error) + // ResourceDeletionComplete reads durable cloud-deletion evidence. A true + // result skips only Delete; secret cleanup hooks still run before Replace + // may create a new resource. Lookup errors prohibit cloud mutation. + ResourceDeletionComplete func(context.Context, interfaces.PlanAction) (bool, error) // OnBeforeAction fires PRE-DISPATCH for every PlanAction, after the // per-iteration ctx.Err() check but before JIT substitution / driver // resolution / cloud-side mutation. The intended use case is policy / @@ -246,7 +250,7 @@ func applyPlanWithEnvProviderAndHooks( } }() - deleteHookActive := hooks.OnResourceDeleted != nil + deleteHookActive := hooks.OnResourceDeleted != nil || hooks.ResourceDeletionComplete != nil inputNames := snapshotKeys(plan.InputSnapshot) result = &interfaces.ApplyResult{ PlanID: plan.ID, @@ -465,6 +469,11 @@ func applyPlanWithEnvProviderAndHooks( // doUpdate / doReplace each append on success; doDelete does not. preLen := len(result.Resources) actionHooks := hooks + if hooks.ResourceDeletionComplete != nil { + actionHooks.ResourceDeletionComplete = func(ctx context.Context, _ interfaces.PlanAction) (bool, error) { + return hooks.ResourceDeletionComplete(ctx, action) + } + } actionHooks.OnResourceDeleted = func(ctx context.Context, _ interfaces.PlanAction) error { if hooks.OnResourceDeleted != nil { if err := hooks.OnResourceDeleted(ctx, action); err != nil { @@ -474,7 +483,8 @@ func applyPlanWithEnvProviderAndHooks( delete(syncedOutputs, action.Resource.Name) return nil } - if err := dispatchAction(ctx, d, runtimeAction, result, actionHooks, deleteHookActive); err != nil { + creationOwned, dispatchErr := dispatchAction(ctx, d, runtimeAction, result, actionHooks, deleteHookActive) + if err := dispatchErr; err != nil { var hookErr hookDispatchError if errors.As(err, &hookErr) { // Phase 2.3 (#698): hookDispatchError wraps a hook @@ -517,7 +527,9 @@ func applyPlanWithEnvProviderAndHooks( // Phase 2.3 (#698): post-apply-hook ran AFTER cloud-side // create/update succeeded — cloud-side work IS done; // hook failure is post-hook semantically. - if err := hooks.OnResourceApplied(ctx, d, action, out); err != nil { + appliedAction := action + appliedAction.CreationOwned = &creationOwned + if err := hooks.OnResourceApplied(ctx, d, appliedAction, out); err != nil { fatalErr = fmt.Errorf("%s/%s: post-apply hook: %w", action.Resource.Type, action.Resource.Name, err) iterErr = err iterStatus = statusForPostHookFailure() @@ -704,18 +716,19 @@ func snapshotKeys(m map[string]string) []string { // An unknown action kind returns an error which ApplyPlan records on // result.Errors so an operator running a malformed plan sees a per-action // diagnostic rather than a silent skip. -func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult, hooks ApplyPlanHooks, deleteHookActive bool) error { +func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult, hooks ApplyPlanHooks, deleteHookActive bool) (bool, error) { switch action.Action { case "create": return doCreate(ctx, d, action, result) case "update": - return doUpdate(ctx, d, action, result) + return false, doUpdate(ctx, d, action, result) case "replace": - return doReplace(ctx, d, action, result, hooks, deleteHookActive) + err := doReplace(ctx, d, action, result, hooks, deleteHookActive) + return err == nil, err case "delete": - return doDelete(ctx, d, action) + return false, deleteResourceWithRecovery(ctx, d, action, hooks) default: - return fmt.Errorf("unknown action %q", action.Action) + return false, fmt.Errorf("unknown action %q", action.Action) } } @@ -753,20 +766,21 @@ func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action int // prefix instead. This boundary is deliberate: ActionError carries // the per-resource action context fields the wrap chain otherwise // duplicates. -func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { +func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) (bool, error) { out, err := d.Create(ctx, action.Resource) + created := err == nil if errors.Is(err, interfaces.ErrResourceAlreadyExists) { us, ok := d.(interfaces.UpsertSupporter) if !ok || !us.SupportsUpsert() { - return err // no recovery available; surface the conflict + return false, err // no recovery available; surface the conflict } ref := interfaces.ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} existing, readErr := d.Read(ctx, ref) if readErr != nil { - return fmt.Errorf("upsert: read after conflict: %w", errors.Join(err, readErr)) + return false, fmt.Errorf("upsert: read after conflict: %w", errors.Join(err, readErr)) } if existing == nil || existing.ProviderID == "" { - return fmt.Errorf("upsert: resource %q found by name but ProviderID is empty: %w", ref.Name, err) + return false, fmt.Errorf("upsert: resource %q found by name but ProviderID is empty: %w", ref.Name, err) } ref.ProviderID = existing.ProviderID out, err = d.Update(ctx, ref, action.Resource) @@ -774,7 +788,7 @@ func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interface if err == nil && out != nil { result.Resources = append(result.Resources, *out) } - return err + return created, err } // doUpdate invokes Update with a ResourceRef carrying action.Current's @@ -854,7 +868,7 @@ func DefaultReplace(ctx context.Context, d interfaces.ResourceDriver, action int } func defaultReplaceWithHooks(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult, hooks ApplyPlanHooks) error { - if err := d.Delete(ctx, refFromAction(action)); err != nil { + if err := deleteResourceWithRecovery(ctx, d, action, hooks); err != nil { return fmt.Errorf("replace: delete: %w", err) } if hooks.OnResourceDeleted != nil { @@ -1005,6 +1019,25 @@ func doDelete(ctx context.Context, d interfaces.ResourceDriver, action interface return d.Delete(ctx, refFromAction(action)) } +func deleteResourceWithRecovery(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, hooks ApplyPlanHooks) error { + if hooks.ResourceDeletionComplete != nil { + complete, err := hooks.ResourceDeletionComplete(ctx, action) + if err != nil { + return err + } + if complete { + return nil + } + } + err := doDelete(ctx, d, action) + // A crash may occur after Delete succeeds but before the durable + // completion marker lands. Only recovery-enabled callers accept absence. + if hooks.ResourceDeletionComplete != nil && interfaces.IsErrResourceNotFound(err) { + return nil + } + return err +} + // refFromAction builds a ResourceRef from the action's resource identity, // threading the ProviderID from action.Current when the plan was built // from existing state. For net-new actions (action.Current == nil) the diff --git a/iac/wfctlhelpers/apply_update_delete_test.go b/iac/wfctlhelpers/apply_update_delete_test.go index 4a379175..f2b5386c 100644 --- a/iac/wfctlhelpers/apply_update_delete_test.go +++ b/iac/wfctlhelpers/apply_update_delete_test.go @@ -8,6 +8,75 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) +func TestReplaceCleanup_CloudDeletionResumeUsesDurableHook(t *testing.T) { + for _, kind := range []string{"delete", "replace"} { + for _, completed := range []bool{false, true} { + t.Run(kind+"/"+map[bool]string{false: "pending", true: "complete"}[completed], func(t *testing.T) { + provider := newFakeProvider() + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: kind, Resource: interfaces.ResourceSpec{Name: "database", Type: "infra.database"}, Current: &interfaces.ResourceState{Name: "database", Type: "infra.database", ProviderID: "prior-id"}}}} + checks, cleanups := 0, 0 + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{ + ResourceDeletionComplete: func(context.Context, interfaces.PlanAction) (bool, error) { checks++; return completed, nil }, + OnResourceDeleted: func(context.Context, interfaces.PlanAction) error { cleanups++; return nil }, + }) + wantDelete := 1 + if completed { + wantDelete = 0 + } + wantCreate := 0 + if kind == "replace" { + wantCreate = 1 + } + if err != nil || len(result.Errors) != 0 || checks != 1 || cleanups != 1 || provider.driver.deleteCount != wantDelete || provider.driver.createCount != wantCreate { + t.Fatalf("resume boundary invalid: err=%v result=%+v checks=%d cleanup=%d driver=%+v", err, result, checks, cleanups, provider.driver) + } + }) + } + } +} + +func TestReplaceCleanup_DurableLookupFailureDoesNotMutateCloud(t *testing.T) { + for _, kind := range []string{"delete", "replace"} { + t.Run(kind, func(t *testing.T) { + provider := newFakeProvider() + cause := errors.New("durable state unavailable") + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: kind, Resource: interfaces.ResourceSpec{Name: "database", Type: "infra.database"}}}} + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, ApplyPlanHooks{ + ResourceDeletionComplete: func(context.Context, interfaces.PlanAction) (bool, error) { return false, cause }, + }) + if err != nil || len(result.Errors) != 1 || provider.driver.deleteCount != 0 || provider.driver.createCount != 0 { + t.Fatalf("failed durable lookup mutated cloud: result=%+v err=%v driver=%+v", result, err, provider.driver) + } + }) + } +} + +func TestDeleteCrash_NotFoundResumesOnlyWithDurableCleanup(t *testing.T) { + for _, journaled := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "durable"}[journaled], func(t *testing.T) { + provider := newCaptureFakeProvider() + provider.driver.deleteErr = interfaces.ErrResourceNotFound + current := &interfaces.ResourceState{Name: "database", Type: "infra.database", ProviderID: "deleted-id"} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "delete", Resource: spec(current.Name, current.Type), Current: current}}} + cleanups := 0 + hooks := ApplyPlanHooks{OnResourceDeleted: func(context.Context, interfaces.PlanAction) error { cleanups++; return nil }} + if journaled { + hooks.ResourceDeletionComplete = func(context.Context, interfaces.PlanAction) (bool, error) { return false, nil } + } + result, err := ApplyPlanWithHooks(t.Context(), provider, plan, hooks) + if err != nil || provider.driver.deleteRef.ProviderID != "deleted-id" || provider.driver.deleteCount != 1 { + t.Fatalf("did not retry exact deleted identity: result=%+v err=%v driver=%+v", result, err, provider.driver) + } + if journaled && (len(result.Errors) != 0 || cleanups != 1) { + t.Fatalf("crash-after-delete could not resume cleanup: errors=%v cleanup=%d", result.Errors, cleanups) + } + if !journaled && (len(result.Errors) != 1 || cleanups != 0) { + t.Fatal("legacy delete silently swallowed a provider error") + } + }) + } +} + // providerIDCapturingDriver records the ResourceRef passed to Update / // Delete so tests can assert ProviderID propagation from action.Current // to the driver call. diff --git a/iac/wfctlhelpers/state.go b/iac/wfctlhelpers/state.go index 1d15408b..944405f7 100644 --- a/iac/wfctlhelpers/state.go +++ b/iac/wfctlhelpers/state.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -152,18 +153,19 @@ func (n *NoopStateStore) Close() error { return nil } // See cmd/wfctl/state_compat_test.go for the on-disk-format compatibility // matrix. type StateRecord struct { - ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` - Provider string `json:"provider"` - ProviderRef string `json:"provider_ref,omitempty"` - ProviderID string `json:"provider_id,omitempty"` - ConfigHash string `json:"config_hash,omitempty"` - Status string `json:"status"` - Config map[string]any `json:"config"` - Outputs map[string]any `json:"outputs"` - Dependencies []string `json:"dependencies,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + Provider string `json:"provider"` + ProviderRef string `json:"provider_ref,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` + Status string `json:"status"` + Config map[string]any `json:"config"` + Outputs map[string]any `json:"outputs"` + Dependencies []string `json:"dependencies,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Lifecycle *interfaces.ResourceLifecycle `json:"lifecycle,omitempty"` } // FSStateStore persists ResourceState records as JSON files under a @@ -233,6 +235,7 @@ func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.Resource Config: state.AppliedConfig, Outputs: state.Outputs, Dependencies: append([]string(nil), state.Dependencies...), + Lifecycle: cloneStateLifecycle(state.Lifecycle), CreatedAt: now, UpdatedAt: now, } @@ -241,7 +244,7 @@ func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.Resource return fmt.Errorf("save state %q: marshal: %w", state.ID, err) } fname := filepath.Join(s.dir, SanitizeStateID(state.ID)+".json") - if err := os.WriteFile(fname, data, 0o600); err != nil { + if err := module.WriteIaCStateFile(fname, data); err != nil { return fmt.Errorf("save state %q: write: %w", state.ID, err) } return nil @@ -503,6 +506,7 @@ func stateRecordToResource(r StateRecord) interfaces.ResourceState { AppliedConfig: r.Config, Outputs: r.Outputs, Dependencies: append([]string(nil), r.Dependencies...), + Lifecycle: cloneStateLifecycle(r.Lifecycle), } } @@ -522,6 +526,7 @@ func iacStateToResourceState(r *module.IaCState) interfaces.ResourceState { AppliedConfig: r.Config, Outputs: r.Outputs, Dependencies: append([]string(nil), r.Dependencies...), + Lifecycle: cloneStateLifecycle(r.Lifecycle), } } @@ -538,7 +543,17 @@ func resourceStateToIaCState(state interfaces.ResourceState) *module.IaCState { Config: state.AppliedConfig, Outputs: state.Outputs, Dependencies: append([]string(nil), state.Dependencies...), + Lifecycle: cloneStateLifecycle(state.Lifecycle), CreatedAt: now, UpdatedAt: now, } } + +func cloneStateLifecycle(lifecycle *interfaces.ResourceLifecycle) *interfaces.ResourceLifecycle { + if lifecycle == nil { + return nil + } + copy := *lifecycle + copy.Secrets = slices.Clone(lifecycle.Secrets) + return © +} diff --git a/iac/wfctlhelpers/state_invariants_test.go b/iac/wfctlhelpers/state_invariants_test.go index fd4cbee6..8702007c 100644 --- a/iac/wfctlhelpers/state_invariants_test.go +++ b/iac/wfctlhelpers/state_invariants_test.go @@ -2,8 +2,10 @@ package wfctlhelpers_test import ( "context" + "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -12,6 +14,42 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) +func TestCleanupPending_StateRoundTrip(t *testing.T) { + lifecycle := &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, + Secrets: []interfaces.RoutedSecretReference{{Key: "exact-routed-key", Provider: "file", Scope: "directory", Subject: "secret-dir", Store: "named-store"}, {Key: "named-alias", VerifiedAbsent: true}}} + for _, backend := range []string{"filesystem", "memory"} { + t.Run(backend, func(t *testing.T) { + cfg := writeStateCfg(t, "modules:\n - name: state\n type: iac.state\n config:\n backend: "+backend+"\n directory: "+t.TempDir()+"\n") + store, err := wfctlhelpers.ResolveStateStore(cfg, "", "") + if err != nil { + t.Fatal(err) + } + state := interfaces.ResourceState{ID: "database", Name: "database", Type: "infra.database", ProviderID: "provider-id", Lifecycle: lifecycle} + if err := store.SaveResource(t.Context(), state); err != nil { + t.Fatal(err) + } + if backend == "filesystem" { + store, err = wfctlhelpers.ResolveStateStore(cfg, "", "") + if err != nil { + t.Fatal(err) + } + } + got, err := store.GetResource(t.Context(), state.Name) + if err != nil || got == nil || !reflect.DeepEqual(got.Lifecycle, lifecycle) { + t.Fatalf("cleanup journal was lost: got=%+v err=%v", got, err) + } + states, err := store.ListResources(t.Context()) + if err != nil || len(states) != 1 || !reflect.DeepEqual(states[0].Lifecycle, lifecycle) { + t.Fatalf("list lost cleanup journal: states=%+v err=%v", states, err) + } + data, err := json.Marshal(got) + if err != nil || !strings.Contains(string(data), "cloud_deleted_secret_cleanup_pending") { + t.Fatalf("phase not persisted: %s err=%v", data, err) + } + }) + } +} + // TestOutOfSubsetMethods_Panic guards design-doc cycle-5 row 4: the // handler library and host module use only the // {SaveResource, GetResource, ListResources, DeleteResource, Close} diff --git a/interfaces/iac_canonical_types.go b/interfaces/iac_canonical_types.go index 75bbd961..023c4514 100644 --- a/interfaces/iac_canonical_types.go +++ b/interfaces/iac_canonical_types.go @@ -1,5 +1,10 @@ package interfaces +import ( + "fmt" + "strings" +) + const ( JobKindPreDeploy = "PRE_DEPLOY" JobKindPostDeploy = "POST_DEPLOY" @@ -8,11 +13,21 @@ const ( JobKindEphemeral = "EPHEMERAL" ) +// MaxProviderJobTimeoutSeconds bounds one-off provider job execution to one hour. +const MaxProviderJobTimeoutSeconds = 3600 + // JobSpec describes a one-off or scheduled job workload. // Kind is one of: PRE_DEPLOY, POST_DEPLOY, FAILED_DEPLOY, SCHEDULED, EPHEMERAL. type JobSpec struct { - Name string `json:"name" yaml:"name"` - Kind string `json:"kind" yaml:"kind"` + Name string `json:"name" yaml:"name"` + Kind string `json:"kind" yaml:"kind"` + // Target identifies the exact existing parent. Providers validate ownership + // and supported resource types; core never guesses a provider-specific type. + Target *ResourceRef `json:"target,omitempty" yaml:"target,omitempty"` + // TimeoutSeconds is 1..MaxProviderJobTimeoutSeconds for targeted jobs. + // Zero is accepted only for untargeted legacy callers, including + // module/provider_ephemeral_runner.go, retaining provider-default behavior. + TimeoutSeconds int `json:"timeout_seconds,omitempty" yaml:"timeout_seconds,omitempty"` Image string `json:"image,omitempty" yaml:"image,omitempty"` RunCommand string `json:"run_command" yaml:"run_command"` EnvVars map[string]string `json:"env_vars,omitempty" yaml:"env_vars,omitempty"` @@ -23,6 +38,23 @@ type JobSpec struct { LogDestinations []LogDestinationSpec `json:"log_destinations,omitempty" yaml:"log_destinations,omitempty"` } +// Validate checks provider-neutral target identity and timeout before dispatch. +// Providers remain responsible for enforcing the execution timeout and cleanup. +func (s JobSpec) Validate() error { + if s.TimeoutSeconds < 0 || s.TimeoutSeconds > MaxProviderJobTimeoutSeconds { + return fmt.Errorf("%w: provider job timeout_seconds must be between 0 and %d", ErrValidation, MaxProviderJobTimeoutSeconds) + } + if s.Target != nil { + if s.TimeoutSeconds == 0 { + return fmt.Errorf("%w: targeted provider jobs require a positive timeout_seconds", ErrValidation) + } + if strings.TrimSpace(s.Target.Name) == "" || strings.TrimSpace(s.Target.Type) == "" || strings.TrimSpace(s.Target.ProviderID) == "" { + return fmt.Errorf("%w: provider job target requires name, type, and provider_id", ErrValidation) + } + } + return nil +} + // WorkerSpec describes a long-running background worker workload. type WorkerSpec struct { Name string `json:"name" yaml:"name"` diff --git a/interfaces/iac_provider.go b/interfaces/iac_provider.go index 4d8ebfb8..27aab23f 100644 --- a/interfaces/iac_provider.go +++ b/interfaces/iac_provider.go @@ -415,6 +415,15 @@ type IaCProviderRunner interface { JobLogs(ctx context.Context, handle JobHandle, sink LogCaptureSink) error } +// IaCProviderJobCanceler is a separate OPTIONAL capability, preserving existing +// IaCProviderRunner implementations. Cancellation applies only to the exact +// provider-owned job identified by the handle, including its ownership metadata. +// Providers own terminal cleanup and return ErrProviderMethodUnimplemented when +// cancellation is unsupported. Callers must discover this capability separately. +type IaCProviderJobCanceler interface { + CancelJob(ctx context.Context, handle JobHandle) error +} + // IaCProviderRegionLister is an OPTIONAL interface that an IaCProvider // implementation MAY also satisfy to expose provider-sourced region lists. // Callers MUST type-assert against this interface and treat the negative case diff --git a/interfaces/iac_provider_jobs_test.go b/interfaces/iac_provider_jobs_test.go new file mode 100644 index 00000000..9a7e180a --- /dev/null +++ b/interfaces/iac_provider_jobs_test.go @@ -0,0 +1,92 @@ +package interfaces_test + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +func targetedJobSpec(t *testing.T, target *interfaces.ResourceRef, timeout int) interfaces.JobSpec { + t.Helper() + spec := interfaces.JobSpec{Name: "migration", Kind: interfaces.JobKindEphemeral, RunCommand: "migrate"} + value := reflect.ValueOf(&spec).Elem() + field := value.FieldByName("Target") + if !field.IsValid() || field.Type() != reflect.TypeFor[*interfaces.ResourceRef]() { + t.Fatal("JobSpec.Target must be *ResourceRef") + } + field.Set(reflect.ValueOf(target)) + field = value.FieldByName("TimeoutSeconds") + if !field.IsValid() || field.Kind() != reflect.Int { + t.Fatal("JobSpec.TimeoutSeconds must be int") + } + field.SetInt(int64(timeout)) + return spec +} + +func TestProviderJobSpecTargetAndTimeoutValidation(t *testing.T) { + target := &interfaces.ResourceRef{Name: "app", Type: "infra.container_service", ProviderID: "app-id"} + for _, tc := range []struct { + name string + target *interfaces.ResourceRef + timeout int + valid bool + }{ + {"legacy untargeted zero", nil, 0, true}, + {"untargeted positive", nil, 600, true}, + {"targeted minimum", target, 1, true}, + {"targeted maximum", target, 3600, true}, + {"targeted zero", target, 0, false}, + {"targeted negative", target, -1, false}, + {"untargeted negative", nil, -1, false}, + {"targeted over maximum", target, 3601, false}, + {"untargeted over maximum", nil, 3601, false}, + {"integer overflow", target, int(^uint(0) >> 1), false}, + {"empty target", &interfaces.ResourceRef{}, 600, false}, + {"missing name", &interfaces.ResourceRef{Type: target.Type, ProviderID: target.ProviderID}, 600, false}, + {"missing type", &interfaces.ResourceRef{Name: target.Name, ProviderID: target.ProviderID}, 600, false}, + {"missing provider ID", &interfaces.ResourceRef{Name: target.Name, Type: target.Type}, 600, false}, + {"blank identity", &interfaces.ResourceRef{Name: " ", Type: "\t", ProviderID: "\n"}, 600, false}, + {"other provider resource type", &interfaces.ResourceRef{Name: "worker", Type: "other.parent", ProviderID: "resource-id"}, 600, true}, + } { + t.Run(tc.name, func(t *testing.T) { + spec := targetedJobSpec(t, tc.target, tc.timeout) + validator, ok := any(spec).(interface{ Validate() error }) + if !ok { + t.Fatal("JobSpec must expose shared validation") + } + err := validator.Validate() + if tc.valid && err != nil || !tc.valid && !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("Validate() = %v, valid = %v", err, tc.valid) + } + }) + } +} + +func TestProviderJobSpecTargetJSONAndRunnerCompatibility(t *testing.T) { + target := &interfaces.ResourceRef{Name: "app", Type: "infra.container_service", ProviderID: "app-id"} + spec := targetedJobSpec(t, target, 600) + data, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + var decoded interfaces.JobSpec + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(spec, decoded) { + t.Fatalf("JSON round trip = %+v, want %+v", decoded, spec) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if len(fields["target"]) == 0 || string(fields["timeout_seconds"]) != "600" { + t.Fatalf("target and timeout must use canonical JSON keys: %s", data) + } + if _, added := reflect.TypeFor[interfaces.IaCProviderRunner]().MethodByName("CancelJob"); added { + t.Fatal("cancellation must not change the existing IaCProviderRunner interface") + } +} diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index 7b19f0cc..ae738509 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -44,12 +44,45 @@ type ResourceState struct { // refuse to compute config-drift on adoption-shaped entries. // - "" (empty): legacy state written before this field existed. // Consumers MUST treat as "adoption" (conservative default). - AppliedConfigSource string `json:"applied_config_source,omitempty"` - Outputs map[string]any `json:"outputs"` - Dependencies []string `json:"dependencies"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LastDriftCheck time.Time `json:"last_drift_check,omitempty"` + AppliedConfigSource string `json:"applied_config_source,omitempty"` + Outputs map[string]any `json:"outputs"` + Dependencies []string `json:"dependencies"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastDriftCheck time.Time `json:"last_drift_check,omitempty"` + Lifecycle *ResourceLifecycle `json:"lifecycle,omitempty"` +} + +// ResourcePhase records recovery debt independently of provider outputs. +type ResourcePhase string + +const ( + ResourcePhaseActive ResourcePhase = "active" + ResourcePhaseSecretRoutingPending ResourcePhase = "secret_routing_pending" //nolint:gosec // Lifecycle label, not a credential. + ResourcePhaseCloudDeletePending ResourcePhase = "cloud_delete_pending" + ResourcePhaseCloudDeletedSecretCleanupPending ResourcePhase = "cloud_deleted_secret_cleanup_pending" //nolint:gosec // Lifecycle label, not a credential. +) + +// ResourceLifecycle contains only identifiers and completion evidence, never +// credential values. Generation fences retries against a different incarnation. +type ResourceLifecycle struct { + Generation string `json:"generation"` + Phase ResourcePhase `json:"phase"` + // RoutingCreated permits rollback only of an interrupted owned creation. + // Its absent/false legacy value never authorizes deleting adopted resources. + RoutingCreated bool `json:"routing_created,omitempty"` + Secrets []RoutedSecretReference `json:"secrets,omitempty"` +} + +// RoutedSecretReference identifies the concrete secret namespace used at write +// time. Store selects a configured named store; empty means the routing default. +type RoutedSecretReference struct { + Key string `json:"key"` + Store string `json:"store,omitempty"` + Provider string `json:"provider"` + Scope string `json:"scope"` + Subject string `json:"subject"` + VerifiedAbsent bool `json:"verified_absent,omitempty"` } // PluginVersionInfo captures the name and version of an installed IaC provider @@ -121,6 +154,10 @@ type PlanAction struct { Resource ResourceSpec `json:"resource"` Current *ResourceState `json:"current,omitempty"` Changes []FieldChange `json:"changes,omitempty"` + // CreationOwned is populated only for post-apply hooks by the engine's + // actual dispatch outcome. Upsert updates are false; saved plans cannot + // assert ownership. Nil preserves legacy direct hook callers. + CreationOwned *bool `json:"-"` // ResolvedConfigHash is the SHA-256 of the action's Resource.Config, // computed via platform.ConfigHash. wfctl preserves declarative references diff --git a/module/iac_state.go b/module/iac_state.go index dc4a84e0..c71ecb03 100644 --- a/module/iac_state.go +++ b/module/iac_state.go @@ -1,22 +1,27 @@ package module -import "context" +import ( + "context" + + "github.com/GoCodeAlone/workflow/interfaces" +) // IaCState tracks the state of an infrastructure resource. type IaCState struct { - ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` // e.g. "kubernetes", "ecs" - Provider string `json:"provider"` // e.g. "aws", "gcp", "local" - ProviderRef string `json:"provider_ref,omitempty"` - ProviderID string `json:"provider_id,omitempty"` - ConfigHash string `json:"config_hash,omitempty"` - Status string `json:"status"` // planned, provisioning, active, destroying, destroyed, error - Outputs map[string]any `json:"outputs"` // provider-specific outputs - Config map[string]any `json:"config"` // the config used to provision - Dependencies []string `json:"dependencies,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Error string `json:"error,omitempty"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` // e.g. "kubernetes", "ecs" + Provider string `json:"provider"` // e.g. "aws", "gcp", "local" + ProviderRef string `json:"provider_ref,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` + Status string `json:"status"` // planned, provisioning, active, destroying, destroyed, error + Outputs map[string]any `json:"outputs"` // provider-specific outputs + Config map[string]any `json:"config"` // the config used to provision + Dependencies []string `json:"dependencies,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Error string `json:"error,omitempty"` + Lifecycle *interfaces.ResourceLifecycle `json:"lifecycle,omitempty"` } // IaCStateStore is the interface for IaC state persistence backends. diff --git a/module/iac_state_fs.go b/module/iac_state_fs.go index a4038847..ea2e55fb 100644 --- a/module/iac_state_fs.go +++ b/module/iac_state_fs.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -80,12 +81,81 @@ func (s *FSIaCStateStore) SaveState(ctx context.Context, state *IaCState) error if err != nil { return fmt.Errorf("iac fs state: SaveState %q: marshal: %w", state.ResourceID, err) } - if err := os.WriteFile(s.statePath(state.ResourceID), data, 0o600); err != nil { + if err := WriteIaCStateFile(s.statePath(state.ResourceID), data); err != nil { return fmt.Errorf("iac fs state: SaveState %q: write: %w", state.ResourceID, err) } return nil } +type iaCStateTempFile interface { + io.Writer + Name() string + Sync() error + Close() error +} + +type iaCStateFileOps struct { + createTemp func(string, string) (iaCStateTempFile, error) + rename func(string, string) error + syncDir func(string) error +} + +func nativeIaCStateFileOps() iaCStateFileOps { + return iaCStateFileOps{ + createTemp: func(dir, pattern string) (iaCStateTempFile, error) { + return os.CreateTemp(dir, pattern) + }, + rename: replaceIaCStateFile, + syncDir: syncIaCStateDirectory, + } +} + +// WriteIaCStateFile replaces a state record using the native durability boundary +// (rename plus directory sync on Unix, write-through replacement on Windows). +// Callers create the directory first. Temporary files are private (0600) and +// never end in .json, so state listings cannot expose uncommitted records. +func WriteIaCStateFile(path string, data []byte) error { + return writeIaCStateFile(path, data, nativeIaCStateFileOps()) +} + +func writeIaCStateFile(path string, data []byte, ops iaCStateFileOps) error { + dir := filepath.Dir(path) + temp, err := ops.createTemp(dir, ".iac-state-*") + if err != nil { + return fmt.Errorf("create temporary state: %w", err) + } + name := temp.Name() + closed := false + defer func() { + if !closed { + _ = temp.Close() + } + _ = os.Remove(name) + }() + n, err := temp.Write(data) + if err != nil { + return fmt.Errorf("write temporary state: %w", err) + } + if n != len(data) { + return fmt.Errorf("write temporary state: %w", io.ErrShortWrite) + } + if err := temp.Sync(); err != nil { + return fmt.Errorf("sync temporary state: %w", err) + } + err = temp.Close() + closed = true + if err != nil { + return fmt.Errorf("close temporary state: %w", err) + } + if err := ops.rename(name, path); err != nil { + return fmt.Errorf("rename state: %w", err) + } + if err := ops.syncDir(dir); err != nil { + return fmt.Errorf("sync state directory: %w", err) + } + return nil +} + // ListStates reads all JSON files from the directory and returns those matching filter. // Supported filter keys: "resource_type", "provider", "status". func (s *FSIaCStateStore) ListStates(ctx context.Context, filter map[string]string) ([]*IaCState, error) { diff --git a/module/iac_state_fs_durability_test.go b/module/iac_state_fs_durability_test.go new file mode 100644 index 00000000..1cca51e0 --- /dev/null +++ b/module/iac_state_fs_durability_test.go @@ -0,0 +1,141 @@ +package module + +import ( + "errors" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +type interruptedStateFile struct { + *os.File + phase string + cause error + events *[]string +} + +func (f *interruptedStateFile) Write(data []byte) (int, error) { + *f.events = append(*f.events, "write") + if f.phase == "write" || f.phase == "short write" { + n, err := f.File.Write(data[:len(data)/2]) + if err != nil { + return n, err + } + if f.phase == "write" { + return n, f.cause + } + return n, nil + } + return f.File.Write(data) +} + +func (f *interruptedStateFile) Sync() error { + *f.events = append(*f.events, "file sync") + if f.phase == "file sync" { + return f.cause + } + return f.File.Sync() +} + +func (f *interruptedStateFile) Close() error { + *f.events = append(*f.events, "close") + err := f.File.Close() + if f.phase == "close" { + return f.cause + } + return err +} + +func TestCleanupPending_AtomicWriteDurability(t *testing.T) { + for _, phase := range []string{"success", "create", "write", "short write", "file sync", "close", "rename", "directory sync"} { + t.Run(phase, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "database.json") + old := []byte(`{"resource_id":"database","status":"active"}`) + next := []byte(`{"resource_id":"database","lifecycle":{"generation":"g1","phase":"cloud_deleted_secret_cleanup_pending"}}`) + if err := os.WriteFile(path, old, 0o600); err != nil { + t.Fatal(err) + } + cause := errors.New("injected storage failure") + var events []string + ops := nativeIaCStateFileOps() + native := ops + ops.createTemp = func(parent, pattern string) (iaCStateTempFile, error) { + events = append(events, "create") + if parent != dir || strings.HasSuffix(pattern, ".json") { + t.Fatalf("temporary state must share the directory and not be JSON: %q %q", parent, pattern) + } + if phase == "create" { + return nil, cause + } + file, err := os.CreateTemp(parent, pattern) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { + t.Fatalf("temporary record is not private: info=%v err=%v", info, err) + } + listed, err := NewFSIaCStateStore(dir).ListStates(t.Context(), nil) + if err != nil || len(listed) != 1 || listed[0].ResourceID != "database" { + t.Fatalf("in-flight temporary file exposed by ListStates: states=%v err=%v", listed, err) + } + return &interruptedStateFile{File: file, phase: phase, cause: cause, events: &events}, nil + } + ops.rename = func(from, to string) error { + events = append(events, "rename") + data, err := os.ReadFile(from) + if err != nil || string(data) != string(next) { + t.Fatalf("rename attempted before complete write: err=%v", err) + } + if phase == "rename" { + return cause + } + return native.rename(from, to) + } + ops.syncDir = func(parent string) error { + events = append(events, "directory sync") + if parent != dir { + t.Fatal("wrong parent directory synced") + } + if phase == "directory sync" { + return cause + } + return native.syncDir(parent) + } + err := writeIaCStateFile(path, next, ops) + switch phase { + case "success": + if err != nil || !reflect.DeepEqual(events, []string{"create", "write", "file sync", "close", "rename", "directory sync"}) { + t.Fatalf("durability order: events=%v err=%v", events, err) + } + case "short write": + if !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("short write accepted: %v", err) + } + default: + if !errors.Is(err, cause) { + t.Fatalf("storage failure swallowed: phase=%s err=%v", phase, err) + } + } + want := old + // After rename a directory-sync failure is uncertain durability, not + // permission to roll back or claim the old record is still current. + if phase == "success" || phase == "directory sync" { + want = next + } + data, readErr := os.ReadFile(path) + if readErr != nil || string(data) != string(want) { + t.Fatalf("pre-rename failure damaged old record: phase=%s err=%v", phase, readErr) + } + temps, globErr := filepath.Glob(filepath.Join(dir, ".iac-state-*")) + if globErr != nil || len(temps) != 0 { + t.Fatalf("temporary files left after return: files=%v err=%v", temps, globErr) + } + }) + } +} diff --git a/module/iac_state_fs_nonwindows.go b/module/iac_state_fs_nonwindows.go new file mode 100644 index 00000000..2e6890e1 --- /dev/null +++ b/module/iac_state_fs_nonwindows.go @@ -0,0 +1,18 @@ +//go:build !windows + +package module + +import "os" + +func replaceIaCStateFile(from, to string) error { + return os.Rename(from, to) +} + +func syncIaCStateDirectory(dir string) error { + parent, err := os.Open(dir) + if err != nil { + return err + } + defer parent.Close() + return parent.Sync() +} diff --git a/module/iac_state_fs_windows.go b/module/iac_state_fs_windows.go new file mode 100644 index 00000000..2c6e354c --- /dev/null +++ b/module/iac_state_fs_windows.go @@ -0,0 +1,34 @@ +package module + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func replaceIaCStateFile(from, to string) error { + return replaceIaCStateFileWith(from, to, windows.MoveFileEx) +} + +func replaceIaCStateFileWith(from, to string, move func(*uint16, *uint16, uint32) error) error { + source, err := windows.UTF16PtrFromString(from) + if err != nil { + return &os.LinkError{Op: "replace", Old: from, New: to, Err: err} + } + target, err := windows.UTF16PtrFromString(to) + if err != nil { + return &os.LinkError{Op: "replace", Old: from, New: to, Err: err} + } + // The temp file shares the target directory: no copy/delete fallback or + // delayed operation is allowed. WRITE_THROUGH is the durability boundary. + if err := move(source, target, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil { + return &os.LinkError{Op: "replace", Old: from, New: to, Err: err} + } + return nil +} + +func syncIaCStateDirectory(string) error { + // Replacement already used WRITE_THROUGH. FlushFileBuffers on the + // read-only directory handle used by os.Open is not a Windows fsync. + return nil +} diff --git a/module/iac_state_fs_windows_test.go b/module/iac_state_fs_windows_test.go new file mode 100644 index 00000000..faf958bf --- /dev/null +++ b/module/iac_state_fs_windows_test.go @@ -0,0 +1,66 @@ +package module + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestCleanupPending_WindowsReplacementFlagsAndErrors(t *testing.T) { + for _, cause := range []error{nil, windows.ERROR_ACCESS_DENIED, windows.ERROR_NOT_SUPPORTED} { + called := false + err := replaceIaCStateFileWith("old.tmp", "state.json", func(from, to *uint16, flags uint32) error { + called = true + if windows.UTF16PtrToString(from) != "old.tmp" || windows.UTF16PtrToString(to) != "state.json" { + t.Fatal("replacement lost exact paths") + } + if flags != windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH { + t.Fatalf("durable same-volume replacement flags missing or unsafe: %#x", flags) + } + return cause + }) + if !called || !errors.Is(err, cause) { + t.Fatalf("native replacement error lost: called=%t err=%v cause=%v", called, err, cause) + } + } + called := false + err := replaceIaCStateFileWith("bad\x00path", "state.json", func(*uint16, *uint16, uint32) error { + called = true + return nil + }) + if err == nil || called { + t.Fatal("invalid path reached native replacement") + } +} + +func TestCleanupPending_WindowsNativeDurableReplacement(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + for _, value := range []string{"old-state", "new-state"} { + if err := WriteIaCStateFile(path, []byte(value)); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != value { + t.Fatalf("native replacement failed: err=%v", err) + } + } + blocked := filepath.Join(dir, "blocked.json") + if err := os.Mkdir(blocked, 0o700); err != nil { + t.Fatal(err) + } + if err := WriteIaCStateFile(blocked, []byte("must-not-replace-directory")); err == nil { + t.Fatal("native replacement failure was ignored") + } + info, err := os.Stat(blocked) + if err != nil || !info.IsDir() { + t.Fatal("failed replacement damaged target") + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 2 { + t.Fatalf("temporary records remain after failed replacement: err=%v", err) + } +} diff --git a/module/iac_state_grpc_client.go b/module/iac_state_grpc_client.go index 6c0019e7..da98170f 100644 --- a/module/iac_state_grpc_client.go +++ b/module/iac_state_grpc_client.go @@ -1,9 +1,16 @@ package module import ( + "bytes" "context" "encoding/json" + "fmt" + "io" + "strings" + "unicode" + "unicode/utf8" + "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) @@ -20,6 +27,17 @@ func iacStateToProto(s *IaCState) (*pb.IaCState, error) { if s == nil { return nil, nil } + if err := validateIaCStateLifecycle(s.Lifecycle); err != nil { + return nil, err + } + var lifecycleJSON []byte + if s.Lifecycle != nil { + var err error + lifecycleJSON, err = json.Marshal(s.Lifecycle) + if err != nil { + return nil, invalidIaCStateLifecycle() + } + } outputsJSON, err := json.Marshal(s.Outputs) if err != nil { return nil, err @@ -29,19 +47,20 @@ func iacStateToProto(s *IaCState) (*pb.IaCState, error) { return nil, err } return &pb.IaCState{ - ResourceId: s.ResourceID, - ResourceType: s.ResourceType, - Provider: s.Provider, - ProviderRef: s.ProviderRef, - ProviderId: s.ProviderID, - ConfigHash: s.ConfigHash, - Status: s.Status, - OutputsJson: outputsJSON, - ConfigJson: configJSON, - Dependencies: s.Dependencies, - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - Error: s.Error, + ResourceId: s.ResourceID, + ResourceType: s.ResourceType, + Provider: s.Provider, + ProviderRef: s.ProviderRef, + ProviderId: s.ProviderID, + ConfigHash: s.ConfigHash, + Status: s.Status, + OutputsJson: outputsJSON, + ConfigJson: configJSON, + Dependencies: s.Dependencies, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + Error: s.Error, + LifecycleJson: lifecycleJSON, }, nil } @@ -53,6 +72,10 @@ func iacStateFromProto(p *pb.IaCState) (*IaCState, error) { if p == nil { return nil, nil } + lifecycle, err := iacStateLifecycleFromJSON(p.LifecycleJson) + if err != nil { + return nil, err + } outputs, err := jsonBytesToMap(p.OutputsJson) if err != nil { return nil, err @@ -75,9 +98,85 @@ func iacStateFromProto(p *pb.IaCState) (*IaCState, error) { CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, Error: p.Error, + Lifecycle: lifecycle, }, nil } +func invalidIaCStateLifecycle() error { + // Decoder errors can contain payload field names; never propagate them. + return fmt.Errorf("%w: invalid IaC state lifecycle metadata", interfaces.ErrValidation) +} + +func validIaCLifecycleIdentifier(value string) bool { + return utf8.ValidString(value) && strings.TrimSpace(value) != "" && !strings.ContainsFunc(value, unicode.IsControl) +} + +func validateIaCStateLifecycle(lifecycle *interfaces.ResourceLifecycle) error { + if lifecycle == nil { + return nil + } + if !validIaCLifecycleIdentifier(lifecycle.Generation) { + return invalidIaCStateLifecycle() + } + switch lifecycle.Phase { + case interfaces.ResourcePhaseActive, interfaces.ResourcePhaseSecretRoutingPending, + interfaces.ResourcePhaseCloudDeletePending, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending: + default: + return invalidIaCStateLifecycle() + } + for _, secret := range lifecycle.Secrets { + if !validIaCLifecycleIdentifier(secret.Key) || !validIaCLifecycleIdentifier(secret.Provider) || + !validIaCLifecycleIdentifier(secret.Scope) || (secret.Store != "" && !validIaCLifecycleIdentifier(secret.Store)) { + return invalidIaCStateLifecycle() + } + // Unprefixed environment providers and the default provider namespace + // legitimately have no subject. Scoped stores require their identity. + if secret.Subject == "" { + if secret.Scope != "process" && secret.Scope != "default" { + return invalidIaCStateLifecycle() + } + } else if !validIaCLifecycleIdentifier(secret.Subject) { + return invalidIaCStateLifecycle() + } + } + return nil +} + +func iacStateLifecycleFromJSON(payload []byte) (*interfaces.ResourceLifecycle, error) { + if len(payload) == 0 { + return nil, nil + } + if !utf8.Valid(payload) { + return nil, invalidIaCStateLifecycle() + } + var lifecycle interfaces.ResourceLifecycle + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&lifecycle); err != nil { + return nil, invalidIaCStateLifecycle() + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, invalidIaCStateLifecycle() + } + // encoding/json accepts null into scalar fields as their zero value. + // Lifecycle metadata requires concrete typed values, including evidence. + decoder = json.NewDecoder(bytes.NewReader(payload)) + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil || token == nil { + return nil, invalidIaCStateLifecycle() + } + } + if err := validateIaCStateLifecycle(&lifecycle); err != nil { + return nil, err + } + return &lifecycle, nil +} + // jsonBytesToMap decodes JSON bytes into a map[string]any. Empty, "null" and // "{}" inputs yield a nil map. func jsonBytesToMap(b []byte) (map[string]any, error) { diff --git a/module/iac_state_lifecycle_grpc_test.go b/module/iac_state_lifecycle_grpc_test.go new file mode 100644 index 00000000..b2e5094e --- /dev/null +++ b/module/iac_state_lifecycle_grpc_test.go @@ -0,0 +1,349 @@ +package module + +import ( + "context" + "encoding/json" + "errors" + "net" + "reflect" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/secrets" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const lifecycleGRPCPrivateMarker = "known-private-cleanup-value" + +func lifecycleGRPCFixture(phase interfaces.ResourcePhase) *IaCState { + return &IaCState{ + ResourceID: "database", ResourceType: "infra.database", Provider: "test-cloud", ProviderRef: "provider-config", ProviderID: "database-id", + ConfigHash: "config-hash", Status: "destroyed", Outputs: map[string]any{"password": "secret_ref://database_password"}, + Config: map[string]any{"password": "${DATABASE_PASSWORD}"}, Dependencies: []string{"network"}, + CreatedAt: "2026-10-02T00:00:00Z", UpdatedAt: "2026-10-03T00:00:00Z", Error: "cleanup pending", + Lifecycle: &interfaces.ResourceLifecycle{ + Generation: "generation-1", Phase: phase, RoutingCreated: phase == interfaces.ResourcePhaseSecretRoutingPending, + Secrets: []interfaces.RoutedSecretReference{ + {Key: "database_password", Store: "named-store", Provider: "file", Scope: "directory", Subject: "/tmp/workflow-secrets"}, + {Key: "named-alias", Provider: "env", Scope: "process", Subject: "WORKFLOW_", VerifiedAbsent: true}, + {Key: "unprefixed-env", Provider: "env", Scope: "process"}, + {Key: "custom-default", Provider: "custom", Scope: "default"}, + }, + }, + } +} + +func lifecycleGRPCConnection(t *testing.T, backend IaCStateStore, options ...grpc.ServerOption) func() *grpc.ClientConn { + t.Helper() + server := grpc.NewServer(options...) + t.Cleanup(server.Stop) + pb.RegisterIaCStateBackendServer(server, &iacStateBackendServer{store: backend}) + listener := bufconn.Listen(4 << 20) + t.Cleanup(func() { _ = listener.Close() }) + go func() { _ = server.Serve(listener) }() + return func() *grpc.ClientConn { + t.Helper() + conn, err := grpc.NewClient("passthrough:///lifecycle-state-test", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn + } +} + +func lifecycleGRPCWireField(t *testing.T, state *pb.IaCState) protoreflect.FieldDescriptor { + t.Helper() + field := state.ProtoReflect().Descriptor().Fields().ByName("lifecycle_json") + if field == nil || field.Number() != 14 || field.Kind() != protoreflect.BytesKind { + t.Fatal("IaCState lifecycle_json must be bytes at the next free field 14") + } + return field +} + +func setLifecycleGRPCPayload(t *testing.T, state *pb.IaCState, payload []byte) { + t.Helper() + state.ProtoReflect().Set(lifecycleGRPCWireField(t, state), protoreflect.ValueOfBytes(payload)) +} + +func assertLifecycleGRPCSafeError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("invalid lifecycle metadata was accepted") + } + if strings.Contains(err.Error(), lifecycleGRPCPrivateMarker) { + t.Fatalf("lifecycle validation error echoed private payload: %v", err) + } +} + +func TestGRPCIaCStateLifecycleRoundTripAndReload(t *testing.T) { + for _, phase := range []interfaces.ResourcePhase{ + interfaces.ResourcePhaseActive, interfaces.ResourcePhaseSecretRoutingPending, + interfaces.ResourcePhaseCloudDeletePending, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, + } { + t.Run(string(phase), func(t *testing.T) { + backend := NewMemoryIaCStateStore() + connect := lifecycleGRPCConnection(t, backend) + conn := connect() + store := NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(conn)) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + want := lifecycleGRPCFixture(phase) + if err := store.SaveState(ctx, want); err != nil { + t.Fatal(err) + } + stored, err := backend.GetState(ctx, want.ResourceID) + if err != nil || !reflect.DeepEqual(stored, want) { + t.Fatalf("native backend lost wire metadata: state = %+v, err = %v", stored, err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + store = NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(connect())) + got, err := store.GetState(ctx, want.ResourceID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("reloaded client read = %+v, err = %v; want %+v", got, err, want) + } + got.Lifecycle.Generation = "caller-change" + got.Lifecycle.Secrets[1].VerifiedAbsent = false + listed, err := store.ListStates(ctx, map[string]string{"provider": want.Provider}) + if err != nil || len(listed) != 1 || !reflect.DeepEqual(listed[0], want) { + t.Fatalf("list round trip = %+v, err = %v", listed, err) + } + listed[0].Lifecycle.Secrets[0].Subject = "list-caller-change" + got, err = store.GetState(ctx, want.ResourceID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("caller mutation changed stored evidence: %+v, %v", got, err) + } + wire, err := iacStateToProto(want) + if err != nil { + t.Fatal(err) + } + payload := wire.ProtoReflect().Get(lifecycleGRPCWireField(t, wire)).Bytes() + var lifecycle interfaces.ResourceLifecycle + if err := json.Unmarshal(payload, &lifecycle); err != nil || !reflect.DeepEqual(&lifecycle, want.Lifecycle) { + t.Fatalf("wire lifecycle differs: lifecycle = %+v, err = %v", lifecycle, err) + } + if strings.Contains(string(payload), lifecycleGRPCPrivateMarker) { + t.Fatal("wire lifecycle contains private material") + } + }) + } +} + +func TestGRPCIaCStateLifecycleDescribeTargetCompatibility(t *testing.T) { + // Exposing only Provider hides the optional TargetDescriber method. + withoutDescriber := struct{ secrets.Provider }{secrets.NewFileProvider(t.TempDir())} + if _, ok := any(withoutDescriber).(secrets.TargetDescriber); ok { + t.Fatal("fallback provider unexpectedly implements TargetDescriber") + } + for _, tc := range []struct { + name string + provider secrets.Provider + scope string + }{ + {"provider without TargetDescriber", withoutDescriber, "default"}, + {"empty environment prefix", secrets.NewEnvProvider(""), "process"}, + } { + t.Run(tc.name, func(t *testing.T) { + target := secrets.DescribeTarget(tc.provider) + if target.Provider != tc.provider.Name() || target.Scope != tc.scope || target.Subject != "" { + t.Fatalf("unexpected producer namespace: %+v", target) + } + backend := NewMemoryIaCStateStore() + connect := lifecycleGRPCConnection(t, backend) + conn := connect() + store := NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(conn)) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + want := lifecycleGRPCFixture(interfaces.ResourcePhaseSecretRoutingPending) + want.Lifecycle.Secrets = []interfaces.RoutedSecretReference{{ + Key: "routed-key", Provider: target.Provider, Scope: target.Scope, Subject: target.Subject, + }} + if err := store.SaveState(ctx, want); err != nil { + t.Fatalf("actual producer namespace rejected: %v", err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + store = NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(connect())) + got, err := store.GetState(ctx, want.ResourceID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("producer namespace reload = %+v, err = %v", got, err) + } + listed, err := store.ListStates(ctx, nil) + if err != nil || len(listed) != 1 || !reflect.DeepEqual(listed[0], want) { + t.Fatalf("producer namespace listing = %+v, err = %v", listed, err) + } + }) + } +} + +func TestGRPCIaCStateLifecycleLegacyAbsence(t *testing.T) { + backend := NewMemoryIaCStateStore() + conn := lifecycleGRPCConnection(t, backend)() + store := NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(conn)) + want := lifecycleGRPCFixture(interfaces.ResourcePhaseActive) + want.Lifecycle = nil + if err := store.SaveState(t.Context(), want); err != nil { + t.Fatal(err) + } + got, err := store.GetState(t.Context(), want.ResourceID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("legacy read = %+v, err = %v", got, err) + } + listed, err := store.ListStates(t.Context(), nil) + if err != nil || len(listed) != 1 || !reflect.DeepEqual(listed[0], want) { + t.Fatalf("legacy listing = %+v, err = %v", listed, err) + } + wire, err := iacStateToProto(want) + if err != nil || len(wire.ProtoReflect().Get(lifecycleGRPCWireField(t, wire)).Bytes()) != 0 { + t.Fatalf("legacy state should omit lifecycle_json: %v", err) + } +} + +func TestGRPCIaCStateLifecycleRejectsInvalidNativeMetadata(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*interfaces.ResourceLifecycle) + }{ + {"empty generation", func(l *interfaces.ResourceLifecycle) { l.Generation = "" }}, + {"blank generation", func(l *interfaces.ResourceLifecycle) { l.Generation = " \t" }}, + {"invalid UTF-8 generation", func(l *interfaces.ResourceLifecycle) { l.Generation = "generation-\xff" }}, + {"unknown phase", func(l *interfaces.ResourceLifecycle) { l.Phase = interfaces.ResourcePhase(lifecycleGRPCPrivateMarker) }}, + {"empty phase", func(l *interfaces.ResourceLifecycle) { l.Phase = "" }}, + {"empty key", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Key = "" }}, + {"empty provider", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Provider = "" }}, + {"empty scope", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Scope = "" }}, + {"missing scoped subject", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Subject = "" }}, + {"blank named store", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Store = " " }}, + {"invalid UTF-8 namespace", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Subject = "directory-\xff" }}, + {"control in key", func(l *interfaces.ResourceLifecycle) { l.Secrets[0].Key = "key\n" + lifecycleGRPCPrivateMarker }}, + {"control in namespace", func(l *interfaces.ResourceLifecycle) { + l.Secrets[0].Subject = "directory\x00" + lifecycleGRPCPrivateMarker + }}, + } { + t.Run(tc.name, func(t *testing.T) { + backend := NewMemoryIaCStateStore() + conn := lifecycleGRPCConnection(t, backend)() + store := NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(conn)) + state := lifecycleGRPCFixture(interfaces.ResourcePhaseActive) + tc.mutate(state.Lifecycle) + wire, err := iacStateToProto(state) + assertLifecycleGRPCSafeError(t, err) + if wire != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("invalid native lifecycle conversion = %+v, %v", wire, err) + } + err = store.SaveState(t.Context(), state) + assertLifecycleGRPCSafeError(t, err) + if !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("SaveState error must preserve ErrValidation: %v", err) + } + got, err := backend.GetState(t.Context(), state.ResourceID) + if err != nil || got != nil { + t.Fatalf("invalid native lifecycle reached backend: %+v, %v", got, err) + } + }) + } +} + +func TestGRPCIaCStateLifecycleRejectsInvalidWireMetadata(t *testing.T) { + valid := `{"generation":"generation-1","phase":"active"}` + for _, tc := range []struct { + name string + payload string + }{ + {"malformed", `{"generation":"` + lifecycleGRPCPrivateMarker}, + {"invalid UTF-8", "{\"generation\":\"generation-\xff\",\"phase\":\"active\"}"}, + {"root string", `"` + lifecycleGRPCPrivateMarker + `"`}, + {"root array", `[]`}, + {"root null", `null`}, + {"blank payload", ` `}, + {"empty object", `{}`}, + {"unknown field", `{"generation":"generation-1","phase":"active","credential_value":"` + lifecycleGRPCPrivateMarker + `"}`}, + {"unknown private field name", `{"generation":"generation-1","phase":"active","` + lifecycleGRPCPrivateMarker + `":true}`}, + {"nested value field", `{"generation":"generation-1","phase":"active","secrets":[{"key":"key","provider":"env","scope":"process","value":"` + lifecycleGRPCPrivateMarker + `"}]}`}, + {"wrong generation type", `{"generation":123,"phase":"active"}`}, + {"wrong phase type", `{"generation":"generation-1","phase":[]}`}, + {"unknown phase", `{"generation":"generation-1","phase":"` + lifecycleGRPCPrivateMarker + `"}`}, + {"missing generation", `{"phase":"active"}`}, + {"missing namespace", `{"generation":"generation-1","phase":"active","secrets":[{"key":"key"}]}`}, + {"wrong absent type", `{"generation":"generation-1","phase":"active","secrets":[{"key":"key","provider":"env","scope":"process","verified_absent":"` + lifecycleGRPCPrivateMarker + `"}]}`}, + {"null absent type", `{"generation":"generation-1","phase":"active","secrets":[{"key":"key","provider":"env","scope":"process","verified_absent":null}]}`}, + {"null store type", `{"generation":"generation-1","phase":"active","secrets":[{"key":"key","provider":"env","scope":"process","store":null}]}`}, + {"null secrets type", `{"generation":"generation-1","phase":"active","secrets":null}`}, + {"trailing object", valid + ` {"credential_value":"` + lifecycleGRPCPrivateMarker + `"}`}, + {"trailing garbage", valid + lifecycleGRPCPrivateMarker}, + } { + t.Run(tc.name, func(t *testing.T) { + backend := NewMemoryIaCStateStore() + want := lifecycleGRPCFixture(interfaces.ResourcePhaseActive) + if err := backend.SaveState(t.Context(), want); err != nil { + t.Fatal(err) + } + conn := lifecycleGRPCConnection(t, backend)() + wire, err := iacStateToProto(want) + if err != nil { + t.Fatal(err) + } + setLifecycleGRPCPayload(t, wire, []byte(tc.payload)) + state, err := iacStateFromProto(wire) + assertLifecycleGRPCSafeError(t, err) + if state != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("invalid wire conversion = %+v, %v", state, err) + } + _, err = pb.NewIaCStateBackendClient(conn).SaveState(t.Context(), &pb.SaveStateRequest{State: wire}) + assertLifecycleGRPCSafeError(t, err) + got, err := backend.GetState(t.Context(), want.ResourceID) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("invalid wire payload replaced backend state: %+v, %v", got, err) + } + }) + } +} + +func TestGRPCIaCStateLifecycleConsumerRejectsCorruptReadAndList(t *testing.T) { + lifecycleGRPCWireField(t, &pb.IaCState{}) + backend := NewMemoryIaCStateStore() + want := lifecycleGRPCFixture(interfaces.ResourcePhaseCloudDeletedSecretCleanupPending) + if err := backend.SaveState(t.Context(), want); err != nil { + t.Fatal(err) + } + interceptor := func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + reply, err := handler(ctx, req) + if err == nil { + payload := []byte(`{"generation":"generation-1","phase":"active","credential_value":"` + lifecycleGRPCPrivateMarker + `"}`) + switch r := reply.(type) { + case *pb.GetStateResponse: + setLifecycleGRPCPayload(t, r.State, payload) + case *pb.ListStatesResponse: + for _, state := range r.States { + setLifecycleGRPCPayload(t, state, payload) + } + } + } + return reply, err + } + conn := lifecycleGRPCConnection(t, backend, grpc.UnaryInterceptor(interceptor))() + store := NewGRPCIaCStateStore(pb.NewIaCStateBackendClient(conn)) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + state, err := store.GetState(ctx, want.ResourceID) + assertLifecycleGRPCSafeError(t, err) + if state != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("consumer returned corrupt state: %+v, %v", state, err) + } + states, err := store.ListStates(ctx, nil) + assertLifecycleGRPCSafeError(t, err) + if states != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("consumer returned corrupt listing: %+v, %v", states, err) + } +} diff --git a/module/iac_state_lifecycle_postgres_integration_test.go b/module/iac_state_lifecycle_postgres_integration_test.go new file mode 100644 index 00000000..edf8b9c6 --- /dev/null +++ b/module/iac_state_lifecycle_postgres_integration_test.go @@ -0,0 +1,103 @@ +package module + +import ( + "context" + "encoding/json" + "net/url" + "os" + "reflect" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestCleanupPending_PostgresLifecycleLiveRoundTrip(t *testing.T) { + dsn := os.Getenv("POSTGRES_TEST_URL") + if dsn == "" { + t.Skip("POSTGRES_TEST_URL not set; needs a disposable PostgreSQL database") + } + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + admin, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal("cannot connect to disposable PostgreSQL database") + } + defer admin.Close(context.Background()) //nolint:errcheck + schema := "iac_cleanup_" + uuid.NewString() + quoted := pgx.Identifier{schema}.Sanitize() + if _, err := admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cleanupCancel() + if _, err := admin.Exec(cleanupCtx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil { + t.Error("failed to clean up test schema") + } + }() + parsed, err := url.Parse(dsn) + if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") { + t.Fatal("POSTGRES_TEST_URL must be a PostgreSQL URL") + } + query := parsed.Query() + query.Set("search_path", quoted) + parsed.RawQuery = query.Encode() + // Exercise additive migration from a pre-lifecycle native state table. + legacy := `CREATE TABLE ` + quoted + `.iac_resources ( +name TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', +applied_config JSONB NOT NULL DEFAULT '{}', outputs JSONB NOT NULL DEFAULT '{}', +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` + if _, err := admin.Exec(ctx, legacy); err != nil { + t.Fatal(err) + } + if _, err := admin.Exec(ctx, "INSERT INTO "+quoted+".iac_resources (name, type) VALUES ('legacy', 'infra.database')"); err != nil { + t.Fatal(err) + } + open := func() *PostgresIaCStateStore { + t.Helper() + store, err := NewPostgresIaCStateStore(ctx, parsed.String()) + if err != nil { + t.Fatal("native state backend failed to migrate/open disposable schema") + } + return store + } + store := open() + defer func() { store.conn.Close() }() + old, err := store.GetState(ctx, "legacy") + if err != nil || old == nil || old.Lifecycle != nil { + t.Fatalf("legacy row compatibility failed: state=%+v err=%v", old, err) + } + for _, phase := range []interfaces.ResourcePhase{interfaces.ResourcePhaseActive, interfaces.ResourcePhaseSecretRoutingPending, interfaces.ResourcePhaseCloudDeletePending, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} { + want := &IaCState{ResourceID: "database", ResourceType: "infra.database", ProviderID: "exact-id", Status: "active", Config: map[string]any{"password": "${PRIVATE_TOKEN}"}, Outputs: map[string]any{"public": "consumer-payload"}, Lifecycle: &interfaces.ResourceLifecycle{Generation: "generation-1", Phase: phase, Secrets: []interfaces.RoutedSecretReference{{Key: "exact-key", Store: "named", Provider: "file", Scope: "directory", Subject: "test-directory", VerifiedAbsent: phase == interfaces.ResourcePhaseCloudDeletedSecretCleanupPending}}}} + if err := store.SaveState(ctx, want); err != nil { + t.Fatal(err) + } + store.conn.Close() + store = open() + got, err := store.GetState(ctx, want.ResourceID) + if err != nil || got == nil || !reflect.DeepEqual(got.Lifecycle, want.Lifecycle) || got.Config["password"] != "${PRIVATE_TOKEN}" || got.Outputs["public"] != "consumer-payload" { + t.Fatalf("native SQL lifecycle lost across reconnect: state=%+v err=%v", got, err) + } + listed, err := store.ListStates(ctx, map[string]string{"resource_type": "infra.database"}) + if err != nil || len(listed) != 2 { + t.Fatalf("native SQL list failed: count=%d err=%v", len(listed), err) + } + var data []byte + if err := admin.QueryRow(ctx, "SELECT lifecycle FROM "+quoted+".iac_resources WHERE name=$1", want.ResourceID).Scan(&data); err != nil { + t.Fatal(err) + } + var wire interfaces.ResourceLifecycle + if err := json.Unmarshal(data, &wire); err != nil || !reflect.DeepEqual(&wire, want.Lifecycle) { + t.Fatalf("actual JSONB does not match lifecycle: lifecycle=%+v err=%v", wire, err) + } + } + if err := store.DeleteState(ctx, "database"); err != nil { + t.Fatal(err) + } + if got, err := store.GetState(ctx, "database"); err != nil || got != nil { + t.Fatalf("native SQL delete failed: state=%+v err=%v", got, err) + } +} diff --git a/module/iac_state_lifecycle_test.go b/module/iac_state_lifecycle_test.go new file mode 100644 index 00000000..75f99afc --- /dev/null +++ b/module/iac_state_lifecycle_test.go @@ -0,0 +1,249 @@ +package module_test + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +func cleanupLifecycle() *interfaces.ResourceLifecycle { + return &interfaces.ResourceLifecycle{ + Generation: "generation-1", Phase: interfaces.ResourcePhaseCloudDeletedSecretCleanupPending, + Secrets: []interfaces.RoutedSecretReference{ + {Key: "database_password", Store: "named-store", Provider: "file", Scope: "directory", Subject: "secret-dir"}, + {Key: "named-alias", Provider: "env", Scope: "process", Subject: "workflow", VerifiedAbsent: true}, + }, + } +} + +func stateWithCleanupLifecycle(t *testing.T, lifecycle *interfaces.ResourceLifecycle) *module.IaCState { + t.Helper() + data, err := json.Marshal(struct { + *module.IaCState + Lifecycle *interfaces.ResourceLifecycle `json:"lifecycle,omitempty"` + }{makeState("database", "infra.database", "test-cloud", "active"), lifecycle}) + if err != nil { + t.Fatal(err) + } + var state module.IaCState + if err := json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + return &state +} + +func assertCleanupLifecycle(t *testing.T, state *module.IaCState, want *interfaces.ResourceLifecycle) { + t.Helper() + if state == nil { + t.Fatal("state was lost") + } + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + var record struct { + Lifecycle *interfaces.ResourceLifecycle `json:"lifecycle"` + } + if err := json.Unmarshal(data, &record); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(record.Lifecycle, want) { + t.Fatalf("cleanup lifecycle changed: got=%+v want=%+v", record.Lifecycle, want) + } + if strings.Contains(string(data), "known-private-cleanup-value") { + t.Fatal("cleanup metadata stored credential plaintext") + } +} + +func TestCleanupPending_NativeStateRoundTrip(t *testing.T) { + for _, backend := range []string{"filesystem", "memory"} { + t.Run(backend, func(t *testing.T) { + dir := t.TempDir() + var store module.IaCStateStore = module.NewMemoryIaCStateStore() + if backend == "filesystem" { + store = module.NewFSIaCStateStore(dir) + } + for _, phase := range []interfaces.ResourcePhase{interfaces.ResourcePhaseActive, interfaces.ResourcePhaseSecretRoutingPending, interfaces.ResourcePhaseCloudDeletePending, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} { + lifecycle := cleanupLifecycle() + lifecycle.Phase = phase + lifecycle.RoutingCreated = phase == interfaces.ResourcePhaseSecretRoutingPending + if err := store.SaveState(t.Context(), stateWithCleanupLifecycle(t, lifecycle)); err != nil { + t.Fatal(err) + } + if backend == "filesystem" { + store = module.NewFSIaCStateStore(dir) + } + got, err := store.GetState(t.Context(), "database") + if err != nil { + t.Fatal(err) + } + assertCleanupLifecycle(t, got, lifecycle) + listed, err := store.ListStates(t.Context(), nil) + if err != nil || len(listed) != 1 { + t.Fatalf("list state: len=%d err=%v", len(listed), err) + } + assertCleanupLifecycle(t, listed[0], lifecycle) + } + if err := store.SaveState(t.Context(), stateWithCleanupLifecycle(t, nil)); err != nil { + t.Fatal(err) + } + legacy, err := store.GetState(t.Context(), "database") + if err != nil { + t.Fatal(err) + } + assertCleanupLifecycle(t, legacy, nil) + }) + } +} + +func TestCleanupPending_MemoryLifecycleMutationIsolation(t *testing.T) { + store := module.NewMemoryIaCStateStore() + state := stateWithCleanupLifecycle(t, cleanupLifecycle()) + if err := store.SaveState(t.Context(), state); err != nil { + t.Fatal(err) + } + state.Lifecycle.Generation = "caller-change" + state.Lifecycle.Secrets[0].Key = "caller-change" + state.Lifecycle.Secrets[1].VerifiedAbsent = false + got, err := store.GetState(t.Context(), "database") + if err != nil { + t.Fatal(err) + } + assertCleanupLifecycle(t, got, cleanupLifecycle()) + got.Lifecycle.Phase = interfaces.ResourcePhaseActive + got.Lifecycle.Secrets[0].VerifiedAbsent = true + listed, err := store.ListStates(t.Context(), nil) + if err != nil || len(listed) != 1 { + t.Fatalf("list: len=%d err=%v", len(listed), err) + } + assertCleanupLifecycle(t, listed[0], cleanupLifecycle()) + listed[0].Lifecycle.Secrets[0].Subject = "list-caller-change" + got, err = store.GetState(t.Context(), "database") + if err != nil { + t.Fatal(err) + } + assertCleanupLifecycle(t, got, cleanupLifecycle()) + if got.Config["version"] != "1.29" || got.Status != "active" { + t.Fatal("lifecycle copy changed existing state semantics") + } +} + +type cleanupRows struct { + visited bool + payload string +} + +func (r *cleanupRows) Next() bool { + if r.visited { + return false + } + r.visited = true + return true +} + +func (r *cleanupRows) Scan(dest ...any) error { + if len(dest) != 11 { + return fmt.Errorf("lifecycle column missing from actual row scan: got %d columns, want 11", len(dest)) + } + values := []string{"database", "infra.database", "test-cloud", "cloud-provider", "cloud-id", "declarative-hash", "active", `{"password":"${TOKEN}"}`, `{"password":"secret_ref://database_password"}`} + for i, value := range values { + *(dest[i].(*string)) = value + } + *(dest[9].(*[]string)) = []string{"network"} + *(dest[10].(*string)) = r.payload + return nil +} + +func (*cleanupRows) Err() error { return nil } + +func TestCleanupPending_PostgresRowDecode(t *testing.T) { + payload, err := json.Marshal(cleanupLifecycle()) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + payload string + want *interfaces.ResourceLifecycle + bad bool + }{ + {"pending", string(payload), cleanupLifecycle(), false}, + {"legacy null", "null", nil, false}, + {"malformed", `{"generation":`, nil, true}, + {"wrong shape", `"known-private-cleanup-value"`, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + states, err := module.ScanIaCStateRowsForTest(&cleanupRows{payload: tc.payload}) + if tc.bad { + if err == nil || len(states) != 0 || !strings.Contains(err.Error(), "decode iac_resources") || !strings.Contains(err.Error(), "lifecycle") || strings.Contains(err.Error(), "known-private-cleanup-value") { + t.Fatalf("invalid lifecycle did not fail closed with safe diagnostics: states=%v err=%v", states, err) + } + return + } + if err != nil || len(states) != 1 { + t.Fatalf("decode lifecycle: states=%v err=%v", states, err) + } + assertCleanupLifecycle(t, states[0], tc.want) + if states[0].ProviderRef != "cloud-provider" || states[0].Outputs["password"] != "secret_ref://database_password" { + t.Fatal("lifecycle decode changed sibling state fields") + } + }) + } +} + +func TestCleanupPending_LifecycleHasIdentifiersNotValues(t *testing.T) { + state := stateWithCleanupLifecycle(t, cleanupLifecycle()) + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + var record map[string]json.RawMessage + if err := json.Unmarshal(data, &record); err != nil { + t.Fatal(err) + } + var lifecycle map[string]json.RawMessage + if err := json.Unmarshal(record["lifecycle"], &lifecycle); err != nil { + t.Fatal(err) + } + lifecycle["credential_value"] = json.RawMessage(`"known-private-cleanup-value"`) + record["lifecycle"], err = json.Marshal(lifecycle) + if err != nil { + t.Fatal(err) + } + data, err = json.Marshal(record) + if err != nil { + t.Fatal(err) + } + var decoded module.IaCState + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + store := module.NewFSIaCStateStore(t.TempDir()) + if err := store.SaveState(t.Context(), &decoded); err != nil { + t.Fatal(err) + } + got, err := store.GetState(t.Context(), state.ResourceID) + if err != nil { + t.Fatal(err) + } + assertCleanupLifecycle(t, got, cleanupLifecycle()) +} + +func TestCleanupPending_PostgresSchema(t *testing.T) { + if !strings.Contains(strings.Join(strings.Fields(module.CreateTableSQL), " "), "lifecycle JSONB") { + t.Fatal("new tables have no lifecycle JSONB column") + } + columns := []string{"name", "type", "provider", "provider_ref", "provider_id", "status", "config_hash", "applied_config", "outputs", "dependencies", "created_at", "updated_at"} + statements := module.MigrateStatementsForExistingColumnsForTest(columns) + if len(statements) != 1 || statements[0] != "ALTER TABLE iac_resources ADD COLUMN IF NOT EXISTS lifecycle JSONB" { + t.Fatalf("legacy schema lifecycle migration is not additive: %v", statements) + } + if statements := module.MigrateStatementsForExistingColumnsForTest(append(columns, "lifecycle")); len(statements) != 0 { + t.Fatalf("up-to-date schema still needs migration: %v", statements) + } +} diff --git a/module/iac_state_memory.go b/module/iac_state_memory.go index e90f2384..4322621c 100644 --- a/module/iac_state_memory.go +++ b/module/iac_state_memory.go @@ -3,6 +3,7 @@ package module import ( "context" "fmt" + "slices" "sync" ) @@ -30,9 +31,7 @@ func (s *MemoryIaCStateStore) GetState(ctx context.Context, resourceID string) ( if !ok { return nil, nil } - // Return a shallow copy to prevent external mutation. - cp := *st - return &cp, nil + return cloneMemoryIaCState(st), nil } // SaveState inserts or replaces a state record. @@ -45,8 +44,7 @@ func (s *MemoryIaCStateStore) SaveState(ctx context.Context, state *IaCState) er } s.mu.Lock() defer s.mu.Unlock() - cp := *state - s.states[state.ResourceID] = &cp + s.states[state.ResourceID] = cloneMemoryIaCState(state) return nil } @@ -58,13 +56,24 @@ func (s *MemoryIaCStateStore) ListStates(ctx context.Context, filter map[string] var results []*IaCState for _, st := range s.states { if matchesFilter(st, filter) { - cp := *st - results = append(results, &cp) + results = append(results, cloneMemoryIaCState(st)) } } return results, nil } +// Lifecycle retry evidence must not alias callers; retain the existing shallow +// semantics for other state fields. +func cloneMemoryIaCState(state *IaCState) *IaCState { + copy := *state + if state.Lifecycle != nil { + lifecycle := *state.Lifecycle + lifecycle.Secrets = slices.Clone(state.Lifecycle.Secrets) + copy.Lifecycle = &lifecycle + } + return © +} + // DeleteState removes a state record by resource ID. func (s *MemoryIaCStateStore) DeleteState(ctx context.Context, resourceID string) error { s.mu.Lock() diff --git a/module/iac_state_postgres.go b/module/iac_state_postgres.go index cb3c6372..26346b45 100644 --- a/module/iac_state_postgres.go +++ b/module/iac_state_postgres.go @@ -6,6 +6,7 @@ import ( "fmt" "hash/fnv" "sync" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -191,6 +192,7 @@ CREATE TABLE IF NOT EXISTS iac_resources ( applied_config JSONB NOT NULL DEFAULT '{}', outputs JSONB NOT NULL DEFAULT '{}', dependencies TEXT[] NOT NULL DEFAULT '{}', + lifecycle JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )` @@ -203,6 +205,7 @@ var MigrateTableSQL = []string{ `ALTER TABLE iac_resources ADD COLUMN IF NOT EXISTS provider_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE iac_resources ADD COLUMN IF NOT EXISTS config_hash TEXT NOT NULL DEFAULT ''`, `ALTER TABLE iac_resources ADD COLUMN IF NOT EXISTS dependencies TEXT[] NOT NULL DEFAULT '{}'`, + `ALTER TABLE iac_resources ADD COLUMN IF NOT EXISTS lifecycle JSONB`, } var migrateTableColumns = []string{ @@ -211,6 +214,7 @@ var migrateTableColumns = []string{ "provider_id", "config_hash", "dependencies", + "lifecycle", } func (c *pgxRealConn) createTable(ctx context.Context) error { @@ -277,9 +281,17 @@ func (c *pgxRealConn) UpsertState(ctx context.Context, st *IaCState) error { if err != nil { return err } + lifecycle, err := json.Marshal(st.Lifecycle) + if err != nil { + return err + } + dependencies := st.Dependencies + if dependencies == nil { + dependencies = []string{} + } _, err = c.pool.Exec(ctx, ` - INSERT INTO iac_resources (name, type, provider, provider_ref, provider_id, config_hash, status, applied_config, outputs, dependencies, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + INSERT INTO iac_resources (name, type, provider, provider_ref, provider_id, config_hash, status, applied_config, outputs, dependencies, lifecycle, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (name) DO UPDATE SET type = EXCLUDED.type, provider = EXCLUDED.provider, @@ -290,20 +302,22 @@ func (c *pgxRealConn) UpsertState(ctx context.Context, st *IaCState) error { applied_config = EXCLUDED.applied_config, outputs = EXCLUDED.outputs, dependencies = EXCLUDED.dependencies, + lifecycle = EXCLUDED.lifecycle, updated_at = NOW() - `, st.ResourceID, st.ResourceType, st.Provider, st.ProviderRef, st.ProviderID, st.ConfigHash, st.Status, string(cfg), string(out), st.Dependencies) + `, st.ResourceID, st.ResourceType, st.Provider, st.ProviderRef, st.ProviderID, st.ConfigHash, st.Status, string(cfg), string(out), dependencies, string(lifecycle)) return err } func (c *pgxRealConn) GetState(ctx context.Context, name string) (*IaCState, error) { var st IaCState - var cfgJSON, outJSON string + var cfgJSON, outJSON, lifecycleJSON string + var createdAt, updatedAt time.Time var deps []string err := c.pool.QueryRow(ctx, ` - SELECT name, type, provider, provider_ref, provider_id, config_hash, status, applied_config::text, outputs::text, dependencies, created_at, updated_at + SELECT name, type, provider, provider_ref, provider_id, config_hash, status, applied_config::text, outputs::text, dependencies, created_at, updated_at, COALESCE(lifecycle::text, 'null') FROM iac_resources WHERE name = $1 `, name).Scan(&st.ResourceID, &st.ResourceType, &st.Provider, &st.ProviderRef, &st.ProviderID, &st.ConfigHash, &st.Status, - &cfgJSON, &outJSON, &deps, &st.CreatedAt, &st.UpdatedAt) + &cfgJSON, &outJSON, &deps, &createdAt, &updatedAt, &lifecycleJSON) if err != nil { if err == pgx.ErrNoRows { return nil, nil @@ -311,7 +325,9 @@ func (c *pgxRealConn) GetState(ctx context.Context, name string) (*IaCState, err return nil, err } st.Dependencies = append([]string(nil), deps...) - if err := decodeIaCStatePayloads(&st, cfgJSON, outJSON); err != nil { + st.CreatedAt = createdAt.UTC().Format(time.RFC3339Nano) + st.UpdatedAt = updatedAt.UTC().Format(time.RFC3339Nano) + if err := decodeIaCStatePayloads(&st, cfgJSON, outJSON, lifecycleJSON); err != nil { return nil, err } return &st, nil @@ -319,7 +335,7 @@ func (c *pgxRealConn) GetState(ctx context.Context, name string) (*IaCState, err func (c *pgxRealConn) ListRows(ctx context.Context) ([]*IaCState, error) { rows, err := c.pool.Query(ctx, ` - SELECT name, type, provider, provider_ref, provider_id, config_hash, status, applied_config::text, outputs::text, dependencies + SELECT name, type, provider, provider_ref, provider_id, config_hash, status, applied_config::text, outputs::text, dependencies, COALESCE(lifecycle::text, 'null') FROM iac_resources `) if err != nil { @@ -339,11 +355,11 @@ func scanIaCStateRows(rows iacStateRows) ([]*IaCState, error) { var results []*IaCState for rows.Next() { var st IaCState - var cfgJSON, outJSON string - if err := rows.Scan(&st.ResourceID, &st.ResourceType, &st.Provider, &st.ProviderRef, &st.ProviderID, &st.ConfigHash, &st.Status, &cfgJSON, &outJSON, &st.Dependencies); err != nil { + var cfgJSON, outJSON, lifecycleJSON string + if err := rows.Scan(&st.ResourceID, &st.ResourceType, &st.Provider, &st.ProviderRef, &st.ProviderID, &st.ConfigHash, &st.Status, &cfgJSON, &outJSON, &st.Dependencies, &lifecycleJSON); err != nil { return nil, fmt.Errorf("scan iac_resources row: %w", err) } - if err := decodeIaCStatePayloads(&st, cfgJSON, outJSON); err != nil { + if err := decodeIaCStatePayloads(&st, cfgJSON, outJSON, lifecycleJSON); err != nil { return nil, err } results = append(results, &st) @@ -351,13 +367,16 @@ func scanIaCStateRows(rows iacStateRows) ([]*IaCState, error) { return results, rows.Err() } -func decodeIaCStatePayloads(st *IaCState, cfgJSON, outJSON string) error { +func decodeIaCStatePayloads(st *IaCState, cfgJSON, outJSON, lifecycleJSON string) error { if err := json.Unmarshal([]byte(cfgJSON), &st.Config); err != nil { return fmt.Errorf("decode iac_resources %q applied_config: %w", st.ResourceID, err) } if err := json.Unmarshal([]byte(outJSON), &st.Outputs); err != nil { return fmt.Errorf("decode iac_resources %q outputs: %w", st.ResourceID, err) } + if err := json.Unmarshal([]byte(lifecycleJSON), &st.Lifecycle); err != nil { + return fmt.Errorf("decode iac_resources %q lifecycle: %w", st.ResourceID, err) + } return nil } diff --git a/module/iac_state_postgres_test.go b/module/iac_state_postgres_test.go index aaad13dd..14b14c86 100644 --- a/module/iac_state_postgres_test.go +++ b/module/iac_state_postgres_test.go @@ -59,8 +59,8 @@ func (r *scanSuccessRows) Next() bool { } func (r *scanSuccessRows) Scan(dest ...any) error { - if len(dest) != 10 { - return fmt.Errorf("dest len = %d, want 10", len(dest)) + if len(dest) != 11 { + return fmt.Errorf("dest len = %d, want 11", len(dest)) } *(dest[0].(*string)) = "site-dns" *(dest[1].(*string)) = "infra.dns" @@ -72,6 +72,7 @@ func (r *scanSuccessRows) Scan(dest ...any) error { *(dest[7].(*string)) = `{"domain":"example.com"}` *(dest[8].(*string)) = `{"records":[]}` *(dest[9].(*[]string)) = []string{"network"} + *(dest[10].(*string)) = "null" return nil } @@ -86,8 +87,8 @@ func (r *scanBadJSONRows) Next() bool { } func (r *scanBadJSONRows) Scan(dest ...any) error { - if len(dest) != 10 { - return fmt.Errorf("dest len = %d, want 10", len(dest)) + if len(dest) != 11 { + return fmt.Errorf("dest len = %d, want 11", len(dest)) } *(dest[0].(*string)) = "site-dns" *(dest[1].(*string)) = "infra.dns" @@ -99,6 +100,7 @@ func (r *scanBadJSONRows) Scan(dest ...any) error { *(dest[7].(*string)) = r.cfgJSON *(dest[8].(*string)) = r.outJSON *(dest[9].(*[]string)) = []string{"network"} + *(dest[10].(*string)) = "null" return nil } @@ -462,7 +464,7 @@ func TestPostgresIaCStateStore_Migration_AddsColumnsForExistingPreChangeSchema(t "created_at": true, "updated_at": true, } - newColumns := []string{"provider", "provider_ref", "provider_id", "config_hash", "dependencies"} + newColumns := []string{"provider", "provider_ref", "provider_id", "config_hash", "dependencies", "lifecycle"} for _, col := range newColumns { if legacySchemaColumns[col] { @@ -503,6 +505,7 @@ func TestPostgresIaCStateStore_Migration_SkipsAlterForExistingColumns(t *testing "applied_config", "outputs", "dependencies", + "lifecycle", "created_at", "updated_at", } @@ -522,6 +525,7 @@ func TestPostgresIaCStateStore_Migration_AltersOnlyMissingColumns(t *testing.T) "config_hash", "applied_config", "outputs", + "lifecycle", "created_at", "updated_at", } diff --git a/platform/differ.go b/platform/differ.go index f6b76b7a..75a11cf5 100644 --- a/platform/differ.go +++ b/platform/differ.go @@ -207,6 +207,19 @@ func ComputePlan(ctx context.Context, p interfaces.IaCProvider, desired []interf // cache-key collisions across distinct resources. func classifyModification(ctx context.Context, p interfaces.IaCProvider, spec interfaces.ResourceSpec, rs interfaces.ResourceState, hash string, hashable bool, out **interfaces.PlanAction) error { rsCopy := rs + if rs.Lifecycle != nil && rs.Lifecycle.Phase == interfaces.ResourcePhaseSecretRoutingPending { + if !rs.Lifecycle.RoutingCreated { + return fmt.Errorf("%w: resource %q has interrupted secret routing; recover its existing credentials before apply", interfaces.ErrValidation, rs.Name) + } + *out = &interfaces.PlanAction{Action: "replace", Resource: spec, Current: &rsCopy, ResolvedConfigHash: hash} + return nil + } + if rs.Lifecycle != nil && (rs.Lifecycle.Phase == interfaces.ResourcePhaseCloudDeletePending || rs.Lifecycle.Phase == interfaces.ResourcePhaseCloudDeletedSecretCleanupPending) { + // Finish the exact old generation's deletion and credential cleanup + // before creating the desired resource; deleted outputs are not live. + *out = &interfaces.PlanAction{Action: "replace", Resource: spec, Current: &rsCopy, ResolvedConfigHash: hash} + return nil + } // Nil-provider fallback: legacy ConfigHash compare. if p == nil { diff --git a/platform/differ_test.go b/platform/differ_test.go index 28283844..a6b2357b 100644 --- a/platform/differ_test.go +++ b/platform/differ_test.go @@ -58,6 +58,24 @@ func TestDiffer_NewResource(t *testing.T) { } } +func TestComputePlan_CleanupPendingCannotBecomeNoopOrUpdate(t *testing.T) { + for _, phase := range []interfaces.ResourcePhase{interfaces.ResourcePhaseCloudDeletePending, interfaces.ResourcePhaseCloudDeletedSecretCleanupPending} { + t.Run(string(phase), func(t *testing.T) { + desired := []interfaces.ResourceSpec{{Name: "database", Type: "infra.database", Config: map[string]any{"region": "public-region"}}} + state := interfaces.ResourceState{Name: "database", Type: "infra.database", ProviderID: "deleted-id", ConfigHash: platform.ConfigHash(desired[0].Config), Lifecycle: &interfaces.ResourceLifecycle{Generation: "cleanup-generation", Phase: phase}} + driver := &fakeDriver{diff: &interfaces.DiffResult{}} + plan, err := platform.ComputePlan(t.Context(), &fakeProvider{driver: driver}, desired, []interfaces.ResourceState{state}) + if err != nil || len(plan.Actions) != 1 || plan.Actions[0].Action != "replace" || plan.Actions[0].Current == nil || plan.Actions[0].Current.ProviderID != state.ProviderID || driver.diffCallCount.Load() != 0 { + t.Fatalf("pending cleanup was treated as live infrastructure: plan=%+v err=%v diff=%d", plan, err, driver.diffCallCount.Load()) + } + plan, err = platform.ComputePlan(t.Context(), nil, nil, []interfaces.ResourceState{state}) + if err != nil || len(plan.Actions) != 1 || plan.Actions[0].Action != "delete" { + t.Fatalf("removed desired resource did not resume deletion: plan=%+v err=%v", plan, err) + } + }) + } +} + func TestComputePlan_NewResource_AdoptionNoopDiffSkipsCreate(t *testing.T) { desired := []interfaces.ResourceSpec{ {Name: "delegation", Type: "infra.dns_delegation", Config: map[string]any{"domain": "example.com"}}, diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 62edb590..11d59797 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -651,6 +651,7 @@ func (a *ExternalPluginAdapter) advertisedOptionalIaCServices() map[string]bool case providerclient.IaCServiceRegionLister, providerclient.IaCServiceDriftDetector, providerclient.IaCServiceRunner, + providerclient.IaCServiceJobCanceler, providerclient.IaCServiceResourceDriver: out[d.ServiceName] = true } @@ -672,12 +673,13 @@ func (a *ExternalPluginAdapter) advertisedOptionalIaCServices() map[string]bool // (no wiring needed — not an iac.provider plugin). // // Optional services (IaCProviderRegionLister, IaCProviderDriftDetector, -// IaCProviderRunner, ResourceDriver) are collected from the ContractRegistry by -// advertisedOptionalIaCServices and forwarded to providerclient.New. +// IaCProviderRunner, IaCProviderJobCanceler, ResourceDriver) are collected from +// the ContractRegistry by advertisedOptionalIaCServices and forwarded to +// providerclient.New. // Optional gRPC clients are constructed only for advertised services, so the // adapter's capability accessors (RegionLister(), DriftDetector(), Runner(), -// ResourceDriver()) return nil / ErrProviderMethodUnimplemented for unadvertised -// services — ensuring fallback paths remain reachable. +// JobCanceler(), ResourceDriver()) return nil / ErrProviderMethodUnimplemented +// for unadvertised services — ensuring fallback paths remain reachable. func (a *ExternalPluginAdapter) iacProviderWiringHooks() []plugin.WiringHook { if !a.advertisesIaCProviderRequiredService() { return nil diff --git a/plugin/external/iac_jobs_launch_test.go b/plugin/external/iac_jobs_launch_test.go new file mode 100644 index 00000000..682aac87 --- /dev/null +++ b/plugin/external/iac_jobs_launch_test.go @@ -0,0 +1,262 @@ +package external + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/interfaces" + pluginpkg "github.com/GoCodeAlone/workflow/plugin" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type jobLaunchLog struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *jobLaunchLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} + +func (l *jobLaunchLog) snapshot() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func buildJobLaunchFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + binary := filepath.Join(root, "fixture") + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-race", "-o", binary, "./testdata/iac-job-plugin") + cmd.Env = append(os.Environ(), "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build native SDK fixture: %v\n%s", err, output) + } + t.Log("built native external SDK fixture with go build -race") + for _, name := range []string{"jobs-native", "jobs-legacy"} { + dir := filepath.Join(root, name) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Link(binary, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + manifest, err := json.Marshal(pluginpkg.PluginManifest{ + Name: name, Version: "0.1.0", Author: "workflow-tests", Description: "Native SDK job launch fixture", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), manifest, 0o600); err != nil { + t.Fatal(err) + } + } + return root +} + +func loadJobLaunchFixture(t *testing.T, root, name string) (*ExternalPluginAdapter, *providerclient.Adapter) { + t.Helper() + logs := &jobLaunchLog{} + manager := NewExternalPluginManager(root, log.New(logs, "", 0)) + t.Cleanup(func() { + manager.Shutdown() + transcript := logs.snapshot() + for _, signature := range []string{ + "panic:", "runtime error:", "no such host", "module not found", "import error", "version mismatch", + "incompatible api version", "schema drift", "missing column", "constraint violation", "permission denied", + "address already in use", "goroutine ", "data race", + } { + if strings.Contains(strings.ToLower(transcript), signature) { + t.Errorf("external process failure signature %q:\n%s", signature, transcript) + } + } + t.Logf("external process transcript:\n%s", transcript) + }) + names, err := manager.DiscoverPlugins() + if err != nil || len(names) != 2 { + t.Fatalf("discover external fixtures: %v, %v", names, err) + } + external, err := manager.LoadPlugin(name) + if err != nil { + t.Fatalf("launch external SDK fixture: %v", err) + } + if err := external.ContractRegistryError(); err != nil { + t.Fatal(err) + } + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + hooks := external.PreInitWiringHooks() + if len(hooks) != 1 { + t.Fatalf("expected IaC provider DI hook, got %d", len(hooks)) + } + if err := hooks[0].Hook(app, nil); err != nil { + t.Fatal(err) + } + var provider interfaces.IaCProvider + if err := app.GetService(name, &provider); err != nil { + t.Fatal(err) + } + adapter, ok := provider.(*providerclient.Adapter) + if !ok { + t.Fatalf("DI provider = %T, want real providerclient.Adapter", provider) + } + return external, adapter +} + +func waitJobLaunchStatus(t *testing.T, runner interfaces.IaCProviderRunner, handle interfaces.JobHandle, match func(*interfaces.JobStatusReply) bool) *interfaces.JobStatusReply { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + for { + reply, err := runner.JobStatus(ctx, handle) + if err != nil { + t.Fatal(err) + } + if match(reply) { + return reply + } + select { + case <-ctx.Done(): + t.Fatalf("native job did not reach expected state: %s, %s", reply.State, reply.Message) + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestIaCJobExternalProcess(t *testing.T) { + root := buildJobLaunchFixture(t) + target := &interfaces.ResourceRef{Name: "parent", Type: "custom.parent", ProviderID: "exact-parent-id"} + t.Run("native runner and canceler", func(t *testing.T) { + external, adapter := loadJobLaunchFixture(t, root, "jobs-native") + advertised := false + for _, contract := range external.ContractRegistry().GetContracts() { + if contract.GetKind() == pb.ContractKind_CONTRACT_KIND_SERVICE && contract.GetServiceName() == providerclient.IaCServiceJobCanceler { + advertised = true + } + } + if !advertised { + t.Fatal("launched SDK did not advertise native cancellation") + } + runner, canceler := adapter.Runner(), adapter.JobCanceler() + if runner == nil || canceler == nil { + t.Fatalf("DI lost advertised native capability: runner=%t canceler=%t", runner != nil, canceler != nil) + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + runs := 0 + run := func(name string, timeout int) *interfaces.JobHandle { + t.Helper() + handle, err := runner.RunJob(ctx, interfaces.JobSpec{Name: name, Kind: interfaces.JobKindEphemeral, Target: target, TimeoutSeconds: timeout}) + if err != nil { + t.Fatal(err) + } + runs++ + pid, err := strconv.Atoi(handle.Metadata["pid"]) + if err != nil || pid <= 0 || pid == os.Getpid() { + t.Fatalf("native job was not handled in an external process: pid=%q", handle.Metadata["pid"]) + } + want := map[string]string{ + "pid": strconv.Itoa(pid), "run_count": strconv.Itoa(runs), "timeout_seconds": strconv.Itoa(timeout), + "target_name": target.Name, "target_type": target.Type, "target_id": target.ProviderID, + } + if !reflect.DeepEqual(handle.Metadata, want) { + t.Fatalf("native process received different target/timeout: %+v, want %+v", handle.Metadata, want) + } + t.Logf("native process pid=%d received target=%s/%s/%s timeout=%ss job=%s", pid, target.Name, target.Type, target.ProviderID, handle.Metadata["timeout_seconds"], handle.ID) + return handle + } + minimum := run("bounded-minimum", 1) + reply := waitJobLaunchStatus(t, runner, *minimum, func(reply *interfaces.JobStatusReply) bool { return reply.State == interfaces.JobStateFailed }) + if reply.Message != "timeout reached" { + t.Fatalf("native timeout was not observed: %s", reply.Message) + } + t.Logf("native timeout result: state=%s message=%s", reply.State, reply.Message) + maximum := run("bounded-maximum", 3600) + if err := canceler.CancelJob(ctx, *maximum); err != nil { + t.Fatal(err) + } + reply = waitJobLaunchStatus(t, runner, *maximum, func(reply *interfaces.JobStatusReply) bool { return reply.State == interfaces.JobStateCancelled }) + if !reflect.DeepEqual(reply.Handle, *maximum) { + t.Fatal("cancelled native job handle changed across the process boundary") + } + t.Logf("native cancellation result: state=%s", reply.State) + for _, timeout := range []int{-1, 0, 3601} { + handle, err := runner.RunJob(ctx, interfaces.JobSpec{Name: "invalid", Target: target, TimeoutSeconds: timeout}) + if handle != nil || !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("invalid targeted timeout %d: %v", timeout, err) + } + _, err = pb.NewIaCProviderRunnerClient(external.Conn()).RunJob(ctx, &pb.JobSpec{ + Name: "invalid", Target: &pb.ResourceRef{Name: target.Name, Type: target.Type, ProviderId: target.ProviderID}, TimeoutSeconds: int32(timeout), //nolint:gosec // G115: fixed test cases are within int32 range + }) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("native SDK accepted invalid targeted timeout %d: %v", timeout, err) + } + } + for _, tc := range []struct { + name string + code codes.Code + }{ + {"cancel-unimplemented", codes.Unimplemented}, {"cancel-denied", codes.PermissionDenied}, {"cancel-unavailable", codes.Unavailable}, + } { + handle := run(tc.name, 60) + err := canceler.CancelJob(ctx, *handle) + if status.Code(err) != tc.code || errors.Is(err, interfaces.ErrProviderMethodUnimplemented) != (tc.code == codes.Unimplemented) { + t.Fatalf("native cancel error %s: %v", tc.name, err) + } + t.Logf("native cancellation failure preserved: %s", status.Code(err)) + } + blocked := run("cancel-block", 60) + callerCtx, callerCancel := context.WithCancel(ctx) + defer callerCancel() + result := make(chan error, 1) + go func() { result <- canceler.CancelJob(callerCtx, *blocked) }() + waitJobLaunchStatus(t, runner, *blocked, func(reply *interfaces.JobStatusReply) bool { return reply.Message == "cancel waiting" }) + callerCancel() + select { + case err := <-result: + if status.Code(err) != codes.Canceled { + t.Fatalf("caller cancellation changed across process boundary: %v", err) + } + case <-ctx.Done(): + t.Fatal("caller cancellation did not end RPC") + } + reply = waitJobLaunchStatus(t, runner, *blocked, func(reply *interfaces.JobStatusReply) bool { return reply.Message == "cancel context canceled" }) + t.Logf("native caller-context observation: %s", reply.Message) + }) + t.Run("legacy runner without canceler", func(t *testing.T) { + external, adapter := loadJobLaunchFixture(t, root, "jobs-legacy") + for _, contract := range external.ContractRegistry().GetContracts() { + if contract.GetServiceName() == providerclient.IaCServiceJobCanceler { + t.Fatal("SDK advertised a cancellation interface the provider does not implement") + } + } + if adapter.Runner() == nil || adapter.JobCanceler() != nil { + t.Fatal("legacy runner discovery changed") + } + handle, err := adapter.Runner().RunJob(t.Context(), interfaces.JobSpec{Name: "legacy", Kind: interfaces.JobKindEphemeral}) + if err != nil || handle == nil || handle.Metadata["timeout_seconds"] != "0" || handle.Metadata["target_id"] != "" { + t.Fatalf("untargeted legacy zero-timeout job: %+v, %v", handle, err) + } + t.Log("launched legacy native runner: zero timeout preserved; cancellation absent") + }) +} diff --git a/plugin/external/proto/iac.pb.go b/plugin/external/proto/iac.pb.go index 80e40738..d9d97cd4 100644 --- a/plugin/external/proto/iac.pb.go +++ b/plugin/external/proto/iac.pb.go @@ -25,6 +25,7 @@ package proto import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -5433,8 +5434,13 @@ type JobSpec struct { Termination *JobTerminationSpec `protobuf:"bytes,8,opt,name=termination,proto3" json:"termination,omitempty"` Alerts []*JobAlertSpec `protobuf:"bytes,9,rep,name=alerts,proto3" json:"alerts,omitempty"` LogDestinations []*JobLogDestinationSpec `protobuf:"bytes,10,rep,name=log_destinations,json=logDestinations,proto3" json:"log_destinations,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Exact existing parent identity: name, type, and provider_id are required. + Target *ResourceRef `protobuf:"bytes,11,opt,name=target,proto3" json:"target,omitempty"` + // Targeted jobs require 1..3600 seconds. Negative or >3600 is invalid for + // every job; zero is accepted only for untargeted legacy provider defaults. + TimeoutSeconds int32 `protobuf:"varint,12,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *JobSpec) Reset() { @@ -5537,6 +5543,20 @@ func (x *JobSpec) GetLogDestinations() []*JobLogDestinationSpec { return nil } +func (x *JobSpec) GetTarget() *ResourceRef { + if x != nil { + return x.Target + } + return nil +} + +func (x *JobSpec) GetTimeoutSeconds() int32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + type JobTerminationSpec struct { state protoimpl.MessageState `protogen:"open.v1"` DrainSeconds int32 `protobuf:"varint,1,opt,name=drain_seconds,json=drainSeconds,proto3" json:"drain_seconds,omitempty"` @@ -6759,20 +6779,23 @@ func (x *TroubleshootResponse) GetDiagnostics() []*Diagnostic { // Outputs / Config map[string]any fields cross the wire as JSON bytes per the // iac.proto hard invariant — same pattern as ResourceState.outputs_json. type IaCState struct { - state protoimpl.MessageState `protogen:"open.v1"` - ResourceId string `protobuf:"bytes,1,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` - ResourceType string `protobuf:"bytes,2,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` - Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` - ProviderRef string `protobuf:"bytes,4,opt,name=provider_ref,json=providerRef,proto3" json:"provider_ref,omitempty"` - ProviderId string `protobuf:"bytes,5,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - ConfigHash string `protobuf:"bytes,6,opt,name=config_hash,json=configHash,proto3" json:"config_hash,omitempty"` - Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` - OutputsJson []byte `protobuf:"bytes,8,opt,name=outputs_json,json=outputsJson,proto3" json:"outputs_json,omitempty"` // JSON-encoded map[string]any (module.IaCState.Outputs) - ConfigJson []byte `protobuf:"bytes,9,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` // JSON-encoded map[string]any (module.IaCState.Config) - Dependencies []string `protobuf:"bytes,10,rep,name=dependencies,proto3" json:"dependencies,omitempty"` - CreatedAt string `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt string `protobuf:"bytes,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - Error string `protobuf:"bytes,13,opt,name=error,proto3" json:"error,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ResourceId string `protobuf:"bytes,1,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + ResourceType string `protobuf:"bytes,2,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` + ProviderRef string `protobuf:"bytes,4,opt,name=provider_ref,json=providerRef,proto3" json:"provider_ref,omitempty"` + ProviderId string `protobuf:"bytes,5,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ConfigHash string `protobuf:"bytes,6,opt,name=config_hash,json=configHash,proto3" json:"config_hash,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + OutputsJson []byte `protobuf:"bytes,8,opt,name=outputs_json,json=outputsJson,proto3" json:"outputs_json,omitempty"` // JSON-encoded map[string]any (module.IaCState.Outputs) + ConfigJson []byte `protobuf:"bytes,9,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` // JSON-encoded map[string]any (module.IaCState.Config) + Dependencies []string `protobuf:"bytes,10,rep,name=dependencies,proto3" json:"dependencies,omitempty"` + CreatedAt string `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + Error string `protobuf:"bytes,13,opt,name=error,proto3" json:"error,omitempty"` + // JSON-encoded ResourceLifecycle: identifiers and cleanup evidence only, + // never secret values. Absent bytes preserve legacy state records. + LifecycleJson []byte `protobuf:"bytes,14,opt,name=lifecycle_json,json=lifecycleJson,proto3" json:"lifecycle_json,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6898,6 +6921,13 @@ func (x *IaCState) GetError() string { return "" } +func (x *IaCState) GetLifecycleJson() []byte { + if x != nil { + return x.LifecycleJson + } + return nil +} + // Configure delivers the iac.state module's YAML config to the plugin so it can // construct the SDK-backed store. backend_name selects which backend the config // is for. config_json is the JSON-encoded module config map[string]any — same @@ -7669,7 +7699,7 @@ var File_iac_proto protoreflect.FileDescriptor const file_iac_proto_rawDesc = "" + "\n" + - "\tiac.proto\x12\x1cworkflow.plugin.external.iac\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcd\x01\n" + + "\tiac.proto\x12\x1cworkflow.plugin.external.iac\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xcd\x01\n" + "\fResourceSpec\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1f\n" + @@ -8016,7 +8046,7 @@ const file_iac_proto_rawDesc = "" + "\bLogChunk\x12\x12\n" + "\x04data\x18\x01 \x01(\fR\x04data\x12\x16\n" + "\x06source\x18\x02 \x01(\tR\x06source\x12\x10\n" + - "\x03eof\x18\x03 \x01(\bR\x03eof\"\xa3\x05\n" + + "\x03eof\x18\x03 \x01(\bR\x03eof\"\x8f\x06\n" + "\aJobSpec\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x14\n" + @@ -8029,7 +8059,9 @@ const file_iac_proto_rawDesc = "" + "\vtermination\x18\b \x01(\v20.workflow.plugin.external.iac.JobTerminationSpecR\vtermination\x12B\n" + "\x06alerts\x18\t \x03(\v2*.workflow.plugin.external.iac.JobAlertSpecR\x06alerts\x12^\n" + "\x10log_destinations\x18\n" + - " \x03(\v23.workflow.plugin.external.iac.JobLogDestinationSpecR\x0flogDestinations\x1a:\n" + + " \x03(\v23.workflow.plugin.external.iac.JobLogDestinationSpecR\x0flogDestinations\x12A\n" + + "\x06target\x18\v \x01(\v2).workflow.plugin.external.iac.ResourceRefR\x06target\x12'\n" + + "\x0ftimeout_seconds\x18\f \x01(\x05R\x0etimeoutSeconds\x1a:\n" + "\fEnvVarsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a@\n" + @@ -8113,7 +8145,7 @@ const file_iac_proto_rawDesc = "" + "\vfailure_msg\x18\x03 \x01(\tR\n" + "failureMsg\"b\n" + "\x14TroubleshootResponse\x12J\n" + - "\vdiagnostics\x18\x01 \x03(\v2(.workflow.plugin.external.iac.DiagnosticR\vdiagnostics\"\xa5\x03\n" + + "\vdiagnostics\x18\x01 \x03(\v2(.workflow.plugin.external.iac.DiagnosticR\vdiagnostics\"\xcc\x03\n" + "\bIaCState\x12\x1f\n" + "\vresource_id\x18\x01 \x01(\tR\n" + "resourceId\x12#\n" + @@ -8134,7 +8166,8 @@ const file_iac_proto_rawDesc = "" + "created_at\x18\v \x01(\tR\tcreatedAt\x12\x1d\n" + "\n" + "updated_at\x18\f \x01(\tR\tupdatedAt\x12\x14\n" + - "\x05error\x18\r \x01(\tR\x05error\"V\n" + + "\x05error\x18\r \x01(\tR\x05error\x12%\n" + + "\x0elifecycle_json\x18\x0e \x01(\fR\rlifecycleJson\"V\n" + "\x10ConfigureRequest\x12!\n" + "\fbackend_name\x18\x01 \x01(\tR\vbackendName\x12\x1f\n" + "\vconfig_json\x18\x02 \x01(\fR\n" + @@ -8279,7 +8312,9 @@ const file_iac_proto_rawDesc = "" + "\x11IaCProviderRunner\x12X\n" + "\x06RunJob\x12%.workflow.plugin.external.iac.JobSpec\x1a'.workflow.plugin.external.iac.JobHandle\x12b\n" + "\tJobStatus\x12'.workflow.plugin.external.iac.JobHandle\x1a,.workflow.plugin.external.iac.JobStatusReply\x12\\\n" + - "\aJobLogs\x12'.workflow.plugin.external.iac.JobHandle\x1a&.workflow.plugin.external.iac.LogChunk0\x012\xa9\x01\n" + + "\aJobLogs\x12'.workflow.plugin.external.iac.JobHandle\x1a&.workflow.plugin.external.iac.LogChunk0\x012f\n" + + "\x16IaCProviderJobCanceler\x12L\n" + + "\tCancelJob\x12'.workflow.plugin.external.iac.JobHandle\x1a\x16.google.protobuf.Empty2\xa9\x01\n" + "\x17IaCRequirementDiscovery\x12\x8d\x01\n" + "\x14DiscoverRequirements\x129.workflow.plugin.external.iac.DiscoverRequirementsRequest\x1a:.workflow.plugin.external.iac.DiscoverRequirementsResponse2\x9e\x01\n" + "\x1cIaCProviderRequirementMapper\x12~\n" + @@ -8469,6 +8504,7 @@ var file_iac_proto_goTypes = []any{ nil, // 144: workflow.plugin.external.iac.JobHandle.MetadataEntry nil, // 145: workflow.plugin.external.iac.ListStatesRequest.FilterEntry (*timestamppb.Timestamp)(nil), // 146: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 147: google.protobuf.Empty } var file_iac_proto_depIdxs = []int32{ 12, // 0: workflow.plugin.external.iac.ResourceSpec.hints:type_name -> workflow.plugin.external.iac.ResourceHints @@ -8541,132 +8577,135 @@ var file_iac_proto_depIdxs = []int32{ 93, // 67: workflow.plugin.external.iac.JobSpec.termination:type_name -> workflow.plugin.external.iac.JobTerminationSpec 94, // 68: workflow.plugin.external.iac.JobSpec.alerts:type_name -> workflow.plugin.external.iac.JobAlertSpec 95, // 69: workflow.plugin.external.iac.JobSpec.log_destinations:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec - 143, // 70: workflow.plugin.external.iac.JobLogDestinationSpec.headers:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry - 144, // 71: workflow.plugin.external.iac.JobHandle.metadata:type_name -> workflow.plugin.external.iac.JobHandle.MetadataEntry - 96, // 72: workflow.plugin.external.iac.JobStatusReply.handle:type_name -> workflow.plugin.external.iac.JobHandle - 9, // 73: workflow.plugin.external.iac.JobStatusReply.state:type_name -> workflow.plugin.external.iac.JobState - 10, // 74: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec - 16, // 75: workflow.plugin.external.iac.ResourceCreateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 11, // 76: workflow.plugin.external.iac.ResourceReadRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 16, // 77: workflow.plugin.external.iac.ResourceReadResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 11, // 78: workflow.plugin.external.iac.ResourceUpdateRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 10, // 79: workflow.plugin.external.iac.ResourceUpdateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec - 16, // 80: workflow.plugin.external.iac.ResourceUpdateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 11, // 81: workflow.plugin.external.iac.ResourceDeleteRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 10, // 82: workflow.plugin.external.iac.ResourceDiffRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec - 16, // 83: workflow.plugin.external.iac.ResourceDiffRequest.current:type_name -> workflow.plugin.external.iac.ResourceOutput - 19, // 84: workflow.plugin.external.iac.ResourceDiffResponse.result:type_name -> workflow.plugin.external.iac.DiffResult - 11, // 85: workflow.plugin.external.iac.ResourceScaleRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 16, // 86: workflow.plugin.external.iac.ResourceScaleResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 11, // 87: workflow.plugin.external.iac.ResourceHealthCheckRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 32, // 88: workflow.plugin.external.iac.ResourceHealthCheckResponse.result:type_name -> workflow.plugin.external.iac.HealthResult - 11, // 89: workflow.plugin.external.iac.TroubleshootRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 33, // 90: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic - 116, // 91: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState - 116, // 92: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState - 145, // 93: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry - 116, // 94: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState - 10, // 95: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec - 10, // 96: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec - 42, // 97: workflow.plugin.external.iac.IaCProviderRequired.Initialize:input_type -> workflow.plugin.external.iac.InitializeRequest - 44, // 98: workflow.plugin.external.iac.IaCProviderRequired.Name:input_type -> workflow.plugin.external.iac.NameRequest - 46, // 99: workflow.plugin.external.iac.IaCProviderRequired.Version:input_type -> workflow.plugin.external.iac.VersionRequest - 48, // 100: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:input_type -> workflow.plugin.external.iac.CapabilitiesRequest - 50, // 101: workflow.plugin.external.iac.IaCProviderRequired.Plan:input_type -> workflow.plugin.external.iac.PlanRequest - 52, // 102: workflow.plugin.external.iac.IaCProviderRequired.Destroy:input_type -> workflow.plugin.external.iac.DestroyRequest - 54, // 103: workflow.plugin.external.iac.IaCProviderRequired.Status:input_type -> workflow.plugin.external.iac.StatusRequest - 56, // 104: workflow.plugin.external.iac.IaCProviderRequired.Import:input_type -> workflow.plugin.external.iac.ImportRequest - 58, // 105: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:input_type -> workflow.plugin.external.iac.ResolveSizingRequest - 60, // 106: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:input_type -> workflow.plugin.external.iac.BootstrapStateBackendRequest - 62, // 107: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:input_type -> workflow.plugin.external.iac.EnumerateAllRequest - 64, // 108: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:input_type -> workflow.plugin.external.iac.EnumerateByTagRequest - 66, // 109: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:input_type -> workflow.plugin.external.iac.DetectDriftRequest - 68, // 110: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:input_type -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest - 70, // 111: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:input_type -> workflow.plugin.external.iac.RevokeProviderCredentialRequest - 72, // 112: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:input_type -> workflow.plugin.external.iac.ListRegionsRequest - 75, // 113: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:input_type -> workflow.plugin.external.iac.GetOwnerRequest - 77, // 114: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:input_type -> workflow.plugin.external.iac.SetOwnerRequest - 79, // 115: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:input_type -> workflow.plugin.external.iac.ListOwnersRequest - 82, // 116: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:input_type -> workflow.plugin.external.iac.FinalizeApplyRequest - 84, // 117: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:input_type -> workflow.plugin.external.iac.RepairDirtyMigrationRequest - 86, // 118: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:input_type -> workflow.plugin.external.iac.ValidatePlanRequest - 88, // 119: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:input_type -> workflow.plugin.external.iac.DetectDriftConfigRequest - 90, // 120: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:input_type -> workflow.plugin.external.iac.CaptureLogsRequest - 92, // 121: workflow.plugin.external.iac.IaCProviderRunner.RunJob:input_type -> workflow.plugin.external.iac.JobSpec - 96, // 122: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:input_type -> workflow.plugin.external.iac.JobHandle - 96, // 123: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:input_type -> workflow.plugin.external.iac.JobHandle - 21, // 124: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:input_type -> workflow.plugin.external.iac.DiscoverRequirementsRequest - 25, // 125: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:input_type -> workflow.plugin.external.iac.MapRequirementsRequest - 98, // 126: workflow.plugin.external.iac.ResourceDriver.Create:input_type -> workflow.plugin.external.iac.ResourceCreateRequest - 100, // 127: workflow.plugin.external.iac.ResourceDriver.Read:input_type -> workflow.plugin.external.iac.ResourceReadRequest - 102, // 128: workflow.plugin.external.iac.ResourceDriver.Update:input_type -> workflow.plugin.external.iac.ResourceUpdateRequest - 104, // 129: workflow.plugin.external.iac.ResourceDriver.Delete:input_type -> workflow.plugin.external.iac.ResourceDeleteRequest - 106, // 130: workflow.plugin.external.iac.ResourceDriver.Diff:input_type -> workflow.plugin.external.iac.ResourceDiffRequest - 108, // 131: workflow.plugin.external.iac.ResourceDriver.Scale:input_type -> workflow.plugin.external.iac.ResourceScaleRequest - 110, // 132: workflow.plugin.external.iac.ResourceDriver.HealthCheck:input_type -> workflow.plugin.external.iac.ResourceHealthCheckRequest - 112, // 133: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:input_type -> workflow.plugin.external.iac.SensitiveKeysRequest - 114, // 134: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:input_type -> workflow.plugin.external.iac.TroubleshootRequest - 117, // 135: workflow.plugin.external.iac.IaCStateBackend.Configure:input_type -> workflow.plugin.external.iac.ConfigureRequest - 119, // 136: workflow.plugin.external.iac.IaCStateBackend.GetState:input_type -> workflow.plugin.external.iac.GetStateRequest - 121, // 137: workflow.plugin.external.iac.IaCStateBackend.SaveState:input_type -> workflow.plugin.external.iac.SaveStateRequest - 123, // 138: workflow.plugin.external.iac.IaCStateBackend.ListStates:input_type -> workflow.plugin.external.iac.ListStatesRequest - 125, // 139: workflow.plugin.external.iac.IaCStateBackend.DeleteState:input_type -> workflow.plugin.external.iac.DeleteStateRequest - 127, // 140: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest - 129, // 141: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest - 131, // 142: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest - 133, // 143: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:input_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsRequest - 43, // 144: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse - 45, // 145: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse - 47, // 146: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse - 49, // 147: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse - 51, // 148: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse - 53, // 149: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse - 55, // 150: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse - 57, // 151: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse - 59, // 152: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse - 61, // 153: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse - 63, // 154: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse - 65, // 155: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse - 67, // 156: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse - 69, // 157: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse - 71, // 158: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse - 74, // 159: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:output_type -> workflow.plugin.external.iac.ListRegionsResponse - 76, // 160: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:output_type -> workflow.plugin.external.iac.GetOwnerResponse - 78, // 161: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:output_type -> workflow.plugin.external.iac.SetOwnerResponse - 81, // 162: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:output_type -> workflow.plugin.external.iac.ListOwnersResponse - 83, // 163: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse - 85, // 164: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse - 87, // 165: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse - 89, // 166: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse - 91, // 167: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:output_type -> workflow.plugin.external.iac.LogChunk - 96, // 168: workflow.plugin.external.iac.IaCProviderRunner.RunJob:output_type -> workflow.plugin.external.iac.JobHandle - 97, // 169: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:output_type -> workflow.plugin.external.iac.JobStatusReply - 91, // 170: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:output_type -> workflow.plugin.external.iac.LogChunk - 24, // 171: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:output_type -> workflow.plugin.external.iac.DiscoverRequirementsResponse - 29, // 172: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:output_type -> workflow.plugin.external.iac.MapRequirementsResponse - 99, // 173: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse - 101, // 174: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse - 103, // 175: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse - 105, // 176: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse - 107, // 177: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse - 109, // 178: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse - 111, // 179: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse - 113, // 180: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse - 115, // 181: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse - 118, // 182: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse - 120, // 183: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse - 122, // 184: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse - 124, // 185: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse - 126, // 186: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse - 128, // 187: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse - 130, // 188: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse - 132, // 189: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse - 134, // 190: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:output_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsResponse - 144, // [144:191] is the sub-list for method output_type - 97, // [97:144] is the sub-list for method input_type - 97, // [97:97] is the sub-list for extension type_name - 97, // [97:97] is the sub-list for extension extendee - 0, // [0:97] is the sub-list for field type_name + 11, // 70: workflow.plugin.external.iac.JobSpec.target:type_name -> workflow.plugin.external.iac.ResourceRef + 143, // 71: workflow.plugin.external.iac.JobLogDestinationSpec.headers:type_name -> workflow.plugin.external.iac.JobLogDestinationSpec.HeadersEntry + 144, // 72: workflow.plugin.external.iac.JobHandle.metadata:type_name -> workflow.plugin.external.iac.JobHandle.MetadataEntry + 96, // 73: workflow.plugin.external.iac.JobStatusReply.handle:type_name -> workflow.plugin.external.iac.JobHandle + 9, // 74: workflow.plugin.external.iac.JobStatusReply.state:type_name -> workflow.plugin.external.iac.JobState + 10, // 75: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec + 16, // 76: workflow.plugin.external.iac.ResourceCreateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 11, // 77: workflow.plugin.external.iac.ResourceReadRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 16, // 78: workflow.plugin.external.iac.ResourceReadResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 11, // 79: workflow.plugin.external.iac.ResourceUpdateRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 10, // 80: workflow.plugin.external.iac.ResourceUpdateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec + 16, // 81: workflow.plugin.external.iac.ResourceUpdateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 11, // 82: workflow.plugin.external.iac.ResourceDeleteRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 10, // 83: workflow.plugin.external.iac.ResourceDiffRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec + 16, // 84: workflow.plugin.external.iac.ResourceDiffRequest.current:type_name -> workflow.plugin.external.iac.ResourceOutput + 19, // 85: workflow.plugin.external.iac.ResourceDiffResponse.result:type_name -> workflow.plugin.external.iac.DiffResult + 11, // 86: workflow.plugin.external.iac.ResourceScaleRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 16, // 87: workflow.plugin.external.iac.ResourceScaleResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 11, // 88: workflow.plugin.external.iac.ResourceHealthCheckRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 32, // 89: workflow.plugin.external.iac.ResourceHealthCheckResponse.result:type_name -> workflow.plugin.external.iac.HealthResult + 11, // 90: workflow.plugin.external.iac.TroubleshootRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 33, // 91: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic + 116, // 92: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState + 116, // 93: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState + 145, // 94: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry + 116, // 95: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState + 10, // 96: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec + 10, // 97: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec + 42, // 98: workflow.plugin.external.iac.IaCProviderRequired.Initialize:input_type -> workflow.plugin.external.iac.InitializeRequest + 44, // 99: workflow.plugin.external.iac.IaCProviderRequired.Name:input_type -> workflow.plugin.external.iac.NameRequest + 46, // 100: workflow.plugin.external.iac.IaCProviderRequired.Version:input_type -> workflow.plugin.external.iac.VersionRequest + 48, // 101: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:input_type -> workflow.plugin.external.iac.CapabilitiesRequest + 50, // 102: workflow.plugin.external.iac.IaCProviderRequired.Plan:input_type -> workflow.plugin.external.iac.PlanRequest + 52, // 103: workflow.plugin.external.iac.IaCProviderRequired.Destroy:input_type -> workflow.plugin.external.iac.DestroyRequest + 54, // 104: workflow.plugin.external.iac.IaCProviderRequired.Status:input_type -> workflow.plugin.external.iac.StatusRequest + 56, // 105: workflow.plugin.external.iac.IaCProviderRequired.Import:input_type -> workflow.plugin.external.iac.ImportRequest + 58, // 106: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:input_type -> workflow.plugin.external.iac.ResolveSizingRequest + 60, // 107: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:input_type -> workflow.plugin.external.iac.BootstrapStateBackendRequest + 62, // 108: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:input_type -> workflow.plugin.external.iac.EnumerateAllRequest + 64, // 109: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:input_type -> workflow.plugin.external.iac.EnumerateByTagRequest + 66, // 110: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:input_type -> workflow.plugin.external.iac.DetectDriftRequest + 68, // 111: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:input_type -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest + 70, // 112: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:input_type -> workflow.plugin.external.iac.RevokeProviderCredentialRequest + 72, // 113: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:input_type -> workflow.plugin.external.iac.ListRegionsRequest + 75, // 114: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:input_type -> workflow.plugin.external.iac.GetOwnerRequest + 77, // 115: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:input_type -> workflow.plugin.external.iac.SetOwnerRequest + 79, // 116: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:input_type -> workflow.plugin.external.iac.ListOwnersRequest + 82, // 117: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:input_type -> workflow.plugin.external.iac.FinalizeApplyRequest + 84, // 118: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:input_type -> workflow.plugin.external.iac.RepairDirtyMigrationRequest + 86, // 119: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:input_type -> workflow.plugin.external.iac.ValidatePlanRequest + 88, // 120: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:input_type -> workflow.plugin.external.iac.DetectDriftConfigRequest + 90, // 121: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:input_type -> workflow.plugin.external.iac.CaptureLogsRequest + 92, // 122: workflow.plugin.external.iac.IaCProviderRunner.RunJob:input_type -> workflow.plugin.external.iac.JobSpec + 96, // 123: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:input_type -> workflow.plugin.external.iac.JobHandle + 96, // 124: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:input_type -> workflow.plugin.external.iac.JobHandle + 96, // 125: workflow.plugin.external.iac.IaCProviderJobCanceler.CancelJob:input_type -> workflow.plugin.external.iac.JobHandle + 21, // 126: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:input_type -> workflow.plugin.external.iac.DiscoverRequirementsRequest + 25, // 127: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:input_type -> workflow.plugin.external.iac.MapRequirementsRequest + 98, // 128: workflow.plugin.external.iac.ResourceDriver.Create:input_type -> workflow.plugin.external.iac.ResourceCreateRequest + 100, // 129: workflow.plugin.external.iac.ResourceDriver.Read:input_type -> workflow.plugin.external.iac.ResourceReadRequest + 102, // 130: workflow.plugin.external.iac.ResourceDriver.Update:input_type -> workflow.plugin.external.iac.ResourceUpdateRequest + 104, // 131: workflow.plugin.external.iac.ResourceDriver.Delete:input_type -> workflow.plugin.external.iac.ResourceDeleteRequest + 106, // 132: workflow.plugin.external.iac.ResourceDriver.Diff:input_type -> workflow.plugin.external.iac.ResourceDiffRequest + 108, // 133: workflow.plugin.external.iac.ResourceDriver.Scale:input_type -> workflow.plugin.external.iac.ResourceScaleRequest + 110, // 134: workflow.plugin.external.iac.ResourceDriver.HealthCheck:input_type -> workflow.plugin.external.iac.ResourceHealthCheckRequest + 112, // 135: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:input_type -> workflow.plugin.external.iac.SensitiveKeysRequest + 114, // 136: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:input_type -> workflow.plugin.external.iac.TroubleshootRequest + 117, // 137: workflow.plugin.external.iac.IaCStateBackend.Configure:input_type -> workflow.plugin.external.iac.ConfigureRequest + 119, // 138: workflow.plugin.external.iac.IaCStateBackend.GetState:input_type -> workflow.plugin.external.iac.GetStateRequest + 121, // 139: workflow.plugin.external.iac.IaCStateBackend.SaveState:input_type -> workflow.plugin.external.iac.SaveStateRequest + 123, // 140: workflow.plugin.external.iac.IaCStateBackend.ListStates:input_type -> workflow.plugin.external.iac.ListStatesRequest + 125, // 141: workflow.plugin.external.iac.IaCStateBackend.DeleteState:input_type -> workflow.plugin.external.iac.DeleteStateRequest + 127, // 142: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest + 129, // 143: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest + 131, // 144: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest + 133, // 145: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:input_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsRequest + 43, // 146: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse + 45, // 147: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse + 47, // 148: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse + 49, // 149: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse + 51, // 150: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse + 53, // 151: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse + 55, // 152: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse + 57, // 153: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse + 59, // 154: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse + 61, // 155: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse + 63, // 156: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse + 65, // 157: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse + 67, // 158: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse + 69, // 159: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse + 71, // 160: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse + 74, // 161: workflow.plugin.external.iac.IaCProviderRegionLister.ListRegions:output_type -> workflow.plugin.external.iac.ListRegionsResponse + 76, // 162: workflow.plugin.external.iac.IaCProviderOwnership.GetOwner:output_type -> workflow.plugin.external.iac.GetOwnerResponse + 78, // 163: workflow.plugin.external.iac.IaCProviderOwnership.SetOwner:output_type -> workflow.plugin.external.iac.SetOwnerResponse + 81, // 164: workflow.plugin.external.iac.IaCProviderOwnership.ListOwners:output_type -> workflow.plugin.external.iac.ListOwnersResponse + 83, // 165: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse + 85, // 166: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse + 87, // 167: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse + 89, // 168: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse + 91, // 169: workflow.plugin.external.iac.IaCProviderLogCapture.CaptureLogs:output_type -> workflow.plugin.external.iac.LogChunk + 96, // 170: workflow.plugin.external.iac.IaCProviderRunner.RunJob:output_type -> workflow.plugin.external.iac.JobHandle + 97, // 171: workflow.plugin.external.iac.IaCProviderRunner.JobStatus:output_type -> workflow.plugin.external.iac.JobStatusReply + 91, // 172: workflow.plugin.external.iac.IaCProviderRunner.JobLogs:output_type -> workflow.plugin.external.iac.LogChunk + 147, // 173: workflow.plugin.external.iac.IaCProviderJobCanceler.CancelJob:output_type -> google.protobuf.Empty + 24, // 174: workflow.plugin.external.iac.IaCRequirementDiscovery.DiscoverRequirements:output_type -> workflow.plugin.external.iac.DiscoverRequirementsResponse + 29, // 175: workflow.plugin.external.iac.IaCProviderRequirementMapper.MapRequirements:output_type -> workflow.plugin.external.iac.MapRequirementsResponse + 99, // 176: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse + 101, // 177: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse + 103, // 178: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse + 105, // 179: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse + 107, // 180: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse + 109, // 181: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse + 111, // 182: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse + 113, // 183: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse + 115, // 184: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse + 118, // 185: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse + 120, // 186: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse + 122, // 187: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse + 124, // 188: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse + 126, // 189: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse + 128, // 190: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse + 130, // 191: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse + 132, // 192: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse + 134, // 193: workflow.plugin.external.iac.ResourceSensitiveInputDeclarer.SensitiveInputPaths:output_type -> workflow.plugin.external.iac.ResourceSensitiveInputPathsResponse + 146, // [146:194] is the sub-list for method output_type + 98, // [98:146] is the sub-list for method input_type + 98, // [98:98] is the sub-list for extension type_name + 98, // [98:98] is the sub-list for extension extendee + 0, // [0:98] is the sub-list for field type_name } func init() { file_iac_proto_init() } @@ -8682,7 +8721,7 @@ func file_iac_proto_init() { NumEnums: 10, NumMessages: 136, NumExtensions: 0, - NumServices: 17, + NumServices: 18, }, GoTypes: file_iac_proto_goTypes, DependencyIndexes: file_iac_proto_depIdxs, diff --git a/plugin/external/proto/iac.proto b/plugin/external/proto/iac.proto index ee6941f3..c8cc831e 100644 --- a/plugin/external/proto/iac.proto +++ b/plugin/external/proto/iac.proto @@ -20,6 +20,7 @@ package workflow.plugin.external.iac; option go_package = "github.com/GoCodeAlone/workflow/plugin/external/proto;proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/empty.proto"; // ───────────────────────────────────────────────────────────────────────────── // REQUIRED service — every IaC provider MUST implement every RPC. @@ -114,6 +115,12 @@ service IaCProviderRunner { rpc JobLogs(JobHandle) returns (stream LogChunk); } +// Cancellation is independently advertised, not added to the existing runner. +// The handle's provider-owned metadata scopes cancellation and terminal cleanup. +service IaCProviderJobCanceler { + rpc CancelJob(JobHandle) returns (google.protobuf.Empty); +} + // IaCRequirementDiscovery is an optional service for config-aware plugins that // need to emit provider-neutral infrastructure requirements from their own // module configuration. Hosts pass a typed, redacted Workflow context plus the @@ -837,6 +844,11 @@ message JobSpec { JobTerminationSpec termination = 8; repeated JobAlertSpec alerts = 9; repeated JobLogDestinationSpec log_destinations = 10; + // Exact existing parent identity: name, type, and provider_id are required. + ResourceRef target = 11; + // Targeted jobs require 1..3600 seconds. Negative or >3600 is invalid for + // every job; zero is accepted only for untargeted legacy provider defaults. + int32 timeout_seconds = 12; } message JobTerminationSpec { @@ -996,6 +1008,9 @@ message IaCState { string created_at = 11; string updated_at = 12; string error = 13; + // JSON-encoded ResourceLifecycle: identifiers and cleanup evidence only, + // never secret values. Absent bytes preserve legacy state records. + bytes lifecycle_json = 14; } // Configure delivers the iac.state module's YAML config to the plugin so it can diff --git a/plugin/external/proto/iac_grpc.pb.go b/plugin/external/proto/iac_grpc.pb.go index 38eb9602..ff6d00b0 100644 --- a/plugin/external/proto/iac_grpc.pb.go +++ b/plugin/external/proto/iac_grpc.pb.go @@ -27,6 +27,7 @@ import ( grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" ) // This is a compile-time assertion to ensure that this generated file @@ -1904,6 +1905,115 @@ var IaCProviderRunner_ServiceDesc = grpc.ServiceDesc{ Metadata: "iac.proto", } +const ( + IaCProviderJobCanceler_CancelJob_FullMethodName = "/workflow.plugin.external.iac.IaCProviderJobCanceler/CancelJob" +) + +// IaCProviderJobCancelerClient is the client API for IaCProviderJobCanceler service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Cancellation is independently advertised, not added to the existing runner. +// The handle's provider-owned metadata scopes cancellation and terminal cleanup. +type IaCProviderJobCancelerClient interface { + CancelJob(ctx context.Context, in *JobHandle, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type iaCProviderJobCancelerClient struct { + cc grpc.ClientConnInterface +} + +func NewIaCProviderJobCancelerClient(cc grpc.ClientConnInterface) IaCProviderJobCancelerClient { + return &iaCProviderJobCancelerClient{cc} +} + +func (c *iaCProviderJobCancelerClient) CancelJob(ctx context.Context, in *JobHandle, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IaCProviderJobCanceler_CancelJob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// IaCProviderJobCancelerServer is the server API for IaCProviderJobCanceler service. +// All implementations must embed UnimplementedIaCProviderJobCancelerServer +// for forward compatibility. +// +// Cancellation is independently advertised, not added to the existing runner. +// The handle's provider-owned metadata scopes cancellation and terminal cleanup. +type IaCProviderJobCancelerServer interface { + CancelJob(context.Context, *JobHandle) (*emptypb.Empty, error) + mustEmbedUnimplementedIaCProviderJobCancelerServer() +} + +// UnimplementedIaCProviderJobCancelerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedIaCProviderJobCancelerServer struct{} + +func (UnimplementedIaCProviderJobCancelerServer) CancelJob(context.Context, *JobHandle) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelJob not implemented") +} +func (UnimplementedIaCProviderJobCancelerServer) mustEmbedUnimplementedIaCProviderJobCancelerServer() { +} +func (UnimplementedIaCProviderJobCancelerServer) testEmbeddedByValue() {} + +// UnsafeIaCProviderJobCancelerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to IaCProviderJobCancelerServer will +// result in compilation errors. +type UnsafeIaCProviderJobCancelerServer interface { + mustEmbedUnimplementedIaCProviderJobCancelerServer() +} + +func RegisterIaCProviderJobCancelerServer(s grpc.ServiceRegistrar, srv IaCProviderJobCancelerServer) { + // If the following call panics, it indicates UnimplementedIaCProviderJobCancelerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&IaCProviderJobCanceler_ServiceDesc, srv) +} + +func _IaCProviderJobCanceler_CancelJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(JobHandle) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IaCProviderJobCancelerServer).CancelJob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IaCProviderJobCanceler_CancelJob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IaCProviderJobCancelerServer).CancelJob(ctx, req.(*JobHandle)) + } + return interceptor(ctx, in, info, handler) +} + +// IaCProviderJobCanceler_ServiceDesc is the grpc.ServiceDesc for IaCProviderJobCanceler service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var IaCProviderJobCanceler_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "workflow.plugin.external.iac.IaCProviderJobCanceler", + HandlerType: (*IaCProviderJobCancelerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CancelJob", + Handler: _IaCProviderJobCanceler_CancelJob_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "iac.proto", +} + const ( IaCRequirementDiscovery_DiscoverRequirements_FullMethodName = "/workflow.plugin.external.iac.IaCRequirementDiscovery/DiscoverRequirements" ) diff --git a/plugin/external/proto/iac_job_contract_test.go b/plugin/external/proto/iac_job_contract_test.go new file mode 100644 index 00000000..027b454e --- /dev/null +++ b/plugin/external/proto/iac_job_contract_test.go @@ -0,0 +1,32 @@ +package proto_test + +import ( + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestProviderJobContractTypedTargetTimeoutAndSeparateCancellation(t *testing.T) { + fields := (&pb.JobSpec{}).ProtoReflect().Descriptor().Fields() + target := fields.ByName("target") + if target == nil || target.Number() != 11 || target.Kind() != protoreflect.MessageKind || target.Message().FullName() != "workflow.plugin.external.iac.ResourceRef" { + t.Fatal("JobSpec target must be a typed ResourceRef at field 11") + } + timeout := fields.ByName("timeout_seconds") + if timeout == nil || timeout.Number() != 12 || timeout.Kind() != protoreflect.Int32Kind { + t.Fatal("JobSpec timeout_seconds must be int32 at field 12") + } + runner := pb.File_iac_proto.Services().ByName("IaCProviderRunner") + if runner.Methods().Len() != 3 || runner.Methods().ByName("CancelJob") != nil { + t.Fatal("existing runner RPC surface must remain unchanged") + } + canceler := pb.File_iac_proto.Services().ByName("IaCProviderJobCanceler") + if canceler == nil || canceler.Methods().Len() != 1 { + t.Fatal("cancellation must have its own optional service") + } + method := canceler.Methods().ByName("CancelJob") + if method == nil || method.Input().FullName() != "workflow.plugin.external.iac.JobHandle" || method.Output().FullName() != "google.protobuf.Empty" || method.IsStreamingClient() || method.IsStreamingServer() { + t.Fatal("CancelJob must accept a typed JobHandle and return Empty") + } +} diff --git a/plugin/external/proto/iac_proto_test.go b/plugin/external/proto/iac_proto_test.go index 2abb183b..335478ed 100644 --- a/plugin/external/proto/iac_proto_test.go +++ b/plugin/external/proto/iac_proto_test.go @@ -78,6 +78,7 @@ func TestOptionalServicesHaveDistinctInterfaces(t *testing.T) { pb.IaCProviderDriftConfigDetectorServer pb.IaCProviderLogCaptureServer pb.IaCProviderRunnerServer + pb.IaCProviderJobCancelerServer } var _ optional = (*allOptionalStub)(nil) } @@ -93,6 +94,7 @@ type allOptionalStub struct { pb.UnimplementedIaCProviderDriftConfigDetectorServer pb.UnimplementedIaCProviderLogCaptureServer pb.UnimplementedIaCProviderRunnerServer + pb.UnimplementedIaCProviderJobCancelerServer } // TestMigrationRepairConfirmationStringMatchesProtoComment guards the diff --git a/plugin/external/sdk/iac_jobs.go b/plugin/external/sdk/iac_jobs.go new file mode 100644 index 00000000..1857fb95 --- /dev/null +++ b/plugin/external/sdk/iac_jobs.go @@ -0,0 +1,158 @@ +package sdk + +import ( + "context" + "errors" + "math" + + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +type nativeIaCJobRunnerServer struct { + pb.UnimplementedIaCProviderRunnerServer + runner interfaces.IaCProviderRunner +} + +func (s *nativeIaCJobRunnerServer) RunJob(ctx context.Context, request *pb.JobSpec) (*pb.JobHandle, error) { + spec := nativeJobSpecFromPB(request) + if err := spec.Validate(); err != nil { + return nil, nativeJobRPCError(err) + } + handle, err := s.runner.RunJob(ctx, spec) + if err != nil { + return nil, nativeJobRPCError(err) + } + if handle == nil { + return nil, status.Error(codes.Internal, "native provider returned nil job handle") + } + return nativeJobHandleToPB(*handle), nil +} + +func (s *nativeIaCJobRunnerServer) JobStatus(ctx context.Context, request *pb.JobHandle) (*pb.JobStatusReply, error) { + reply, err := s.runner.JobStatus(ctx, nativeJobHandleFromPB(request)) + if err != nil { + return nil, nativeJobRPCError(err) + } + if reply == nil { + return nil, status.Error(codes.Internal, "native provider returned nil job status") + } + if reply.ExitCode < math.MinInt32 || reply.ExitCode > math.MaxInt32 { + return nil, status.Error(codes.Internal, "native provider job exit code is out of int32 range") + } + return &pb.JobStatusReply{ + Handle: nativeJobHandleToPB(reply.Handle), State: nativeJobStateToPB(reply.State), + ExitCode: int32(reply.ExitCode), Message: reply.Message, //nolint:gosec // G115: range checked above + }, nil +} + +func (s *nativeIaCJobRunnerServer) JobLogs(request *pb.JobHandle, stream grpc.ServerStreamingServer[pb.LogChunk]) error { + return nativeJobRPCError(s.runner.JobLogs(stream.Context(), nativeJobHandleFromPB(request), nativeJobLogSink{stream: stream})) +} + +type nativeJobLogSink struct { + stream grpc.ServerStreamingServer[pb.LogChunk] +} + +func (s nativeJobLogSink) WriteLogChunk(chunk interfaces.LogChunk) error { + return s.stream.Send(&pb.LogChunk{Data: append([]byte(nil), chunk.Data...), Source: chunk.Source, Eof: chunk.EOF}) +} + +type nativeIaCJobCancelerServer struct { + pb.UnimplementedIaCProviderJobCancelerServer + canceler interfaces.IaCProviderJobCanceler +} + +func (s *nativeIaCJobCancelerServer) CancelJob(ctx context.Context, request *pb.JobHandle) (*emptypb.Empty, error) { + if err := s.canceler.CancelJob(ctx, nativeJobHandleFromPB(request)); err != nil { + return nil, nativeJobRPCError(err) + } + return &emptypb.Empty{}, nil +} + +func nativeJobRPCError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, interfaces.ErrProviderMethodUnimplemented): + return status.Error(codes.Unimplemented, err.Error()) + case errors.Is(err, interfaces.ErrValidation): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return status.FromContextError(err).Err() + default: + return err + } +} + +func nativeJobSpecFromPB(request *pb.JobSpec) interfaces.JobSpec { + spec := interfaces.JobSpec{ + Name: request.GetName(), Kind: request.GetKind(), Image: request.GetImage(), + RunCommand: request.GetRunCommand(), Cron: request.GetCron(), + EnvVars: nativeJobStringMap(request.GetEnvVars()), EnvVarsSecret: nativeJobStringMap(request.GetEnvVarsSecret()), + TimeoutSeconds: int(request.GetTimeoutSeconds()), + } + if target := request.GetTarget(); target != nil { + spec.Target = &interfaces.ResourceRef{Name: target.GetName(), Type: target.GetType(), ProviderID: target.GetProviderId()} + } + if termination := request.GetTermination(); termination != nil { + spec.Termination = &interfaces.TerminationSpec{ + DrainSeconds: int(termination.GetDrainSeconds()), GracePeriodSeconds: int(termination.GetGracePeriodSeconds()), + } + } + for _, alert := range request.GetAlerts() { + spec.Alerts = append(spec.Alerts, interfaces.AlertSpec{ + Rule: alert.GetRule(), Operator: alert.GetOperator(), Value: alert.GetValue(), Window: alert.GetWindow(), Disabled: alert.GetDisabled(), + }) + } + for _, destination := range request.GetLogDestinations() { + spec.LogDestinations = append(spec.LogDestinations, interfaces.LogDestinationSpec{ + Name: destination.GetName(), Endpoint: destination.GetEndpoint(), Headers: nativeJobStringMap(destination.GetHeaders()), TLS: destination.GetTls(), + }) + } + return spec +} + +func nativeJobHandleFromPB(handle *pb.JobHandle) interfaces.JobHandle { + return interfaces.JobHandle{ + ID: handle.GetId(), Name: handle.GetName(), Provider: handle.GetProvider(), Metadata: nativeJobStringMap(handle.GetMetadata()), + } +} + +func nativeJobHandleToPB(handle interfaces.JobHandle) *pb.JobHandle { + return &pb.JobHandle{ + Id: handle.ID, Name: handle.Name, Provider: handle.Provider, Metadata: nativeJobStringMap(handle.Metadata), + } +} + +func nativeJobStringMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func nativeJobStateToPB(state interfaces.JobState) pb.JobState { + switch state { + case interfaces.JobStatePending: + return pb.JobState_JOB_STATE_PENDING + case interfaces.JobStateRunning: + return pb.JobState_JOB_STATE_RUNNING + case interfaces.JobStateSucceeded: + return pb.JobState_JOB_STATE_SUCCEEDED + case interfaces.JobStateFailed: + return pb.JobState_JOB_STATE_FAILED + case interfaces.JobStateCancelled: + return pb.JobState_JOB_STATE_CANCELLED + default: + return pb.JobState_JOB_STATE_UNSPECIFIED + } +} diff --git a/plugin/external/sdk/iac_jobs_test.go b/plugin/external/sdk/iac_jobs_test.go new file mode 100644 index 00000000..d6e3be8f --- /dev/null +++ b/plugin/external/sdk/iac_jobs_test.go @@ -0,0 +1,44 @@ +package sdk_test + +import ( + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" +) + +type typedJobRunnerOnly struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderRunnerServer +} + +type typedJobCancelerOnly struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderJobCancelerServer +} + +func TestProviderJobTypedSDKCapabilitiesRegisterIndependently(t *testing.T) { + for _, tc := range []struct { + name string + provider any + runner bool + canceler bool + }{ + {"legacy", &pb.UnimplementedIaCProviderRequiredServer{}, false, false}, + {"typed runner", &typedJobRunnerOnly{}, true, false}, + {"typed canceler", &typedJobCancelerOnly{}, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := grpc.NewServer() + t.Cleanup(server.Stop) + if err := sdk.RegisterAllIaCProviderServices(server, tc.provider); err != nil { + t.Fatal(err) + } + services := serviceNamesFromRegistry(sdk.BuildContractRegistry(server)) + if services[pb.IaCProviderRunner_ServiceDesc.ServiceName] != tc.runner || services[pb.IaCProviderJobCanceler_ServiceDesc.ServiceName] != tc.canceler { + t.Fatalf("SDK service registration = %v; want runner %v, canceler %v", services, tc.runner, tc.canceler) + } + }) + } +} diff --git a/plugin/external/sdk/iacserver.go b/plugin/external/sdk/iacserver.go index 6a3037ef..9b7d432a 100644 --- a/plugin/external/sdk/iacserver.go +++ b/plugin/external/sdk/iacserver.go @@ -8,6 +8,7 @@ import ( "strings" goplugin "github.com/GoCodeAlone/go-plugin" + "github.com/GoCodeAlone/workflow/interfaces" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -41,11 +42,17 @@ import ( // pb.IaCProviderDriftConfigDetectorServer // pb.IaCProviderLogCaptureServer // pb.IaCProviderRunnerServer +// pb.IaCProviderJobCancelerServer // pb.IaCRequirementDiscoveryServer // pb.IaCProviderRequirementMapperServer // pb.IaCStateBackendServer // pb.ResourceSensitiveInputDeclarerServer // +// Native interfaces.IaCProviderRunner and interfaces.IaCProviderJobCanceler are +// also registered through typed SDK bridges when no protobuf implementation +// exists. Cancellation is discovered independently; neither capability implies +// the other. +// // ResourceDriver: // // pb.ResourceDriverServer — separate gRPC service, also auto-registered @@ -183,6 +190,13 @@ func registerIaCServicesOnly(s *grpc.Server, provider any) error { } if v, ok := provider.(pb.IaCProviderRunnerServer); ok { pb.RegisterIaCProviderRunnerServer(s, v) + } else if v, ok := provider.(interfaces.IaCProviderRunner); ok { + pb.RegisterIaCProviderRunnerServer(s, &nativeIaCJobRunnerServer{runner: v}) + } + if v, ok := provider.(pb.IaCProviderJobCancelerServer); ok { + pb.RegisterIaCProviderJobCancelerServer(s, v) + } else if v, ok := provider.(interfaces.IaCProviderJobCanceler); ok { + pb.RegisterIaCProviderJobCancelerServer(s, &nativeIaCJobCancelerServer{canceler: v}) } if v, ok := provider.(pb.IaCRequirementDiscoveryServer); ok { pb.RegisterIaCRequirementDiscoveryServer(s, v) diff --git a/plugin/external/testdata/iac-job-plugin/main.go b/plugin/external/testdata/iac-job-plugin/main.go new file mode 100644 index 00000000..6a42276a --- /dev/null +++ b/plugin/external/testdata/iac-job-plugin/main.go @@ -0,0 +1,115 @@ +// This fixture substitutes only cloud execution with an in-memory native job +// provider. The SDK owns registration, protobuf conversion, and process serving. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type jobRecord struct { + handle interfaces.JobHandle + state interfaces.JobState + message string + deadline time.Time +} + +type jobProvider struct { + pb.UnimplementedIaCProviderRequiredServer + mu sync.Mutex + next int + jobs map[string]*jobRecord +} + +func (p *jobProvider) RunJob(_ context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.next++ + metadata := map[string]string{ + "pid": strconv.Itoa(os.Getpid()), "run_count": strconv.Itoa(p.next), + "timeout_seconds": strconv.Itoa(spec.TimeoutSeconds), + } + if spec.Target != nil { + metadata["target_name"] = spec.Target.Name + metadata["target_type"] = spec.Target.Type + metadata["target_id"] = spec.Target.ProviderID + } + handle := interfaces.JobHandle{ID: fmt.Sprintf("job-%d", p.next), Name: spec.Name, Provider: "native-launch-fixture", Metadata: metadata} + record := &jobRecord{handle: handle, state: interfaces.JobStateRunning, message: "running"} + if spec.TimeoutSeconds > 0 { + record.deadline = time.Now().Add(time.Duration(spec.TimeoutSeconds) * time.Second) + } + p.jobs[handle.ID] = record + return &handle, nil +} + +func (p *jobProvider) JobStatus(_ context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + p.mu.Lock() + defer p.mu.Unlock() + record := p.jobs[handle.ID] + if record == nil { + return nil, interfaces.ErrResourceNotFound + } + if record.state == interfaces.JobStateRunning && !record.deadline.IsZero() && !time.Now().Before(record.deadline) { + record.state, record.message = interfaces.JobStateFailed, "timeout reached" + } + return &interfaces.JobStatusReply{Handle: record.handle, State: record.state, Message: record.message}, nil +} + +func (*jobProvider) JobLogs(_ context.Context, _ interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + return sink.WriteLogChunk(interfaces.LogChunk{Data: []byte("native job fixture\n"), Source: "stdout", EOF: true}) +} + +type cancelableJobProvider struct{ *jobProvider } + +func (p *cancelableJobProvider) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + p.mu.Lock() + record := p.jobs[handle.ID] + if record == nil { + p.mu.Unlock() + return interfaces.ErrResourceNotFound + } + switch record.handle.Name { + case "cancel-unimplemented": + p.mu.Unlock() + return interfaces.ErrProviderMethodUnimplemented + case "cancel-denied": + p.mu.Unlock() + return status.Error(codes.PermissionDenied, "fixture denied") + case "cancel-unavailable": + p.mu.Unlock() + return status.Error(codes.Unavailable, "fixture unavailable") + case "cancel-block": + record.message = "cancel waiting" + p.mu.Unlock() + <-ctx.Done() + p.mu.Lock() + record.message = "cancel context canceled" + p.mu.Unlock() + return ctx.Err() + default: + record.state, record.message = interfaces.JobStateCancelled, "cancelled" + p.mu.Unlock() + return nil + } +} + +func main() { + provider := &jobProvider{jobs: make(map[string]*jobRecord)} + if filepath.Base(os.Args[0]) == "jobs-legacy" { + sdk.ServeIaCPlugin(provider, sdk.IaCServeOptions{}) + return + } + sdk.ServeIaCPlugin(&cancelableJobProvider{provider}, sdk.IaCServeOptions{}) +} diff --git a/wftest/bdd/strict_iac.go b/wftest/bdd/strict_iac.go index fff98bec..ddd1bfe9 100644 --- a/wftest/bdd/strict_iac.go +++ b/wftest/bdd/strict_iac.go @@ -6,6 +6,7 @@ import ( "google.golang.org/grpc" + "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) @@ -75,7 +76,13 @@ var iacServiceChecks = []iacServiceCheck{ }}, {"workflow.plugin.external.iac.IaCProviderRunner", func(p any) bool { _, ok := p.(pb.IaCProviderRunnerServer) - return ok + _, native := p.(interfaces.IaCProviderRunner) + return ok || native + }}, + {"workflow.plugin.external.iac.IaCProviderJobCanceler", func(p any) bool { + _, ok := p.(pb.IaCProviderJobCancelerServer) + _, native := p.(interfaces.IaCProviderJobCanceler) + return ok || native }}, {"workflow.plugin.external.iac.ResourceDriver", func(p any) bool { _, ok := p.(pb.ResourceDriverServer) diff --git a/wftest/bdd/strict_iac_test.go b/wftest/bdd/strict_iac_test.go index d8eced94..02467f0e 100644 --- a/wftest/bdd/strict_iac_test.go +++ b/wftest/bdd/strict_iac_test.go @@ -1,12 +1,14 @@ package bdd_test import ( + "context" "fmt" "strings" "testing" "google.golang.org/grpc" + "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "github.com/GoCodeAlone/workflow/plugin/external/sdk" "github.com/GoCodeAlone/workflow/wftest/bdd" @@ -64,6 +66,7 @@ func TestAssertProviderCapabilitiesMatchRegistration_ManuallyRegisteredMissingOp "ResourceDriver", "IaCRequirementDiscovery", "IaCProviderRequirementMapper", + "IaCProviderJobCanceler", } joined := strings.Join(rec.errors, "\n") for _, name := range wantContains { @@ -153,6 +156,7 @@ type allCapabilitiesStub struct { pb.UnimplementedResourceDriverServer pb.UnimplementedIaCRequirementDiscoveryServer pb.UnimplementedIaCProviderRequirementMapperServer + pb.UnimplementedIaCProviderJobCancelerServer } // requiredOnlyStub satisfies Required ONLY. @@ -162,3 +166,45 @@ type requiredOnlyStub struct { // noIaCStub satisfies no IaC interface. type noIaCStub struct{} + +type nativeJobCapabilitiesStub struct { + pb.UnimplementedIaCProviderRequiredServer +} + +func (*nativeJobCapabilitiesStub) RunJob(context.Context, interfaces.JobSpec) (*interfaces.JobHandle, error) { + return nil, nil +} + +func (*nativeJobCapabilitiesStub) JobStatus(context.Context, interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + return nil, nil +} + +func (*nativeJobCapabilitiesStub) JobLogs(context.Context, interfaces.JobHandle, interfaces.LogCaptureSink) error { + return nil +} + +func (*nativeJobCapabilitiesStub) CancelJob(context.Context, interfaces.JobHandle) error { + return nil +} + +func TestProviderJobNativeCapabilitiesBDDRegistration(t *testing.T) { + provider := &nativeJobCapabilitiesStub{} + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, provider); err != nil { + t.Fatal(err) + } + rec := &recordingT{} + bdd.AssertProviderCapabilitiesMatchRegistration(rec, provider, server) + if rec.failed { + t.Fatalf("SDK native capabilities mismatch: %v", rec.errors) + } + server = grpc.NewServer() + pb.RegisterIaCProviderRequiredServer(server, provider) + rec = &recordingT{} + bdd.AssertProviderCapabilitiesMatchRegistration(rec, provider, server) + for _, service := range []string{"IaCProviderRunner", "IaCProviderJobCanceler"} { + if !strings.Contains(strings.Join(rec.errors, "\n"), service) { + t.Errorf("BDD did not report missing native %s registration: %v", service, rec.errors) + } + } +} From e03188c3cbf462558dce9abc70cfe5eebdf278f3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 3 Oct 2026 01:34:32 -0400 Subject: [PATCH 4/5] fix(deps): declare native Windows import --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ad956ec7..0e7958ce 100644 --- a/go.mod +++ b/go.mod @@ -253,7 +253,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/arch v0.28.0 // indirect golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.48.0 // indirect + golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect From 9cefcef62672a945d36c1e7d621be2da873dcaae Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 3 Oct 2026 01:47:47 -0400 Subject: [PATCH 5/5] fix(wfctl): remove unused stale-plan filtering --- cmd/wfctl/infra.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 5dcaf4a3..02f93a98 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -1578,7 +1578,6 @@ func runInfraApply(args []string) error { return fmt.Errorf("validate plan include scope: %w", err) } desired = filterSpecsByInclude(desired, planIncludeSet) - currentState = filterStatesByInclude(currentState, planIncludeSet) } currentHash := desiredStateHash(desired) if plan.DesiredHash != currentHash {