From 92ae7643db1c703a383d9866e5f742778714232d Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 1 May 2026 09:23:12 -0400 Subject: [PATCH 1/2] feat: opa plan validations in workspace engine --- apps/workspace-engine/oapi/openapi.json | 71 +++++++++ apps/workspace-engine/oapi/spec/main.jsonnet | 3 +- .../oapi/spec/schemas/plan_validation.jsonnet | 49 ++++++ .../oapi/spec/schemas/policy.jsonnet | 1 + apps/workspace-engine/pkg/db/models.go | 18 +++ .../pkg/db/plan_validation.sql.go | 128 +++++++++++++++ .../pkg/db/queries/plan_validation.sql | 37 +++++ .../pkg/db/queries/schema.sql | 19 +++ apps/workspace-engine/pkg/db/sqlc.yaml | 6 + apps/workspace-engine/pkg/oapi/oapi.gen.go | 31 ++++ .../deploymentplanresult/controller_test.go | 23 +++ .../deploymentplanresult/getters.go | 13 ++ .../deploymentplanresult/getters_postgres.go | 67 ++++++++ .../deploymentplanresult/setters.go | 4 + .../deploymentplanresult/setters_postgres.go | 7 + .../deploymentplanresult/validation.go | 148 ++++++++++++++++++ 16 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 apps/workspace-engine/oapi/spec/schemas/plan_validation.jsonnet create mode 100644 apps/workspace-engine/pkg/db/plan_validation.sql.go create mode 100644 apps/workspace-engine/pkg/db/queries/plan_validation.sql create mode 100644 apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index df435a167..2edb0fc1b 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -1195,6 +1195,74 @@ ], "type": "object" }, + "PlanValidationOpaRule": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "description": "Human-readable rule name; used in check output to identify which rule produced a violation.", + "type": "string" + }, + "rego": { + "description": "Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }).", + "type": "string" + } + }, + "required": [ + "name", + "rego" + ], + "type": "object" + }, + "PlanValidationResult": { + "properties": { + "evaluatedAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "passed": { + "type": "boolean" + }, + "resultId": { + "description": "ID of the deployment_plan_target_result this validation was run against.", + "type": "string" + }, + "ruleId": { + "description": "Polymorphic rule id. Resolves to a specific rule type (e.g. PlanValidationOpaRule) known by the writing controller.", + "type": "string" + }, + "violations": { + "items": { + "$ref": "#/components/schemas/PlanValidationViolation" + }, + "type": "array" + } + }, + "required": [ + "id", + "resultId", + "ruleId", + "passed", + "violations", + "evaluatedAt" + ], + "type": "object" + }, + "PlanValidationViolation": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, "Policy": { "properties": { "createdAt": { @@ -1293,6 +1361,9 @@ "id": { "type": "string" }, + "planValidationOpa": { + "$ref": "#/components/schemas/PlanValidationOpaRule" + }, "policyId": { "type": "string" }, diff --git a/apps/workspace-engine/oapi/spec/main.jsonnet b/apps/workspace-engine/oapi/spec/main.jsonnet index 8ceeb705e..cfa96ad44 100644 --- a/apps/workspace-engine/oapi/spec/main.jsonnet +++ b/apps/workspace-engine/oapi/spec/main.jsonnet @@ -32,6 +32,7 @@ (import 'schemas/systems.jsonnet') + (import 'schemas/workflows.jsonnet') + (import 'schemas/release_targets.jsonnet') + - (import 'schemas/variablesets.jsonnet'), + (import 'schemas/variablesets.jsonnet') + + (import 'schemas/plan_validation.jsonnet'), }, } diff --git a/apps/workspace-engine/oapi/spec/schemas/plan_validation.jsonnet b/apps/workspace-engine/oapi/spec/schemas/plan_validation.jsonnet new file mode 100644 index 000000000..445ef8823 --- /dev/null +++ b/apps/workspace-engine/oapi/spec/schemas/plan_validation.jsonnet @@ -0,0 +1,49 @@ +local openapi = import '../lib/openapi.libsonnet'; + +{ + PlanValidationOpaRule: { + type: 'object', + required: ['name', 'rego'], + properties: { + name: { + type: 'string', + description: 'Human-readable rule name; used in check output to identify which rule produced a violation.', + }, + description: { type: 'string' }, + rego: { + type: 'string', + description: 'Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }).', + }, + }, + }, + + PlanValidationResult: { + type: 'object', + required: ['id', 'resultId', 'ruleId', 'passed', 'violations', 'evaluatedAt'], + properties: { + id: { type: 'string' }, + resultId: { + type: 'string', + description: 'ID of the deployment_plan_target_result this validation was run against.', + }, + ruleId: { + type: 'string', + description: 'Polymorphic rule id. Resolves to a specific rule type (e.g. PlanValidationOpaRule) known by the writing controller.', + }, + passed: { type: 'boolean' }, + violations: { + type: 'array', + items: openapi.schemaRef('PlanValidationViolation'), + }, + evaluatedAt: { type: 'string', format: 'date-time' }, + }, + }, + + PlanValidationViolation: { + type: 'object', + required: ['message'], + properties: { + message: { type: 'string' }, + }, + }, +} diff --git a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet index b37166890..79360b04a 100644 --- a/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/policy.jsonnet @@ -55,6 +55,7 @@ local openapi = import '../lib/openapi.libsonnet'; verification: openapi.schemaRef('VerificationRule'), versionCooldown: openapi.schemaRef('VersionCooldownRule'), rollback: openapi.schemaRef('RollbackRule'), + planValidationOpa: openapi.schemaRef('PlanValidationOpaRule'), }, }, diff --git a/apps/workspace-engine/pkg/db/models.go b/apps/workspace-engine/pkg/db/models.go index 884eb2d17..d6d9144f7 100644 --- a/apps/workspace-engine/pkg/db/models.go +++ b/apps/workspace-engine/pkg/db/models.go @@ -458,6 +458,15 @@ type DeploymentPlanTargetResult struct { CompletedAt pgtype.Timestamptz } +type DeploymentPlanTargetResultValidation struct { + ID uuid.UUID + ResultID uuid.UUID + RuleID uuid.UUID + Passed bool + Violations []byte + EvaluatedAt pgtype.Timestamptz +} + type DeploymentVersion struct { ID uuid.UUID Name string @@ -624,6 +633,15 @@ type PolicyRuleJobVerificationMetric struct { FailureThreshold pgtype.Int4 } +type PolicyRulePlanValidationOpa struct { + ID uuid.UUID + PolicyID uuid.UUID + Name string + Description pgtype.Text + Rego string + CreatedAt pgtype.Timestamptz +} + type PolicyRuleRetry struct { ID uuid.UUID PolicyID uuid.UUID diff --git a/apps/workspace-engine/pkg/db/plan_validation.sql.go b/apps/workspace-engine/pkg/db/plan_validation.sql.go new file mode 100644 index 000000000..59ccad114 --- /dev/null +++ b/apps/workspace-engine/pkg/db/plan_validation.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: plan_validation.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const getCurrentVersionForPlanTarget = `-- name: GetCurrentVersionForPlanTarget :one +SELECT dv.id, dv.name, dv.tag, dv.config, dv.job_agent_config, dv.deployment_id, dv.metadata, dv.status, dv.message, dv.created_at, dv.workspace_id +FROM deployment_plan_target t +JOIN release rel ON rel.id = t.current_release_id +JOIN deployment_version dv ON dv.id = rel.version_id +WHERE t.id = $1 +` + +func (q *Queries) GetCurrentVersionForPlanTarget(ctx context.Context, id uuid.UUID) (DeploymentVersion, error) { + row := q.db.QueryRow(ctx, getCurrentVersionForPlanTarget, id) + var i DeploymentVersion + err := row.Scan( + &i.ID, + &i.Name, + &i.Tag, + &i.Config, + &i.JobAgentConfig, + &i.DeploymentID, + &i.Metadata, + &i.Status, + &i.Message, + &i.CreatedAt, + &i.WorkspaceID, + ) + return i, err +} + +const listPlanValidationOpaRulesForWorkspace = `-- name: ListPlanValidationOpaRulesForWorkspace :many +SELECT + r.id, + r.policy_id, + r.name, + r.description, + r.rego, + r.created_at, + p.selector AS policy_selector +FROM policy_rule_plan_validation_opa r +JOIN policy p ON p.id = r.policy_id +WHERE p.workspace_id = $1 + AND p.enabled = true +ORDER BY p.priority DESC, r.created_at DESC +` + +type ListPlanValidationOpaRulesForWorkspaceRow struct { + ID uuid.UUID + PolicyID uuid.UUID + Name string + Description pgtype.Text + Rego string + CreatedAt pgtype.Timestamptz + PolicySelector string +} + +func (q *Queries) ListPlanValidationOpaRulesForWorkspace(ctx context.Context, workspaceID uuid.UUID) ([]ListPlanValidationOpaRulesForWorkspaceRow, error) { + rows, err := q.db.Query(ctx, listPlanValidationOpaRulesForWorkspace, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlanValidationOpaRulesForWorkspaceRow + for rows.Next() { + var i ListPlanValidationOpaRulesForWorkspaceRow + if err := rows.Scan( + &i.ID, + &i.PolicyID, + &i.Name, + &i.Description, + &i.Rego, + &i.CreatedAt, + &i.PolicySelector, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPlanValidationResult = `-- name: UpsertPlanValidationResult :exec +INSERT INTO deployment_plan_target_result_validation ( + result_id, rule_id, passed, violations, evaluated_at +) +VALUES ( + $1, + $2, + $3, + $4, + NOW() +) +ON CONFLICT (result_id, rule_id) DO UPDATE +SET passed = EXCLUDED.passed, + violations = EXCLUDED.violations, + evaluated_at = EXCLUDED.evaluated_at +` + +type UpsertPlanValidationResultParams struct { + ResultID uuid.UUID + RuleID uuid.UUID + Passed bool + Violations []byte +} + +func (q *Queries) UpsertPlanValidationResult(ctx context.Context, arg UpsertPlanValidationResultParams) error { + _, err := q.db.Exec(ctx, upsertPlanValidationResult, + arg.ResultID, + arg.RuleID, + arg.Passed, + arg.Violations, + ) + return err +} diff --git a/apps/workspace-engine/pkg/db/queries/plan_validation.sql b/apps/workspace-engine/pkg/db/queries/plan_validation.sql new file mode 100644 index 000000000..6aa0b21b0 --- /dev/null +++ b/apps/workspace-engine/pkg/db/queries/plan_validation.sql @@ -0,0 +1,37 @@ +-- name: ListPlanValidationOpaRulesForWorkspace :many +SELECT + r.id, + r.policy_id, + r.name, + r.description, + r.rego, + r.created_at, + p.selector AS policy_selector +FROM policy_rule_plan_validation_opa r +JOIN policy p ON p.id = r.policy_id +WHERE p.workspace_id = $1 + AND p.enabled = true +ORDER BY p.priority DESC, r.created_at DESC; + +-- name: GetCurrentVersionForPlanTarget :one +SELECT dv.* +FROM deployment_plan_target t +JOIN release rel ON rel.id = t.current_release_id +JOIN deployment_version dv ON dv.id = rel.version_id +WHERE t.id = $1; + +-- name: UpsertPlanValidationResult :exec +INSERT INTO deployment_plan_target_result_validation ( + result_id, rule_id, passed, violations, evaluated_at +) +VALUES ( + sqlc.arg('result_id'), + sqlc.arg('rule_id'), + sqlc.arg('passed'), + sqlc.arg('violations'), + NOW() +) +ON CONFLICT (result_id, rule_id) DO UPDATE +SET passed = EXCLUDED.passed, + violations = EXCLUDED.violations, + evaluated_at = EXCLUDED.evaluated_at; diff --git a/apps/workspace-engine/pkg/db/queries/schema.sql b/apps/workspace-engine/pkg/db/queries/schema.sql index 68a235b1a..bbfe1224b 100644 --- a/apps/workspace-engine/pkg/db/queries/schema.sql +++ b/apps/workspace-engine/pkg/db/queries/schema.sql @@ -527,4 +527,23 @@ CREATE TABLE variable_set_variable ( key TEXT NOT NULL, value JSONB NOT NULL, UNIQUE (variable_set_id, key) +); + +CREATE TABLE policy_rule_plan_validation_opa ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + policy_id UUID NOT NULL REFERENCES policy(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + rego TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE deployment_plan_target_result_validation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + result_id UUID NOT NULL REFERENCES deployment_plan_target_result(id) ON DELETE CASCADE, + rule_id UUID NOT NULL, + passed BOOLEAN NOT NULL, + violations JSONB NOT NULL DEFAULT '[]'::jsonb, + evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (result_id, rule_id) ); \ No newline at end of file diff --git a/apps/workspace-engine/pkg/db/sqlc.yaml b/apps/workspace-engine/pkg/db/sqlc.yaml index 849da6811..56670b9fd 100644 --- a/apps/workspace-engine/pkg/db/sqlc.yaml +++ b/apps/workspace-engine/pkg/db/sqlc.yaml @@ -32,6 +32,7 @@ sql: - queries/deployment_plan.sql - queries/release_targets.sql - queries/variable_sets.sql + - queries/plan_validation.sql database: uri: "postgresql://ctrlplane:ctrlplane@127.0.0.1:5432/ctrlplane?sslmode=disable" gen: @@ -205,3 +206,8 @@ sql: - column: "deployment_plan_target_result.agent_state" go_type: type: "[]byte" + + # DeploymentPlanTargetResultValidation + - column: "deployment_plan_target_result_validation.violations" + go_type: + type: "[]byte" diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index a56502d7e..18289aa24 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -674,6 +674,36 @@ type ObjectValue struct { Object map[string]interface{} `json:"object"` } +// PlanValidationOpaRule defines model for PlanValidationOpaRule. +type PlanValidationOpaRule struct { + Description *string `json:"description,omitempty"` + + // Name Human-readable rule name; used in check output to identify which rule produced a violation. + Name string `json:"name"` + + // Rego Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }). + Rego string `json:"rego"` +} + +// PlanValidationResult defines model for PlanValidationResult. +type PlanValidationResult struct { + EvaluatedAt time.Time `json:"evaluatedAt"` + Id string `json:"id"` + Passed bool `json:"passed"` + + // ResultId ID of the deployment_plan_target_result this validation was run against. + ResultId string `json:"resultId"` + + // RuleId Polymorphic rule id. Resolves to a specific rule type (e.g. PlanValidationOpaRule) known by the writing controller. + RuleId string `json:"ruleId"` + Violations []PlanValidationViolation `json:"violations"` +} + +// PlanValidationViolation defines model for PlanValidationViolation. +type PlanValidationViolation struct { + Message string `json:"message"` +} + // Policy defines model for Policy. type Policy struct { CreatedAt string `json:"createdAt"` @@ -708,6 +738,7 @@ type PolicyRule struct { EnvironmentProgression *EnvironmentProgressionRule `json:"environmentProgression,omitempty"` GradualRollout *GradualRolloutRule `json:"gradualRollout,omitempty"` Id string `json:"id"` + PlanValidationOpa *PlanValidationOpaRule `json:"planValidationOpa,omitempty"` PolicyId string `json:"policyId"` Retry *RetryRule `json:"retry,omitempty"` Rollback *RollbackRule `json:"rollback,omitempty"` diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go index 8b978e765..95c2047cb 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go @@ -15,6 +15,7 @@ import ( "workspace-engine/pkg/jobagents" "workspace-engine/pkg/jobagents/types" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/policies/match" "workspace-engine/pkg/reconcile" ) @@ -67,6 +68,21 @@ func (m *mockGetter) ListDeploymentPlanTargetResultsByTargetID( return nil, nil } +func (m *mockGetter) GetMatchingPlanValidationOpaRules( + _ context.Context, + _ uuid.UUID, + _ *match.Target, +) ([]oapi.PolicyRule, error) { + return nil, nil +} + +func (m *mockGetter) GetCurrentVersionForPlanTarget( + _ context.Context, + _ uuid.UUID, +) (*oapi.DeploymentVersion, error) { + return nil, nil +} + type completedCall struct { ID uuid.UUID Status db.DeploymentPlanTargetStatus @@ -109,6 +125,13 @@ func (m *mockSetter) UpdateDeploymentPlanTargetResultState( return m.stateErr } +func (m *mockSetter) UpsertPlanValidationResult( + _ context.Context, + _ db.UpsertPlanValidationResultParams, +) error { + return nil +} + // --- helpers --- func testRegistry(agents ...*mockAgent) *jobagents.Registry { diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go index 1eb469b80..2a9c82d9c 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters.go @@ -5,6 +5,8 @@ import ( "github.com/google/uuid" "workspace-engine/pkg/db" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/policies/match" ) // Getter abstracts read operations needed by the plan result controller. @@ -23,4 +25,15 @@ type Getter interface { ctx context.Context, targetID uuid.UUID, ) ([]db.ListDeploymentPlanTargetResultsByTargetIDRow, error) + + GetMatchingPlanValidationOpaRules( + ctx context.Context, + workspaceID uuid.UUID, + target *match.Target, + ) ([]oapi.PolicyRule, error) + + GetCurrentVersionForPlanTarget( + ctx context.Context, + planTargetID uuid.UUID, + ) (*oapi.DeploymentVersion, error) } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go index 056e255e4..b4771a611 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/getters_postgres.go @@ -2,13 +2,19 @@ package deploymentplanresult import ( "context" + "errors" + "fmt" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "workspace-engine/pkg/db" "workspace-engine/pkg/jobagents" "workspace-engine/pkg/jobagents/argo" "workspace-engine/pkg/jobagents/terraformcloud" "workspace-engine/pkg/jobagents/testrunner" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/policies/match" ) type PostgresGetter struct{} @@ -34,6 +40,67 @@ func (g *PostgresGetter) ListDeploymentPlanTargetResultsByTargetID( return db.GetQueries(ctx).ListDeploymentPlanTargetResultsByTargetID(ctx, targetID) } +func (g *PostgresGetter) GetCurrentVersionForPlanTarget( + ctx context.Context, + planTargetID uuid.UUID, +) (*oapi.DeploymentVersion, error) { + row, err := db.GetQueries(ctx).GetCurrentVersionForPlanTarget(ctx, planTargetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get current version for plan target: %w", err) + } + return db.ToOapiDeploymentVersion(row), nil +} + +func (g *PostgresGetter) GetMatchingPlanValidationOpaRules( + ctx context.Context, + workspaceID uuid.UUID, + target *match.Target, +) ([]oapi.PolicyRule, error) { + rows, err := db.GetQueries(ctx).ListPlanValidationOpaRulesForWorkspace(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("list plan validation opa rules: %w", err) + } + if len(rows) == 0 { + return nil, nil + } + + selectorMatches := make(map[string]bool, len(rows)) + matched := make([]oapi.PolicyRule, 0, len(rows)) + for _, row := range rows { + if _, seen := selectorMatches[row.PolicySelector]; !seen { + selectorMatches[row.PolicySelector] = match.Match( + ctx, + &oapi.Policy{Selector: row.PolicySelector}, + target, + ) + } + if !selectorMatches[row.PolicySelector] { + continue + } + + var description *string + if row.Description.Valid { + d := row.Description.String + description = &d + } + + matched = append(matched, oapi.PolicyRule{ + Id: row.ID.String(), + PolicyId: row.PolicyID.String(), + CreatedAt: row.CreatedAt.Time.Format(time.RFC3339), + PlanValidationOpa: &oapi.PlanValidationOpaRule{ + Name: row.Name, + Description: description, + Rego: row.Rego, + }, + }) + } + return matched, nil +} + func newRegistry() *jobagents.Registry { registry := jobagents.NewRegistry(nil, nil) registry.Register( diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/setters.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/setters.go index 034c17ffa..bee9580f3 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/setters.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/setters.go @@ -16,4 +16,8 @@ type Setter interface { ctx context.Context, arg db.UpdateDeploymentPlanTargetResultStateParams, ) error + UpsertPlanValidationResult( + ctx context.Context, + arg db.UpsertPlanValidationResultParams, + ) error } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/setters_postgres.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/setters_postgres.go index dda4d75b3..51e7b847e 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/setters_postgres.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/setters_postgres.go @@ -21,3 +21,10 @@ func (s *PostgresSetter) UpdateDeploymentPlanTargetResultState( ) error { return db.GetQueries(ctx).UpdateDeploymentPlanTargetResultState(ctx, arg) } + +func (s *PostgresSetter) UpsertPlanValidationResult( + ctx context.Context, + arg db.UpsertPlanValidationResultParams, +) error { + return db.GetQueries(ctx).UpsertPlanValidationResult(ctx, arg) +} diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go new file mode 100644 index 000000000..f32da65b4 --- /dev/null +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go @@ -0,0 +1,148 @@ +package deploymentplanresult + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "workspace-engine/pkg/db" + "workspace-engine/pkg/jobagents/types" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/planvalidation" + "workspace-engine/pkg/policies/match" +) + +func RunPlanValidation( + ctx context.Context, + getter Getter, + setter Setter, + result db.DeploymentPlanTargetResult, + planResult *types.PlanResult, + dispatchCtx oapi.DispatchContext, +) error { + ctx, span := tracer.Start(ctx, "RunPlanValidation") + defer span.End() + + span.SetAttributes(attribute.String("result_id", result.ID.String())) + + target := &match.Target{ + Deployment: dispatchCtx.Deployment, + Environment: dispatchCtx.Environment, + Resource: dispatchCtx.Resource, + } + + workspaceID, err := uuid.Parse(dispatchCtx.Environment.WorkspaceId) + if err != nil { + return fmt.Errorf("parse workspace id: %w", err) + } + + rules, err := getter.GetMatchingPlanValidationOpaRules(ctx, workspaceID, target) + if err != nil { + return fmt.Errorf("get matching opa rules: %w", err) + } + + span.SetAttributes(attribute.Int("rules.count", len(rules))) + + if len(rules) == 0 { + return nil + } + + input, err := buildOpaInput(ctx, getter, result.TargetID, planResult, dispatchCtx) + if err != nil { + return fmt.Errorf("build opa input: %w", err) + } + + results, err := evaluateRules(ctx, rules, input) + if err != nil { + return fmt.Errorf("evaluate rules: %w", err) + } + + span.SetAttributes(attribute.Int("rules.evaluated", len(results))) + + for _, rule := range rules { + res, ok := results[rule.Id] + if !ok { + continue + } + if err := persistResult(ctx, setter, result.ID, rule, res); err != nil { + return fmt.Errorf("persist result for rule %s: %w", rule.Id, err) + } + } + + return nil +} + +func buildOpaInput( + ctx context.Context, + getter Getter, + planTargetID uuid.UUID, + planResult *types.PlanResult, + dispatchCtx oapi.DispatchContext, +) (planvalidation.Input, error) { + currentVersion, err := getter.GetCurrentVersionForPlanTarget(ctx, planTargetID) + if err != nil { + return planvalidation.Input{}, fmt.Errorf("get current version: %w", err) + } + + return planvalidation.Input{ + Current: planResult.Current, + Proposed: planResult.Proposed, + HasChanges: planResult.HasChanges, + AgentType: dispatchCtx.JobAgent.Type, + Deployment: dispatchCtx.Deployment, + Environment: dispatchCtx.Environment, + Resource: dispatchCtx.Resource, + ProposedVersion: dispatchCtx.Version, + CurrentVersion: currentVersion, + }, nil +} + +func evaluateRules( + ctx context.Context, + rules []oapi.PolicyRule, + input planvalidation.Input, +) (map[string]*planvalidation.Result, error) { + results := make(map[string]*planvalidation.Result, len(rules)) + for _, rule := range rules { + if rule.PlanValidationOpa == nil { + continue + } + res, err := planvalidation.Evaluate(ctx, rule.PlanValidationOpa.Rego, input) + if err != nil { + return nil, fmt.Errorf("evaluate rule %s: %w", rule.Id, err) + } + results[rule.Id] = res + } + return results, nil +} + +func persistResult( + ctx context.Context, + setter Setter, + resultID uuid.UUID, + rule oapi.PolicyRule, + res *planvalidation.Result, +) error { + ruleID, err := uuid.Parse(rule.Id) + if err != nil { + return fmt.Errorf("parse rule id: %w", err) + } + + violations := make([]oapi.PlanValidationViolation, len(res.Denials)) + for i, msg := range res.Denials { + violations[i] = oapi.PlanValidationViolation{Message: msg} + } + violationsJSON, err := json.Marshal(violations) + if err != nil { + return fmt.Errorf("marshal violations: %w", err) + } + + return setter.UpsertPlanValidationResult(ctx, db.UpsertPlanValidationResultParams{ + ResultID: resultID, + RuleID: ruleID, + Passed: res.Passed, + Violations: violationsJSON, + }) +} From 17f7971ce07a1af212cad66221fb1270a48d1aaf Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 1 May 2026 10:05:37 -0400 Subject: [PATCH 2/2] fix litn --- .../deploymentplanresult/controller.go | 11 + .../deploymentplanresult/controller_test.go | 17 +- .../deploymentplanresult/validation.go | 8 + .../deploymentplanresult/validation_test.go | 298 ++++++++++++++++++ 4 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 apps/workspace-engine/svc/controllers/deploymentplanresult/validation_test.go diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go index f3d86b303..4b933db7e 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller.go @@ -183,6 +183,17 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, fmt.Errorf("save completed result: %w", err) } + if validationErr := RunPlanValidation( + ctx, + c.getter, + c.setter, + result, + planResult, + dispatchCtx, + ); validationErr != nil { + span.RecordError(validationErr) + } + if checkErr := MaybeUpdateTargetCheck(ctx, c.getter, resultID); checkErr != nil { span.RecordError(checkErr) } diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go index 95c2047cb..79260a8d4 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/controller_test.go @@ -45,6 +45,11 @@ func (m *mockAgent) Plan( type mockGetter struct { result db.DeploymentPlanTargetResult err error + + opaRules []oapi.PolicyRule + opaRulesErr error + currentVersion *oapi.DeploymentVersion + currentVersionErr error } func (m *mockGetter) GetDeploymentPlanTargetResult( @@ -73,14 +78,14 @@ func (m *mockGetter) GetMatchingPlanValidationOpaRules( _ uuid.UUID, _ *match.Target, ) ([]oapi.PolicyRule, error) { - return nil, nil + return m.opaRules, m.opaRulesErr } func (m *mockGetter) GetCurrentVersionForPlanTarget( _ context.Context, _ uuid.UUID, ) (*oapi.DeploymentVersion, error) { - return nil, nil + return m.currentVersion, m.currentVersionErr } type completedCall struct { @@ -100,6 +105,9 @@ type mockSetter struct { stateCalls []stateCall stateErr error + + validationCalls []db.UpsertPlanValidationResultParams + validationErr error } func (m *mockSetter) UpdateDeploymentPlanTargetResultCompleted( @@ -127,9 +135,10 @@ func (m *mockSetter) UpdateDeploymentPlanTargetResultState( func (m *mockSetter) UpsertPlanValidationResult( _ context.Context, - _ db.UpsertPlanValidationResultParams, + arg db.UpsertPlanValidationResultParams, ) error { - return nil + m.validationCalls = append(m.validationCalls, arg) + return m.validationErr } // --- helpers --- diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go index f32da65b4..a02ce5d8b 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation.go @@ -27,6 +27,14 @@ func RunPlanValidation( span.SetAttributes(attribute.String("result_id", result.ID.String())) + if dispatchCtx.Environment == nil || dispatchCtx.Deployment == nil || + dispatchCtx.Resource == nil { + span.AddEvent( + "validation skipped: dispatch context missing environment, deployment, or resource", + ) + return nil + } + target := &match.Target{ Deployment: dispatchCtx.Deployment, Environment: dispatchCtx.Environment, diff --git a/apps/workspace-engine/svc/controllers/deploymentplanresult/validation_test.go b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation_test.go new file mode 100644 index 000000000..80c05300c --- /dev/null +++ b/apps/workspace-engine/svc/controllers/deploymentplanresult/validation_test.go @@ -0,0 +1,298 @@ +package deploymentplanresult + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "workspace-engine/pkg/db" + "workspace-engine/pkg/jobagents/types" + "workspace-engine/pkg/oapi" +) + +func validationFixtures() ( + db.DeploymentPlanTargetResult, + *types.PlanResult, + oapi.DispatchContext, +) { + return db.DeploymentPlanTargetResult{ + ID: uuid.New(), + TargetID: uuid.New(), + }, + &types.PlanResult{ + Current: "old", + Proposed: "new", + HasChanges: true, + }, + oapi.DispatchContext{ + JobAgent: oapi.JobAgent{Type: "argo-cd"}, + Deployment: &oapi.Deployment{ + Id: uuid.New().String(), + Name: "my-deploy", + }, + Environment: &oapi.Environment{ + Id: uuid.New().String(), + Name: "staging", + WorkspaceId: uuid.New().String(), + }, + Resource: &oapi.Resource{ + Id: uuid.New().String(), + Name: "web-app", + }, + Version: &oapi.DeploymentVersion{ + Id: uuid.New().String(), + Tag: "v2.0.0", + }, + } +} + +func opaRule(name, rego string) oapi.PolicyRule { + return oapi.PolicyRule{ + Id: uuid.New().String(), + PolicyId: uuid.New().String(), + PlanValidationOpa: &oapi.PlanValidationOpaRule{ + Name: name, + Rego: rego, + }, + } +} + +func TestRunPlanValidation_MissingDispatchEntities_IsSkipped(t *testing.T) { + rule := opaRule("any", ` +package test +import rego.v1 +deny contains msg if { + msg := "x" +} +`) + result, planResult, _ := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + for _, tc := range []struct { + name string + dispatchCtx oapi.DispatchContext + }{ + {"nil environment", oapi.DispatchContext{ + Deployment: &oapi.Deployment{Id: uuid.New().String()}, + Resource: &oapi.Resource{Id: uuid.New().String()}, + }}, + {"nil deployment", oapi.DispatchContext{ + Environment: &oapi.Environment{Id: uuid.New().String(), WorkspaceId: uuid.New().String()}, + Resource: &oapi.Resource{Id: uuid.New().String()}, + }}, + {"nil resource", oapi.DispatchContext{ + Environment: &oapi.Environment{Id: uuid.New().String(), WorkspaceId: uuid.New().String()}, + Deployment: &oapi.Deployment{Id: uuid.New().String()}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + setter.validationCalls = nil + err := RunPlanValidation( + context.Background(), + getter, + setter, + result, + planResult, + tc.dispatchCtx, + ) + require.NoError(t, err) + assert.Empty(t, setter.validationCalls) + }) + } +} + +func TestRunPlanValidation_NoMatchingRules(t *testing.T) { + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + assert.Empty(t, setter.validationCalls) +} + +func TestRunPlanValidation_PassingRule(t *testing.T) { + rule := opaRule("no-op", ` +package test +import rego.v1 +deny contains msg if { + false + msg := "never" +} +`) + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + require.Len(t, setter.validationCalls, 1) + + call := setter.validationCalls[0] + assert.Equal(t, result.ID, call.ResultID) + assert.True(t, call.Passed) + + var violations []oapi.PlanValidationViolation + require.NoError(t, json.Unmarshal(call.Violations, &violations)) + assert.Empty(t, violations) +} + +func TestRunPlanValidation_FailingRule(t *testing.T) { + rule := opaRule("always-deny", ` +package test +import rego.v1 +deny contains msg if { + msg := "always denied" +} +`) + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + require.Len(t, setter.validationCalls, 1) + + call := setter.validationCalls[0] + assert.False(t, call.Passed) + + var violations []oapi.PlanValidationViolation + require.NoError(t, json.Unmarshal(call.Violations, &violations)) + require.Len(t, violations, 1) + assert.Equal(t, "always denied", violations[0].Message) +} + +func TestRunPlanValidation_RuleReadsEnvironmentName(t *testing.T) { + rule := opaRule("prod-block", ` +package test +import rego.v1 +deny contains msg if { + input.environment.name == "production" + msg := "blocked in prod" +} +`) + result, planResult, dispatchCtx := validationFixtures() + dispatchCtx.Environment.Name = "production" + + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + require.Len(t, setter.validationCalls, 1) + assert.False(t, setter.validationCalls[0].Passed) +} + +func TestRunPlanValidation_RuleReadsProposedString(t *testing.T) { + rule := opaRule("forbid-secret", ` +package test +import rego.v1 +deny contains msg if { + contains(input.proposed, "SECRET") + msg := "proposed contains a secret" +} +`) + result, planResult, dispatchCtx := validationFixtures() + planResult.Proposed = "config: SECRET=foo" + + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + require.Len(t, setter.validationCalls, 1) + assert.False(t, setter.validationCalls[0].Passed) +} + +func TestRunPlanValidation_NilPlanValidationOpa_IsSkipped(t *testing.T) { + rule := oapi.PolicyRule{ + Id: uuid.New().String(), + PolicyId: uuid.New().String(), + PlanValidationOpa: nil, + } + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + assert.Empty(t, setter.validationCalls) +} + +func TestRunPlanValidation_MultipleRules_PersistsEach(t *testing.T) { + pass := opaRule("pass", ` +package a +import rego.v1 +deny contains msg if { + false + msg := "x" +} +`) + fail := opaRule("fail", ` +package b +import rego.v1 +deny contains msg if { + msg := "boom" +} +`) + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{pass, fail}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.NoError(t, err) + require.Len(t, setter.validationCalls, 2) + + byRuleID := map[uuid.UUID]db.UpsertPlanValidationResultParams{} + for _, c := range setter.validationCalls { + byRuleID[c.RuleID] = c + } + + passID := uuid.MustParse(pass.Id) + failID := uuid.MustParse(fail.Id) + assert.True(t, byRuleID[passID].Passed) + assert.False(t, byRuleID[failID].Passed) +} + +func TestRunPlanValidation_GetRulesError(t *testing.T) { + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRulesErr: fmt.Errorf("db unreachable")} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.Error(t, err) + assert.Contains(t, err.Error(), "get matching opa rules") +} + +func TestRunPlanValidation_BadRego_ReturnsError(t *testing.T) { + rule := opaRule("bad", "this is not valid rego at all") + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.Error(t, err) + assert.Empty(t, setter.validationCalls) +} + +func TestRunPlanValidation_UpsertError(t *testing.T) { + rule := opaRule("any", ` +package test +import rego.v1 +deny contains msg if { + msg := "x" +} +`) + result, planResult, dispatchCtx := validationFixtures() + getter := &mockGetter{opaRules: []oapi.PolicyRule{rule}} + setter := &mockSetter{validationErr: fmt.Errorf("upsert failed")} + + err := RunPlanValidation(context.Background(), getter, setter, result, planResult, dispatchCtx) + require.Error(t, err) + assert.Contains(t, err.Error(), "persist result for rule") +}