Skip to content

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
stephrobert merged 4 commits into
mainfrom
fix/571-block-root-reachable
Aug 28, 2026

Conversation

@stephrobert

Copy link
Copy Markdown
Owner

Closes #571. Closes #365.

A client writing root_volume { volume_type = "sbs_volume" } got a disk the
instance API could not reach. The premise held — and it was larger than five
operations, and wrong about two of them.

The table, with scw 2.56.3 against a real emulator

Each row is a fresh subject. The witness — scw block volume get on a disk that
is in block — answered on every run, so a 404 here means unreachable, never
the client is broken.

operation before after step 1 after step 2
block volume get <default DEV1-S root>#365 404 404 200
attach-volume a free block volume 404 200 200
attach-volume another server's block root 404 (unreachable, not guarded) 412, the guard bites 412
server update volumes.0.id=<block root> "does not exist" "is attached to server X" same
POST /instance/v1/…/snapshots (Terraform's path) 404 201 201
instance snapshot create … unified=true rc=1 rc=0 rc=0
instance volume delete <block root> 404 404 — deliberate 404
instance server detach-volume <block root> 200, released nothing 200, released same
server terminate <sbs root> with-block=true rc=124, hangs rc=0 rc=0

Three corrections to the brief, each measured

1. GetVolume and DeleteVolume are not defects. The SDK's own
getUnknownVolume (api/instance/v1/volume_utils.go) falls back to
block.GetVolume only on a typed 404 from the instance side, and scw's
-D trace of terminate takes exactly that pair. Making them resolve both kinds
would 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-volume is a sixth, and it is the worst. It answered 200 and
released nothing
, so scw instance server terminate … with-block=true polled a
block 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: attachTemplateVolumes silently skipped
additional-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 every terminate would hang after the flip;
  • 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;
  • Owns declared kindPrivateNIC and kindVolume and not kindBlockVolume,
    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 the
SDK enum — is not a label but a promise: scw instance image list calls
block.GetSnapshot for every image whose root_volume.volume_type says so, and
fails the whole listing on error (custom_image.go:222). An image cut from
such a snapshot made instance image list answer 404 for the entire zone.

It is unified now — the value the CLI itself sends for that input — and the
test 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.json that carried it were
deleted from the gate's own output, never retyped — the staleness rule made it
compulsory. feint corpus --check now compares those exchanges for real: 0
divergent 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 disk
names the image snapshot it was restored from) and last_detached_at.

volumes_constraint.min_size is untouched, checked rather than supposed:
scw instance server create type=DEV1-S is the first thing the suite does, and it
passes.

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 instance snapshot
block snapshot create volume-id=<server root> works n/a
instance image create snapshot-id=… 404 works
block volume create from-snapshot=… works 404

A block snapshot is now the only kind you can take of a server's root disk, and
instance image create cannot cut an image from one — so the golden-image chain
is 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.md carries 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, 0
that did not
, including the new block-volumes-reach-their-server.json.

Re-run at the end on the clean tree at 2b09d68 with 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 and
    this lot was bounded to none. Measured reason it is safe to skip: the only
    coverage/ key this diff changes (ListVolumesTypes' decline reason) does not
    appear in coverage/evidence.json at all.
  • limits:check is red, and was red before this branch — the #277
    per-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 refresh re-dates every section and that is a claim nobody earned.
  • No cloud recording arbitrates whether the real instance.GetVolume answers for
    an SBS volume. docs/limits.md says so rather than guessing.

Where testplan was wrong

It prescribed FEINT_VM=incus-ovn conformance:leg -- runtime, which this
change cannot earn: it routes on servers.go importing internal/core/machine,
not on what the diff does. Measured — grep volume over
internal/providers/scaleway/machines.go, internal/core/machine/*.go and
internal/core/cloudinit/*.go returns zero hits: the machine layer never
reads a volume.

One incidental fix: scaleway-cloud-fidelity.json's volumesOf mutation became
ambiguous when anyVolume introduced a second identical loop header, and
falsify:lint refused it before this branch had a first commit. Retargeted at the
line only volumesOf has.

…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)
@stephrobert
stephrobert merged commit b6a792a into main Aug 28, 2026
30 checks passed
@stephrobert
stephrobert deleted the fix/571-block-root-reachable branch August 28, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant