fix(scaleway): a disk in the block product is reachable from its server, a DEV1-S root lives there like the cloud's, and the detach that answered 200 released nothing (#571, #365) - #594
Merged
Conversation
…er, and the detach that answered 200 released nothing (#571) Since #8 served `sbs_volume`, one server's disks can live in two stores. Every operation on the server-volume relationship resolved `kindVolume` alone, so a disk the emulator itself had just published was unreachable through all of them. Measured with scw 2.56.3 against a binary built from 3b00d23, on 2026-08-28, on a server anybody can create with `root-volume=sbs:20GB`: attach-volume, on a volume `scw block volume create` made 404 attach-volume, on another server's block root 404 server update volumes.0.id=<block root> refused, "does not exist" instance snapshot create volume-id=<block root> 404 instance volume delete <block root> 404 (correct, see below) detach-volume 200, and released nothing The last line is the one that costs a client its command. `scw instance server terminate <server> with-block=true` walks GetVolume (instance, 404) → GetVolume (block, 200) → detach-volume → then polls the block volume until its status leaves `in_use`. The detach answered 200 while the disk kept its server, so the status never moved: rc=124 at twenty-five seconds, five identical block GETs in the CLI's own -D trace. `anyVolume` resolves both products, and the five doors that take a server and a volume id go through it: attach-volume, detach-volume, the update's volume map, a create naming a volume, and instance CreateSnapshot. `serverVolumeView` dispatches the rendering, so a block disk is published inside a server as an instance VolumeServer carrying `volume_type: sbs_volume` — the value the Terraform provider branches on — whichever door attached it. **Two operations deliberately do NOT resolve both kinds**, and that is the half this change had to get right. instance/v1 GetVolume and DeleteVolume keep answering 404 for a block volume, because the SDK's own dual-product reader (`api/instance/v1/volume_utils.go`, `getUnknownVolume`) falls back to `block.GetVolume` only on a typed `ResourceNotFoundError`. A symmetrical fix would have ended the search before it reached the product that owns the disk, which is the failure #8 exists to prevent. The mutation that makes `volumeOf` resolve both kinds is in the spec, and it reddens `TestAnSbsRootVolumeIsReadableThroughTheBlockFallback`. Three defects of the same family came with it: * `attachStoredVolume` never marked a block volume `in_use`, while `detachStoredVolume` had always marked it `available` — so an attached disk answered `references: [attached]` and `status: available` at once, and `scw` polls the status; * an instance snapshot of a block volume fell through to the `b_ssd` default, naming a product it was not taken from. `sbs_snapshot` is read from the SDK (`VolumeVolumeType` declares it beside `sbs_volume`, and `Snapshot .VolumeType` is a `VolumeVolumeType`, while `CreateSnapshotRequest .VolumeType` cannot spell it) and declared as a reading, not a measurement: no recorded account here holds one. A type the client names still wins, because that field "overrides" in the SDK's own words; * `Owns` declared `kindPrivateNIC` and `kindVolume` and not `kindBlockVolume`, so `storetest.Orphans` — the invariant that no disk names a machine that is gone — skipped every disk of one product. The witness is planted rather than hoped for: a sweep that reports nothing is indistinguishable from a sweep that looked nowhere. `TestAttachingDoesNotStealAnotherServersVolume` now runs its three doors once per product. The honest reason is not that it caught a theft: before the shared resolver a block root was unstealable because it was unreachable, so the guard read as present and stood on an accident. Neutralise the owner comparison in `attachStoredVolume` and both halves go red — which is the property the instance half alone could not give. Nothing changes for any existing client: every answer that was a 200 is the same 200. `mise run conformance:leg -- scw-cli` and `-- fields` green, `mise run prepush` green, and the twelve mutations of `tools/falsify/specs/block-volumes-reach-their-server.json` all bite. One existing mutation was retargeted rather than rewritten: the `volumesOf` fragment of `scaleway-cloud-fidelity.json` became ambiguous when `anyVolume` introduced a second identical loop header, and `falsify:lint` refused it before this commit existed. It now carries the inner line that only `volumesOf` has. Refs #571. This is step 1 of two; it changes no default and closes nothing. Assisted-by: Claude Code (claude-opus-5)
…and eighteen acceptance entries go with it (#365) The default flip #365 asked for. `scw` follows the server's own `volumes["0"].id` into `block/v1alpha1` and is answered there now, where this emulator used to 404 on a path every `scw instance server delete` takes. scw block volume get <a default DEV1-S root> before: rc=1, "cannot find resource 'volume' with ID …" after: rc=0, the volume, in_use, one reference naming its server Measured on a real fr-par-1 account and recorded in corpus/scaleway/scw-instance.jsonl: `CreateServer` with no `volumes` in the body is answered with `volumes: {"0": {"volume_type": "sbs_volume"}}`, read back three times through block and deleted there. It is one line because #571 landed first. The reason not to flip was real and was measured — five operations resolved `kindVolume` alone — but it was a defect that already existed for anybody writing `root_volume { volume_type = "sbs_volume" }`, not a price this change would have paid. Reading a defect as a cost is what kept this open; docs/limits.md now says so where the old limit was. **Eighteen acceptance entries in corpus/accepted.json are deleted**, which the staleness rule makes compulsory rather than optional: the gate reported each of them as excusing nothing, and they all said the same thing — "a server's root volume lives in instance/v1 here and in block upstream". They were removed from the gate's own output rather than by hand. `feint corpus --check` now compares those exchanges for real: 0 divergent findings nothing accepts. Comparing them for the first time found two fields, and both are served rather than accepted, because a recording is the strongest source this repository has: * **`parent_snapshot_id`** — the cloud's root disk names the image snapshot it was restored from (scw-instance.jsonl seq 9, scw-billed-shapes.jsonl seq 13), and this answered null. Not invented: `root_volume` on an instance/v1 Image IS that snapshot — `createImage` reads the client's snapshot id out of that very field — so the identifier was already published and the disk now points back at it. * **`last_detached_at`** — null on every read while the volume is held, a timestamp on the read that follows the detach (seq 9/14 then 18; seq 2/13/27 then 33). Written where the state already moves to `available`, which is the one place a volume stops being held. The four unit tests that encoded a root disk in `instance/v1` state the same facts one product further on, and one of them now asserts the cloud's own sequence: delete the server (204), read the volume in block (200), delete it there (204). `tools/conformance/scaleway/scw-cli.sh` changes, and the reason is a client behaviour rather than an emulator one: `scw instance snapshot create volume-id=<a block volume>` calls `instance.GetVolume` itself before it sends anything (scaleway-cli 2.56.3, `internal/namespaces/instance/v1/ custom_snapshot.go`) and returns that error, so the golden-image path can no longer take the server's root as its subject. It takes an instance volume the client creates, which is what a client does, and the suite gained the step that proves the new reality: the server's root disk is snapshotted through `scw block snapshot create` and names the disk it came from. The instance route does resolve a block volume — `unified=true` reaches it and answers an `sbs_snapshot` — and that is asserted by unit test and by the raw route, since no client walks it. Whether the real cloud answers `instance.GetVolume` for an SBS volume is **not measured**: no recording carries that call. docs/limits.md says so rather than guessing. `volumes_constraint.min_size` is untouched and the trap stays disarmed, checked rather than assumed: `scw instance server create type=DEV1-S` is the first thing the conformance suite does and it passes, and a block root sums to nothing local. Gates: `mise run prepush` green, `mise run conformance:leg -- scw-cli` and `-- fields` green, `feint corpus --check` green with 174 acceptance entries where there were 192, and the sixteen mutations of tools/falsify/specs/block-volumes-reach-their-server.json all bite. Closes #365. Assisted-by: Claude Code (claude-opus-5)
…nswerable there, and this one was not (#571) Found by measuring my own fix. The instance snapshot of a block volume that the commit before this one added was typed `sbs_snapshot`, read straight off the SDK's `VolumeVolumeType` enum. That value is not a label, it is a promise: `scw instance image list` calls `block.GetSnapshot` for every image whose `root_volume.volume_type` is `sbs_snapshot` and fails the WHOLE listing on error (scaleway-cli 2.56.3, `internal/namespaces/instance/v1/custom_image.go:222`). The snapshot lives in `instance/v1`, so the promise was false, and cutting an image from it broke a command that has nothing to do with it: scw instance image list before: rc=1, "cannot find resource 'snapshot' with ID …" — for the whole zone after: rc=0, the image listed `unified` instead, which is not a compromise: it is the value the CLI itself sends for this exact input. With `unified=true` it skips the volume lookup and asks for `SnapshotVolumeTypeUnified` whatever the volume is; without that flag it reads the volume through `instance.GetVolume` and gives up on the 404. So a unified snapshot is the only instance snapshot of a block volume any `scw` user can ask for. The test asserts the promise rather than the spelling — if the type ever is `sbs_snapshot`, `block/v1alpha1` must answer for that id — so it keeps holding the day a snapshot really does cross the two products. Which is the finding this commit also writes down and does NOT fix, because it is a body of work and the maintainer asked to be asked. Measured 2026-08-28: scw block snapshot create volume-id=<a server's root> works scw instance image create snapshot-id=<block snapshot> 404 scw instance snapshot get <block snapshot> 404 scw block volume create from-snapshot=<instance snap> 404 A block snapshot is now the only kind a client can take of a server's root disk, and `scw instance image create` cannot cut an image from one — so the golden-image chain is walkable from a volume the client created and no longer from the server's own root. It is the shape #571 fixed for volumes, one product over, and it is real rather than a decision: the SDK says images built on block snapshots exist. docs/limits.md carries the table. Assisted-by: Claude Code (claude-opus-5)
…topped enforcing at SW-3 (#571) Not prose tidying. The comment above `rootVolume` opened with "the type stays b_ssd whatever is asked" and closed with "so today there is no writable value" — both false since #8 served `sbs_volume`, and flatly wrong now that no type at all gets a block disk. The next reader of that function would have been told the opposite of what the code does, by the comment written to stop exactly that. The two reasons it gives are kept and separated, because that separation is the lesson #8 paid for: the local types are still overridden (the CLI sums LOCAL volumes against `volumes_constraint.min_size`), `sbs_volume` is honoured, and omitting the block now gets `sbs_volume` too. One stale citation with it: the conformance suite named TestAnInstanceSnapshotOfABlockVolumeIsAnSbsSnapshot, which was renamed in the commit before this one when its subject turned out to be the promise rather than the spelling. Assisted-by: Claude Code (claude-opus-5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #571. Closes #365.
A client writing
root_volume { volume_type = "sbs_volume" }got a disk theinstance API could not reach. The premise held — and it was larger than five
operations, and wrong about two of them.
The table, with
scw2.56.3 against a real emulatorEach row is a fresh subject. The witness —
scw block volume geton a disk thatis in block — answered on every run, so a 404 here means unreachable, never
the client is broken.
block volume get <default DEV1-S root>— #365attach-volumea free block volumeattach-volumeanother server's block rootserver update volumes.0.id=<block root>POST /instance/v1/…/snapshots(Terraform's path)instance snapshot create … unified=trueinstance volume delete <block root>instance server detach-volume <block root>server terminate <sbs root> with-block=trueThree corrections to the brief, each measured
1.
GetVolumeandDeleteVolumeare not defects. The SDK's owngetUnknownVolume(api/instance/v1/volume_utils.go) falls back toblock.GetVolumeonly on a typed 404 from the instance side, andscw's-Dtrace ofterminatetakes exactly that pair. Making them resolve both kindswould break the fallback — it is in the spec as a mutation, and it reddens
TestAnSbsRootVolumeIsReadableThroughTheBlockFallback.So the five are three defects plus two doors that must stay shut.
2.
detach-volumeis a sixth, and it is the worst. It answered 200 andreleased nothing, so
scw instance server terminate … with-block=truepolled ablock volume for a status that could never move: rc=124 at 25 s, five
identical block GETs in its own trace. Reachable today by anybody writing
root-volume=sbs:20GB.3. A seventh, unlisted:
attachTemplateVolumessilently skippedadditional-volumes.0=<a block volume>at create — 201 with the disk unattached.Does step 1 make step 2 safe? Not as specified.
The gap was measured and closed rather than shipped on the brief's word.
Three things had to be added first:
detach-volume, without which everyterminatewould hang after the flip;attachStoredVolumenever marked a block volumein_usewhiledetachStoredVolumehad always marked itavailable— so an attached diskanswered
references: [attached]andstatus: availableat once, andscwpolls the status;OwnsdeclaredkindPrivateNICandkindVolumeand notkindBlockVolume,so
storetest.Orphans— the sweep proving no disk names a dead machine —skipped every disk of one product, for as long as that product has existed
here. Witness planted.
With those in, step 2 is one line, and it is safe.
And the fix broke something, which was then measured
Typing the instance snapshot of a block volume
sbs_snapshot— straight off theSDK enum — is not a label but a promise:
scw instance image listcallsblock.GetSnapshotfor every image whoseroot_volume.volume_typesays so, andfails the whole listing on error (
custom_image.go:222). An image cut fromsuch a snapshot made
instance image listanswer 404 for the entire zone.It is
unifiednow — the value the CLI itself sends for that input — and thetest asserts the promise, not the spelling.
#365, and a debt it had been hiding
scw block volume get <a default DEV1-S root>: 404 before, 200 after.The 18 acceptance entries in
corpus/accepted.jsonthat carried it weredeleted from the gate's own output, never retyped — the staleness rule made it
compulsory.
feint corpus --checknow compares those exchanges for real: 0divergent findings nothing accepts, 174 entries where there were 192.
Comparing them for the first time found two fields the cloud answers and this did
not, both served rather than accepted:
parent_snapshot_id(the root disknames the image snapshot it was restored from) and
last_detached_at.volumes_constraint.min_sizeis untouched, checked rather than supposed:scw instance server create type=DEV1-Sis the first thing the suite does, and itpasses.
Snapshots — measured, not fixed, and it is a decision
Same shape one product over, and step 2 put it on the default path:
block snapshot create volume-id=<server root>instance image create snapshot-id=…block volume create from-snapshot=…A block snapshot is now the only kind you can take of a server's root disk, and
instance image createcannot cut an image from one — so the golden-image chainis walkable from a client-created volume and no longer from the server's own
root. Real, not a choice: the SDK says images built on block snapshots exist.
docs/limits.mdcarries the table. The change was not widened to fix it.Gates
prepush,conformance:leg -- scw-cli / fields / terraform / opentofu,feint corpus --check— all green. 17 falsify specs, 17 mutations, 17 bit, 0that did not, including the new
block-volumes-reach-their-server.json.Re-run at the end on the clean tree at
2b09d68with zero uncommitted changes,because the first batch overlapped with in-flight edits and its reds were the
copy's, not the code's.
What was not obtained
mise run evidence:update— prescribed, not run: it starts machines andthis lot was bounded to none. Measured reason it is safe to skip: the only
coverage/key this diff changes (ListVolumesTypes' decline reason) does notappear in
coverage/evidence.jsonat all.limits:checkis red, and was red before this branch — the#277per-parameter section, acknowledged 2026-08-27, with Scaleway renamed the VPC object-storage field on 2026-08-25 and the response body still answers the old name: the query parameter was fixed, the answer was not #570 closing today at
12:45. That section is byte-identical here and on
main; it was not re-dated,because
refreshre-dates every section and that is a claim nobody earned.instance.GetVolumeanswers foran SBS volume.
docs/limits.mdsays so rather than guessing.Where
testplanwas wrongIt prescribed
FEINT_VM=incus-ovn conformance:leg -- runtime, which thischange cannot earn: it routes on
servers.goimportinginternal/core/machine,not on what the diff does. Measured —
grep volumeoverinternal/providers/scaleway/machines.go,internal/core/machine/*.goandinternal/core/cloudinit/*.goreturns zero hits: the machine layer neverreads a volume.
One incidental fix:
scaleway-cloud-fidelity.json'svolumesOfmutation becameambiguous when
anyVolumeintroduced a second identical loop header, andfalsify:lintrefused it before this branch had a first commit. Retargeted at theline only
volumesOfhas.