diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2a11f60c..cb57ce48 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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:"`) and profileable like a blessed one. | diff --git a/glidefs/src/block/api.rs b/glidefs/src/block/api.rs index 5485abc4..4956930c 100644 --- a/glidefs/src/block/api.rs +++ b/glidefs/src/block/api.rs @@ -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(); diff --git a/glidefs/src/block/router.rs b/glidefs/src/block/router.rs index 33aa4b72..ab99ee20 100644 --- a/glidefs/src/block/router.rs +++ b/glidefs/src/block/router.rs @@ -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));