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" + } + ] +}