diff --git a/AGENTS.md b/AGENTS.md index 3ddc8f843..352bdbb7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,12 +30,20 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) ### Docker sandbox provider - Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities). - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. -- Fabro no longer clones a repository into a sandbox: the engine prepares - every run's checkout. `CloneRequest` still travels beside the sandbox spec - so the run record names the origin and branch; fabro validates it (a pin - needs a branch, a non-GitHub origin needs `skip_clone`) and refuses a - request that asks for a clone. Preflight and `fabro exec` initialize - sandboxes with `CloneRequest::none()`, which creates an empty workspace. +- A GitHub target's workspace is checked out by Fabro's hooks, not by the + sandbox driver or Petri's `start` checkout: when a fresh run's scope is + acquired, `fabro-petri`'s `RunWorkspaces::check_out_source` fetches the + target's revision inside the sandbox with a read-only token the server + resolves at each worker launch (`FABRO_RUN_GIT_CREDENTIAL`, scrubbed at + worker startup), and seeds the workspace's snapshot repository with that + commit so checkpoint bundles from a shallow clone import. When the run + ends, the server pushes the final checkpoint to `fabro/run/` from the + snapshot repository and requests the pull request + (`fabro-server/src/server/run_publication.rs`). `CloneRequest` still + travels beside the sandbox spec so the run record names the origin and + branch; the sandbox layer refuses a request that asks it to clone. + Preflight and `fabro exec` initialize sandboxes with `CloneRequest::none()`, + which creates an empty workspace. ### Release automation - `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. @@ -141,12 +149,20 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as ### Docker sandbox provider - Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities). - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. -- Fabro no longer clones a repository into a sandbox: the engine prepares - every run's checkout. `CloneRequest` still travels beside the sandbox spec - so the run record names the origin and branch; fabro validates it (a pin - needs a branch, a non-GitHub origin needs `skip_clone`) and refuses a - request that asks for a clone. Preflight and `fabro exec` initialize - sandboxes with `CloneRequest::none()`, which creates an empty workspace. +- A GitHub target's workspace is checked out by Fabro's hooks, not by the + sandbox driver or Petri's `start` checkout: when a fresh run's scope is + acquired, `fabro-petri`'s `RunWorkspaces::check_out_source` fetches the + target's revision inside the sandbox with a read-only token the server + resolves at each worker launch (`FABRO_RUN_GIT_CREDENTIAL`, scrubbed at + worker startup), and seeds the workspace's snapshot repository with that + commit so checkpoint bundles from a shallow clone import. When the run + ends, the server pushes the final checkpoint to `fabro/run/` from the + snapshot repository and requests the pull request + (`fabro-server/src/server/run_publication.rs`). `CloneRequest` still + travels beside the sandbox spec so the run record names the origin and + branch; the sandbox layer refuses a request that asks it to clone. + Preflight and `fabro exec` initialize sandboxes with `CloneRequest::none()`, + which creates an empty workspace. ### Release automation - `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. diff --git a/docs/public/execution/checkpoints.mdx b/docs/public/execution/checkpoints.mdx index 216f270f4..f9c482ff3 100644 --- a/docs/public/execution/checkpoints.mdx +++ b/docs/public/execution/checkpoints.mdx @@ -86,7 +86,7 @@ This means your original working directory stays untouched while the agent makes If the working directory has uncommitted changes, the worktree starts from committed `HEAD` and those uncommitted changes are not included. Fabro logs a warning so you can commit, stash, or run explicitly in place when that is what you want. -For Docker and Daytona sandboxes, the repository is cloned into the sandbox and checkpoint Git operations run there. The run branch is pushed to origin from the sandbox after each checkpoint when pushing is configured. +For Docker and Daytona sandboxes, a GitHub target is checked out inside the sandbox and checkpoint Git operations run there; each checkpoint commit reaches the server as a Git bundle. When a successful run ends, the server pushes the run branch to origin when pushing is configured. ## Resuming a run @@ -120,10 +120,10 @@ After a node completes, Fabro: 1. Stores offloaded context payloads in CAS. 2. Creates a code checkpoint commit when Git checkpointing is enabled. -3. Pushes the run branch when configured and collects the code diff. +3. Collects the code diff. 4. Emits a checkpoint event with execution state and the code commit SHA. The run store persists this event and updates the projection. -A checkpoint commit failure stops execution. Intermediate push and diff failures emit warning notices. A required final publish failure marks the run as failed. +A checkpoint commit failure stops execution. A diff failure emits a warning notice. When the run ends, the server pushes the run branch; a failed push is a warning notice on the run. ## Inspecting run history diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index 3c1dca782..6ece5ca99 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -28,8 +28,8 @@ The rest of this page describes the `app` strategy, which is required for browse | Feature | How it's used | |---|---| | **OAuth login** | Users sign in to the web UI with their GitHub account | -| **Private repo cloning** | Daytona and Docker sandboxes clone private repositories using short-lived Installation Access Tokens | -| **Checkpoint pushing** | After each workflow stage, Fabro pushes the run branch back to origin from inside the sandbox | +| **Private repo cloning** | Daytona and Docker sandboxes fetch private repositories using short-lived, read-only Installation Access Tokens | +| **Run branch pushing** | When a run ends, the Fabro server pushes the run branch to origin | | **Auto-PR** | When `[run.pull_request] enabled = true` in the [run config](/execution/run-configuration#runpull_request), Fabro opens a PR from the agent's working branch after a successful run | | **Auto-merge** | When `[run.pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | | **Sandbox GITHUB_TOKEN** | When `[run.integrations.github.permissions]` are declared at any layer (workflow, project, or user settings), Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | @@ -216,16 +216,15 @@ An empty `allowed_usernames` list rejects all users. ### Repository cloning in sandboxes -When a workflow runs in a remote sandbox (Daytona or Docker), Fabro clones the current repository into the sandbox using the GitHub App: +When a run targets a GitHub repository, its workspace is checked out inside the sandbox (Docker or Daytona) before the first stage runs: -1. Fabro detects the local repository's `origin` remote URL and current branch -2. SSH URLs (e.g. `git@github.com:owner/repo.git`) are converted to HTTPS -3. Fabro signs a short-lived JWT using the App ID and private key (RS256, 10-minute validity) -4. Using the JWT, Fabro looks up the GitHub App installation for the repository (`GET /repos/\{owner\}/\{repo\}/installation`) -5. Fabro requests a scoped Installation Access Token with `contents: write` permission on the specific repository -6. The sandbox clones via HTTPS using `x-access-token` as the username and the token as the password +1. When the server launches the run's worker, it signs a short-lived JWT using the App ID and private key (RS256, 10-minute validity) +2. Using the JWT, Fabro looks up the GitHub App installation for the repository (`GET /repos/\{owner\}/\{repo\}/installation`) +3. Fabro requests a scoped Installation Access Token with `contents: read` permission on the specific repository and hands it to the worker, which removes it from its environment at startup +4. Inside the sandbox, Fabro fetches the selected revision from `https://github.com//` at the run's `[run.clone] depth`, presenting the token as an HTTP header on that one command, and checks out the working branch +5. The workspace's `origin` is the plain HTTPS URL: the token is never written into the repository, its configuration, or its remote -For public repositories, the clone works without credentials. The token is still generated because it's needed for pushing checkpoints. +The files belong to the user the sandbox runs commands as. For public repositories the fetch works without credentials when none are configured. #### Git targets for run intents @@ -319,11 +318,9 @@ Every commit a run creates is authored and committed by the run's GitHub credent ### Checkpoint pushing -After each workflow stage, Fabro [checkpoints](/execution/checkpoints) by pushing the run branch to origin. Before a successful run becomes terminal, the publish stage pushes the final commit again and treats failure as a run failure. Inside remote sandboxes, the git remote URL is configured with the Installation Access Token for authenticated pushing. +After each workflow stage, Fabro [checkpoints](/execution/checkpoints) the workspace on the run branch, `fabro/run/`, and moves the commit to the server. When a successful run ends, the server pushes the run's final commit to that branch on origin with its own credentials (an Installation Access Token with `contents: write`, minted for the push). The sandbox never holds a credential that can push. `[run.run_branch] push = false` keeps the branch on the server. -When pull request creation is enabled, Fabro then checks that GitHub reports the run branch at the exact final commit before opening the PR. A failed final push, branch check, or PR creation marks the run as failed with `publish_failed`; the terminal run event is emitted only after this step finishes. - -For long-running workflows, Fabro refreshes the token before each push since Installation Access Tokens are short-lived (typically 1 hour). +When pull request creation is enabled and the run changed files, the server then requests the pull request, and Fabro checks that GitHub reports the run branch at the exact final commit before opening it. A failed push or pull request request is recorded on the run as a warning notice; the run's own outcome is unchanged. ## Troubleshooting diff --git a/lib/apps/fabro-cli/src/commands/run/mod.rs b/lib/apps/fabro-cli/src/commands/run/mod.rs index 14c72e710..9d0095fe9 100644 --- a/lib/apps/fabro-cli/src/commands/run/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/mod.rs @@ -39,10 +39,20 @@ pub(crate) mod test_support; pub(crate) mod timeline; pub(crate) mod wait; +/// The credentials the server hands a run worker through its environment, +/// captured and scrubbed from the process before anything is spawned. +#[derive(Default)] +pub(crate) struct WorkerSecrets { + /// The worker's bearer for the server's API. + pub(crate) token: Option, + /// The read-only credential the run's GitHub target is fetched with. + pub(crate) git_credential: Option, +} + pub(crate) async fn dispatch( cmd: RunCommands, base_ctx: &CommandContext, - worker_token: Option, + worker_secrets: WorkerSecrets, ) -> Result<()> { let printer = base_ctx.printer(); @@ -106,7 +116,8 @@ pub(crate) async fn dispatch( mode, fabro_home, }) => { - let worker_token = worker_token + let worker_token = worker_secrets + .token .filter(|token| !token.trim().is_empty()) .ok_or_else(|| { anyhow!("FABRO_WORKER_TOKEN is required for worker subprocess auth") @@ -121,6 +132,7 @@ pub(crate) async fn dispatch( mode, fabro_home, &worker_token, + worker_secrets.git_credential, ) .instrument(run_span), ) diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index 72ab7365e..e792be955 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -82,6 +82,7 @@ use fabro_petri::platform_records::{HttpPlatformRecords, PlatformRecords}; use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; use fabro_petri::runtime::{self, RuntimeSpec}; use fabro_petri::secrets::VaultSecrets; +use fabro_petri::source::{RunSource, SourceCredential}; use fabro_petri::{HttpRunStore, admission}; use fabro_static::EnvVars; use fabro_store::RunProjection; @@ -104,17 +105,20 @@ use crate::command_context; /// What the worker holds when it hands a run to Petri. pub(super) struct PetriWorker<'a> { - pub(super) run_id: RunId, - pub(super) target: ServerTarget, - pub(super) client: Client, - pub(super) run_state: RunProjection, - pub(super) storage_dir: &'a Path, - pub(super) run_dir: PathBuf, - pub(super) mode: RunWorkerMode, + pub(super) run_id: RunId, + pub(super) target: ServerTarget, + pub(super) client: Client, + pub(super) run_state: RunProjection, + pub(super) storage_dir: &'a Path, + pub(super) run_dir: PathBuf, + pub(super) mode: RunWorkerMode, /// The Fabro home the server named; `None` falls back to Petri's own /// lookup of the worker's environment. - pub(super) fabro_home: Option, - pub(super) worker_token: &'a str, + pub(super) fabro_home: Option, + pub(super) worker_token: &'a str, + /// The read-only credential the run's GitHub target is fetched with, + /// when the server resolved one. + pub(super) git_credential: Option, } /// Execute the run to its end. `Ok` when the record says it succeeded; @@ -199,11 +203,17 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { } runner::set_worker_title(&run_id, WorkerTitlePhase::Running); + let source = RunSource::for_run( + worker.run_state.spec.target.as_ref(), + &worker.run_state.spec.settings.run, + worker.git_credential.clone(), + ); let hooks = HooksSpec::for_run( Arc::clone(&records), &worker.run_state.spec.settings.run, Arc::new(ClientArtifactWriter::new(worker.client.clone_for_reuse())), ) + .with_source(source) .with_test_gates(test_checkpoint_gates()); let request = RunRequest { run_id: run_id.to_string(), diff --git a/lib/apps/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs index e8120b462..01826f500 100644 --- a/lib/apps/fabro-cli/src/commands/run/runner.rs +++ b/lib/apps/fabro-cli/src/commands/run/runner.rs @@ -14,6 +14,7 @@ use fabro_interview::{ }; use fabro_manifest::SuppliedWorkflowVersionPackager; use fabro_petri::controls::RunControls; +use fabro_petri::source::SourceCredential; use fabro_tool::fabro_client::ClientBackend; use fabro_types::RunId; use fabro_vault::{SecretStore, Vault}; @@ -62,6 +63,7 @@ pub(crate) async fn execute( mode: RunWorkerMode, fabro_home: Option, worker_token: &str, + git_credential: Option, ) -> Result<()> { let _ = fabro_proc::title_init(); set_worker_title(&run_id, initial_worker_title_phase(mode)); @@ -89,6 +91,7 @@ pub(crate) async fn execute( mode, fabro_home, worker_token, + git_credential: git_credential.and_then(SourceCredential::from_encoded), })) .await } diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index 83deb5c3e..235413dc0 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -58,19 +58,23 @@ async fn main() { // inherits a process env that no longer contains this credential, so an // unscrubbed spawn site cannot leak it. The token flows to `runner::execute` // through explicit function arguments instead of the environment. - let worker_token = if subcommand == Some("__run-worker") { - let worker_token = process_env_var(EnvVars::FABRO_WORKER_TOKEN); + let worker_secrets = if subcommand == Some("__run-worker") { + let secrets = commands::run::WorkerSecrets { + token: process_env_var(EnvVars::FABRO_WORKER_TOKEN), + git_credential: process_env_var(EnvVars::FABRO_RUN_GIT_CREDENTIAL), + }; #[expect( clippy::disallowed_methods, - reason = "Scrub the worker bearer from this process's env before any \ - child process is spawned, so no descendant can inherit it." + reason = "Scrub the worker's credentials from this process's env before any \ + child process is spawned, so no descendant can inherit them." )] { std::env::remove_var(EnvVars::FABRO_WORKER_TOKEN); + std::env::remove_var(EnvVars::FABRO_RUN_GIT_CREDENTIAL); } - worker_token + secrets } else { - None + commands::run::WorkerSecrets::default() }; install_miette_hook(); @@ -80,7 +84,7 @@ async fn main() { let start = std::time::Instant::now(); - let (command_name, result) = Box::pin(main_inner(worker_token)).await; + let (command_name, result) = Box::pin(main_inner(worker_secrets)).await; let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); let exit_code = result.as_ref().err().map_or(0, exit::exit_code_for); @@ -188,7 +192,7 @@ pub(crate) fn process_env_var(name: &str) -> Option { std::env::var(name).ok() } -async fn main_inner(worker_token: Option) -> (String, Result<()>) { +async fn main_inner(worker_secrets: commands::run::WorkerSecrets) -> (String, Result<()>) { let _ = default_provider().install_default(); let cli = Cli::parse(); @@ -251,7 +255,7 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { commands::exec::execute(args, &base_ctx).await?; } Commands::RunCmd(cmd) => { - Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_token)).await?; + Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_secrets)).await?; } Commands::Preflight(args) => { commands::preflight::execute(args, &base_ctx).await?; diff --git a/lib/apps/fabro-server/src/git_checkout.rs b/lib/apps/fabro-server/src/git_checkout.rs index d2856be42..6908fd0ef 100644 --- a/lib/apps/fabro-server/src/git_checkout.rs +++ b/lib/apps/fabro-server/src/git_checkout.rs @@ -294,7 +294,7 @@ impl GitAuthConfig { } } - fn git_env(&self, clone_url: &str) -> Vec<(String, String)> { + pub(crate) fn git_env(&self, clone_url: &str) -> Vec<(String, String)> { vec![ ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), ( @@ -305,7 +305,7 @@ impl GitAuthConfig { ] } - fn sensitive_values(&self) -> &[String] { + pub(crate) fn sensitive_values(&self) -> &[String] { &self.sensitive_values } } diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 2a6046844..065dbe487 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -169,6 +169,7 @@ mod handler; pub(crate) mod petri_runs; mod pull_request_supervisor; pub(crate) mod resource_sampler; +pub(crate) mod run_publication; pub(crate) mod run_records; mod session_runtime; pub(crate) mod stream_follower; @@ -3827,6 +3828,7 @@ fn worker_launch_spec( run_dir: &std::path::Path, agent_fabro_tools_enabled: bool, github_app_private_key: Option, + run_git_credential: Option, ) -> anyhow::Result { let current_exe = std::env::current_exe().context("reading current executable path")?; let executable = @@ -3865,6 +3867,7 @@ fn worker_launch_spec( fabro_log, active_config_path: state.active_config_path().to_path_buf(), github_app_private_key, + run_git_credential, fabro_home: fabro_config::Home::from_env().root().to_path_buf(), }) } @@ -4182,6 +4185,9 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { return; } + // The read-only credential the worker fetches a GitHub target with, + // resolved for this launch. + let run_git_credential = run_publication::clone_credential(&state, &run_state.spec).await; // The worker reads the Daytona key from the vault itself; only the // GitHub App key crosses on its command. let github_app_private_key = match state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await { @@ -4208,6 +4214,7 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &run_dir_for_build, agent_fabro_tools_enabled, github_app_private_key, + run_git_credential, ) }) .await @@ -4317,6 +4324,7 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { }; accumulate_concluded_run_usage(&state, &final_state); + run_publication::spawn(Arc::clone(&state), run_id); let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { diff --git a/lib/apps/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs index 650a8d09c..662c44e65 100644 --- a/lib/apps/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -314,6 +314,36 @@ fn available_pull_request_response( } } +/// Record that a pull request should be created for the run, unless one +/// exists or a creation is already pending. The caller holds the run's +/// pull request create lock. `Ok(true)` when this call recorded the request. +pub(in crate::server) async fn request_pull_request_creation( + state: &Arc, + id: RunId, + model: String, + force: bool, +) -> Result { + // Under the create lock, the projection is the latest word on whether a + // pull request exists or a creation is already pending. + let run_state = state.load_run_projection(&id).await?; + let appended = run_state.pull_request.is_none() + && !run_state + .pull_request_creation + .as_ref() + .is_some_and(fabro_types::PullRequestCreation::is_pending); + if appended { + let record = PlatformRecord::PullRequestRequested(PullRequestRequestedRecord { + creation_id: fabro_types::PullRequestCreationId::new(), + model, + force, + }); + run_records::append(state, id, record) + .await + .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + } + Ok(appended) +} + async fn create_run_pull_request( RequireRunScoped(id): RequireRunScoped, State(state): State>, @@ -353,29 +383,10 @@ async fn create_run_pull_request( } }; let _create_guard = state.pull_request_create_locks.lock(id).await; - let creation_id = fabro_types::PullRequestCreationId::new(); - // Under the create lock, the projection is the latest word on whether a - // pull request exists or a creation is already pending. - let run_state = match state.load_run_projection(&id).await { - Ok(run_state) => run_state, + let appended = match request_pull_request_creation(&state, id, model, body.force).await { + Ok(appended) => appended, Err(err) => return err.into_response(), }; - let appended = run_state.pull_request.is_none() - && !run_state - .pull_request_creation - .as_ref() - .is_some_and(fabro_types::PullRequestCreation::is_pending); - if appended { - let record = PlatformRecord::PullRequestRequested(PullRequestRequestedRecord { - creation_id, - model, - force: body.force, - }); - if let Err(err) = run_records::append(&state, id, record).await { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - } let run_state = match state.load_run_projection(&id).await { Ok(run_state) => run_state, diff --git a/lib/apps/fabro-server/src/server/petri_runs.rs b/lib/apps/fabro-server/src/server/petri_runs.rs index 0a7e596a1..e3b1e7fd0 100644 --- a/lib/apps/fabro-server/src/server/petri_runs.rs +++ b/lib/apps/fabro-server/src/server/petri_runs.rs @@ -51,6 +51,7 @@ use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::recovery::{self, Recovery, RecoveryRequest}; use fabro_petri::runtime::{self, RuntimeSpec}; use fabro_petri::secrets::VaultSecrets; +use fabro_petri::source::{RunSource, SourceCredential}; use fabro_petri::{SqliteRunStore, admission, projection, run_graph}; use fabro_static::EnvVars; use fabro_store::platform_records::{RunLifecycleKind, RunLifecycleRecord}; @@ -460,13 +461,21 @@ pub(crate) async fn execute(state: Arc, run_id: RunId) { let observers = vec![petri_interviewer.observer()]; let (_, eligible) = state.resolve_llm_client_with_ready_ids().await; let dry_run = run_state.spec.settings.run.execution.mode == RunMode::DryRun; + let source = RunSource::for_run( + run_state.spec.target.as_ref(), + &run_state.spec.settings.run, + super::run_publication::clone_credential(&state, &run_state.spec) + .await + .and_then(SourceCredential::from_encoded), + ); let hooks = HooksSpec::for_run( Arc::new(SqlitePlatformRecords::new(Arc::clone( &state.stores.run_summaries, ))), &run_state.spec.settings.run, Arc::new(StoreArtifactWriter::new(state.artifact_store.clone())), - ); + ) + .with_source(source); let runtime = runtime_spec( &state, &eligible, @@ -537,6 +546,7 @@ pub(crate) async fn execute(state: Arc, run_id: RunId) { warn!(run_id = %run_id, error = ?err, "the run's final state could not be read for the usage aggregate"); } } + super::run_publication::spawn(Arc::clone(&state), run_id); } /// Bring a Petri run the server left in flight back to its worker after a diff --git a/lib/apps/fabro-server/src/server/run_publication.rs b/lib/apps/fabro-server/src/server/run_publication.rs new file mode 100644 index 000000000..c75985f2c --- /dev/null +++ b/lib/apps/fabro-server/src/server/run_publication.rs @@ -0,0 +1,499 @@ +//! A GitHub-target run's work leaves for its repository from the server. +//! +//! The run's workspaces are checked out from the repository inside their +//! sandboxes, and every checkpoint reaches the run's snapshot repository on +//! this host (`fabro_petri::checkpoint`). When the run ends, the server +//! pushes the final checkpoint to the run branch, `fabro/run/`, on the +//! repository with its own GitHub credentials, then, for a successful run +//! with changes whose settings ask for one, records the pull request request +//! the creation supervisor opens. The sandbox never holds a credential that +//! can push. +//! +//! A run that did not succeed, a dry run, a run whose run branch is not +//! pushed, and a run with no GitHub target publish nothing. A push that +//! fails is a warning notice on the run, as is a pull request that cannot +//! be requested; neither changes the run's outcome. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use fabro_config::Storage; +use fabro_store::platform_records::{PlatformRecord, RunNoticeRecord}; +use fabro_types::settings::run::RunMode; +use fabro_types::{RunId, RunNoticeLevel, RunSpec, RunTarget}; +use tokio::process::Command; +use tokio::{fs, time}; +use tracing::{info, warn}; + +use super::handler::pull_requests::request_pull_request_creation; +use super::{AppState, run_records}; +use crate::git_checkout::{self, GitAuthConfig}; + +/// How long one push to the repository may take. +const PUSH_TIMEOUT: Duration = Duration::from_mins(5); +/// Attempts at the push: a fresh token can take a moment to reach every +/// GitHub replica. +const PUSH_ATTEMPTS: u32 = 3; +const PUSH_RETRY_DELAY: Duration = Duration::from_secs(2); + +/// The read-only credential a run's worker fetches its GitHub target with, +/// as the base64 of `username:password`. `None` when the run checks nothing +/// out, or the server has no GitHub credentials for the repository (a +/// public repository is then fetched anonymously). +pub(crate) async fn clone_credential(state: &AppState, spec: &RunSpec) -> Option { + let Some(RunTarget::Git(target)) = spec.target.as_ref() else { + return None; + }; + let settings = &spec.settings.run; + if !settings.clone.enabled || settings.execution.mode == RunMode::DryRun { + return None; + } + let validated = target.clone().validate().ok()?; + let github = &state.server_settings().server.integrations.github; + let credentials = match state.github_credentials(github).await { + Ok(Some(credentials)) => credentials, + Ok(None) => return None, + Err(err) => { + warn!(error = %err, "GitHub credentials are unavailable; the run's repository is fetched anonymously"); + return None; + } + }; + let context = match state.http_client.clone() { + Some(client) => fabro_github::GitHubContext::with_http_client( + &credentials, + &state.github_api_base_url, + client, + ), + None => fabro_github::GitHubContext::new(&credentials, &state.github_api_base_url), + }; + let repository = validated.repository(); + match fabro_github::resolve_read_only_clone_credentials( + &context, + repository.owner(), + repository.repo(), + ) + .await + { + Ok(credentials) => Some(BASE64_STANDARD.encode(format!( + "{}:{}", + credentials.username(), + credentials.password() + ))), + Err(err) => { + warn!(repository = %repository, error = %err, "no read credential for the run's repository; it is fetched anonymously"); + None + } + } +} + +/// Publish the run's work once it has ended, in the background. +pub(crate) fn spawn(state: Arc, run_id: RunId) { + tokio::spawn(async move { + if let Err(err) = publish(&state, run_id).await { + warn!(run_id = %run_id, error = %err, "the run's work was not published"); + notice(&state, run_id, "run_publish_failed", &format!("{err:#}")).await; + } + }); +} + +/// What an ended run pushes: the repository, the branch, the commit, and +/// whether a pull request follows. +struct Publication { + repository: fabro_types::GitHubRepositorySlug, + run_branch: String, + sha: String, + pull_request: bool, + model: Option, +} + +async fn publish(state: &Arc, run_id: RunId) -> anyhow::Result<()> { + state.petri_projector.settle(run_id).await; + let Some(projection) = run_records::projection(state, run_id).await? else { + return Ok(()); + }; + let Some(publication) = publication(&projection) else { + return Ok(()); + }; + let snapshots = Storage::new(state.server_storage_dir()) + .run_scratch(&run_id) + .root() + .join("petri") + .join("snapshots"); + let Some(repository) = snapshot_holding(&snapshots, &publication.sha).await else { + anyhow::bail!( + "the run's final commit {} is in none of its snapshot repositories", + publication.sha + ); + }; + push(state, &repository, &publication).await?; + info!( + run_id = %run_id, + repository = %publication.repository, + branch = publication.run_branch, + sha = publication.sha, + "run branch pushed" + ); + if publication.pull_request { + request_pull_request(state, run_id, publication.model).await; + } + Ok(()) +} + +/// What the ended run publishes, or `None` when it publishes nothing. +fn publication(projection: &fabro_store::RunProjection) -> Option { + let spec = &projection.spec; + let Some(RunTarget::Git(target)) = spec.target.as_ref() else { + return None; + }; + let settings = &spec.settings.run; + if settings.execution.mode == RunMode::DryRun + || !settings.run_branch.enabled + || !settings.run_branch.push + { + return None; + } + let conclusion = projection.conclusion.as_ref()?; + if !conclusion.status.is_successful() { + return None; + } + let sha = conclusion + .final_git_commit_sha + .clone() + .filter(|sha| !sha.trim().is_empty())?; + let run_branch = projection + .start + .as_ref() + .and_then(|start| start.run_branch.clone())?; + let repository = target.clone().validate().ok()?.repository().clone(); + let has_changes = conclusion + .diff + .patch + .as_deref() + .is_some_and(|patch| !patch.trim().is_empty()); + let pull_request = has_changes + && settings + .pull_request + .as_ref() + .is_some_and(|pull_request| pull_request.enabled); + Some(Publication { + repository, + run_branch, + sha, + pull_request, + model: settings.model.name.clone(), + }) +} + +/// The snapshot repository of the run that holds `sha`. +async fn snapshot_holding(snapshots: &Path, sha: &str) -> Option { + let mut entries = fs::read_dir(snapshots).await.ok()?; + let mut repositories = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().is_some_and(|extension| extension == "git") { + repositories.push(path); + } + } + repositories.sort(); + for repository in repositories { + let held = git( + &repository, + &["cat-file", "-e", &format!("{sha}^{{commit}}")], + &[], + ) + .await + .is_ok_and(|output| output.status.success()); + if held { + return Some(repository); + } + } + None +} + +/// Push `sha` from the snapshot repository to the run branch on GitHub, +/// with the server's credentials, retrying a failure that may be a token +/// still replicating. +async fn push( + state: &AppState, + repository: &Path, + publication: &Publication, +) -> anyhow::Result<()> { + let github = &state.server_settings().server.integrations.github; + let credentials = state + .github_credentials(github) + .await? + .ok_or_else(|| anyhow::anyhow!("the server has no GitHub credentials to push with"))?; + let context = match state.http_client.clone() { + Some(client) => fabro_github::GitHubContext::with_http_client( + &credentials, + &state.github_api_base_url, + client, + ), + None => fabro_github::GitHubContext::new(&credentials, &state.github_api_base_url), + }; + let push_credentials = fabro_github::resolve_clone_credentials( + &context, + publication.repository.owner(), + publication.repository.repo(), + ) + .await?; + let auth = GitAuthConfig::new(&push_credentials); + let url = git_checkout::github_clone_url(&publication.repository); + let env = auth.git_env(&url); + let refspec = format!("{}:refs/heads/{}", publication.sha, publication.run_branch); + let mut last = String::new(); + for attempt in 1..=PUSH_ATTEMPTS { + let output = git(repository, &["push", "--quiet", &url, &refspec], &env).await?; + if output.status.success() { + return Ok(()); + } + last = redact( + String::from_utf8_lossy(&output.stderr).trim(), + auth.sensitive_values(), + ); + warn!( + attempt, + branch = publication.run_branch, + error = last, + "pushing the run branch failed" + ); + if attempt < PUSH_ATTEMPTS { + time::sleep(PUSH_RETRY_DELAY).await; + } + } + anyhow::bail!( + "the run branch {} could not be pushed to {}: {last}", + publication.run_branch, + publication.repository + ) +} + +/// Record the pull request request for the run, with the run's model or +/// the catalog's default, and hand it to the creation supervisor. +async fn request_pull_request(state: &Arc, run_id: RunId, model: Option) { + let model = if let Some(model) = model { + model + } else { + let configured = state.ready_llm_provider_ids().await; + let catalog = state.catalog(); + let Some(entry) = catalog.default_offering_for(&configured) else { + notice( + state, + run_id, + "pull_request_not_requested", + "no LLM model is available to write the pull request", + ) + .await; + return; + }; + entry.model.id().to_string() + }; + let guard = state.pull_request_create_locks.lock(run_id).await; + let requested = request_pull_request_creation(state, run_id, model, false).await; + drop(guard); + match requested { + Ok(true) => { + if let Ok(projection) = state.load_run_projection(&run_id).await { + if let Some(creation) = projection.pull_request_creation.as_ref() { + state.enqueue_pull_request_creation(run_id, creation.requested_at); + state.notify_pull_request_scheduler(); + } + } + } + Ok(false) => {} + Err(err) => { + warn!(run_id = %run_id, error = ?err, "the pull request was not requested"); + notice( + state, + run_id, + "pull_request_not_requested", + "the pull request could not be requested", + ) + .await; + } + } +} + +async fn notice(state: &AppState, run_id: RunId, code: &str, message: &str) { + let record = PlatformRecord::RunNotice(RunNoticeRecord { + level: RunNoticeLevel::Warn, + code: code.to_string(), + message: message.to_string(), + }); + if let Err(err) = run_records::append(state, run_id, record).await { + warn!(run_id = %run_id, error = %err, "the run's publication notice was not recorded"); + } +} + +/// `git` in `directory` with `env` added, non-interactive, bounded. +async fn git( + directory: &Path, + args: &[&str], + env: &[(String, String)], +) -> anyhow::Result { + let mut command = Command::new("git"); + command + .args(args) + .current_dir(directory) + .envs(env.iter().map(|(key, value)| (key, value))) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(std::process::Stdio::null()) + .kill_on_drop(true); + Ok(time::timeout(PUSH_TIMEOUT, command.output()) + .await + .map_err(|_| anyhow::anyhow!("git {} timed out", args.first().unwrap_or(&"")))??) +} + +fn redact(text: &str, secrets: &[String]) -> String { + secrets + .iter() + .filter(|secret| !secret.is_empty()) + .fold(text.to_string(), |text, secret| text.replace(secret, "***")) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use fabro_types::settings::run::PullRequestSettings; + use fabro_types::{ + Conclusion, GitRunTarget, RunProjection, RunTarget, StageOutcome, StartRecord, test_support, + }; + + use super::*; + + const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + /// A GitHub-target run that succeeded with a change and a pull request + /// asked for, pushed to its run branch. + fn ended() -> RunProjection { + let mut spec = test_support::test_run_spec(); + spec.target = Some(RunTarget::Git(GitRunTarget { + repo: "acme/widgets".to_string(), + branch: "main".to_string(), + tag: None, + sha: None, + })); + spec.settings.run.run_branch.enabled = true; + spec.settings.run.run_branch.push = true; + spec.settings.run.pull_request = Some(PullRequestSettings { + enabled: true, + ..PullRequestSettings::default() + }); + let mut projection = RunProjection::new(String::new(), spec, Utc::now()); + projection.start = Some(StartRecord { + start_time: Utc::now(), + run_branch: Some("fabro/run/1".to_string()), + base_sha: None, + }); + let mut conclusion = Conclusion::outcome_only(Utc::now(), StageOutcome::Succeeded, None); + conclusion.final_git_commit_sha = Some(SHA.to_string()); + conclusion.diff.patch = Some("diff --git a/README.md b/README.md\n".to_string()); + projection.conclusion = Some(conclusion); + projection + } + + #[test] + fn a_successful_github_run_pushes_its_run_branch_and_asks_for_a_pull_request() { + let publication = publication(&ended()).expect("the run publishes"); + assert_eq!(publication.repository.to_string(), "acme/widgets"); + assert_eq!(publication.run_branch, "fabro/run/1"); + assert_eq!(publication.sha, SHA); + assert!(publication.pull_request); + } + + #[test] + fn a_run_without_changes_or_a_pull_request_setting_pushes_without_one() { + let mut unchanged = ended(); + unchanged.conclusion.as_mut().unwrap().diff.patch = Some(" \n".to_string()); + assert!(!publication(&unchanged).unwrap().pull_request); + + let mut not_asked = ended(); + not_asked.spec.settings.run.pull_request = None; + assert!(!publication(¬_asked).unwrap().pull_request); + } + + #[test] + fn nothing_is_published_for_a_failed_dry_unpushed_or_non_github_run() { + let mut failed = ended(); + failed.conclusion.as_mut().unwrap().status = StageOutcome::Failed { + retry_requested: false, + }; + assert!(publication(&failed).is_none()); + + let mut dry = ended(); + dry.spec.settings.run.execution.mode = RunMode::DryRun; + assert!(publication(&dry).is_none()); + + let mut unpushed = ended(); + unpushed.spec.settings.run.run_branch.push = false; + assert!(publication(&unpushed).is_none()); + + let mut folder = ended(); + folder.spec.target = Some(RunTarget::None {}); + assert!(publication(&folder).is_none()); + + let mut no_commit = ended(); + no_commit.conclusion.as_mut().unwrap().final_git_commit_sha = None; + assert!(publication(&no_commit).is_none()); + } + + #[test] + fn a_push_failure_never_prints_the_credential() { + assert_eq!( + redact("fatal: token s3cret rejected", &["s3cret".to_string()]), + "fatal: token *** rejected" + ); + } + + #[tokio::test] + #[expect( + clippy::disallowed_methods, + reason = "the test builds its fixture repositories with synchronous git" + )] + async fn the_snapshot_repository_holding_the_final_commit_is_found() { + let root = tempfile::tempdir().unwrap(); + let snapshots = root.path().join("snapshots"); + let work = root.path().join("work"); + std::fs::create_dir_all(&snapshots).unwrap(); + std::fs::create_dir_all(&work).unwrap(); + let run = |directory: &Path, args: &[&str]| { + let output = std::process::Command::new("git") + .current_dir(directory) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "{args:?}"); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + run(&work, &["init", "-q"]); + run(&work, &[ + "-c", + "user.name=t", + "-c", + "user.email=t@example.com", + "commit", + "-q", + "--allow-empty", + "-m", + "one", + ]); + let sha = run(&work, &["rev-parse", "HEAD"]); + for name in ["a.git", "b.git"] { + run(&snapshots, &["init", "-q", "--bare", name]); + } + run(&work, &[ + "push", + "-q", + &snapshots.join("b.git").to_string_lossy(), + "HEAD:refs/checkpoints/0/1/1", + ]); + assert_eq!( + snapshot_holding(&snapshots, &sha).await, + Some(snapshots.join("b.git")) + ); + assert_eq!(snapshot_holding(&snapshots, SHA).await, None); + } +} diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index baaa5351e..56b8240e4 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -2274,6 +2274,7 @@ fn worker_command_forwards_github_app_private_key_from_vault() { storage_dir.path(), false, Some("test-private-key".to_string()), + None, ) .unwrap(); let cmd = LocalWorkerRuntime::command_for_spec(&spec); @@ -2288,6 +2289,41 @@ fn worker_command_forwards_github_app_private_key_from_vault() { ); } +#[cfg(unix)] +#[test] +fn worker_command_carries_the_run_git_credential_only_when_resolved() { + let storage_dir = tempfile::tempdir().unwrap(); + let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN)); + let spec = worker_launch_spec( + state.as_ref(), + RunId::new(), + RunExecutionMode::Start, + storage_dir.path(), + false, + None, + Some("eC1hY2Nlc3MtdG9rZW46c2VjcmV0".to_string()), + ) + .unwrap(); + let cmd = LocalWorkerRuntime::command_for_spec(&spec); + assert_eq!( + command_env_value(&cmd, EnvVars::FABRO_RUN_GIT_CREDENTIAL), + EnvOverride::Set("eC1hY2Nlc3MtdG9rZW46c2VjcmV0".to_string()) + ); + + let cmd = worker_command( + state.as_ref(), + RunId::new(), + RunExecutionMode::Start, + storage_dir.path(), + false, + ) + .unwrap(); + assert_eq!( + command_env_value(&cmd, EnvVars::FABRO_RUN_GIT_CREDENTIAL), + EnvOverride::Unchanged + ); +} + #[cfg(unix)] #[test] fn worker_command_omits_github_app_private_key_when_unset() { @@ -2490,6 +2526,7 @@ fn worker_command( run_dir, agent_fabro_tools_enabled, None, + None, )?; Ok(LocalWorkerRuntime::command_for_spec(&spec)) } diff --git a/lib/apps/fabro-server/src/worker_runtime.rs b/lib/apps/fabro-server/src/worker_runtime.rs index 7b4fa39b6..35c5ccd9f 100644 --- a/lib/apps/fabro-server/src/worker_runtime.rs +++ b/lib/apps/fabro-server/src/worker_runtime.rs @@ -48,6 +48,8 @@ pub(crate) struct WorkerLaunchSpec { pub(crate) fabro_log: Option, pub(crate) active_config_path: PathBuf, pub(crate) github_app_private_key: Option, + /// The read-only credential the run's GitHub target is fetched with. + pub(crate) run_git_credential: Option, /// The Fabro home the server resolved, so a Petri run's skills step /// reads the same home whatever the worker's environment says. pub(crate) fabro_home: PathBuf, @@ -109,6 +111,9 @@ impl LocalWorkerRuntime { if let Some(pem) = spec.github_app_private_key.as_deref() { cmd.env(EnvVars::GITHUB_APP_PRIVATE_KEY, pem); } + if let Some(credential) = spec.run_git_credential.as_deref() { + cmd.env(EnvVars::FABRO_RUN_GIT_CREDENTIAL, credential); + } #[cfg(unix)] fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); diff --git a/lib/components/fabro-petri/src/checkpoint.rs b/lib/components/fabro-petri/src/checkpoint.rs index 85a186f0d..bfebe1b82 100644 --- a/lib/components/fabro-petri/src/checkpoint.rs +++ b/lib/components/fabro-petri/src/checkpoint.rs @@ -50,6 +50,8 @@ use petri_runtime::ir::LogStream; use tokio::process::Command; use tokio::{fs, time}; +use crate::source::{RunSource, SourceRevision}; + /// The failure class of a stage whose checkpoint commit failed: fatal to /// the run, and terminal for a restart. pub const CHECKPOINT_FAILED_CLASS: &str = "checkpoint_failed"; @@ -65,6 +67,14 @@ pub const ATTEMPT_TRAILER: &str = "Fabro-Attempt"; const FOOTER: &str = "\u{2692}\u{fe0f} Generated with [Fabro](https://fabro.sh)"; const REFS_PREFIX: &str = "refs/checkpoints/"; +/// The ref in a snapshot repository that names the commit the workspace was +/// checked out at from the run's source: the basis every bundle of the +/// run's own commits is cut against. +pub const SOURCE_REF: &str = "refs/fabro/source"; + +/// How long a fetch from the run's origin may take. +const SOURCE_FETCH_TIMEOUT: Duration = Duration::from_mins(5); + /// Git's empty tree: what a root commit is diffed against. const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -351,6 +361,9 @@ pub struct RunWorkspaces { author: GitAuthor, exclude_globs: Vec, timeout: Duration, + /// Where a Git target's workspaces are checked out from; `None` for a + /// run with no remote repository. + source: Option, } impl RunWorkspaces { @@ -367,9 +380,18 @@ impl RunWorkspaces { author, exclude_globs: settings.exclude_globs.clone(), timeout: Duration::from_millis(settings.commit_timeout_ms.max(1)), + source: None, } } + /// The same workspaces, checked out from `source` when a fresh run first + /// acquires them, and restored from it into a fresh sandbox. + #[must_use] + pub fn with_source(mut self, source: Option) -> Self { + self.source = source; + self + } + /// The run branch every workspace of the run commits on. #[must_use] pub fn run_branch(&self) -> String { @@ -405,6 +427,152 @@ impl RunWorkspaces { Site::Host(self.workspace_path(workspace)) } + /// Check the run's source out into a workspace that holds no repository + /// yet, at the revision the run starts from, and seed the workspace's + /// snapshot repository with that commit. A workspace that already holds + /// a repository (a resumed run's, or a sandbox another execution already + /// prepared) is left as it is. The commit checked out, or `None` when the + /// run has no source or the workspace was already prepared. + pub async fn check_out_source( + &self, + site: &Site, + workspace: &str, + ) -> Result, CheckpointError> { + let Some(source) = &self.source else { + return Ok(None); + }; + if let Site::Host(path) = site { + fs::create_dir_all(path) + .await + .map_err(|source| CheckpointError::Io { + path: path.clone(), + source, + })?; + } + if self + .git_status(site, "rev-parse", &["rev-parse", "--git-dir"]) + .await? + .is_some() + { + return Ok(None); + } + self.git(site, "init", &["init", "-q"]).await?; + self.git(site, "remote add", &[ + "remote", + "add", + "origin", + &source.origin, + ]) + .await?; + self.fetch_source(site, "origin", &source.revision.refspec()) + .await?; + self.git(site, "checkout", &[ + "checkout", + "-q", + "-B", + &source.branch, + "FETCH_HEAD", + ]) + .await?; + if let SourceRevision::Branch(branch) = &source.revision { + // `origin/` resolves offline, as a clone leaves it. + self.git(site, "update-ref", &[ + "update-ref", + &format!("refs/remotes/origin/{branch}"), + "FETCH_HEAD", + ]) + .await?; + } + let sha = self.git(site, "rev-parse", &["rev-parse", "HEAD"]).await?; + self.seed_snapshot(workspace, &sha).await?; + Ok(Some(sha)) + } + + /// Put the commit a workspace was checked out at into its snapshot + /// repository, under [`SOURCE_REF`], fetched from the origin at the run's + /// depth. + async fn seed_snapshot(&self, workspace: &str, sha: &str) -> Result<(), CheckpointError> { + let Some(source) = &self.source else { + return Ok(()); + }; + let repository = Site::Host(self.ensure_snapshot_repository(workspace).await?); + if !self.has_object(&repository, sha).await? { + self.fetch_source(&repository, &source.origin, sha).await?; + } + self.git(&repository, "update-ref", &["update-ref", SOURCE_REF, sha]) + .await?; + Ok(()) + } + + /// The commit the workspace was checked out at from the run's source, as + /// its snapshot repository records it. + pub async fn source_base(&self, workspace: &str) -> Result, CheckpointError> { + let repository = self.snapshot_repository(workspace); + if !fs::try_exists(&repository).await.unwrap_or(false) { + return Ok(None); + } + self.git_status(&Site::Host(repository), "rev-parse", &[ + "rev-parse", + "-q", + "--verify", + &format!("{SOURCE_REF}^{{commit}}"), + ]) + .await + } + + /// Fetch `refspec` from `remote` (a remote name, or the origin's URL) at + /// `site`, with the source's credential and depth. + async fn fetch_source( + &self, + site: &Site, + remote: &str, + refspec: &str, + ) -> Result<(), CheckpointError> { + let Some(source) = &self.source else { + return Err(CheckpointError::Command { + action: "fetch".to_string(), + status: "no source".to_string(), + detail: "the run has no repository to fetch from".to_string(), + }); + }; + let depth = source.depth_arg(); + let mut args = vec!["fetch", "-q", "--no-tags"]; + if let Some(depth) = depth.as_deref() { + args.push(depth); + } + args.extend([remote, "--", refspec]); + let output = self + .run_with( + site, + "fetch", + &args, + &source.fetch_env(), + SOURCE_FETCH_TIMEOUT, + ) + .await?; + if output.success { + Ok(()) + } else { + Err(CheckpointError::Command { + action: "fetch".to_string(), + status: "non-zero exit".to_string(), + detail: detail(&output.stderr), + }) + } + } + + /// Whether the repository at `site` holds the commit `sha`. + async fn has_object(&self, site: &Site, sha: &str) -> Result { + Ok(self + .git_status(site, "cat-file", &[ + "cat-file", + "-e", + &format!("{sha}^{{commit}}"), + ]) + .await? + .is_some()) + } + /// Commit the workspace's files on the run branch as the snapshot of /// `key`, and publish it. An earlier commit of the same key that the /// workspace still sits on, unchanged, is reused. On a sandbox site @@ -725,11 +893,19 @@ impl RunWorkspaces { "{workspace}.restore-{}.bundle", key.transfer_name() )); + // A workspace checked out from the run's source gets its starting + // commit from the origin again and the run's own commits as a bundle + // cut against it. + let base = self.source_base(workspace).await?; + let revision = match &base { + Some(base) => format!("{base}..{}", key.snapshot_ref()), + None => key.snapshot_ref(), + }; self.git(&Site::Host(repository), "bundle create", &[ "bundle", "create", &staged.to_string_lossy(), - &key.snapshot_ref(), + &revision, ]) .await?; let bytes = fs::read(&staged) @@ -747,6 +923,9 @@ impl RunWorkspaces { })?; let restored = async { self.git(&site, "init", &["init", "-q"]).await?; + if let Some(base) = &base { + self.fetch_base(&site, base).await?; + } self.git(&site, "fetch", &[ "fetch", "-q", @@ -772,6 +951,37 @@ impl RunWorkspaces { restored } + /// Bring the source's starting commit into a sandbox workspace that + /// lacks it, with `origin` naming the source. + async fn fetch_base(&self, site: &Site, base: &str) -> Result<(), CheckpointError> { + if self.has_object(site, base).await? { + return Ok(()); + } + let Some(source) = &self.source else { + return Err(CheckpointError::Command { + action: "fetch".to_string(), + status: "no source".to_string(), + detail: format!( + "the workspace starts from {base}, which only the run's source repository holds" + ), + }); + }; + if self + .git_status(site, "remote get-url", &["remote", "get-url", "origin"]) + .await? + .is_none() + { + self.git(site, "remote add", &[ + "remote", + "add", + "origin", + &source.origin, + ]) + .await?; + } + self.fetch_source(site, "origin", base).await + } + async fn verify_restored(&self, site: &Site, sha: &str) -> Result<(), CheckpointError> { let actual = self.git(site, "rev-parse", &["rev-parse", "HEAD"]).await?; if actual != sha { @@ -932,6 +1142,17 @@ impl RunWorkspaces { } _ => None, }; + // A workspace checked out from the run's source falls back to the + // commit it started from, which the repository was seeded with, so a + // stage's own commits never make the bundle carry the source's whole + // history. + let basis = match basis { + Some(parent) => Some(parent), + None => match self.source_base(workspace).await? { + Some(base) if self.has_object(&site, &base).await? => Some(base), + _ => None, + }, + }; let revision = match &basis { Some(parent) => format!("{parent}..{branch}"), None => branch.clone(), @@ -1051,7 +1272,9 @@ impl RunWorkspaces { }; let mut all = vec!["-c"]; all.extend(args); - let output = self.run_sandbox(env, "sh", &all, action).await?; + let output = self + .run_sandbox(env, "sh", &all, action, &[], self.timeout) + .await?; if output.success { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } else { @@ -1132,6 +1355,19 @@ impl RunWorkspaces { site: &Site, action: &str, args: &[S], + ) -> Result { + self.run_with(site, action, args, &[], self.timeout).await + } + + /// [`Self::run`] with extra environment for the one command and its own + /// deadline: a fetch from the origin carries its credential this way. + async fn run_with>( + &self, + site: &Site, + action: &str, + args: &[S], + env: &[(String, String)], + timeout: Duration, ) -> Result { let mut all: Vec<&str> = vec![ "-c", @@ -1147,8 +1383,11 @@ impl RunWorkspaces { ]; all.extend(args.iter().map(AsRef::as_ref)); match site { - Site::Host(cwd) => self.run_host(cwd, &all, action).await, - Site::Sandbox(env) => self.run_sandbox(env, "git", &all, action).await, + Site::Host(cwd) => self.run_host(cwd, &all, action, env, timeout).await, + Site::Sandbox(sandbox) => { + self.run_sandbox(sandbox, "git", &all, action, env, timeout) + .await + } } } @@ -1157,11 +1396,14 @@ impl RunWorkspaces { cwd: &Path, args: &[&str], action: &str, + env: &[(String, String)], + timeout: Duration, ) -> Result { let mut command = Command::new("git"); command .args(args) .current_dir(cwd) + .envs(env.iter().map(|(key, value)| (key, value))) .env("GIT_TERMINAL_PROMPT", "0") .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") @@ -1170,7 +1412,7 @@ impl RunWorkspaces { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - match time::timeout(self.timeout, command.output()).await { + match time::timeout(timeout, command.output()).await { Ok(Ok(output)) => Ok(GitOutput { success: output.status.success(), stdout: output.stdout, @@ -1181,8 +1423,8 @@ impl RunWorkspaces { source, }), Err(_) => Err(CheckpointError::TimedOut { - action: action.to_string(), - timeout: self.timeout, + action: action.to_string(), + timeout, }), } } @@ -1195,13 +1437,17 @@ impl RunWorkspaces { program: &str, args: &[&str], action: &str, + extra_env: &[(String, String)], + timeout: Duration, ) -> Result { let spec = ProcessSpec::new(program, args) .with_output(OutputMode::Bytes) - .with_timeout(Some(self.timeout)) + .with_timeout(Some(timeout)) .with_env( - [("GIT_TERMINAL_PROMPT".into(), "0".into())] - .into_iter() + extra_env + .iter() + .map(|(key, value)| (key.as_str().into(), value.as_str().into())) + .chain([("GIT_TERMINAL_PROMPT".into(), "0".into())]) .collect(), ); let mut handle = env @@ -1243,8 +1489,8 @@ impl RunWorkspaces { let (stdout, stderr) = drain.await.unwrap_or_default(); if status.timed_out { return Err(CheckpointError::TimedOut { - action: action.to_string(), - timeout: self.timeout, + action: action.to_string(), + timeout, }); } Ok(GitOutput { diff --git a/lib/components/fabro-petri/src/fork.rs b/lib/components/fabro-petri/src/fork.rs index 44f551aa8..fd59f5e88 100644 --- a/lib/components/fabro-petri/src/fork.rs +++ b/lib/components/fabro-petri/src/fork.rs @@ -54,7 +54,7 @@ use tokio::fs; use tokio::process::Command; use tracing::{debug, info}; -use crate::checkpoint::{CheckpointKey, RunWorkspaces}; +use crate::checkpoint::{CheckpointKey, RunWorkspaces, SOURCE_REF}; use crate::platform_records::{PlatformRecordError, PlatformRecords}; use crate::projection::FoldState; use crate::projector::ProjectError; @@ -415,6 +415,19 @@ async fn seed_snapshots( let name = key.snapshot_ref(); args.push(format!("+{name}:{name}")); } + // The commit a checked-out workspace started from goes with its + // checkpoints: the fork's bundles and restores are cut against it. + if git(&source_repository, &[ + "rev-parse", + "-q", + "--verify", + SOURCE_REF, + ]) + .await + .is_ok() + { + args.push(format!("+{SOURCE_REF}:{SOURCE_REF}")); + } git(&repository, &args).await.map_err(failed)?; debug!( workspace, diff --git a/lib/components/fabro-petri/src/hooks.rs b/lib/components/fabro-petri/src/hooks.rs index 892a24838..2f771e9de 100644 --- a/lib/components/fabro-petri/src/hooks.rs +++ b/lib/components/fabro-petri/src/hooks.rs @@ -33,6 +33,11 @@ //! the `run.diff` platform record with the patch as a blob; then the //! forwarded point, so the local service runs `run_complete` and `run_failed` //! with the sandbox in place. +//! - `scope_acquired`: a fresh run's Git target checked out into the workspace +//! from inside the scope, with its snapshot repository seeded with the +//! starting commit ([`crate::source`]); a resumed run's workspace brought to +//! its snapshot instead (see below). A checkout that fails fails the scope's +//! firings with the reason. //! - `scope_released`: forwarded, so the local service runs `sandbox_cleanup` //! with the sandbox in place. Fabro's own end-of-run work (the terminal //! lifecycle event, notifications on it) is the run lifecycle path's, on the @@ -108,6 +113,7 @@ use crate::checkpoint::{ }; use crate::platform_records::{PlatformRecordError, PlatformRecords}; use crate::recovery::{self, Plan, RecoveryError, RestoreTarget}; +use crate::source::RunSource; use crate::workspace::{self, WorkspaceLookup, WorkspaceLookupError}; /// The note kind the hooks record on a firing about its checkpoint. @@ -225,6 +231,9 @@ pub struct HooksSpec { pub test_gates: Option, /// Where captured workspace files go. pub artifact_writer: Arc, + /// Where a Git target's workspaces are checked out from, when a fresh + /// run first acquires them. `None` for a run with no remote repository. + pub source: Option, } impl HooksSpec { @@ -242,9 +251,17 @@ impl HooksSpec { artifacts: settings.artifacts.include.clone(), test_gates: None, artifact_writer, + source: None, } } + /// Check a Git target's workspaces out from `source`. + #[must_use] + pub fn with_source(mut self, source: Option) -> Self { + self.source = source; + self + } + #[must_use] pub fn with_test_gates(mut self, gates: Option) -> Self { self.test_gates = gates; @@ -431,7 +448,8 @@ impl FabroHooks { run_id.to_string(), spec.git.author, &spec.git.checkpoint, - ); + ) + .with_source(spec.source); Self { inner, run_id, @@ -746,6 +764,42 @@ impl FabroHooks { .await } + /// A fresh run's workspace, checked out from the run's source before + /// the first attempt runs in it. A failure fails the scope's firings + /// with the reason. + async fn check_out_source( + &self, + workspace: &str, + site: &Site, + ) -> Result<(), ScopeAcquiredError> { + let serialized = self.scopes.lock_for(workspace); + let _held = serialized.lock().await; + match self.workspaces.check_out_source(site, workspace).await { + Ok(Some(sha)) => { + info!( + run_id = %self.run_id, + workspace, + sha, + site = ?site, + "workspace checked out from the run's repository" + ); + Ok(()) + } + Ok(None) => Ok(()), + Err(error) => { + warn!( + run_id = %self.run_id, + workspace, + error = %error, + "the run's repository could not be checked out" + ); + Err(ScopeAcquiredError::new(format!( + "the run's repository could not be checked out: {error}" + ))) + } + } + } + /// Bring a workspace to the snapshot the resumed run's durable state /// names, once, at its first acquisition. After a restart the server /// already brought a host workspace there, so this verifies; a fork's @@ -1402,14 +1456,14 @@ impl ExecutionHooks for FabroHooks { acquired.scope, (workspace.clone(), Arc::clone(&acquired.env)), ); - if !self.resumed { - return Ok(()); - } let site = if self.host_workspaces { self.workspaces.host(&workspace) } else { Site::Sandbox(Arc::clone(&acquired.env)) }; + if !self.resumed { + return self.check_out_source(&workspace, &site).await; + } self.restore(&workspace, &site) .await .map_err(|error| ScopeAcquiredError::new(error.render())) @@ -1525,6 +1579,7 @@ mod tests { artifacts: vec!["assets/**".to_string()], test_gates: None, artifact_writer, + source: None, }, Arc::new(NoHooks), run_id, diff --git a/lib/components/fabro-petri/src/lib.rs b/lib/components/fabro-petri/src/lib.rs index 415f12fb5..83b1080d9 100644 --- a/lib/components/fabro-petri/src/lib.rs +++ b/lib/components/fabro-petri/src/lib.rs @@ -43,6 +43,8 @@ //! - [`checkpoint`]: the Git snapshots of a run's workspaces, on the host or //! inside a Docker or Daytona sandbox, and the snapshot repository they are //! published to; +//! - [`source`]: where a GitHub target's workspace is checked out from, the +//! revision, depth and read credential its in-sandbox fetch uses; //! - [`recovery`]: the resume-on-restart protocol, which brings every live //! workspace to the snapshot its durable state names: a host workspace before //! the run goes back to a worker, a sandbox workspace in the worker when its @@ -86,6 +88,7 @@ pub mod run_graph; pub mod run_store; pub mod runtime; pub mod secrets; +pub mod source; #[cfg(feature = "test-support")] pub mod test_support; pub mod workspace; diff --git a/lib/components/fabro-petri/src/source.rs b/lib/components/fabro-petri/src/source.rs new file mode 100644 index 000000000..1c79ccb8c --- /dev/null +++ b/lib/components/fabro-petri/src/source.rs @@ -0,0 +1,241 @@ +//! Where a run's repository comes from when it lives on GitHub: the origin, +//! the revision the run starts from, the working branch, the history depth, +//! and the read credential the fetch presents. +//! +//! A Git target's workspace is checked out by Fabro, not by Petri: when a +//! scope's environment is acquired for a fresh run, the hooks fetch the +//! revision into the workspace from inside the scope, so the files belong to +//! the user every later command runs as and nothing is copied in from this +//! host (see [`crate::hooks`]). The same fetch seeds the workspace's snapshot +//! repository with the starting commit, so every checkpoint leaves the +//! sandbox as a bundle of the run's own commits alone, and a shallow clone's +//! missing history is never needed. Petri's own `start` checkout is not used +//! for a Git target: the run binds no repository for it. +//! +//! The credential reaches one `git` command at a time through its +//! environment, as an HTTP header scoped to the origin. It is never written +//! into the repository, its configuration, or its remote URL. + +use std::fmt; + +use fabro_types::settings::run::{RunCloneSettings, RunMode, RunNamespace}; +use fabro_types::{GitRunTarget, RunTarget}; + +/// The revision a run starts from, in the order the target names it: an +/// exact commit wins over a tag, and a tag over the branch head. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceRevision { + Commit(String), + Tag(String), + Branch(String), +} + +impl SourceRevision { + /// What `git fetch` asks the origin for. A branch and a tag may share a + /// name, so both are qualified. + #[must_use] + pub fn refspec(&self) -> String { + match self { + Self::Commit(sha) => sha.clone(), + Self::Tag(tag) => format!("refs/tags/{tag}"), + Self::Branch(branch) => format!("refs/heads/{branch}"), + } + } +} + +/// The HTTP basic credential a fetch from the origin presents: the +/// base64 of `username:password`, as the server resolved it for the run's +/// repository. +#[derive(Clone, PartialEq, Eq)] +pub struct SourceCredential(String); + +impl SourceCredential { + /// A credential from its base64 `username:password` encoding; `None` + /// for an empty one. + #[must_use] + pub fn from_encoded(encoded: impl Into) -> Option { + let encoded = encoded.into(); + let encoded = encoded.trim(); + (!encoded.is_empty()).then(|| Self(encoded.to_string())) + } + + /// The encoded value, for handing to a process that fetches. + #[must_use] + pub fn encoded(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SourceCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SourceCredential()") + } +} + +/// A run's GitHub repository as its workspaces check it out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunSource { + /// The repository's HTTPS URL: the workspace's `origin`. + pub origin: String, + pub revision: SourceRevision, + /// The branch the workspace stands on before the run branch is created + /// from it. + pub branch: String, + /// Commits of history to fetch; `None` is the whole history. + pub depth: Option, + pub credential: Option, +} + +impl RunSource { + /// The source a Git target gives under the run's clone settings, or + /// `None` when the run checks nothing out. + #[must_use] + pub fn for_target( + target: &GitRunTarget, + origin: String, + clone: &RunCloneSettings, + credential: Option, + ) -> Option { + if !clone.enabled { + return None; + } + let revision = if let Some(sha) = target.sha.clone().filter(|sha| !sha.is_empty()) { + SourceRevision::Commit(sha) + } else if let Some(tag) = target.tag.clone().filter(|tag| !tag.is_empty()) { + SourceRevision::Tag(tag) + } else { + SourceRevision::Branch(target.branch.clone()) + }; + Some(Self { + origin, + revision, + branch: target.branch.clone(), + depth: clone + .depth_limit() + .and_then(|depth| u32::try_from(depth).ok()), + credential, + }) + } + + /// The source of a run whose target is a GitHub repository, under its + /// settings: `None` for any other target, a dry run, or a run whose + /// clone is disabled. A target that does not name a valid GitHub + /// repository checks nothing out. + #[must_use] + pub fn for_run( + target: Option<&RunTarget>, + settings: &RunNamespace, + credential: Option, + ) -> Option { + let Some(RunTarget::Git(target)) = target else { + return None; + }; + if settings.execution.mode == RunMode::DryRun { + return None; + } + let validated = target.clone().validate().ok()?; + let origin = validated.repository().https_url(); + Self::for_target(validated.target(), origin, &settings.clone, credential) + } + + /// The environment a `git` command that talks to the origin runs with: + /// the credential as an `Authorization` header for the origin alone. + #[must_use] + pub fn fetch_env(&self) -> Vec<(String, String)> { + let Some(credential) = &self.credential else { + return Vec::new(); + }; + vec![ + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ( + "GIT_CONFIG_KEY_0".to_string(), + format!("http.{}.extraheader", self.origin), + ), + ( + "GIT_CONFIG_VALUE_0".to_string(), + format!("AUTHORIZATION: basic {}", credential.encoded()), + ), + ] + } + + /// The `--depth` argument of a fetch, when the history is limited. + #[must_use] + pub fn depth_arg(&self) -> Option { + self.depth.map(|depth| format!("--depth={depth}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target(tag: Option<&str>, sha: Option<&str>) -> GitRunTarget { + GitRunTarget { + repo: "acme/widgets".to_string(), + branch: "main".to_string(), + tag: tag.map(str::to_string), + sha: sha.map(str::to_string), + } + } + + fn clone(enabled: bool, depth: i32) -> RunCloneSettings { + RunCloneSettings { enabled, depth } + } + + #[test] + fn a_commit_wins_over_a_tag_and_a_tag_over_the_branch() { + let origin = "https://github.com/acme/widgets".to_string(); + let settings = clone(true, 100); + let pick = |tag, sha| { + RunSource::for_target(&target(tag, sha), origin.clone(), &settings, None) + .unwrap() + .revision + }; + assert_eq!( + pick(Some("v1"), Some("abc")), + SourceRevision::Commit("abc".to_string()) + ); + assert_eq!( + pick(Some("v1"), None), + SourceRevision::Tag("v1".to_string()) + ); + assert_eq!(pick(None, None), SourceRevision::Branch("main".to_string())); + assert_eq!(pick(None, None).refspec(), "refs/heads/main"); + assert_eq!(pick(Some("v1"), None).refspec(), "refs/tags/v1"); + } + + #[test] + fn a_disabled_clone_checks_nothing_out_and_depth_zero_is_full_history() { + let origin = "https://github.com/acme/widgets".to_string(); + assert!( + RunSource::for_target(&target(None, None), origin.clone(), &clone(false, 1), None) + .is_none() + ); + let full = + RunSource::for_target(&target(None, None), origin, &clone(true, 0), None).unwrap(); + assert_eq!(full.depth, None); + assert_eq!(full.depth_arg(), None); + } + + #[test] + fn the_credential_is_scoped_to_the_origin_and_never_printed() { + let source = RunSource::for_target( + &target(None, None), + "https://github.com/acme/widgets".to_string(), + &clone(true, 1), + SourceCredential::from_encoded("c2VjcmV0"), + ) + .unwrap(); + let env = source.fetch_env(); + assert!(env.contains(&( + "GIT_CONFIG_KEY_0".to_string(), + "http.https://github.com/acme/widgets.extraheader".to_string() + ))); + assert!(env.contains(&( + "GIT_CONFIG_VALUE_0".to_string(), + "AUTHORIZATION: basic c2VjcmV0".to_string() + ))); + assert!(!format!("{source:?}").contains("c2VjcmV0")); + assert!(SourceCredential::from_encoded(" ").is_none()); + } +} diff --git a/lib/components/fabro-petri/tests/hooks.rs b/lib/components/fabro-petri/tests/hooks.rs index 1139745c9..4a2411937 100644 --- a/lib/components/fabro-petri/tests/hooks.rs +++ b/lib/components/fabro-petri/tests/hooks.rs @@ -23,7 +23,7 @@ use fabro_petri::artifacts::StoreArtifactWriter; use fabro_petri::blobs::Blobs; use fabro_petri::check::{self, Bundle, CheckRequest, Launch}; use fabro_petri::checkpoint::{ - CHECKPOINT_FAILED_CLASS, CheckpointKey, RunGitSettings, RunWorkspaces, + CHECKPOINT_FAILED_CLASS, CheckpointKey, RunGitSettings, RunWorkspaces, SOURCE_REF, Site, }; use fabro_petri::controls::RunControls; use fabro_petri::engine::{self, Execution, RunRequest, RunStatus}; @@ -32,6 +32,7 @@ use fabro_petri::platform_records::PlatformRecords; use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; use fabro_petri::recovery::{self, Recovery, RecoveryRequest}; use fabro_petri::runtime::RuntimeSpec; +use fabro_petri::source::{RunSource, SourceRevision}; use fabro_petri::test_support::{MemoryBlobs, MemoryPlatformRecords}; use fabro_store::{ArtifactStore, PlatformRecord, PlatformRecordKind}; use fabro_types::settings::run::{EnvironmentResourcesSettings, RunCheckpointSettings}; @@ -90,6 +91,11 @@ struct Harness { /// The `[run.artifacts] include` patterns the hooks collect under. artifacts: Vec, artifact_store: ArtifactStore, + /// Where the run's workspaces are checked out from. + source: Option, + /// Treat the local provider's workspaces as a sandbox's: `git` runs + /// through the scope's environment and checkpoints leave as bundles. + sandboxed: bool, _root: tempfile::TempDir, } @@ -113,6 +119,8 @@ impl Harness { records: Arc::new(MemoryPlatformRecords::new()), blobs: Arc::new(MemoryBlobs::new()), artifacts: Vec::new(), + source: None, + sandboxed: false, _root: root, } } @@ -121,12 +129,13 @@ impl Harness { HooksSpec { records: Arc::clone(&self.records) as Arc, git: RunGitSettings { - host_workspaces: *provider == SandboxProviderKind::LOCAL, + host_workspaces: *provider == SandboxProviderKind::LOCAL && !self.sandboxed, ..RunGitSettings::default() }, artifacts: self.artifacts.clone(), test_gates: None, artifact_writer: Arc::new(StoreArtifactWriter::new(self.artifact_store.clone())), + source: self.source.clone(), } } @@ -1057,3 +1066,176 @@ async fn assert_sandbox_run_publishes_every_checkpoint(provider: SandboxProvider "the large file came through the split transfer whole" ); } + +/// An upstream repository with `commits` commits on `main`, each changing +/// `README.md`, and the commit `main` ends on. +async fn upstream(root: &Path, commits: usize) -> (PathBuf, String) { + let work = root.join("upstream-work"); + let bare = root.join("upstream.git"); + fs::create_dir_all(&work) + .await + .expect("the work tree creates"); + git(&work, &["init", "-q", "-b", "main"]).await; + for index in 1..=commits { + fs::write(work.join("README.md"), format!("revision {index}\n")) + .await + .expect("the file writes"); + git(&work, &["add", "README.md"]).await; + git(&work, &[ + "-c", + "user.name=Upstream", + "-c", + "user.email=upstream@example.com", + "commit", + "-q", + "-m", + &format!("revision {index}"), + ]) + .await; + } + git(root, &[ + "clone", + "-q", + "--bare", + &work.to_string_lossy(), + &bare.to_string_lossy(), + ]) + .await; + let head = git(&work, &["rev-parse", "HEAD"]).await; + (bare, head) +} + +/// A Git target's run starts from its repository at depth one: the stage +/// sees the files and a shallow history, the snapshot repository is seeded +/// with the starting commit, and every checkpoint builds on it (a commit the +/// stage made itself included), whether the workspace is on the host or +/// `git` runs through the scope's environment and checkpoints leave as +/// bundles (which a shallow clone could not send whole). +#[tokio::test] +async fn a_git_source_is_checked_out_shallow_and_checkpoints_build_on_its_commit() { + for sandboxed in [false, true] { + let mut harness = Harness::new(); + let (origin, head) = upstream(&harness.run_dir.with_file_name("upstream"), 3).await; + harness.sandboxed = sandboxed; + harness.source = Some(RunSource { + origin: format!("file://{}", origin.display()), + revision: SourceRevision::Branch("main".to_string()), + branch: "main".to_string(), + depth: Some(1), + credential: None, + }); + let workflow = workflow( + " edit [shape=parallelogram, script=\"test \\\"$(cat README.md)\\\" = 'revision 3' && test \\\"$(git rev-parse --is-shallow-repository)\\\" = true && git rev-parse origin/main && echo edited >> README.md && git -c user.name=Agent -c \ + user.email=agent@example.com commit -q -am 'agent edit' && echo uncommitted > \ + notes.txt\"]", + " start -> edit -> exit", + ); + let outcome = harness + .run_on(SandboxProviderKind::LOCAL, &workflow, SETTINGS) + .await; + assert_eq!( + outcome.status, + RunStatus::Success, + "sandboxed={sandboxed}: {outcome:?}" + ); + + let workspace = harness.workspace().await; + let workspaces = harness.workspaces(); + assert_eq!( + workspaces + .source_base(&workspace) + .await + .expect("the base reads"), + Some(head.clone()), + "sandboxed={sandboxed}: the snapshot repository is seeded with the starting commit" + ); + let repository = workspaces.snapshot_repository(&workspace); + assert_eq!(git(&repository, &["rev-parse", SOURCE_REF]).await, head); + let checkpoints = harness.checkpoints(); + let (_, last) = checkpoints.last().expect("a checkpoint was recorded"); + assert_eq!( + git(&repository, &["show", &format!("{last}:README.md")]).await, + "revision 3\nedited", + "sandboxed={sandboxed}: the last checkpoint carries the stage's edit" + ); + let (_, first) = checkpoints.first().expect("a checkpoint was recorded"); + assert_eq!( + git(&repository, &["rev-parse", &format!("{first}^")]).await, + head, + "sandboxed={sandboxed}: the run branch starts on the source's commit" + ); + assert_eq!( + git(&repository, &["show", &format!("{last}:notes.txt")]).await, + "uncommitted", + "sandboxed={sandboxed}: the checkpoint after the stage's own commit carries the rest" + ); + assert_eq!( + git(&repository, &["rev-list", "--count", last]).await, + (checkpoints.len() + 2).to_string(), + "sandboxed={sandboxed}: the snapshot holds the run's commits, the stage's own commit \ + among them, on the one starting commit" + ); + } +} + +/// A workspace that already holds a repository is not checked out again: +/// the source is fetched once per fresh workspace. +#[tokio::test] +async fn a_prepared_workspace_is_left_as_it_is() { + let root = tempfile::tempdir().expect("a temp dir"); + let (origin, _) = upstream(root.path(), 1).await; + let workspaces = RunWorkspaces::new( + root.path().join("run"), + "run-1".to_string(), + GitAuthor::default(), + &RunCheckpointSettings::default(), + ) + .with_source(Some(RunSource { + origin: format!("file://{}", origin.display()), + revision: SourceRevision::Branch("main".to_string()), + branch: "main".to_string(), + depth: None, + credential: None, + })); + let path = root.path().join("prepared"); + fs::create_dir_all(&path) + .await + .expect("the workspace creates"); + git(&path, &["init", "-q"]).await; + let site = Site::Host(path.clone()); + assert_eq!( + workspaces + .check_out_source(&site, "prepared") + .await + .expect("the check succeeds"), + None + ); + assert!(!path.join("README.md").exists()); +} + +/// A revision the origin does not have fails the checkout with git's reason. +#[tokio::test] +async fn an_unavailable_revision_fails_the_checkout() { + let root = tempfile::tempdir().expect("a temp dir"); + let (origin, _) = upstream(root.path(), 1).await; + let workspaces = RunWorkspaces::new( + root.path().join("run"), + "run-1".to_string(), + GitAuthor::default(), + &RunCheckpointSettings::default(), + ) + .with_source(Some(RunSource { + origin: format!("file://{}", origin.display()), + revision: SourceRevision::Branch("missing".to_string()), + branch: "missing".to_string(), + depth: Some(1), + credential: None, + })); + let path = root.path().join("fresh"); + let site = Site::Host(path); + let error = workspaces + .check_out_source(&site, "fresh") + .await + .expect_err("the branch does not exist"); + assert!(error.to_string().contains("git fetch failed"), "{error}"); +} diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index 53a45f158..81d22e008 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -26,6 +26,9 @@ impl EnvVars { pub const FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS: &'static str = "FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS"; pub const FABRO_QUIET: &'static str = "FABRO_QUIET"; + /// The read-only credential a run's worker fetches its GitHub target + /// with: the base64 of `username:password`, scrubbed at worker startup. + pub const FABRO_RUN_GIT_CREDENTIAL: &'static str = "FABRO_RUN_GIT_CREDENTIAL"; pub const FABRO_SERVER: &'static str = "FABRO_SERVER"; pub const FABRO_SERVER_MAX_CONCURRENT_RUNS: &'static str = "FABRO_SERVER_MAX_CONCURRENT_RUNS"; pub const FABRO_SLACK_APP_TOKEN: &'static str = "FABRO_SLACK_APP_TOKEN"; @@ -228,6 +231,7 @@ mod tests { EnvVars::FABRO_PUSH_CRED_REFRESH_AHEAD, EnvVars::FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS, EnvVars::FABRO_QUIET, + EnvVars::FABRO_RUN_GIT_CREDENTIAL, EnvVars::FABRO_SERVER, EnvVars::FABRO_SERVER_MAX_CONCURRENT_RUNS, EnvVars::FABRO_SLACK_APP_TOKEN,