Make a rollback pin outliving its project visible and deletable
`survey_rollback_pins` walks images, not projects, and deliberately tolerates an absent project by falling back to the raw id as the display name. Two things then dropped it on the floor: `destroy` called `find_project` before the confirmation check, so it refused such a pin every time, and the per-project table joins destructive items to rows by project_id, where rows come only from projects in the store. The result was a multi-GB `pre-migration-*` image that the scan measured, the panel never rendered, and nothing could remove — in the one screen built to find exactly that. `destroy` takes the same early return `OrphanVolume` already takes, and still validates the tag: `latest` names the project's live snapshot, so the ownerless path must not be a way around that check. The UI grows a bucket for destructive items matching no row, rather than filtering them away. The typed gate already compared against the id via `project_name`; the dialog now says "project id" instead of asking for a project name that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -3035,6 +3035,52 @@ async fn reclaim_containers(
|
||||
/// the typed confirmation is permission to act, not a promise that the world
|
||||
/// stood still. Both of those re-checks predate the move to the destructive
|
||||
/// path and are deliberately unchanged.
|
||||
/// Drop a rollback pin whose project is no longer in `projects.json`.
|
||||
///
|
||||
/// The owned case ([`destroy`]'s `RollbackPin` arm) takes the project's claim
|
||||
/// and clears the ownerless marker. Neither applies here: there is no project
|
||||
/// to claim, and nothing else can be mid-operation on an id the store does not
|
||||
/// know. What *does* still apply is the tag validation — this is the one
|
||||
/// destructive variant carrying a free-form string over IPC, and `latest` would
|
||||
/// name a live snapshot rather than a pin.
|
||||
async fn destroy_ownerless_rollback_pin(
|
||||
project_id: &str,
|
||||
tag: &str,
|
||||
) -> Result<ReclaimResult, String> {
|
||||
if migration::parse_rollback_tag(tag).is_none() {
|
||||
return Err(format!(
|
||||
"{:?} is not a rollback pin tag. Nothing was removed.",
|
||||
tag
|
||||
));
|
||||
}
|
||||
let reference = format!("triple-c-snapshot-{}:{}", project_id, tag);
|
||||
migration::untag_image(&reference).await?;
|
||||
// The grace clock is meaningless once the tag is gone, and the marker file
|
||||
// would otherwise outlive everything that could ever read it.
|
||||
migration_store::clear_ownerless(project_id, tag);
|
||||
// Untagging only makes the image dangling; the sweep applies its own
|
||||
// refusal rules to whatever that turns out to be.
|
||||
let sweep = container::sweep_orphaned_snapshots().await;
|
||||
log::info!(
|
||||
"Dropped ownerless rollback pin {} on explicit confirmation",
|
||||
reference
|
||||
);
|
||||
Ok(ReclaimResult {
|
||||
target: None,
|
||||
destroyed: Some(DestructiveTarget::RollbackPin {
|
||||
project_id: project_id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
}),
|
||||
ok: true,
|
||||
freed_bytes: sweep.reclaimed_bytes,
|
||||
projected_bytes: None,
|
||||
message: format!(
|
||||
"Dropped rollback pin {} for a project that is no longer in Triple-C.",
|
||||
tag
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn destroy_orphan_volume(name: &str, projects: &[Project]) -> Result<ReclaimResult, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -3772,6 +3818,26 @@ pub async fn destroy(
|
||||
return destroy_orphan_volume(name, projects).await;
|
||||
}
|
||||
|
||||
// **A rollback pin can outlive the project it belongs to.**
|
||||
// `survey_rollback_pins` walks *images*, not projects, and deliberately
|
||||
// tolerates an absent project by falling back to the raw id as the display
|
||||
// name. So a pin left by a project the user has since deleted is measured
|
||||
// and listed — and `find_project` below would refuse it on every attempt,
|
||||
// making a multi-GB image permanently undeletable through the panel that
|
||||
// exists to find exactly that. Take the same early return `OrphanVolume`
|
||||
// takes, confirming against the subject the UI actually showed: the id.
|
||||
if let DestructiveTarget::RollbackPin { project_id, tag } = target {
|
||||
if find_project(projects, target.project_id()).is_err() {
|
||||
if !confirmation_matches(project_id, confirmation) {
|
||||
return Err(format!(
|
||||
"This pin's project is no longer in Triple-C, so there is no name to type. Type the project id ({}) exactly to confirm. Nothing was removed.",
|
||||
project_id
|
||||
));
|
||||
}
|
||||
return destroy_ownerless_rollback_pin(project_id, tag).await;
|
||||
}
|
||||
}
|
||||
|
||||
let project = find_project(projects, target.project_id())?;
|
||||
if !confirmation_matches(&project.name, confirmation) {
|
||||
return Err(format!(
|
||||
|
||||
@@ -1547,3 +1547,48 @@ async fn compaction_end_to_end_against_a_real_image() {
|
||||
probe.stdout
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ownerless_rollback_pin_is_confirmed_against_its_id_not_a_project_name() {
|
||||
// `survey_rollback_pins` walks images, not projects, so a pin outlives the
|
||||
// project that made it. Before the early return, `destroy` called
|
||||
// `find_project` first and refused such a pin every single time — a
|
||||
// multi-GB image measured by the panel and deletable by nothing in it.
|
||||
let projects: Vec<Project> = Vec::new();
|
||||
let target = DestructiveTarget::RollbackPin {
|
||||
project_id: "dead0000-0000-0000-0000-000000000000".to_string(),
|
||||
tag: "pre-migration-20260101-101500".to_string(),
|
||||
};
|
||||
|
||||
// The wrong subject is refused, and the message says what to type instead
|
||||
// of the "project not found" the old path produced.
|
||||
let err = destroy(&target, "some-project-name", &projects)
|
||||
.await
|
||||
.expect_err("a mismatched confirmation must refuse");
|
||||
assert!(
|
||||
err.contains("dead0000-0000-0000-0000-000000000000"),
|
||||
"the refusal should name the id to type, got: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("Nothing was removed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ownerless_rollback_pin_still_refuses_a_tag_that_is_not_a_pin() {
|
||||
// The tag is the one free-form string a destructive target carries, and
|
||||
// `latest` names the project's live snapshot. The owned arm validates it;
|
||||
// the ownerless arm must not be the way around that check.
|
||||
let projects: Vec<Project> = Vec::new();
|
||||
let target = DestructiveTarget::RollbackPin {
|
||||
project_id: "dead0000-0000-0000-0000-000000000000".to_string(),
|
||||
tag: "latest".to_string(),
|
||||
};
|
||||
let err = destroy(&target, "dead0000-0000-0000-0000-000000000000", &projects)
|
||||
.await
|
||||
.expect_err("`latest` is not a rollback pin tag");
|
||||
assert!(
|
||||
err.contains("not a rollback pin tag"),
|
||||
"got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user