Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ HTTP REST API for orchestrators (scale-to-zero, live migration). (`api.rs`)
| `/api/exports/{name}/snapshots/{seq}` | `DELETE` | Delete a specific snapshot (idempotent) |
| `/api/exports/{name}/tag` | `POST` | Publish current manifest under a named alias without re-flushing. Body: `{"tag":"name"}`. |
| `/api/manifests/{s3_prefix}/{name}` | `HEAD` | Check manifest existence (200/404). No data transfer, no running export required. |
| (fence on a fork) | | A fork copies its source's manifest, but a fork into a **new** name starts unfenced: `create_export` clears `generation` and `lease_revision` when the source manifest name differs from the new name, so the caller's own token stamps it at attach and a child placed below its source's generation is granted. A fork into the **same** name (a parked volume reconstituted from its own manifest) keeps the stamp, and a lower token is still refused (409). |
| `/api/exports/{name}/drain` | `POST` | Flush all dirty blocks to S3 (no versioned snapshot). Returns **409 Conflict** (`superseded: …`) when a newer fence token owns the volume (an evacuated source, a partitioned predecessor): nothing is written and nothing is retried; release the export with a retaining `DELETE` (no `?purge`), which abandons its teardown drain instead of retrying it. |
| `/api/exports/{name}/promote` | `POST` | Toggle readonly → read-write |
| `/api/exports/{name}/promote-base` | `POST` | Publish a snapshot's manifest under `bases/{base_name}` (no data re-upload) and register it in the image index. Body: `{"base_name":"...","sequence":N}`. Idempotent; the promoted base is forkable (`from: "image:<base_name>"`) and profileable like a blessed one. |
Expand Down
79 changes: 79 additions & 0 deletions glidefs/src/block/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3031,6 +3031,85 @@ mod tests {
assert_eq!(body_json(resp).await["s3_prefix"], "source");
}

/// A fork into a NEW name has no prior owner: it must not inherit its source's fence stamp, or a child whose placement
/// generation is below the source's is refused (409) on the attach of a volume nobody else can hold.
#[tokio::test]
async fn test_fork_into_a_new_name_starts_unfenced() {
let temp = TempDir::new().unwrap();
let router = create_test_router(&temp).await;
// The source is owned at (5, 2).
let resp = request(
&router,
Method::PUT,
"/api/exports/source",
Some(r#"{"size_gb": 0.01, "generation": 5, "lease_revision": 2}"#),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = request(&router, Method::POST, "/api/exports/source/snapshot", None).await;
let id = body_json(resp).await["snapshot_id"].as_str().unwrap().to_string();

// A child at generation 1 forks it: granted, and owned at its own token, not the source's.
let body = format!(r#"{{"size_gb": 0.01, "from": "snapshot:{id}", "generation": 1, "lease_revision": 1}}"#);
let resp = request(&router, Method::PUT, "/api/exports/child", Some(&body)).await;
assert_eq!(resp.status(), StatusCode::CREATED, "a fork into a new name is not fenced by its source's stamp");
let info = body_json(request(&router, Method::GET, "/api/exports/child", None).await).await;
assert_eq!(info["generation"], 1);
assert_eq!(info["lease_revision"], 1);

// A fork with no token at all starts unstamped (granted against (0,0)).
let body = format!(r#"{{"size_gb": 0.01, "from": "snapshot:{id}"}}"#);
let resp = request(&router, Method::PUT, "/api/exports/child2", Some(&body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);

// The source itself is untouched: a lower token is still refused there.
let resp = request(
&router,
Method::PUT,
"/api/exports/source",
Some(r#"{"size_gb": 0.01, "generation": 1}"#),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT, "the source keeps its own fence");
}

/// A reconstitute into the SAME name (instd's purge re-attaches a parked volume from its own manifest) keeps the stamp:
/// the volume has an owner, and a lower token must still be refused.
#[tokio::test]
async fn test_same_name_reconstitute_keeps_the_stamp() {
let temp = TempDir::new().unwrap();
let router = create_test_router(&temp).await;
let resp = request(
&router,
Method::PUT,
"/api/exports/vol1",
Some(r#"{"size_gb": 0.01, "generation": 5}"#),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
// Park it: the export goes, the manifest stays, stamped.
let resp = request(&router, Method::DELETE, "/api/exports/vol1", None).await;
assert!(resp.status().is_success());
// Reconstitute it from its own manifest at a lower generation: refused.
let resp = request(
&router,
Method::PUT,
"/api/exports/vol1",
Some(r#"{"size_gb": 0.01, "from": "volume:vol1", "generation": 3}"#),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT, "a same-name fork keeps the stamp, so the lower token is fenced");
// At the owning generation it is granted.
let resp = request(
&router,
Method::PUT,
"/api/exports/vol1",
Some(r#"{"size_gb": 0.01, "from": "volume:vol1", "generation": 5}"#),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn test_api_fork_from_image() {
let temp = TempDir::new().unwrap();
Expand Down
9 changes: 9 additions & 0 deletions glidefs/src/block/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1750,6 +1750,15 @@ impl ExportRouter {

let mut vm = fork_vm;
vm.size = device_size;
// A fork into a NEW name has no prior owner: the single-attach fence guards a name against two holders, and this
// name has none yet. Left on the copy, the SOURCE's stamp would refuse a child placed below the source's
// generation or lease (409) on a volume nobody else can hold. The caller's own token stamps it at attach
// (`enforce_attach_fence`). A fork into the SAME name (a parked volume reconstituted from its own manifest) keeps
// the stamp: that name has an owner, and a lower token must still be refused.
if manifest_name != name {
vm.generation = 0;
vm.lease_revision = 0;
}

// Store the volume manifest for this export
let volume_manifest = Arc::new(parking_lot::RwLock::new(vm));
Expand Down
Loading