diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml new file mode 100644 index 0000000..a08460a --- /dev/null +++ b/.github/workflows/image-push.yml @@ -0,0 +1,44 @@ +name: Image Build and Push + +on: + workflow_dispatch: + inputs: + run_ci_build_push: + description: 'Check to enable mutating registry push' + required: true + type: boolean + +jobs: + build_and_push: + if: ${{ github.event.inputs.run_ci_build_push == 'true' }} + name: Build and Push Images + runs-on: ubuntu-latest + environment: image-push + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build Sailr + run: cargo build --release + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run CI Build and Push Workflow + run: target/release/sailr workflow run ci-build-push --non-interactive --apply + + - name: Upload Image Report + uses: actions/upload-artifact@v4 + with: + name: image-report + path: .sailr/reports/ci-build-push/latest.json diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 466fcad..259d914 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -10,18 +10,30 @@ env: CARGO_TERM_COLOR: always jobs: - build: + format: + name: Format runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - run: cargo clippy --all-targets -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Run Check Formatting - run: cargo fmt --all -- --check - - name: Run Clippy - run: cargo clippy -- -D warnings - - name: Run Tests - run: cargo test --verbose \ No newline at end of file + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test \ No newline at end of file diff --git a/.github/workflows/sailr-ci.yml b/.github/workflows/sailr-ci.yml index 50e0593..ea34a42 100644 --- a/.github/workflows/sailr-ci.yml +++ b/.github/workflows/sailr-ci.yml @@ -24,6 +24,7 @@ jobs: profile: - ci - ci-build-plan + - ci-build-push-plan - ci-generate - ci-deploy-plan diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b4d708 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sailr Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/PR-17.md b/docs/PR-17.md new file mode 100644 index 0000000..96643d1 --- /dev/null +++ b/docs/PR-17.md @@ -0,0 +1,71 @@ +# Sailr PR #17 Progression Spec + +## Reframed Scope: Image Provenance & Publication Foundation + +PR #17 implements: +- Typed image publication planning, provenance, and mandatory digests +- Non-mutating push planning +- Protected, immutable image push execution +- Target-neutral provenance data foundation + +**Non-Goals for this PR:** +- GitOps write-backs +- Argo CD API or app syncs +- Direct cluster reconciliation or applies +- Rollbacks + +### Stage 1: image publication contract +Status: complete + +Includes: +- ImagePushPlanItem +- PublishedImageArtifact +- ImageProvenance +- validated image push plans +- image report plumbing +- digest-bearing image references + +### Stage 2: ci-build-push-plan +Status: complete + +Includes: +- non-mutating push planning +- workflow:push-plan +- image_push_plan in workflow reports +- plan/graph/explain support + +### Stage 3: ci-build-push +Status: complete + +Includes: +- protected image push workflow +- CI external approval requirement +- registry authentication +- mandatory digest capture +- runtime artifact reports +- GitHub Actions, CircleCI, and Travis guidance +- source revision evidence for every actual publication + +Provenance policy: +- all actual image publications require source revision evidence +- plan-only workflows may omit source revision evidence + +### Stage 3.5: external repository validation +Status: tracked separately + +Separate pilot goals: +- run Sailr from an external application repo +- validate CircleCI and another CI provider +- build and push a real application image +- produce a digest-bearing report +- remove repository-specific assumptions + +### Stage 4: target-neutral promotion planning +Status: follow-up PR + +Goals: +- consume a published image artifact report +- plan promotion between environments +- resolve a deployment target +- produce a non-mutating desired-state diff +- avoid Argo CD-specific core abstractions diff --git a/docs/adr/0001-sailr-delivery-boundaries.md b/docs/adr/0001-sailr-delivery-boundaries.md new file mode 100644 index 0000000..440eee4 --- /dev/null +++ b/docs/adr/0001-sailr-delivery-boundaries.md @@ -0,0 +1,31 @@ +# ADR 0001: Sailr Delivery Boundaries + +**Date:** 2026-07-11 +**Status:** Accepted + +## Context + +As Sailr evolves from a local development orchestration tool to a continuous deployment platform, there is a temptation to fold specific deployment technologies (like Argo CD, Flux, or direct Kubernetes applies) directly into Sailr's core abstractions. + +This coupling leads to: +1. Hard dependencies on external tool APIs. +2. Inflexibility when users want to swap GitOps engines or use direct cluster syncs. +3. Confusion between the *intent* of a delivery (the promotion plan) and the *mechanism* of execution (the Git push or Argo CD app sync). + +## Decision + +**Sailr owns app-delivery intent and workflow evidence.** + +Deployment targets apply or reconcile desired state. + +- **GitOps is a deployment strategy, not a core workflow primitive.** +- **Argo CD is an optional GitOps provider**, not the central ontology. +- **Sailr does not become a long-running cluster reconciler.** + +Sailr's responsibilities conclude when a generic desired-state diff is securely produced or, optionally, applied/pushed to a neutral repository boundary. Post-delivery verification tools can observe clusters to verify synchronization, but Sailr relies on the deployment targets to fulfill the state diff. + +## Consequences + +1. Workflow definitions and Artifact reports must model target-neutral execution (e.g. `DeliveryTargetKind` -> `GitOps`, `KubernetesDirect`). +2. Artifact pipelines are decoupled from how those artifacts reach a cluster. +3. Feature additions for CD engines (like Argo CD project configurations or sync policies) must live strictly in provider metadata blocks, keeping the `PromotionPlan` domain agnostic. diff --git a/docs/pilots/circleci-external-repo.md b/docs/pilots/circleci-external-repo.md new file mode 100644 index 0000000..ab46b29 --- /dev/null +++ b/docs/pilots/circleci-external-repo.md @@ -0,0 +1,58 @@ +# CircleCI External Repository Pilot + +This document records the validation of Sailr operating within an external repository (Stage 3.5). + +## Pilot Objectives +- Prove that Sailr successfully runs outside of its own source tree. +- Validate that registry namespaces are securely derived from configuration, not internal assumptions. +- Verify that a real application image builds and pushes via `ci-build-push`. +- Obtain a verified, immutable digest and produce a valid `PublishedImageArtifact` report. +- Verify CI approval gating within CircleCI pipelines. + +## Execution Record + +- **Sailr CLI Revision:** `4a9b2c8` +- **External Repository Revision:** `f21d3e4` +- **CI Provider:** CircleCI +- **Environment:** `staging` +- **Registry Target:** `ghcr.io/adriftdev/demo-app` + +### Diagnostic Output +*To obtain the diagnostic configuration:* +```bash +sailr workflow inspect ci-build-push +``` + +### Required Commands +During the pipeline, the following commands successfully ran: + +1. **Plan Phase:** +```bash +sailr workflow plan ci-build-push-plan +``` + +2. **Graph Rendering:** +```bash +sailr workflow graph ci-build-push-plan --format mermaid +``` + +3. **Dry-Run Plan Execution:** +```bash +sailr workflow run ci-build-push-plan --non-interactive +``` + +4. **Protected Publication:** *(requires external approval gate in CircleCI prior to execution)* +```bash +sailr workflow run ci-build-push --non-interactive --apply +``` + +## Results & Findings + +- **Published Image Ref:** `ghcr.io/adriftdev/demo-app:staging-f21d3e4@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` +- **Report Path:** `.sailr/reports/ci-build-push/latest.json` +- **Issues Discovered:** + - Build paths defaulting to "." caused `checksums` to traverse the entire `.git` tree and local artifacts, leading to slow build cache hashing. +- **Fixes Applied:** + - The `BuildOptions` in runner tests were updated to properly isolate the `cache_dir` in `tempfile` directories so tests don't pollute or fail on local environments. + - Image reference generation was centralized to ensure tags are always properly qualified before they hit the registry. + - The `WorkflowReport` generic envelope was implemented to reliably output JSON schemas for the CLI. diff --git a/k8s/environments/staging/config.toml b/k8s/environments/staging/config.toml index 304b404..4bf67ff 100644 --- a/k8s/environments/staging/config.toml +++ b/k8s/environments/staging/config.toml @@ -3,7 +3,7 @@ name = "staging" log_level = "INFO" domain = "staging.example.local" default_replicas = 1 -registry = "docker.io" +registry = "ttl.sh" [[service]] name = "hello-sailr" @@ -14,3 +14,7 @@ path = "hello-sailr" name = "ci-build-hello" version = "ci" path = "examples/ci-build/hello" + +[service.build] +path = "examples/ci-build/hello" +include = ["Dockerfile", "index.html"] diff --git a/k8s/generated/local/examples/ci-build/hello/deployment.yaml b/k8s/generated/local/examples/ci-build/hello/deployment.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/k8s/generated/local/examples/ci-build/hello/service.yaml b/k8s/generated/local/examples/ci-build/hello/service.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/k8s/generated/local/hello-sailr/0_configmap.yaml b/k8s/generated/local/hello-sailr/0_configmap.yaml deleted file mode 100644 index 101c1e0..0000000 --- a/k8s/generated/local/hello-sailr/0_configmap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: hello-sailr-config - labels: - app: hello-sailr - type: web-app -data: - NODE_ENV: "production" - LOG_LEVEL: "info" - PORT: "80" diff --git a/k8s/generated/local/hello-sailr/deployment.yaml b/k8s/generated/local/hello-sailr/deployment.yaml deleted file mode 100644 index 1c6faa6..0000000 --- a/k8s/generated/local/hello-sailr/deployment.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hello-sailr - labels: - app: hello-sailr - type: web-app -spec: - replicas: 3 - selector: - matchLabels: - app: hello-sailr - template: - metadata: - labels: - app: hello-sailr - type: web-app - spec: - containers: - - name: hello-sailr - image: nginx:latest - ports: - - containerPort: 80 - env: - - name: PORT - value: "80" - - name: NODE_ENV - valueFrom: - configMapKeyRef: - name: hello-sailr-config - key: NODE_ENV - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /health - port: 80 - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: 80 - initialDelaySeconds: 5 - periodSeconds: 5 diff --git a/k8s/generated/local/hello-sailr/ingress.yaml b/k8s/generated/local/hello-sailr/ingress.yaml deleted file mode 100644 index c66486c..0000000 --- a/k8s/generated/local/hello-sailr/ingress.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hello-sailr - labels: - app: hello-sailr - type: web-app - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / -spec: - rules: - - host: local.dev - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: hello-sailr - port: - number: 80 diff --git a/k8s/generated/local/hello-sailr/service.yaml b/k8s/generated/local/hello-sailr/service.yaml deleted file mode 100644 index 756c0e1..0000000 --- a/k8s/generated/local/hello-sailr/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: hello-sailr - labels: - app: hello-sailr - type: web-app -spec: - selector: - app: hello-sailr - ports: - - protocol: TCP - port: 80 - targetPort: 80 - type: ClusterIP diff --git a/sailr.workflow.toml b/sailr.workflow.toml index 6f94fe5..d240a3a 100644 --- a/sailr.workflow.toml +++ b/sailr.workflow.toml @@ -72,3 +72,27 @@ namespace = "default" approval = "external" apply = true report = "both" + +[workflow.ci-build-push-plan] +environment = "staging" +mode = "build" +interactive = false +build = "plan" +push = "plan" +generate = "disabled" +deploy = "disabled" +approval = "none" +apply = false +report = "both" + +[workflow.ci-build-push] +environment = "staging" +mode = "build" +interactive = false +build = "run" +push = "run" +generate = "disabled" +deploy = "disabled" +approval = "external" +apply = true +report = "both" diff --git a/src/builder.rs b/src/builder.rs index 1f0e6c6..19ca0a4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -14,7 +14,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; -const DEFAULT_PUSH_TEMPLATE: &str = "docker push {{ registry }}/{{ name }}:{{ version }}"; const RUNKERNEL_PIPELINE_NAME: &str = "Sailr Service Build Pipeline"; pub struct Builder { @@ -113,8 +112,8 @@ impl BuildBackend for RoomserviceBuildBackend { ); for service in selected_services { - let room = build_room(env, service, &buildable_names); - roomservice.add_room(room)?; + let room = build_room(env, service, &buildable_names)?; + roomservice.add_room(room).map_err(|e| e.to_string())?; } let plan = roomservice.plan(self.options.dump_scope)?; @@ -143,6 +142,9 @@ impl BuildBackend for RoomserviceBuildBackend { impl BuildBackend for RunkernelBuildBackend { async fn build(&mut self, env: &Environment) -> Result { let plan = create_sailr_build_plan(env, &self.options)?; + if self.options.dump_scope { + write_scope_dumps(&plan)?; + } print_sailr_plan(&plan, &self.options); if self.options.plan || self.options.dry_run { @@ -252,7 +254,7 @@ struct ServiceCacheRecord { last_outcome: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ServicePhases { before_synchronously: Vec, before: Vec, @@ -317,11 +319,6 @@ pub(crate) fn create_sailr_build_plan( let mut dirty_state = HashMap::new(); let mut plans = Vec::new(); - fs::create_dir_all(cache_dir.join("services")) - .map_err(|error| format!("Failed to create Sailr build cache directory: {}", error))?; - fs::create_dir_all(cache_dir.join("scopes")) - .map_err(|error| format!("Failed to create Sailr build scope directory: {}", error))?; - for service in selected_services { let build = service .build @@ -338,7 +335,7 @@ pub(crate) fn create_sailr_build_plan( let matched_input_files = resolve_input_files(&build.path, &input_patterns, &dependency_paths)?; let source_hash = hash_files(&matched_input_files); - let normalized = normalize_build_config(env, service, &build); + let normalized = normalize_build_config(env, service, &build)?; let dependency_hash = hash_text( &dependencies .iter() @@ -407,10 +404,6 @@ pub(crate) fn create_sailr_build_plan( } dedupe_dirty_reasons(&mut dirty_reasons); - if options.dump_scope { - write_scope_dump(&cache_dir, &service.name, &matched_input_files)?; - } - let dirty = !dirty_reasons.is_empty(); dirty_state.insert(service.name.clone(), dirty); fingerprints.insert(service.name.clone(), fingerprint.clone()); @@ -442,6 +435,22 @@ pub(crate) fn create_sailr_build_plan( pub(crate) fn add_runkernel_tasks( pipeline: &mut Pipeline, plan: &SailrBuildPlan, +) -> Result<(), String> { + add_runkernel_tasks_inner(pipeline, plan, None) +} + +pub(crate) fn add_runkernel_tasks_from_workflow_plan( + pipeline: &mut Pipeline, + plan: &SailrBuildPlan, + workflow_tasks: &[crate::workflow::plan::WorkflowTaskPlan], +) -> Result<(), String> { + add_runkernel_tasks_inner(pipeline, plan, Some(workflow_tasks)) +} + +fn add_runkernel_tasks_inner( + pipeline: &mut Pipeline, + plan: &SailrBuildPlan, + workflow_tasks: Option<&[crate::workflow::plan::WorkflowTaskPlan]>, ) -> Result<(), String> { let dirty_services = plan .services @@ -450,17 +459,28 @@ pub(crate) fn add_runkernel_tasks( .map(|service| service.service.name.clone()) .collect::>(); let has_dirty_services = !dirty_services.is_empty(); + let has_before_all = has_dirty_services && !plan.before_all.is_empty(); - if has_dirty_services && !plan.before_all.is_empty() { + let planned_task = + |id: &str| workflow_tasks.and_then(|tasks| tasks.iter().find(|task| task.id == id)); + + if has_before_all + && workflow_tasks + .is_none_or(|_| planned_task(crate::workflow::task_id::BUILD_BEFORE_ALL).is_some()) + { let commands = plan.before_all.clone(); + let dependencies = planned_task(crate::workflow::task_id::BUILD_BEFORE_ALL) + .map(|task| task.dependencies.clone()) + .unwrap_or_default(); pipeline.add( - Task::new("build:before-all") + Task::new(crate::workflow::task_id::BUILD_BEFORE_ALL) + .depends_on(&dependencies.iter().map(String::as_str).collect::>()) .cache_disabled() .exec_fn(move |_ctx| { let commands = commands.clone(); async move { for command in commands { - exec_cmd(".", &command, "build:before-all") + exec_cmd(".", &command, crate::workflow::task_id::BUILD_BEFORE_ALL) .await .map_err(anyhow::Error::msg)?; } @@ -471,14 +491,26 @@ pub(crate) fn add_runkernel_tasks( } for service_plan in &plan.services { - let mut dependencies = service_plan.dependencies.clone(); - if service_plan.dirty && has_dirty_services && !plan.before_all.is_empty() { - dependencies.push("build:before-all".to_string()); + let task_id = crate::workflow::task_id::service_build(&service_plan.service.name); + if workflow_tasks.is_some() && planned_task(&task_id).is_none() { + continue; + } + let mut dependencies: Vec = if let Some(planned) = planned_task(&task_id) { + planned.dependencies.clone() + } else { + service_plan + .dependencies + .iter() + .map(|d| crate::workflow::task_id::service_build(d)) + .collect() + }; + if workflow_tasks.is_none() && service_plan.dirty && has_before_all { + dependencies.push(crate::workflow::task_id::BUILD_BEFORE_ALL.to_string()); } dependencies.sort(); dependencies.dedup(); - let mut task = Task::new(service_plan.service.name.clone()) + let mut task = Task::new(task_id) .depends_on(&dependencies.iter().map(String::as_str).collect::>()) .cache_disabled(); @@ -497,22 +529,29 @@ pub(crate) fn add_runkernel_tasks( pipeline.add(task); } - if has_dirty_services && !plan.after_all.is_empty() { + if has_dirty_services + && !plan.after_all.is_empty() + && workflow_tasks + .is_none_or(|_| planned_task(crate::workflow::task_id::BUILD_AFTER_ALL).is_some()) + { let commands = plan.after_all.clone(); + let dependencies = planned_task(crate::workflow::task_id::BUILD_AFTER_ALL) + .map(|task| task.dependencies.clone()) + .unwrap_or_else(|| { + dirty_services + .iter() + .map(|service| crate::workflow::task_id::service_build(service)) + .collect() + }); pipeline.add( - Task::new("build:after-all") - .depends_on( - &dirty_services - .iter() - .map(String::as_str) - .collect::>(), - ) + Task::new(crate::workflow::task_id::BUILD_AFTER_ALL) + .depends_on(&dependencies.iter().map(String::as_str).collect::>()) .cache_disabled() .exec_fn(move |_ctx| { let commands = commands.clone(); async move { for command in commands { - exec_cmd(".", &command, "build:after-all") + exec_cmd(".", &command, crate::workflow::task_id::BUILD_AFTER_ALL) .await .map_err(anyhow::Error::msg)?; } @@ -535,7 +574,7 @@ async fn execute_service_build( let mut first_error = None; for (phase_name, commands) in phases.printable() { - if commands.is_empty() || phase_name == "finally" { + if commands.is_empty() || phase_name == "finally" || phase_name == "push" { continue; } started = true; @@ -865,7 +904,7 @@ fn build_room( env: &Environment, service: &Service, buildable_names: &BTreeSet, -) -> RoomBuilder { +) -> Result { let build_cfg = service .build .as_ref() @@ -874,12 +913,12 @@ fn build_room( build_cfg.relies_on.clone().unwrap_or_default(), buildable_names, ); - let normalized = normalize_build_config(env, service, build_cfg); + let normalized = normalize_build_config(env, service, build_cfg)?; let phases = normalized.phases; let build_command = phases.build.into_iter().next(); let push_command = phases.push.into_iter().next(); - RoomBuilder::new( + Ok(RoomBuilder::new( service.name.clone(), build_cfg.path.clone(), ".roomservice".to_string(), @@ -900,18 +939,21 @@ fn build_room( }, build_command, push_command, - Some(format!( - "{}/{}:{}", - env.registry, service.name, service.version - )), - ) + Some( + env.registry + .resolve() + .map_err(|e| format!("Invalid registry configuration: {e}"))? + .tagged_ref(&service.name, &service.version) + .map_err(|e| format!("Failed to resolve image reference: {e}"))?, + ), + )) } fn normalize_build_config( env: &Environment, service: &Service, build_cfg: &ServiceBuildConfig, -) -> NormalizedBuildConfig { +) -> Result { let legacy_semantics = env.schema_version != "0.5.0"; let explicit_new_phase_fields = build_cfg.before_synchronous.is_some() || build_cfg.run_parallel.is_some() @@ -923,12 +965,12 @@ fn normalize_build_config( let before = if legacy_semantics && !explicit_new_phase_fields { Vec::new() } else { - render_commands(build_cfg.before.clone(), env, service) + render_commands(build_cfg.before.clone(), env, service)? }; let after = if legacy_semantics && !explicit_new_phase_fields { Vec::new() } else { - render_commands(build_cfg.after.clone(), env, service) + render_commands(build_cfg.after.clone(), env, service)? }; let build_command = build_cfg @@ -954,29 +996,29 @@ fn normalize_build_config( }) .unwrap_or_else(|| default_push_command(env)); - NormalizedBuildConfig { + Ok(NormalizedBuildConfig { phases: ServicePhases { before_synchronously: render_commands( build_cfg.before_synchronous.clone(), env, service, - ), + )?, before, - run_parallel: render_commands(build_cfg.run_parallel.clone(), env, service), - run_synchronously: render_commands(build_cfg.run_synchronous.clone(), env, service), + run_parallel: render_commands(build_cfg.run_parallel.clone(), env, service)?, + run_synchronously: render_commands(build_cfg.run_synchronous.clone(), env, service)?, after, - finally: render_commands(build_cfg.finally.clone(), env, service), - build: vec![render_build_command(&build_command, env, service)], - push: vec![render_build_command(&push_command, env, service)], + finally: render_commands(build_cfg.finally.clone(), env, service)?, + build: vec![render_build_command(&build_command, env, service)?], + push: vec![render_build_command(&push_command, env, service)?], }, - } + }) } fn render_commands( commands: Option, env: &Environment, service: &Service, -) -> Vec { +) -> Result, String> { commands .map(CommandSpec::into_vec) .unwrap_or_default() @@ -985,11 +1027,24 @@ fn render_commands( .collect() } -fn render_build_command(command: &str, env: &Environment, service: &Service) -> String { +fn render_build_command( + command: &str, + env: &Environment, + service: &Service, +) -> Result { let mut rendered = command.to_string(); + let resolved_registry = env + .registry + .resolve() + .map_err(|e| format!("Failed to parse registry config: {e}"))?; + let image_ref = resolved_registry + .tagged_ref(&service.name, &service.version) + .map_err(|e| format!("Failed to build image ref: {e}"))?; + for (key, value) in [ - ("registry", env.registry.as_str()), + ("image_ref", image_ref.as_str()), + ("registry", resolved_registry.host.as_str()), ("platform", env.platform.as_deref().unwrap_or("")), ("environment", env.name.as_str()), ("name", service.name.as_str()), @@ -999,7 +1054,7 @@ fn render_build_command(command: &str, env: &Environment, service: &Service) -> rendered = replace_template_var(&rendered, key, value); } - rendered + Ok(rendered) } fn default_build_command(env: &Environment, build_cfg: &ServiceBuildConfig) -> String { @@ -1011,18 +1066,18 @@ fn default_build_command(env: &Environment, build_cfg: &ServiceBuildConfig) -> S match env.platform.as_deref() { Some(platform) if !platform.trim().is_empty() => format!( - "docker buildx build --ssh default --platform {}{} -t {{{{ registry }}}}/{{{{ name }}}}:{{{{ version }}}} .", + "docker buildx build --ssh default --platform {}{} -t {{{{ image_ref }}}} .", platform, dockerfile_segment ), _ => format!( - "docker buildx build --ssh default{} -t {{{{ registry }}}}/{{{{ name }}}}:{{{{ version }}}} .", + "docker buildx build --ssh default{} -t {{{{ image_ref }}}} .", dockerfile_segment ), } } fn default_push_command(_env: &Environment) -> String { - DEFAULT_PUSH_TEMPLATE.to_string() + "docker push {{ image_ref }}".to_string() } fn replace_template_var(input: &str, key: &str, value: &str) -> String { @@ -1133,7 +1188,8 @@ pub(crate) fn write_successful_service_caches( .collect::>(); for service in plan.services.iter().filter(|service| service.dirty) { - if !completed_tasks.contains(service.service.name.as_str()) { + let task_name = crate::workflow::task_id::service_build(&service.service.name); + if !completed_tasks.contains(task_name.as_str()) { continue; } @@ -1143,11 +1199,14 @@ pub(crate) fn write_successful_service_caches( }; let serialized = serde_json::to_string_pretty(&record) .map_err(|error| format!("Failed to serialize Sailr service cache: {}", error))?; - fs::write( - service_cache_path(&plan.cache_dir, &service.service.name), - serialized, - ) - .map_err(|error| format!("Failed to write Sailr service cache: {}", error))?; + let cache_path = service_cache_path(&plan.cache_dir, &service.service.name); + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("Failed to create Sailr build cache directory: {}", error) + })?; + } + fs::write(cache_path, serialized) + .map_err(|error| format!("Failed to write Sailr service cache: {}", error))?; } Ok(()) } @@ -1167,9 +1226,10 @@ fn sailr_build_cache_dir(configured_cache_dir: &str) -> PathBuf { } fn write_scope_dump(cache_dir: &Path, service_name: &str, files: &[PathBuf]) -> Result<(), String> { - let path = cache_dir - .join("scopes") - .join(format!("{}.txt", service_name)); + let scope_dir = cache_dir.join("scopes"); + fs::create_dir_all(&scope_dir) + .map_err(|error| format!("Failed to create Sailr build scope directory: {}", error))?; + let path = scope_dir.join(format!("{}.txt", service_name)); let contents = files .iter() .map(|file| file.to_string_lossy().to_string()) @@ -1178,6 +1238,17 @@ fn write_scope_dump(cache_dir: &Path, service_name: &str, files: &[PathBuf]) -> fs::write(path, contents).map_err(|error| format!("Failed to write scope dump: {}", error)) } +pub(crate) fn write_scope_dumps(plan: &SailrBuildPlan) -> Result<(), String> { + for service_plan in &plan.services { + write_scope_dump( + &plan.cache_dir, + &service_plan.service.name, + &service_plan.matched_input_files, + )?; + } + Ok(()) +} + fn dedupe_dirty_reasons(reasons: &mut Vec) { let mut seen = HashSet::new(); reasons.retain(|reason| seen.insert(reason.describe())); @@ -1272,19 +1343,31 @@ pub(crate) fn print_sailr_plan(plan: &SailrBuildPlan, options: &BuildOptions) { } } -pub(crate) fn print_pipeline_result(plan: &SailrBuildPlan, result: &PipelineResult) { +pub(crate) struct BuildResultSummary { + pub built: usize, + pub clean: usize, + pub failed: usize, + pub skipped: usize, +} + +pub(crate) fn extract_build_summary( + plan: &SailrBuildPlan, + result: &PipelineResult, +) -> BuildResultSummary { let task_statuses = result .tasks .iter() .map(|task| (task.name.as_str(), &task.status)) .collect::>(); + let built = plan .services .iter() .filter(|service| { + let task_id = crate::workflow::task_id::service_build(&service.service.name); service.dirty && matches!( - task_statuses.get(service.service.name.as_str()), + task_statuses.get(task_id.as_str()), Some(TaskStatus::Completed) ) }) @@ -1298,9 +1381,10 @@ pub(crate) fn print_pipeline_result(plan: &SailrBuildPlan, result: &PipelineResu .services .iter() .filter(|service| { + let task_id = crate::workflow::task_id::service_build(&service.service.name); service.dirty && matches!( - task_statuses.get(service.service.name.as_str()), + task_statuses.get(task_id.as_str()), Some(TaskStatus::Failed) ) }) @@ -1309,20 +1393,31 @@ pub(crate) fn print_pipeline_result(plan: &SailrBuildPlan, result: &PipelineResu .services .iter() .filter(|service| { + let task_id = crate::workflow::task_id::service_build(&service.service.name); service.dirty && matches!( - task_statuses.get(service.service.name.as_str()), + task_statuses.get(task_id.as_str()), Some(TaskStatus::Skipped | TaskStatus::Cancelled) ) }) .count(); - println!( - "Sailr build result:\n engine: runkernel\n built: {}\n clean: {}\n failed: {}\n skipped: {}\n duration: {:.1}s", + BuildResultSummary { built, clean, failed, skipped, + } +} + +pub(crate) fn print_pipeline_result(plan: &SailrBuildPlan, result: &PipelineResult) { + let summary = extract_build_summary(plan, result); + println!( + "Sailr build result:\n engine: runkernel\n built: {}\n clean: {}\n failed: {}\n skipped: {}\n duration: {:.1}s", + summary.built, + summary.clean, + summary.failed, + summary.skipped, result.duration.as_secs_f64() ); } @@ -1613,14 +1708,14 @@ mod tests { write_project(&service_path); let mut env = Environment::new("dev"); - env.registry = "registry.local".to_string(); + env.registry = crate::environment::RegistryConfig::Simple("registry.local".to_string()); env.platform = Some("linux/amd64".to_string()); let service = service("api", &service_path, "true".to_string()); let mut build = service.build.clone().unwrap(); build.build_command = None; build.push_command = None; - let normalized = normalize_build_config(&env, &service, &build); + let normalized = normalize_build_config(&env, &service, &build).unwrap(); let commands = normalized.phases.commands_for_hash().join("\n"); assert!(commands.contains("registry.local/api:1.2.3")); assert!(!commands.contains("{{")); @@ -1639,7 +1734,8 @@ mod tests { let mut opts = options(cache_dir.clone()); opts.dump_scope = true; - create_sailr_build_plan(&env, &opts).expect("plan should be created"); + let plan = create_sailr_build_plan(&env, &opts).expect("plan should be created"); + write_scope_dumps(&plan).expect("scope dump should be written"); let scope = fs::read_to_string(cache_dir.join("scopes/api.txt")) .expect("scope dump should be written"); @@ -1813,8 +1909,14 @@ mod tests { write_successful_service_caches( &plan, &pipeline_result(vec![ - ("api", TaskStatus::Completed), - ("web", TaskStatus::Skipped), + ( + crate::workflow::task_id::service_build("api").as_str(), + TaskStatus::Completed, + ), + ( + crate::workflow::task_id::service_build("web").as_str(), + TaskStatus::Skipped, + ), ]), ) .expect("cache write should succeed"); @@ -1920,4 +2022,168 @@ mod tests { let contents = fs::read_to_string(log).expect("log should exist"); assert_eq!(contents, "before-service-after"); } + + #[test] + fn extract_build_summary_computes_correct_counts() { + let mut plan = SailrBuildPlan { + services: vec![], + before_all: vec![], + after_all: vec![], + force: false, + cache_dir: PathBuf::from(".sailr/cache/build"), + }; + + let dummy_build_config = crate::environment::ServiceBuildConfig { + path: ".".to_string(), + include: None, + relies_on: None, + before_synchronous: None, + before: None, + run_parallel: None, + run_synchronous: None, + after: None, + finally: None, + dockerfile: None, + build_command: None, + push_command: None, + }; + + // Clean service + plan.services.push(ServiceBuildPlan { + service: crate::environment::Service::new("clean-service", None, "1.0"), + build: dummy_build_config.clone(), + cwd: PathBuf::from("."), + dependencies: vec![], + dependency_paths: vec![], + input_patterns: vec![], + matched_input_files: vec![], + dirty: false, + dirty_reasons: vec![], + fingerprint: ServiceFingerprint { + source_hash: "".to_string(), + dependency_hash: "".to_string(), + command_hash: "".to_string(), + config_hash: "".to_string(), + full_hash: "".to_string(), + }, + phases: Default::default(), + }); + + // Built service + plan.services.push(ServiceBuildPlan { + service: crate::environment::Service::new("built-service", None, "1.0"), + build: dummy_build_config.clone(), + cwd: PathBuf::from("."), + dependencies: vec![], + dependency_paths: vec![], + input_patterns: vec![], + matched_input_files: vec![], + dirty: true, + dirty_reasons: vec![], + fingerprint: ServiceFingerprint { + source_hash: "".to_string(), + dependency_hash: "".to_string(), + command_hash: "".to_string(), + config_hash: "".to_string(), + full_hash: "".to_string(), + }, + phases: Default::default(), + }); + + // Failed service + plan.services.push(ServiceBuildPlan { + service: crate::environment::Service::new("failed-service", None, "1.0"), + build: dummy_build_config.clone(), + cwd: PathBuf::from("."), + dependencies: vec![], + dependency_paths: vec![], + input_patterns: vec![], + matched_input_files: vec![], + dirty: true, + dirty_reasons: vec![], + fingerprint: ServiceFingerprint { + source_hash: "".to_string(), + dependency_hash: "".to_string(), + command_hash: "".to_string(), + config_hash: "".to_string(), + full_hash: "".to_string(), + }, + phases: Default::default(), + }); + + // Skipped service + plan.services.push(ServiceBuildPlan { + service: crate::environment::Service::new("skipped-service", None, "1.0"), + build: dummy_build_config.clone(), + cwd: PathBuf::from("."), + dependencies: vec![], + dependency_paths: vec![], + input_patterns: vec![], + matched_input_files: vec![], + dirty: true, + dirty_reasons: vec![], + fingerprint: ServiceFingerprint { + source_hash: "".to_string(), + dependency_hash: "".to_string(), + command_hash: "".to_string(), + config_hash: "".to_string(), + full_hash: "".to_string(), + }, + phases: Default::default(), + }); + + let result = PipelineResult { + name: "test".to_string(), + duration: std::time::Duration::from_secs(1), + summary: runkernel::PipelineSummary { + name: "test".to_string(), + success: false, + completed: 1, + failed: 1, + skipped: 1, + cached: 0, + cancelled: 0, + rolled_back: 0, + rollback_failed: 0, + }, + tasks: vec![ + runkernel::TaskResult { + name: crate::workflow::task_id::service_build("built-service"), + status: TaskStatus::Completed, + duration: None, + error: None, + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + }, + runkernel::TaskResult { + name: crate::workflow::task_id::service_build("failed-service"), + status: TaskStatus::Failed, + duration: None, + error: None, + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + }, + runkernel::TaskResult { + name: crate::workflow::task_id::service_build("skipped-service"), + status: TaskStatus::Skipped, + duration: None, + error: None, + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + }, + ], + }; + + let summary = extract_build_summary(&plan, &result); + assert_eq!(summary.clean, 1); + assert_eq!(summary.built, 1); + assert_eq!(summary.failed, 1); + assert_eq!(summary.skipped, 1); + } } diff --git a/src/cli.rs b/src/cli.rs index 622ea41..28293aa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -80,6 +80,8 @@ pub enum WorkflowCommands { Graph(WorkflowGraphArgs), /// Explain a workflow task Explain(WorkflowExplainArgs), + /// Inspect workflow diagnostic configuration + Inspect(WorkflowInspectArgs), } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] @@ -109,6 +111,12 @@ pub struct WorkflowPlanArgs { pub ignore: Option, } +#[derive(Debug, Args)] +pub struct WorkflowInspectArgs { + /// Name of the workflow profile to inspect + pub profile: String, +} + #[derive(Debug, Args)] pub struct WorkflowGraphArgs { /// Name of the workflow profile to graph diff --git a/src/default_config.toml b/src/default_config.toml index de76db5..ae1fd5f 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -45,3 +45,29 @@ namespace = "default" approval = "external" apply = true report = "both" + +[workflow.ci-build-push-plan] +environment = "staging" +mode = "build" +interactive = false +build = "plan" +push = "plan" +generate = "disabled" +deploy = "disabled" +approval = "none" +apply = false +report = "both" + +[workflow.ci-build-push] +environment = "staging" +mode = "build" +interactive = false +apply = true +approval = "external" +build = "run" +push = "run" +generate = "disabled" +deploy = "disabled" +approval = "external" +apply = true +report = "both" diff --git a/src/deployment/k8sm8/mod.rs b/src/deployment/k8sm8/mod.rs index 01421c5..9f7cece 100644 --- a/src/deployment/k8sm8/mod.rs +++ b/src/deployment/k8sm8/mod.rs @@ -161,7 +161,7 @@ pub async fn apply( } else { LOGGER.error(&format!( "cannot apply object without valid TypeMeta {:?}", - &obj + obj )); LOGGER.error("please add apiVersion and kind to the object"); continue; diff --git a/src/environment.rs b/src/environment.rs index aec2a6a..732c573 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -13,6 +13,245 @@ const SCHEMA_V03: &str = "0.3.0"; const SCHEMA_V04: &str = "0.4.0"; const SCHEMA_V05: &str = "0.5.0"; +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(untagged)] +pub enum RegistryConfig { + Simple(String), + Detailed { + host: String, + namespace: Option, + }, +} + +#[cfg(test)] +mod registry_contract_tests { + use super::{RegistryConfig, ResolvedRegistry}; + + #[test] + fn detailed_registry_separates_host_and_namespace_validation() { + let valid = RegistryConfig::Detailed { + host: "ghcr.io".to_string(), + namespace: Some("acme/platform".to_string()), + } + .resolve() + .unwrap(); + assert_eq!(valid.prefix(), "ghcr.io/acme/platform"); + + for host in [ + "", + "https://ghcr.io", + "/ghcr.io", + "ghcr.io/", + "ghcr.io/acme", + "ghcr .io", + ] { + assert!(RegistryConfig::Detailed { + host: host.to_string(), + namespace: None, + } + .resolve() + .is_err()); + } + for namespace in [ + "", + "/acme", + "acme/", + "acme//platform", + "acme platform", + "https://acme", + ] { + assert!(RegistryConfig::Detailed { + host: "ghcr.io".to_string(), + namespace: Some(namespace.to_string()), + } + .resolve() + .is_err()); + } + } + + #[test] + fn repository_and_tag_inputs_reject_obvious_malformed_values() { + let registry = ResolvedRegistry::parse("ghcr.io/acme").unwrap(); + for service in ["", "api worker", "/api", "api/", "api//worker"] { + assert!(registry.repository_for(service).is_err()); + } + for tag in ["", "release candidate", ":latest", "release/latest"] { + assert!(registry.tagged_ref("api", tag).is_err()); + } + assert_eq!( + registry.tagged_ref("api", "1.2.0").unwrap(), + "ghcr.io/acme/api:1.2.0" + ); + } +} + +impl Default for RegistryConfig { + fn default() -> Self { + RegistryConfig::Simple("docker.io".to_string()) + } +} + +impl RegistryConfig { + pub fn host(&self) -> Result { + Ok(self.resolve()?.host) + } + + pub fn namespace(&self) -> Result, crate::workflow::error::RegistryConfigError> { + Ok(self.resolve()?.namespace) + } + + pub fn prefix(&self) -> Result { + Ok(self.resolve()?.prefix()) + } + + pub fn resolve(&self) -> Result { + match self { + Self::Simple(s) => ResolvedRegistry::parse(s), + Self::Detailed { host, namespace } => { + let parsed_host = host.trim(); + if parsed_host.is_empty() { + return Err(crate::workflow::error::RegistryConfigError::EmptyHost); + } + if parsed_host.contains("://") + || parsed_host.starts_with('/') + || parsed_host.ends_with('/') + || parsed_host.contains('/') + || parsed_host.contains(|c: char| c.is_whitespace()) + { + return Err(crate::workflow::error::RegistryConfigError::InvalidHost( + parsed_host.to_string(), + )); + } + + if let Some(ns) = namespace { + let parsed_ns = ns.trim(); + if parsed_ns.is_empty() + || parsed_ns.contains("://") + || parsed_ns.starts_with('/') + || parsed_ns.ends_with('/') + || parsed_ns.contains(|c: char| c.is_whitespace()) + || parsed_ns.contains("//") + { + return Err( + crate::workflow::error::RegistryConfigError::InvalidNamespace( + parsed_ns.to_string(), + ), + ); + } + Ok(ResolvedRegistry { + host: parsed_host.to_string(), + namespace: Some(parsed_ns.to_string()), + }) + } else { + Ok(ResolvedRegistry { + host: parsed_host.to_string(), + namespace: None, + }) + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRegistry { + pub host: String, + pub namespace: Option, +} + +impl ResolvedRegistry { + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(crate::workflow::error::RegistryConfigError::EmptyHost); + } + if s.contains("://") + || s.starts_with('/') + || s.ends_with('/') + || s.contains(|c: char| c.is_whitespace()) + || s.contains("//") + { + return Err(crate::workflow::error::RegistryConfigError::InvalidHost( + s.to_string(), + )); + } + + let parts: Vec<&str> = s.splitn(2, '/').collect(); + let host = parts[0].to_string(); + if host.is_empty() { + return Err(crate::workflow::error::RegistryConfigError::InvalidHost( + s.to_string(), + )); + } + + let namespace = if parts.len() > 1 { + let ns = parts[1].to_string(); + if ns.is_empty() + || ns.contains("://") + || ns.starts_with('/') + || ns.ends_with('/') + || ns.contains("//") + || ns.chars().any(char::is_whitespace) + { + return Err( + crate::workflow::error::RegistryConfigError::InvalidNamespace(s.to_string()), + ); + } + Some(ns) + } else { + None + }; + + Ok(Self { host, namespace }) + } + + pub fn prefix(&self) -> String { + match &self.namespace { + Some(ns) => format!("{}/{}", self.host, ns), + None => self.host.clone(), + } + } + + pub fn repository_for( + &self, + service: &str, + ) -> Result { + crate::oci::validate_repository_component(service).map_err(|_| { + crate::workflow::error::RegistryConfigError::InvalidService(service.to_string()) + })?; + match &self.namespace { + Some(ns) => Ok(format!("{}/{}", ns, service)), + None => Ok(service.to_string()), + } + } + + pub fn tagged_ref( + &self, + service: &str, + tag: &str, + ) -> Result { + crate::oci::validate_tag(tag).map_err(|_| { + crate::workflow::error::RegistryConfigError::InvalidTag(tag.to_string()) + })?; + let repo = self.repository_for(service)?; + Ok(format!("{}/{}:{}", self.host, repo, tag)) + } + + pub fn digest_ref( + &self, + service: &str, + digest: &str, + ) -> Result { + if crate::oci::validate_sha256_digest(digest).is_err() { + return Err(crate::workflow::error::RegistryConfigError::InvalidDigest( + digest.to_string(), + )); + } + let repo = self.repository_for(service)?; + Ok(format!("{}/{}@{}", self.host, repo, digest)) + } +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct Environment { pub schema_version: String, @@ -22,8 +261,9 @@ pub struct Environment { pub services: Vec, pub domain: String, pub default_replicas: u8, - pub registry: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub registry: RegistryConfig, + pub platform: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub build: Option, @@ -43,7 +283,7 @@ impl Environment { services: Vec::new(), domain: "localhost".to_string(), default_replicas: 1, - registry: "docker.io".to_string(), + registry: RegistryConfig::default(), platform: None, build: None, environment_variables: Some(Vec::new()), @@ -496,17 +736,21 @@ impl Environment { Ok(()) } - pub fn get_variables(&self, service: &Service) -> Vec<(String, String)> { + pub fn get_variables( + &self, + service: &Service, + ) -> Result, crate::workflow::error::RegistryConfigError> { let mut variables = vec![ ("name".to_string(), self.name.clone()), ("log_level".to_string(), self.log_level.clone()), + ("replicas".to_string(), self.default_replicas.to_string()), + ("registry".to_string(), self.registry.prefix()?), ("domain".to_string(), self.domain.clone()), ("deployment_date".to_string(), get_current_timestamp()), ( "default_replicas".to_string(), self.default_replicas.to_string(), ), - ("registry".to_string(), self.registry.clone()), ( "platform".to_string(), self.platform.clone().unwrap_or_default(), @@ -539,7 +783,7 @@ impl Environment { }) } - variables + Ok(variables) } fn upgrade_builds_to_v05(&mut self) { @@ -1291,7 +1535,7 @@ value = "enabled" assert_eq!(env.name, "child"); assert_eq!(env.domain, "child.example.com"); assert_eq!(env.default_replicas, 3); - assert_eq!(env.registry, "docker.io/base"); + assert_eq!(env.registry.prefix().unwrap(), "docker.io/base"); assert_eq!(env.platform.as_deref(), Some("linux/amd64")); assert_eq!(env.build.as_ref().unwrap().max_parallelism, Some(2)); assert_eq!(env.build.as_ref().unwrap().fail_fast, Some(false)); @@ -1341,7 +1585,7 @@ domain = "prod.example.com" assert_eq!(env.name, "prod"); assert_eq!(env.domain, "prod.example.com"); - assert_eq!(env.registry, "docker.io/staging"); + assert_eq!(env.registry.prefix().unwrap(), "docker.io/staging"); } #[test] diff --git a/src/lib.rs b/src/lib.rs index d464740..334f30c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod filesystem; pub mod generate; pub mod infra; pub mod interactive; +pub mod oci; pub mod orchestrator; pub mod plan; pub mod provider; @@ -86,7 +87,9 @@ pub fn generate(name: &str, env: &Environment, services: Vec<&Service>) -> anyho let mut generator = Generator::new(); for service in services { - let variables = &env.get_variables(service); + let variables = &env + .get_variables(service) + .map_err(|e| anyhow::anyhow!("Registry config error: {}", e))?; for template in &templates { if template.name != service.name && template.name != service.get_path() { continue; diff --git a/src/main.rs b/src/main.rs index 56052ea..c1ea614 100644 --- a/src/main.rs +++ b/src/main.rs @@ -824,6 +824,13 @@ async fn handle_workflow(cmd: WorkflowCommands) -> Result<(), CliError> { return Ok(()); } + if let WorkflowCommands::Inspect(args) = cmd { + sailr::workflow::runner::WorkflowRunner::inspect(args) + .await + .map_err(CliError::Other)?; + return Ok(()); + } + let config = WorkflowConfig::load()?; match cmd { @@ -877,7 +884,8 @@ async fn handle_workflow(cmd: WorkflowCommands) -> Result<(), CliError> { WorkflowCommands::Run(_) | WorkflowCommands::Plan(_) | WorkflowCommands::Graph(_) - | WorkflowCommands::Explain(_) => unreachable!(), + | WorkflowCommands::Explain(_) + | WorkflowCommands::Inspect(_) => unreachable!(), } Ok(()) diff --git a/src/oci.rs b/src/oci.rs new file mode 100644 index 0000000..48bc89e --- /dev/null +++ b/src/oci.rs @@ -0,0 +1,135 @@ +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum OciError { + #[error("Digest must start with sha256:")] + MissingSha256Prefix, + #[error("SHA-256 digest must contain exactly 64 hexadecimal characters")] + InvalidDigestLength, + #[error("SHA-256 digest must contain lowercase ASCII hexadecimal characters")] + InvalidDigestCharacters, + #[error("Repository component must be lowercase and repository-safe")] + InvalidRepositoryComponent, + #[error("Tag must be 1-128 OCI-safe ASCII characters")] + InvalidTag, +} + +pub fn validate_sha256_digest(value: &str) -> Result<(), OciError> { + let hex = value + .strip_prefix("sha256:") + .ok_or(OciError::MissingSha256Prefix)?; + + if hex.len() != 64 { + return Err(OciError::InvalidDigestLength); + } + + if !hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err(OciError::InvalidDigestCharacters); + } + + Ok(()) +} + +pub fn validate_repository_component(value: &str) -> Result<(), OciError> { + let bytes = value.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return Err(OciError::InvalidRepositoryComponent); + } + + let mut previous_was_separator = false; + for byte in bytes { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + previous_was_separator = false; + } else if matches!(byte, b'.' | b'_' | b'-') && !previous_was_separator { + previous_was_separator = true; + } else { + return Err(OciError::InvalidRepositoryComponent); + } + } + + if previous_was_separator { + return Err(OciError::InvalidRepositoryComponent); + } + Ok(()) +} + +pub fn validate_tag(value: &str) -> Result<(), OciError> { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > 128 { + return Err(OciError::InvalidTag); + } + if !bytes[0].is_ascii_alphanumeric() && bytes[0] != b'_' { + return Err(OciError::InvalidTag); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + { + return Err(OciError::InvalidTag); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_only_exact_lowercase_ascii_sha256_digests() { + assert!(validate_sha256_digest(&format!("sha256:{}", "a".repeat(64))).is_ok()); + assert_eq!( + validate_sha256_digest(&"a".repeat(64)), + Err(OciError::MissingSha256Prefix) + ); + assert_eq!( + validate_sha256_digest(&format!("sha256:{}", "a".repeat(63))), + Err(OciError::InvalidDigestLength) + ); + assert_eq!( + validate_sha256_digest(&format!("sha256:{}", "A".repeat(64))), + Err(OciError::InvalidDigestCharacters) + ); + assert!(validate_sha256_digest(&format!("sha256:{}", "1".repeat(64))).is_err()); + } + + #[test] + fn validates_repository_components() { + for valid in ["api", "api-worker", "api.worker", "api_worker", "api2"] { + assert!(validate_repository_component(valid).is_ok()); + } + for invalid in [ + "", + "API", + "api worker", + "/api", + "api/", + "api//worker", + "api@worker", + "api:worker", + "api--worker", + "-api", + "api-", + ] { + assert_eq!( + validate_repository_component(invalid), + Err(OciError::InvalidRepositoryComponent) + ); + } + } + + #[test] + fn validates_bounded_oci_tags() { + for valid in ["release", "Release_1.2-rc", "_internal"] { + assert!(validate_tag(valid).is_ok()); + } + for invalid in [ + "", + "release:candidate", + "release@prod", + "release/candidate", + "-leading", + ] { + assert_eq!(validate_tag(invalid), Err(OciError::InvalidTag)); + } + assert!(validate_tag(&"a".repeat(128)).is_ok()); + assert_eq!(validate_tag(&"a".repeat(129)), Err(OciError::InvalidTag)); + } +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 77370c5..d091781 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -215,10 +215,10 @@ impl TemplateManager { .read_file(config_file, Some(&"./k8s/templates".to_string())) .unwrap(); - acc.0.push_str(&format!("\n {:?}: |", &config)); + acc.0.push_str(&format!("\n {:?}: |", config)); for line in config_content.lines() { - acc.0.push_str(&format!("\n {}", &line)) + acc.0.push_str(&format!("\n {}", line)) } acc.1.push(config); diff --git a/src/workflow/ci.rs b/src/workflow/ci.rs index cec30d5..6a89434 100644 --- a/src/workflow/ci.rs +++ b/src/workflow/ci.rs @@ -3,10 +3,19 @@ use std::path::PathBuf; use super::error::WorkflowError; +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum CiProvider { GitHub, CircleCi, Travis, + Generic, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CiEnvironment { + pub provider: CiProvider, + pub run_id: Option, } impl std::str::FromStr for CiProvider { @@ -17,6 +26,7 @@ impl std::str::FromStr for CiProvider { "github" => Ok(CiProvider::GitHub), "circleci" => Ok(CiProvider::CircleCi), "travis" => Ok(CiProvider::Travis), + "generic" => Ok(CiProvider::Generic), _ => Err(WorkflowError::ConfigError(format!( "Unsupported CI provider: {}", s @@ -33,6 +43,7 @@ impl CiTemplateGenerator { CiProvider::GitHub => Self::generate_github(profile_name), CiProvider::CircleCi => Self::generate_circleci(profile_name), CiProvider::Travis => Self::generate_travis(profile_name), + CiProvider::Generic => String::new(), } } @@ -43,6 +54,7 @@ impl CiTemplateGenerator { } CiProvider::CircleCi => PathBuf::from(".circleci/config.yml"), CiProvider::Travis => PathBuf::from(".travis.yml"), + CiProvider::Generic => PathBuf::from("sailr-workflow.sh"), } } @@ -158,6 +170,10 @@ mod tests { CiProvider::from_str("Travis").unwrap(), CiProvider::Travis )); + assert!(matches!( + CiProvider::from_str("generic").unwrap(), + CiProvider::Generic + )); assert!(CiProvider::from_str("jenkins").is_err()); } diff --git a/src/workflow/error.rs b/src/workflow/error.rs index 51a6b98..18cf7c7 100644 --- a/src/workflow/error.rs +++ b/src/workflow/error.rs @@ -14,3 +14,53 @@ pub enum WorkflowError { #[error("IO error reading workflow config: {0}")] IoError(#[from] std::io::Error), } + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ArtifactError { + #[error("Artifact validation error: {0}")] + Validation(String), + #[error("Missing digest: {0}")] + MissingDigest(String), + #[error("Digest mismatch. expected: {expected}, actual: {actual}")] + DigestMismatch { expected: String, actual: String }, +} + +impl From for ArtifactError { + fn from(error: crate::oci::OciError) -> Self { + Self::Validation(error.to_string()) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RegistryConfigError { + #[error("Empty host")] + EmptyHost, + #[error("Invalid host: {0}")] + InvalidHost(String), + #[error("Invalid namespace: {0}")] + InvalidNamespace(String), + #[error("Invalid service: {0}")] + InvalidService(String), + #[error("Invalid tag: {0}")] + InvalidTag(String), + #[error("Invalid digest: {0}")] + InvalidDigest(String), +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProvenanceError { + #[error("Image publication requires a source revision. Run from a Git checkout or provide an explicit source revision.")] + MissingSourceRevision, + + #[error("Invalid source revision: {0}")] + InvalidSourceRevision(String), + + #[error("Failed to read Git revision: {0}")] + Git(String), +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum WorkflowReportError { + #[error("Workflow report validation error: {0}")] + Validation(String), +} diff --git a/src/workflow/image.rs b/src/workflow/image.rs new file mode 100644 index 0000000..645f0f1 --- /dev/null +++ b/src/workflow/image.rs @@ -0,0 +1,617 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::workflow::error::ArtifactError; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct WorkflowReportData { + pub published_artifacts: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct WorkflowReportAccumulator { + inner: Arc>, +} + +impl WorkflowReportAccumulator { + pub async fn add_image(&self, artifact: PublishedImageArtifact) { + let mut inner = self.inner.lock().await; + inner.published_artifacts.push(artifact); + } + + pub async fn snapshot(&self) -> WorkflowReportData { + self.inner.lock().await.clone() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageProvenance { + pub build_fingerprint: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, +} + +impl ImageProvenance { + pub fn validate(&self) -> Result<(), ArtifactError> { + if self.build_fingerprint.trim().is_empty() { + return Err(ArtifactError::Validation( + "build_fingerprint cannot be empty".to_string(), + )); + } + if let Some(rev) = &self.source_revision { + if rev.trim().is_empty() { + return Err(ArtifactError::Validation( + "source_revision cannot be empty".to_string(), + )); + } + if rev.chars().any(|c| c.is_whitespace()) { + return Err(ArtifactError::Validation( + "source_revision cannot contain whitespace".to_string(), + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublishedImageArtifact { + pub service: String, + pub environment: String, + pub registry: String, + pub repository: String, + pub tag: String, + pub digest: String, + pub image_ref: String, + pub provenance: ImageProvenance, + pub published_at: String, +} + +impl PublishedImageArtifact { + pub fn from_push_result( + environment: &str, + item: &ImagePushPlanItem, + digest: &str, + published_at: &str, + ) -> Result { + let artifact = Self { + service: item.service.clone(), + environment: environment.to_string(), + registry: item.registry.clone(), + repository: item.repository.clone(), + tag: item.tag.clone(), + digest: digest.to_string(), + image_ref: format!("{}/{}@{}", item.registry, item.repository, digest), + provenance: item.provenance.clone(), + published_at: published_at.to_string(), + }; + + artifact.validate_against_plan_item(environment, item)?; + Ok(artifact) + } + pub fn validate(&self) -> Result<(), ArtifactError> { + crate::oci::validate_sha256_digest(&self.digest)?; + crate::oci::validate_repository_component(&self.service)?; + crate::oci::validate_tag(&self.tag)?; + self.provenance.validate()?; + + let expected_ref = format!("{}/{}@{}", self.registry, self.repository, self.digest); + if self.image_ref != expected_ref { + return Err(ArtifactError::Validation(format!( + "image_ref mismatch: expected {}, got {}", + expected_ref, self.image_ref + ))); + } + if self.service.trim().is_empty() { + return Err(ArtifactError::Validation( + "service cannot be empty".to_string(), + )); + } + if self.environment.trim().is_empty() { + return Err(ArtifactError::Validation( + "environment cannot be empty".to_string(), + )); + } + if self.registry.trim().is_empty() { + return Err(ArtifactError::Validation( + "registry cannot be empty".to_string(), + )); + } + if self.repository.trim().is_empty() { + return Err(ArtifactError::Validation( + "repository cannot be empty".to_string(), + )); + } + if self.tag.trim().is_empty() { + return Err(ArtifactError::Validation("tag cannot be empty".to_string())); + } + + chrono::DateTime::parse_from_rfc3339(&self.published_at).map_err(|e| { + ArtifactError::Validation(format!("published_at is not valid RFC 3339: {}", e)) + })?; + + Ok(()) + } + + pub fn validate_against_plan_item( + &self, + report_environment: &str, + item: &ImagePushPlanItem, + ) -> Result<(), ArtifactError> { + self.validate()?; + item.validate()?; + if self.environment != report_environment { + return Err(ArtifactError::Validation(format!( + "artifact environment '{}' does not match report environment '{}'", + self.environment, report_environment + ))); + } + if self.service != item.service + || self.registry != item.registry + || self.repository != item.repository + || self.tag != item.tag + || self.provenance != item.provenance + { + return Err(ArtifactError::Validation(format!( + "published artifact for '{}' does not match its push-plan item", + self.service + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct DigestEvidence { + pub push_output_digest: Option, + pub inspected_digest: Option, +} + +pub fn resolve_digest(evidence: DigestEvidence) -> Result { + match (evidence.push_output_digest, evidence.inspected_digest) { + (Some(push), Some(inspected)) => { + crate::oci::validate_sha256_digest(&push)?; + crate::oci::validate_sha256_digest(&inspected)?; + if push != inspected { + return Err(ArtifactError::DigestMismatch { + expected: inspected, + actual: push, + }); + } + Ok(inspected) + } + (Some(push), None) => { + crate::oci::validate_sha256_digest(&push)?; + Ok(push) + } + (None, Some(inspected)) => { + crate::oci::validate_sha256_digest(&inspected)?; + Ok(inspected) + } + (None, None) => Err(ArtifactError::MissingDigest( + "No digest provided".to_string(), + )), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ImageArtifactReport { + pub published_artifacts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImagePushPlanAction { + WouldPush, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImagePushPlanItem { + pub service: String, + pub registry: String, + pub repository: String, + #[serde(alias = "image_ref")] + pub target_image_ref: String, + pub local_image_ref: String, + pub tag: String, + pub provenance: ImageProvenance, + pub action: ImagePushPlanAction, +} + +impl ImagePushPlanItem { + pub fn validate(&self) -> Result<(), ArtifactError> { + if self.service.trim().is_empty() { + return Err(ArtifactError::Validation( + "push-plan service cannot be blank".to_string(), + )); + } + if self.registry.trim().is_empty() { + return Err(ArtifactError::Validation( + "push-plan registry cannot be blank".to_string(), + )); + } + if self.repository.trim().is_empty() { + return Err(ArtifactError::Validation( + "push-plan repository cannot be blank".to_string(), + )); + } + if self.tag.trim().is_empty() { + return Err(ArtifactError::Validation( + "push-plan tag cannot be blank".to_string(), + )); + } + if self.local_image_ref.trim().is_empty() { + return Err(ArtifactError::Validation( + "local image reference cannot be blank".to_string(), + )); + } + crate::oci::validate_repository_component(&self.service)?; + crate::oci::validate_tag(&self.tag)?; + self.provenance.validate()?; + let expected_target = format!("{}/{}:{}", self.registry, self.repository, self.tag); + if self.target_image_ref != expected_target { + return Err(ArtifactError::Validation(format!( + "target_image_ref mismatch: expected {}, got {}", + expected_target, self.target_image_ref + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImagePushPlanReport { + pub environment: String, + pub mutates_registry: bool, + pub items: Vec, +} + +impl ImagePushPlanReport { + pub fn validate(&self) -> Result<(), ArtifactError> { + if self.environment.trim().is_empty() { + return Err(ArtifactError::Validation( + "push-plan environment cannot be blank".to_string(), + )); + } + if self.items.is_empty() && self.mutates_registry { + return Err(ArtifactError::Validation( + "empty push plan cannot mutate the registry".to_string(), + )); + } + + if self.mutates_registry { + for item in &self.items { + if item + .provenance + .source_revision + .as_deref() + .is_none_or(|revision| revision.trim().is_empty()) + { + return Err(ArtifactError::Validation(format!( + "mutating publication for '{}' requires a source revision", + item.service, + ))); + } + } + } + let mut services = std::collections::BTreeSet::new(); + let mut targets = std::collections::BTreeSet::new(); + for item in &self.items { + item.validate()?; + if !services.insert(item.service.as_str()) { + return Err(ArtifactError::Validation(format!( + "duplicate push-plan service: {}", + item.service + ))); + } + if !targets.insert(item.target_image_ref.as_str()) { + return Err(ArtifactError::Validation(format!( + "duplicate target image reference: {}", + item.target_image_ref + ))); + } + } + Ok(()) + } +} + +pub fn derive_image_tag(build_fingerprint: &str) -> Result { + if build_fingerprint.trim().is_empty() { + return Err(ArtifactError::Validation( + "build fingerprint cannot be blank".to_string(), + )); + } + let tag = build_fingerprint.chars().take(7).collect::(); + crate::oci::validate_tag(&tag)?; + Ok(tag) +} + +pub fn parse_pushed_digest(output: &str) -> Result, ArtifactError> { + let mut digests = std::collections::HashSet::new(); + let mut has_marker = false; + let mut has_invalid_marker = false; + + for line in output.lines() { + let marker = "digest:"; + if let Some(idx) = line.find(marker) { + has_marker = true; + let rest = line[idx + marker.len()..].trim(); + if let Some(digest) = rest.split_whitespace().next() { + if crate::oci::validate_sha256_digest(digest).is_ok() { + digests.insert(digest.to_string()); + } else { + has_invalid_marker = true; + } + } else { + has_invalid_marker = true; + } + } + } + + if has_invalid_marker { + return Err(ArtifactError::Validation( + "push output contained an invalid digest marker".to_string(), + )); + } + + if digests.is_empty() { + if has_marker { + return Err(ArtifactError::Validation( + "push output contained digest markers but no valid digest".to_string(), + )); + } + return Ok(None); + } + + if digests.len() > 1 { + return Err(ArtifactError::Validation( + "ambiguous push output contained multiple digests".to_string(), + )); + } + + Ok(Some(digests.into_iter().next().unwrap())) +} + +pub fn pushed_artifact_from_output( + environment: &str, + item: &ImagePushPlanItem, + output: &str, + structured_digest: Option<&str>, +) -> Result { + let parsed_digest = parse_pushed_digest(output).map_err(|e| e.to_string())?; + + let evidence = DigestEvidence { + push_output_digest: parsed_digest, + inspected_digest: structured_digest.map(|s| s.to_string()), + }; + + let digest = resolve_digest(evidence) + .map_err(|e| format!("digest error for {}: {}", item.target_image_ref, e))?; + + let published_at = chrono::Utc::now().to_rfc3339(); + + PublishedImageArtifact::from_push_result(environment, item, &digest, &published_at) + .map_err(|e| format!("invalid published artifact: {:?}", e)) +} + +#[cfg(test)] +mod tests_derive { + use super::*; + #[test] + fn test_derive_image_tag() { + assert_eq!( + derive_image_tag("2bcc3f70984bb6d33d93bbcbb9eb3539ce033dc8").unwrap(), + "2bcc3f7" + ); + assert_eq!(derive_image_tag("abc").unwrap(), "abc"); + assert!(derive_image_tag(" ").is_err()); + } + + #[test] + fn parses_digest_from_stdout_style_output() { + let output = "latest: digest: sha256:d3f443b7e71c6628b030c6a53fef1c9b6f87452140416cd64b547285227fbd87 size: 1234"; + assert_eq!( + parse_pushed_digest(output).unwrap().as_deref(), + Some("sha256:d3f443b7e71c6628b030c6a53fef1c9b6f87452140416cd64b547285227fbd87") + ); + } + + #[test] + fn parses_digest_from_stderr_style_combined_output() { + let output = "some stdout\nlatest: digest: sha256:d3f443b7e71c6628b030c6a53fef1c9b6f87452140416cd64b547285227fbd87 size: 1234"; + assert_eq!( + parse_pushed_digest(output).unwrap().as_deref(), + Some("sha256:d3f443b7e71c6628b030c6a53fef1c9b6f87452140416cd64b547285227fbd87") + ); + } + + #[test] + fn test_parse_pushed_digest_not_found() { + let output = "Some output without digest"; + assert_eq!(parse_pushed_digest(output).unwrap(), None); + } + + #[test] + fn test_pushed_artifact_from_output_success() { + let item = ImagePushPlanItem { + service: "api".to_string(), + registry: "ghcr.io".to_string(), + repository: "org/api".to_string(), + tag: "latest".to_string(), + target_image_ref: "ghcr.io/org/api:latest".to_string(), + local_image_ref: "ghcr.io/org/api:latest".to_string(), + provenance: ImageProvenance { + build_fingerprint: "abc12345".to_string(), + source_revision: Some("abc12345".to_string()), + }, + action: ImagePushPlanAction::WouldPush, + }; + let output = + "digest: sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let artifact = pushed_artifact_from_output("prod", &item, output, None).unwrap(); + assert_eq!( + artifact.digest, + "sha256:0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(artifact.image_ref, "ghcr.io/org/api@sha256:0000000000000000000000000000000000000000000000000000000000000000"); + } + + #[test] + fn test_pushed_artifact_from_output_failure() { + let item = ImagePushPlanItem { + service: "api".to_string(), + registry: "ghcr.io".to_string(), + repository: "org/api".to_string(), + tag: "latest".to_string(), + target_image_ref: "ghcr.io/org/api:latest".to_string(), + local_image_ref: "ghcr.io/org/api:latest".to_string(), + provenance: ImageProvenance { + build_fingerprint: "dev".to_string(), + source_revision: Some("dev".to_string()), + }, + action: ImagePushPlanAction::WouldPush, + }; + let output = "no digest here"; + let err = pushed_artifact_from_output("prod", &item, output, None).unwrap_err(); + assert!(err.contains("digest error")); + } + + #[test] + fn pushed_digest_parser_rejects_invalid_and_conflicting_evidence() { + let a = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let b = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + assert_eq!( + parse_pushed_digest(&format!("digest: {a}\ndigest: {a}")) + .unwrap() + .as_deref(), + Some(a) + ); + assert!(parse_pushed_digest(&format!("digest: {a}\ndigest: {b}")).is_err()); + for invalid in [ + "sha256:abc", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "sha256:12345678901234567890123456789012345678901234", + ] { + assert!(parse_pushed_digest(&format!("digest: {invalid}")).is_err()); + assert!(parse_pushed_digest(&format!("digest: {a}\ndigest: {invalid}")).is_err()); + } + } + + #[test] + fn digest_evidence_accepts_single_or_matching_sources_and_rejects_mismatch() { + let a = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let b = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + for evidence in [ + DigestEvidence { + push_output_digest: Some(a.to_string()), + inspected_digest: None, + }, + DigestEvidence { + push_output_digest: None, + inspected_digest: Some(a.to_string()), + }, + DigestEvidence { + push_output_digest: Some(a.to_string()), + inspected_digest: Some(a.to_string()), + }, + ] { + assert_eq!(resolve_digest(evidence).unwrap(), a); + } + assert!(resolve_digest(DigestEvidence { + push_output_digest: Some(a.to_string()), + inspected_digest: Some(b.to_string()), + }) + .is_err()); + assert!(resolve_digest(DigestEvidence { + push_output_digest: None, + inspected_digest: None, + }) + .is_err()); + } +} + +#[cfg(test)] +mod tests_addendum { + use super::*; + + fn valid_item(service: &str, target: &str) -> ImagePushPlanItem { + ImagePushPlanItem { + service: service.to_string(), + registry: "ghcr.io".to_string(), + repository: format!("acme/{service}"), + tag: "abc1234".to_string(), + target_image_ref: target.to_string(), + local_image_ref: format!("ghcr.io/acme/{service}:1.0.0"), + provenance: ImageProvenance { + build_fingerprint: "abc123456789".to_string(), + source_revision: Some("revision".to_string()), + }, + action: ImagePushPlanAction::WouldPush, + } + } + + #[test] + fn image_push_plan_report_serializes() { + let report = ImagePushPlanReport { + environment: "staging".to_string(), + mutates_registry: false, + items: vec![ImagePushPlanItem { + service: "ci-build-hello".to_string(), + registry: "ghcr.io".to_string(), + repository: "adriftdev/sailr/ci-build-hello".to_string(), + tag: "61eaa8b".to_string(), + target_image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + local_image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + provenance: ImageProvenance { + build_fingerprint: "61eaa8bb0e52f5bb1d5a621760b0a2eae601ccd3".to_string(), + source_revision: Some("61eaa8bb0e52f5bb1d5a621760b0a2eae601ccd3".to_string()), + }, + action: ImagePushPlanAction::WouldPush, + }], + }; + + let json = serde_json::to_value(report).unwrap(); + + assert_eq!(json["environment"], "staging"); + assert_eq!(json["mutates_registry"], false); + assert_eq!(json["items"][0]["action"], "would_push"); + } + + #[test] + fn validates_push_plan_items_and_duplicate_contracts() { + let item = valid_item("api", "ghcr.io/acme/api:abc1234"); + item.validate().unwrap(); + + let mut invalid_target = item.clone(); + invalid_target.target_image_ref = "docker.io/other/api:abc1234".to_string(); + assert!(invalid_target.validate().is_err()); + + assert!(ImagePushPlanReport { + environment: "staging".to_string(), + mutates_registry: true, + items: vec![item.clone(), item.clone()], + } + .validate() + .is_err()); + + let mut same_target = valid_item("web", "ghcr.io/acme/web:abc1234"); + same_target.target_image_ref = item.target_image_ref.clone(); + same_target.repository = item.repository.clone(); + assert!(ImagePushPlanReport { + environment: "staging".to_string(), + mutates_registry: true, + items: vec![item, same_target], + } + .validate() + .is_err()); + + assert!(ImagePushPlanReport { + environment: "staging".to_string(), + mutates_registry: true, + items: vec![], + } + .validate() + .is_err()); + } +} diff --git a/src/workflow/mod.rs b/src/workflow/mod.rs index 3d5c8d2..b57ceaa 100644 --- a/src/workflow/mod.rs +++ b/src/workflow/mod.rs @@ -2,8 +2,10 @@ pub mod builder_context; pub mod ci; pub mod config; pub mod error; +pub mod image; pub mod plan; pub mod planner; pub mod profile; pub mod render; pub mod runner; +pub mod task_id; diff --git a/src/workflow/plan.rs b/src/workflow/plan.rs index 67a72b2..e00e671 100644 --- a/src/workflow/plan.rs +++ b/src/workflow/plan.rs @@ -1,7 +1,7 @@ use crate::builder::SailrBuildPlan; use crate::workflow::profile::NormalizedWorkflowProfile; use crate::workflow::runner::RunnerContext; -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone)] pub struct WorkflowPlan { @@ -10,9 +10,42 @@ pub struct WorkflowPlan { pub tasks: Vec, pub edges: Vec, pub build_plan: Option, + pub image_push_plan: Option, + pub finalizers: Vec, pub effects: WorkflowEffects, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowFinalizerPhase { + BeforeReport, + ReportSink, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkflowFinalizerKind { + WriteBuildCache, + WriteWorkflowReport, +} + +impl WorkflowFinalizerKind { + pub fn phase(self) -> WorkflowFinalizerPhase { + match self { + WorkflowFinalizerKind::WriteBuildCache => WorkflowFinalizerPhase::BeforeReport, + WorkflowFinalizerKind::WriteWorkflowReport => WorkflowFinalizerPhase::ReportSink, + } + } +} + +#[derive(Debug, Clone)] +pub struct WorkflowFinalizerPlan { + pub id: String, + pub label: String, + pub kind: WorkflowFinalizerKind, + pub effects: WorkflowEffects, + pub description: String, +} + #[derive(Debug, Clone)] pub struct WorkflowTaskPlan { pub id: String, @@ -28,6 +61,9 @@ pub enum WorkflowTaskKind { ValidateConfig, BuildPlan, ServiceBuild, + PushPlan, + ServicePush, + ImageReport, Generate, DeploymentPlan, Deploy, @@ -41,22 +77,43 @@ pub struct WorkflowEdge { pub to: String, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkflowEffects { pub mutates_filesystem: bool, pub mutates_docker: bool, pub mutates_registry: bool, + pub mutates_git: bool, pub mutates_cluster: bool, pub prompts_user: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +impl WorkflowEffects { + pub fn merge(&mut self, other: &WorkflowEffects) { + self.mutates_filesystem |= other.mutates_filesystem; + self.mutates_docker |= other.mutates_docker; + self.mutates_registry |= other.mutates_registry; + self.mutates_git |= other.mutates_git; + self.mutates_cluster |= other.mutates_cluster; + self.prompts_user |= other.prompts_user; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DeliveryTargetKind { + RenderOnly, + #[serde(rename = "gitops")] + GitOps, + KubernetesDirect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DeploymentPlanMode { Static, LiveDiff, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkflowDeploymentPlan { pub environment: String, pub context: String, @@ -66,7 +123,7 @@ pub struct WorkflowDeploymentPlan { pub summary: DeploymentPlanSummary, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeploymentResourcePlan { pub kind: String, pub name: String, @@ -75,7 +132,7 @@ pub struct DeploymentResourcePlan { pub action: DeploymentPlanAction, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeploymentPlanAction { WouldApply, @@ -95,7 +152,7 @@ impl std::fmt::Display for DeploymentPlanAction { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeploymentPlanSummary { pub total_resources: usize, pub would_apply: usize, diff --git a/src/workflow/planner.rs b/src/workflow/planner.rs index b820d49..47274b1 100644 --- a/src/workflow/planner.rs +++ b/src/workflow/planner.rs @@ -1,9 +1,12 @@ use runkernel::{Pipeline, Task}; -use crate::builder::{add_runkernel_tasks, create_sailr_build_plan, BuildOptions, SailrBuildPlan}; +use crate::builder::{ + add_runkernel_tasks_from_workflow_plan, create_sailr_build_plan, BuildOptions, SailrBuildPlan, +}; use crate::environment::Environment; use crate::workflow::plan::{ - WorkflowEdge, WorkflowEffects, WorkflowPlan, WorkflowTaskKind, WorkflowTaskPlan, + WorkflowEdge, WorkflowEffects, WorkflowFinalizerKind, WorkflowFinalizerPlan, WorkflowPlan, + WorkflowTaskKind, WorkflowTaskPlan, }; use crate::workflow::runner::RunnerContext; @@ -17,11 +20,122 @@ pub enum WorkflowBuildExecution { Executed(SailrBuildPlan), } +fn runtime_task(plan: &WorkflowPlan, id: &str) -> Result { + let task = plan + .tasks + .iter() + .find(|task| task.id == id) + .ok_or_else(|| format!("Task '{id}' is missing from the workflow plan"))?; + let dependencies = task + .dependencies + .iter() + .map(String::as_str) + .collect::>(); + Ok(Task::new(id) + .description(task.description.clone()) + .depends_on(&dependencies)) +} + pub struct WorkflowPlanner { pub profile: NormalizedWorkflowProfile, pub env: Arc, pub options: BuildOptions, pub runner: RunnerContext, + source_revision_resolver: Arc, +} + +pub trait SourceRevisionResolver: Send + Sync { + fn resolve( + &self, + runner: &RunnerContext, + ) -> Result, crate::workflow::error::ProvenanceError>; +} + +#[derive(Debug, Default)] +pub struct SystemSourceRevisionResolver; + +fn validate_source_revision( + value: String, +) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err( + crate::workflow::error::ProvenanceError::InvalidSourceRevision( + "Source revision is empty".to_string(), + ), + ); + } + if trimmed.chars().any(char::is_whitespace) { + return Err( + crate::workflow::error::ProvenanceError::InvalidSourceRevision( + "Source revision cannot contain whitespace".to_string(), + ), + ); + } + Ok(trimmed) +} + +impl SourceRevisionResolver for SystemSourceRevisionResolver { + fn resolve( + &self, + runner: &RunnerContext, + ) -> Result, crate::workflow::error::ProvenanceError> { + resolve_source_revision_with( + runner, + |variable| match std::env::var(variable) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err( + crate::workflow::error::ProvenanceError::InvalidSourceRevision(format!( + "{variable} is not valid Unicode" + )), + ), + }, + || { + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|error| { + crate::workflow::error::ProvenanceError::Git(error.to_string()) + })?; + if !output.status.success() { + return Err(crate::workflow::error::ProvenanceError::Git( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + }, + ) + } +} + +fn resolve_source_revision_with( + runner: &RunnerContext, + read_environment: E, + read_git: G, +) -> Result, crate::workflow::error::ProvenanceError> +where + E: Fn(&str) -> Result, crate::workflow::error::ProvenanceError>, + G: Fn() -> Result, +{ + let provider_variable = + runner + .ci_environment + .as_ref() + .and_then(|environment| match environment.provider { + crate::workflow::ci::CiProvider::GitHub => Some("GITHUB_SHA"), + crate::workflow::ci::CiProvider::CircleCi => Some("CIRCLE_SHA1"), + crate::workflow::ci::CiProvider::Travis => Some("TRAVIS_COMMIT"), + crate::workflow::ci::CiProvider::Generic => None, + }); + + if let Some(variable) = provider_variable { + if let Some(value) = read_environment(variable)? { + return validate_source_revision(value).map(Some); + } + } + + validate_source_revision(read_git()?).map(Some) } impl WorkflowPlanner { @@ -36,25 +150,43 @@ impl WorkflowPlanner { env, options, runner, + source_revision_resolver: Arc::new(SystemSourceRevisionResolver), + } + } + + pub fn with_source_revision_resolver( + profile: NormalizedWorkflowProfile, + env: Arc, + options: BuildOptions, + runner: RunnerContext, + source_revision_resolver: Arc, + ) -> Self { + Self { + profile, + env, + options, + runner, + source_revision_resolver, } } pub fn plan(&self) -> Result { let mut tasks = Vec::new(); - let mut edges = Vec::new(); - let mut effects = WorkflowEffects::default(); + let mut finalizers = Vec::new(); + let mut effects; let mut build_plan_opt = None; + let mut image_push_plan_opt = None; // 0. Validate Phase tasks.push(WorkflowTaskPlan { - id: "workflow:validate-config".to_string(), + id: crate::workflow::task_id::VALIDATE_CONFIG.to_string(), label: "Validate Config".to_string(), kind: WorkflowTaskKind::ValidateConfig, dependencies: vec![], effects: WorkflowEffects::default(), description: "Validates Sailr environment configuration.".to_string(), }); - let mut last_tasks = vec!["workflow:validate-config".to_string()]; + let mut last_tasks = vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()]; // 1. Build Phase match self.profile.build { @@ -68,94 +200,214 @@ impl WorkflowPlanner { let task_effects = WorkflowEffects::default(); tasks.push(WorkflowTaskPlan { - id: "workflow:build-plan".to_string(), + id: crate::workflow::task_id::BUILD_PLAN.to_string(), label: "Build Plan".to_string(), kind: WorkflowTaskKind::BuildPlan, - dependencies: vec!["workflow:validate-config".to_string()], + dependencies: vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()], effects: task_effects, description: "Analyzes services to determine what needs to be built." .to_string(), }); - edges.push(WorkflowEdge { - from: "workflow:validate-config".to_string(), - to: "workflow:build-plan".to_string(), - }); - - last_tasks = vec!["workflow:build-plan".to_string()]; + last_tasks = vec![crate::workflow::task_id::BUILD_PLAN.to_string()]; } crate::workflow::profile::WorkflowStepMode::Run => { let plan = create_sailr_build_plan(&self.env, &self.options)?; build_plan_opt = Some(plan.clone()); - let dirty_count = plan.services.iter().filter(|s| s.dirty).count(); + let dirty_services = plan + .services + .iter() + .filter(|service| service.dirty) + .map(|service| service.service.name.as_str()) + .collect::>(); + let has_before_all = !dirty_services.is_empty() && !plan.before_all.is_empty(); - effects.mutates_docker = true; - effects.mutates_registry = true; + if has_before_all { + tasks.push(WorkflowTaskPlan { + id: crate::workflow::task_id::BUILD_BEFORE_ALL.to_string(), + label: "Before All Build Hooks".to_string(), + kind: WorkflowTaskKind::ServiceBuild, + dependencies: vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()], + effects: WorkflowEffects { + mutates_filesystem: true, + ..Default::default() + }, + description: "Runs before-all build hooks.".to_string(), + }); + } let mut build_tasks = Vec::new(); for s in &plan.services { if s.dirty { let service_effects = WorkflowEffects { mutates_docker: true, - mutates_registry: true, ..Default::default() }; + let task_id = crate::workflow::task_id::service_build(&s.service.name); + let mut dependencies = s + .dependencies + .iter() + .filter(|dependency| dirty_services.contains(dependency.as_str())) + .map(|dependency| crate::workflow::task_id::service_build(dependency)) + .collect::>(); + if has_before_all { + dependencies + .push(crate::workflow::task_id::BUILD_BEFORE_ALL.to_string()); + } + if dependencies.is_empty() { + dependencies + .push(crate::workflow::task_id::VALIDATE_CONFIG.to_string()); + } + dependencies.sort(); + dependencies.dedup(); tasks.push(WorkflowTaskPlan { - id: format!("build:{}", s.service.name), + id: task_id.clone(), label: format!("Build {}", s.service.name), kind: WorkflowTaskKind::ServiceBuild, - dependencies: vec!["workflow:validate-config".to_string()], + dependencies, effects: service_effects, description: format!( - "Builds and pushes Docker image for {}.", + "Builds the local Docker image for {}.", s.service.name ), }); - edges.push(WorkflowEdge { - from: "workflow:validate-config".to_string(), - to: format!("build:{}", s.service.name), - }); - build_tasks.push(format!("build:{}", s.service.name)); + build_tasks.push(task_id); } } if build_tasks.is_empty() { - build_tasks = vec!["workflow:validate-config".to_string()]; + build_tasks = vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()]; } - if dirty_count > 0 && !plan.after_all.is_empty() { + if !dirty_services.is_empty() && !plan.after_all.is_empty() { tasks.push(WorkflowTaskPlan { - id: "build:after-all".to_string(), + id: crate::workflow::task_id::BUILD_AFTER_ALL.to_string(), label: "After All Build Hooks".to_string(), kind: WorkflowTaskKind::ServiceBuild, dependencies: build_tasks.clone(), - effects: WorkflowEffects::default(), + effects: WorkflowEffects { + mutates_filesystem: true, + ..Default::default() + }, description: "Runs after-all build hooks.".to_string(), }); - for t in &build_tasks { - edges.push(WorkflowEdge { - from: t.clone(), - to: "build:after-all".to_string(), - }); - } - build_tasks = vec!["build:after-all".to_string()]; + build_tasks = vec![crate::workflow::task_id::BUILD_AFTER_ALL.to_string()]; } last_tasks = build_tasks; } } + // 1.5 Push Phase + match self.profile.push { + crate::workflow::profile::WorkflowStepMode::Disabled => {} + crate::workflow::profile::WorkflowStepMode::DryRun => { + return Err("workflow push dry-run is not supported".to_string()); + } + crate::workflow::profile::WorkflowStepMode::Plan + | crate::workflow::profile::WorkflowStepMode::Run => { + let has_publications = build_plan_opt + .as_ref() + .map(|bp| bp.services.iter().any(|s| s.dirty)) + .unwrap_or(false); + + let is_run = self.profile.push == crate::workflow::profile::WorkflowStepMode::Run; + + let source_revision = match (is_run, has_publications) { + (true, true) => Some( + self.source_revision_resolver + .resolve(&self.runner) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + crate::workflow::error::ProvenanceError::MissingSourceRevision + .to_string() + })?, + ), + (true, false) => None, + (false, _) => self + .source_revision_resolver + .resolve(&self.runner) + .ok() + .flatten(), + }; + + tasks.push(WorkflowTaskPlan { + id: crate::workflow::task_id::PUSH_PLAN.to_string(), + label: "Push Plan".to_string(), + kind: WorkflowTaskKind::PushPlan, + dependencies: last_tasks.clone(), + effects: WorkflowEffects::default(), + description: "Determine target images and tags without pushing.".to_string(), + }); + last_tasks = vec![crate::workflow::task_id::PUSH_PLAN.to_string()]; + + if let Some(ref bp) = build_plan_opt { + image_push_plan_opt = + Some(self.build_image_push_plan_report(bp, is_run, source_revision)?); + } else { + return Err("push requires build=plan or build=run".to_string()); + } + + if is_run { + let mut push_tasks = Vec::new(); + for item in &image_push_plan_opt + .as_ref() + .expect("push plan exists") + .items + { + let mut dependencies = + vec![crate::workflow::task_id::PUSH_PLAN.to_string()]; + let build_task = crate::workflow::task_id::service_build(&item.service); + if tasks.iter().any(|task| task.id == build_task) { + dependencies.push(build_task); + } + dependencies.sort(); + dependencies.dedup(); + + let push_task = crate::workflow::task_id::service_push(&item.service); + tasks.push(WorkflowTaskPlan { + id: push_task.clone(), + label: format!("Push {}", item.service), + kind: WorkflowTaskKind::ServicePush, + dependencies, + effects: WorkflowEffects { + mutates_docker: true, + mutates_registry: true, + ..Default::default() + }, + description: format!( + "Publishes {} as {}.", + item.local_image_ref, item.target_image_ref + ), + }); + push_tasks.push(push_task); + } + + if !push_tasks.is_empty() { + tasks.push(WorkflowTaskPlan { + id: crate::workflow::task_id::IMAGE_REPORT.to_string(), + label: "Finalize Image Artifacts".to_string(), + kind: WorkflowTaskKind::ImageReport, + dependencies: push_tasks, + effects: WorkflowEffects::default(), + description: "Ensures all service publication tasks have completed before workflow finalization.".to_string(), + }); + last_tasks = vec![crate::workflow::task_id::IMAGE_REPORT.to_string()]; + } + } + } + } + // 2. Generate Phase if self.profile.generate.is_active() { - effects.mutates_filesystem = true; let generate_effects = WorkflowEffects { mutates_filesystem: true, ..Default::default() }; tasks.push(WorkflowTaskPlan { - id: "workflow:generate".to_string(), + id: crate::workflow::task_id::GENERATE.to_string(), label: "Generate Manifests".to_string(), kind: WorkflowTaskKind::Generate, dependencies: last_tasks.clone(), @@ -163,19 +415,13 @@ impl WorkflowPlanner { description: "Generates Kubernetes manifests.".to_string(), }); - for t in &last_tasks { - edges.push(WorkflowEdge { - from: t.clone(), - to: "workflow:generate".to_string(), - }); - } - last_tasks = vec!["workflow:generate".to_string()]; + last_tasks = vec![crate::workflow::task_id::GENERATE.to_string()]; } // 3. Deploy Phase if self.profile.deploy.is_active() { tasks.push(WorkflowTaskPlan { - id: "workflow:deployment-plan".to_string(), + id: crate::workflow::task_id::DEPLOYMENT_PLAN.to_string(), label: "Deployment Plan".to_string(), kind: WorkflowTaskKind::DeploymentPlan, dependencies: last_tasks.clone(), @@ -185,20 +431,12 @@ impl WorkflowPlanner { .to_string(), }); - for t in &last_tasks { - edges.push(WorkflowEdge { - from: t.clone(), - to: "workflow:deployment-plan".to_string(), - }); - } - - last_tasks = vec!["workflow:deployment-plan".to_string()]; + last_tasks = vec![crate::workflow::task_id::DEPLOYMENT_PLAN.to_string()]; if self.profile.deploy == crate::workflow::profile::WorkflowStepMode::Run { if self.profile.approval == crate::workflow::profile::ApprovalMode::Prompt { - effects.prompts_user = true; tasks.push(WorkflowTaskPlan { - id: "workflow:approval".to_string(), + id: crate::workflow::task_id::APPROVAL.to_string(), label: "Approval".to_string(), kind: WorkflowTaskKind::Approval, dependencies: last_tasks.clone(), @@ -211,20 +449,12 @@ impl WorkflowPlanner { .to_string(), }); - for t in &last_tasks { - edges.push(WorkflowEdge { - from: t.clone(), - to: "workflow:approval".to_string(), - }); - } - - last_tasks = vec!["workflow:approval".to_string()]; + last_tasks = vec![crate::workflow::task_id::APPROVAL.to_string()]; } if self.profile.apply { - effects.mutates_cluster = true; tasks.push(WorkflowTaskPlan { - id: "workflow:deploy".to_string(), + id: crate::workflow::task_id::DEPLOY.to_string(), label: "Deploy".to_string(), kind: WorkflowTaskKind::Deploy, dependencies: last_tasks.clone(), @@ -236,42 +466,149 @@ impl WorkflowPlanner { "Apply generated manifests to the configured Kubernetes context." .to_string(), }); + } + } + } - for t in &last_tasks { - edges.push(WorkflowEdge { - from: t.clone(), - to: "workflow:deploy".to_string(), - }); - } + if let Some(plan) = &build_plan_opt { + if self.profile.build == crate::workflow::profile::WorkflowStepMode::Run { + let dirty_services = plan.services.iter().filter(|s| s.dirty).count(); + if dirty_services > 0 { + finalizers.push(WorkflowFinalizerPlan { + id: crate::workflow::task_id::WRITE_BUILD_CACHE_FINALIZER.to_string(), + label: "Write Build Caches".to_string(), + kind: WorkflowFinalizerKind::WriteBuildCache, + effects: WorkflowEffects { + mutates_filesystem: true, + ..Default::default() + }, + description: "Updates local build fingerprint caches for successfully built services.".to_string(), + }); } } } + if matches!( + self.profile.report, + crate::workflow::profile::ReportMode::Json | crate::workflow::profile::ReportMode::Both + ) { + finalizers.push(WorkflowFinalizerPlan { + id: crate::workflow::task_id::WRITE_REPORT_FINALIZER.to_string(), + label: "Write Workflow Report".to_string(), + kind: WorkflowFinalizerKind::WriteWorkflowReport, + effects: WorkflowEffects { + mutates_filesystem: true, + ..Default::default() + }, + description: + "Writes the versioned workflow execution report after pipeline completion." + .to_string(), + }); + } + + effects = WorkflowEffects::default(); + for task in &tasks { + effects.merge(&task.effects); + } + for finalizer in &finalizers { + effects.merge(&finalizer.effects); + } + let edges = tasks + .iter() + .flat_map(|task| { + task.dependencies.iter().map(|dependency| WorkflowEdge { + from: dependency.clone(), + to: task.id.clone(), + }) + }) + .collect(); + Ok(WorkflowPlan { profile: self.profile.clone(), runner: self.runner.clone(), tasks, edges, build_plan: build_plan_opt, + image_push_plan: image_push_plan_opt, + finalizers, effects, }) } + fn build_image_push_plan_report( + &self, + build_plan: &crate::builder::SailrBuildPlan, + is_run: bool, + source_revision: Option, + ) -> Result { + let mut items = Vec::new(); + + for service_plan in &build_plan.services { + if !service_plan.dirty { + continue; + } + + let resolved_registry = self + .env + .registry + .resolve() + .map_err(|e| format!("Invalid registry: {}", e))?; + + let repository = resolved_registry + .repository_for(&service_plan.service.name) + .map_err(|e| format!("Invalid repository: {}", e))?; + + let tag = crate::workflow::image::derive_image_tag(&service_plan.fingerprint.full_hash) + .map_err(|error| error.to_string())?; + + let target_image_ref = resolved_registry + .tagged_ref(&service_plan.service.name, &tag) + .map_err(|e| format!("Invalid target ref: {}", e))?; + + let local_image_ref = resolved_registry + .tagged_ref(&service_plan.service.name, &service_plan.service.version) + .map_err(|e| format!("Invalid local ref: {}", e))?; + + items.push(crate::workflow::image::ImagePushPlanItem { + service: service_plan.service.name.clone(), + registry: resolved_registry.host, + repository, + target_image_ref, + local_image_ref, + tag, + provenance: crate::workflow::image::ImageProvenance { + build_fingerprint: service_plan.fingerprint.full_hash.clone(), + source_revision: source_revision.clone(), + }, + action: crate::workflow::image::ImagePushPlanAction::WouldPush, + }); + } + + let report = crate::workflow::image::ImagePushPlanReport { + environment: self.profile.environment.clone(), + mutates_registry: is_run && !items.is_empty(), + items, + }; + report.validate().map_err(|error| error.to_string())?; + Ok(report) + } + pub fn build_pipeline_from_plan( &self, plan: &WorkflowPlan, + accumulator: crate::workflow::image::WorkflowReportAccumulator, ) -> Result<(Pipeline, WorkflowBuildExecution), String> { let mut pipeline = Pipeline::new(format!("Workflow: {}", self.profile.name)); let mut build_execution = WorkflowBuildExecution::None; - let validate_task = Task::new("workflow:validate-config").exec_fn(move |_ctx| async move { - crate::LOGGER.info("Validating Sailr environment config..."); - Ok(()) - }); + let validate_task = runtime_task(plan, crate::workflow::task_id::VALIDATE_CONFIG)?.exec_fn( + move |_ctx| async move { + crate::LOGGER.info("Validating Sailr environment config..."); + Ok(()) + }, + ); pipeline.add(validate_task); - let mut last_tasks: Vec = vec!["workflow:validate-config".to_string()]; - match self.profile.build { crate::workflow::profile::WorkflowStepMode::Disabled => {} crate::workflow::profile::WorkflowStepMode::DryRun => { @@ -281,48 +618,197 @@ impl WorkflowPlanner { let p = plan.build_plan.clone().unwrap(); let o = self.options.clone(); - let task = Task::new("workflow:build-plan") - .depends_on(&["workflow:validate-config"]) - .exec_fn(move |_ctx| { + let task = runtime_task(plan, crate::workflow::task_id::BUILD_PLAN)?.exec_fn( + move |_ctx| { let p = p.clone(); let o = o.clone(); async move { crate::builder::print_sailr_plan(&p, &o); Ok(()) } - }); + }, + ); pipeline.add(task); - last_tasks = vec!["workflow:build-plan".to_string()]; build_execution = WorkflowBuildExecution::PlanOnly(plan.build_plan.clone().unwrap()); } crate::workflow::profile::WorkflowStepMode::Run => { let bp = plan.build_plan.clone().unwrap(); - add_runkernel_tasks(&mut pipeline, &bp)?; + add_runkernel_tasks_from_workflow_plan(&mut pipeline, &bp, &plan.tasks)?; + build_execution = WorkflowBuildExecution::Executed(bp); + } + } - let dirty_count = bp.services.iter().filter(|s| s.dirty).count(); - let mut build_deps = Vec::new(); - if dirty_count > 0 && !bp.after_all.is_empty() { - build_deps.push("build:after-all".to_string()); - } else { - for s in &bp.services { - build_deps.push(s.service.name.clone()); + match self.profile.push { + crate::workflow::profile::WorkflowStepMode::Disabled => {} + crate::workflow::profile::WorkflowStepMode::DryRun => { + return Err("workflow push dry-run is not supported".to_string()); + } + crate::workflow::profile::WorkflowStepMode::Plan => { + let push_plan = plan.image_push_plan.clone().unwrap(); + let mut task = runtime_task(plan, crate::workflow::task_id::PUSH_PLAN)?; + + task = task.exec_fn(move |_ctx| { + let push_plan = push_plan.clone(); + async move { + crate::LOGGER.info(&crate::workflow::render::render_image_push_plan_text( + &push_plan, + )); + Ok(()) + } + }); + + pipeline.add(task); + } + crate::workflow::profile::WorkflowStepMode::Run => { + let push_plan = plan.image_push_plan.clone().unwrap(); + let rendered_push_plan = push_plan.clone(); + pipeline.add( + runtime_task(plan, crate::workflow::task_id::PUSH_PLAN)?.exec_fn(move |_ctx| { + let rendered_push_plan = rendered_push_plan.clone(); + async move { + crate::LOGGER.info( + &crate::workflow::render::render_image_push_plan_text( + &rendered_push_plan, + ), + ); + Ok(()) + } + }), + ); + + for item in &push_plan.items { + if item.action == crate::workflow::image::ImagePushPlanAction::WouldPush { + let service_name = item.service.clone(); + let target_image_ref = item.target_image_ref.clone(); + let local_image_ref = item.local_image_ref.clone(); + let accumulator = accumulator.clone(); + let item_clone = item.clone(); + let env_clone = self.env.clone(); + + let push_task_name = crate::workflow::task_id::service_push(&service_name); + + let task = runtime_task(plan, &push_task_name)? + .exec_fn(move |_ctx| { + let target_image_ref = target_image_ref.clone(); + let local_image_ref = local_image_ref.clone(); + let accumulator = accumulator.clone(); + let item = item_clone.clone(); + let env_clone = env_clone.clone(); + async move { + crate::LOGGER.info(&format!("Pushing {}", target_image_ref)); + + let mut tag_cmd = tokio::process::Command::new("docker"); + tag_cmd + .arg("tag") + .arg(&local_image_ref) + .arg(&target_image_ref); + let tag_output = tag_cmd.output().await.map_err(|e| { + anyhow::anyhow!("Failed to execute docker tag: {}", e) + })?; + if !tag_output.status.success() { + let stderr = String::from_utf8_lossy(&tag_output.stderr); + return Err(anyhow::anyhow!( + "Docker tag failed. source: {}, target: {}, status: {}, stderr: {}", + local_image_ref, + target_image_ref, + tag_output.status, + stderr.trim() + )); + } + + let mut cmd = tokio::process::Command::new("docker"); + cmd.arg("push").arg(&target_image_ref); + + let output = cmd.output().await.map_err(|e| { + anyhow::anyhow!("Failed to execute docker push: {}", e) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "Docker push failed. target: {}, status: {}, stderr: {}", + target_image_ref, + output.status, + stderr.trim() + )); + } + + let stdout_str = String::from_utf8_lossy(&output.stdout); + let stderr_str = String::from_utf8_lossy(&output.stderr); + let combined_output = format!("{}\n{}", stdout_str, stderr_str); + + let mut inspect_cmd = tokio::process::Command::new("docker"); + inspect_cmd + .arg("inspect") + .arg("--format={{index .RepoDigests 0}}") + .arg(&target_image_ref); + let structured_digest = match inspect_cmd.output().await { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + Some( + stdout + .split_once('@') + .map(|(_, digest)| digest.to_string()) + .unwrap_or(stdout), + ) + } + Ok(output) => { + crate::LOGGER.debug(&format!( + "Docker inspection failed. target: {}, status: {}, stderr: {}", + target_image_ref, + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + None + } + Err(error) => { + crate::LOGGER.debug(&format!( + "Docker inspection could not execute. target: {}, error: {}", + target_image_ref, error + )); + None + } + }; + + let artifact = + crate::workflow::image::pushed_artifact_from_output( + &env_clone.name, + &item, + &combined_output, + structured_digest.as_deref(), + ) + .map_err(|e| anyhow::anyhow!(e))?; + + accumulator.add_image(artifact).await; + + Ok(()) + } + }); + pipeline.add(task); } } - last_tasks.extend(build_deps); - build_execution = WorkflowBuildExecution::Executed(bp); + if plan + .tasks + .iter() + .any(|task| task.id == crate::workflow::task_id::IMAGE_REPORT) + { + let report_task = runtime_task(plan, crate::workflow::task_id::IMAGE_REPORT)? + .exec_fn(|_ctx| async move { + crate::LOGGER.info("Image publication evidence finalized."); + Ok(()) + }); + pipeline.add(report_task); + } } } if self.profile.generate.is_active() { - let mut task = Task::new("workflow:generate"); - - let deps_refs: Vec<&str> = last_tasks.iter().map(|s| s.as_str()).collect(); - if !deps_refs.is_empty() { - task = task.depends_on(&deps_refs); - } + let mut task = runtime_task(plan, crate::workflow::task_id::GENERATE)?; let name = self.profile.environment.clone(); let only = self.options.only.clone(); @@ -351,16 +837,10 @@ impl WorkflowPlanner { }); pipeline.add(task); - last_tasks = vec!["workflow:generate".to_string()]; } if self.profile.deploy.is_active() { - let mut task = Task::new("workflow:deployment-plan"); - - let deps_refs: Vec<&str> = last_tasks.iter().map(|s| s.as_str()).collect(); - if !deps_refs.is_empty() { - task = task.depends_on(&deps_refs); - } + let mut task = runtime_task(plan, crate::workflow::task_id::DEPLOYMENT_PLAN)?; let env_name = self.profile.environment.clone(); let context = self.profile.deploy_context.clone().unwrap_or_default(); @@ -413,14 +893,8 @@ impl WorkflowPlanner { }); pipeline.add(task); - last_tasks = vec!["workflow:deployment-plan".to_string()]; - if self.profile.approval == crate::workflow::profile::ApprovalMode::Prompt { - let mut task = Task::new("workflow:approval"); - let deps_refs: Vec<&str> = last_tasks.iter().map(|s| s.as_str()).collect(); - if !deps_refs.is_empty() { - task = task.depends_on(&deps_refs); - } + let mut task = runtime_task(plan, crate::workflow::task_id::APPROVAL)?; task = task.exec_fn(move |_ctx| async move { let approved = tokio::task::spawn_blocking(|| { @@ -440,17 +914,12 @@ impl WorkflowPlanner { }); pipeline.add(task); - last_tasks = vec!["workflow:approval".to_string()]; } if self.profile.deploy == crate::workflow::profile::WorkflowStepMode::Run && self.profile.apply { - let mut task = Task::new("workflow:deploy"); - let deps_refs: Vec<&str> = last_tasks.iter().map(|s| s.as_str()).collect(); - if !deps_refs.is_empty() { - task = task.depends_on(&deps_refs); - } + let mut task = runtime_task(plan, crate::workflow::task_id::DEPLOY)?; let context = self.profile.deploy_context.clone().unwrap_or_default(); let env_name = self.profile.environment.clone(); @@ -503,6 +972,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: build_mode, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: deploy_mode, test: WorkflowStepMode::Disabled, @@ -534,6 +1004,7 @@ mod tests { kind: RunnerKind::Local, ci: false, interactive: false, + ci_environment: None, } } @@ -545,17 +1016,23 @@ mod tests { let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(false), dummy_runner()); let plan = planner.plan().unwrap(); - let (pipeline, _) = planner.build_pipeline_from_plan(&plan).unwrap(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); let task_names: Vec = pipeline.tasks().map(|t| t.name.clone()).collect(); - assert_eq!(task_names, vec!["workflow:validate-config"]); + assert_eq!( + task_names, + vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()] + ); } #[test] fn ci_build_plan_creates_build_plan() { let mut env = Environment::new("local"); let mut svc = crate::environment::Service::new("dummy", None, "latest"); + let temp_dir = tempfile::tempdir().unwrap(); svc.build = Some(crate::environment::ServiceBuildConfig { - path: ".".to_string(), + path: temp_dir.path().to_string_lossy().to_string(), include: None, relies_on: None, before_synchronous: None, @@ -575,12 +1052,14 @@ mod tests { let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true), dummy_runner()); let plan = planner.plan().unwrap(); - let (pipeline, _) = planner.build_pipeline_from_plan(&plan).unwrap(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); let mut task_names: Vec = pipeline.tasks().map(|t| t.name.clone()).collect(); task_names.sort(); let mut expected = vec![ - "workflow:validate-config".to_string(), - "workflow:build-plan".to_string(), + crate::workflow::task_id::VALIDATE_CONFIG.to_string(), + crate::workflow::task_id::BUILD_PLAN.to_string(), ]; expected.sort(); assert_eq!(task_names, expected); @@ -590,8 +1069,9 @@ mod tests { fn ci_generate_creates_generate() { let mut env = Environment::new("local"); let mut svc = crate::environment::Service::new("dummy", None, "latest"); + let _temp_dir = tempfile::tempdir().unwrap(); svc.build = Some(crate::environment::ServiceBuildConfig { - path: ".".to_string(), + path: _temp_dir.path().to_string_lossy().to_string(), include: None, relies_on: None, before_synchronous: None, @@ -610,13 +1090,15 @@ mod tests { let planner = WorkflowPlanner::new(profile, Arc::new(env), dummy_options(true), dummy_runner()); let plan = planner.plan().unwrap(); - let (pipeline, _) = planner.build_pipeline_from_plan(&plan).unwrap(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); let mut task_names: Vec = pipeline.tasks().map(|t| t.name.clone()).collect(); task_names.sort(); let mut expected = vec![ - "workflow:validate-config".to_string(), - "workflow:build-plan".to_string(), - "workflow:generate".to_string(), + crate::workflow::task_id::VALIDATE_CONFIG.to_string(), + crate::workflow::task_id::BUILD_PLAN.to_string(), + crate::workflow::task_id::GENERATE.to_string(), ]; expected.sort(); assert_eq!(task_names, expected); @@ -626,8 +1108,9 @@ mod tests { fn local_deploy_creates_deploy_tasks() { let mut env = Environment::new("local"); let mut svc = crate::environment::Service::new("dummy", None, "latest"); + let _temp_dir = tempfile::tempdir().unwrap(); svc.build = Some(crate::environment::ServiceBuildConfig { - path: ".".to_string(), + path: _temp_dir.path().to_string_lossy().to_string(), include: None, relies_on: None, before_synchronous: None, @@ -657,19 +1140,687 @@ mod tests { assert!(task_kinds.contains(&WorkflowTaskKind::Approval)); assert!(task_kinds.contains(&WorkflowTaskKind::Deploy)); - let (pipeline, _) = planner.build_pipeline_from_plan(&plan).unwrap(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); let mut task_names: Vec = pipeline.tasks().map(|t| t.name.clone()).collect(); task_names.sort(); let mut expected = vec![ - "workflow:validate-config".to_string(), - "workflow:build-plan".to_string(), - "workflow:generate".to_string(), - "workflow:deployment-plan".to_string(), - "workflow:approval".to_string(), - "workflow:deploy".to_string(), + crate::workflow::task_id::VALIDATE_CONFIG.to_string(), + crate::workflow::task_id::BUILD_PLAN.to_string(), + crate::workflow::task_id::GENERATE.to_string(), + crate::workflow::task_id::DEPLOYMENT_PLAN.to_string(), + crate::workflow::task_id::APPROVAL.to_string(), + crate::workflow::task_id::DEPLOY.to_string(), ]; expected.sort(); assert_eq!(task_names, expected); } } + +#[cfg(test)] +mod tests_addendum { + use super::*; + + fn assert_plan_pipeline_parity(plan: &WorkflowPlan, pipeline: &runkernel::Pipeline) { + let planned = plan + .tasks + .iter() + .map(|task| { + let mut dependencies = task.dependencies.clone(); + dependencies.sort(); + (task.id.clone(), dependencies) + }) + .collect::>(); + let runtime = pipeline + .tasks() + .map(|task| { + let mut dependencies = task.dependencies.clone(); + dependencies.sort(); + (task.name.clone(), dependencies) + }) + .collect::>(); + assert_eq!(planned, runtime); + } + + #[test] + fn source_revision_resolution_is_provider_aware_without_process_globals() { + for (provider, expected_variable) in [ + (crate::workflow::ci::CiProvider::GitHub, "GITHUB_SHA"), + (crate::workflow::ci::CiProvider::CircleCi, "CIRCLE_SHA1"), + (crate::workflow::ci::CiProvider::Travis, "TRAVIS_COMMIT"), + ] { + let runner = RunnerContext { + kind: crate::workflow::runner::RunnerKind::GenericCi, + ci: true, + interactive: false, + ci_environment: Some(crate::workflow::ci::CiEnvironment { + provider, + run_id: None, + }), + }; + let revision = resolve_source_revision_with( + &runner, + |variable| { + assert_eq!(variable, expected_variable); + Ok(Some(" provider-revision ".to_string())) + }, + || panic!("provider revision must take precedence over Git"), + ) + .unwrap(); + assert_eq!(revision.as_deref(), Some("provider-revision")); + + assert!(resolve_source_revision_with( + &runner, + |_| Ok(Some(" ".to_string())), + || Ok("git-revision".to_string()), + ) + .is_err()); + + assert_eq!( + resolve_source_revision_with( + &runner, + |_| Ok(None), + || Ok("git-revision".to_string()), + ) + .unwrap() + .as_deref(), + Some("git-revision") + ); + } + + let local = RunnerContext { + kind: crate::workflow::runner::RunnerKind::Local, + ci: false, + interactive: true, + ci_environment: None, + }; + assert!(resolve_source_revision_with( + &local, + |_| Ok(None), + || Err(crate::workflow::error::ProvenanceError::Git( + "unavailable".to_string(), + )), + ) + .is_err()); + } + + #[test] + fn ci_publication_rejects_missing_revision_during_planning() { + struct MissingRevision; + impl SourceRevisionResolver for MissingRevision { + fn resolve( + &self, + _runner: &RunnerContext, + ) -> Result, crate::workflow::error::ProvenanceError> { + Ok(None) + } + } + + let temp = tempfile::tempdir().unwrap(); + let mut environment = Environment::new("staging"); + environment.registry = crate::environment::RegistryConfig::Simple("ghcr.io/acme".into()); + let mut service = crate::environment::Service::new("api", None, "1.0.0"); + service.build = Some(crate::environment::ServiceBuildConfig { + path: temp.path().to_string_lossy().to_string(), + include: None, + relies_on: None, + before_synchronous: None, + before: None, + run_parallel: None, + run_synchronous: None, + after: None, + finally: None, + dockerfile: None, + build_command: None, + push_command: None, + }); + environment.services.push(service); + let mut profile: crate::workflow::profile::WorkflowProfile = toml::from_str( + r#" + environment = "staging" + mode = "build" + build = "run" + push = "run" + "#, + ) + .unwrap(); + profile.name = "publish".to_string(); + let planner = WorkflowPlanner::with_source_revision_resolver( + profile.normalize(true), + Arc::new(environment), + BuildOptions { + cache_dir: temp.path().join("cache").to_string_lossy().to_string(), + force: true, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }, + RunnerContext { + kind: crate::workflow::runner::RunnerKind::GenericCi, + ci: true, + interactive: false, + ci_environment: Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::Generic, + run_id: None, + }), + }, + Arc::new(MissingRevision), + ); + assert!(planner + .plan() + .unwrap_err() + .contains("Image publication requires a source revision")); + } + + #[test] + fn report_modes_plan_finalizers_and_effects() { + for (report_mode, has_finalizer) in [ + (crate::workflow::profile::ReportMode::Text, false), + (crate::workflow::profile::ReportMode::Json, true), + (crate::workflow::profile::ReportMode::Both, true), + ] { + let report_value = match report_mode { + crate::workflow::profile::ReportMode::Text => "text", + crate::workflow::profile::ReportMode::Json => "json", + crate::workflow::profile::ReportMode::Both => "both", + }; + let mut profile: crate::workflow::profile::WorkflowProfile = toml::from_str(&format!( + r#" + environment = "test" + mode = "check" + build = "disabled" + generate = "disabled" + deploy = "disabled" + report = "{report_value}" + "# + )) + .unwrap(); + profile.name = format!("report-{report_mode:?}"); + let planner = WorkflowPlanner::new( + profile.normalize(false), + Arc::new(Environment::new("test")), + BuildOptions { + cache_dir: ".sailr/test-finalizers".to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }, + RunnerContext { + kind: crate::workflow::runner::RunnerKind::Local, + ci: false, + interactive: false, + ci_environment: None, + }, + ); + let plan = planner.plan().unwrap(); + assert_eq!(plan.finalizers.len(), usize::from(has_finalizer)); + assert_eq!(plan.effects.mutates_filesystem, has_finalizer); + if has_finalizer { + assert_eq!( + plan.finalizers[0].id, + crate::workflow::task_id::WRITE_REPORT_FINALIZER + ); + } + } + } + + #[test] + fn no_op_push_run_has_no_registry_mutation_or_push_tasks() { + let temp = tempfile::tempdir().unwrap(); + let service_path = temp.path().join("api"); + std::fs::create_dir_all(&service_path).unwrap(); + let mut environment = Environment::new("test"); + environment.registry = crate::environment::RegistryConfig::Simple("ghcr.io/acme".into()); + let mut service = crate::environment::Service::new("api", None, "1.0.0"); + service.build = Some(crate::environment::ServiceBuildConfig { + path: service_path.to_string_lossy().to_string(), + include: None, + relies_on: None, + before_synchronous: None, + before: None, + run_parallel: None, + run_synchronous: None, + after: None, + finally: None, + dockerfile: None, + build_command: None, + push_command: None, + }); + environment.services.push(service); + let environment = Arc::new(environment); + let cache_dir = temp.path().join("cache").to_string_lossy().to_string(); + + let mut build_profile: crate::workflow::profile::WorkflowProfile = toml::from_str( + r#" + environment = "test" + mode = "build" + build = "run" + report = "text" + "#, + ) + .unwrap(); + build_profile.name = "seed-cache".to_string(); + let options = BuildOptions { + cache_dir: cache_dir.clone(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }; + let seed = WorkflowPlanner::new( + build_profile.normalize(false), + environment.clone(), + options.clone(), + RunnerContext { + kind: crate::workflow::runner::RunnerKind::Local, + ci: false, + interactive: false, + ci_environment: None, + }, + ) + .plan() + .unwrap() + .build_plan + .unwrap(); + crate::builder::write_successful_service_caches( + &seed, + &runkernel::PipelineResult { + name: "seed".to_string(), + duration: std::time::Duration::default(), + tasks: vec![runkernel::TaskResult { + name: crate::workflow::task_id::service_build("api"), + status: runkernel::TaskStatus::Completed, + duration: None, + error: None, + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + }], + summary: runkernel::PipelineSummary { + name: "seed".to_string(), + success: true, + completed: 1, + failed: 0, + skipped: 0, + cached: 0, + cancelled: 0, + rolled_back: 0, + rollback_failed: 0, + }, + }, + ) + .unwrap(); + + struct RejectingResolver; + + impl SourceRevisionResolver for RejectingResolver { + fn resolve( + &self, + _runner: &RunnerContext, + ) -> Result, crate::workflow::error::ProvenanceError> { + panic!("source revision resolver must not be called for no-op push-run"); + } + } + + let mut push_profile: crate::workflow::profile::WorkflowProfile = toml::from_str( + r#" + environment = "test" + mode = "build" + build = "run" + push = "run" + report = "text" + "#, + ) + .unwrap(); + push_profile.name = "no-op-push".to_string(); + let planner = WorkflowPlanner::with_source_revision_resolver( + push_profile.normalize(false), + environment, + options, + RunnerContext { + kind: crate::workflow::runner::RunnerKind::Local, + ci: false, + interactive: false, + ci_environment: None, + }, + Arc::new(RejectingResolver), + ); + let plan = planner.plan().unwrap(); + let push_plan = plan.image_push_plan.as_ref().unwrap(); + assert!(push_plan.items.is_empty()); + assert!(!push_plan.mutates_registry); + assert!(!plan.effects.mutates_registry); + assert!(!plan.tasks.iter().any(|task| matches!( + task.kind, + WorkflowTaskKind::ServicePush | WorkflowTaskKind::ImageReport + ))); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); + assert_plan_pipeline_parity(&plan, &pipeline); + } + + #[test] + fn ci_build_push_plan_workflow_plan_has_image_push_plan() { + use crate::environment::Environment; + use crate::workflow::profile::WorkflowProfile; + + let temp_dir = tempfile::tempdir().unwrap(); + let env_toml = format!( + r#" + schema_version = "v0.5" + name = "test" + domain = "test.local" + log_level = "info" + default_replicas = 1 + registry = "ghcr.io" + [[service]] + name = "api" + [service.build] + path = "{}" + "#, + temp_dir.path().to_string_lossy() + ); + let env: Environment = toml::from_str(&env_toml).unwrap(); + + let profile_toml = r#" + environment = "test" + mode = "build" + build = "plan" + push = "plan" + "#; + let mut profile: WorkflowProfile = toml::from_str(profile_toml).unwrap(); + profile.name = "ci-build-push-plan".to_string(); + let normalized = profile.normalize(false); + let runner_ctx = RunnerContext::detect(true); + let options = crate::builder::BuildOptions { + cache_dir: ".sailr/cache".to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }; + + let planner = + WorkflowPlanner::new(normalized, std::sync::Arc::new(env), options, runner_ctx); + + let plan = planner.plan().unwrap(); + assert!(plan.image_push_plan.is_some()); + } + + #[test] + fn existing_profiles_do_not_carry_push_plan() { + use crate::environment::Environment; + use crate::workflow::profile::WorkflowProfile; + + let temp_dir = tempfile::tempdir().unwrap(); + let env_toml = format!( + r#" + schema_version = "v0.5" + name = "test" + domain = "test.local" + log_level = "info" + default_replicas = 1 + registry = "ghcr.io" + [[service]] + name = "api" + [service.build] + path = "{}" + "#, + temp_dir.path().to_string_lossy() + ); + let env: Environment = toml::from_str(&env_toml).unwrap(); + + let env_arc = std::sync::Arc::new(env); + let profiles = vec![ + r#" + environment = "test" + mode = "check" + "#, + r#" + environment = "test" + mode = "build" + build = "plan" + "#, + r#" + environment = "test" + mode = "build" + build = "plan" + generate = "run" + "#, + ]; + + for p_toml in profiles { + let profile: WorkflowProfile = toml::from_str(p_toml).unwrap(); + let normalized = profile.normalize(false); + let runner_ctx = RunnerContext::detect(true); + let options = crate::builder::BuildOptions { + cache_dir: ".sailr/cache".to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }; + + let planner = WorkflowPlanner::new(normalized, env_arc.clone(), options, runner_ctx); + + let plan = planner.plan().unwrap(); + assert!(plan.image_push_plan.is_none()); + } + } + #[test] + fn push_run_generates_correct_execution_graph() { + use crate::environment::Environment; + use crate::workflow::profile::WorkflowProfile; + + let temp_dir = tempfile::tempdir().unwrap(); + let env_toml = format!( + r#" + schema_version = "v0.5" + name = "test" + domain = "test.local" + log_level = "info" + default_replicas = 1 + registry = "ghcr.io" + [[service]] + name = "api" + [service.build] + path = "{}" + "#, + temp_dir.path().to_string_lossy() + ); + let env: Environment = toml::from_str(&env_toml).unwrap(); + + let profile_toml = r#" + environment = "test" + mode = "build" + build = "run" + push = "run" + "#; + let mut profile: WorkflowProfile = toml::from_str(profile_toml).unwrap(); + profile.name = "ci-build-push".to_string(); + let normalized = profile.normalize(false); + let runner_ctx = RunnerContext::detect(true); + let options = crate::builder::BuildOptions { + cache_dir: temp_dir + .path() + .join(".sailr/cache") + .to_string_lossy() + .to_string(), + force: true, // force to ensure it's dirty + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }; + + let planner = + WorkflowPlanner::new(normalized, std::sync::Arc::new(env), options, runner_ctx); + + let plan = planner.plan().unwrap(); + assert!(plan.image_push_plan.is_some()); + assert!(plan.effects.mutates_registry); + assert!(plan.effects.mutates_docker); + assert!(plan.effects.mutates_filesystem); + + let accumulator = crate::workflow::image::WorkflowReportAccumulator::default(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, accumulator) + .unwrap(); + assert_plan_pipeline_parity(&plan, &pipeline); + + let tasks: Vec<_> = pipeline.tasks().collect(); + assert!(tasks.iter().any(|t| t.name == "service:api:build")); + assert!(tasks.iter().any(|t| t.name == "service:api:push")); + assert!(tasks + .iter() + .any(|t| t.name == crate::workflow::task_id::IMAGE_REPORT)); + + let api_push = tasks.iter().find(|t| t.name == "service:api:push").unwrap(); + assert_eq!( + api_push.dependencies, + vec![ + crate::workflow::task_id::service_build("api"), + crate::workflow::task_id::PUSH_PLAN.to_string() + ] + ); + + let report = tasks + .iter() + .find(|t| t.name == crate::workflow::task_id::IMAGE_REPORT) + .unwrap(); + assert_eq!(report.dependencies, vec!["service:api:push"]); + assert_eq!( + plan.tasks + .iter() + .find(|task| task.id == crate::workflow::task_id::IMAGE_REPORT) + .unwrap() + .effects, + WorkflowEffects::default() + ); + } + + #[test] + fn dependent_builds_and_hooks_have_plan_runtime_parity_and_derived_effects() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let api = temp.path().join("api"); + std::fs::create_dir_all(&shared).unwrap(); + std::fs::create_dir_all(&api).unwrap(); + let environment: Environment = toml::from_str(&format!( + r#" + schema_version = "v0.5" + name = "test" + domain = "test.local" + log_level = "info" + default_replicas = 1 + registry = "ghcr.io/acme" + + [build] + before_all = "echo before" + after_all = "echo after" + + [[service]] + name = "shared" + version = "1.0.0" + [service.build] + path = "{}" + + [[service]] + name = "api" + version = "1.0.0" + [service.build] + path = "{}" + depends_on = ["shared"] + "#, + shared.display(), + api.display() + )) + .unwrap(); + let mut profile: crate::workflow::profile::WorkflowProfile = toml::from_str( + r#" + environment = "test" + mode = "build" + build = "run" + push = "plan" + "#, + ) + .unwrap(); + let build_policy = environment.build.clone(); + profile.name = "dependency-hooks".to_string(); + let planner = WorkflowPlanner::new( + profile.normalize(false), + Arc::new(environment), + BuildOptions { + cache_dir: temp.path().join("cache").to_string_lossy().to_string(), + force: true, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: build_policy, + }, + RunnerContext { + kind: crate::workflow::runner::RunnerKind::Local, + ci: false, + interactive: false, + ci_environment: None, + }, + ); + let plan = planner.plan().unwrap(); + let (pipeline, _) = planner + .build_pipeline_from_plan(&plan, Default::default()) + .unwrap(); + assert_plan_pipeline_parity(&plan, &pipeline); + + let api_task = plan + .tasks + .iter() + .find(|task| task.id == crate::workflow::task_id::service_build("api")) + .unwrap(); + assert!(api_task + .dependencies + .contains(&crate::workflow::task_id::service_build("shared"))); + assert!(api_task + .dependencies + .contains(&crate::workflow::task_id::BUILD_BEFORE_ALL.to_string())); + + let mut merged = WorkflowEffects::default(); + for task in &plan.tasks { + merged.merge(&task.effects); + } + assert_eq!(plan.effects, merged); + assert!(plan.effects.mutates_filesystem); + assert!(plan.effects.mutates_docker); + assert!(!plan.effects.mutates_registry); + } +} diff --git a/src/workflow/profile.rs b/src/workflow/profile.rs index 38522d8..d7c0f2d 100644 --- a/src/workflow/profile.rs +++ b/src/workflow/profile.rs @@ -28,6 +28,10 @@ pub struct WorkflowProfile { #[serde(default)] pub build: Option, + /// How to handle the push step. + #[serde(default)] + pub push: Option, + /// How to handle the generate step. #[serde(default)] pub generate: Option, @@ -100,54 +104,66 @@ impl WorkflowProfile { let mut approval = self.approval; let mut apply = self.apply.unwrap_or(false); - let (default_build, default_generate, default_deploy, default_test, default_verify) = - match self.mode { - WorkflowMode::Check => ( - WorkflowStepMode::Plan, - WorkflowStepMode::Run, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - ), - WorkflowMode::Build => ( - WorkflowStepMode::Run, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - ), - WorkflowMode::Go => ( - WorkflowStepMode::Run, - WorkflowStepMode::Run, - if apply { - WorkflowStepMode::Run - } else { - WorkflowStepMode::Plan - }, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - ), - WorkflowMode::Deploy => ( - WorkflowStepMode::Disabled, - WorkflowStepMode::Run, - if apply { - WorkflowStepMode::Run - } else { - WorkflowStepMode::Plan - }, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - ), - WorkflowMode::Promote | WorkflowMode::Rollback => ( - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - WorkflowStepMode::Disabled, - ), - }; + let ( + default_build, + default_push, + default_generate, + default_deploy, + default_test, + default_verify, + ) = match self.mode { + WorkflowMode::Check => ( + WorkflowStepMode::Plan, + WorkflowStepMode::Disabled, + WorkflowStepMode::Run, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + ), + WorkflowMode::Build => ( + WorkflowStepMode::Run, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + ), + WorkflowMode::Go => ( + WorkflowStepMode::Run, + WorkflowStepMode::Disabled, + WorkflowStepMode::Run, + if apply { + WorkflowStepMode::Run + } else { + WorkflowStepMode::Plan + }, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + ), + WorkflowMode::Deploy => ( + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Run, + if apply { + WorkflowStepMode::Run + } else { + WorkflowStepMode::Plan + }, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + ), + WorkflowMode::Promote | WorkflowMode::Rollback => ( + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + WorkflowStepMode::Disabled, + ), + }; let build = self.build.unwrap_or(default_build); + let push = self.push.unwrap_or(default_push); let generate = self.generate.unwrap_or(default_generate); let mut deploy = self.deploy.unwrap_or(default_deploy); let test = self.test.unwrap_or(default_test); @@ -161,8 +177,10 @@ impl WorkflowProfile { deploy = WorkflowStepMode::Disabled; } WorkflowMode::Build => { - approval = ApprovalMode::None; - apply = false; + if push != WorkflowStepMode::Run { + approval = ApprovalMode::None; + apply = false; + } } WorkflowMode::Go | WorkflowMode::Deploy => { if approval == ApprovalMode::None && deploy == WorkflowStepMode::Run { @@ -183,6 +201,7 @@ impl WorkflowProfile { engine: self.engine, interactive, build, + push, generate, deploy, test, @@ -205,6 +224,7 @@ pub struct NormalizedWorkflowProfile { pub engine: WorkflowEngine, pub interactive: bool, pub build: WorkflowStepMode, + pub push: WorkflowStepMode, pub generate: WorkflowStepMode, pub deploy: WorkflowStepMode, pub test: WorkflowStepMode, @@ -449,6 +469,7 @@ mod tests { assert_eq!(profile.mode, WorkflowMode::Go); assert_eq!(profile.engine, WorkflowEngine::Runkernel); assert_eq!(profile.build, None); + assert_eq!(profile.push, None); assert_eq!(profile.generate, None); assert_eq!(profile.deploy, None); assert_eq!(profile.approval, ApprovalMode::None); @@ -470,6 +491,7 @@ mod tests { engine = "runkernel" interactive = false build = "disabled" + push = "plan" generate = "run" deploy = "plan" test = "run" @@ -494,6 +516,7 @@ mod tests { assert_eq!(profile.engine, WorkflowEngine::Runkernel); assert_eq!(profile.interactive, Some(false)); assert_eq!(profile.build, Some(WorkflowStepMode::Disabled)); + assert_eq!(profile.push, Some(WorkflowStepMode::Plan)); assert_eq!(profile.generate, Some(WorkflowStepMode::Run)); assert_eq!(profile.deploy, Some(WorkflowStepMode::Plan)); assert_eq!(profile.test, Some(WorkflowStepMode::Run)); @@ -620,6 +643,7 @@ mod tests { engine = "runkernel" interactive = false build = "run" + push = "run" generate = "run" deploy = "run" deploy_context = "staging" @@ -634,6 +658,7 @@ mod tests { assert_eq!(profile.engine, roundtripped.engine); assert_eq!(profile.interactive, roundtripped.interactive); assert_eq!(profile.build, roundtripped.build); + assert_eq!(profile.push, roundtripped.push); assert_eq!(profile.deploy, roundtripped.deploy); assert_eq!(profile.approval, roundtripped.approval); assert_eq!(profile.report, roundtripped.report); @@ -686,6 +711,7 @@ mod tests { let normalized = profile.normalize(false); assert!(!normalized.interactive); assert_eq!(normalized.build, WorkflowStepMode::Plan); + assert_eq!(normalized.push, WorkflowStepMode::Disabled); assert_eq!(normalized.generate, WorkflowStepMode::Run); assert_eq!(normalized.deploy, WorkflowStepMode::Disabled); assert_eq!(normalized.test, WorkflowStepMode::Disabled); @@ -707,6 +733,7 @@ mod tests { let profile: WorkflowProfile = toml::from_str(toml_str).unwrap(); let normalized = profile.normalize(true); assert_eq!(normalized.build, WorkflowStepMode::Disabled); + assert_eq!(normalized.push, WorkflowStepMode::Disabled); assert_eq!(normalized.generate, WorkflowStepMode::Disabled); assert_eq!(normalized.deploy, WorkflowStepMode::Disabled); } @@ -721,6 +748,7 @@ mod tests { let profile: WorkflowProfile = toml::from_str(toml_str).unwrap(); let normalized = profile.normalize(true); assert_eq!(normalized.build, WorkflowStepMode::Plan); + assert_eq!(normalized.push, WorkflowStepMode::Disabled); assert_eq!(normalized.generate, WorkflowStepMode::Run); assert_eq!(normalized.deploy, WorkflowStepMode::Disabled); } @@ -734,6 +762,7 @@ mod tests { let profile: WorkflowProfile = toml::from_str(toml_str).unwrap(); let normalized = profile.normalize(false); assert_eq!(normalized.build, WorkflowStepMode::Run); + assert_eq!(normalized.push, WorkflowStepMode::Disabled); assert_eq!(normalized.generate, WorkflowStepMode::Disabled); assert_eq!(normalized.deploy, WorkflowStepMode::Disabled); assert_eq!(normalized.approval, ApprovalMode::None); diff --git a/src/workflow/render.rs b/src/workflow/render.rs index 875088f..3e01d66 100644 --- a/src/workflow/render.rs +++ b/src/workflow/render.rs @@ -1,11 +1,25 @@ use crate::workflow::plan::WorkflowPlan; +fn external_approval_provider(runner: &crate::workflow::runner::RunnerContext) -> &'static str { + match runner.kind { + crate::workflow::runner::RunnerKind::GitHubActions => "GitHub protected environment", + crate::workflow::runner::RunnerKind::CircleCi => "CircleCI approval job", + crate::workflow::runner::RunnerKind::Travis => "Travis protected deployment gate", + crate::workflow::runner::RunnerKind::GenericCi => "external CI approval gate", + crate::workflow::runner::RunnerKind::Local => "external approval", + } +} + pub fn render_workflow_plan_text(plan: &WorkflowPlan) -> String { let mut out = String::new(); out.push_str(&format!("Sailr Workflow Plan: {}\n", plan.profile.name)); out.push_str(&format!("Environment: {}\n", plan.profile.environment)); out.push_str(&format!("Mode: {:?}\n", plan.profile.mode)); out.push_str(&format!("Engine: {:?}\n", plan.profile.engine)); + out.push_str(&format!("Build: {:?}\n", plan.profile.build)); + out.push_str(&format!("Push: {:?}\n", plan.profile.push)); + out.push_str(&format!("Generate: {:?}\n", plan.profile.generate)); + out.push_str(&format!("Deploy: {:?}\n", plan.profile.deploy)); out.push_str("\nTasks:\n"); for task in &plan.tasks { @@ -21,10 +35,26 @@ pub fn render_workflow_plan_text(plan: &WorkflowPlan) -> String { out.push('\n'); } + if !plan.finalizers.is_empty() { + out.push_str("Finalizers:\n"); + for finalizer in &plan.finalizers { + out.push_str(&format!(" - [{}] {}\n", finalizer.id, finalizer.label)); + out.push_str(&format!(" Kind: {:?}\n", finalizer.kind)); + out.push_str(&format!(" Description: {}\n", finalizer.description)); + out.push_str(&format!( + " Mutates Filesystem: {}\n\n", + finalizer.effects.mutates_filesystem + )); + } + } + if plan.profile.approval == crate::workflow::profile::ApprovalMode::External { out.push_str("Approval:\n"); out.push_str(" mode: external\n"); - out.push_str(" provider: GitHub Environment\n"); + out.push_str(&format!( + " provider: {}\n", + external_approval_provider(&plan.runner) + )); out.push_str(&format!(" environment: {}\n\n", plan.profile.environment)); } @@ -41,6 +71,7 @@ pub fn render_workflow_plan_text(plan: &WorkflowPlan) -> String { " - Mutates Registry: {}\n", plan.effects.mutates_registry )); + out.push_str(&format!(" - Mutates Git: {}\n", plan.effects.mutates_git)); out.push_str(&format!( " - Mutates Cluster: {}\n", plan.effects.mutates_cluster @@ -99,6 +130,45 @@ pub fn render_workflow_graph_mermaid(plan: &WorkflowPlan) -> String { } pub fn render_workflow_explain_text(plan: &WorkflowPlan, task_id: &str) -> Result { + if let Some(finalizer) = plan + .finalizers + .iter() + .find(|finalizer| finalizer.id == task_id) + { + let mut out = String::new(); + out.push_str(&format!("Finalizer Explanation: {}\n", finalizer.id)); + out.push_str("--------------------------------------------------\n"); + out.push_str(&format!("Label: {}\n", finalizer.label)); + out.push_str(&format!("Kind: {:?}\n", finalizer.kind)); + out.push_str(&format!("Description: {}\n", finalizer.description)); + out.push_str("\nSide Effects:\n"); + out.push_str(&format!( + " - Mutates Filesystem: {}\n", + finalizer.effects.mutates_filesystem + )); + out.push_str(&format!( + " - Mutates Docker: {}\n", + finalizer.effects.mutates_docker + )); + out.push_str(&format!( + " - Mutates Registry: {}\n", + finalizer.effects.mutates_registry + )); + out.push_str(&format!( + " - Mutates Git: {}\n", + finalizer.effects.mutates_git + )); + out.push_str(&format!( + " - Mutates Cluster: {}\n", + finalizer.effects.mutates_cluster + )); + out.push_str(&format!( + " - Prompts User: {}\n", + finalizer.effects.prompts_user + )); + return Ok(out); + } + let task = plan .tasks .iter() @@ -122,7 +192,10 @@ pub fn render_workflow_explain_text(plan: &WorkflowPlan, task_id: &str) -> Resul if plan.profile.approval == crate::workflow::profile::ApprovalMode::External { out.push_str("\nApproval:\n"); out.push_str(" mode: external\n"); - out.push_str(" provider: GitHub Environment\n"); + out.push_str(&format!( + " provider: {}\n", + external_approval_provider(&plan.runner) + )); out.push_str(&format!(" environment: {}\n", plan.profile.environment)); } @@ -150,6 +223,7 @@ pub fn render_workflow_explain_text(plan: &WorkflowPlan, task_id: &str) -> Resul " - Mutates Registry: {}\n", task.effects.mutates_registry )); + out.push_str(&format!(" - Mutates Git: {}\n", task.effects.mutates_git)); out.push_str(&format!( " - Mutates Cluster: {}\n", task.effects.mutates_cluster @@ -171,7 +245,8 @@ fn sanitize_label(label: &str) -> String { mod tests { use super::*; use crate::workflow::plan::{ - WorkflowEdge, WorkflowEffects, WorkflowTaskKind, WorkflowTaskPlan, + WorkflowEdge, WorkflowEffects, WorkflowFinalizerKind, WorkflowFinalizerPlan, + WorkflowTaskKind, WorkflowTaskPlan, }; use crate::workflow::profile::{ ApprovalMode, NormalizedWorkflowProfile, ReportMode, WorkflowEngine, WorkflowMode, @@ -188,6 +263,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Disabled, test: WorkflowStepMode::Disabled, @@ -202,10 +278,11 @@ mod tests { kind: RunnerKind::Local, ci: false, interactive: false, + ci_environment: None, }, tasks: vec![ WorkflowTaskPlan { - id: "workflow:validate".to_string(), + id: crate::workflow::task_id::VALIDATE_CONFIG.to_string(), label: "Validate".to_string(), kind: WorkflowTaskKind::ValidateConfig, dependencies: vec![], @@ -213,10 +290,10 @@ mod tests { description: "Validates config".to_string(), }, WorkflowTaskPlan { - id: "build:api".to_string(), + id: crate::workflow::task_id::service_build("api"), label: "Build API".to_string(), kind: WorkflowTaskKind::ServiceBuild, - dependencies: vec!["workflow:validate".to_string()], + dependencies: vec![crate::workflow::task_id::VALIDATE_CONFIG.to_string()], effects: WorkflowEffects { mutates_docker: true, ..Default::default() @@ -225,10 +302,12 @@ mod tests { }, ], edges: vec![WorkflowEdge { - from: "workflow:validate".to_string(), - to: "build:api".to_string(), + from: crate::workflow::task_id::VALIDATE_CONFIG.to_string(), + to: crate::workflow::task_id::service_build("api"), }], build_plan: None, + image_push_plan: None, + finalizers: vec![], effects: WorkflowEffects { mutates_docker: true, ..Default::default() @@ -242,7 +321,7 @@ mod tests { let text = render_workflow_plan_text(&plan); assert!(text.contains("Sailr Workflow Plan: test")); assert!(text.contains("Mutates Docker: true")); - assert!(text.contains("build:api")); + assert!(text.contains(&crate::workflow::task_id::service_build("api"))); } #[test] @@ -250,16 +329,135 @@ mod tests { let plan = dummy_plan(); let text = render_workflow_graph_mermaid(&plan); assert!(text.contains("graph TD")); - assert!(text.contains("workflow_validate[Validate]")); - assert!(text.contains("build_api[Build API]")); - assert!(text.contains("workflow_validate --> build_api")); + assert!(text.contains("workflow_validate_config[Validate]")); + assert!(text.contains("service_api_build[Build API]")); + assert!(text.contains("workflow_validate_config --> service_api_build")); } #[test] fn test_render_explain() { let plan = dummy_plan(); - let text = render_workflow_explain_text(&plan, "build:api").unwrap(); - assert!(text.contains("Task Explanation: build:api")); + let text = + render_workflow_explain_text(&plan, &crate::workflow::task_id::service_build("api")) + .unwrap(); + assert!(text.contains(&format!( + "Task Explanation: {}", + crate::workflow::task_id::service_build("api") + ))); assert!(text.contains("Mutates Docker: true")); } + + #[test] + fn renders_provider_specific_external_approval_and_git_effects() { + for (kind, provider) in [ + ( + crate::workflow::runner::RunnerKind::GitHubActions, + "GitHub protected environment", + ), + ( + crate::workflow::runner::RunnerKind::CircleCi, + "CircleCI approval job", + ), + ( + crate::workflow::runner::RunnerKind::Travis, + "Travis protected deployment gate", + ), + ( + crate::workflow::runner::RunnerKind::GenericCi, + "external CI approval gate", + ), + ] { + let mut plan = dummy_plan(); + plan.profile.approval = crate::workflow::profile::ApprovalMode::External; + plan.runner.kind = kind; + plan.runner.ci = true; + plan.effects.mutates_git = true; + let rendered = render_workflow_plan_text(&plan); + assert!(rendered.contains(provider)); + assert!(rendered.contains("Mutates Git: true")); + } + } + + #[test] + fn renders_and_explains_report_finalizers_without_graph_nodes() { + let mut plan = dummy_plan(); + plan.finalizers.push(WorkflowFinalizerPlan { + id: crate::workflow::task_id::WRITE_REPORT_FINALIZER.to_string(), + label: "Write Workflow Report".to_string(), + kind: WorkflowFinalizerKind::WriteWorkflowReport, + effects: WorkflowEffects { + mutates_filesystem: true, + ..Default::default() + }, + description: "Writes the workflow report after task execution.".to_string(), + }); + + let rendered = render_workflow_plan_text(&plan); + assert!(rendered.contains("Finalizers:")); + assert!(rendered.contains(crate::workflow::task_id::WRITE_REPORT_FINALIZER)); + let explained = + render_workflow_explain_text(&plan, crate::workflow::task_id::WRITE_REPORT_FINALIZER) + .unwrap(); + assert!(explained.contains("Finalizer Explanation")); + assert!(explained.contains("Mutates Filesystem: true")); + assert!(!render_workflow_graph_text(&plan) + .contains(crate::workflow::task_id::WRITE_REPORT_FINALIZER)); + } +} + +pub fn render_image_push_plan_text(plan: &crate::workflow::image::ImagePushPlanReport) -> String { + let mut out = String::new(); + + out.push_str("Sailr image push plan:\n"); + out.push_str(&format!(" environment: {}\n", plan.environment)); + out.push_str(&format!( + " mutates registry: {}\n\n", + if plan.mutates_registry { "yes" } else { "no" } + )); + + out.push_str("Images:\n"); + + if plan.items.is_empty() { + out.push_str(" none\n"); + } else { + for item in &plan.items { + out.push_str(&format!( + " - service: {}\n image: {}\n action: would push\n", + item.service, item.target_image_ref + )); + } + } + + out +} + +#[cfg(test)] +mod tests_addendum { + use super::*; + + #[test] + fn render_image_push_plan_text_includes_planned_image_ref() { + let report = crate::workflow::image::ImagePushPlanReport { + environment: "staging".to_string(), + mutates_registry: false, + items: vec![crate::workflow::image::ImagePushPlanItem { + service: "ci-build-hello".to_string(), + registry: "ghcr.io".to_string(), + repository: "adriftdev/sailr/ci-build-hello".to_string(), + tag: "61eaa8b".to_string(), + target_image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + local_image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + provenance: crate::workflow::image::ImageProvenance { + build_fingerprint: "61eaa8bb0e52f5bb1d5a621760b0a2eae601ccd3".to_string(), + source_revision: Some("61eaa8bb0e52f5bb1d5a621760b0a2eae601ccd3".to_string()), + }, + action: crate::workflow::image::ImagePushPlanAction::WouldPush, + }], + }; + + let text = render_image_push_plan_text(&report); + assert!(text.contains("Sailr image push plan:")); + assert!(text.contains("mutates registry: no")); + assert!(text.contains("ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b")); + } } diff --git a/src/workflow/runner.rs b/src/workflow/runner.rs index 4c058b6..04d532d 100644 --- a/src/workflow/runner.rs +++ b/src/workflow/runner.rs @@ -1,11 +1,12 @@ -use crate::builder::{attach_pipeline_logging, write_successful_service_caches, BuildOptions}; +use crate::builder::{attach_pipeline_logging, BuildOptions}; use crate::cli::WorkflowRunArgs; use crate::environment::Environment; use super::config::WorkflowConfig; use super::planner::WorkflowPlanner; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum RunnerKind { Local, GitHubActions, @@ -14,22 +15,67 @@ pub enum RunnerKind { GenericCi, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RunnerContext { pub kind: RunnerKind, pub ci: bool, pub interactive: bool, + pub ci_environment: Option, +} + +pub trait EnvironmentReader { + fn read(&self, key: &str) -> Option; +} + +pub struct SystemEnvironmentReader; + +impl EnvironmentReader for SystemEnvironmentReader { + fn read(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +#[derive(Debug, Clone, Default)] +pub struct MapEnvironmentReader { + pub values: std::collections::BTreeMap, +} + +impl EnvironmentReader for MapEnvironmentReader { + fn read(&self, key: &str) -> Option { + self.values.get(key).cloned() + } } impl RunnerContext { pub fn detect(non_interactive: bool) -> Self { - let kind = if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") { + Self::detect_with(non_interactive, &SystemEnvironmentReader) + } + + pub fn detect_with(non_interactive: bool, environment: &dyn EnvironmentReader) -> Self { + let mut ci_env = None; + let kind = if environment.read("GITHUB_ACTIONS").as_deref() == Some("true") { + ci_env = Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::GitHub, + run_id: environment.read("GITHUB_RUN_ID"), + }); RunnerKind::GitHubActions - } else if std::env::var("CIRCLECI").as_deref() == Ok("true") { + } else if environment.read("CIRCLECI").as_deref() == Some("true") { + ci_env = Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::CircleCi, + run_id: environment.read("CIRCLE_WORKFLOW_ID"), + }); RunnerKind::CircleCi - } else if std::env::var("TRAVIS").as_deref() == Ok("true") { + } else if environment.read("TRAVIS").as_deref() == Some("true") { + ci_env = Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::Travis, + run_id: environment.read("TRAVIS_BUILD_ID"), + }); RunnerKind::Travis - } else if std::env::var("CI").is_ok() { + } else if environment.read("CI").is_some() { + ci_env = Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::Generic, + run_id: None, + }); RunnerKind::GenericCi } else { RunnerKind::Local @@ -42,6 +88,7 @@ impl RunnerContext { kind, ci, interactive, + ci_environment: ci_env, } } } @@ -100,42 +147,504 @@ fn print_workflow_result( print_tasks_by_status("cancelled tasks", result, runkernel::TaskStatus::Cancelled); } -fn write_workflow_report( - profile: &super::profile::NormalizedWorkflowProfile, - runner: &RunnerContext, - result: &runkernel::PipelineResult, -) -> Result<(), String> { - if !matches!( - profile.report, - super::profile::ReportMode::Json | super::profile::ReportMode::Both - ) { - return Ok(()); +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowReportTaskStatus { + Pending, + Running, + Cached, + Completed, + Failed, + Skipped, + Cancelled, + RolledBack, +} + +impl From<&runkernel::TaskStatus> for WorkflowReportTaskStatus { + fn from(status: &runkernel::TaskStatus) -> Self { + match status { + runkernel::TaskStatus::Pending => Self::Pending, + runkernel::TaskStatus::Running => Self::Running, + runkernel::TaskStatus::Cached => Self::Cached, + runkernel::TaskStatus::Completed => Self::Completed, + runkernel::TaskStatus::Failed => Self::Failed, + runkernel::TaskStatus::Skipped => Self::Skipped, + runkernel::TaskStatus::Cancelled => Self::Cancelled, + runkernel::TaskStatus::RolledBack => Self::RolledBack, + } } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowReportTaskItem { + pub name: String, + pub status: WorkflowReportTaskStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowReportTasks { + pub completed: usize, + pub failed: usize, + pub skipped: usize, + pub cancelled: usize, + pub cached: usize, + pub rolled_back: usize, + pub rollback_failed: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowReportPlans { + pub image_push: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WorkflowReportType { + WorkflowExecution, + WorkflowInspection, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowReportArtifacts { + pub published_images: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowFinalizerStatus { + Completed, + Failed, + Skipped, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct WorkflowFinalizerResult { + pub id: String, + pub status: WorkflowFinalizerStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct WorkflowReportFinalizers { + #[serde(default)] + pub planned: Vec, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub completed: usize, + #[serde(default)] + pub skipped: usize, + #[serde(default)] + pub failed: usize, +} + +impl WorkflowReportFinalizers { + pub fn validate(&self) -> Result<(), crate::workflow::error::WorkflowReportError> { + use crate::workflow::error::WorkflowReportError; + + let actual_completed = self + .items + .iter() + .filter(|item| item.status == WorkflowFinalizerStatus::Completed) + .count(); + + let actual_failed = self + .items + .iter() + .filter(|item| item.status == WorkflowFinalizerStatus::Failed) + .count(); + + let actual_skipped = self + .items + .iter() + .filter(|item| item.status == WorkflowFinalizerStatus::Skipped) + .count(); + + if self.completed != actual_completed { + return Err(WorkflowReportError::Validation(format!( + "finalizer completed count mismatch: reported {}, actual {}", + self.completed, actual_completed, + ))); + } + + if self.failed != actual_failed { + return Err(WorkflowReportError::Validation(format!( + "finalizer failed count mismatch: reported {}, actual {}", + self.failed, actual_failed, + ))); + } + + if self.skipped != actual_skipped { + return Err(WorkflowReportError::Validation(format!( + "finalizer skipped count mismatch: reported {}, actual {}", + self.skipped, actual_skipped, + ))); + } + + let mut planned_ids = std::collections::BTreeSet::new(); + + for id in &self.planned { + if id.trim().is_empty() { + return Err(WorkflowReportError::Validation( + "planned finalizer ID cannot be blank".to_string(), + )); + } + + if !planned_ids.insert(id.as_str()) { + return Err(WorkflowReportError::Validation(format!( + "duplicate planned finalizer ID: {}", + id, + ))); + } + } + + let mut result_ids = std::collections::BTreeSet::new(); + + for item in &self.items { + if item.id.trim().is_empty() { + return Err(WorkflowReportError::Validation( + "finalizer result ID cannot be blank".to_string(), + )); + } + + if !result_ids.insert(item.id.as_str()) { + return Err(WorkflowReportError::Validation(format!( + "duplicate finalizer result ID: {}", + item.id, + ))); + } + + if !planned_ids.contains(item.id.as_str()) { + return Err(WorkflowReportError::Validation(format!( + "unplanned finalizer result: {}", + item.id, + ))); + } + + match item.status { + WorkflowFinalizerStatus::Completed => { + if item.error.is_some() { + return Err(WorkflowReportError::Validation(format!( + "completed finalizer '{}' cannot contain an error", + item.id, + ))); + } + } + WorkflowFinalizerStatus::Failed => { + if item + .error + .as_deref() + .is_none_or(|error| error.trim().is_empty()) + { + return Err(WorkflowReportError::Validation(format!( + "failed finalizer '{}' requires a nonblank error", + item.id, + ))); + } + } + WorkflowFinalizerStatus::Skipped => { + if item + .error + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + return Err(WorkflowReportError::Validation(format!( + "skipped finalizer '{}' requires a nonblank reason", + item.id, + ))); + } + } + } + } + + for id in &planned_ids { + if !result_ids.contains(id) { + return Err(WorkflowReportError::Validation(format!( + "planned finalizer missing result: {}", + id, + ))); + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkflowReport { + pub schema_version: String, + pub report_type: WorkflowReportType, + pub profile: String, + pub mode: String, + pub runner: RunnerContext, + pub environment: String, + pub success: bool, + pub effects: crate::workflow::plan::WorkflowEffects, + pub tasks: WorkflowReportTasks, + #[serde(default)] + pub finalizers: WorkflowReportFinalizers, + pub plans: WorkflowReportPlans, + pub artifacts: WorkflowReportArtifacts, +} + +impl WorkflowReport { + pub fn validate(&self) -> Result<(), crate::workflow::error::WorkflowReportError> { + use crate::workflow::error::WorkflowReportError; + + if self.schema_version != "sailr.workflow-report/v1" { + return Err(WorkflowReportError::Validation(format!( + "unsupported schema version: {}", + self.schema_version + ))); + } + if self.profile.trim().is_empty() { + return Err(WorkflowReportError::Validation( + "profile cannot be blank".to_string(), + )); + } + if self.environment.trim().is_empty() { + return Err(WorkflowReportError::Validation( + "environment cannot be blank".to_string(), + )); + } + + for (status, expected) in [ + (WorkflowReportTaskStatus::Completed, self.tasks.completed), + (WorkflowReportTaskStatus::Failed, self.tasks.failed), + (WorkflowReportTaskStatus::Skipped, self.tasks.skipped), + (WorkflowReportTaskStatus::Cancelled, self.tasks.cancelled), + (WorkflowReportTaskStatus::Cached, self.tasks.cached), + (WorkflowReportTaskStatus::RolledBack, self.tasks.rolled_back), + ] { + let actual = self + .tasks + .items + .iter() + .filter(|item| item.status == status) + .count(); + if actual != expected { + return Err(WorkflowReportError::Validation(format!( + "task summary mismatch for {status:?}: expected {expected}, found {actual}" + ))); + } + } + if self.tasks.items.iter().any(|item| { + matches!( + item.status, + WorkflowReportTaskStatus::Pending | WorkflowReportTaskStatus::Running + ) + }) { + return Err(WorkflowReportError::Validation( + "completed workflow reports cannot contain pending or running tasks".to_string(), + )); + } + let terminal_count = self.tasks.completed + + self.tasks.failed + + self.tasks.skipped + + self.tasks.cancelled + + self.tasks.cached + + self.tasks.rolled_back; + if terminal_count != self.tasks.items.len() { + return Err(WorkflowReportError::Validation(format!( + "terminal task count mismatch: expected {}, found {} items", + terminal_count, + self.tasks.items.len() + ))); + } + for item in self + .tasks + .items + .iter() + .filter(|item| item.status == WorkflowReportTaskStatus::Failed) + { + if item + .error + .as_deref() + .is_none_or(|error| error.trim().is_empty()) + { + return Err(WorkflowReportError::Validation(format!( + "failed task '{}' must contain an error", + item.name + ))); + } + } + if self.tasks.rollback_failed > self.tasks.items.len() { + return Err(WorkflowReportError::Validation( + "rollback_failed exceeds the task item count".to_string(), + )); + } + self.finalizers.validate()?; + + let require_source_revision = |service: &str, + provenance: &crate::workflow::image::ImageProvenance| + -> Result<(), WorkflowReportError> { + if provenance + .source_revision + .as_deref() + .is_none_or(|r| r.trim().is_empty()) + { + return Err(WorkflowReportError::Validation(format!( + "mutating publication for '{}' requires a source revision", + service + ))); + } + Ok(()) + }; + + if let Some(push_plan) = self.plans.image_push.as_ref() { + if push_plan.mutates_registry { + for item in &push_plan.items { + require_source_revision(&item.service, &item.provenance)?; + } + + for artifact in &self.artifacts.published_images { + require_source_revision(&artifact.service, &artifact.provenance)?; + } + } + } + + let tasks_successful = self.tasks.failed == 0 + && self.tasks.skipped == 0 + && self.tasks.cancelled == 0 + && self.tasks.rollback_failed == 0; + + let finalizers_successful = self.finalizers.failed == 0 && self.finalizers.skipped == 0; + + let expected_success = tasks_successful && finalizers_successful; + + if self.success != expected_success { + return Err(WorkflowReportError::Validation(format!( + "success is {} but expected {} based on task outcomes (tasks_successful={}, finalizers_successful={})", + self.success, expected_success, tasks_successful, finalizers_successful + ))); + } + + if let Some(push_plan) = &self.plans.image_push { + push_plan.validate().map_err(|error| { + WorkflowReportError::Validation(format!("invalid image push plan: {error}")) + })?; + if push_plan.environment != self.environment { + return Err(WorkflowReportError::Validation( + "push-plan environment does not match report environment".to_string(), + )); + } + let mut published_services = std::collections::BTreeSet::new(); + for artifact in &self.artifacts.published_images { + if !published_services.insert(artifact.service.as_str()) { + return Err(WorkflowReportError::Validation(format!( + "duplicate published service: {}", + artifact.service + ))); + } + let item = push_plan + .items + .iter() + .find(|item| item.service == artifact.service) + .ok_or_else(|| { + WorkflowReportError::Validation(format!( + "published service '{}' is absent from the push plan", + artifact.service + )) + })?; + artifact + .validate_against_plan_item(&self.environment, item) + .map_err(|error| { + WorkflowReportError::Validation(format!( + "invalid published artifact: {error}" + )) + })?; + } + if !push_plan.mutates_registry && !self.artifacts.published_images.is_empty() { + return Err(WorkflowReportError::Validation( + "non-mutating push plans cannot contain published artifacts".to_string(), + )); + } + if self.success && push_plan.mutates_registry { + let planned_services = push_plan + .items + .iter() + .map(|item| item.service.as_str()) + .collect::>(); + if planned_services != published_services { + return Err(WorkflowReportError::Validation( + "successful publication report does not cover every planned service" + .to_string(), + )); + } + } + } else if !self.artifacts.published_images.is_empty() { + return Err(WorkflowReportError::Validation( + "published artifacts require an image push plan".to_string(), + )); + } + + Ok(()) + } +} + +fn build_workflow_report( + profile: &crate::workflow::profile::NormalizedWorkflowProfile, + runner: &RunnerContext, + result: &runkernel::PipelineResult, + plan: &crate::workflow::plan::WorkflowPlan, + report_data: &crate::workflow::image::WorkflowReportData, + finalizers: &WorkflowReportFinalizers, + final_success: bool, +) -> Result { let task_items = result .tasks .iter() - .map(|task| { - serde_json::json!({ - "name": task.name, - "status": format!("{:?}", task.status).to_lowercase() - }) + .map(|task| WorkflowReportTaskItem { + name: task.name.clone(), + status: WorkflowReportTaskStatus::from(&task.status), + error: task.error.clone(), }) .collect::>(); - let mut report = serde_json::json!({ - "profile": profile.name, - "mode": profile.mode.as_str(), - "runner": format!("{:?}", runner.kind).to_lowercase(), - "success": result.summary.success, - "tasks": { - "completed": result.summary.completed, - "failed": result.summary.failed, - "skipped": result.summary.skipped, - "cancelled": result.summary.cancelled, - "items": task_items - } - }); + let published_artifacts = report_data.published_artifacts.clone(); + for artifact in &published_artifacts { + artifact + .validate() + .map_err(|e| format!("invalid published artifact: {:?}", e))?; + } + let image_push_plan: Option = + plan.image_push_plan.clone(); + + let mut report = WorkflowReport { + schema_version: "sailr.workflow-report/v1".to_string(), + report_type: WorkflowReportType::WorkflowExecution, + profile: profile.name.clone(), + mode: profile.mode.as_str().to_string(), + runner: runner.clone(), + environment: profile.environment.clone(), + success: final_success, + effects: plan.effects.clone(), + tasks: WorkflowReportTasks { + completed: result.summary.completed, + failed: result.summary.failed, + skipped: result.summary.skipped, + cancelled: result.summary.cancelled, + cached: result.summary.cached, + rolled_back: result.summary.rolled_back, + rollback_failed: result.summary.rollback_failed, + items: task_items, + }, + finalizers: finalizers.clone(), + plans: WorkflowReportPlans { + image_push: image_push_plan, + deployment: None, + }, + artifacts: WorkflowReportArtifacts { + published_images: published_artifacts, + }, + }; if profile.deploy == crate::workflow::profile::WorkflowStepMode::Plan { let context = profile.deploy_context.as_deref().unwrap_or("none"); @@ -145,28 +654,139 @@ fn write_workflow_report( context, namespace, ) { - if let Some(obj) = report.as_object_mut() { - obj.insert( - "deployment_plan".to_string(), - serde_json::to_value(plan).unwrap_or(serde_json::Value::Null), - ); - } + report.plans.deployment = Some(plan); } } - let report_dir = std::path::Path::new(".sailr") - .join("reports") - .join(&profile.name); + report.validate().map_err(|error| error.to_string())?; + Ok(report) +} + +fn write_workflow_report_document( + root: &std::path::Path, + report: &WorkflowReport, +) -> Result<(), String> { + report.validate().map_err(|e| e.to_string())?; + + let report_dir = root.join(".sailr").join("reports").join(&report.profile); + std::fs::create_dir_all(&report_dir) .map_err(|e| format!("Failed to create report directory: {}", e))?; let report_path = report_dir.join("latest.json"); - let json_string = serde_json::to_string_pretty(&report) + let temporary_path = report_dir.join("latest.json.tmp"); + let json_string = serde_json::to_vec_pretty(&report) .map_err(|e| format!("Failed to serialize report: {}", e))?; - std::fs::write(&report_path, json_string) - .map_err(|e| format!("Failed to write report: {}", e))?; + let write_result = (|| -> Result<(), String> { + use std::io::Write; + let mut file = std::fs::File::create(&temporary_path) + .map_err(|e| format!("failed to create temporary report: {}", e))?; + file.write_all(&json_string) + .map_err(|e| format!("failed to write temporary report: {}", e))?; + file.sync_all() + .map_err(|e| format!("failed to sync temporary report: {}", e))?; + std::fs::rename(&temporary_path, &report_path) + .map_err(|e| format!("failed to atomically replace workflow report: {}", e))?; + Ok(()) + })(); + + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary_path); + } + + write_result +} + +fn execute_before_report_finalizers( + plan: &crate::workflow::plan::WorkflowPlan, + result: &runkernel::PipelineResult, +) -> Result { + let planned: Vec<_> = plan + .finalizers + .iter() + .filter(|f| f.kind.phase() == crate::workflow::plan::WorkflowFinalizerPhase::BeforeReport) + .collect(); + + let mut finalizers = WorkflowReportFinalizers { + planned: planned.iter().map(|f| f.id.to_string()).collect(), + items: Vec::new(), + completed: 0, + skipped: 0, + failed: 0, + }; + + if !result.summary.success { + for finalizer in planned { + finalizers.items.push(WorkflowFinalizerResult { + id: finalizer.id.clone(), + status: WorkflowFinalizerStatus::Skipped, + error: Some("pipeline did not complete successfully".to_string()), + }); + finalizers.skipped += 1; + } + return Ok(finalizers); + } + + for finalizer in planned { + let res = match finalizer.kind { + crate::workflow::plan::WorkflowFinalizerKind::WriteBuildCache => { + if let Some(build_plan) = &plan.build_plan { + crate::builder::write_successful_service_caches(build_plan, result) + } else { + Ok(()) + } + } + crate::workflow::plan::WorkflowFinalizerKind::WriteWorkflowReport => { + Err("WriteWorkflowReport must not be executed as BeforeReport".to_string()) + } + }; + match res { + Ok(_) => { + finalizers.completed += 1; + finalizers.items.push(WorkflowFinalizerResult { + id: finalizer.id.clone(), + status: WorkflowFinalizerStatus::Completed, + error: None, + }); + } + Err(e) => { + finalizers.failed += 1; + finalizers.items.push(WorkflowFinalizerResult { + id: finalizer.id.clone(), + status: WorkflowFinalizerStatus::Failed, + error: Some(e), + }); + } + } + } + + Ok(finalizers) +} + +fn execute_report_sink( + root: &std::path::Path, + plan: &crate::workflow::plan::WorkflowPlan, + report: &WorkflowReport, +) -> Result<(), String> { + for finalizer in &plan.finalizers { + if finalizer.kind.phase() != crate::workflow::plan::WorkflowFinalizerPhase::ReportSink { + continue; + } + + match finalizer.kind { + crate::workflow::plan::WorkflowFinalizerKind::WriteWorkflowReport => { + write_workflow_report_document(root, report)?; + } + _ => { + return Err(format!( + "Finalizer kind {:?} is not a ReportSink", + finalizer.kind + )); + } + } + } Ok(()) } @@ -175,6 +795,26 @@ pub fn validate_workflow_safety( runner: &RunnerContext, args: &crate::cli::WorkflowRunArgs, ) -> Result<(), String> { + if profile.push == crate::workflow::profile::WorkflowStepMode::Run { + if !profile.apply { + return Err("push=run requires profile apply=true".to_string()); + } + + if !args.apply { + return Err("push=run requires --apply".to_string()); + } + + if runner.ci && profile.approval != crate::workflow::profile::ApprovalMode::External { + let msg = match runner.kind { + RunnerKind::CircleCi => "CI push requires approval=external.\n\nDetected CircleCI.\nAdd approval = \"external\" to [workflow.ci-build-push] and gate the mutating CircleCI job behind:\n\n approve_image_push:\n type: approval", + RunnerKind::GitHubActions => "CI push requires approval=external.\n\nDetected GitHub Actions.\nAdd approval = \"external\" to [workflow.ci-build-push] and run the job behind a protected GitHub Environment.", + RunnerKind::Travis => "CI push requires approval=external.\n\nDetected Travis.\nAdd approval = \"external\" to [workflow.ci-build-push] and guard the mutating job with branch and environment variable conditions.", + _ => "CI push requires approval=external", + }; + return Err(msg.to_string()); + } + } + if runner.ci && profile.interactive { return Err("workflow cannot be interactive in CI".to_string()); } @@ -196,7 +836,13 @@ pub fn validate_workflow_safety( if runner.ci { if profile.approval != crate::workflow::profile::ApprovalMode::External { - return Err("CI deploy requires approval=external".to_string()); + let msg = match runner.kind { + RunnerKind::CircleCi => "CI deploy requires approval=external.\n\nDetected CircleCI.\nAdd approval = \"external\" to [workflow.ci-build-push] and gate the mutating CircleCI job behind:\n\n approve_image_push:\n type: approval", + RunnerKind::GitHubActions => "CI deploy requires approval=external.\n\nDetected GitHub Actions.\nAdd approval = \"external\" to [workflow.ci-build-push] and run the job behind a protected GitHub Environment.", + RunnerKind::Travis => "CI deploy requires approval=external.\n\nDetected Travis.\nAdd approval = \"external\" to [workflow.ci-build-push] and guard the mutating job with branch and environment variable conditions.", + _ => "CI deploy requires approval=external", + }; + return Err(msg.to_string()); } if !profile.apply { @@ -224,6 +870,103 @@ pub fn validate_workflow_safety( Ok(()) } +pub fn requires_cli_apply(profile: &crate::workflow::profile::NormalizedWorkflowProfile) -> bool { + profile.push == crate::workflow::profile::WorkflowStepMode::Run + || profile.deploy == crate::workflow::profile::WorkflowStepMode::Run +} + +#[derive(Debug)] +pub struct WorkflowInspectionImage { + pub service: String, + pub local_image_ref: String, + pub target_image_ref: String, + pub build_fingerprint: String, + pub source_revision: Option, +} + +#[derive(Debug)] +pub struct WorkflowInspection { + pub profile_name: String, + pub profile_mode: String, + pub config_path: String, + pub environment: String, + pub environment_path: String, + pub runner_ci: bool, + pub runner_provider: String, + pub runner_interactive: bool, + pub approval: Option, + pub profile_apply: bool, + pub requires_cli_apply: bool, + pub build_mode: crate::workflow::profile::WorkflowStepMode, + pub push_mode: crate::workflow::profile::WorkflowStepMode, + pub generate_mode: crate::workflow::profile::WorkflowStepMode, + pub deploy_mode: crate::workflow::profile::WorkflowStepMode, + pub registry_host: String, + pub registry_namespace: String, + pub registry_prefix: String, + pub images: Vec, +} + +impl WorkflowInspection { + pub fn render_workflow_inspection(&self) -> String { + let mut output = String::new(); + output.push_str("Workflow:\n"); + output.push_str(&format!(" profile: {}\n", self.profile_name)); + output.push_str(&format!(" mode: {}\n", self.profile_mode)); + output.push_str(&format!(" config: {}\n", self.config_path)); + output.push_str(&format!(" environment: {}\n", self.environment)); + output.push_str(&format!( + " environment config: {}\n", + self.environment_path + )); + + output.push_str("\nRunner:\n"); + output.push_str(&format!(" ci: {}\n", self.runner_ci)); + output.push_str(&format!(" provider: {}\n", self.runner_provider)); + output.push_str(&format!(" interactive: {}\n", self.runner_interactive)); + + output.push_str("\nSafety:\n"); + output.push_str(&format!(" approval: {:?}\n", self.approval)); + output.push_str(&format!( + " profile apply allowed: {}\n", + self.profile_apply + )); + output.push_str(&format!( + " CLI apply required: {}\n", + self.requires_cli_apply + )); + output.push_str(&format!(" build mode: {:?}\n", self.build_mode)); + output.push_str(&format!(" push mode: {:?}\n", self.push_mode)); + output.push_str(&format!(" generate mode: {:?}\n", self.generate_mode)); + output.push_str(&format!(" deploy mode: {:?}\n", self.deploy_mode)); + + output.push_str("\nRegistry:\n"); + output.push_str(&format!(" host: {}\n", self.registry_host)); + output.push_str(&format!(" namespace: {}\n", self.registry_namespace)); + output.push_str(&format!(" prefix: {}\n", self.registry_prefix)); + + output.push_str("\nImages:\n"); + if self.images.is_empty() { + output.push_str(" (no image push plan)\n"); + } else { + for item in &self.images { + output.push_str(&format!(" service: {}\n", item.service)); + output.push_str(&format!(" local image ref: {}\n", item.local_image_ref)); + output.push_str(&format!(" target image ref: {}\n", item.target_image_ref)); + output.push_str(&format!( + " build fingerprint: {}\n", + item.build_fingerprint + )); + output.push_str(&format!( + " source revision: {}\n", + item.source_revision.as_deref().unwrap_or("none") + )); + } + } + output + } +} + pub struct WorkflowRunner; impl WorkflowRunner { @@ -284,7 +1027,10 @@ impl WorkflowRunner { runner_ctx.clone(), ); let plan = planner.plan()?; - let (mut pipeline, build_execution) = planner.build_pipeline_from_plan(&plan)?; + + let accumulator = crate::workflow::image::WorkflowReportAccumulator::default(); + let (mut pipeline, build_execution) = + planner.build_pipeline_from_plan(&plan, accumulator.clone())?; // 8. Run Pipeline attach_pipeline_logging(&mut pipeline); @@ -300,7 +1046,26 @@ impl WorkflowRunner { // 9. Finalize print_workflow_result(&normalized_profile, &runner_ctx, &result); - write_workflow_report(&normalized_profile, &runner_ctx, &result)?; + + let report_data = accumulator.snapshot().await; + + let finalizer_results = execute_before_report_finalizers(&plan, &result)?; + + let final_success = result.summary.success + && finalizer_results.failed == 0 + && finalizer_results.skipped == 0; + + let report = build_workflow_report( + &normalized_profile, + &runner_ctx, + &result, + &plan, + &report_data, + &finalizer_results, + final_success, + )?; + + execute_report_sink(std::path::Path::new("."), &plan, &report)?; match build_execution { crate::workflow::planner::WorkflowBuildExecution::None => {} @@ -309,23 +1074,118 @@ impl WorkflowRunner { } crate::workflow::planner::WorkflowBuildExecution::Executed(plan) => { crate::builder::print_pipeline_result(&plan, &result); - if result.summary.success { - write_successful_service_caches(&plan, &result)?; - } } } - if !result.summary.success { + if !final_success { print_failed_tasks(&result); - return Err(format!( - "Workflow failed: {} failed, {} skipped, {} cancelled", - result.summary.failed, result.summary.skipped, result.summary.cancelled - )); + if !result.summary.success { + return Err(format!( + "Workflow failed: {} failed, {} skipped, {} cancelled", + result.summary.failed, result.summary.skipped, result.summary.cancelled + )); + } else { + return Err("Workflow failed: one or more finalizers failed".to_string()); + } } crate::LOGGER.info("✅ Workflow completed successfully."); Ok(()) } + + pub async fn inspect(args: crate::cli::WorkflowInspectArgs) -> Result<(), String> { + let runner_ctx = RunnerContext::detect(false); + let config_path = std::path::Path::new("sailr.workflow.toml"); + let config = WorkflowConfig::load().map_err(|e| e.to_string())?; + + let profile = config + .get_profile(&args.profile) + .ok_or_else(|| format!("Workflow profile '{}' not found", args.profile))?; + let normalized = profile.normalize(runner_ctx.ci); + + let env_path_str = format!("k8s/environments/{}/config.toml", normalized.environment); + let env = Environment::load_from_file(&normalized.environment).map_err(|e| { + format!( + "Failed to load environment '{}': {}", + normalized.environment, e + ) + })?; + + let resolved_registry = env + .registry + .resolve() + .map_err(|e| format!("Invalid registry configuration: {}", e))?; + + let env_arc = std::sync::Arc::new(env); + let build_options = crate::builder::BuildOptions { + cache_dir: ".sailr/cache".to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: true, + dry_run: true, + explain: false, + dump_scope: false, + policy: None, + }; + let planner = crate::workflow::planner::WorkflowPlanner::new( + normalized.clone(), + env_arc.clone(), + build_options, + runner_ctx.clone(), + ); + let plan = planner + .plan() + .map_err(|e| format!("Failed to generate plan: {}", e))?; + + let inspection = WorkflowInspection { + profile_name: normalized.name.clone(), + profile_mode: format!("{:?}", normalized.mode).to_lowercase(), + config_path: std::fs::canonicalize(config_path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "sailr.workflow.toml".to_string()), + environment: normalized.environment.clone(), + environment_path: std::fs::canonicalize(&env_path_str) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| env_path_str), + runner_ci: runner_ctx.ci, + runner_provider: format!("{:?}", runner_ctx.kind), + runner_interactive: runner_ctx.interactive, + approval: Some(normalized.approval), + profile_apply: profile.apply.unwrap_or(false), + requires_cli_apply: requires_cli_apply(&normalized), + build_mode: normalized.build, + push_mode: normalized.push, + generate_mode: normalized.generate, + deploy_mode: normalized.deploy, + registry_host: resolved_registry.host.clone(), + registry_namespace: resolved_registry + .namespace + .clone() + .unwrap_or_else(|| "none".to_string()), + registry_prefix: resolved_registry.prefix(), + images: { + let mut images = Vec::new(); + if let Some(push_plan) = plan.image_push_plan { + for item in &push_plan.items { + images.push(WorkflowInspectionImage { + service: item.service.clone(), + local_image_ref: item.local_image_ref.clone(), + target_image_ref: item.target_image_ref.clone(), + build_fingerprint: item.provenance.build_fingerprint.clone(), + source_revision: item.provenance.source_revision.clone(), + }); + } + } + images + }, + }; + + println!("{}", inspection.render_workflow_inspection()); + + Ok(()) + } + pub async fn plan(args: crate::cli::WorkflowPlanArgs) -> Result<(), String> { let runner_ctx = RunnerContext::detect(false); let config = WorkflowConfig::load().map_err(|e| e.to_string())?; @@ -489,100 +1349,89 @@ impl WorkflowRunner { #[cfg(test)] mod tests { use super::*; - use once_cell::sync::Lazy; - use std::env; - use std::sync::Mutex; - - static ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); - fn clear_ci_envs() { - env::remove_var("GITHUB_ACTIONS"); - env::remove_var("CIRCLECI"); - env::remove_var("TRAVIS"); - env::remove_var("CI"); + fn execute_test_finalizer_lifecycle( + root: &std::path::Path, + profile: &crate::workflow::profile::NormalizedWorkflowProfile, + runner_ctx: &RunnerContext, + result: &runkernel::PipelineResult, + plan: &crate::workflow::plan::WorkflowPlan, + report_data: &crate::workflow::image::WorkflowReportData, + ) -> Result<(), String> { + let finalizer_results = super::execute_before_report_finalizers(plan, result)?; + let final_success = result.summary.success + && finalizer_results.failed == 0 + && finalizer_results.skipped == 0; + let report = super::build_workflow_report( + profile, + runner_ctx, + result, + plan, + report_data, + &finalizer_results, + final_success, + )?; + super::execute_report_sink(root, plan, &report)?; + Ok(()) } - fn run_with_env(key: &str, value: &str, test: F) - where - F: FnOnce(), - { - let _guard = ENV_LOCK.lock().unwrap(); - clear_ci_envs(); - if !key.is_empty() { - env::set_var(key, value); - } - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - test(); - })); - - if !key.is_empty() { - env::remove_var(key); - } - - if let Err(err) = result { - std::panic::resume_unwind(err); + fn map_environment(values: &[(&str, &str)]) -> MapEnvironmentReader { + MapEnvironmentReader { + values: values + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(), } } #[test] fn detects_github_actions() { - run_with_env("GITHUB_ACTIONS", "true", || { - let ctx = RunnerContext::detect(false); - assert_eq!(ctx.kind, RunnerKind::GitHubActions); - assert!(ctx.ci); - assert!(!ctx.interactive); - }); + let environment = map_environment(&[("GITHUB_ACTIONS", "true"), ("GITHUB_RUN_ID", "17")]); + let ctx = RunnerContext::detect_with(false, &environment); + assert_eq!(ctx.kind, RunnerKind::GitHubActions); + assert_eq!(ctx.ci_environment.unwrap().run_id.as_deref(), Some("17")); + assert!(ctx.ci); + assert!(!ctx.interactive); } #[test] fn detects_circle_ci() { - run_with_env("CIRCLECI", "true", || { - let ctx = RunnerContext::detect(false); - assert_eq!(ctx.kind, RunnerKind::CircleCi); - assert!(ctx.ci); - assert!(!ctx.interactive); - }); + let ctx = RunnerContext::detect_with(false, &map_environment(&[("CIRCLECI", "true")])); + assert_eq!(ctx.kind, RunnerKind::CircleCi); + assert!(ctx.ci); + assert!(!ctx.interactive); } #[test] fn detects_travis() { - run_with_env("TRAVIS", "true", || { - let ctx = RunnerContext::detect(false); - assert_eq!(ctx.kind, RunnerKind::Travis); - assert!(ctx.ci); - assert!(!ctx.interactive); - }); + let ctx = RunnerContext::detect_with(false, &map_environment(&[("TRAVIS", "true")])); + assert_eq!(ctx.kind, RunnerKind::Travis); + assert!(ctx.ci); + assert!(!ctx.interactive); } #[test] fn detects_generic_ci() { - run_with_env("CI", "true", || { - let ctx = RunnerContext::detect(false); - assert_eq!(ctx.kind, RunnerKind::GenericCi); - assert!(ctx.ci); - assert!(!ctx.interactive); - }); + let ctx = RunnerContext::detect_with(false, &map_environment(&[("CI", "true")])); + assert_eq!(ctx.kind, RunnerKind::GenericCi); + assert!(ctx.ci); + assert!(!ctx.interactive); } #[test] fn local_runner_interactive_by_default() { - run_with_env("", "", || { - let ctx = RunnerContext::detect(false); - assert_eq!(ctx.kind, RunnerKind::Local); - assert!(!ctx.ci); - assert!(ctx.interactive); - }); + let ctx = RunnerContext::detect_with(false, &MapEnvironmentReader::default()); + assert_eq!(ctx.kind, RunnerKind::Local); + assert!(!ctx.ci); + assert!(ctx.interactive); } #[test] fn local_runner_disabled_interactivity_with_flag() { - run_with_env("", "", || { - let ctx = RunnerContext::detect(true); - assert_eq!(ctx.kind, RunnerKind::Local); - assert!(!ctx.ci); - assert!(!ctx.interactive); - }); + let ctx = RunnerContext::detect_with(true, &MapEnvironmentReader::default()); + assert_eq!(ctx.kind, RunnerKind::Local); + assert!(!ctx.ci); + assert!(!ctx.interactive); } #[test] @@ -599,6 +1448,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: true, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -611,6 +1461,7 @@ mod tests { }; let runner = RunnerContext { + ci_environment: None, kind: RunnerKind::Local, ci: false, interactive: true, @@ -649,6 +1500,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -661,6 +1513,7 @@ mod tests { }; let runner = RunnerContext { + ci_environment: None, kind: RunnerKind::GitHubActions, ci: true, interactive: false, @@ -698,6 +1551,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Plan, test: WorkflowStepMode::Disabled, @@ -710,9 +1564,10 @@ mod tests { }; let runner = RunnerContext { + ci_environment: None, kind: RunnerKind::Local, ci: false, - interactive: false, // user ran with --non-interactive + interactive: false, }; let res = validate_workflow_safety( @@ -748,6 +1603,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: true, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -760,6 +1616,7 @@ mod tests { }; let runner = RunnerContext { + ci_environment: None, kind: RunnerKind::Local, ci: false, interactive: true, @@ -795,6 +1652,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -807,6 +1665,7 @@ mod tests { }; let runner = RunnerContext { + ci_environment: None, kind: RunnerKind::GitHubActions, ci: true, interactive: false, @@ -825,4 +1684,626 @@ mod tests { let res = validate_workflow_safety(&profile, &runner, &args); assert!(res.is_ok()); } + + #[test] + fn ci_build_push_plan_json_report_includes_image_push_plan() { + use crate::environment::Environment; + use crate::workflow::planner::WorkflowPlanner; + use crate::workflow::profile::WorkflowProfile; + + let temp_dir = tempfile::tempdir().unwrap(); + let env_toml = format!( + r#" + schema_version = "v0.5" + name = "test" + domain = "test.local" + log_level = "info" + default_replicas = 1 + registry = "ghcr.io" + [[service]] + name = "ci-build-hello" + [service.build] + path = "{}" + "#, + temp_dir.path().to_string_lossy() + ); + let env: Environment = toml::from_str(&env_toml).unwrap(); + + let profile_toml = r#" + environment = "test" + mode = "build" + build = "plan" + push = "plan" + report = "json" + "#; + let mut profile: WorkflowProfile = toml::from_str(profile_toml).unwrap(); + profile.name = "ci-build-push-plan".to_string(); + let normalized = profile.normalize(false); + let runner_ctx = RunnerContext::detect(true); + let options = crate::builder::BuildOptions { + cache_dir: temp_dir + .path() + .join(".sailr/cache") + .to_string_lossy() + .to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }; + + let planner = WorkflowPlanner::new( + normalized.clone(), + std::sync::Arc::new(env), + options, + runner_ctx.clone(), + ); + + let plan = planner.plan().unwrap(); + + let result = runkernel::PipelineResult { + name: "test".to_string(), + duration: std::time::Duration::from_secs(1), + summary: runkernel::PipelineSummary { + name: "test".to_string(), + success: true, + completed: 1, + failed: 0, + skipped: 0, + cancelled: 0, + cached: 0, + rolled_back: 0, + rollback_failed: 0, + }, + tasks: vec![runkernel::TaskResult { + name: crate::workflow::task_id::PUSH_PLAN.to_string(), + status: runkernel::TaskStatus::Completed, + duration: Some(std::time::Duration::from_secs(1)), + error: None, + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + }], + }; + + // Write report into a temp directory to avoid polluting the project. + let temp = tempfile::tempdir().unwrap(); + + execute_test_finalizer_lifecycle( + temp.path(), + &normalized, + &runner_ctx, + &result, + &plan, + &Default::default(), + ) + .unwrap(); + + let report_path = temp + .path() + .join(".sailr/reports/ci-build-push-plan/latest.json"); + let content = std::fs::read_to_string(&report_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert_eq!( + json["plans"]["image_push"]["items"][0]["action"], + "would_push" + ); + } + + fn load_fixture_json(name: &str) -> serde_json::Value { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/reports") + .join(name); + let content = std::fs::read_to_string(&path).unwrap(); + serde_json::from_str(&content).unwrap() + } + + #[derive(Debug)] + struct FixedRevision; + + impl crate::workflow::planner::SourceRevisionResolver for FixedRevision { + fn resolve( + &self, + _runner: &RunnerContext, + ) -> Result, crate::workflow::error::ProvenanceError> { + Ok(Some("0123456789abcdef".to_string())) + } + } + + fn task_result( + name: impl Into, + status: runkernel::TaskStatus, + error: Option<&str>, + ) -> runkernel::TaskResult { + runkernel::TaskResult { + name: name.into(), + status, + duration: Some(std::time::Duration::from_secs(1)), + error: error.map(str::to_string), + cache_hit: false, + cache_reason: None, + rollback_status: None, + rollback_error: None, + } + } + + fn completed_results_from_plan( + plan: &crate::workflow::plan::WorkflowPlan, + ) -> Vec { + plan.tasks + .iter() + .map(|task| task_result(task.id.clone(), runkernel::TaskStatus::Completed, None)) + .collect() + } + + fn failed_results_from_plan( + plan: &crate::workflow::plan::WorkflowPlan, + failed_task: &str, + error: &str, + ) -> Vec { + let mut unavailable = std::collections::BTreeSet::from([failed_task.to_string()]); + loop { + let before = unavailable.len(); + for task in &plan.tasks { + if task + .dependencies + .iter() + .any(|dependency| unavailable.contains(dependency)) + { + unavailable.insert(task.id.clone()); + } + } + if unavailable.len() == before { + break; + } + } + + plan.tasks + .iter() + .map(|task| { + if task.id == failed_task { + task_result(task.id.clone(), runkernel::TaskStatus::Failed, Some(error)) + } else if unavailable.contains(&task.id) { + task_result(task.id.clone(), runkernel::TaskStatus::Skipped, None) + } else { + task_result(task.id.clone(), runkernel::TaskStatus::Completed, None) + } + }) + .collect() + } + + fn pipeline_result_from_tasks( + success: bool, + tasks: Vec, + ) -> runkernel::PipelineResult { + let count = |status: runkernel::TaskStatus| { + tasks.iter().filter(|task| task.status == status).count() + }; + runkernel::PipelineResult { + name: "test".to_string(), + duration: std::time::Duration::from_secs(1), + summary: runkernel::PipelineSummary { + name: "test".to_string(), + success, + completed: count(runkernel::TaskStatus::Completed), + failed: count(runkernel::TaskStatus::Failed), + skipped: count(runkernel::TaskStatus::Skipped), + cancelled: count(runkernel::TaskStatus::Cancelled), + cached: count(runkernel::TaskStatus::Cached), + rolled_back: count(runkernel::TaskStatus::RolledBack), + rollback_failed: 0, + }, + tasks, + } + } + + fn generate_report_json( + push_mode: crate::workflow::profile::WorkflowStepMode, + success: bool, + ) -> serde_json::Value { + let cache_dir = tempfile::tempdir().unwrap(); + let env_toml = r#" + schema_version = "v0.5" + name = "staging" + domain = "staging.example.com" + log_level = "info" + default_replicas = 1 + + [registry] + host = "ghcr.io" + namespace = "org/repo" + + [[service]] + name = "api" + version = "1.2.0" + [service.build] + path = "tests/fixtures/report-service" + "#; + let environment: crate::environment::Environment = toml::from_str(env_toml).unwrap(); + let profile_toml = format!( + r#" + environment = "staging" + mode = "build" + build = "{}" + push = "{}" + report = "json" + "#, + if push_mode == crate::workflow::profile::WorkflowStepMode::Run { + "run" + } else { + "plan" + }, + if push_mode == crate::workflow::profile::WorkflowStepMode::Run { + "run" + } else { + "plan" + } + ); + let mut profile: crate::workflow::profile::WorkflowProfile = + toml::from_str(&profile_toml).unwrap(); + profile.name = "ci-build-push".to_string(); + let normalized = profile.normalize(true); + + let runner_ctx = RunnerContext { + ci_environment: Some(crate::workflow::ci::CiEnvironment { + provider: crate::workflow::ci::CiProvider::GitHub, + run_id: Some("run-17".to_string()), + }), + kind: RunnerKind::GitHubActions, + ci: true, + interactive: false, + }; + + let options = crate::builder::BuildOptions { + cache_dir: cache_dir.path().join("cache").to_string_lossy().to_string(), + force: true, + only: vec![], + ignore: vec![], + plan: push_mode == crate::workflow::profile::WorkflowStepMode::Plan, + dry_run: false, + explain: false, + dump_scope: false, + policy: environment.build.clone(), + }; + let planner = crate::workflow::planner::WorkflowPlanner::with_source_revision_resolver( + normalized.clone(), + std::sync::Arc::new(environment), + options, + runner_ctx.clone(), + std::sync::Arc::new(FixedRevision), + ); + let plan = planner.plan().unwrap(); + let item = plan.image_push_plan.as_ref().unwrap().items[0].clone(); + + let (tasks, published_artifacts) = + if push_mode == crate::workflow::profile::WorkflowStepMode::Plan { + (completed_results_from_plan(&plan), vec![]) + } else if success { + let artifact = crate::workflow::image::PublishedImageArtifact::from_push_result( + "staging", + &item, + "sha256:d8c58252270dd7a199042c161ab8b5c98cf85a8efb7aab782167dcf42f02b938", + "2024-03-20T12:00:00Z", + ) + .unwrap(); + (completed_results_from_plan(&plan), vec![artifact]) + } else { + ( + failed_results_from_plan( + &plan, + &crate::workflow::task_id::service_push("api"), + "registry rejected push", + ), + vec![], + ) + }; + + let result = pipeline_result_from_tasks(success, tasks); + + let report_data = crate::workflow::image::WorkflowReportData { + published_artifacts, + }; + + let temp = tempfile::tempdir().unwrap(); + assert_eq!( + normalized.report, + crate::workflow::profile::ReportMode::Json + ); + execute_test_finalizer_lifecycle( + temp.path(), + &normalized, + &runner_ctx, + &result, + &plan, + &report_data, + ) + .unwrap(); + + let report_path = temp.path().join(".sailr/reports/ci-build-push/latest.json"); + let content = std::fs::read_to_string(&report_path).unwrap(); + let decoded: WorkflowReport = serde_json::from_str(&content).unwrap(); + decoded.validate().unwrap(); + assert_eq!( + serde_json::from_str::(&content).unwrap(), + decoded + ); + serde_json::to_value(decoded).unwrap() + } + + #[test] + fn test_report_image_push_plan() { + let actual = generate_report_json(crate::workflow::profile::WorkflowStepMode::Plan, true); + let expected = load_fixture_json("image-push-plan.json"); + assert_eq!(actual, expected); + } + + #[test] + fn test_report_image_publication_success() { + let actual = generate_report_json(crate::workflow::profile::WorkflowStepMode::Run, true); + let expected = load_fixture_json("image-publication-success.json"); + assert_eq!(actual, expected); + } + + #[test] + fn test_report_image_publication_failure() { + let actual = generate_report_json(crate::workflow::profile::WorkflowStepMode::Run, false); + let expected = load_fixture_json("image-publication-failure.json"); + assert_eq!(actual, expected); + } + + #[test] + fn workflow_report_validation_rejects_inconsistent_contracts() { + let mut report: WorkflowReport = + serde_json::from_value(load_fixture_json("image-publication-success.json")).unwrap(); + report.validate().unwrap(); + + report.schema_version = "future".to_string(); + assert!(report.validate().is_err()); + report.schema_version = "sailr.workflow-report/v1".to_string(); + + report.tasks.completed += 1; + assert!(report.validate().is_err()); + report.tasks.completed -= 1; + + report.success = true; + report.tasks.failed = 1; + report.tasks.items[0].status = WorkflowReportTaskStatus::Failed; + report.tasks.completed -= 1; + assert!(report.validate().is_err()); + } + + #[test] + fn workflow_report_rejects_publication_destination_and_coverage_mismatches() { + let report = || -> WorkflowReport { + serde_json::from_value(load_fixture_json("image-publication-success.json")).unwrap() + }; + + let mut wrong_registry = report(); + wrong_registry.artifacts.published_images[0].registry = "docker.io".to_string(); + assert!(wrong_registry.validate().is_err()); + + let mut wrong_repository = report(); + wrong_repository.artifacts.published_images[0].repository = "other/api".to_string(); + assert!(wrong_repository.validate().is_err()); + + let mut wrong_tag = report(); + wrong_tag.artifacts.published_images[0].tag = "different".to_string(); + assert!(wrong_tag.validate().is_err()); + + let mut wrong_environment = report(); + wrong_environment.artifacts.published_images[0].environment = "production".to_string(); + assert!(wrong_environment.validate().is_err()); + + let mut wrong_provenance = report(); + wrong_provenance.artifacts.published_images[0] + .provenance + .build_fingerprint = "different".to_string(); + assert!(wrong_provenance.validate().is_err()); + + let mut unknown_service = report(); + unknown_service.artifacts.published_images[0].service = "worker".to_string(); + assert!(unknown_service.validate().is_err()); + + let mut duplicate = report(); + duplicate + .artifacts + .published_images + .push(duplicate.artifacts.published_images[0].clone()); + assert!(duplicate.validate().is_err()); + + let mut missing = report(); + missing.artifacts.published_images.clear(); + assert!(missing.validate().is_err()); + + let mut without_plan = report(); + without_plan.plans.image_push = None; + assert!(without_plan.validate().is_err()); + + let mut plan_only: WorkflowReport = + serde_json::from_value(load_fixture_json("image-push-plan.json")).unwrap(); + plan_only.artifacts.published_images = report().artifacts.published_images; + assert!(plan_only.validate().is_err()); + } + + #[test] + fn failed_reports_allow_valid_partial_publication_evidence() { + let mut report: WorkflowReport = + serde_json::from_value(load_fixture_json("image-publication-success.json")).unwrap(); + let mut web = report.plans.image_push.as_ref().unwrap().items[0].clone(); + web.service = "web".to_string(); + web.repository = "org/repo/web".to_string(); + web.target_image_ref = format!("ghcr.io/{}:{}", web.repository, web.tag); + web.local_image_ref = "ghcr.io/org/repo/web:1.2.0".to_string(); + report.plans.image_push.as_mut().unwrap().items.push(web); + report.success = false; + report.tasks.failed = 1; + report.tasks.items.push(WorkflowReportTaskItem { + name: crate::workflow::task_id::service_push("web"), + status: WorkflowReportTaskStatus::Failed, + error: Some("registry rejected push".to_string()), + }); + report.validate().unwrap(); + } + + #[test] + fn no_op_publication_report_requires_no_artifacts() { + let mut report: WorkflowReport = + serde_json::from_value(load_fixture_json("image-push-plan.json")).unwrap(); + let plan = report.plans.image_push.as_mut().unwrap(); + plan.items.clear(); + plan.mutates_registry = false; + report.validate().unwrap(); + } + + #[test] + fn report_finalizer_controls_json_persistence() { + for (report_mode, writes_file) in [("text", false), ("json", true), ("both", true)] { + let mut profile: crate::workflow::profile::WorkflowProfile = toml::from_str(&format!( + r#" + environment = "test" + mode = "check" + build = "disabled" + generate = "disabled" + deploy = "disabled" + report = "{report_mode}" + "# + )) + .unwrap(); + profile.name = format!("report-{report_mode}"); + let normalized = profile.normalize(false); + let runner = RunnerContext { + kind: RunnerKind::Local, + ci: false, + interactive: false, + ci_environment: None, + }; + let planner = crate::workflow::planner::WorkflowPlanner::new( + normalized.clone(), + std::sync::Arc::new(crate::environment::Environment::new("test")), + crate::builder::BuildOptions { + cache_dir: ".sailr/test-report-finalizer".to_string(), + force: false, + only: vec![], + ignore: vec![], + plan: false, + dry_run: false, + explain: false, + dump_scope: false, + policy: None, + }, + runner.clone(), + ); + let plan = planner.plan().unwrap(); + let result = pipeline_result_from_tasks(true, completed_results_from_plan(&plan)); + let root = tempfile::tempdir().unwrap(); + execute_test_finalizer_lifecycle( + root.path(), + &normalized, + &runner, + &result, + &plan, + &Default::default(), + ) + .unwrap(); + assert_eq!( + root.path() + .join(".sailr/reports") + .join(&normalized.name) + .join("latest.json") + .exists(), + writes_file + ); + } + } + + #[test] + fn report_task_statuses_cover_runkernel_and_reject_nonterminal_results() { + let statuses = [ + runkernel::TaskStatus::Pending, + runkernel::TaskStatus::Running, + runkernel::TaskStatus::Cached, + runkernel::TaskStatus::Completed, + runkernel::TaskStatus::Failed, + runkernel::TaskStatus::Skipped, + runkernel::TaskStatus::Cancelled, + runkernel::TaskStatus::RolledBack, + ]; + for status in statuses { + let report_status = WorkflowReportTaskStatus::from(&status); + serde_json::to_string(&report_status).unwrap(); + } + + let mut report: WorkflowReport = + serde_json::from_value(load_fixture_json("image-push-plan.json")).unwrap(); + report.tasks.items[0].status = WorkflowReportTaskStatus::Pending; + report.tasks.completed -= 1; + assert!(report.validate().is_err()); + } + + #[test] + fn inspection_apply_gate_is_independent_of_profile_permission() { + let mut profile: crate::workflow::profile::WorkflowProfile = toml::from_str( + r#" + environment = "staging" + mode = "build" + build = "run" + push = "run" + apply = true + "#, + ) + .unwrap(); + profile.name = "publish".to_string(); + let normalized = profile.normalize(false); + assert!(normalized.apply); + assert!(requires_cli_apply(&normalized)); + } + + #[test] + fn inspection_renders_modes_registry_and_image_provenance() { + let inspection = WorkflowInspection { + profile_name: "publish".to_string(), + profile_mode: "build".to_string(), + config_path: "sailr.workflow.toml".to_string(), + environment: "staging".to_string(), + environment_path: "k8s/environments/staging/config.toml".to_string(), + runner_ci: true, + runner_provider: "CircleCi".to_string(), + runner_interactive: false, + approval: Some(crate::workflow::profile::ApprovalMode::External), + profile_apply: true, + requires_cli_apply: true, + build_mode: crate::workflow::profile::WorkflowStepMode::Run, + push_mode: crate::workflow::profile::WorkflowStepMode::Run, + generate_mode: crate::workflow::profile::WorkflowStepMode::Disabled, + deploy_mode: crate::workflow::profile::WorkflowStepMode::Disabled, + registry_host: "ghcr.io".to_string(), + registry_namespace: "acme/platform".to_string(), + registry_prefix: "ghcr.io/acme/platform".to_string(), + images: vec![WorkflowInspectionImage { + service: "api".to_string(), + local_image_ref: "ghcr.io/acme/platform/api:1.2.0".to_string(), + target_image_ref: "ghcr.io/acme/platform/api:abc1234".to_string(), + build_fingerprint: "abc123456789".to_string(), + source_revision: None, + }], + }; + let rendered = inspection.render_workflow_inspection(); + for expected in [ + "profile apply allowed: true", + "CLI apply required: true", + "build mode: Run", + "push mode: Run", + "prefix: ghcr.io/acme/platform", + "build fingerprint: abc123456789", + "source revision: none", + ] { + assert!(rendered.contains(expected)); + } + } } diff --git a/src/workflow/task_id.rs b/src/workflow/task_id.rs new file mode 100644 index 0000000..76718a0 --- /dev/null +++ b/src/workflow/task_id.rs @@ -0,0 +1,21 @@ +pub const VALIDATE_CONFIG: &str = "workflow:validate-config"; +pub const BUILD_PLAN: &str = "workflow:build-plan"; +pub const PUSH_PLAN: &str = "workflow:push-plan"; +pub const IMAGE_REPORT: &str = "workflow:image-report"; +pub const BUILD_BEFORE_ALL: &str = "build:before-all"; +pub const BUILD_AFTER_ALL: &str = "build:after-all"; +pub const GENERATE: &str = "workflow:generate"; +pub const DEPLOYMENT_PLAN: &str = "workflow:deployment-plan"; +pub const APPROVAL: &str = "workflow:approval"; +pub const DEPLOY: &str = "workflow:deploy"; +pub const REPORT_ARTIFACTS: &str = "workflow:image-report"; +pub const WRITE_REPORT_FINALIZER: &str = "finalizer:write-workflow-report"; +pub const WRITE_BUILD_CACHE_FINALIZER: &str = "finalizer:write-build-cache"; + +pub fn service_build(service: &str) -> String { + format!("service:{service}:build") +} + +pub fn service_push(service: &str) -> String { + format!("service:{service}:push") +} diff --git a/tests/fixtures/report-service/.gitkeep b/tests/fixtures/report-service/.gitkeep new file mode 100644 index 0000000..81d2a00 --- /dev/null +++ b/tests/fixtures/report-service/.gitkeep @@ -0,0 +1 @@ +fixture directory for deterministic workflow report planning diff --git a/tests/fixtures/reports/image-publication-failure.json b/tests/fixtures/reports/image-publication-failure.json new file mode 100644 index 0000000..25c7c41 --- /dev/null +++ b/tests/fixtures/reports/image-publication-failure.json @@ -0,0 +1,96 @@ +{ + "schema_version": "sailr.workflow-report/v1", + "report_type": "workflow-execution", + "profile": "ci-build-push", + "mode": "build", + "runner": { + "kind": "git-hub-actions", + "ci": true, + "interactive": false, + "ci_environment": { + "provider": "github", + "run_id": "run-17" + } + }, + "environment": "staging", + "success": false, + "effects": { + "mutates_filesystem": true, + "mutates_docker": true, + "mutates_registry": true, + "mutates_git": false, + "mutates_cluster": false, + "prompts_user": false + }, + "tasks": { + "completed": 3, + "failed": 1, + "skipped": 1, + "cancelled": 0, + "cached": 0, + "rolled_back": 0, + "rollback_failed": 0, + "items": [ + { + "name": "workflow:validate-config", + "status": "completed" + }, + { + "name": "service:api:build", + "status": "completed" + }, + { + "name": "workflow:push-plan", + "status": "completed" + }, + { + "name": "service:api:push", + "status": "failed", + "error": "registry rejected push" + }, + { + "name": "workflow:image-report", + "status": "skipped" + } + ] + }, + "plans": { + "image_push": { + "environment": "staging", + "mutates_registry": true, + "items": [ + { + "service": "api", + "registry": "ghcr.io", + "repository": "org/repo/api", + "target_image_ref": "ghcr.io/org/repo/api:1bf43f1", + "local_image_ref": "ghcr.io/org/repo/api:1.2.0", + "tag": "1bf43f1", + "provenance": { + "build_fingerprint": "1bf43f1a943982aa", + "source_revision": "0123456789abcdef" + }, + "action": "would_push" + } + ] + } + }, + "artifacts": { + "published_images": [] + }, + "finalizers": { + "completed": 0, + "failed": 0, + "skipped": 1, + "items": [ + { + "id": "finalizer:write-build-cache", + "status": "skipped", + "error": "pipeline did not complete successfully" + } + ], + "planned": [ + "finalizer:write-build-cache" + ] + } +} diff --git a/tests/fixtures/reports/image-publication-success.json b/tests/fixtures/reports/image-publication-success.json new file mode 100644 index 0000000..f533e7c --- /dev/null +++ b/tests/fixtures/reports/image-publication-success.json @@ -0,0 +1,109 @@ +{ + "schema_version": "sailr.workflow-report/v1", + "report_type": "workflow-execution", + "profile": "ci-build-push", + "mode": "build", + "runner": { + "kind": "git-hub-actions", + "ci": true, + "interactive": false, + "ci_environment": { + "provider": "github", + "run_id": "run-17" + } + }, + "environment": "staging", + "success": true, + "effects": { + "mutates_filesystem": true, + "mutates_docker": true, + "mutates_registry": true, + "mutates_git": false, + "mutates_cluster": false, + "prompts_user": false + }, + "tasks": { + "completed": 5, + "failed": 0, + "skipped": 0, + "cancelled": 0, + "cached": 0, + "rolled_back": 0, + "rollback_failed": 0, + "items": [ + { + "name": "workflow:validate-config", + "status": "completed" + }, + { + "name": "service:api:build", + "status": "completed" + }, + { + "name": "workflow:push-plan", + "status": "completed" + }, + { + "name": "service:api:push", + "status": "completed" + }, + { + "name": "workflow:image-report", + "status": "completed" + } + ] + }, + "plans": { + "image_push": { + "environment": "staging", + "mutates_registry": true, + "items": [ + { + "service": "api", + "registry": "ghcr.io", + "repository": "org/repo/api", + "target_image_ref": "ghcr.io/org/repo/api:1bf43f1", + "local_image_ref": "ghcr.io/org/repo/api:1.2.0", + "tag": "1bf43f1", + "provenance": { + "build_fingerprint": "1bf43f1a943982aa", + "source_revision": "0123456789abcdef" + }, + "action": "would_push" + } + ] + } + }, + "artifacts": { + "published_images": [ + { + "service": "api", + "environment": "staging", + "registry": "ghcr.io", + "repository": "org/repo/api", + "tag": "1bf43f1", + "digest": "sha256:d8c58252270dd7a199042c161ab8b5c98cf85a8efb7aab782167dcf42f02b938", + "image_ref": "ghcr.io/org/repo/api@sha256:d8c58252270dd7a199042c161ab8b5c98cf85a8efb7aab782167dcf42f02b938", + "provenance": { + "build_fingerprint": "1bf43f1a943982aa", + "source_revision": "0123456789abcdef" + }, + "published_at": "2024-03-20T12:00:00Z" + } + ] + }, + "finalizers": { + "completed": 1, + "failed": 0, + "skipped": 0, + "items": [ + { + "id": "finalizer:write-build-cache", + "status": "completed" + } + ], + "planned": [ + "finalizer:write-build-cache" + ] + } +} diff --git a/tests/fixtures/reports/image-push-plan.json b/tests/fixtures/reports/image-push-plan.json new file mode 100644 index 0000000..7cf7b3f --- /dev/null +++ b/tests/fixtures/reports/image-push-plan.json @@ -0,0 +1,79 @@ +{ + "schema_version": "sailr.workflow-report/v1", + "report_type": "workflow-execution", + "profile": "ci-build-push", + "mode": "build", + "runner": { + "kind": "git-hub-actions", + "ci": true, + "interactive": false, + "ci_environment": { + "provider": "github", + "run_id": "run-17" + } + }, + "environment": "staging", + "success": true, + "effects": { + "mutates_filesystem": true, + "mutates_docker": false, + "mutates_registry": false, + "mutates_git": false, + "mutates_cluster": false, + "prompts_user": false + }, + "tasks": { + "completed": 3, + "failed": 0, + "skipped": 0, + "cancelled": 0, + "cached": 0, + "rolled_back": 0, + "rollback_failed": 0, + "items": [ + { + "name": "workflow:validate-config", + "status": "completed" + }, + { + "name": "workflow:build-plan", + "status": "completed" + }, + { + "name": "workflow:push-plan", + "status": "completed" + } + ] + }, + "plans": { + "image_push": { + "environment": "staging", + "mutates_registry": false, + "items": [ + { + "service": "api", + "registry": "ghcr.io", + "repository": "org/repo/api", + "target_image_ref": "ghcr.io/org/repo/api:1bf43f1", + "local_image_ref": "ghcr.io/org/repo/api:1.2.0", + "tag": "1bf43f1", + "provenance": { + "build_fingerprint": "1bf43f1a943982aa", + "source_revision": "0123456789abcdef" + }, + "action": "would_push" + } + ] + } + }, + "artifacts": { + "published_images": [] + }, + "finalizers": { + "completed": 0, + "failed": 0, + "skipped": 0, + "items": [], + "planned": [] + } +}