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..0b32242 --- /dev/null +++ b/docs/PR-17.md @@ -0,0 +1,14 @@ +### Stage 3: `ci-build-push` +Status: complete + +Includes: +- `ci-build-push` profile. +- Manual protected image push workflow. +- GHCR login. +- Runtime image report accumulator. +- Mandatory digest capture. +- Digest-bearing `ImageArtifact` reports. +- `push=run` plan/graph/explain support. + +### Stage 4: promotion plan +Status: next diff --git a/docs/pilots/circleci-external-repo.md b/docs/pilots/circleci-external-repo.md new file mode 100644 index 0000000..ccdd2c7 --- /dev/null +++ b/docs/pilots/circleci-external-repo.md @@ -0,0 +1,36 @@ +# CircleCI External Repo Pilot + +## External repo +https://github.com/josh-tracey/sailr-travis-pilot + +## Sailr revision +`experiment/runkernel` + +## CI provider +CircleCI + +## Registry +`europe-west2-docker.pkg.dev` (GCP Artifact Registry) + +## Service +`skyfleet` + +## Profiles tested +- `ci-build-push-plan` +- `ci-build-push` + +## What passed +- Sailr installed from Git revision. +- Push plan ran in CircleCI. +- CircleCI approval gate configured and worked. +- Sailr accepted `approval = external`. +- Image pushed to registry (GCP). +- Digest captured. +- Report validated with jq. + +## Issues found +- Initial failure: `CI push requires approval=external`. +- Resolution: Kept `approval = external` in `sailr.workflow.toml` and added `approve_image_push` job of `type: approval` to CircleCI configuration to align manual approval gates with Sailr's CI safety constraints. + +## Follow-up work +- Provider-specific error messages implemented in Sailr (`src/workflow/runner.rs`) to guide developers towards correct CI setups for CircleCI, GitHub Actions, and Travis. 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..b9e2ee7 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -471,14 +471,18 @@ pub(crate) fn add_runkernel_tasks( } for service_plan in &plan.services { - let mut dependencies = service_plan.dependencies.clone(); + let mut dependencies: Vec = service_plan + .dependencies + .iter() + .map(|d| format!("service:{}:build", d)) + .collect(); if service_plan.dirty && has_dirty_services && !plan.before_all.is_empty() { dependencies.push("build:before-all".to_string()); } dependencies.sort(); dependencies.dedup(); - let mut task = Task::new(service_plan.service.name.clone()) + let mut task = Task::new(format!("service:{}:build", service_plan.service.name)) .depends_on(&dependencies.iter().map(String::as_str).collect::>()) .cache_disabled(); @@ -503,6 +507,9 @@ pub(crate) fn add_runkernel_tasks( Task::new("build:after-all") .depends_on( &dirty_services + .iter() + .map(|s| format!("service:{}:build", s)) + .collect::>() .iter() .map(String::as_str) .collect::>(), @@ -535,7 +542,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; @@ -902,7 +909,9 @@ fn build_room( push_command, Some(format!( "{}/{}:{}", - env.registry, service.name, service.version + env.registry.host(), + service.name, + service.version )), ) } @@ -989,7 +998,7 @@ fn render_build_command(command: &str, env: &Environment, service: &Service) -> let mut rendered = command.to_string(); for (key, value) in [ - ("registry", env.registry.as_str()), + ("registry", env.registry.prefix().as_str()), ("platform", env.platform.as_deref().unwrap_or("")), ("environment", env.name.as_str()), ("name", service.name.as_str()), @@ -1133,7 +1142,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 = format!("service:{}:build", service.service.name); + if !completed_tasks.contains(task_name.as_str()) { continue; } @@ -1613,7 +1623,7 @@ 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(); @@ -1813,8 +1823,8 @@ mod tests { write_successful_service_caches( &plan, &pipeline_result(vec![ - ("api", TaskStatus::Completed), - ("web", TaskStatus::Skipped), + ("service:api:build", TaskStatus::Completed), + ("service:web:build", TaskStatus::Skipped), ]), ) .expect("cache write should succeed"); 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..9c450d3 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -13,6 +13,45 @@ 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, + }, +} + +impl Default for RegistryConfig { + fn default() -> Self { + RegistryConfig::Simple("docker.io".to_string()) + } +} + +impl RegistryConfig { + pub fn host(&self) -> String { + match self { + Self::Simple(s) => s.clone(), + Self::Detailed { host, .. } => host.clone(), + } + } + + pub fn namespace(&self) -> Option { + match self { + Self::Simple(_) => None, + Self::Detailed { namespace, .. } => namespace.clone(), + } + } + + pub fn prefix(&self) -> String { + match self.namespace() { + Some(ns) => format!("{}/{}", self.host(), ns), + None => self.host(), + } + } +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct Environment { pub schema_version: String, @@ -22,8 +61,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 +83,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()), @@ -500,13 +540,14 @@ impl Environment { 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(), @@ -1291,7 +1332,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.host(), "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 +1382,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.host(), "docker.io/staging"); } #[test] 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/image.rs b/src/workflow/image.rs new file mode 100644 index 0000000..54a9ec8 --- /dev/null +++ b/src/workflow/image.rs @@ -0,0 +1,330 @@ +use serde::{Deserialize, Serialize}; + +use std::sync::Arc; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct WorkflowReportData { + pub images: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct WorkflowReportAccumulator { + inner: Arc>, +} + +impl WorkflowReportAccumulator { + pub async fn add_image(&self, artifact: ImageArtifact) { + let mut inner = self.inner.lock().await; + inner.images.push(artifact); + } + + pub async fn snapshot(&self) -> WorkflowReportData { + self.inner.lock().await.clone() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageArtifact { + pub service: String, + pub environment: String, + pub registry: String, + pub repository: String, + pub tag: String, + pub digest: Option, + pub image_ref: String, + pub source_sha: Option, + pub built_at: Option, +} + +impl ImageArtifact { + pub fn tagged( + service: impl Into, + environment: impl Into, + registry: impl Into, + repository: impl Into, + tag: impl Into, + source_sha: Option, + ) -> Self { + let registry = registry.into(); + let repository = repository.into(); + let tag = tag.into(); + + let image_ref = format!("{}/{}:{}", registry, repository, tag); + + Self { + service: service.into(), + environment: environment.into(), + registry, + repository, + tag, + digest: None, + image_ref, + source_sha, + built_at: None, + } + } + + pub fn with_digest(mut self, digest: impl Into) -> Self { + let digest = digest.into(); + self.image_ref = format!("{}/{}@{}", self.registry, self.repository, digest); + self.digest = Some(digest); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ImageArtifactReport { + pub 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, + pub tag: String, + pub image_ref: String, + pub source_sha: Option, + pub action: ImagePushPlanAction, +} + +impl ImageArtifact { + pub fn from_push_plan_item(environment: impl Into, item: &ImagePushPlanItem) -> Self { + ImageArtifact::tagged( + item.service.clone(), + environment, + item.registry.clone(), + item.repository.clone(), + item.tag.clone(), + item.source_sha.clone(), + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImagePushPlanReport { + pub environment: String, + pub mutates_registry: bool, + pub items: Vec, +} + +pub fn derive_image_tag(source_sha: Option<&str>) -> String { + match source_sha { + Some(sha) if sha.len() >= 7 => sha[0..7].to_string(), + Some(sha) => sha.to_string(), + None => "dev".to_string(), + } +} + +pub fn parse_pushed_digest(output: &str) -> Option { + output.lines().find_map(|line| { + let marker = "digest:"; + let idx = line.find(marker)?; + let rest = line[idx + marker.len()..].trim(); + rest.split_whitespace().next().map(str::to_string) + }) +} + +pub fn pushed_artifact_from_output( + environment: &str, + item: &ImagePushPlanItem, + output: &str, +) -> Result { + let digest = parse_pushed_digest(output).ok_or_else(|| { + format!( + "image push succeeded but digest could not be captured for {}", + item.image_ref + ) + })?; + + Ok(ImageArtifact::from_push_plan_item(environment, item).with_digest(digest)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_artifact_tagged_builds_tag_ref() { + let artifact = ImageArtifact::tagged( + "ci-build-hello", + "staging", + "ghcr.io", + "adriftdev/sailr/ci-build-hello", + "2bcc3f7", + Some("2bcc3f70984bb6d33d93bbcbb9eb3539ce033dc8".to_string()), + ); + + assert_eq!(artifact.service, "ci-build-hello"); + assert_eq!(artifact.environment, "staging"); + assert_eq!(artifact.registry, "ghcr.io"); + assert_eq!(artifact.repository, "adriftdev/sailr/ci-build-hello"); + assert_eq!(artifact.tag, "2bcc3f7"); + assert_eq!(artifact.digest, None); + assert_eq!( + artifact.image_ref, + "ghcr.io/adriftdev/sailr/ci-build-hello:2bcc3f7" + ); + } + + #[test] + fn image_artifact_with_digest_builds_digest_ref() { + let artifact = ImageArtifact::tagged( + "ci-build-hello", + "staging", + "ghcr.io", + "adriftdev/sailr/ci-build-hello", + "2bcc3f7", + None, + ) + .with_digest("sha256:abc123"); + + assert_eq!(artifact.digest.as_deref(), Some("sha256:abc123")); + assert_eq!( + artifact.image_ref, + "ghcr.io/adriftdev/sailr/ci-build-hello@sha256:abc123" + ); + } + + #[test] + fn empty_image_artifact_report_serializes() { + let report = ImageArtifactReport::default(); + let json = serde_json::to_value(report).unwrap(); + + assert_eq!(json["artifacts"], serde_json::json!([])); + } + + #[test] + fn image_artifact_serializes_expected_shape() { + let artifact = ImageArtifact::tagged( + "ci-build-hello", + "staging", + "ghcr.io", + "adriftdev/sailr/ci-build-hello", + "2bcc3f7", + Some("2bcc3f70984bb6d33d93bbcbb9eb3539ce033dc8".to_string()), + ) + .with_digest("sha256:abc123"); + + let json = serde_json::to_value(artifact).unwrap(); + + assert_eq!(json["service"], "ci-build-hello"); + assert_eq!(json["environment"], "staging"); + assert_eq!(json["registry"], "ghcr.io"); + assert_eq!(json["repository"], "adriftdev/sailr/ci-build-hello"); + assert_eq!(json["tag"], "2bcc3f7"); + assert_eq!(json["digest"], "sha256:abc123"); + assert_eq!( + json["image_ref"], + "ghcr.io/adriftdev/sailr/ci-build-hello@sha256:abc123" + ); + } +} + +#[cfg(test)] +mod tests_derive { + use super::*; + #[test] + fn test_derive_image_tag() { + assert_eq!( + derive_image_tag(Some("2bcc3f70984bb6d33d93bbcbb9eb3539ce033dc8")), + "2bcc3f7" + ); + assert_eq!(derive_image_tag(Some("abc")), "abc"); + assert_eq!(derive_image_tag(None), "dev"); + } + + #[test] + fn parses_digest_from_stdout_style_output() { + let output = "latest: digest: sha256:abc123 size: 1234"; + assert_eq!( + parse_pushed_digest(output).as_deref(), + Some("sha256:abc123") + ); + } + + #[test] + fn parses_digest_from_stderr_style_combined_output() { + let output = "some stdout\nlatest: digest: sha256:def456 size: 1234"; + assert_eq!( + parse_pushed_digest(output).as_deref(), + Some("sha256:def456") + ); + } + + #[test] + fn returns_none_when_digest_missing() { + let output = "pushed some layers"; + assert_eq!(parse_pushed_digest(output), 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(), + image_ref: "ghcr.io/org/api:latest".to_string(), + source_sha: None, + action: ImagePushPlanAction::WouldPush, + }; + let output = "digest: sha256:xyz"; + let artifact = pushed_artifact_from_output("prod", &item, output).unwrap(); + assert_eq!(artifact.digest, Some("sha256:xyz".to_string())); + assert_eq!(artifact.image_ref, "ghcr.io/org/api@sha256:xyz"); + } + + #[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(), + image_ref: "ghcr.io/org/api:latest".to_string(), + source_sha: None, + action: ImagePushPlanAction::WouldPush, + }; + let output = "no digest here"; + let err = pushed_artifact_from_output("prod", &item, output).unwrap_err(); + assert_eq!( + err, + "image push succeeded but digest could not be captured for ghcr.io/org/api:latest" + ); + } +} + +#[cfg(test)] +mod tests_addendum { + use super::*; + + #[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(), + image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + source_sha: 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"); + } +} diff --git a/src/workflow/mod.rs b/src/workflow/mod.rs index 3d5c8d2..6aadb8a 100644 --- a/src/workflow/mod.rs +++ b/src/workflow/mod.rs @@ -2,6 +2,7 @@ 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; diff --git a/src/workflow/plan.rs b/src/workflow/plan.rs index 67a72b2..4f98d32 100644 --- a/src/workflow/plan.rs +++ b/src/workflow/plan.rs @@ -10,6 +10,7 @@ pub struct WorkflowPlan { pub tasks: Vec, pub edges: Vec, pub build_plan: Option, + pub image_push_plan: Option, pub effects: WorkflowEffects, } @@ -28,6 +29,9 @@ pub enum WorkflowTaskKind { ValidateConfig, BuildPlan, ServiceBuild, + PushPlan, + ServicePush, + ImageReport, Generate, DeploymentPlan, Deploy, diff --git a/src/workflow/planner.rs b/src/workflow/planner.rs index b820d49..de7fe49 100644 --- a/src/workflow/planner.rs +++ b/src/workflow/planner.rs @@ -44,6 +44,7 @@ impl WorkflowPlanner { let mut edges = Vec::new(); let mut effects = WorkflowEffects::default(); let mut build_plan_opt = None; + let mut image_push_plan_opt = None; // 0. Validate Phase tasks.push(WorkflowTaskPlan { @@ -146,6 +147,46 @@ impl WorkflowPlanner { } } + // 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 is_run = self.profile.push == crate::workflow::profile::WorkflowStepMode::Run; + + if is_run { + effects.mutates_docker = true; + effects.mutates_registry = true; + effects.mutates_filesystem = true; + } + + tasks.push(WorkflowTaskPlan { + id: "workflow: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(), + }); + for t in &last_tasks { + edges.push(WorkflowEdge { + from: t.clone(), + to: "workflow:push-plan".to_string(), + }); + } + last_tasks = vec!["workflow: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)?); + } else { + return Err("push requires build=plan or build=run".to_string()); + } + } + } + // 2. Generate Phase if self.profile.generate.is_active() { effects.mutates_filesystem = true; @@ -253,13 +294,64 @@ impl WorkflowPlanner { tasks, edges, build_plan: build_plan_opt, + image_push_plan: image_push_plan_opt, effects, }) } + fn build_image_push_plan_report( + &self, + build_plan: &crate::builder::SailrBuildPlan, + mutates_registry: bool, + ) -> Result { + let mut items = Vec::new(); + + for service_plan in &build_plan.services { + if !service_plan.dirty { + continue; + } + + let registry = if self.env.registry.host().is_empty() { + "docker.io".to_string() + } else { + self.env.registry.host() + }; + + let namespace = self + .env + .registry + .namespace() + .unwrap_or_else(|| "adriftdev/sailr".to_string()); + + let repository = format!("{}/{}", namespace, service_plan.service.name); + + let tag = + crate::workflow::image::derive_image_tag(Some(&service_plan.fingerprint.full_hash)); + + let image_ref = format!("{}/{}:{}", registry, repository, tag); + + items.push(crate::workflow::image::ImagePushPlanItem { + service: service_plan.service.name.clone(), + registry, + repository, + tag, + image_ref, + source_sha: Some(service_plan.fingerprint.full_hash.clone()), + action: crate::workflow::image::ImagePushPlanAction::WouldPush, + }); + } + + Ok(crate::workflow::image::ImagePushPlanReport { + environment: self.profile.environment.clone(), + mutates_registry, + items, + }) + } + 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; @@ -307,7 +399,7 @@ impl WorkflowPlanner { build_deps.push("build:after-all".to_string()); } else { for s in &bp.services { - build_deps.push(s.service.name.clone()); + build_deps.push(format!("service:{}:build", s.service.name)); } } @@ -316,6 +408,135 @@ impl WorkflowPlanner { } } + 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 = Task::new("workflow:push-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); + } + + 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); + last_tasks = vec!["workflow:push-plan".to_string()]; + } + crate::workflow::profile::WorkflowStepMode::Run => { + let push_plan = plan.image_push_plan.clone().unwrap(); + let mut push_tasks = Vec::new(); + + for item in &push_plan.items { + if item.action == crate::workflow::image::ImagePushPlanAction::WouldPush { + let service_name = item.service.clone(); + let image_ref = item.image_ref.clone(); + let accumulator = accumulator.clone(); + let item_clone = item.clone(); + let env_clone = self.env.clone(); + + let build_task_name = format!("service:{}:build", service_name); + let push_task_name = format!("service:{}:push", service_name); + + let task = Task::new(push_task_name.clone()) + .depends_on(&[build_task_name.as_str()]) + .exec_fn(move |_ctx| { + let image_ref = 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 {}", image_ref)); + + let svc = env_clone + .services + .iter() + .find(|s| s.name == item.service) + .unwrap(); + let registry = if env_clone.registry.prefix().is_empty() { + "docker.io".to_string() + } else { + env_clone.registry.prefix() + }; + let local_image = + format!("{}/{}:{}", registry, svc.name, svc.version); + + let mut tag_cmd = tokio::process::Command::new("docker"); + tag_cmd.arg("tag").arg(&local_image).arg(&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 for {} to {}: {}", + local_image, + image_ref, + stderr + )); + } + + let mut cmd = tokio::process::Command::new("docker"); + cmd.arg("push").arg(&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: {}", + stderr + )); + } + + 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 artifact = + crate::workflow::image::pushed_artifact_from_output( + &env_clone.name, + &item, + &combined_output, + ) + .map_err(|e| anyhow::anyhow!(e))?; + + accumulator.add_image(artifact).await; + + Ok(()) + } + }); + pipeline.add(task); + push_tasks.push(push_task_name); + } + } + + let report_task = Task::new("workflow:image-report") + .depends_on(&push_tasks.iter().map(|s| s.as_str()).collect::>()) + .exec_fn(|_ctx| async move { + crate::LOGGER.info("Image push report generated."); + Ok(()) + }); + + pipeline.add(report_task); + last_tasks = vec!["workflow:image-report".to_string()]; + } + } + if self.profile.generate.is_active() { let mut task = Task::new("workflow:generate"); @@ -503,6 +724,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: build_mode, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: deploy_mode, test: WorkflowStepMode::Disabled, @@ -545,7 +767,9 @@ 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"]); } @@ -554,8 +778,9 @@ mod tests { 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,7 +800,9 @@ 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![ @@ -590,8 +817,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,7 +838,9 @@ 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![ @@ -626,8 +856,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,7 +888,9 @@ 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(); @@ -673,3 +906,194 @@ mod tests { assert_eq!(task_names, expected); } } + +#[cfg(test)] +mod tests_addendum { + use super::*; + + #[test] + fn ci_build_push_plan_workflow_plan_has_image_push_plan() { + use crate::environment::Environment; + use crate::workflow::profile::WorkflowProfile; + + let env_toml = 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 = "." + "#; + 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: ".sailr/cache".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(); + + 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 == "workflow:image-report")); + + let api_push = tasks.iter().find(|t| t.name == "service:api:push").unwrap(); + assert_eq!(api_push.dependencies, vec!["service:api:build"]); + + let report = tasks + .iter() + .find(|t| t.name == "workflow:image-report") + .unwrap(); + assert_eq!(report.dependencies, vec!["service:api:push"]); + } +} 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..17f3d36 100644 --- a/src/workflow/render.rs +++ b/src/workflow/render.rs @@ -6,6 +6,10 @@ pub fn render_workflow_plan_text(plan: &WorkflowPlan) -> String { 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 { @@ -188,6 +192,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Disabled, test: WorkflowStepMode::Disabled, @@ -229,6 +234,7 @@ mod tests { to: "build:api".to_string(), }], build_plan: None, + image_push_plan: None, effects: WorkflowEffects { mutates_docker: true, ..Default::default() @@ -263,3 +269,56 @@ mod tests { assert!(text.contains("Mutates Docker: true")); } } + +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.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(), + image_ref: "ghcr.io/adriftdev/sailr/ci-build-hello:61eaa8b".to_string(), + source_sha: 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..d1df3e9 100644 --- a/src/workflow/runner.rs +++ b/src/workflow/runner.rs @@ -101,9 +101,11 @@ fn print_workflow_result( } fn write_workflow_report( - profile: &super::profile::NormalizedWorkflowProfile, + profile: &crate::workflow::profile::NormalizedWorkflowProfile, runner: &RunnerContext, result: &runkernel::PipelineResult, + plan: &crate::workflow::plan::WorkflowPlan, + report_data: &crate::workflow::image::WorkflowReportData, ) -> Result<(), String> { if !matches!( profile.report, @@ -123,6 +125,21 @@ fn write_workflow_report( }) .collect::>(); + let mut images = report_data.images.clone(); + let image_push_plan: Option = + plan.image_push_plan.clone(); + + if images.is_empty() { + if let Some(ref pp) = image_push_plan { + for item in &pp.items { + images.push(crate::workflow::image::ImageArtifact::from_push_plan_item( + &profile.environment, + item, + )); + } + } + } + let mut report = serde_json::json!({ "profile": profile.name, "mode": profile.mode.as_str(), @@ -134,7 +151,9 @@ fn write_workflow_report( "skipped": result.summary.skipped, "cancelled": result.summary.cancelled, "items": task_items - } + }, + "images": images, + "image_push_plan": image_push_plan }); if profile.deploy == crate::workflow::profile::WorkflowStepMode::Plan { @@ -157,6 +176,7 @@ fn write_workflow_report( let report_dir = std::path::Path::new(".sailr") .join("reports") .join(&profile.name); + std::fs::create_dir_all(&report_dir) .map_err(|e| format!("Failed to create report directory: {}", e))?; @@ -164,7 +184,7 @@ fn write_workflow_report( let json_string = serde_json::to_string_pretty(&report) .map_err(|e| format!("Failed to serialize report: {}", e))?; - std::fs::write(&report_path, json_string) + std::fs::write(&report_path, &json_string) .map_err(|e| format!("Failed to write report: {}", e))?; Ok(()) @@ -175,6 +195,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 +236,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 { @@ -284,7 +330,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 +349,15 @@ 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; + write_workflow_report( + &normalized_profile, + &runner_ctx, + &result, + &plan, + &report_data, + )?; match build_execution { crate::workflow::planner::WorkflowBuildExecution::None => {} @@ -599,6 +656,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: true, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -649,6 +707,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -698,6 +757,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Plan, test: WorkflowStepMode::Disabled, @@ -748,6 +808,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: true, build: WorkflowStepMode::Run, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -795,6 +856,7 @@ mod tests { engine: WorkflowEngine::Runkernel, interactive: false, build: WorkflowStepMode::Plan, + push: WorkflowStepMode::Disabled, generate: WorkflowStepMode::Run, deploy: WorkflowStepMode::Run, test: WorkflowStepMode::Disabled, @@ -825,4 +887,100 @@ 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 env_toml = 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 = "." + "#; + 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: ".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.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![], + }; + + // Write report into a temp directory to avoid polluting the project. + let temp = tempfile::tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + write_workflow_report( + &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["image_push_plan"]["environment"], "test"); + assert_eq!(json["image_push_plan"]["mutates_registry"], false); + assert_eq!(json["image_push_plan"]["items"][0]["action"], "would_push"); + + std::env::set_current_dir(original_dir).unwrap(); + } }