Skip to content

Commit a9db150

Browse files
committed
Preserve legacy flat cache during install backup
1 parent 6ccfffa commit a9db150

7 files changed

Lines changed: 142 additions & 41 deletions

File tree

Cargo.lock

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ members = ["crates/*"]
44
exclude = ["support/m80-close-range"]
55

66
[workspace.package]
7-
version = "0.2.21"
7+
version = "0.2.22"
88
edition = "2021"
99
license = "Apache-2.0 OR MIT"
1010
repository = "https://github.com/moradology/m80"

crates/m80-cli/src/cmds/install/layout/flat_projection.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ impl FlatPathBackup {
447447
}
448448
}
449449
Ok(metadata) if metadata.is_dir() => {
450-
hardlink_copy_tree(&path, backup)?;
450+
backup_directory_tree(&path, backup)?;
451451
FlatPathState::Directory {
452452
backup: backup.to_path_buf(),
453453
}
@@ -496,6 +496,79 @@ impl FlatPathBackup {
496496
}
497497
}
498498

499+
fn backup_directory_tree(source: &Path, destination: &Path) -> Result<(), FcError> {
500+
let parent = destination_parent(destination)?;
501+
fs::create_dir_all(parent).map_err(|source_err| FcError::PathIo {
502+
path: parent.to_path_buf(),
503+
source: source_err,
504+
})?;
505+
let tmp = sibling_tmp_path(destination, "backup-tree");
506+
remove_path_if_exists(&tmp)?;
507+
copy_backup_tree_contents(source, &tmp)?;
508+
if let Err(err) = replace_path_with_prepared_tree(&tmp, destination) {
509+
let _ = remove_path_if_exists(&tmp);
510+
return Err(err);
511+
}
512+
Ok(())
513+
}
514+
515+
fn copy_backup_tree_contents(source: &Path, destination: &Path) -> Result<(), FcError> {
516+
fs::create_dir(destination).map_err(|source_err| FcError::PathIo {
517+
path: destination.to_path_buf(),
518+
source: source_err,
519+
})?;
520+
fs::set_permissions(destination, fs::Permissions::from_mode(0o755)).map_err(|source_err| {
521+
FcError::PathIo {
522+
path: destination.to_path_buf(),
523+
source: source_err,
524+
}
525+
})?;
526+
for entry in fs::read_dir(source).map_err(|source_err| FcError::PathIo {
527+
path: source.to_path_buf(),
528+
source: source_err,
529+
})? {
530+
let entry = entry.map_err(|source_err| FcError::PathIo {
531+
path: source.to_path_buf(),
532+
source: source_err,
533+
})?;
534+
let source_path = entry.path();
535+
let destination_path = destination.join(entry.file_name());
536+
let metadata =
537+
fs::symlink_metadata(&source_path).map_err(|source_err| FcError::PathIo {
538+
path: source_path.clone(),
539+
source: source_err,
540+
})?;
541+
if metadata.file_type().is_symlink() {
542+
let target = fs::read_link(&source_path).map_err(|source_err| FcError::PathIo {
543+
path: source_path.clone(),
544+
source: source_err,
545+
})?;
546+
symlink(&target, &destination_path).map_err(|source_err| FcError::PathIo {
547+
path: destination_path,
548+
source: source_err,
549+
})?;
550+
} else if metadata.is_dir() {
551+
copy_backup_tree_contents(&source_path, &destination_path)?;
552+
} else if metadata.is_file() {
553+
fs::hard_link(&source_path, &destination_path).map_err(|source_err| {
554+
FcError::PathIo {
555+
path: destination_path,
556+
source: source_err,
557+
}
558+
})?;
559+
} else {
560+
return Err(FcError::Config(ConfigError::InvalidValue {
561+
field: "install.flat_projection",
562+
reason: format!(
563+
"flat projection backup contains unsupported file type: {}",
564+
source_path.display()
565+
),
566+
}));
567+
}
568+
}
569+
Ok(())
570+
}
571+
499572
fn flat_managed_paths(install_root: &Path) -> Vec<PathBuf> {
500573
let bin_dir = install_root.join("bin");
501574
let artifact_dir = install_root.join("artifacts");
@@ -519,7 +592,7 @@ mod tests {
519592
use std::fs;
520593
use std::os::unix::fs::MetadataExt;
521594

522-
use super::hardlink_copy_tree;
595+
use super::{backup_directory_tree, hardlink_copy_tree};
523596

524597
#[test]
525598
fn hardlink_copy_tree_replaces_stale_destination_entries() {
@@ -549,6 +622,31 @@ mod tests {
549622
);
550623
}
551624

625+
#[test]
626+
fn backup_directory_tree_preserves_legacy_symlinks_without_following() {
627+
let temp = tempfile::tempdir().unwrap();
628+
let source = temp.path().join("source");
629+
let destination = temp.path().join("backup");
630+
fs::create_dir_all(&source).unwrap();
631+
fs::write(source.join("manifest.json"), b"manifest").unwrap();
632+
std::os::unix::fs::symlink(
633+
"../versions/v0.2.20/artifacts/release-proof-cache",
634+
source.join("release-proof-cache"),
635+
)
636+
.unwrap();
637+
638+
backup_directory_tree(&source, &destination).unwrap();
639+
640+
assert_same_inode(
641+
&source.join("manifest.json"),
642+
&destination.join("manifest.json"),
643+
);
644+
assert_eq!(
645+
fs::read_link(destination.join("release-proof-cache")).unwrap(),
646+
std::path::PathBuf::from("../versions/v0.2.20/artifacts/release-proof-cache")
647+
);
648+
}
649+
552650
fn assert_same_inode(left: &std::path::Path, right: &std::path::Path) {
553651
let left = fs::metadata(left).unwrap();
554652
let right = fs::metadata(right).unwrap();

crates/m80-cli/tests/release/current_latest_version_cutover.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@ fn current_latest_version_cutover_names_real_repair_tag() {
2828
.expect("read installer layout fixture");
2929

3030
assert!(
31-
cargo_toml.contains("version = \"0.2.21\""),
31+
cargo_toml.contains("version = \"0.2.22\""),
3232
"workspace package version must match the current repair tag"
3333
);
3434
assert!(
35-
runbook.contains("workspace package version `0.2.21`")
36-
&& runbook.contains("expected release tag is `v0.2.21`"),
35+
runbook.contains("workspace package version `0.2.22`")
36+
&& runbook.contains("expected release tag is `v0.2.22`"),
3737
"release runbook must document the real current repair version"
3838
);
3939
assert!(
40-
behavior.contains("matching stable tag is `v0.2.21`")
40+
behavior.contains("matching stable tag is `v0.2.22`")
4141
&& behavior.contains("`v0.2.7` installer handoff"),
4242
"behavior doc must name the repaired tag and the superseded latest state"
4343
);

docs/behaviors/release/current-latest-version-cutover.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,22 @@ keeps the installer fix, restores the lockfile, and retries only transient
3838
stale-latest observations before declaring the public-access receipt failed.
3939
`v0.2.21` carries the flat `/opt/m80/bin` and `/opt/m80/artifacts` hardlink
4040
projection so preflight and clients use stable paths while the versioned
41-
install record remains available for rollback.
41+
install record remains available for rollback. `v0.2.22` fixes the legacy
42+
flat proof-cache bridge case found on vulcan, where an old nested symlink under
43+
`/opt/m80/artifacts/release-proof-cache/` made the `v0.2.21` rollback backup
44+
reject the install before activation.
4245

43-
The current repair source therefore uses workspace package version `0.2.21`.
44-
The matching stable tag is `v0.2.21`; tags at or before the existing public
45-
latest `v0.2.20` are intentionally rejected as old-release backfill candidates
46+
The current repair source therefore uses workspace package version `0.2.22`.
47+
The matching stable tag is `v0.2.22`; tags at or before the existing public
48+
latest `v0.2.21` are intentionally rejected as old-release backfill candidates
4649
by `scripts/current_latest_repair_preflight.py`.
4750

4851
The release runbook documents this as the real source-state expectation rather
4952
than a fixture-only value. Dev builds still render as `<package-version>-dev`,
50-
so an unreleased local build now reports `0.2.21-dev` and remains barred from
53+
so an unreleased local build now reports `0.2.22-dev` and remains barred from
5154
using mutable `releases/latest` implicitly.
5255

53-
Fixture and release-script coverage uses `v0.2.21` as the packageable release
56+
Fixture and release-script coverage uses `v0.2.22` as the packageable release
5457
tag. Rust installer-layout fixtures derive their release tag from
5558
`CARGO_PKG_VERSION`, so the bundled layout tests also follow the same cutover.
5659
Tests may still use other tags only when they are explicitly testing mismatch,

0 commit comments

Comments
 (0)