From 3a196b27fd46793478a4579fd0e53d3a070815d9 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Sat, 5 Sep 2026 10:44:45 +0200 Subject: [PATCH] Accept both release archive shapes --- .github/workflows/release.yml | 28 ++-- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 + docs/fabric-sync-process-plan.md | 20 ++- now.md | 36 ++++- src/service.rs | 113 +++++++++----- src/update.rs | 250 +++++++++++++++++++++++-------- 8 files changed, 334 insertions(+), 124 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afd457e..4407f0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,21 +48,31 @@ jobs: set -euo pipefail mkdir -p dist/package cp "target/${{ matrix.target }}/release/fabric" dist/package/fabric - chmod 755 dist/package/fabric + cp "target/${{ matrix.target }}/release/fabric-sync" dist/package/fabric-sync + chmod 755 dist/package/fabric dist/package/fabric-sync archive="dist/fabric-${{ matrix.target }}.tar.gz" - # Release A is the reader-only transition. Step 7 restores the pair. - tar -czf "$archive" -C dist/package fabric + tar -czf "$archive" -C dist/package fabric fabric-sync members="$(tar -tzf "$archive")" - if [[ "$members" != "fabric" ]]; then - echo "transition archive must contain exactly fabric" >&2 + if [[ "$members" != $'fabric\nfabric-sync' ]]; then + echo "release archive must contain exactly fabric and fabric-sync" >&2 printf 'archive members:\n%s\n' "$members" >&2 exit 1 fi - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$archive" > "$archive.sha256" - else - sha256sum "$archive" > "$archive.sha256" + single_archive="dist/fabric-only-${{ matrix.target }}.tar.gz" + tar -czf "$single_archive" -C dist/package fabric + single_members="$(tar -tzf "$single_archive")" + if [[ "$single_members" != "fabric" ]]; then + echo "fabric-only archive must contain exactly fabric" >&2 + printf 'archive members:\n%s\n' "$single_members" >&2 + exit 1 fi + for artifact in "$archive" "$single_archive"; do + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$artifact" > "$artifact.sha256" + else + sha256sum "$artifact" > "$artifact.sha256" + fi + done - uses: actions/upload-artifact@v4 with: diff --git a/Cargo.lock b/Cargo.lock index 84003b5..1513a93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "fabric" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index c130d68..0294b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fabric" -version = "0.2.3" +version = "0.2.4" edition = "2024" description = "Local socket facade for iroh-backed cross-machine transports" repository = "https://github.com/compoundingtech/fabric" diff --git a/README.md b/README.md index ac1394a..52a1848 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,13 @@ and `fabric-sync` (not dot-prefixed paths). Verify that shape before extracting: The process-extraction plan defines one fabric-only transition release before the first paired deployment. That gated reader release is the only exception. +The updater accepts that one-member shape and the paired shape. It rejects all +other member sets. A one-member install removes an installed companion and +keeps both prior binaries for rollback. + +Current releases publish the pair at `fabric-.tar.gz`. Release `0.2.4` +also publishes `fabric-only-.tar.gz` for a strict one-member reader. +That compatibility asset requires `fabric update --url ... --sha256 ...`. `fabric update` arms a detached verifier before it replaces either binary. On macOS, launchd owns a transient one-shot job across terminal loss and system diff --git a/docs/fabric-sync-process-plan.md b/docs/fabric-sync-process-plan.md index f69e8cf..688a3b1 100644 --- a/docs/fabric-sync-process-plan.md +++ b/docs/fabric-sync-process-plan.md @@ -186,7 +186,7 @@ pair-aware rollback reader and the macOS supervisor. It contains no companion. The reader restores both old processes when a companion existed. It removes the companion binary and OS service when no companion existed. The first paired archive is the later writer. This gate is per machine, so a roaming machine such -as Bluey first receives the transition release when it returns. +as Bluey first receives a fabric-only archive when it returns. The reader-before-writer rule applies to every update artifact. If a new release writes an artifact that an old rollback binary must read, the old binary must @@ -195,6 +195,13 @@ definitions, generation records, durable state, and future install metadata all follow this rule. A release plan must identify the reader for each new artifact before it permits the writer. +Release `v0.2.3+bef869a` is a published one-member route. Release `0.2.4` +publishes the canonical paired archive for the strict `0.2.2` server readers. +It also publishes a distinct one-member compatibility archive for Bluey's +strict `0.2.1` reader. Bluey selects that asset with an explicit URL and hash, +so it needs only one supervised update. The `0.2.4` updater accepts both valid +shapes, and later releases can return to paired archives only. + ### Paired-install rollback inventory Every future paired-install change must add its machine effects to this table. @@ -330,6 +337,11 @@ Release archives contain exactly `fabric` and `fabric-sync`. The installer and updater verify both members, both hashes, and equal versions. Rollback stores a matched pair. +The updater also accepts the earlier fabric-only release shape. A one-member +install removes an installed companion and keeps the complete prior set for +rollback. It rejects all other member sets. This tolerant reader prevents a +future archive-shape change from requiring another strict-reader flag day. + The main daemon still runs embedded sync. Removing the companion binary reverts this step without changing behavior. @@ -375,9 +387,9 @@ Deploy one machine first and prove both mixed directions. Continue one machine at a time. The release gate remains with Silber.cos. Reverting the complete binary pair restores the embedded owner and reads the unchanged state. -No paired archive may reach a machine until that machine runs the fabric-only -transition release and has proved its rollback reader. Bluey follows the same -gate when it returns. +No paired archive may reach a machine until that machine runs a fabric-only +release and has proved its rollback reader. Bluey follows the same gate when it +returns. Its distinct `0.2.4` compatibility asset satisfies this reader gate. ### 8. Remove the dormant embedded engine diff --git a/now.md b/now.md index 37430f9..4cfa212 100644 --- a/now.md +++ b/now.md @@ -4,9 +4,10 @@ The living handoff for whoever owns fabric next (there was none before; keep thi current). This records what is DONE, what is IN FLIGHT, and what is NEXT — the things the repo history alone does not carry. -_Last updated: 2026-09-05 by Silber.fabric-codex. Silber and hetz run Release A. -Bluey was away and last reported the earlier fleet build. Ask Silber.cos before -each release or deployment._ +_Last updated: 2026-09-05 by Silber.fabric-codex. Silber and hetz run +`0.2.2+a2f8a73`, measured by Silber.cos on 2026-09-05. Bluey is away. Nathan +last saw Bluey on `0.2.1+48208e4` at about 22:30 on 2026-09-04. Treat that as +last-known, not current. Ask Silber.cos before each release or deployment._ For extraction steps 6 and 8, merge on green without asking for a separate Silber.cos approval. Silber.cos holds the step 7 activation gate and every @@ -54,8 +55,9 @@ it. Release A must be a fabric-only transition release. It carries pair-aware rollback and the macOS supervisor, but its archive has no companion. Deploy and prove Release A on each machine before that machine receives its first paired -archive. Bluey also receives Release A first when it returns. Silber.cos owns -both release and deployment gates. +archive. Bluey can instead receive the fabric-only `0.2.4` compatibility asset +when it returns. That asset installs the same tolerant reader before Bluey sees +a paired archive. Silber.cos owns both release and deployment gates. The Release A reader must handle OS service state as well as binary paths. It must restart both old services when a companion rollback exists. It must remove @@ -108,6 +110,30 @@ problem. An enabled service with a missing binary now asks for a matched pair. PR #171 merged the transition doctor fix as `475807e`. It passed the Nix, macOS, and deterministic checks. It is not released or deployed. +PR #172 isolated one peer's slow connection open from healthy peers. A recovery +regression exposed two separate races. PR #174 corrected the connection race as +`2d629f4`. Issue #175 retains the older sync rename race, which remains unfixed. + +PR #173 set version `0.2.3` and merged as `bef869a`. Release +`v0.2.3+bef869a` is published with one-member archives. The checksums and the +macOS binary passed. No server installed it. The `0.2.2` updater refused it +before staging because that reader requires exactly two archive members. + +PR #176 restores paired release packaging. It also makes the updater +accept exactly two valid shapes: `fabric` alone, or `fabric` with +`fabric-sync`. Extra, missing, duplicate, and dot-prefixed members remain +errors. A one-member install removes the companion and keeps the prior pair for +rollback. Both Linux shapes schedule the service restart outside the caller's +cgroup. The one-member restart omits the absent companion. Release `0.2.4` must +use paired archives so the strict `0.2.2` server readers can install it. The +release also publishes a distinct one-member asset +for Bluey's strict `0.2.1` reader. Bluey selects that asset with an explicit URL +and SHA-256, so it can move to `0.2.4` in one supervised update. The explicit +URL path does not check Git commit direction. It checks the caller's hash, the +archive shape, the executable, its reported version, and rollback readiness. +Do not replace or remove `v0.2.3`. Silber.cos must gate the release and every +deployment. + A retained hetz-to-Silber window measured 300 direct probes over 358.498 seconds. All probes passed. External p50 was 45.013 ms, p95 was 2.117 seconds, and p99 was 2.675 seconds. Nineteen probes exceeded two seconds. Eighteen of diff --git a/src/service.rs b/src/service.rs index 7bbde5c..d7d6cd6 100644 --- a/src/service.rs +++ b/src/service.rs @@ -186,19 +186,27 @@ fn control_socket_state( /// That is the same trap as installing at `command -v fabric`, entered from the /// other side. pub fn install_at(home: &FabricHome, exe: &Path, options: ServiceInstallOptions) -> Result<()> { - install_at_with_verification(home, exe, options, InstallVerification::Immediate) + install_at_with_verification(home, exe, options, InstallVerification::Immediate, true) } pub(crate) fn install_at_for_update( home: &FabricHome, exe: &Path, options: ServiceInstallOptions, + companion_exists: bool, + detached_supervisor: bool, ) -> Result<()> { + let verification = if detached_supervisor { + InstallVerification::DetachedSupervisor + } else { + InstallVerification::Immediate + }; install_at_with_verification( home, exe, options, - InstallVerification::DetachedSupervisor, + verification, + companion_exists, ) } @@ -207,6 +215,7 @@ fn install_at_with_verification( exe: &Path, options: ServiceInstallOptions, verification: InstallVerification, + companion_exists: bool, ) -> Result<()> { // The managed OS-service is a PROD-only concept, under a single global label. // Installing it against a dev/custom home would register a SECOND service on @@ -230,14 +239,23 @@ fn install_at_with_verification( let allow_exec = resolve_allow_exec(home, options.allow_exec)?; let memory_max_mb = resolve_memory_max_mb(home, options.memory_max_mb)?; let spec = ServiceSpec::new(exe, home.root(), allow_shell, allow_exec, memory_max_mb)?; - require_sync_companion(&spec)?; + if companion_exists { + require_sync_companion(&spec)?; + } match ServiceManager::current()? { #[cfg(target_os = "linux")] - ServiceManager::SystemdUser => install_systemd_user(&spec)?, + ServiceManager::SystemdUser => { + install_systemd_user(&spec, companion_exists)?; + } #[cfg(target_os = "macos")] ServiceManager::LaunchdUser => { + if !companion_exists { + remove_launchd_sync_for_rollback()?; + } install_launchd_user(home, &spec)?; - install_launchd_sync_user(home, &spec)?; + if companion_exists { + install_launchd_sync_user(home, &spec)?; + } } } @@ -427,34 +445,34 @@ fn resolve_allow_exec(home: &FabricHome, requested: Option) -> Result (&'static str, Vec) { - ( - "systemd-run", - vec![ - "--user".into(), - // THE VERIFIER TRUSTS THIS DELAY, so systemd must honour it. - // - // `fabric update` schedules this restart at +3s and a verifier at - // +12s that waits 45s for the new version and rolls back if it does - // not see it. systemd defaults to AccuracySec=1min and batches - // timers, so without this line the restart can fire up to a minute - // late while the verifier fires on time, sees the OLD daemon for its - // whole window, and rolls a good update back to the previous binary - // — cleanly, with the only record in the journal. The verifier's own - // timer already sets this; the restart it verifies must too, or the - // two delays encode an order systemd is free to ignore. Finding 6 of - // the 2026-08-29 review. - "--timer-property=AccuracySec=1s".into(), - // Long enough that the caller returns before its cgroup goes away, - // short enough that an operator is not left waiting on it. - "--on-active=3".into(), - "systemctl".into(), - "--user".into(), - "restart".into(), - SERVICE_NAME.into(), - SYNC_SERVICE_NAME.into(), - ], - ) +fn systemd_restart_argv(companion_exists: bool) -> (&'static str, Vec) { + let mut args = vec![ + "--user".into(), + // THE VERIFIER TRUSTS THIS DELAY, so systemd must honour it. + // + // `fabric update` schedules this restart at +3s and a verifier at + // +12s that waits 45s for the new version and rolls back if it does + // not see it. systemd defaults to AccuracySec=1min and batches + // timers, so without this line the restart can fire up to a minute + // late while the verifier fires on time, sees the OLD daemon for its + // whole window, and rolls a good update back to the previous binary + // — cleanly, with the only record in the journal. The verifier's own + // timer already sets this; the restart it verifies must too, or the + // two delays encode an order systemd is free to ignore. Finding 6 of + // the 2026-08-29 review. + "--timer-property=AccuracySec=1s".into(), + // Long enough that the caller returns before its cgroup goes away, + // short enough that an operator is not left waiting on it. + "--on-active=3".into(), + "systemctl".into(), + "--user".into(), + "restart".into(), + SERVICE_NAME.into(), + ]; + if companion_exists { + args.push(SYNC_SERVICE_NAME.into()); + } + ("systemd-run", args) } /// Resolve the memory ceiling, in the same shape as the two allow flags above. @@ -711,26 +729,35 @@ impl ServiceManager { } #[cfg(target_os = "linux")] -fn install_systemd_user(spec: &ServiceSpec) -> Result<()> { +fn install_systemd_user(spec: &ServiceSpec, companion_exists: bool) -> Result<()> { let unit_path = systemd_user_unit_path()?; let sync_unit_path = systemd_sync_user_unit_path()?; + if !companion_exists { + remove_systemd_sync_for_rollback()?; + } if let Some(parent) = unit_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::write(&unit_path, render_systemd_user_unit(spec)) .with_context(|| format!("failed to write {}", unit_path.display()))?; - fs::write(&sync_unit_path, render_systemd_sync_user_unit(spec)?) - .with_context(|| format!("failed to write {}", sync_unit_path.display()))?; + if companion_exists { + fs::write(&sync_unit_path, render_systemd_sync_user_unit(spec)?) + .with_context(|| format!("failed to write {}", sync_unit_path.display()))?; + } run_command("systemctl", &["--user", "daemon-reload"])?; run_command("systemctl", &["--user", "enable", SERVICE_NAME])?; - run_command("systemctl", &["--user", "enable", SYNC_SERVICE_NAME])?; - let (program, args) = systemd_restart_argv(); + if companion_exists { + run_command("systemctl", &["--user", "enable", SYNC_SERVICE_NAME])?; + } + let (program, args) = systemd_restart_argv(companion_exists); let args: Vec<&str> = args.iter().map(String::as_str).collect(); run_command(program, &args)?; println!("unit\t{}", unit_path.display()); - println!("sync-unit\t{}", sync_unit_path.display()); + if companion_exists { + println!("sync-unit\t{}", sync_unit_path.display()); + } // Say scheduled, because it is. Claiming a restart that has not happened yet // would make a failed start look like a successful install. println!("restart\tscheduled"); @@ -1666,7 +1693,7 @@ mod tests { /// the effect would be the thing that got killed. #[test] fn the_linux_service_restart_is_detached_from_the_caller() { - let (program, args) = systemd_restart_argv(); + let (program, args) = systemd_restart_argv(true); assert_eq!( program, "systemd-run", "the restart is issued in place, so it kills its own caller" @@ -1693,6 +1720,12 @@ mod tests { args.contains(&SYNC_SERVICE_NAME), "the same detached action must restart the companion: {args:?}" ); + + let (_, single_args) = systemd_restart_argv(false); + assert!( + !single_args.iter().any(|arg| arg == SYNC_SERVICE_NAME), + "a fabric-only release must not restart the absent companion: {single_args:?}" + ); } #[test] diff --git a/src/update.rs b/src/update.rs index a42e03c..12e1fcb 100644 --- a/src/update.rs +++ b/src/update.rs @@ -138,14 +138,14 @@ pub fn verify_sha256(bytes: &[u8], expected: &str) -> Result<()> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseBinaries { pub fabric: Vec, - pub fabric_sync: Vec, + pub fabric_sync: Option>, } -/// Take the matched binary pair out of a release archive. +/// Take the release binaries out of a one-member or paired archive. /// -/// A release archive holds literal `fabric` and `fabric-sync` members. It holds -/// no dot-prefixed paths, directories, or extra files. Anything else is not a -/// thing we published, and unpacking it to find out would already write it. +/// A release archive always holds literal `fabric`. It can also hold literal +/// `fabric-sync`. It holds no dot-prefixed paths, directories, duplicate +/// members, or extra files. Anything else is not a release we published. pub fn extract_release_binaries(archive: &[u8]) -> Result { use std::io::Read; let decoder = flate2::read::GzDecoder::new(archive); @@ -179,15 +179,18 @@ pub fn extract_release_binaries(archive: &[u8]) -> Result { } } } - if names.len() != 2 || fabric.is_none() || fabric_sync.is_none() { + let valid_members = fabric.is_some() + && ((names.len() == 1 && fabric_sync.is_none()) + || (names.len() == 2 && fabric_sync.is_some())); + if !valid_members { bail!( - "the archive is not a fabric release: expected exactly two members named \ + "the archive is not a fabric release: expected `fabric` alone or exactly \ `fabric` and `fabric-sync`, found {names:?}" ); } Ok(ReleaseBinaries { fabric: fabric.expect("checked above"), - fabric_sync: fabric_sync.expect("checked above"), + fabric_sync, }) } @@ -538,6 +541,34 @@ where Ok((rollback, companion_rollback)) } +/// Preserve the prior set, arm supervision, then install a fabric-only release. +/// +/// A one-member archive makes companion absence affirmative. If the installed +/// release has a companion, keep its rollback copy and remove its live path. +fn commit_staged_single( + staged: &Path, + path: &Path, + companion_path: &Path, + stamp: &str, + before_commit: F, +) -> Result<(PathBuf, PathBuf)> +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ + let rollback = prepare_rollback(path, stamp)?; + let companion_rollback = prepare_rollback(companion_path, stamp) + .context("fabric-sync rollback could not be prepared")?; + before_commit(&rollback, &companion_rollback)?; + commit_prepared(staged, path)?; + if companion_path.exists() + && let Err(error) = std::fs::remove_file(companion_path) + { + restore_after_pair_commit_failure(path, &rollback)?; + return Err(error).context("fabric-sync could not be removed; fabric was restored"); + } + Ok((rollback, companion_rollback)) +} + /// A stamp for rollback names. Seconds are enough: two updates inside one second /// on one machine is not a case worth a dependency. pub fn timestamp() -> String { @@ -1321,12 +1352,15 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re let binaries = extract_release_binaries(&archive)?; let stamp = timestamp(); let staged = stage_binary(&installed_path, &binaries.fabric, &stamp)?; - let staged_companion = match stage_binary(&companion_path, &binaries.fabric_sync, &stamp) { - Ok(path) => path, - Err(error) => { - let _ = std::fs::remove_file(&staged); - return Err(error); - } + let staged_companion = match binaries.fabric_sync.as_deref() { + Some(bytes) => match stage_binary(&companion_path, bytes, &stamp) { + Ok(path) => Some(path), + Err(error) => { + let _ = std::fs::remove_file(&staged); + return Err(error); + } + }, + None => None, }; // Ask the binary what it is before trusting it with the name of the one @@ -1335,24 +1369,31 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re Ok(version) => version, Err(error) => { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); - return Err(error.context("the downloaded binary could not run, so nothing was installed")); + if let Some(path) = &staged_companion { + let _ = std::fs::remove_file(path); + } + return Err( + error.context("the downloaded binary could not run, so nothing was installed") + ); } }; - let companion_version = match binary_version(&staged_companion) { - Ok(version) => version, - Err(error) => { + if let Some(path) = &staged_companion { + let companion_version = match binary_version(path) { + Ok(version) => version, + Err(error) => { + let _ = std::fs::remove_file(&staged); + let _ = std::fs::remove_file(path); + return Err(error + .context("the downloaded companion could not run, so nothing was installed")); + } + }; + if companion_version != staged_version { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); - return Err(error.context("the downloaded companion could not run, so nothing was installed")); + let _ = std::fs::remove_file(path); + bail!( + "fabric reports {staged_version}, but fabric-sync reports {companion_version}; nothing was installed" + ); } - }; - if companion_version != staged_version { - let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); - bail!( - "fabric reports {staged_version}, but fabric-sync reports {companion_version}; nothing was installed" - ); } println!("downloaded\t{staged_version}"); @@ -1360,7 +1401,9 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re && &staged_version != expected { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); + if let Some(path) = &staged_companion { + let _ = std::fs::remove_file(path); + } bail!( "the release claims to be {expected} but the binary in it reports \ {staged_version}; nothing was installed" @@ -1369,7 +1412,9 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re if options.dry_run { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); + if let Some(path) = &staged_companion { + let _ = std::fs::remove_file(path); + } println!(); println!("DRY RUN: verified and stopped. Nothing was changed."); return Ok(0); @@ -1377,7 +1422,9 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re if let Err(error) = crate::gitremote::validate_helper_install(&installed_path) { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); + if let Some(path) = &staged_companion { + let _ = std::fs::remove_file(path); + } return Err(error.context("the Git helper path is unsafe, so nothing was installed")); } @@ -1386,34 +1433,52 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re let generation = new_update_generation(); write_update_generation(home, &generation)?; - let (rollback, companion_rollback) = match commit_staged_pair( - &staged, - &installed_path, - &staged_companion, - &companion_path, - &stamp, - |rollback, _companion_rollback| { - schedule_before_pair_commit( - home, - &options, - &installed_path, - rollback, - &generation, - &staged_version, - &stamp, - ) - }, - ) { + let before_commit = |rollback: &Path, _companion_rollback: &Path| { + schedule_before_pair_commit( + home, + &options, + &installed_path, + rollback, + &generation, + &staged_version, + &stamp, + ) + }; + let committed = if let Some(path) = &staged_companion { + commit_staged_pair( + &staged, + &installed_path, + path, + &companion_path, + &stamp, + before_commit, + ) + } else { + commit_staged_single( + &staged, + &installed_path, + &companion_path, + &stamp, + before_commit, + ) + }; + let (rollback, companion_rollback) = match committed { Ok(pair) => pair, Err(error) => { let _ = std::fs::remove_file(&staged); - let _ = std::fs::remove_file(&staged_companion); + if let Some(path) = &staged_companion { + let _ = std::fs::remove_file(path); + } return Err(error); } }; let helper = crate::gitremote::install_helper_for(&installed_path)?; println!("installed\t{}", binary_version(&installed_path)?); - println!("companion\t{}", binary_version(&companion_path)?); + if companion_path.exists() { + println!("companion\t{}", binary_version(&companion_path)?); + } else { + println!("companion\tabsent in release"); + } println!("helper\t{}", helper.display()); if rollback.exists() { println!("rollback\t{}", rollback.display()); @@ -1423,7 +1488,14 @@ pub async fn run(home: &crate::config::FabricHome, options: UpdateOptions) -> Re } let running = binary_version(&installed_path)?; - finish(home, &options, &rollback, &generation, &running)?; + finish( + home, + &options, + &rollback, + &generation, + &running, + staged_companion.is_some(), + )?; Ok(0) } @@ -1524,7 +1596,14 @@ async fn roll_back( println!("rollback companion\t{}", previous_companion.display()); } let running = binary_version(installed_path)?; - finish(home, options, &previous, &generation, &running)?; + finish( + home, + options, + &previous, + &generation, + &running, + companion_path.exists(), + )?; Ok(0) } @@ -1582,6 +1661,7 @@ fn finish( rollback: &Path, generation: &str, expect: &str, + companion_exists: bool, ) -> Result<()> { if options.no_restart { println!("restart\tskipped"); @@ -1614,11 +1694,13 @@ fn finish( memory_max_mb: None, }; let supervised = rollback.exists(); - if supervised { - crate::service::install_at_for_update(home, &managed, install_options)?; - } else { - crate::service::install_at(home, &managed, install_options)?; - } + crate::service::install_at_for_update( + home, + &managed, + install_options, + companion_exists, + supervised, + )?; schedule_supervisor(home, rollback, generation, expect)?; if supervised { let previous = binary_version(rollback).unwrap_or_else(|_| "the previous version".into()); @@ -1826,7 +1908,15 @@ mod tests { ]); let binaries = extract_release_binaries(&archive).unwrap(); assert_eq!(binaries.fabric, b"daemon ELF-ish"); - assert_eq!(binaries.fabric_sync, b"companion ELF-ish"); + assert_eq!(binaries.fabric_sync.unwrap(), b"companion ELF-ish"); + } + + #[test] + fn a_fabric_only_release_archive_is_accepted() { + let archive = make_archive(&[("fabric", b"daemon ELF-ish")]); + let binaries = extract_release_binaries(&archive).unwrap(); + assert_eq!(binaries.fabric, b"daemon ELF-ish"); + assert!(binaries.fabric_sync.is_none()); } /// Write a tar header by hand so the stored name is EXACTLY what we say. @@ -1931,12 +2021,16 @@ mod tests { } #[test] - fn the_release_a_workflow_packages_only_the_reader() { + fn the_release_workflow_packages_both_reader_shapes() { let workflow = include_str!("../.github/workflows/release.yml"); - assert!(workflow.contains("tar -czf \"$archive\" -C dist/package fabric\n")); - assert!(workflow.contains("[[ \"$members\" != \"fabric\" ]]")); - assert!(!workflow.contains("release/fabric-sync\" dist/package/fabric-sync")); - assert!(!workflow.contains("-C dist/package fabric fabric-sync")); + assert!(workflow.contains("release/fabric-sync\" dist/package/fabric-sync")); + assert!(workflow.contains("-C dist/package fabric fabric-sync")); + assert!(workflow.contains("$'fabric\\nfabric-sync'")); + assert!(workflow.contains("fabric-only-${{ matrix.target }}.tar.gz")); + assert!(workflow.contains("-C dist/package fabric\n")); + assert!(workflow.contains("[[ \"$single_members\" != \"fabric\" ]]")); + assert!(workflow.contains("for artifact in \"$archive\" \"$single_archive\"")); + assert!(workflow.contains("\"$artifact\" > \"$artifact.sha256\"")); } } @@ -2070,6 +2164,34 @@ mod io_tests { assert_eq!(std::fs::read(companion_rollback).unwrap(), b"old companion"); } + #[test] + fn a_fabric_only_release_removes_the_companion_and_keeps_pair_rollback() { + let dir = tempfile::tempdir().unwrap(); + let fabric = dir.path().join("fabric"); + let companion = dir.path().join("fabric-sync"); + std::fs::write(&fabric, b"old daemon").unwrap(); + std::fs::write(&companion, b"old companion").unwrap(); + let staged = stage_binary(&fabric, b"new daemon", "single").unwrap(); + + let (rollback, companion_rollback) = commit_staged_single( + &staged, + &fabric, + &companion, + "single", + |rollback, companion_rollback| { + assert_eq!(std::fs::read(rollback)?, b"old daemon"); + assert_eq!(std::fs::read(companion_rollback)?, b"old companion"); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(std::fs::read(&fabric).unwrap(), b"new daemon"); + assert!(!companion.exists()); + assert_eq!(std::fs::read(rollback).unwrap(), b"old daemon"); + assert_eq!(std::fs::read(companion_rollback).unwrap(), b"old companion"); + } + /// Nothing partial may survive a run. A leftover `.fabric-incoming-*` beside /// the binary would be a half-written binary sitting in the install /// directory, which is the sort of thing somebody later runs by mistake.