Added
-
A block snapshot imported from Object Storage, through both doors
(#807):block/v1/API.ImportSnapshotFromObjectStorageand
block/v1alpha1/API.ImportSnapshotFromObjectStorage.The client sends
{bucket, key}and the real cloud reads that object
server-side, so nothing in the Terraform provider's path speaks S3 on this
call. That is what separates it from its export half, which stays declined:
there the provider builds the S3 endpoint from the region in code, so pointing
it at this emulator would take DNS interception and a certificate it accepts.What is recorded is the intention. A snapshot appears,
available, with no
parent volume and a size of zero, because no bytes moved and none could have.
The divergence is stated indocs/limits.mdrather than hidden: the real API
refuses an object that does not exist, and there is no Object Storage here to
check a bucket and a key against. -
SCIM, the whole chain, and a spot start — the nightly drift of 2026-09-29
(#800), triaged by serving rather than declining.The scan found
iam/v1alpha1/API.GetScimTokennew upstream and untriaged while
its six neighbours were declined. Serving that one alone was the option that
looked cheapest and was the worst: the route would have answered 404 to every
identifier, and counted as implemented incoverage/scaleway-coverage.json,
because creating the token it reads needs a SCIM configuration and enabling one
was refused. A coverage number that counts an unreachable route is the one
thing this project cannot afford.So the chain is served end to end —
iam/v1alpha1/API.EnableOrganizationScim,
iam/v1alpha1/API.GetOrganizationScim,iam/v1alpha1/API.DeleteScim,
iam/v1alpha1/API.CreateScimToken,iam/v1alpha1/API.ListScimTokens,
iam/v1alpha1/API.GetScimToken,iam/v1alpha1/API.DeleteScimToken— one
configuration per account, tokens under
it, and the configuration's deletion takes its tokens with it because upstream
has no operation that ever would. The bearer token is answered once and kept
nowhere, which is both how the product behaves and the only shape that leaves
no secret forPUT /_feint/stateto hand back.What is not claimed: the
/scim/v2/Usersand/scim/v2/Groupsendpoints a
directory actually calls. This is the control plane a client configures;
provisioning users from a directory is a second product and it is not emulated.The CLI drives it.
scw iam scimandscw iam scim-tokensexist, so the
conformance suite walks the family with the real client rather than declaring
that nobody calls it; only the three operations the CLI has no subcommand for
— enabling SCIM, reading the configuration, reading a single token — carry a
Route.Undrivenreason, and the suite reaches the first of them with curl
because the three the CLI does drive all take the identifier it returns.
Changed
-
instance/v2alpha1/API.StartSpotServeris declined, and the reason is the
contract (#800). It arrived in the same drift and the first answer was to
serve it: its response carries no spot field at all, so the body could not have
promised a client anything about billing or interruption, which are the two
halves of "spot" an emulator cannot honour.What refuses it is measured. The operation is in the Go SDK and not in the
document Scaleway publishes —contracts/scaleway.jsonknowsspot_infoon
aServerTypeand no such operation — and there is no exemption in that
direction, deliberately:contract.CheckRoutesanswers "the API defines no
such operation", and a route whose response nothing can validate breaks one of
the three mechanisms this project rests on.coverage/contract-only.json
covers the opposite case, a document the SDK never wrapped.So it waits for the document, and the nightly scan brings it back the day that
moves. No official client reaches it either:scw instance serverhas no spot
subcommand. -
The API gateway's own route:
scw/Client.GetAPIMetadata(#776).
GET /metadataanswers{"platform", "partition", "domain"}, which the SDK
reads to build asrn://<service>.<domain>/…client-side for every product
that gained one.Measured 2026-09-14 through
feint proxy --record: oneapplyplusdestroy
of the conformance fixture sent 148 of these and this emulator answered 404
to every one — 148 of that run's 169 refusals. Nothing failed, because the
SDK discards the error and the SRN stays empty. That is luck rather than a
decision, and the shape of #257 exactly.The instrument had to move before the handler could.
Route.Operation
must name an operation the drift scan finds, and the scan walked
api/<product>/<version>only, whereClient.GetAPIMetadatais not — so the
route would have been an orphan, and all three baselines carry zero. The scan
now reads the gateway package on a criterion of its own: a method that
builds a request rather than one that carries someone else's. Reusing the
product walk's matcher was measured wrong in both directions — it found
Client.Do, the transport every call goes through, andConfig.String, a
formatter, and missed the one operation that matters.The contract describes it too, through
--gateway, the same escape the
refusal shape already uses: Scaleway publishes one document per product and
the gateway is not one. So the response is validated rather than exempted.The three values are the real cloud's, read on a read-only shot at an fr-par
account and committed ascorpus/scaleway/scw-gateway.jsonl. A committed
corpus is sanitised, so it grades the status and the field tree and not the
values; those are held by the contract fragment and by a test that writes them
out rather than reading them from the handler. -
A server gives up a private interface:
instance/v2alpha1/API.DetachServerPrivateNetworkInterface.
POST /instance/v2alpha1/zones/{zone}/servers/{id}/detach-private-network-interface
dissociates an interface from its server and answers the server. Terraform
provider 2.83.0 sends it before every delete of an interface
(fix(instance): detach private network interface before deleting it,
upstream #4354), and a 501 there failed everyterraform destroy.The refusal it leaves said "no client this project drives reaches for these
operations" — a sentence that already carried one expiry date, from 2.81.0,
and has now collected a second. Only the detach is withdrawn: a recording of a
full apply plus destroy shows it twice and the symmetric
AttachServerPrivateNetworkInterfacenever, so that one keeps the reason.It dissociates rather than deletes, and that is measured. The client's next
two calls are a read that must answer 200 and aDELETEit sends itself;
deleting at the detach answers the read with a 404, the client skips its
delete, and the suite passes for a reason that is not the stated one. Both
behaviours are green — only one matches what the client does.Serving it needed the server rendered in v2alpha1's own shape, which this pack
did not have. Three vocabularies differ frominstance/v1and the contract
check caught each: a server isstartedwhere v1 saysrunning, an interface
isavailablewhere a server isstarted, and a volume v1 spells
sbs_volumeissbs.b_ssdandunifiedhave no v2alpha1 spelling and
answerunknown_volume_typerather than a guessed equivalence. -
A load balancer can be migrated:
lb/v1/ZonedAPI.MigrateLB(#762).
POST /lb/v1/zones/{zone}/lbs/{id}/migrateaccepts a new offer and the read
that follows shows it. It is the one Day-2 action this API has, and no client
could exercise it: the real account bills the only migration it accepts, so an
example could not take that decision on somebody's behalf.The route was declined on the ground that answering "would confirm a resize
nothing performed". That argument did not survive its own neighbours:
UpdateServeralready accepts a newcommercial_typeand stores it, and
CreateLBalready accepts any type string. The reason is withdrawn rather
than reworded, and the same withdrawal was applied tovpcgw/v2where the
gateway upgrade cited it.One divergence, in
docs/limits.md: the real API refuses a migration to the
type the balancer already carries, with 400invalid_arguments. This does
not, for the reasonCreateLBalready carries —corpus:checkreplays
recordings whose values are synthetic, so a refusal keyed on a value turns a
recorded 200 into a 400 on replay. -
The volume catalogue is served:
instance/v1/API.ListVolumesTypes(#758).
GET /instance/v1/zones/{zone}/products/volumesanswers the two types this
emulator mints,l_ssdandscratch, with the display names, snapshot
capability and size constraints a realfr-par-1returned. A client that
reads the menu before it creates no longer meets a 501 there.The route was declined until now, and the decline named its own expiry: "no
recording of /products/volumes exists in corpus/ [...] the day that recording
lands, this becomes a serve". #758 landed it, as afeint proxytranscript
of a real account taken on 2026-09-10. Every figure in the answer is a line of
that transcript, held by a test.The catalogue and what
POST /volumesaccepts are now derived from one table,
so the menu cannot offer a type the create refuses.
Proved against
The clients
| Client | Version | Pinned in | Drives, in CI |
|---|---|---|---|
scw
| 2.56.3 | .github/workflows/conformance.yml (SCW_VERSION)
| Scaleway |
| Terraform | 1.13.3 | .github/workflows/conformance.yml (TERRAFORM_VERSION)
| Exoscale, Outscale, Scaleway |
| OpenTofu | 1.12.5 | .github/workflows/conformance.yml (TOFU_VERSION)
| Exoscale, Outscale, Scaleway |
octl
| 0.0.32 | .github/workflows/conformance.yml (OCTL_VERSION)
| Outscale |
exo
| 1.95.6 | .github/workflows/conformance.yml (EXO_VERSION)
| Exoscale |
The Terraform providers
The client above is the engine; what answers this emulator is the provider.
Each row is one required_providers entry, read where it is written.
| Fixture or stack | Provider | Constraint | What that is worth | Applied in CI |
|---|---|---|---|---|
tools/conformance/faults
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
tools/conformance/outscale/terraform
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
tools/conformance/outscale/terraform-doorway
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
tools/conformance/scaleway/terraform
| scaleway/scaleway
| 2.83.0
| exact: the version that answered | yes |
examples/stacks/exoscale
| exoscale/exoscale
| 0.72.0
| exact: the version that answered | yes |
examples/stacks/outscale
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
examples/stacks/outscale/modules/net
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
examples/stacks/scaleway
| scaleway/scaleway
| 2.83.0
| exact: the version that answered | yes |
examples/quickstart/outscale
| outscale/outscale
| ~> 1.7
| constraint: resolved fresh on each run, so the version that answered is not knowable here | yes |
examples/quickstart/scaleway
| scaleway/scaleway
| 2.83.0
| exact: the version that answered | yes |
Read the third column narrowly, because it is the one a consumer pins against.
An exact constraint names the version that answered. A constraint is
resolved by terraform init -upgrade on the runner, every run, so the version
that answered is not knowable from this repository — it is a floor, not a
proof. Not pinned is not pinned: nothing here says which version ran, and
no number is invented to fill the cell.
Every stack under examples/stacks/ is applied on every pull request.
Generated from the conformance workflow and the fixtures at this tag.
A newer client is not known to work until the pin moves and the suite
passes on it: docs/clients.md says what each cell is worth.