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
144 changes: 0 additions & 144 deletions corpus/accepted.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion coverage/scaleway-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,7 @@
{
"operation": "instance/v1/API.ListVolumesTypes",
"product": "instance",
"reason": "the emulator serves one volume type, b_ssd, because that is what its catalogue attaches, so a type list would describe capabilities nothing here can create",
"reason": "the instance volumes this emulator makes are b_ssd, its servers' root disks are sbs_volume in the block product, and neither is backed by storage: a type list would describe capabilities and constraints nothing here can honour",
"version": "v1",
"status": "declined"
},
Expand Down
87 changes: 63 additions & 24 deletions docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,9 +803,10 @@ Measured by @vde-dis on #8, with OpenTofu 1.12.5 and `scaleway/scaleway` 2.80.0:
migrate to sbs or downgrade terraform."*
- **`sbs_volume` used to plan for ever**, because the emulator overrode the type
to `b_ssd` and the value read back never matched the value sent. It is now
honoured: the disk is created in `block/v1`, and the provider reads it back
through the fallback it always used — `instance.GetVolume` first, then
`block.GetVolume` on a typed 404.
honoured, and since #365 it is also what a request naming no type gets: the
disk is created in `block/v1`, and the provider reads it back through the
fallback it always used — `instance.GetVolume` first, then `block.GetVolume`
on a typed 404.
- **The local types (`l_ssd`, `scratch`) are still overridden**, and that has its
own reason, unchanged: the emulated catalogue declares
`volumes_constraint.min_size` at 0 and the CLI sums local volumes against it,
Expand Down Expand Up @@ -897,27 +898,65 @@ the pack: this emulator was never asked to delete the gateway, and answering
to avoid. They go when the gateway is recorded again with its destruction in
the transcript.

### A server's root volume lives in `instance/v1` here and in `block` upstream

The largest single divergence the 2026-08-24 recording found, and it is one
default. `CreateServer` with no `volumes` in the body is answered by `fr-par`
with `volumes: {"0": {"volume_type": "sbs_volume"}}` — a *block* volume — and
the recording then reads that volume three times through
`block/v1alpha1/API.GetVolume` and deletes it there. This emulator gives such a
server a `b_ssd` volume in `instance/v1`, so all four of those calls answer
`404`: **forty-three findings, one default.**

`sbs_volume` is honoured when a client asks for it, and
`tools/conformance/scaleway/terraform/main.tf` asks for it, so the path itself
is proven end to end by the real provider. What is not done is making it the
default, and the reason is measured rather than assumed: the whole
`instance/v1` volume surface reads a server's root disk out of the instance
store. Flipping the default reds ten tests at once — `CreateSnapshot` and
`CreateImage` cannot find the volume to snapshot, `attach-volume` and
`detach-volume` refuse it, and terminate stops carrying it away. That is a
batch of its own, not a line in a handler, and it is the same shape of decision
as the asynchronous-delete entry above: a lifecycle that belongs to every kind,
changed in one place.
### A server's root volume lives in `block`, like the cloud's — since #365

This entry used to be the largest single divergence the 2026-08-24 recording
found, and it is over. `CreateServer` with no `volumes` in the body is answered
by `fr-par` with `volumes: {"0": {"volume_type": "sbs_volume"}}` — a *block*
volume — and the recording then reads that volume three times through
`block/v1alpha1/API.GetVolume` and deletes it there. This emulator gave such a
server a `b_ssd` volume in `instance/v1`, so all four of those calls answered
`404`: forty-three findings, one default. The eighteen acceptance entries that
carried them are deleted, and `feint corpus --check` compares those exchanges
for real now.

It is worth keeping why it took two steps, because the first was mistaken for a
price rather than a defect. The reason not to flip was measured: the whole
`instance/v1` volume surface read a server's root disk out of the instance
store, so a block root was invisible to `attach-volume`, `detach-volume`, the
update's volume map, a create naming a volume and `CreateSnapshot`. But that was
already true for anybody who wrote `root_volume { volume_type = "sbs_volume" }`,
which has worked since SW-3 — the defect existed, unmeasured, and the flip only
made it universal. **The cost of a change and a defect it exposes are not the
same thing**, and reading one as the other is what left this open for a month.
#571 fixed the five resolutions first; this became one line.

What the flip left standing, and it is a client behaviour rather than an
emulator one: **`scw instance snapshot create volume-id=<a server's root disk>`
no longer works without `unified=true`**. The CLI 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 command stops one call before the emulator. The instance route does resolve
a block volume — `unified=true` reaches it and answers an `sbs_snapshot` — and
`scw block snapshot create volume-id=<root>` is the path the conformance suite
walks. Whether the real cloud answers `instance.GetVolume` for an SBS volume is
**not measured here**: no recording carries that call, and the SDK's own
`getUnknownVolume` only makes sense if it can 404.

**And the snapshots have not crossed, which the flip put on the default path.**
Measured on 2026-08-28, against this emulator, with `scw` 2.56.3:

| naming a *block* snapshot | naming an *instance* snapshot |
|---|---|
| `scw block snapshot create volume-id=<a server's root>` — works | n/a |
| `scw instance image create snapshot-id=…` — **404** | works |
| `scw instance snapshot get …` — 404 (the fallback's own shape) | works |
| `scw block volume create from-snapshot.snapshot-id=…` — works | **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 — snapshot a disk, cut an image, boot from it — is walkable from a volume
the client created and no longer from the server's own root. This is the shape
#571 fixed for volumes, one product over, and it is **named rather than fixed**:
the SDK says images built on block snapshots exist (`Image.RootVolume.VolumeType`
can be `sbs_snapshot`, and `scw instance image list` reads `block.GetSnapshot`
for exactly that value), so the gap is real and not a decision.

One consequence is already guarded, because it was created and measured inside
this change: an instance snapshot of a block volume is typed `unified`, never
`sbs_snapshot`. `sbs_snapshot` is a promise that the id resolves in
`block/v1alpha1`, and an image cut from a snapshot that broke that promise made
`scw instance image list` fail for the whole zone.

## What survives a dead emulator, in one table

Expand Down
2 changes: 1 addition & 1 deletion docs/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ are in `coverage/`, one artefact per provider.
- `instance` — 1 operation — it writes into Object Storage, which is not emulated because the Terraform provider builds the S3 endpoint in code: supporting it needs DNS interception and a certificate, measured in docs/limits.md
- `instance` — 1 operation — its request carries tags and nothing else, and the pack stores no tag on a private NIC, so it would answer success over a field nothing reads back
- `instance` — 1 operation — its thirteen counters span resources this pack does not serve, so every total would be short by the unemulated remainder with nothing saying which
- `instance` — 1 operation — the emulator serves one volume type, b_ssd, because that is what its catalogue attaches, so a type list would describe capabilities nothing here can create
- `instance` — 1 operation — the instance volumes this emulator makes are b_ssd, its servers' root disks are sbs_volume in the block product, and neither is backed by storage: a type list would describe capabilities and constraints nothing here can honour
- `instance` — 1 operation — the server already publishes allowed_actions, derived from its state, so a second listing would be a second place to keep in step with the first
- `ipam` — 1 operation — ipam/v1alpha1 is the superseded draft of ipam/v1, which is served
- `lb` — 53 operations — the regional lb/v1 API is deprecated upstream in favour of the zoned one, which is served: the portal publishes only the zoned document, and every measured client calls ZonedAPI
Expand Down
32 changes: 24 additions & 8 deletions internal/providers/scaleway/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,18 @@ func (p *Pack) blockVolumeView(res *resource.Resource) map[string]any {
"class": blockStorageClass,
"perf_iops": res.Attrs["perf_iops"],
},
// Present and null on every volume the recorded account returned. Neither
// is emulated: no Key Manager, and no detachment history.
"kms_key_id": nil,
// Present and null on every volume the recorded account returned. Not
// emulated: there is no Key Manager here.
"kms_key_id": nil,
// A timestamp once something has released this volume, null before —
// which is what both recordings show, null while attached and a string
// on the read that follows the detach. Written by detachStoredVolume,
// the one place a volume stops being held.
"last_detached_at": nil,
}
if detached := textOf(res.Attrs["last_detached_at"]); detached != "" {
view["last_detached_at"] = detached
}
// A string when the volume came from a snapshot, null otherwise. The SDK
// declares a pointer and the recorded account only had volumes with a parent,
// so the null branch is the SDK's reading and the string branch is measured.
Expand Down Expand Up @@ -774,8 +781,9 @@ func (p *Pack) listBlockVolumeTypes(w http.ResponseWriter, r *http.Request) {

// ---- The bridge with instance/v1 -------------------------------------------

// blockRootVolumeServerView renders a block volume the way instance/v1 lists it
// inside a server.
// blockVolumeServerView renders a block volume the way instance/v1 lists it
// inside a server. Reached through serverVolumeView, which is what every builder
// of a `volumes` map calls.
//
// Two shapes for one disk, and both are needed: the server's `volumes` map is an
// instance VolumeServer whatever product owns the volume, and the fallback read
Expand All @@ -785,7 +793,11 @@ func (p *Pack) listBlockVolumeTypes(w http.ResponseWriter, r *http.Request) {
//
// volume_type is "sbs_volume", which is what tells the provider to fall back at
// all: it reads instance.GetVolume first and only tries block on a typed 404.
func blockRootVolumeServerView(res *resource.Resource) map[string]any {
//
// It was named blockRootVolumeServerView while a root disk was the only block
// volume a server could carry. It is not: `scw instance server attach-volume
// volume-type=sbs_volume` puts one under any key, and the name said otherwise.
func blockVolumeServerView(res *resource.Resource) map[string]any {
out := map[string]any{
"id": res.ID,
"name": textOf(res.Attrs["name"]),
Expand Down Expand Up @@ -828,7 +840,11 @@ const (
// block one — being in both would answer the first call and never exercise the
// fallback, which is precisely the path #8 exists to unblock.
// TestAnSbsRootVolumeIsReadableThroughTheBlockFallback fails without this.
func (p *Pack) newBlockRootVolume(zone, project, name string, size uint64) *resource.Resource {
//
// parentSnapshot is the image snapshot the disk was restored from, which is what
// the cloud publishes and what a client reads to know where its root came from
// (see imageRootSnapshot).
func (p *Pack) newBlockRootVolume(zone, project, name string, size uint64, parentSnapshot string) *resource.Resource {
now := p.env.Now()
return &resource.Resource{
ID: p.env.NewID(),
Expand All @@ -843,7 +859,7 @@ func (p *Pack) newBlockRootVolume(zone, project, name string, size uint64) *reso
"tags": []any{},
"size": size,
"zone": zone,
"parent_snapshot_id": "",
"parent_snapshot_id": parentSnapshot,
"perf_iops": uint32(blockDefaultIOPS),
},
}
Expand Down
Loading
Loading