From 0ac7e3145a9730532d149c2049a76c7a3934ae3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Tue, 15 Sep 2026 09:10:11 +0200 Subject: [PATCH] feat(scaleway): the API gateway's own route, and a scan that can name it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /metadata` is what the SDK asks before building a `srn://…` client-side, and this emulator answered 404 to it. Measured through `feint proxy --record`: one apply plus destroy of the conformance fixture sent **148 of them, all refused** — 148 of that run's 169 refusals (#776). Nothing failed, because the callers discard the error and the SRN stays empty. That is luck rather than a decision, and the shape of #257 exactly. THE INSTRUMENT MOVED BEFORE THE HANDLER COULD `Route.Operation` must name an operation the drift scan finds, and the scan walked `api//` only. `Client.GetAPIMetadata` lives in `scw/` and hangs off `*Client`, so it failed two of the walk's criteria at once: a route declaring it would have been an orphan, and all three baselines carry zero. The scan now reads the gateway package on a criterion of its own. Reusing issuesRequest was measured wrong IN BOTH DIRECTIONS, which is the part worth recording: that matcher is written for the product packages, where the request literal is a QUALIFIED scw.ScalewayRequest, delegation targets an unexported method, and the transport is `recv.client.Do`. Inside `scw/` the literal is unqualified, `Do` is exported and called on the receiver, and there is no `client` field — so it returned `Client.Do`, the transport every call goes through, and `Config.String`, a formatter, and missed the one operation that matters. Building a request is the honest criterion: `Do` receives one, `String` never sees one. It answers exactly one method today and will answer a second the day Scaleway adds one, which is the scan's job rather than a leak. My own pre-measurement was wrong too, and the coincidence was nasty: a grep for `.Do(` or `ScalewayRequest{` counted "83 methods, one issues a request". The right answer is also one — a different one. THE CONTRACT DESCRIBES IT RATHER THAN EXEMPTING IT TestEveryRouteMatchesItsContract requires the operation to exist in the document, and Scaleway publishes one document per product. Rather than weaken that guard, the extraction grew a `--gateway` fragment, the same escape `--error-shape` already uses for a shape the SDK reads and the documents do not carry. So the response is validated, and a collision with an extracted name is refused rather than silently won. THE VALUES ARE THE REAL CLOUD'S A read-only shot at an fr-par account, 2026-09-14 and again 2026-09-15, no resource created, committed as corpus/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. THREE GUARDS SAID NO, AND EACH WAS RIGHT - TestScanProductAndVersionAreCarried: an operation with no version. The gateway declares none, on scan_outscale.go's precedent — so the test now asserts BOTH halves: a product operation carries a version, the gateway carries none. - TestEveryRouteFallsUnderADeclaredPrefix: `/metadata` under no declared prefix, which would answer a near miss in net/http's plain text. - TestEveryDeclaredPrefixLooksLikeAScalewayProduct: `/metadata` is not shaped like `//v/`. The exception is named rather than admitted by a looser regex, and held from the other side: if the path leaves the list, the test says the exception now permits nothing. PROVEN - `drift:check` green, `scw: 1 implemented, 0 declined, 0 unknown (of 1)`. - `corpus:check` replays the recording. - conformance:leg -- probe, scw-cli and fields: green. `probe` drives the route from its API description, which no unit test does; `fields` is the only leg where the omission gate judges. - Six mutations, each compiling and each biting, including the one that changes the domain — every SRN a client builds from it would be silently wrong. - `mise run prepush` green; evidence.json records it driven, `contract: clean`. ONE THING I CANNOT EXPLAIN `falsify -- recorded-scaleway-fields` failed once, in a way its own five mutations did not: they all bit, and the post-restoration run of the whole tree came back red. It has been green on the four runs since. `go test ./...` passes in the repository, and a byte-for-byte reproduction of the copy falsify builds — same exclusions — passes too. So the cause is not identified, and this says so rather than calling it flaky on one green. Assisted-by: Claude Code (claude-opus-5) --- CHANGELOG.fr.md | 32 +++++ CHANGELOG.md | 31 ++++ README.fr.md | 4 +- README.md | 12 +- contracts/scaleway.json | 133 ++++++++++++++++++ corpus/accepted.json | 84 ++++++----- corpus/scaleway/scw-gateway.jsonl | 1 + coverage/evidence.json | 11 +- coverage/scaleway-baseline.json | 1 + coverage/scaleway-coverage.json | 23 ++- docs/confidence.md | 2 +- docs/limits-acks.json | 8 +- docs/limits.md | 54 ++++--- docs/routes.md | 14 +- internal/drift/scan_scaleway.go | 123 ++++++++++++++++ internal/drift/scan_scaleway_test.go | 63 ++++++++- .../testdata/fake-scaleway-sdk/scw/client.go | 59 ++++++++ internal/providers/scaleway/gateway.go | 79 +++++++++++ internal/providers/scaleway/gateway_test.go | 87 ++++++++++++ internal/providers/scaleway/pack.go | 10 ++ internal/providers/scaleway/prefixes_test.go | 20 +++ tools/contract/extract-openapi.py | 33 +++++ tools/contract/scaleway-gateway.yaml | 46 ++++++ tools/contract/update.sh | 3 +- tools/drift/gate.sh | 9 +- .../the-gateway-is-served-and-scanned.json | 52 +++++++ 26 files changed, 901 insertions(+), 93 deletions(-) create mode 100644 corpus/scaleway/scw-gateway.jsonl create mode 100644 internal/drift/testdata/fake-scaleway-sdk/scw/client.go create mode 100644 internal/providers/scaleway/gateway.go create mode 100644 internal/providers/scaleway/gateway_test.go create mode 100644 tools/contract/scaleway-gateway.yaml create mode 100644 tools/falsify/specs/the-gateway-is-served-and-scanned.json diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 3e26cecf..3875ad08 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,38 @@ change ni l'un ni l'autre a sa place dans `git log`. ### Ajouté +- **La route propre à la passerelle d'API : `scw/Client.GetAPIMetadata`** + (#776). `GET /metadata` répond `{"platform", "partition", "domain"}`, que le + SDK lit pour construire côté client un `srn://./…` pour chaque + produit qui en a reçu un. + + Mesuré le 2026-09-14 avec `feint proxy --record` : un `apply` puis un + `destroy` de la fixture de conformance en ont envoyé **148, et cet émulateur a + répondu 404 à chacun**, soit 148 des 169 refus de cette exécution. Rien + n'échouait, parce que le SDK jette l'erreur et que le SRN reste vide. C'est de + la chance, pas une décision, et c'est exactement la forme de #257. + + **L'instrument devait bouger avant le gestionnaire.** `Route.Operation` doit + nommer une opération que le scan de dérive trouve, et ce scan ne lisait que + `api//`, où `Client.GetAPIMetadata` ne se trouve pas : la + route serait devenue orpheline, alors que les trois baselines en portent zéro. + Le scan lit désormais le paquet de la passerelle, avec un critère propre : une + méthode qui **construit** une requête, et non une qui transporte celle d'un + autre. Réutiliser le filtre des produits était faux dans les deux sens, et + c'est mesuré : il trouvait `Client.Do`, le transport que toute requête + traverse, et `Config.String`, un formateur, et manquait la seule opération qui + compte. + + Le contrat la décrit aussi, par `--gateway`, la même échappatoire que la forme + de refus utilise déjà : Scaleway publie un document par produit et la + passerelle n'en est pas un. La réponse est donc **validée**, pas exemptée. + + Les trois valeurs sont celles du vrai cloud, lues lors d'un tir en lecture + seule sur un compte fr-par et versées dans + `corpus/scaleway/scw-gateway.jsonl`. Un corpus commité est anonymisé : il note + le statut et l'arbre des champs, jamais les valeurs, que tiennent le fragment + de contrat et un test qui les écrit au lieu de les relire du gestionnaire. + - **Un serveur rend une interface privée : `instance/v2alpha1/API.DetachServerPrivateNetworkInterface`.** `POST /instance/v2alpha1/zones/{zone}/servers/{id}/detach-private-network-interface` dissocie une interface de son serveur et répond le serveur. Le provider diff --git a/CHANGELOG.md b/CHANGELOG.md index 466de9c0..d77e956c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,37 @@ what this project is judged on: **a response shape a client can observe**, and ### Added +- **The API gateway's own route: `scw/Client.GetAPIMetadata`** (#776). + `GET /metadata` answers `{"platform", "partition", "domain"}`, which the SDK + reads to build a `srn://./…` client-side for every product + that gained one. + + Measured 2026-09-14 through `feint proxy --record`: one `apply` plus `destroy` + 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//` only, where `Client.GetAPIMetadata` is 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, and `Config.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 as `corpus/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 diff --git a/README.fr.md b/README.fr.md index 807f0441..d52d922d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -29,7 +29,7 @@ > [!IMPORTANT] > **Ce qu'on peut pointer vers cet émulateur, et ce qu'on ne peut pas.** > -> **Prouvé** : 377 des 398 opérations montées sont pilotées par un vrai client, à chaque pull request. `scw`, `octl`, `exo`, Terraform et OpenTofu tournent contre l'émulateur en CI, et les machines démarrent réellement : connexion ssh sur le compte par défaut de chaque provider, subnets isolés, pare-feu qui filtre. La chaîne complète est décrite dans [docs/conformance.md](docs/conformance.md). +> **Prouvé** : 378 des 399 opérations montées sont pilotées par un vrai client, à chaque pull request. `scw`, `octl`, `exo`, Terraform et OpenTofu tournent contre l'émulateur en CI, et les machines démarrent réellement : connexion ssh sur le compte par défaut de chaque provider, subnets isolés, pare-feu qui filtre. La chaîne complète est décrite dans [docs/conformance.md](docs/conformance.md). > > **Pas prouvé** : quotas, prix, capacité réelle, validation des identifiants, authentification, cohérence à terme. Les 57 sections de [docs/limits.md](docs/limits.md) disent chacune ce qu'elle coûte. Un émulateur avec un seul compte implicite et aucune grille tarifaire devrait inventer ces chiffres, et quelqu'un agirait dessus. > @@ -140,7 +140,7 @@ valider](docs/confidence.md) dit où cette affirmation s'arrête. ```bash feint serve # feint dev listening on 127.0.0.1:4599 -# scaleway 194 routes +# scaleway 195 routes # outscale 100 routes # exoscale 104 routes # machines none diff --git a/README.md b/README.md index f60ee28b..be85fffe 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ > [!IMPORTANT] > **What is safe to point at this emulator, and what is not.** > -> **Proven**: 377 of the 398 mounted operations are driven by a real client, on every pull request: each pack's own official CLI, and an infrastructure engine wherever a pack admits one, all of them running against the emulator in CI, and machines really boot: an ssh login on each provider's own default account, isolated subnets, a firewall that filters. The whole chain is described in [docs/conformance.md](docs/conformance.md). +> **Proven**: 378 of the 399 mounted operations are driven by a real client, on every pull request: each pack's own official CLI, and an infrastructure engine wherever a pack admits one, all of them running against the emulator in CI, and machines really boot: an ssh login on each provider's own default account, isolated subnets, a firewall that filters. The whole chain is described in [docs/conformance.md](docs/conformance.md). > > **Not proven**: quotas, prices, real capacity, identifier validation, authentication, eventual consistency. The 57 sections of [docs/limits.md](docs/limits.md) each say what one costs. An emulator with a single implicit account and no price list would have to invent those figures, and somebody would act on them. > @@ -129,7 +129,7 @@ that claim stops. ```bash feint serve # feint dev listening on 127.0.0.1:4599 -# scaleway 194 routes +# scaleway 195 routes # outscale 100 routes # exoscale 104 routes # machines none @@ -856,13 +856,13 @@ same block is published in the body of every release. -398 routes are mounted across the three packs. The tables count *upstream operations* +399 routes are mounted across the three packs. The tables count *upstream operations* rather than routes: what the provider's own SDK or API description declares, against what this emulator serves, declines on purpose, or has not triaged yet. #### Scaleway -194 routes mounted. Of the 536 operations upstream declares: 36% served, +195 routes mounted. Of the 537 operations upstream declares: 36% served, 63% declined on purpose, 0% untriaged. | Group | Served | Declined | Untriaged | Upstream | @@ -875,8 +875,8 @@ what this emulator serves, declines on purpose, or has not triaged yet. | `vpc` | 19 | 18 | 0 | 37 | | `block` | 22 | 5 | 0 | 27 | | `account` | 5 | 7 | 0 | 12 | -| *… 2 smaller groups* | 10 | 8 | 0 | 18 | -| **Total** | **194** | **342** | **0** | **536** | +| *… 3 smaller groups* | 11 | 8 | 0 | 19 | +| **Total** | **195** | **342** | **0** | **537** | #### Exoscale diff --git a/contracts/scaleway.json b/contracts/scaleway.json index cde45995..79343416 100644 --- a/contracts/scaleway.json +++ b/contracts/scaleway.json @@ -3581,6 +3581,12 @@ } } }, + "scw.GetAPIMetadata": { + "path": "/metadata", + "method": "GET", + "group": "Gateway", + "response": "scw.ApiMetadata" + }, "vpc/v2.AddPrivateNetworkObjectStoragePrivateAccess": { "path": "/vpc/v2/regions/{region}/object-storage-private-access/{vpc_id}/private-networks", "method": "POST", @@ -4393,6 +4399,9 @@ "qualification": { "ref": "account/v3.scaleway.account.v3.Qualification" }, + "srn": { + "type": "string" + }, "status": { "ref": "account/v3.scaleway.account.v3.Project.Status" }, @@ -6283,6 +6292,9 @@ "size": { "type": "integer" }, + "srn": { + "type": "string" + }, "status": { "ref": "block/v1.scaleway.block.v1.Snapshot.Status" }, @@ -6378,6 +6390,9 @@ "specs": { "ref": "block/v1.scaleway.block.v1.VolumeSpecifications" }, + "srn": { + "type": "string" + }, "status": { "ref": "block/v1.scaleway.block.v1.Volume.Status" }, @@ -6438,6 +6453,9 @@ "specs": { "ref": "block/v1.scaleway.block.v1.VolumeSpecifications" }, + "srn": { + "type": "string" + }, "type": { "type": "string" }, @@ -7412,6 +7430,9 @@ "secret_key": { "type": "string" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" }, @@ -7450,6 +7471,9 @@ "organization_id": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -7672,6 +7696,9 @@ "organization_id": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -7718,6 +7745,9 @@ "jti": { "type": "string" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" }, @@ -8072,6 +8102,9 @@ "resource_type": { "ref": "iam/v1alpha1.scaleway.iam.v1alpha1.Log.ResourceType" }, + "srn": { + "type": "string" + }, "user_agent": { "type": "string" } @@ -8228,6 +8261,9 @@ "organization_id": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -8266,6 +8302,9 @@ "pretty_name": { "type": "string" }, + "srn": { + "type": "string" + }, "unit": { "type": "string" }, @@ -8317,6 +8356,9 @@ }, "project_ids": { "type": "array" + }, + "srn": { + "type": "string" } } }, @@ -8364,6 +8406,9 @@ "public_key": { "type": "string" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" } @@ -8384,6 +8429,9 @@ "single_sign_on_url": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "iam/v1alpha1.scaleway.iam.v1alpha1.Saml.Status" } @@ -8426,6 +8474,9 @@ "origin": { "ref": "iam/v1alpha1.scaleway.iam.v1alpha1.SamlCertificate.Origin" }, + "srn": { + "type": "string" + }, "type": { "ref": "iam/v1alpha1.scaleway.iam.v1alpha1.SamlCertificate.Type" } @@ -8530,6 +8581,9 @@ "phone_number": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "iam/v1alpha1.scaleway.iam.v1alpha1.User.Status" }, @@ -12239,6 +12293,9 @@ "project_id": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -12289,6 +12346,9 @@ "server_id": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.PrivateNetworkInterface.Status" }, @@ -12347,6 +12407,9 @@ "server_id": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.PrivateNetworkInterface.Status" }, @@ -12456,6 +12519,9 @@ "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.SecurityGroupRule" } }, + "srn": { + "type": "string" + }, "stateless": { "type": "boolean" }, @@ -12614,6 +12680,9 @@ "project_id": { "type": "string" }, + "srn": { + "type": "string" + }, "stateless": { "type": "boolean" }, @@ -12676,6 +12745,9 @@ "server_type": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.Server.Status" }, @@ -12909,6 +12981,9 @@ "server_type": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.Server.Status" }, @@ -13074,6 +13149,9 @@ "server_type": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -13139,6 +13217,9 @@ "server_type": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -13329,6 +13410,9 @@ "source": { "ref": "ipam/v1.scaleway.ipam.v1.Source" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -15455,6 +15539,25 @@ } } }, + "scw.ApiMetadata": { + "closed": false, + "required": [ + "domain", + "partition", + "platform" + ], + "properties": { + "domain": { + "type": "string" + }, + "partition": { + "type": "string" + }, + "platform": { + "type": "string" + } + } + }, "scw.ResponseError": { "closed": false, "required": [ @@ -15886,6 +15989,9 @@ "source": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -16091,6 +16197,9 @@ "region": { "type": "string" }, + "srn": { + "type": "string" + }, "subnets": { "type": "array", "items": { @@ -16141,6 +16250,9 @@ "region": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -16216,6 +16328,9 @@ "region": { "type": "string" }, + "srn": { + "type": "string" + }, "subnet": { "type": "string" }, @@ -16263,6 +16378,9 @@ "routing_enabled": { "type": "boolean" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -16301,6 +16419,9 @@ "region": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "vpc/v2.scaleway.vpc.v2.VPCConnectorStatus" }, @@ -16602,6 +16723,9 @@ "smtp_enabled": { "type": "boolean" }, + "srn": { + "type": "string" + }, "status": { "ref": "vpcgw/v2.scaleway.vpc_gw.v2.Gateway.Status" }, @@ -16667,6 +16791,9 @@ "push_default_route": { "type": "boolean" }, + "srn": { + "type": "string" + }, "status": { "ref": "vpcgw/v2.scaleway.vpc_gw.v2.GatewayNetwork.Status" }, @@ -16728,6 +16855,9 @@ "reverse": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -16879,6 +17009,9 @@ "public_port": { "type": "integer" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" }, diff --git a/corpus/accepted.json b/corpus/accepted.json index 925e6531..1bff4f7f 100644 --- a/corpus/accepted.json +++ b/corpus/accepted.json @@ -188,25 +188,19 @@ "warn_after_days": 180, "recorded": [ { - "file": "scaleway/scw-cli.jsonl", - "at": "2026-08-21", - "client": "scw 2.56.3", - "cloud": "Scaleway fr-par" - }, - { - "file": "scaleway/scw-instance.jsonl", + "file": "exoscale/exo-cli.jsonl", "at": "2026-08-21", - "client": "scw 2.56.3", - "cloud": "Scaleway fr-par" + "client": "exo 1.95.1 (egoscale v3.1.36)", + "cloud": "Exoscale ch-gva-2" }, { - "file": "scaleway/terraform.jsonl", - "at": "2026-08-21", - "client": "terraform-provider-scaleway 2.81.0", - "cloud": "Scaleway fr-par" + "file": "exoscale/exo-free-shapes.jsonl", + "at": "2026-08-24", + "client": "exo 1.95.1 (egoscale v3.1.36)", + "cloud": "Exoscale ch-gva-2" }, { - "file": "exoscale/exo-cli.jsonl", + "file": "exoscale/exo-refusals.jsonl", "at": "2026-08-21", "client": "exo 1.95.1 (egoscale v3.1.36)", "cloud": "Exoscale ch-gva-2" @@ -218,23 +212,25 @@ "cloud": "Outscale eu-west-2, no account" }, { - "file": "outscale/oapi-cli-lifecycle.jsonl", - "at": "2026-08-21", + "file": "outscale/oapi-cli-lb-shapes.jsonl", + "at": "2026-08-24", "client": "oapi-cli 0.13.0", "cloud": "Outscale cloudgouv-eu-west-1", "region": "cloudgouv-eu-west-1" }, { - "file": "scaleway/scw-refusals.jsonl", + "file": "outscale/oapi-cli-lifecycle.jsonl", "at": "2026-08-21", - "client": "scw 2.56.3", - "cloud": "Scaleway fr-par" + "client": "oapi-cli 0.13.0", + "cloud": "Outscale cloudgouv-eu-west-1", + "region": "cloudgouv-eu-west-1" }, { - "file": "exoscale/exo-refusals.jsonl", - "at": "2026-08-21", - "client": "exo 1.95.1 (egoscale v3.1.36)", - "cloud": "Exoscale ch-gva-2" + "file": "outscale/oapi-cli-machine-shapes.jsonl", + "at": "2026-08-24", + "client": "oapi-cli 0.13.0", + "cloud": "Outscale cloudgouv-eu-west-1", + "region": "cloudgouv-eu-west-1" }, { "file": "outscale/oapi-cli-refusals.jsonl", @@ -244,36 +240,46 @@ "region": "cloudgouv-eu-west-1" }, { - "file": "scaleway/scw-free-shapes.jsonl", + "file": "scaleway/scw-billed-shapes.jsonl", "at": "2026-08-24", "client": "scw 2.56.3", "cloud": "Scaleway fr-par" }, { - "file": "scaleway/scw-billed-shapes.jsonl", - "at": "2026-08-24", + "file": "scaleway/scw-cli.jsonl", + "at": "2026-08-21", "client": "scw 2.56.3", "cloud": "Scaleway fr-par" }, { - "file": "exoscale/exo-free-shapes.jsonl", + "file": "scaleway/scw-free-shapes.jsonl", "at": "2026-08-24", - "client": "exo 1.95.1 (egoscale v3.1.36)", - "cloud": "Exoscale ch-gva-2" + "client": "scw 2.56.3", + "cloud": "Scaleway fr-par" }, { - "file": "outscale/oapi-cli-machine-shapes.jsonl", - "at": "2026-08-24", - "client": "oapi-cli 0.13.0", - "cloud": "Outscale cloudgouv-eu-west-1", - "region": "cloudgouv-eu-west-1" + "file": "scaleway/scw-gateway.jsonl", + "at": "2026-09-15", + "client": "scaleway-sdk-go, the call provider 2.83.0 makes", + "cloud": "Scaleway fr-par" }, { - "file": "outscale/oapi-cli-lb-shapes.jsonl", - "at": "2026-08-24", - "client": "oapi-cli 0.13.0", - "cloud": "Outscale cloudgouv-eu-west-1", - "region": "cloudgouv-eu-west-1" + "file": "scaleway/scw-instance.jsonl", + "at": "2026-08-21", + "client": "scw 2.56.3", + "cloud": "Scaleway fr-par" + }, + { + "file": "scaleway/scw-refusals.jsonl", + "at": "2026-08-21", + "client": "scw 2.56.3", + "cloud": "Scaleway fr-par" + }, + { + "file": "scaleway/terraform.jsonl", + "at": "2026-08-21", + "client": "terraform-provider-scaleway 2.81.0", + "cloud": "Scaleway fr-par" } ], "accepted": [ diff --git a/corpus/scaleway/scw-gateway.jsonl b/corpus/scaleway/scw-gateway.jsonl new file mode 100644 index 00000000..6684e9c1 --- /dev/null +++ b/corpus/scaleway/scw-gateway.jsonl @@ -0,0 +1 @@ +{"seq":1,"t":"2020-01-01T00:00:00Z","method":"GET","path":"/metadata","host":"redacted-1","operation":"scw/Client.GetAPIMetadata","provider":"scaleway","status":200,"ms":0,"mounted":true,"req":{"headers":{"Accept-Encoding":"identity","Connection":"close","User-Agent":"scaleway-sdk-go","X-Auth-Token":"REDACTED-1"}},"res":{"headers":{"Content-Length":"59","Content-Security-Policy":"REDACTED-2","Content-Type":"application/json","Date":"redacted-5","Server":"REDACTED-3","Strict-Transport-Security":"REDACTED-4","X-Content-Type-Options":"REDACTED-5","X-Frame-Options":"REDACTED-6","X-Ratelimit-Limit":"REDACTED-7","X-Ratelimit-Remaining":"REDACTED-8","X-Ratelimit-Reset":"REDACTED-9","X-Request-Id":"00000000-0000-4000-8000-000000000001"},"body":{"domain":"redacted-2","partition":"redacted-3","platform":"redacted-4"}}} diff --git a/coverage/evidence.json b/coverage/evidence.json index 6a6e555f..b059a263 100644 --- a/coverage/evidence.json +++ b/coverage/evidence.json @@ -6,7 +6,7 @@ "none" ], "generated_from": { - "contracts": "e969cbd8c5841bb9", + "contracts": "f0d31330a2dc84b7", "shapes": "cac046c206cc9fc8", "suites": "88882d1375a101e2" }, @@ -3287,6 +3287,15 @@ "behaviour": true, "negative": true }, + "scw/Client.GetAPIMetadata": { + "driven": true, + "probed": "response", + "contract": "clean", + "dataplane": true, + "shape": "unobserved", + "behaviour": false, + "negative": false + }, "vpc/v2/API.CreatePrivateNetwork": { "driven": true, "probed": "response", diff --git a/coverage/scaleway-baseline.json b/coverage/scaleway-baseline.json index 98cc1fec..02e98107 100644 --- a/coverage/scaleway-baseline.json +++ b/coverage/scaleway-baseline.json @@ -9,6 +9,7 @@ "ipam", "lb", "marketplace", + "scw", "vpc", "vpcgw" ], diff --git a/coverage/scaleway-coverage.json b/coverage/scaleway-coverage.json index 157bd8bf..de1f497d 100644 --- a/coverage/scaleway-coverage.json +++ b/coverage/scaleway-coverage.json @@ -1,7 +1,7 @@ { "provider": "scaleway", - "total": 536, - "implemented": 194, + "total": 537, + "implemented": 195, "declined": 342, "unknown": 0, "orphans": null, @@ -162,6 +162,13 @@ } ] }, + { + "product": "scw", + "total": 1, + "implemented": 1, + "declined": 0, + "unknown": 0 + }, { "product": "vpc", "total": 37, @@ -259,6 +266,13 @@ "declined": 7, "unknown": 0 }, + { + "group": "scw", + "total": 1, + "implemented": 1, + "declined": 0, + "unknown": 0 + }, { "group": "vpc", "total": 37, @@ -3160,6 +3174,11 @@ "version": "v2", "status": "declined" }, + { + "operation": "scw/Client.GetAPIMetadata", + "product": "scw", + "status": "implemented" + }, { "operation": "vpc/v2/API.AddPrivateNetworkObjectStoragePrivateAccess", "product": "vpc", diff --git a/docs/confidence.md b/docs/confidence.md index 1f71edf3..1cd64fb0 100644 --- a/docs/confidence.md +++ b/docs/confidence.md @@ -17,7 +17,7 @@ operation has earned — the generated block below carries the count — and -**398 operations mounted, 377 driven by a real client** in the recorded run. The +**399 operations mounted, 378 driven by a real client** in the recorded run. The 21 that are not each state why at their route, and [routes.md](routes.md) prints the reason under the pack that owns it. diff --git a/docs/limits-acks.json b/docs/limits-acks.json index c5ebdddb..d2ed9750 100644 --- a/docs/limits-acks.json +++ b/docs/limits-acks.json @@ -8,6 +8,7 @@ "A declared query parameter is served or refused, never dropped — and `labels` is the refused one": "2026-08-27", "A machine's route out: which shapes reach a package repository (#507)": "2026-08-28", "A public address is the provider's value, made to answer on the host": "2026-08-27", + "A reverse that does not resolve is accepted here and refused upstream (#676)": "2026-09-04", "A run presented as local can still reach the real cloud (#280)": "2026-08-27", "An API reboot used to log `Failed to add route: file exists` for its own public /32 (#498, lifted 2026-08-27)": "2026-08-27", "An ERROR is a failure and a WARN is a decline, and one refusal was on the wrong side (#474)": "2026-08-28", @@ -16,6 +17,7 @@ "An Outscale load balancer distributes packets inside its network, and nowhere else": "2026-08-27", "An Outscale machine owns a root volume, and that volume holds no bytes": "2026-08-27", "Exoscale has one zone per process, and the reason is the client": "2026-08-27", + "Four Outscale parameters reach the API only as a payload, and that is `octl`'s gap": "2026-09-14", "Identifiers are not checked against anything": "2026-09-02", "Lifecycle transitions are immediate, unless you ask for otherwise": "2026-09-03", "Managed Kubernetes is not emulated, and a CRUD-only version is refused (#283)": "2026-08-27", @@ -25,20 +27,18 @@ "ReadTags does not list an internet service, because upstream names no type for one": "2026-08-27", "The Exoscale Terraform provider is refused, and why": "2026-08-27", "The Exoscale stack's second plan is not empty: two per-id outputs read back null at apply time (#520)": "2026-08-29", + "The Scaleway gateway's `GET /metadata` is served, and what it answers is three constants (#776)": "2026-09-15", "The catalogue is a whitelist, and its values are measured": "2026-08-27", "The contracts do not guarantee the same thing": "2026-08-27", "The cost of DNS/TLS interception, measured (#76)": "2026-08-27", "The firewall enforces, within stated bounds": "2026-08-28", "The guest's DHCP client is not how a published address reaches a machine (#587)": "2026-08-29", - "The Scaleway gateway serves `GET /metadata` and this emulator does not (#776)": "2026-09-14", "The per-parameter half: 18 Scaleway list operations, 72 parameters, each served or refused (#277)": "2026-08-29", "The station reaches an OVN private address only via the network's router, and the posted uplink routes do not go there (#496)": "2026-08-27", "The three packs hand their security groups to the runtime, within two measured bounds": "2026-08-28", - "Four Outscale parameters reach the API only as a payload, and that is `octl`'s gap": "2026-09-14", "What survives a dead emulator, in one table": "2026-08-27", "`feint images resolve` prints only what boots, and names what it left out (#476)": "2026-08-30", "`lb/v1` refusals carry an envelope the real one does not": "2026-09-03", - "`scw` 2.56.3 prints a recovered panic on every successful `lb acl delete`, and the defect is upstream (#505)": "2026-08-28", - "A reverse that does not resolve is accepted here and refused upstream (#676)": "2026-09-04" + "`scw` 2.56.3 prints a recovered panic on every successful `lb acl delete`, and the defect is upstream (#505)": "2026-08-28" } } diff --git a/docs/limits.md b/docs/limits.md index f873773f..7a74c590 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -2955,7 +2955,7 @@ proof. |---|---|--:|---| | Exoscale | `2.0.0` | 473 | *assumed* by this emulator | | Outscale | `1.42.0` | 655 | **declared** by the provider | -| Scaleway | `instance/v1, instance/v2alpha1, vpc/v2, ipam/v1, iam/v1alpha1, marketplace/v2, block/v1, block/v1alpha1, lb/v1, vpcgw/v2, account/v3, baremetal/v1` | 707 | **declared** by the provider | +| Scaleway | `instance/v1, instance/v2alpha1, vpc/v2, ipam/v1, iam/v1alpha1, marketplace/v2, block/v1, block/v1alpha1, lb/v1, vpcgw/v2, account/v3, baremetal/v1` | 708 | **declared** by the provider | **Declared** means the provider wrote `additionalProperties: false` themselves: @@ -3252,42 +3252,38 @@ choice rather than an accident: publishes none, and listing the private groups under a public label would be the same lie #271 names, pointed the other way. -## The Scaleway gateway serves `GET /metadata` and this emulator does not (#776) +## The Scaleway gateway's `GET /metadata` is served, and what it answers is three constants (#776) -The Scaleway SDK that provider **2.82.0** embeds asks the gateway for its -metadata, and uses the domain it answers to compute a `srn://…` client-side for -every product that gained one. This emulator mounts no such route. +The SDK that Terraform provider **2.82.0** and later embed asks the gateway for +its metadata, and uses the domain it answers to compute a `srn://…` client-side +for every product that gained one. This emulator mounted no such route until +2026-09-15, and answered 404: measured through `feint proxy --record`, one +`apply` plus `destroy` of the conformance fixture sent **148 of them**, which +was 148 of that run's 169 refusals. -Measured 2026-09-14, one `apply` plus `destroy` of the conformance fixture -through `feint proxy --record`: **539 exchanges over 100 paths, of which 148 are -`GET /metadata` and every one is answered 404** — 148 of the run's 169 refusals. +Nothing failed, and that was luck rather than a decision: the callers discard +the error, so the SRN stayed empty rather than wrong. -Nothing fails, and that is luck rather than a decision. `scw/client.go` reads the -metadata and its callers discard the error: - -```go -apiMetadata, err := s.client.GetAPIMetadata() -if err == nil { - resp.setSRN(apiMetadata.Domain) -} -``` - -So the SRN stays empty and the apply completes. A caller that stops ignoring it -turns this into a failure with no change on this side, which is the shape of -#257 exactly. - -What the real cloud answers is known rather than guessed — a read-only shot -against a real `fr-par` account the same day, no resource created: +**What is served now, and what it costs.** Three constants: ``` -GET https://api.scaleway.com/metadata -> 200 +GET /metadata -> 200 {"platform": "external", "partition": "scw", "domain": "scw.eu"} ``` -It is not mounted yet because `Route.Operation` must name an operation the drift -scan finds, and that scan walks `api//` only, where -`GetAPIMetadata` is not. A route declaring it becomes an orphan, and all three -baselines carry zero. The instrument gets decided before the handler: #776. +They are the real cloud's own answer, read on a read-only shot at an `fr-par` +account on 2026-09-14 and again on 2026-09-15, and committed as +`corpus/scaleway/scw-gateway.jsonl`. **They are constants, and that is the +limit**: the real gateway describes the platform a caller reached, so an +account on a different platform or partition would be told this one. Every +account this project can reach answers these three, and an emulator serving one +platform cannot honestly vary them — but a reader building a multi-platform +expectation on this route would be building it on a fixture. + +The corpus grades the shape rather than the values, because a committed corpus +is sanitised: it keeps the status, the field tree and the types, and replaces +every value with a synthetic one. The values are held by +`tools/contract/scaleway-gateway.yaml` and by a test that writes them out. ## The per-parameter half: 18 Scaleway list operations, 72 parameters, each served or refused (#277) diff --git a/docs/routes.md b/docs/routes.md index 3e7d4b77..84523574 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -24,7 +24,7 @@ up to. -398 operations served across 3 packs, counted from the record of the last +399 operations served across 3 packs, counted from the record of the last recorded conformance run (machines: incus, none). Reproduce it yourself, offline, from the committed artefact: @@ -43,8 +43,8 @@ a workflow. None of them opens a socket. |---|---|---|---|---|---|---|---|---| | Exoscale | 104 | 88 % (91) | 92 % (96) | 92 % (96) | 88 % (91) | 32 % (33) | 80 % (83) | 17 % (18) | | Outscale | 100 | 100 % (100) | 100 % (100) | 100 % (100) | 100 % (100) | 94 % (94) | 89 % (89) | 96 % (96) | -| Scaleway | 194 | 96 % (186) | 98 % (191) | 100 % (194) | 96 % (186) | 52 % (100) | 89 % (173) | 73 % (141) | -| **All three** | 398 | 95 % (377) | 97 % (387) | 98 % (390) | 95 % (377) | 57 % (227) | 87 % (345) | 64 % (255) | +| Scaleway | 195 | 96 % (187) | 98 % (192) | 100 % (195) | 96 % (187) | 51 % (100) | 89 % (173) | 72 % (141) | +| **All three** | 399 | 95 % (378) | 97 % (388) | 98 % (391) | 95 % (378) | 57 % (227) | 86 % (345) | 64 % (255) | What each axis says, one line each. They are independent and are never added into one number: none of them implies another, and an operation can be driven @@ -81,7 +81,7 @@ span that still cannot attribute a touch says how many it lost (#398). -400 routes across 3 packs. Every route names the upstream operation it +401 routes across 3 packs. Every route names the upstream operation it stands for, in the provider's own spelling: that name is what the drift scan matches against the upstream SDK, so a route that renames it stops being counted. @@ -348,6 +348,12 @@ disappears from the suite. |---|---|---|---| | `GET` | `/marketplace/v2/local-images` | `marketplace/v2/API.ListLocalImages` | `client` `contract` `shape` `runtime` `probe` | +### `scw` + +| Method | Path | Upstream operation | Proven by | +|---|---|---|---| +| `GET` | `/metadata` | `scw/Client.GetAPIMetadata` | `client` `contract` `runtime` `probe` | + ### `vpc` | Method | Path | Upstream operation | Proven by | diff --git a/internal/drift/scan_scaleway.go b/internal/drift/scan_scaleway.go index badcbf5e..d5be0d9d 100644 --- a/internal/drift/scan_scaleway.go +++ b/internal/drift/scan_scaleway.go @@ -50,10 +50,88 @@ func ScanScalewaySDK(root string) ([]Operation, error) { } } + gateway, err := scanGatewayDir(filepath.Join(root, "scw")) + if err != nil { + return nil, err + } + ops = append(ops, gateway...) + sort.Slice(ops, func(i, j int) bool { return ops[i].Name < ops[j].Name }) return ops, nil } +// scanGatewayDir reads the operations the API gateway serves, which live in +// `scw/` rather than under a product. +// +// # Why the walk above is not enough +// +// The SDK that Terraform provider 2.83.0 embeds calls `GET /metadata` after +// every read of a product that carries an SRN, to build one client-side. This +// emulator answered 404 to it 148 times per apply (#776), and the route could +// not be mounted to fix that: `Route.Operation` must name an operation this scan +// finds, and `Client.GetAPIMetadata` failed TWO of its criteria — the directory, +// and a receiver that is `*Client` rather than something ending in API. +// +// # Why widening here does not widen the surface +// +// Widening the instrument that reports drift is the change CLAUDE.md warns +// about, so the criterion is what keeps this narrow rather than the directory: +// of the 83 exported methods in `scw/`, exactly one BUILDS a request, and that +// is the one this walk returns. A helper that formats a zone, parses a size or +// renders an error is not an operation and never reaches the baseline. +// +// It does NOT reuse issuesRequest, and buildsGatewayRequest says at length why: +// that matcher is written for the product packages and was measured wrong here +// in both directions. +// +// The day Scaleway adds a second gateway call, it appears here, the baseline +// disagrees, and somebody triages it. That is the mechanism working, not a leak. +// +// # The name carries no version, and that is deliberate +// +// `scw/Client.GetAPIMetadata`, on the precedent scan_outscale.go already sets +// and states: the gateway declares no API version, the path lives in the +// endpoint template, and inventing a "v1" would be a fact nobody could check. +// +// TestTheGatewayScanCountsWhatBuildsARequest fails without this. +func scanGatewayDir(dir string) ([]Operation, error) { + files, err := os.ReadDir(dir) + if err != nil { + // A checkout without scw/ is not this scan's problem to diagnose: the + // product walk above has already failed on the same root if the clone is + // broken, and an empty gateway surface is a truthful answer for an SDK + // laid out differently. + return nil, nil //nolint:nilerr // absence is not drift + } + + var ops []Operation + for _, f := range files { + if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") { + continue + } + path := filepath.Join(dir, f.Name()) + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 || !fn.Name.IsExported() { + continue + } + if !buildsGatewayRequest(fn) { + continue + } + ops = append(ops, Operation{ + Name: "scw/" + receiverName(fn.Recv.List[0].Type) + "." + fn.Name.Name, + Product: "scw", + }) + } + } + return ops, nil +} + func scanSDKDir(dir, product, version string) ([]Operation, error) { files, err := os.ReadDir(dir) if err != nil { @@ -170,6 +248,51 @@ func issuesRequest(fn *ast.FuncDecl) bool { return found } +// buildsGatewayRequest reports whether a method of `scw/` BUILDS a request, +// which is what makes it an operation rather than plumbing. +// +// issuesRequest above cannot answer this, and reusing it was wrong in both +// directions — measured, not guessed. Its three patterns are written for the +// product packages: a QUALIFIED `scw.ScalewayRequest` literal, a delegation to +// an unexported method, and `recv.client.Do`. Inside `scw/` itself the literal +// is unqualified, `Do` is exported and called on the receiver directly, and +// there is no `client` field. So it missed `Client.GetAPIMetadata`, the one +// operation this walk exists for, while accepting `Client.Do` — the transport +// every call goes through — and `Config.String`, a formatter. +// +// Building a request is the honest criterion here: `Do` RECEIVES one, +// `String` never sees one, and a method that constructs a ScalewayRequest with +// a path is addressing the gateway. It answers exactly one method today, and +// it will answer a second the day Scaleway adds one — which is the scan's job. +// +// TestTheGatewayScanCountsWhatBuildsARequest fails without the distinction, on +// both halves. +func buildsGatewayRequest(fn *ast.FuncDecl) bool { + if fn.Body == nil { + return false + } + found := false + ast.Inspect(fn.Body, func(n ast.Node) bool { + if found { + return false + } + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + // Unqualified inside the package, qualified if the walk ever reads it + // from outside. Both spellings name the same type. + switch t := lit.Type.(type) { + case *ast.Ident: + found = t.Name == "ScalewayRequest" + case *ast.SelectorExpr: + found = isRequestLiteral(t) + } + return !found + }) + return found +} + // isRequestLiteral matches the scw.ScalewayRequest composite literal every // generated method builds. func isRequestLiteral(expr ast.Expr) bool { diff --git a/internal/drift/scan_scaleway_test.go b/internal/drift/scan_scaleway_test.go index ab604c12..6f6dee6c 100644 --- a/internal/drift/scan_scaleway_test.go +++ b/internal/drift/scan_scaleway_test.go @@ -28,6 +28,9 @@ func TestScanScalewaySDK(t *testing.T) { "instance/v1/API.UpdateServer", "instance/v1/ZonedAPI.ListVolumes", "rdb/v1/API.ListInstances", + // The gateway, which lives in scw/ rather than under a product and + // carries no version — the precedent scan_outscale.go sets. + "scw/Client.GetAPIMetadata", } if !slices.Equal(names, want) { t.Fatalf("unexpected surface\n got: %v\nwant: %v", names, want) @@ -37,7 +40,10 @@ func TestScanScalewaySDK(t *testing.T) { // method and a non-API receiver are all invisible to the real API surface. // So are the two shapes of client-side convenience — a poller, and a method // composing exported calls — and a constant accessor that reaches nothing. - for _, ghost := range []string{"GhostFromAComment", "GhostFromAString", "internalHelper", "NotAnOperation", "updateServer", "WaitForServer", "ServerActionAndWait", "Zones"} { + for _, ghost := range []string{"GhostFromAComment", "GhostFromAString", "internalHelper", "NotAnOperation", "updateServer", "WaitForServer", "ServerActionAndWait", "Zones", + // The gateway's own two traps: the transport every call goes through, + // and a formatter. Reusing the product walk's matcher reported both. + "Client.Do", "Config.String"} { for _, name := range names { if strings.Contains(name, ghost) { t.Fatalf("scanner picked up %q, which is not an API operation", ghost) @@ -58,8 +64,59 @@ func TestScanProductAndVersionAreCarried(t *testing.T) { t.Fatalf("scan: %v", err) } for _, op := range ops { - if op.Product == "" || op.Version == "" { - t.Fatalf("operation %q lost its product/version: %+v", op.Name, op) + if op.Product == "" { + t.Fatalf("operation %q lost its product: %+v", op.Name, op) } + // The gateway is the exception, and it is asserted rather than skipped: + // it MUST carry no version, because it declares none and inventing one + // would be a fact nobody could check — the precedent scan_outscale.go + // sets for the same reason. Everything else must carry one. + if op.Product == "scw" { + if op.Version != "" { + t.Fatalf("the gateway operation %q carries version %q; it declares none", + op.Name, op.Version) + } + continue + } + if op.Version == "" { + t.Fatalf("operation %q lost its version: %+v", op.Name, op) + } + } +} + +// The gateway walk counts what BUILDS a request, and nothing else. +// +// Both halves, because the first version of this walk got both wrong: it reused +// issuesRequest, which is written for the product packages, and that matcher +// found `Client.Do` — the transport every call goes through — and +// `Config.String` — a formatter — while missing `Client.GetAPIMetadata`, the one +// operation the walk exists for. +// +// The fake SDK's scw/client.go carries those three shapes on purpose. +func TestTheGatewayScanCountsWhatBuildsARequest(t *testing.T) { + ops, err := drift.ScanScalewaySDK(filepath.Join("testdata", "fake-scaleway-sdk")) + if err != nil { + t.Fatalf("scan: %v", err) + } + + gateway := make([]string, 0, 1) + for _, op := range ops { + if strings.HasPrefix(op.Name, "scw/") { + gateway = append(gateway, op.Name) + if op.Product != "scw" { + t.Errorf("%s carries product %q, want scw", op.Name, op.Product) + } + // The gateway declares no API version, and inventing one would be a + // fact nobody could check — the precedent scan_outscale.go states. + if op.Version != "" { + t.Errorf("%s carries version %q; the gateway declares none", op.Name, op.Version) + } + } + } + + if !slices.Equal(gateway, []string{"scw/Client.GetAPIMetadata"}) { + t.Fatalf("the gateway surface is %v, want exactly [scw/Client.GetAPIMetadata]. "+ + "Client.Do carries a request rather than building one, and Config.String "+ + "never sees one", gateway) } } diff --git a/internal/drift/testdata/fake-scaleway-sdk/scw/client.go b/internal/drift/testdata/fake-scaleway-sdk/scw/client.go new file mode 100644 index 00000000..2347f804 --- /dev/null +++ b/internal/drift/testdata/fake-scaleway-sdk/scw/client.go @@ -0,0 +1,59 @@ +// Package scw stands in for the SDK's gateway package. +// +// Three methods, and the three are the distinction the gateway walk has to +// make. They are copied in shape from the real scw/client.go, where reusing the +// product walk's issuesRequest found Do and String and missed GetAPIMetadata. +package scw + +// ScalewayRequest is the type a gateway call builds. Unqualified here, because +// this file lives in the package that declares it — which is exactly what the +// product walk's matcher cannot see. +type ScalewayRequest struct { + Method string + Path string +} + +// ApiMetadata is what the gateway answers. +type ApiMetadata struct { + Platform string + Partition string + Domain string +} + +// Client is the gateway client. +type Client struct { + apiMetadata ApiMetadata +} + +// GetAPIMetadata BUILDS a request, so it is an operation. This is the one the +// walk must find. +func (c *Client) GetAPIMetadata() (ApiMetadata, error) { + scwReq := &ScalewayRequest{ + Method: "GET", + Path: "/metadata", + } + err := c.Do(scwReq, &c.apiMetadata) + if err != nil { + return ApiMetadata{}, err + } + return c.apiMetadata, nil +} + +// Do RECEIVES a request and carries it. It is the transport every call goes +// through, and counting it would report the plumbing as an endpoint — which the +// product walk's matcher did. +func (c *Client) Do(req *ScalewayRequest, res any) error { + _ = req + _ = res + return nil +} + +// Config is configuration, and String formats it. It reaches nothing. +type Config struct { + Name string +} + +// String never sees a request, and was reported as an operation all the same. +func (c *Config) String() string { + return c.Name +} diff --git a/internal/providers/scaleway/gateway.go b/internal/providers/scaleway/gateway.go new file mode 100644 index 00000000..eedfa2f9 --- /dev/null +++ b/internal/providers/scaleway/gateway.go @@ -0,0 +1,79 @@ +package scaleway + +import ( + "net/http" + + "github.com/stephrobert/feint/internal/core/emulator" +) + +// The API gateway's own route, which belongs to no product. +// +// # Why it is here +// +// The SDK that Terraform provider 2.83.0 embeds asks the gateway for its +// metadata after every read of a product that carries an SRN, and builds a +// `srn://…` client-side from the domain it answers. This emulator mounted no +// such route: measured 2026-09-14 through `feint proxy --record`, one apply plus +// destroy of the conformance fixture sent **148 `GET /metadata`, every one +// answered 404** — 148 of that run's 169 refusals (#776). +// +// Nothing failed, and that was luck rather than a decision: scw/client.go reads +// the metadata and its callers discard the error, so the SRN stayed empty. A +// caller that stops discarding it turns this into a failure with no change on +// this side, which is the shape of #257 exactly. +// +// # The values are measured, not invented +// +// A read-only shot at a real fr-par account, 2026-09-14 and again 2026-09-15: +// +// GET https://api.scaleway.com/metadata -> 200 +// {"platform": "external", "partition": "scw", "domain": "scw.eu"} +// +// Rule 4 says the shape comes from the provider rather than from a guess, and +// these three strings are the provider's own answer. They are constants because +// they describe the platform rather than the account: the same three values come +// back for any caller, which is what makes them safe to serve without an account +// to read them from. +// +// # Which artefact holds which half, because they do not hold the same one +// +// corpus/scaleway/scw-gateway.jsonl carries the exchange and `corpus:check` +// replays it, but a committed corpus is SANITISED: it keeps the status, the +// field tree and the types, and replaces every value with a synthetic one of the +// same shape. So it proves this route answers 200 with three string fields under +// those names — and it cannot prove the values. +// +// The values live in two places that a test can fail on: +// tools/contract/scaleway-gateway.yaml, which is what the contract extraction +// reads, and TestTheGatewayAnswersTheMetadataTheSdkAsksFor, which writes the +// three strings out rather than reading them from the map below — comparing the +// answer against its own source would pass whatever both became. +// +// # The operation had to exist before the route could +// +// `Route.Operation` must name an operation the drift scan finds, and the scan +// walked `api//` only. `scw/Client.GetAPIMetadata` lives +// outside it, so the route would have been an orphan — and all three baselines +// carry zero. internal/drift/scan_scaleway.go now reads the gateway package too, +// on a criterion of its own: a method that BUILDS a request rather than one that +// carries someone else's. + +// gatewayMetadata is what api.scaleway.com answers at /metadata. Exported +// nowhere: a client reads it through the route, and a test reads it from here so +// the two cannot drift apart. +var gatewayMetadata = map[string]any{ + "platform": "external", + "partition": "scw", + "domain": "scw.eu", +} + +// metadata answers the gateway's description of the platform. +// +// No zone, no project, no authentication branch: the real gateway answers this +// to any caller that reaches it, which the recording shows, and inventing a +// refusal here would be a behaviour nobody measured. +// +// TestTheGatewayAnswersTheMetadataTheSdkAsksFor fails without this. +func (p *Pack) metadata(w http.ResponseWriter, _ *http.Request) { + emulator.WriteJSON(w, http.StatusOK, gatewayMetadata) +} diff --git a/internal/providers/scaleway/gateway_test.go b/internal/providers/scaleway/gateway_test.go new file mode 100644 index 00000000..d830b558 --- /dev/null +++ b/internal/providers/scaleway/gateway_test.go @@ -0,0 +1,87 @@ +package scaleway_test + +import ( + "net/http" + "testing" +) + +// The gateway answers the metadata the SDK asks for, with the values a real +// account answered. +// +// The SDK that Terraform provider 2.83.0 embeds calls this after every read of a +// product carrying an SRN, and builds a `srn://./…` from the +// domain. Measured 2026-09-14: one apply plus destroy sent 148 of these and this +// emulator answered 404 to every one (#776). Nothing failed, because the SDK +// discards the error — which is luck, not a decision. +// +// The three values are the recording's, not a guess: +// +// GET https://api.scaleway.com/metadata -> 200 +// {"platform": "external", "partition": "scw", "domain": "scw.eu"} +// +// corpus/scaleway/scw-gateway.jsonl carries the exchange, and `corpus:check` +// replays it on every pull request. +func TestTheGatewayAnswersTheMetadataTheSdkAsksFor(t *testing.T) { + ts := newTestServer(t) + + status, body := do(t, ts, "GET", "/metadata", "") + if status != http.StatusOK { + t.Fatalf("GET /metadata: expected 200, got %d (%v)", status, body) + } + + // Written out rather than read from the handler: comparing the answer + // against the map it is built from would pass whatever both became. These + // are the strings the real gateway sent. + for field, want := range map[string]string{ + "platform": "external", + "partition": "scw", + "domain": "scw.eu", + } { + if got, _ := body[field].(string); got != want { + t.Errorf("%s = %q, the recorded gateway answers %q", field, got, want) + } + } + if len(body) != 3 { + t.Errorf("the answer carries %d field(s): %v. The recording carries three, "+ + "and a fourth would be one this emulator invented", len(body), body) + } +} + +// The route is not zoned, not projected, and not authenticated differently from +// anything else. +// +// The real gateway answers it to any caller that reaches it — that is what the +// recording shows — so inventing a refusal here would be a behaviour nobody +// measured. This holds the shape of the route rather than its body: a second +// call answers the same thing, and no path variable makes it vary. +func TestTheGatewayMetadataDoesNotVary(t *testing.T) { + ts := newTestServer(t) + + _, first := do(t, ts, "GET", "/metadata", "") + _, second := do(t, ts, "GET", "/metadata", "") + for field := range first { + if first[field] != second[field] { + t.Errorf("%s changed between two reads: %v then %v", + field, first[field], second[field]) + } + } + + // And it is a GET: the SDK issues no other verb at this path, so anything + // else must not be served here by accident. + // + // Read with a bare client rather than the shared helper, which decodes JSON + // and fails on this answer — because an unmounted path answers net/http's + // plain-text "404 page not found". The real gateway answers a Scaleway error + // document there (`{"type":"404","message":…}`, measured 2026-09-14). That + // divergence is real and belongs to every unmounted path rather than to this + // route, so it is named in #776 and not fixed here; this assertion is about + // the verb, and it is written not to depend on a body it is not judging. + res, err := ts.Client().Post(ts.URL+"/metadata", "application/json", nil) //nolint:noctx // test client + if err != nil { + t.Fatalf("POST /metadata: %v", err) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode == http.StatusOK { + t.Error("POST /metadata answered 200; the gateway serves a read") + } +} diff --git a/internal/providers/scaleway/pack.go b/internal/providers/scaleway/pack.go index 7a6cdfcb..9e535517 100644 --- a/internal/providers/scaleway/pack.go +++ b/internal/providers/scaleway/pack.go @@ -71,6 +71,10 @@ func (p *Pack) Routes() []emulator.Route { const lbZones = "/lb/v1/zones/{zone}" const gwZones = "/vpc-gw/v2/zones/{zone}" return []emulator.Route{ + // The gateway's own route, under no product. gateway.go carries the + // recording and the reason the three values are constants. + {Method: "GET", Path: "/metadata", Operation: "scw/Client.GetAPIMetadata", Handler: p.metadata}, + {Method: "GET", Path: zones + "/servers", Operation: "instance/v1/API.ListServers", Handler: p.listServers}, {Method: "POST", Path: zones + "/servers", Operation: "instance/v1/API.CreateServer", Handler: p.createServer}, {Method: "GET", Path: zones + "/servers/{id}", Operation: "instance/v1/API.GetServer", Handler: p.getServer}, @@ -585,6 +589,12 @@ var productPrefixes = []string{ "/vpc-gw/v2/", // Since #631: Elastic Metal's listing, the one route of the product. "/baremetal/v1/", + // Since #776: the API gateway's own path, which belongs to no product. It + // is a full path rather than a prefix with a trailing slash, because the + // gateway serves exactly this one and `/metadata/anything` is not its space. + // What this list decides is the error envelope, and a client that mistypes + // here is unambiguously a Scaleway client. + "/metadata", // Published by Scaleway and not served here. They are declared so that a // client reaching one gets a Scaleway error envelope rather than net/http's diff --git a/internal/providers/scaleway/prefixes_test.go b/internal/providers/scaleway/prefixes_test.go index 35395220..f8e7415f 100644 --- a/internal/providers/scaleway/prefixes_test.go +++ b/internal/providers/scaleway/prefixes_test.go @@ -70,11 +70,31 @@ func TestEveryDeclaredPrefixLooksLikeAScalewayProduct(t *testing.T) { // space, and both answer requests in this pack's dialect. shape := regexp.MustCompile(`^/[a-z0-9-]+/v[0-9]+(alpha[0-9]*|beta[0-9]*)?/$`) + // The gateway is the one thing in this list that is not a product, and it is + // named rather than matched by a looser shape: `/metadata` is served by the + // gateway in front of every product (#776), it carries no version because + // the gateway declares none, and a regex widened to admit it would also + // admit the typos this test exists to catch. + const gateway = "/metadata" + + seenGateway := false for _, prefix := range unrouted.Prefixes() { + if prefix == gateway { + seenGateway = true + continue + } if !shape.MatchString(prefix) { t.Errorf("prefix %q is not shaped like a Scaleway product root", prefix) } } + + // And the exception is held from the other side too: if the gateway path + // ever leaves this list, the exception above stops being taken and this + // line says so, rather than quietly permitting a value nothing declares. + if !seenGateway { + t.Errorf("%q is no longer declared; the exception carved out for it above "+ + "now permits nothing and should go with it", gateway) + } } // TestAnUnservedProductAnswersInScalewaysDialect is #74's finding, driven. diff --git a/tools/contract/extract-openapi.py b/tools/contract/extract-openapi.py index 0bba9272..7d7babe7 100755 --- a/tools/contract/extract-openapi.py +++ b/tools/contract/extract-openapi.py @@ -496,6 +496,12 @@ def main() -> int: help="a YAML fragment {fields: [...]} adding fields a recording of the real cloud " "carries and the document does not declare; every entry cites its recording", ) + parser.add_argument( + "--gateway", + metavar="FILE", + help="a YAML fragment {name, operation, schemas} describing an operation the API " + "gateway serves and no per-product document declares; same reason as --error-shape", + ) args = parser.parse_args() schemas: dict = {} @@ -570,6 +576,33 @@ def main() -> int: artefact["errorSchema"] = error_schema if recorded_fields: artefact["recordedFields"] = recorded_fields + # The gateway's own operations, which belong to no product document. Folded + # in last, and a collision is refused rather than resolved: a name the + # documents already carry means the fragment has become redundant, and + # letting it win silently is how an artefact stops being what the extraction + # produces. + if args.gateway: + with open(args.gateway) as f: + fragment = yaml.safe_load(f) + name = fragment["name"] + if name in operations: + print( + f"{args.output}: {name} is already declared by a document; " + f"drop the gateway fragment", + file=sys.stderr, + ) + return 1 + operations[name] = fragment["operation"] + for schema_name, sch in (fragment.get("schemas") or {}).items(): + if schema_name in schemas: + print( + f"{args.output}: schema {schema_name} is already extracted; " + f"the gateway fragment must not redefine it", + file=sys.stderr, + ) + return 1 + schemas[schema_name] = schema_entry(sch, args) + artefact["operations"] = dict(sorted(operations.items())) artefact["schemas"] = dict(sorted(schemas.items())) diff --git a/tools/contract/scaleway-gateway.yaml b/tools/contract/scaleway-gateway.yaml new file mode 100644 index 00000000..51c609cc --- /dev/null +++ b/tools/contract/scaleway-gateway.yaml @@ -0,0 +1,46 @@ +# The API gateway's own operation, which no per-product OpenAPI document +# declares because the gateway is not a product. +# +# Scaleway publishes one schema.yml per product (tools/contract/scaleway-products.txt) +# and `GET /metadata` belongs to none of them: it is served by the gateway in +# front of all of them, and its Go entry point lives in scaleway-sdk-go/scw/client.go +# rather than under api//. Same reason scaleway-error.yaml +# exists — a shape the SDK reads and the documents do not carry. +# +# WHY IT MATTERS: the SDK that Terraform provider 2.83.0 embeds calls this after +# every read of a product carrying an SRN, to build one client-side. Measured +# 2026-09-14, one apply plus destroy of the conformance fixture sent 148 of them +# and this emulator answered 404 to every one (#776). +# +# THE SHAPE IS MEASURED, NOT TRANSCRIBED FROM A GUESS. A read-only shot at a +# real fr-par account on 2026-09-14, recorded through `feint proxy --record` and +# committed as corpus/scaleway/scw-gateway.jsonl: +# +# GET https://api.scaleway.com/metadata -> 200 +# {"platform": "external", "partition": "scw", "domain": "scw.eu"} +# +# The field names and types match scw.ApiMetadata in the SDK, which is what +# GetAPIMetadata unmarshals into. `required` carries all three because the +# recording carries all three and the struct has no omitempty: a caller reading +# Domain to build an SRN gets the empty string if one goes missing, and the SRN +# is then silently wrong rather than absent. +name: scw.GetAPIMetadata +operation: + path: /metadata + method: GET + group: Gateway + response: scw.ApiMetadata +schemas: + scw.ApiMetadata: + type: object + required: + - platform + - partition + - domain + properties: + platform: + type: string + partition: + type: string + domain: + type: string diff --git a/tools/contract/update.sh b/tools/contract/update.sh index f6da0281..2b372fdd 100755 --- a/tools/contract/update.sh +++ b/tools/contract/update.sh @@ -67,4 +67,5 @@ done < tools/contract/scaleway-products.txt # shellcheck disable=SC2086 # specs is a built argument list, and must split extract contracts/scaleway.json --provider scaleway \ --source "https://www.scaleway.com/en/developers/api//schema.yml" \ - --error-shape tools/contract/scaleway-error.yaml $specs + --error-shape tools/contract/scaleway-error.yaml \ + --gateway tools/contract/scaleway-gateway.yaml $specs diff --git a/tools/drift/gate.sh b/tools/drift/gate.sh index eae50eff..eb3a4a4d 100755 --- a/tools/drift/gate.sh +++ b/tools/drift/gate.sh @@ -37,7 +37,14 @@ OUTSCALE_SDK="${FEINT_SDK_OUTSCALE:-.upstream/osc-sdk-go}" # right after it. Adding it here is what puts the product's thirty-seven # operations in front of the triage — two served, thirty-five declined with a # reason. -SCALEWAY_PRODUCTS="${FEINT_PRODUCTS:-instance,vpc,ipam,iam,marketplace,block,lb,vpcgw,account,baremetal}" +# scw joined on 2026-09-15 with #776. It is not a product: it is the API +# gateway that sits in front of all of them, and its one operation lives in +# scaleway-sdk-go/scw/ rather than under api//. The SDK +# provider 2.83.0 embeds calls it after every read of a product carrying an +# SRN — 148 times in one apply, every one answered 404 here before the route +# was mounted. Naming it here is what puts it in front of the triage instead +# of leaving the scanned surface and the published document disagreeing. +SCALEWAY_PRODUCTS="${FEINT_PRODUCTS:-instance,vpc,ipam,iam,marketplace,block,lb,vpcgw,account,baremetal,scw}" [ -x "$FEINT" ] || { echo "no feint binary at $FEINT (build it: mise run build)" >&2; exit 1; } diff --git a/tools/falsify/specs/the-gateway-is-served-and-scanned.json b/tools/falsify/specs/the-gateway-is-served-and-scanned.json new file mode 100644 index 00000000..24837ea0 --- /dev/null +++ b/tools/falsify/specs/the-gateway-is-served-and-scanned.json @@ -0,0 +1,52 @@ +{ + "mutations": [ + { + "label": "the gateway walk takes any method, so the transport every call goes through is reported as an endpoint and the measured surface inflates", + "file": "internal/drift/scan_scaleway.go", + "package": "./internal/drift/", + "find": "\t\t\tif !buildsGatewayRequest(fn) {\n\t\t\t\tcontinue\n\t\t\t}", + "replace": "\t\t\tif !buildsGatewayRequest(fn) && len(fn.Name.Name) < 0 {\n\t\t\t\tcontinue\n\t\t\t}", + "test": "TestTheGatewayScanCountsWhatBuildsARequest" + }, + { + "label": "the walk stops reading the gateway package, so the operation the route names becomes an orphan again", + "file": "internal/drift/scan_scaleway.go", + "package": "./internal/drift/", + "find": "\tops = append(ops, gateway...)", + "replace": "\tif len(gateway) < 0 {\n\t\tops = append(ops, gateway...)\n\t}", + "test": "TestTheGatewayScanCountsWhatBuildsARequest" + }, + { + "label": "the gateway operation is given a version, which is the fact nobody can check that scan_outscale.go refuses to invent", + "file": "internal/drift/scan_scaleway.go", + "package": "./internal/drift/", + "find": "\t\t\t\tName: \"scw/\" + receiverName(fn.Recv.List[0].Type) + \".\" + fn.Name.Name,\n\t\t\t\tProduct: \"scw\",", + "replace": "\t\t\t\tName: \"scw/\" + receiverName(fn.Recv.List[0].Type) + \".\" + fn.Name.Name,\n\t\t\t\tProduct: \"scw\",\n\t\t\t\tVersion: \"v1\",", + "test": "TestScanProductAndVersionAreCarried" + }, + { + "label": "the gateway answers a domain of its own invention, and every SRN a client builds from it is silently wrong", + "file": "internal/providers/scaleway/gateway.go", + "package": "./internal/providers/scaleway/", + "find": "\t\"domain\": \"scw.eu\",", + "replace": "\t\"domain\": \"scaleway.com\",", + "test": "TestTheGatewayAnswersTheMetadataTheSdkAsksFor" + }, + { + "label": "the answer gains a field the recording does not carry, which is the invented shape rule 4 forbids", + "file": "internal/providers/scaleway/gateway.go", + "package": "./internal/providers/scaleway/", + "find": "\t\"platform\": \"external\",", + "replace": "\t\"platform\": \"external\",\n\t\"region\": \"fr-par\",", + "test": "TestTheGatewayAnswersTheMetadataTheSdkAsksFor" + }, + { + "label": "the gateway path leaves the declared space, so a client mistyping near it gets net/http's plain text instead of a Scaleway error", + "file": "internal/providers/scaleway/pack.go", + "package": "./internal/providers/scaleway/", + "find": "\t\"/metadata\",", + "replace": "\t\"/metadata-not-declared\",", + "test": "TestEveryRouteFallsUnderADeclaredPrefix" + } + ] +}