mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
377 lines
13 KiB
Rust
377 lines
13 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Context as _;
|
|
use async_trait::async_trait;
|
|
use fabro_api::types::RunManifest;
|
|
use fabro_automation::{AutomationId, AutomationTarget};
|
|
use fabro_config::{EnvironmentLayer, MergeMap};
|
|
use fabro_manifest::ManifestBuildInput;
|
|
use fabro_types::{DirtyStatus, GitContext, GitHubRepositorySlug, RunId};
|
|
use fabro_util::error::collect_chain;
|
|
use tokio::{fs, task};
|
|
|
|
use crate::git_checkout::{
|
|
GitCheckoutError, GitRepoCache, WorktreePrepareInput, resolve_git_auth_config,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct AutomationRunMaterializeInput {
|
|
pub automation_id: AutomationId,
|
|
pub target: AutomationTarget,
|
|
pub run_id: RunId,
|
|
pub user_settings_path: PathBuf,
|
|
pub temp_root: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct AutomationRunMaterialized {
|
|
pub manifest: RunManifest,
|
|
pub submitted_manifest_bytes: Vec<u8>,
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum RunMaterializeError {
|
|
#[error("invalid repository target: {0}")]
|
|
InvalidTarget(String),
|
|
#[error("failed to clone repository: {0}")]
|
|
CloneFailed(String),
|
|
#[error("failed to resolve workflow: {0}")]
|
|
WorkflowNotFound(String),
|
|
#[error("failed to build run manifest: {0}")]
|
|
Manifest(String),
|
|
#[error("failed to load GitHub credentials: {0}")]
|
|
Credentials(String),
|
|
}
|
|
|
|
impl From<GitCheckoutError> for RunMaterializeError {
|
|
fn from(value: GitCheckoutError) -> Self {
|
|
match value {
|
|
GitCheckoutError::CloneFailed(message) => Self::CloneFailed(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub(crate) trait AutomationRunMaterializer: Send + Sync {
|
|
async fn materialize(
|
|
&self,
|
|
input: AutomationRunMaterializeInput,
|
|
) -> Result<AutomationRunMaterialized, RunMaterializeError>;
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ProductionAutomationRunMaterializer {
|
|
github_credentials: Option<fabro_github::GitHubCredentials>,
|
|
github_api_base_url: String,
|
|
http_client: Option<fabro_http::HttpClient>,
|
|
environment_defaults: MergeMap<EnvironmentLayer>,
|
|
repo_cache: Arc<GitRepoCache>,
|
|
}
|
|
|
|
impl ProductionAutomationRunMaterializer {
|
|
pub(crate) fn new(
|
|
github_credentials: Option<fabro_github::GitHubCredentials>,
|
|
github_api_base_url: String,
|
|
http_client: Option<fabro_http::HttpClient>,
|
|
environment_defaults: MergeMap<EnvironmentLayer>,
|
|
repo_cache: Arc<GitRepoCache>,
|
|
) -> Self {
|
|
Self {
|
|
github_credentials,
|
|
github_api_base_url,
|
|
http_client,
|
|
environment_defaults,
|
|
repo_cache,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
|
|
async fn materialize(
|
|
&self,
|
|
input: AutomationRunMaterializeInput,
|
|
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
|
let repo = parse_target_repository(&input.target.repository)?;
|
|
fs::create_dir_all(&input.temp_root).await.map_err(|err| {
|
|
RunMaterializeError::CloneFailed(format!(
|
|
"failed to create temp root {}: {err}",
|
|
input.temp_root.display()
|
|
))
|
|
})?;
|
|
let temp_dir = tempfile::Builder::new()
|
|
.prefix(&format!(
|
|
"automation-{}-{}-",
|
|
input.automation_id.as_str(),
|
|
input.run_id
|
|
))
|
|
.tempdir_in(&input.temp_root)
|
|
.map_err(|err| {
|
|
RunMaterializeError::CloneFailed(format!(
|
|
"failed to create per-run temp directory under {}: {err}",
|
|
input.temp_root.display()
|
|
))
|
|
})?;
|
|
let checkout_dir = temp_dir.path().join("repo");
|
|
let auth = resolve_git_auth_config(
|
|
self.github_credentials.as_ref(),
|
|
&repo,
|
|
&self.github_api_base_url,
|
|
self.http_client.clone(),
|
|
)
|
|
.await
|
|
.map_err(|err| RunMaterializeError::CloneFailed(render_error_chain(err.as_ref())))?;
|
|
|
|
let checked_out_sha = self
|
|
.repo_cache
|
|
.prepare_worktree(WorktreePrepareInput {
|
|
repo: &repo,
|
|
ref_selector: &input.target.ref_selector,
|
|
auth: auth.as_ref(),
|
|
worktree_dir: &checkout_dir,
|
|
})
|
|
.await?;
|
|
|
|
let manifest_input = ManifestFromCheckoutInput {
|
|
workflow: input.target.workflow,
|
|
user_settings_path: input.user_settings_path,
|
|
checkout_dir,
|
|
git_context: ManifestGitContextInput {
|
|
repo,
|
|
ref_selector: input.target.ref_selector,
|
|
checked_out_sha,
|
|
},
|
|
environment_defaults: self.environment_defaults.clone(),
|
|
};
|
|
task::spawn_blocking(move || build_manifest_from_checkout(manifest_input))
|
|
.await
|
|
.map_err(|err| {
|
|
RunMaterializeError::Manifest(format!("manifest build task failed: {err}"))
|
|
})?
|
|
}
|
|
}
|
|
|
|
fn render_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
|
collect_chain(error).join(": ")
|
|
}
|
|
|
|
fn parse_target_repository(value: &str) -> Result<GitHubRepositorySlug, RunMaterializeError> {
|
|
fabro_automation::parse_github_repository_slug(value)
|
|
.map_err(|err| RunMaterializeError::InvalidTarget(err.to_string()))
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ManifestFromCheckoutInput {
|
|
workflow: String,
|
|
user_settings_path: PathBuf,
|
|
checkout_dir: PathBuf,
|
|
git_context: ManifestGitContextInput,
|
|
environment_defaults: MergeMap<EnvironmentLayer>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ManifestGitContextInput {
|
|
repo: GitHubRepositorySlug,
|
|
ref_selector: String,
|
|
checked_out_sha: String,
|
|
}
|
|
|
|
fn build_manifest_from_checkout(
|
|
args: ManifestFromCheckoutInput,
|
|
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
|
let ManifestFromCheckoutInput {
|
|
workflow,
|
|
user_settings_path,
|
|
checkout_dir,
|
|
git_context,
|
|
environment_defaults,
|
|
} = args;
|
|
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
|
|
workflow: workflow.into(),
|
|
cwd: checkout_dir,
|
|
user_settings_path: Some(user_settings_path),
|
|
environment_defaults,
|
|
..ManifestBuildInput::default()
|
|
})
|
|
.map_err(|err| manifest_build_error(&err))?;
|
|
|
|
let mut manifest = built.manifest;
|
|
manifest.git = Some(GitContext {
|
|
origin_url: git_context.repo.https_url(),
|
|
branch: git_context.ref_selector,
|
|
sha: Some(git_context.checked_out_sha),
|
|
dirty: DirtyStatus::Clean,
|
|
});
|
|
let submitted_manifest_bytes = serde_json::to_vec(&manifest)
|
|
.context("failed to serialize materialized run manifest")
|
|
.map_err(|err| RunMaterializeError::Manifest(err.to_string()))?;
|
|
Ok(AutomationRunMaterialized {
|
|
manifest,
|
|
submitted_manifest_bytes,
|
|
})
|
|
}
|
|
|
|
fn manifest_build_error(error: &anyhow::Error) -> RunMaterializeError {
|
|
if error.chain().any(|source| {
|
|
source
|
|
.downcast_ref::<fabro_config::Error>()
|
|
.is_some_and(|err| matches!(err, fabro_config::Error::WorkflowNotFound(_)))
|
|
}) {
|
|
RunMaterializeError::WorkflowNotFound(render_error_chain(error.as_ref()))
|
|
} else {
|
|
RunMaterializeError::Manifest(render_error_chain(error.as_ref()))
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
#[derive(Clone)]
|
|
pub struct TestAutomationRunMaterializer {
|
|
inner: std::sync::Arc<std::sync::Mutex<TestAutomationRunMaterializerState>>,
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
struct TestAutomationRunMaterializerState {
|
|
captured_inputs: Vec<AutomationRunMaterializeInput>,
|
|
response: Result<AutomationRunMaterialized, RunMaterializeError>,
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl TestAutomationRunMaterializer {
|
|
pub fn succeed(manifest: RunManifest, submitted_manifest_bytes: Vec<u8>) -> Self {
|
|
Self::new(Ok(AutomationRunMaterialized {
|
|
manifest,
|
|
submitted_manifest_bytes,
|
|
}))
|
|
}
|
|
|
|
pub fn fail_invalid_target(message: impl Into<String>) -> Self {
|
|
Self::new(Err(RunMaterializeError::InvalidTarget(message.into())))
|
|
}
|
|
|
|
fn new(response: Result<AutomationRunMaterialized, RunMaterializeError>) -> Self {
|
|
Self {
|
|
inner: std::sync::Arc::new(std::sync::Mutex::new(TestAutomationRunMaterializerState {
|
|
captured_inputs: Vec::new(),
|
|
response,
|
|
})),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn captured_inputs(&self) -> Vec<AutomationRunMaterializeInput> {
|
|
self.inner
|
|
.lock()
|
|
.expect("test automation materializer lock poisoned")
|
|
.captured_inputs
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) fn into_materializer(self) -> std::sync::Arc<dyn AutomationRunMaterializer> {
|
|
std::sync::Arc::new(self)
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
#[async_trait]
|
|
impl AutomationRunMaterializer for TestAutomationRunMaterializer {
|
|
async fn materialize(
|
|
&self,
|
|
input: AutomationRunMaterializeInput,
|
|
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
|
let mut guard = self
|
|
.inner
|
|
.lock()
|
|
.expect("test automation materializer lock poisoned");
|
|
guard.captured_inputs.push(input);
|
|
guard.response.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Materializer unit tests write small temporary workflow fixtures synchronously."
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
|
|
use fabro_types::DirtyStatus;
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
fn test_environment_defaults() -> MergeMap<EnvironmentLayer> {
|
|
MergeMap::from(HashMap::from([("default".to_string(), EnvironmentLayer {
|
|
provider: Some("local".to_string()),
|
|
..EnvironmentLayer::default()
|
|
})]))
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_builder_uses_checkout_for_workflow_and_separate_git_context() {
|
|
let temp = TempDir::new().unwrap();
|
|
let checkout = temp.path().join("checkout");
|
|
let workflow_dir = checkout.join(".fabro/workflows/demo");
|
|
fs::create_dir_all(&workflow_dir).unwrap();
|
|
fs::write(checkout.join(".fabro/project.toml"), "_version = 1\n").unwrap();
|
|
fs::write(
|
|
workflow_dir.join("workflow.fabro"),
|
|
r#"digraph Demo { graph [goal="Ship automation"] start [shape=Mdiamond] exit [shape=Msquare] start -> exit }"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
workflow_dir.join("workflow.toml"),
|
|
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
|
)
|
|
.unwrap();
|
|
let user_settings_path = temp.path().join("settings.toml");
|
|
fs::write(&user_settings_path, "_version = 1\n").unwrap();
|
|
let repo = parse_target_repository("workspace-org/app").unwrap();
|
|
let sha = "0123456789abcdef0123456789abcdef01234567".to_string();
|
|
|
|
let materialized = build_manifest_from_checkout(ManifestFromCheckoutInput {
|
|
workflow: "demo".to_string(),
|
|
user_settings_path: user_settings_path.clone(),
|
|
checkout_dir: checkout.clone(),
|
|
git_context: ManifestGitContextInput {
|
|
repo,
|
|
ref_selector: "release".to_string(),
|
|
checked_out_sha: sha.clone(),
|
|
},
|
|
environment_defaults: test_environment_defaults(),
|
|
})
|
|
.expect("manifest should build from checkout");
|
|
|
|
assert_eq!(materialized.manifest.cwd, checkout.display().to_string());
|
|
assert_eq!(
|
|
materialized.manifest.target.path,
|
|
".fabro/workflows/demo/workflow.fabro"
|
|
);
|
|
assert!(
|
|
materialized
|
|
.manifest
|
|
.configs
|
|
.iter()
|
|
.any(|config| config.path.as_deref() == Some(user_settings_path.to_str().unwrap()))
|
|
);
|
|
let git = materialized
|
|
.manifest
|
|
.git
|
|
.as_ref()
|
|
.expect("git context should be set");
|
|
assert_eq!(git.origin_url, "https://github.com/workspace-org/app");
|
|
assert_eq!(git.branch, "release");
|
|
assert_eq!(git.sha.as_deref(), Some(sha.as_str()));
|
|
assert_eq!(git.dirty, DirtyStatus::Clean);
|
|
let submitted_manifest: serde_json::Value =
|
|
serde_json::from_slice(&materialized.submitted_manifest_bytes)
|
|
.expect("submitted bytes should be a manifest");
|
|
assert!(submitted_manifest.get("run_id").is_none());
|
|
assert_eq!(
|
|
submitted_manifest,
|
|
serde_json::to_value(&materialized.manifest).unwrap()
|
|
);
|
|
}
|
|
}
|