Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ func (s *ServerCLI) Run(_ *kong.Context) error {
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 --task-queue %s --type %s --input '{}'\n", s.TemporalTaskQueue, orchestrator.OrchestratorWorkflowType)
fmt.Printf(" temporal workflow start --workflow-id %s --task-queue %s --type %s --input '{}'\n", orchestrator.ActiveScanWorkflowID, s.TemporalTaskQueue, orchestrator.OrchestratorWorkflowType)
fmt.Println("\n📖 For more information, see the README.md")
fmt.Println("\nPress Ctrl+C to stop...")

Expand Down
12 changes: 12 additions & 0 deletions pkg/scan/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package scan

import (
"encoding/json"
"errors"
"fmt"
"net/http"

"go.temporal.io/api/serviceerror"

"github.com/block/Version-Guard/pkg/telemetry"
"github.com/block/Version-Guard/pkg/types"
)
Expand Down Expand Up @@ -65,6 +68,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Source: telemetry.ScanSourceHTTP,
})
if err != nil {
if isScanAlreadyRunning(err) {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
Expand All @@ -83,3 +90,8 @@ func writeJSON(w http.ResponseWriter, status int, body interface{}) {
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

func isScanAlreadyRunning(err error) bool {
var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
return errors.As(err, &alreadyStarted)
}
13 changes: 13 additions & 0 deletions pkg/scan/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.temporal.io/api/serviceerror"

"github.com/block/Version-Guard/pkg/types"
"github.com/block/Version-Guard/pkg/workflow/orchestrator"
Expand Down Expand Up @@ -122,3 +123,15 @@ func TestHandler_TriggerError_Returns500(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, rec.Body.String(), "temporal unavailable")
}

func TestHandler_ScanAlreadyRunning_Returns409(t *testing.T) {
mock := &mockStarter{err: serviceerror.NewWorkflowExecutionAlreadyStarted("already running", "request", "run")}
h := newTestHandler(t, mock)

req := httptest.NewRequest(http.MethodPost, "/scan", http.NoBody)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

assert.Equal(t, http.StatusConflict, rec.Code)
assert.Contains(t, rec.Body.String(), "already running")
}
18 changes: 10 additions & 8 deletions pkg/scan/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/google/uuid"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"

"github.com/block/Version-Guard/pkg/telemetry"
Expand Down Expand Up @@ -147,9 +148,11 @@ func (t *Trigger) Run(ctx context.Context, in Input) (res Result, err error) {
workflowID = buildWorkflowID(scanID)

opts := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: t.taskQueue,
WorkflowExecutionTimeout: defaultExecutionTimeout,
ID: workflowID,
TaskQueue: t.taskQueue,
WorkflowExecutionTimeout: defaultExecutionTimeout,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_FAIL,
WorkflowExecutionErrorWhenAlreadyStarted: true,
}

run, err := t.starter.ExecuteWorkflow(ctx, opts, orchestrator.OrchestratorWorkflow, orchestrator.WorkflowInput{
Expand All @@ -171,9 +174,8 @@ func (t *Trigger) Run(ctx context.Context, in Input) (res Result, err error) {
}, nil
}

// buildWorkflowID produces a workflow ID that is distinguishable from
// scheduled executions. Scheduled runs use the schedule's generated IDs;
// manual runs are prefixed so they are easy to find in Temporal UI/CLI.
func buildWorkflowID(scanID string) string {
return fmt.Sprintf("version-guard-scan-%s", scanID)
// buildWorkflowID returns the singleton orchestrator workflow ID. Temporal
// rejects a new run with this ID while the previous scan is still open.
func buildWorkflowID(_ string) string {
return orchestrator.ActiveScanWorkflowID
}
13 changes: 7 additions & 6 deletions pkg/scan/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package scan
import (
"context"
"errors"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"

"github.com/block/Version-Guard/pkg/types"
Expand Down Expand Up @@ -50,21 +50,23 @@ func (m *mockStarter) ExecuteWorkflow(_ context.Context, options client.StartWor

func TestTrigger_Run_FullScan(t *testing.T) {
mock := &mockStarter{
run: &mockWorkflowRun{id: "version-guard-scan-abc", runID: "run-1"},
run: &mockWorkflowRun{id: orchestrator.ActiveScanWorkflowID, runID: "run-1"},
}
defaults := []types.ResourceType{"aurora-mysql", "eks"}
tr := NewTriggerWithStarter(mock, "version-guard-orchestrator", defaults)

res, err := tr.Run(context.Background(), Input{ScanID: "abc"})

require.NoError(t, err)
assert.Equal(t, "version-guard-scan-abc", res.WorkflowID)
assert.Equal(t, orchestrator.ActiveScanWorkflowID, res.WorkflowID)
assert.Equal(t, "run-1", res.RunID)
assert.Equal(t, "abc", res.ScanID)

require.True(t, mock.called)
assert.Equal(t, "version-guard-scan-abc", mock.calledOpts.ID)
assert.Equal(t, orchestrator.ActiveScanWorkflowID, mock.calledOpts.ID)
assert.Equal(t, "version-guard-orchestrator", mock.calledOpts.TaskQueue)
assert.Equal(t, enumspb.WORKFLOW_ID_CONFLICT_POLICY_FAIL, mock.calledOpts.WorkflowIDConflictPolicy)
assert.True(t, mock.calledOpts.WorkflowExecutionErrorWhenAlreadyStarted)

require.Len(t, mock.calledArgs, 1)
in, ok := mock.calledArgs[0].(orchestrator.WorkflowInput)
Expand Down Expand Up @@ -115,8 +117,7 @@ func TestTrigger_Run_GeneratesScanIDWhenEmpty(t *testing.T) {

require.NoError(t, err)
assert.NotEmpty(t, res.ScanID, "ScanID should be generated when not provided")
assert.True(t, strings.HasPrefix(mock.calledOpts.ID, "version-guard-scan-"),
"workflow ID should be prefixed")
assert.Equal(t, orchestrator.ActiveScanWorkflowID, mock.calledOpts.ID)
in := mock.calledArgs[0].(orchestrator.WorkflowInput)
assert.Equal(t, res.ScanID, in.ScanID, "generated ScanID should be passed to workflow")
}
Expand Down
16 changes: 14 additions & 2 deletions pkg/schedule/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"time"

enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"

Expand Down Expand Up @@ -77,6 +78,7 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
Jitter: cfg.Jitter,
},
Action: &client.ScheduleWorkflowAction{
ID: orchestrator.ActiveScanWorkflowID,
Workflow: orchestrator.OrchestratorWorkflow,
Args: []interface{}{orchestrator.WorkflowInput{
ResourceTypes: cfg.ResourceTypes,
Expand All @@ -86,7 +88,8 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
TaskQueue: cfg.TaskQueue,
WorkflowExecutionTimeout: 2 * time.Hour,
},
Paused: cfg.Paused,
Overlap: enumspb.SCHEDULE_OVERLAP_POLICY_SKIP,
Paused: cfg.Paused,
}

_, err := m.scheduleClient.Create(ctx, opts)
Expand Down Expand Up @@ -117,8 +120,9 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
}
existingCrons := existingSpec.CronExpressions
specMatches := len(existingCrons) == 1 && existingCrons[0] == cfg.CronExpression && existingSpec.Jitter == cfg.Jitter
overlapMatches := desc.Schedule.Policy != nil && desc.Schedule.Policy.Overlap == enumspb.SCHEDULE_OVERLAP_POLICY_SKIP
actionMatches := scheduleActionMatches(desc.Schedule.Action, &cfg)
if specMatches && actionMatches {
if specMatches && overlapMatches && actionMatches {
fmt.Printf(" Schedule %q already configured (cron: %s)\n", cfg.ScheduleID, cfg.CronExpression)
return nil
}
Expand All @@ -140,7 +144,12 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
CronExpressions: []string{cfg.CronExpression},
Jitter: cfg.Jitter,
}
if input.Description.Schedule.Policy == nil {
input.Description.Schedule.Policy = &client.SchedulePolicies{}
}
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.TaskQueue = cfg.TaskQueue
action.Args = []interface{}{orchestrator.WorkflowInput{
ResourceTypes: cfg.ResourceTypes,
Expand Down Expand Up @@ -175,6 +184,9 @@ func scheduleActionMatches(action client.ScheduleAction, cfg *Config) bool {
if !ok {
return false
}
if wfAction.ID != orchestrator.ActiveScanWorkflowID {
return false
}
if wfAction.TaskQueue != cfg.TaskQueue {
return false
}
Expand Down
8 changes: 8 additions & 0 deletions pkg/schedule/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"

Expand Down Expand Up @@ -129,7 +130,9 @@ func TestEnsureSchedule_CreatesNew(t *testing.T) {
assert.Equal(t, "test-schedule", mock.createOpts.ID)
assert.Equal(t, []string{"0 */6 * * *"}, mock.createOpts.Spec.CronExpressions)
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, "test-queue", action.TaskQueue)
assert.Equal(t, 2*time.Hour, action.WorkflowExecutionTimeout)
require.Len(t, action.Args, 1)
Expand All @@ -143,11 +146,13 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) {
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,
TaskQueue: "test-queue",
Args: []interface{}{orchestrator.WorkflowInput{
ResourceTypes: testResourceTypes,
Expand Down Expand Up @@ -227,8 +232,11 @@ func TestEnsureSchedule_AlreadyExists_NewWebhookURL(t *testing.T) {
require.NoError(t, err)
assert.True(t, handle.updateCalled, "Update must be called when EmitterWebhookURL changes")
require.NotNil(t, captured)
require.NotNil(t, captured.Schedule.Policy)
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)
require.Len(t, action.Args, 1)
in, ok := action.Args[0].(orchestrator.WorkflowInput)
require.True(t, ok)
Expand Down
16 changes: 13 additions & 3 deletions pkg/store/memory/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,27 @@ func (s *Store) GetSummary(ctx context.Context, filters store.FindingFilters) (*
return summary, nil
}

// DeleteStaleFindings removes findings older than the specified time
func (s *Store) DeleteStaleFindings(ctx context.Context, resourceType types.ResourceType, olderThan time.Time) error {
// ReplaceFindings atomically replaces all findings of resourceType with
// the given set, evicting entries absent from it
func (s *Store) ReplaceFindings(ctx context.Context, resourceType types.ResourceType, findings []*types.Finding) error {
s.mu.Lock()
defer s.mu.Unlock()

for id, finding := range s.findings {
if finding.ResourceType == resourceType && finding.UpdatedAt.Before(olderThan) {
if finding.ResourceType == resourceType {
delete(s.findings, id)
}
}

now := time.Now()
for _, finding := range findings {
finding.UpdatedAt = now
if finding.DetectedAt.IsZero() {
finding.DetectedAt = now
}
s.findings[finding.ResourceID] = finding
}

return nil
}

Expand Down
54 changes: 27 additions & 27 deletions pkg/store/memory/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,45 +203,45 @@ func TestStore_GetSummary(t *testing.T) {
assert.False(t, summary.LastScanTime.IsZero())
}

func TestStore_DeleteStaleFindings(t *testing.T) {
func TestStore_ReplaceFindings(t *testing.T) {
ctx := context.Background()
s := NewStore()

oldTime := time.Now().Add(-48 * time.Hour)
recentTime := time.Now().Add(-1 * time.Hour)

findings := []*types.Finding{
{ResourceID: "1", ResourceType: types.ResourceTypeAurora, UpdatedAt: oldTime},
{ResourceID: "2", ResourceType: types.ResourceTypeAurora, UpdatedAt: recentTime},
{ResourceID: "3", ResourceType: types.ResourceTypeElastiCache, UpdatedAt: oldTime},
}

// Manually set UpdatedAt (SaveFindings would overwrite it)
s.mu.Lock()
for _, f := range findings {
s.findings[f.ResourceID] = f
}
s.mu.Unlock()

// Delete Aurora findings older than 24 hours ago
cutoff := time.Now().Add(-24 * time.Hour)
err := s.DeleteStaleFindings(ctx, types.ResourceTypeAurora, cutoff)
err := s.SaveFindings(ctx, []*types.Finding{
{ResourceID: "1", ResourceType: types.ResourceTypeAurora},
{ResourceID: "2", ResourceType: types.ResourceTypeAurora},
{ResourceID: "3", ResourceType: types.ResourceTypeElastiCache},
})
require.NoError(t, err)

// Should have 2 findings left (1 recent Aurora + 1 old ElastiCache)
assert.Len(t, s.findings, 2)
// Replace Aurora's set: "2" survives (re-listed), "1" is evicted,
// "4" is new, and the ElastiCache finding is untouched.
err = s.ReplaceFindings(ctx, types.ResourceTypeAurora, []*types.Finding{
{ResourceID: "2", ResourceType: types.ResourceTypeAurora},
{ResourceID: "4", ResourceType: types.ResourceTypeAurora},
})
require.NoError(t, err)

// Old Aurora finding should be deleted
assert.Len(t, s.findings, 3)
_, exists := s.findings["1"]
assert.False(t, exists)

// Recent Aurora finding should remain
_, exists = s.findings["2"]
assert.True(t, exists)

// Old ElastiCache finding should remain (different resource type)
_, exists = s.findings["4"]
assert.True(t, exists)
_, exists = s.findings["3"]
assert.True(t, exists)

// UpdatedAt is stamped on the replacement set
assert.False(t, s.findings["4"].UpdatedAt.IsZero())

// An empty replacement set empties the type
err = s.ReplaceFindings(ctx, types.ResourceTypeAurora, nil)
require.NoError(t, err)
aurora := types.ResourceTypeAurora
remaining, err := s.ListFindings(ctx, store.FindingFilters{ResourceType: &aurora})
require.NoError(t, err)
assert.Empty(t, remaining)
}

func TestStore_UpdateExistingFinding(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions pkg/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type Store interface {
// GetSummary calculates aggregate statistics for findings
GetSummary(ctx context.Context, filters FindingFilters) (*ScanSummary, error)

// DeleteStaleFindings removes findings older than the specified time
DeleteStaleFindings(ctx context.Context, resourceType types.ResourceType, olderThan time.Time) error
// ReplaceFindings atomically replaces all findings of resourceType
// with the given set, evicting entries absent from it
ReplaceFindings(ctx context.Context, resourceType types.ResourceType, findings []*types.Finding) error
}

// FindingFilters defines optional filters for querying findings.
Expand Down
Loading
Loading