| title | Self-deploy source preparation & warm target dir reference | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| description | Reference for the cwd-independent self-deploy source preparer (SelfDeploySourcePreparer / GitSourcePreparer), the persistent warm build directories under the state root, the build_self_deploy_candidate builder, the SelfDeployOrchestrator::with_source constructor, the source-preparation UpdateConfig/env surface, the new SafeUpdateError variants, and the security model that fetches and checks out the merged head before building. | |||||||||
| last_updated | 2026-06-28 | |||||||||
| review_schedule | as-needed | |||||||||
| owner | simard | |||||||||
| doc_type | reference | |||||||||
| status | implemented | |||||||||
| related |
|
Status: implemented. The
SelfDeploySourcePreparertrait, theGitSourcePreparerproduction implementation, theself_deploy_src_dir()/self_deploy_target_dir()path helpers,build_self_deploy_candidate(), theSelfDeployOrchestrator::with_sourceconstructor, and theSourceResolveFailed/FetchFailed/CheckoutFailederror variants live insrc/self_deploy/source_prep.rs,src/self_deploy/orchestrator.rs, andsrc/self_relaunch/canary.rs. They are additive:SelfDeployOrchestrator::new,build_canary, andRelaunchConfig::Defaultkeep their existing signatures and behaviour. The preparer's resolve→fetch→detached-checkout path, the resolution precedence, the SHA/transport validation, and the warm-dir helpers are covered by hermetic offline tests (a local fixture-repoorigin); only the fullcargo buildand the live daemon restart are left to operator runs, not CI.
This reference specifies the typed surface that makes simard self-deploy
autonomous (it builds the merged commit, not the on-disk checkout's
HEAD) and fast (it reuses a persistent, warm build directory instead of a
cold per-run temp_dir()). It extends the
self-deploy API reference; for the end-to-end sequence
and rationale see reconcile-and-self-deploy.
Before this change, simard self-deploy had two gaps that prevented it from
being a hands-free, fast deploy:
- Build source was the on-disk checkout's
HEAD, not the merged commit.build_candidate→self_relaunch::build_canaryrancargo buildagainstmanifest_dir = "."— whatever the cwd checkout happened to be on.--checkcorrectly detected drift versusorigin/main, but the build did notgit fetch+ checkout the merged commit first, so it only deployed the new code when the operator happened to run it from an already-up-to-date checkout. - Cold target dir → slow build.
canary_target_dirwas a freshtemp_dir()/simard-canary-<PID>each run, forcing a full from-scratch compile (~10+ min) instead of an incremental build (~2–3 min).
This reference closes both. The single guiding rule:
simard self-deploy, run from any working directory, builds and deploys the current merged head — never the cwd'sHEAD— and reuses a warm, incremental target directory.
- Path helpers
SelfDeploySourcePreparerGitSourcePreparer- Repo resolution precedence
build_self_deploy_candidateSelfDeployOrchestrator::with_source- Configuration & environment
- Error variants
- Security model
- Cleanup-reaper non-overlap
- Tests
- Source layout
Two helpers resolve the persistent self-deploy directories under the
resolved state root. Both honour
SIMARD_STATE_ROOT; neither creates the directory (the first writer — the
preparer's clone or build_self_deploy_candidate — is responsible for
create_dir_all).
/// Persistent clone of the Simard source used for self-deploy builds.
/// `<state_root>/self-deploy-src/` (default `~/.simard/self-deploy-src/`).
pub fn self_deploy_src_dir() -> PathBuf;
/// Persistent, warm Cargo target directory reused across self-deploy builds.
/// `<state_root>/self-deploy-target/` (default `~/.simard/self-deploy-target/`).
pub fn self_deploy_target_dir() -> PathBuf;| Helper | Default path | Relocated by |
|---|---|---|
self_deploy_src_dir() |
~/.simard/self-deploy-src/ |
SIMARD_STATE_ROOT, then SIMARD_SELF_DEPLOY_REPO override (see precedence) |
self_deploy_target_dir() |
~/.simard/self-deploy-target/ |
SIMARD_STATE_ROOT |
Both live under $HOME/.simard, outside temp_dir(), so they survive
reboots and /tmp reapers and stay warm between runs.
The source-preparation step is abstracted behind a trait so the orchestrator sequence stays hermetically testable — a fake preparer drives the fetch/checkout-then-build wiring with no network and no real git.
/// Prepares a cwd-independent Simard source checkout at the merged head before
/// the self-deploy build. Implementations MUST fail loud — never silently fall
/// back to building the current working directory's `HEAD`.
pub trait SelfDeploySourcePreparer: Send + Sync {
/// Make `target_commit` available on disk for building: resolve the
/// canonical repo (never cwd), `git fetch origin`, then
/// `git checkout --detach <target_commit>`. Returns the prepared work-tree
/// root whose `Cargo.toml` the build will target.
fn prepare(&self, target_commit: &str) -> Result<PathBuf, SafeUpdateError>;
}Contract
prepareis cwd-independent: it operates on the resolved canonical repo (see "Repo resolution precedence"), never oncurrent_dir().prepareis loud on failure: a resolve/fetch/checkout error returns a specific error variant and aborts the deploy before any daemon mutation. There is no cwd-HEADfallback.prepareleaves the work-tree on a detached checkout oftarget_commit(no per-run branch accumulation), sobuild.rsembeds exactly that SHA.
The production implementation. It runs git via the repo's hardened
env_clear() + PATH/HOME-only pattern (mirroring engineer_worktree's git
helper), so a hostile ambient environment cannot hijack the build source.
pub struct GitSourcePreparer { /* optional explicit repo override … */ }
impl GitSourcePreparer {
/// Production preparer: resolves the canonical repo via the documented
/// precedence and reuses the persistent warm directories.
pub fn new() -> Self;
/// Preparer rooted at an explicit repo (tests / non-default installs).
pub fn at(repo_dir: impl Into<PathBuf>) -> Self;
/// Resolve the canonical repo via the documented precedence (env override →
/// persistent checkout → clone-from-origin). Never returns cwd. Used by the
/// `self-deploy` CLI to point a `GitDeploySource` at the resolved repo.
pub fn resolve_repo(&self) -> Result<PathBuf, SafeUpdateError>;
/// `git fetch origin` in the resolved/owned repo so its remote-tracking
/// refs (and thus the merged head) are current. The first dedicated
/// git-fetch helper in `src/`; shared by `prepare` and the CLI (which
/// fetches before reading the merged head to deploy). Loud on failure
/// (`FetchFailed`).
pub fn fetch_origin(&self, repo: &Path) -> Result<(), SafeUpdateError>;
/// Resolve the canonical repo via the same precedence as `resolve_repo`
/// **minus the clone step** (env override → persistent checkout → `None`).
/// Read-only: it never clones, so it is safe for `self-deploy --check`
/// ("makes no changes"). `None` means no canonical source exists yet, *or*
/// a present override was rejected by validation — in which case the caller
/// falls back to a best-effort cwd report. A rejected override is **logged
/// loudly to stderr** (never silently degraded). Used by `report_drift`.
pub fn resolve_existing_repo(&self) -> Option<PathBuf>;
}
impl Default for GitSourcePreparer { /* == new() */ }
impl SelfDeploySourcePreparer for GitSourcePreparer { /* … */ }A fake (RecordingPreparer) records the requested target commit and returns a
chosen outcome; the orchestrator/composition tests use it to assert the build is
wired to the prepared SHA, not cwd.
resolve_repo picks the canonical Simard source with this precedence — the
first match wins:
SIMARD_SELF_DEPLOY_REPO— an absolute path to an existing git work-tree. Validated (no.., not a symlink, real work-tree); an invalid value aborts withSourceResolveFailed— it never falls back to cwd.- Persistent checkout at
self_deploy_src_dir()(~/.simard/self-deploy-src/) when it already exists as a git work-tree. - Clone-from-origin into
self_deploy_src_dir(). The origin URL is discovered from the current checkout'sgit remote get-url origin, then transport-validated (https/ssh only) beforegit cloneruns. The clone relies on the ambient git credential helper for auth.
The same resolved repo is used for both merged-SHA resolution and the build, so the SHA that is reported, fetched, checked out, and built is always the same one.
--checkresolution (as implemented). Both the deploy path (run_self_deploy) andsimard self-deploy --check(report_drift) resolve the canonical repo cwd-independently, so the SHA--checkreports matches the SHA the deploy fetches, checks out, and builds. The deploy path usesresolve_repo(env override → persistent checkout → clone) andfetch_origins it, failing loudly if the source cannot be made available.--checkusesresolve_existing_repo(env override → persistent checkout →None) and a best-effortfetch_origin, because it must remain read-only: it never clones (honoring "makes no changes") and tolerates an offline fetch (reporting against local tracking refs) rather than erroring. It falls back to a cwd-rootedGitDeploySource::new()only when no canonical source exists yet (e.g. before the first deploy) — a state in which the deploy itself has nothing canonical to build from, so there is nothing for the two to diverge over.A present-but-invalid override (path traversal / symlink / non-work-tree) is not silently swallowed:
resolve_existing_repowarns loudly on stderr (keeping stdout/--jsonclean) and then degrades to the cwd report, so an operator who only ever runs--checkstill sees the misconfiguration the effectfulresolve_repowould have aborted on.
A sibling of build_canary in src/self_relaunch/canary.rs. It builds the
prepared repo into the persistent warm target dir and returns the built
binary path. build_canary and RelaunchConfig::Default are unchanged.
/// Build the self-deploy candidate from a prepared, cwd-independent source
/// checkout into a persistent warm target dir. Equivalent to `build_canary`
/// but with `--manifest-path <repo>/Cargo.toml` and
/// `--target-dir <target_dir>` (a warm, reused directory), so the build
/// compiles the merged head and reuses incremental artifacts.
pub fn build_self_deploy_candidate(
repo: &Path, // prepared work-tree root (from SelfDeploySourcePreparer::prepare)
target_dir: &Path, // persistent warm dir, e.g. self_deploy_target_dir()
) -> SimardResult<PathBuf>;| Aspect | build_canary (legacy, unchanged) |
build_self_deploy_candidate (new) |
|---|---|---|
| Manifest source | RelaunchConfig.manifest_dir (default "." = cwd) |
the prepared repo (merged head) |
| Target dir | RelaunchConfig.canary_target_dir (cold temp_dir()/…PID) |
persistent warm target_dir |
| Build command | cargo build --release --target-dir <dir> --manifest-path <m>/Cargo.toml |
same flags, warm/merged inputs |
| Built binary | <target_dir>/release/simard |
<target_dir>/release/simard |
The build still sets CARGO_BUILD_JOBS from cargo_jobs::cargo_jobs() and
fails loud (SimardError/SafeUpdateError::BuildFailed) if the binary is
missing after a "successful" build.
Concurrency: serialized by the host-wide build lock.
prepare_and_build(the orchestrator's step-1 composition) acquires the sharedBuildLock—<state_root>/cargo_build.lock, the same lock the operator dashboard's/api/build-locksurfaces and can force-release — for the whole resolve→checkout→build window, releasing it on drop (RAII). Two concurrent self-deploys share the persistent source checkout and the warm target dir; without serialization, one run'sgit checkout --detach <shaB>could rewrite the working tree while another'scargo buildis still reading it, so it would compile one tree yet embed the other'sSIMARD_GIT_HASHand slip the wrong source past theversion_advancedgate (which only checks the embedded SHA). Failing to acquire the lock within the timeout aborts loudly withBuildFailedrather than racing.
Load-bearing build environment.
build.rsderivesSIMARD_GIT_HASHfrom a baregit rev-parse HEADthat resolves against the build script's working directory (Cargo runs it in the manifest directory). For the embedded SHA to equaltarget_commit,build_self_deploy_candidatemust invokecargo buildagainst the prepared repo with a sanitized environment —GIT_DIR/GIT_WORK_TREEneutralized — so the build script cannot be redirected to a different repo'sHEAD. This is distinct from the preparer's own hardened git helper (see security model); the build invocation needs the same protection because the post-build integrity gate depends on it.
The orchestrator gains an optional source preparer. new() is preserved
byte-for-byte (no preparer → legacy build_canary from cwd, for backward
compatibility and the existing default tests). with_source() opts into the
prepare-then-warm-build path.
pub struct SelfDeployOrchestrator {
config: UpdateConfig,
restarter: Box<dyn DaemonRestarter>,
target_commit: String,
install_path: PathBuf,
/// `None` → legacy cwd build via `build_canary` (unchanged behaviour).
/// `Some` → fetch/checkout the merged head, then warm-build it.
build_source: Option<Box<dyn SelfDeploySourcePreparer>>,
}
impl SelfDeployOrchestrator {
/// Unchanged. `build_source = None`.
pub fn new(
config: UpdateConfig,
restarter: Box<dyn DaemonRestarter>,
target_commit: String,
install_path: PathBuf,
) -> Self;
/// New. Wires a source preparer so `build_candidate` prepares the merged
/// head and builds it into the warm target dir.
pub fn with_source(
config: UpdateConfig,
restarter: Box<dyn DaemonRestarter>,
target_commit: String,
install_path: PathBuf,
source: Box<dyn SelfDeploySourcePreparer>,
) -> Self;
pub fn run(&self) -> Result<SelfDeployOutcome, SafeUpdateError>;
}Relation to
DeploySourceKind.DeploySourceKind(already inorchestrator.rs, defaultBuildFromSource, alternativeReleaseDownload) records the intent — build the candidate from mergedmainsource vs. download a tagged release.build_sourceis the mechanism that fulfils theBuildFromSourceintent cwd-independently: when present it fetches and checks out the merged head before building. The two are orthogonal — this feature addsbuild_sourceand does not changeDeploySourceKindor the untouchedReleaseDownloadpath.
build_candidate remains step 1 of the load-bearing
run_sequence — it runs before
any backup, drain, swap, or restart. When build_source is Some, step 1
becomes:
1a. source.prepare(target_commit) // resolve repo + git fetch origin + checkout --detach
1b. build_self_deploy_candidate(prepared_repo, self_deploy_target_dir())
Because preparation is step 1, a fetch/checkout/clone failure aborts loudly
before the daemon is touched — no dual backup, no drain, no swap has run. The
rest of the sequence (gate → dual backup → drain → orphan-reap → swap → restart
→ health-check → rollback) and the rollback_then tail are unchanged; only
the build source and target dir differ.
The existing post-deploy version_advanced probe compares the running binary's
embedded SIMARD_GIT_HASH against target_commit via commits_compatible
(healthy when the two are equal, or one is a case-insensitive prefix of the
other — abbreviation tolerance; an empty or "unknown" running SHA never
matches) and rolls back otherwise. It is not a git-ancestry check: a
topologically newer running SHA that is not a prefix of target_commit would
read as incompatible. Because prepare checks out exactly target_commit (a
full 40-hex SHA) and the build runs in that prepared work-tree (see the
load-bearing build-environment note above), build.rs embeds that exact SHA, so
the running SHA equals target_commit and the probe stays self-consistent.
| Variable | Purpose | Default |
|---|---|---|
SIMARD_SELF_DEPLOY_REPO |
Absolute path to an existing git work-tree to use as the self-deploy source, bypassing the persistent clone. Validated; invalid → SourceResolveFailed (never cwd fallback). |
unset → resolve via precedence |
SIMARD_STATE_ROOT |
Relocates self_deploy_src_dir() and self_deploy_target_dir() (and all other durable state). See state-root-resolution. |
~/.simard |
No new UpdateConfig fields are required for the default behaviour; the warm
directories are derived from the resolved state root. The existing self-deploy
UpdateConfig fields (self-deploy-api)
keep their meaning.
Added to SafeUpdateError. All three are
raised during step 1 (source preparation), i.e. before any daemon
mutation — they are pure aborts, never trigger rollback, and leave the install
path untouched.
| Variant | Raised when |
|---|---|
SourceResolveFailed { detail } |
The canonical repo could not be resolved: invalid SIMARD_SELF_DEPLOY_REPO (.., symlink, or not a work-tree), an undiscoverable origin URL, or a failed first-time clone. |
FetchFailed { detail } |
git fetch origin failed and the target object is not already cached locally — the merged head cannot be made available. |
CheckoutFailed { detail } |
SHA validation failed (^[0-9a-f]{40}$) or git checkout --detach <sha> failed (e.g. the merged object is not present after fetch). |
Every variant carries a detail string and is surfaced loudly in logs and the
cycle report; none is swallowed and none falls back to a cwd build.
The source preparer interpolates a SHA and a remote URL into git argv and
writes to disk, so it applies defence-in-depth controls:
| Control | Rule |
|---|---|
| SHA validation | The target SHA is validated against ^[0-9a-f]{40}$ before any git interpolation, blocking leading-- option injection into checkout/fetch. |
| Repo-path validation | SIMARD_SELF_DEPLOY_REPO (and any explicit at() override) must be absolute, contain no .., not be a symlink, and resolve to a real git work-tree. Invalid → SourceResolveFailed, never a cwd fallback. |
| Transport allow-list | Only https://, ssh://, and the scp-like git@host:path origins are accepted. Arbitrary-command / remote-helper transports (ext::, fd::, and any scheme::address) are rejected before git clone ever runs. |
| Hardened git execution | Every preparer git call uses env_clear() and re-injects only PATH and HOME, blocking GIT_DIR / GIT_WORK_TREE / LD_PRELOAD hijack. Arguments are passed as an argv array — never via sh -c. |
| Build-environment sanitization | build_self_deploy_candidate invokes cargo build with GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_COMMON_DIR / GIT_OBJECT_DIRECTORY removed, so build.rs's git rev-parse HEAD resolves the prepared repo's HEAD (== target_commit) and the post-build SIMARD_GIT_HASH integrity gate stays self-consistent. (The rest of the env — PATH/HOME/CARGO_*/RUSTUP_* — is preserved so the build runs.) |
| Non-destructive checkout | prepare leaves the work-tree on a plain git checkout --detach <sha> of the exact merged commit. The managed clone is only ever written by self-deploy, so it stays clean; a dirty user-provided SIMARD_SELF_DEPLOY_REPO fails loud (CheckoutFailed) rather than being force-wiped — the preparer never reset --hard/cleans a caller's tree. |
| Trust boundary | The built SHA always originates from the trusted origin default branch (merged_head against the resolved canonical repo), never a cwd-controlled ref. cargo build still runs build.rs/proc-macros from source; that supply-chain surface is bounded by this SHA-trust + the post-build SIMARD_GIT_HASH integrity gate. |
The disk reaper in src/cmd_cleanup/disk.rs has several scans:
clean_simard_canaries removes entries under a temp_dir() / /tmp base whose
names start with simard-, simard-canary, simard-e2e, amplihack-,
amplihack_eval, or ia2-; clean_stale_cargo_targets targets the fixed paths
/tmp/simard-canary and /tmp/cargo-target; and three ~/.simard scans
(rotate_simard_binary_backups on ~/.simard/bin/simard.bak-*,
trim_simard_snapshots on ~/.simard/snapshots/, and remove_old_corrupt_dbs
on ~/.simard top-level entries matching is_corrupt_quarantine_name, i.e. a
.corrupt-<ts> infix). The warm directories are named self-deploy-src /
self-deploy-target directly under ~/.simard: they are within the base
remove_old_corrupt_dbs reads, but they are safe because no reaper's name
filter matches self-deploy-* (they do not start with simard-/amplihack-,
are not bin/snapshots, and carry no .corrupt- infix). A dedicated test
(warm_target_dir_name_does_not_match_any_cleanup_reaper) asserts non-overlap
for both dirs by calling the real is_corrupt_quarantine_name
predicate (the only reaper whose scan base — the ~/.simard top level —
actually contains them) and the replicated /tmp name filters, so the warm dir
is never reaped out from under an incremental build even if a future change
broadens the ~/.simard reaper.
All hermetic tests run offline using a local fixture repo (git init →
commit → set a local origin/main ref; git fetch from a file:///path remote
is offline) plus a fake preparer. SIMARD_SELF_DEPLOY_REPO injects the fixture.
The source-prep cases live in a new src/self_deploy/tests_source_prep.rs;
the sequence-wiring cases augment the existing
src/self_deploy/tests_orchestrator.rs (which already covers DeploySourceKind
and the fake-effects ordering) rather than replacing it.
Test (in src/self_deploy/tests_source_prep.rs / tests_orchestrator.rs) |
Asserts |
|---|---|
| fetch/checkout-then-build from an arbitrary cwd | The build is wired to the merged SHA's checkout, not cwd HEAD. |
| resolver precedence | SIMARD_SELF_DEPLOY_REPO → persistent checkout → clone, in order. |
--check read-only resolution |
resolve_existing_repo() returns the env override or persistent checkout cwd-independently, and None (never a clone) when no canonical source exists. |
--check invalid-override degradation |
A present-but-invalid override resolves to None (cwd fallback) without cloning or erroring, and warns loudly on stderr — never silently swallowed. |
| warm target dir | The build targets the persistent self_deploy_target_dir(), reused across runs — not a per-PID temp_dir(). |
| loud failure | A failed fetch/clone/checkout aborts with the specific variant and never builds cwd HEAD. |
| SHA validation | A non-40-hex or leading-- SHA is rejected before any git call. |
| reaper non-overlap | self_deploy_src_dir() / self_deploy_target_dir() fall under no cleanup scan base. |
| backward compatibility | RelaunchConfig::Default, build_canary, and SelfDeployOrchestrator::new default tests still pass. |
The genuinely effectful end-to-end path (real network fetch, real
cargo build, real restart) stays under #[ignore]d operator tests, never in
CI.
src/self_deploy/
source_prep.rs # NEW: SelfDeploySourcePreparer trait + GitSourcePreparer
# + self_deploy_src_dir()/self_deploy_target_dir()
orchestrator.rs # EXISTING: adds SelfDeployOrchestrator::with_source +
# build_source field; DeploySourceKind unchanged
tests_source_prep.rs # NEW: local-fixture-repo + fake-preparer tests
tests_orchestrator.rs # EXISTING: augmented with prepare→warm-build wiring tests
src/self_relaunch/
canary.rs # EXISTING: adds build_self_deploy_candidate (sibling of build_canary)
src/safe_update/
errors.rs # EXISTING: adds SourceResolveFailed / FetchFailed / CheckoutFailed
src/operator_cli/
self_deploy.rs # EXISTING: wires GitSourcePreparer via with_source
- Self-deploy API reference — the core types, config, CLI, and health JSON.
- Reconcile-and-self-deploy concept — the end-to-end sequence and rationale.
- How to run self-deploy from any directory — operator runbook.
- State-root resolution — how
SIMARD_STATE_ROOTrelocates the warm dirs.