diff --git a/README.md b/README.md index af4edc8..e920c78 100644 --- a/README.md +++ b/README.md @@ -13,35 +13,42 @@ Version Guard helps organizations maintain infrastructure security and complianc ## πŸ—οΈ Architecture -Version Guard implements a **two-stage detection pipeline**: +Version Guard implements a **two-stage detection pipeline**. Manual and scheduled triggers share the same scan pipeline; scheduled triggers go through a small Temporal launcher workflow first so Temporal's schedule-generated workflow IDs do not bypass the singleton scan guard. ``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Manual trigger β”‚ β”‚ Temporal Schedule API β”‚ +β”‚ POST /scan or CLI β”‚ β”‚ SCHEDULE_* env vars β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ β–Ό + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ ScheduledScanWorkflow β”‚ + β”‚ β”‚ launcher only; no scanning β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SINGLETON SCAN WORKFLOW β”‚ +β”‚ workflow ID: version-guard-active-scan β”‚ +β”‚ Temporal rejects a second open run with this same ID β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ STAGE 1: DETECT β”‚ +β”‚ Fan-Out: parallel detection per resource type β”‚ +β”‚ Inventory (Wiz) + EOL data + classify β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ STAGE 1: DETECT (Temporal Workflow) β”‚ -β”‚ β”‚ -β”‚ Fan-Out: Parallel Detection per Resource Type β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚Aurora β”‚ β”‚ EKS β”‚ β”‚ More β”‚ ... β”‚ -β”‚ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β–Ό β”‚ -β”‚ Inventory (Wiz) + EOL Data + Classify β”‚ -β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ STAGE 2: STORE β”‚ -β”‚ β–Ό β”‚ -β”‚ Create Versioned JSON Snapshot β”‚ -β”‚ β”‚ β”‚ -β”‚ s3://bucket/snapshots/YYYY/MM/DD/{snapshot-id}.json β”‚ -β”‚ s3://bucket/snapshots/latest.json β”‚ -β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - πŸ“€ YOUR CUSTOM EMITTERS - (See "Extending Version Guard") +β”‚ s3://bucket/snapshots/YYYY/MM/DD/{snapshot-id}.json β”‚ +β”‚ s3://bucket/snapshots/latest.json β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό + πŸ“€ YOUR CUSTOM EMITTERS + (See "Extending Version Guard") ``` **Key Components:** @@ -248,11 +255,13 @@ curl -X POST http://localhost:8081/scan \ **Via Temporal directly:** ```bash -# Temporal CLI (from inside the temporal container if using docker-compose) +# Manual scan through the singleton orchestrator workflow. Use the fixed +# workflow ID so Temporal enforces "only one active scan at a time". docker compose exec temporal temporal workflow start \ + --workflow-id version-guard-active-scan \ --task-queue version-guard-detection \ --type OrchestratorWorkflow \ - --input '{}' \ + --input '{"ResourceTypes":["aurora-mysql","eks"],"ScanScope":"full"}' \ --address localhost:7233 \ --namespace version-guard-dev @@ -284,7 +293,7 @@ Resource Breakdown: **Verify snapshot creation:** -Snapshots are stored in MinIO (local S3) at `s3://version-guard-snapshots/snapshots/YYYY/MM/DD/{workflow-id}.json`: +Snapshots are stored in MinIO (local S3) at `s3://version-guard-snapshots/snapshots/YYYY/MM/DD/{snapshot-id}.json`. For scheduled runs, the snapshot ID remains the schedule-generated run ID (for example `version-guard-scheduled-scan-2026-06-11T18:00:00Z`) even though the underlying scan workflow uses the fixed singleton ID: ```bash # List snapshots (from logs) @@ -376,7 +385,16 @@ export SCHEDULE_CRON="*/30 * * * *" # Every 30 minutes export SCHEDULE_JITTER="2m" ``` -The schedule uses a create-or-update pattern β€” safe to restart the server without creating duplicate schedules. If the cron expression changes, the existing schedule is updated automatically. +The public configuration has not changed: `SCHEDULE_ENABLED`, `SCHEDULE_CRON`, `SCHEDULE_ID`, `SCHEDULE_JITTER`, and `TEMPORAL_TASK_QUEUE` are still the only knobs. The schedule uses a create-or-update pattern β€” safe to restart the server without creating duplicate schedules. If the cron expression changes, the existing schedule is updated automatically. + +Under the hood, the schedule starts `ScheduledScanWorkflow`. Temporal schedules may append the scheduled fire time to that workflow ID (for example `version-guard-scheduled-scan-2026-06-11T08:00:00Z`). That launcher then starts the real scan as child workflow ID `version-guard-active-scan`. Manual scans use the same fixed `version-guard-active-scan` ID directly. Because both paths converge on the same fixed workflow ID, Temporal rejects overlapping scans while still allowing each completed daily schedule to create a unique snapshot. + +In short: + +- **Config stays the same** β€” no new env vars, chart values, ports, task queues, or schedule IDs. +- **Manual scans stay the same** β€” `POST /scan` and the CLI start `version-guard-active-scan`. +- **Scheduled scans keep unique snapshot IDs** β€” the scheduled launcher ID is passed through as `ScanID`. +- **Only one collector scan can run at a time** β€” scheduled and manual triggers both contend on `version-guard-active-scan`. ```bash # Verify the schedule diff --git a/cmd/server/main.go b/cmd/server/main.go index 261551b..5c84e15 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -422,8 +422,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error { // Register workflows w.RegisterWorkflow(detection.DetectionWorkflow) + w.RegisterWorkflow(orchestrator.ScheduledScanWorkflow) w.RegisterWorkflow(orchestrator.OrchestratorWorkflow) - fmt.Println("βœ“ Workflows registered (detection, orchestrator)") + fmt.Println("βœ“ Workflows registered (detection, scheduled scan, orchestrator)") // Register activities // Detection workflow activities @@ -478,8 +479,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error { if s.ScheduleEnabled { fmt.Printf(" Scans will run automatically (schedule: %s)\n", s.ScheduleCron) } - fmt.Println("\nπŸ“– To trigger a scan manually, use the Temporal UI or CLI:") - fmt.Printf(" temporal workflow start --workflow-id %s --task-queue %s --type %s --input '{}'\n", orchestrator.ActiveScanWorkflowID, s.TemporalTaskQueue, orchestrator.OrchestratorWorkflowType) + fmt.Println("\nπŸ“– To trigger a scan manually, use the HTTP admin API:") + fmt.Printf(" curl -X POST http://localhost:%d/scan\n", s.HTTPPort) + fmt.Printf(" Advanced Temporal users: start %s with workflow ID %s and explicit ResourceTypes input\n", orchestrator.OrchestratorWorkflowType, orchestrator.ActiveScanWorkflowID) fmt.Println("\nπŸ“– For more information, see the README.md") fmt.Println("\nPress Ctrl+C to stop...") diff --git a/pkg/schedule/schedule.go b/pkg/schedule/schedule.go index ead7e34..c425b6b 100644 --- a/pkg/schedule/schedule.go +++ b/pkg/schedule/schedule.go @@ -78,8 +78,8 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error { Jitter: cfg.Jitter, }, Action: &client.ScheduleWorkflowAction{ - ID: orchestrator.ActiveScanWorkflowID, - Workflow: orchestrator.OrchestratorWorkflow, + ID: orchestrator.ScheduledScanWorkflowID, + Workflow: orchestrator.ScheduledScanWorkflow, Args: []interface{}{orchestrator.WorkflowInput{ ResourceTypes: cfg.ResourceTypes, EmitterWebhookURL: cfg.EmitterWebhookURL, @@ -149,7 +149,8 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error { } input.Description.Schedule.Policy.Overlap = enumspb.SCHEDULE_OVERLAP_POLICY_SKIP if action, ok := input.Description.Schedule.Action.(*client.ScheduleWorkflowAction); ok { - action.ID = orchestrator.ActiveScanWorkflowID + action.ID = orchestrator.ScheduledScanWorkflowID + action.Workflow = orchestrator.ScheduledScanWorkflow action.TaskQueue = cfg.TaskQueue action.Args = []interface{}{orchestrator.WorkflowInput{ ResourceTypes: cfg.ResourceTypes, @@ -184,7 +185,7 @@ func scheduleActionMatches(action client.ScheduleAction, cfg *Config) bool { if !ok { return false } - if wfAction.ID != orchestrator.ActiveScanWorkflowID { + if wfAction.ID != orchestrator.ScheduledScanWorkflowID { return false } if wfAction.TaskQueue != cfg.TaskQueue { diff --git a/pkg/schedule/schedule_test.go b/pkg/schedule/schedule_test.go index ae66069..d75fd52 100644 --- a/pkg/schedule/schedule_test.go +++ b/pkg/schedule/schedule_test.go @@ -132,7 +132,8 @@ func TestEnsureSchedule_CreatesNew(t *testing.T) { assert.Equal(t, 5*time.Minute, mock.createOpts.Spec.Jitter) assert.Equal(t, enumspb.SCHEDULE_OVERLAP_POLICY_SKIP, mock.createOpts.Overlap) action := mock.createOpts.Action.(*client.ScheduleWorkflowAction) - assert.Equal(t, orchestrator.ActiveScanWorkflowID, action.ID) + assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID) + assert.NotNil(t, action.Workflow) assert.Equal(t, "test-queue", action.TaskQueue) assert.Equal(t, 2*time.Hour, action.WorkflowExecutionTimeout) require.Len(t, action.Args, 1) @@ -152,7 +153,7 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) { Jitter: 5 * time.Minute, }, Action: &client.ScheduleWorkflowAction{ - ID: orchestrator.ActiveScanWorkflowID, + ID: orchestrator.ScheduledScanWorkflowID, TaskQueue: "test-queue", Args: []interface{}{orchestrator.WorkflowInput{ ResourceTypes: testResourceTypes, @@ -181,6 +182,59 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) { assert.False(t, handle.updateCalled, "Update should not be called when cron and action match") } +func TestEnsureSchedule_AlreadyExists_OldDirectOrchestratorActionUpdates(t *testing.T) { + handle := &mockScheduleHandle{ + id: "test-schedule", + describeOut: &client.ScheduleDescription{ + Schedule: client.Schedule{ + Policy: &client.SchedulePolicies{Overlap: enumspb.SCHEDULE_OVERLAP_POLICY_SKIP}, + Spec: &client.ScheduleSpec{ + CronExpressions: []string{"0 */6 * * *"}, + Jitter: 5 * time.Minute, + }, + Action: &client.ScheduleWorkflowAction{ + ID: orchestrator.ActiveScanWorkflowID, + Workflow: orchestrator.OrchestratorWorkflow, + TaskQueue: "test-queue", + Args: []interface{}{orchestrator.WorkflowInput{ + ResourceTypes: testResourceTypes, + ScanScope: orchestrator.ScanScopeFull, + }}, + }, + }, + }, + } + var captured *client.ScheduleUpdate + handle.updateFn = func(opts client.ScheduleUpdateOptions) { + input := client.ScheduleUpdateInput{Description: *handle.describeOut} + result, err := opts.DoUpdate(input) + require.NoError(t, err) + captured = result + } + mock := &mockCreator{ + createErr: temporal.ErrScheduleAlreadyRunning, + handle: handle, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), Config{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + Jitter: 5 * time.Minute, + TaskQueue: "test-queue", + ResourceTypes: testResourceTypes, + }) + + require.NoError(t, err) + assert.True(t, handle.updateCalled, "Update should rewrite the pre-hotfix direct orchestrator schedule action") + require.NotNil(t, captured) + action, ok := captured.Schedule.Action.(*client.ScheduleWorkflowAction) + require.True(t, ok) + assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID) + assert.NotNil(t, action.Workflow) +} + // TestEnsureSchedule_AlreadyExists_NewWebhookURL guards the contract that // setting EMITTER_WEBHOOK_URL on a deployment whose schedule already // exists must propagate into the schedule's WorkflowInput. Without this @@ -236,7 +290,8 @@ func TestEnsureSchedule_AlreadyExists_NewWebhookURL(t *testing.T) { assert.Equal(t, enumspb.SCHEDULE_OVERLAP_POLICY_SKIP, captured.Schedule.Policy.Overlap) action, ok := captured.Schedule.Action.(*client.ScheduleWorkflowAction) require.True(t, ok, "action should be a ScheduleWorkflowAction") - assert.Equal(t, orchestrator.ActiveScanWorkflowID, action.ID) + assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID) + assert.NotNil(t, action.Workflow) require.Len(t, action.Args, 1) in, ok := action.Args[0].(orchestrator.WorkflowInput) require.True(t, ok) diff --git a/pkg/workflow/orchestrator/orchestrator_test.go b/pkg/workflow/orchestrator/orchestrator_test.go index 06f62b3..9140dd5 100644 --- a/pkg/workflow/orchestrator/orchestrator_test.go +++ b/pkg/workflow/orchestrator/orchestrator_test.go @@ -133,6 +133,40 @@ func TestOrchestratorWorkflow_RejectsNonSingletonWorkflowID(t *testing.T) { assert.Contains(t, err.Error(), ActiveScanWorkflowID) } +func TestScheduledScanWorkflow_StartsSingletonChildWithScheduledScanID(t *testing.T) { + suite := &testsuite.WorkflowTestSuite{} + env := suite.NewTestWorkflowEnvironment() + env.SetStartWorkflowOptions(client.StartWorkflowOptions{ID: ScheduledScanWorkflowID + "-2026-06-11T08:00:00Z"}) + env.RegisterWorkflow(ScheduledScanWorkflow) + env.RegisterWorkflow(OrchestratorWorkflow) + + var capturedInput WorkflowInput + env.OnWorkflow(OrchestratorWorkflow, mock.Anything, mock.Anything). + Return(func(ctx workflow.Context, in WorkflowInput) (*WorkflowOutput, error) { + capturedInput = in + assert.Equal(t, ActiveScanWorkflowID, workflow.GetInfo(ctx).WorkflowExecution.ID) + return &WorkflowOutput{ + ScanID: in.ScanID, + SnapshotID: "snapshot-from-child", + }, nil + }) + + env.ExecuteWorkflow(ScheduledScanWorkflow, WorkflowInput{ + ResourceTypes: []types.ResourceType{types.ResourceTypeAurora}, + ScanScope: ScanScopeFull, + }) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + assert.Equal(t, ScheduledScanWorkflowID+"-2026-06-11T08:00:00Z", capturedInput.ScanID) + assert.Equal(t, []types.ResourceType{types.ResourceTypeAurora}, capturedInput.ResourceTypes) + + var output WorkflowOutput + require.NoError(t, env.GetWorkflowResult(&output)) + assert.Equal(t, capturedInput.ScanID, output.ScanID) + assert.Equal(t, "snapshot-from-child", output.SnapshotID) +} + func TestOrchestratorWorkflow_ScanIDDefaultsToWorkflowID(t *testing.T) { env := newOrchestratorEnv(t) diff --git a/pkg/workflow/orchestrator/workflow.go b/pkg/workflow/orchestrator/workflow.go index 553ee7f..e56542e 100644 --- a/pkg/workflow/orchestrator/workflow.go +++ b/pkg/workflow/orchestrator/workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + enumspb "go.temporal.io/api/enums/v1" "go.temporal.io/sdk/temporal" "go.temporal.io/sdk/workflow" @@ -24,6 +25,7 @@ const ( OrchestratorWorkflowType = "VersionGuardOrchestratorWorkflow" TaskQueueName = "version-guard-orchestrator" ActiveScanWorkflowID = "version-guard-active-scan" + ScheduledScanWorkflowID = "version-guard-scheduled-scan" ScanScopeFull = "full" ScanScopeTargeted = "targeted" @@ -75,6 +77,42 @@ type ResourceTypeResult struct { Error string // Empty if successful } +// ScheduledScanWorkflow is the Temporal schedule entry point. Temporal appends +// the scheduled fire time to workflow IDs for uniqueness, so schedules cannot +// start OrchestratorWorkflow directly while it enforces ActiveScanWorkflowID. +// This launcher may have a timestamp-suffixed ID, but it starts the real scan +// as a child workflow using the fixed singleton ID. That keeps scheduled and +// manual scans mutually exclusive while preserving unique scheduled ScanIDs. +func ScheduledScanWorkflow(ctx workflow.Context, input WorkflowInput) (*WorkflowOutput, error) { + info := workflow.GetInfo(ctx) + if input.ScanID == "" { + input.ScanID = info.WorkflowExecution.ID + } + + childOpts := workflow.ChildWorkflowOptions{ + WorkflowID: ActiveScanWorkflowID, + WorkflowExecutionTimeout: 2 * time.Hour, + WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + } + childCtx := workflow.WithChildOptions(ctx, childOpts) + + var output WorkflowOutput + err := workflow.ExecuteChildWorkflow(childCtx, OrchestratorWorkflow, input).Get(ctx, &output) + if err != nil { + logger := workflow.GetLogger(ctx) + logger.Error("Scheduled scan workflow failed", + "event", "scheduled_scan_workflow_failed", + "scanID", input.ScanID, + "workflowID", info.WorkflowExecution.ID, + "runID", info.WorkflowExecution.RunID, + "childWorkflowID", ActiveScanWorkflowID, + "error", err) + return nil, err + } + + return &output, nil +} + // OrchestratorWorkflow is the main workflow that orchestrates the three-stage pipeline: // Stage 1: Detect - Fan out across resource types in parallel // Stage 2: Store - Write classified findings to S3 as versioned snapshot