From 7449b6b1e19ced6f11d712285b6de421a5e07f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 14 Sep 2026 21:11:55 +0200 Subject: [PATCH] feat(scaleway): a server gives up a private interface, and the pin moves to 2.83.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider 2.83.0 was published at 15:06 on 2026-09-14, three hours after 2.82.0's pin landed, and every `terraform destroy` against this emulator broke: 501 Not Implemented: feint does not serve /instance/v2alpha1/zones/fr-par-1/servers/{id}/detach-private-network-interface `fix(instance): detach private network interface before deleting it` (upstream #4354) sends a call this pack declined. The drift machinery behaved the way it is built to: the failure named the missing path instead of answering something wrong. THE REASON THAT EXPIRED, AND THE HALF THAT KEEPS IT The refusal read "no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it". A sentence that already carried one expiry date has collected a second. Only the detach leaves the list. `feint proxy` recorded a full apply plus destroy: 35 v2alpha1 calls, the detach twice, and AttachServerPrivateNetworkInterface **never**. Serving a half because its other half is served would be surface nothing drives, so the reason is split rather than deleted. IT DISSOCIATES RATHER THAN DELETES, AND THAT IS MEASURED The first implementation reused releaseNIC and deleted. Every suite stayed green. The recording shows why that was luck: 200 POST …/detach-private-network-interface 200 GET …/private-network-interfaces/{id} <- still there 204 DELETE …/private-network-interfaces/{id} <- the client deletes it 404 GET …/private-network-interfaces/{id} which is the upstream fix's own name spelled out as two calls. Deleting at the detach answered the client's read with a 404, so it never sent its DELETE. Both behaviours pass the suite; one matches what the client does. The SDK said the same without any measurement: DetachAndDeletePrivateNetworkInterface exists separately, which is only meaningful if the plain one leaves the interface standing. THREE VOCABULARIES THAT ARE NOT instance/v1's Serving it needed the server rendered in v2alpha1's shape, which this pack did not have — nineteen fields, taken from contracts/scaleway.json rather than from memory. The contract check caught every divergence, and each is the same class: server status v1 `running` v2alpha1 `started` interface status `available`, never a server's word volume type v1 `sbs_volume` v2alpha1 `sbs` `b_ssd` and `unified` have no v2alpha1 spelling and answer `unknown_volume_type`, the enum's own escape hatch, rather than a guessed equivalence onto `sbs`: the products look alike and no measurement says they are the same, which is exactly the invented format rule 4 forbids. WHAT FALSIFY FOUND IN THIS WORK Three tests were green for a reason that was not theirs: - the empty-identifier test asserted "not 200", so it could not tell a 400 `invalid_arguments` from a 404, and an empty id resolves to nothing anyway. The guard was redundant; the test was weak. - the field test asserted PRESENCE, never whether a value belongs to its enum. Both vocabulary defects passed under it. - the enum test stayed green under the mutation that spoils the public interface, because the server had no public address: `public_network_interface` was null and the assertion never ran. The population trap, met in my own test. Eight mutations bite now, including one that restores the delete-at-detach. AND A GUARD I DISARMED WITHOUT NOTICING Editing the decline reason moved a fragment that tools/falsify/specs/artefact-reasons.json mutates, so a guard about artefacts printing stale reasons had quietly stopped being measured. `falsify:lint` caught it; the fragment is retargeted on the sentence as it stands today, and its three mutations bite again. PROVEN - conformance:leg -- terraform, scw-cli and **fields**: green. `fields` is the only leg where the omission gate judges, and it judged the nineteen fields. - conformance:stacks, conformance:quickstart: green. - conformance:functional under incus-ovn, **three passes**, Scaleway and Outscale, no divergence between them: machines really running on the host, public paths, firewall in both directions, cross-VPC isolation, reboots, stop/start cycles, rule sets, teardown. Incus identical before and after. - The seven falsify specs the testplan named, plus the new one. - evidence.json records the operation as driven, with `contract: clean`. Assisted-by: Claude Code (claude-opus-5) --- CHANGELOG.fr.md | 30 ++ CHANGELOG.md | 26 ++ README.fr.md | 4 +- README.md | 16 +- coverage/evidence.json | 11 +- coverage/scaleway-coverage.json | 141 ++++--- docs/clients.md | 6 +- docs/confidence.md | 2 +- docs/routes.md | 13 +- examples/quickstart/scaleway/main.tf | 2 +- examples/stacks/scaleway/main.tf | 5 +- internal/cli/docs_proved_test.go | 2 +- internal/providers/scaleway/pack.go | 19 +- .../providers/scaleway/servers_v2alpha1.go | 387 ++++++++++++++++++ .../scaleway/servers_v2alpha1_test.go | 317 ++++++++++++++ tools/conformance/scaleway/terraform/main.tf | 15 +- ...detaches-the-interface-a-client-names.json | 61 +++ tools/falsify/specs/artefact-reasons.json | 6 +- 18 files changed, 962 insertions(+), 101 deletions(-) create mode 100644 internal/providers/scaleway/servers_v2alpha1.go create mode 100644 internal/providers/scaleway/servers_v2alpha1_test.go create mode 100644 tools/falsify/specs/a-server-detaches-the-interface-a-client-names.json diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index fd5de227..3e26cecf 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -19,6 +19,36 @@ change ni l'un ni l'autre a sa place dans `git log`. ### Ajouté +- **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 + Terraform **2.83.0** l'envoie avant chaque suppression d'interface + (`fix(instance): detach private network interface before deleting it`, + upstream #4354), et un 501 à cet endroit faisait échouer tous les + `terraform destroy`. + + Le refus qu'elle quitte disait « aucun client piloté par ce projet n'atteint + ces opérations » : une phrase qui portait déjà une date de péremption, venue + de 2.81.0, et qui vient d'en collecter une seconde. Seul le détachement est + retiré : l'enregistrement d'un apply et d'un destroy complets le montre deux + fois et ne montre jamais `AttachServerPrivateNetworkInterface`, qui garde donc + la raison. + + **Elle dissocie sans supprimer, et c'est mesuré.** Les deux appels suivants du + client sont une relecture qui doit répondre 200, puis un `DELETE` qu'il envoie + lui-même ; supprimer dès le détachement répond 404 à cette relecture, le + client saute son `DELETE`, et la suite passe pour une raison qui n'est pas + celle annoncée. Les deux comportements sont verts, un seul correspond à ce que + le client fait. + + La servir demandait de rendre le serveur dans la forme propre à v2alpha1, que + ce pack n'avait pas. Trois vocabulaires diffèrent de `instance/v1` et le + contrôle de contrat a attrapé chacun : un serveur est `started` là où v1 dit + `running`, une interface est `available` là où un serveur est `started`, et un + volume que v1 écrit `sbs_volume` s'écrit `sbs`. `b_ssd` et `unified` n'ont pas + d'orthographe en v2alpha1 et répondent `unknown_volume_type` plutôt qu'une + équivalence devinée. + - **Un équilibreur de charge peut être migré : `lb/v1/ZonedAPI.MigrateLB`** (#762). `POST /lb/v1/zones/{zone}/lbs/{id}/migrate` accepte une nouvelle offre et la relecture qui suit la montre. C'est la seule action de jour 2 que porte diff --git a/CHANGELOG.md b/CHANGELOG.md index c5780943..466de9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,32 @@ what this project is judged on: **a response shape a client can observe**, and ### Added +- **A server gives up a private interface: `instance/v2alpha1/API.DetachServerPrivateNetworkInterface`.** + `POST /instance/v2alpha1/zones/{zone}/servers/{id}/detach-private-network-interface` + dissociates an interface from its server and answers the server. Terraform + provider **2.83.0** sends it before every delete of an interface + (`fix(instance): detach private network interface before deleting it`, + upstream #4354), and a 501 there failed every `terraform destroy`. + + The refusal it leaves said *"no client this project drives reaches for these + operations"* — a sentence that already carried one expiry date, from 2.81.0, + and has now collected a second. Only the detach is withdrawn: a recording of a + full apply plus destroy shows it twice and the symmetric + `AttachServerPrivateNetworkInterface` never, so that one keeps the reason. + + **It dissociates rather than deletes, and that is measured.** The client's next + two calls are a read that must answer 200 and a `DELETE` it sends itself; + deleting at the detach answers the read with a 404, the client skips its + delete, and the suite passes for a reason that is not the stated one. Both + behaviours are green — only one matches what the client does. + + Serving it needed the server rendered in v2alpha1's own shape, which this pack + did not have. Three vocabularies differ from `instance/v1` and the contract + check caught each: a server is `started` where v1 says `running`, an interface + is `available` where a server is `started`, and a volume v1 spells + `sbs_volume` is `sbs`. `b_ssd` and `unified` have no v2alpha1 spelling and + answer `unknown_volume_type` rather than a guessed equivalence. + - **A load balancer can be migrated: `lb/v1/ZonedAPI.MigrateLB`** (#762). `POST /lb/v1/zones/{zone}/lbs/{id}/migrate` accepts a new offer and the read that follows shows it. It is the one Day-2 action this API has, and no client diff --git a/README.fr.md b/README.fr.md index ba0ca96f..807f0441 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é** : 376 des 397 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é** : 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). > > **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 193 routes +# scaleway 194 routes # outscale 100 routes # exoscale 104 routes # machines none diff --git a/README.md b/README.md index 9f861983..f60ee28b 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**: 376 of the 397 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**: 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). > > **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 193 routes +# scaleway 194 routes # outscale 100 routes # exoscale 104 routes # machines none @@ -831,8 +831,8 @@ argument is that the upstream moves: | Client | Version proven in CI | Emulated provider | |---|---|---| | `scw` | 2.56.3 | Scaleway | -| Terraform | 1.13.3 with providers `outscale/outscale ~> 1.7`, `scaleway/scaleway 2.82.0` | Exoscale, Outscale, Scaleway | -| OpenTofu | 1.12.5 with providers `outscale/outscale ~> 1.7`, `scaleway/scaleway 2.82.0` | Exoscale, Outscale, Scaleway | +| Terraform | 1.13.3 with providers `outscale/outscale ~> 1.7`, `scaleway/scaleway 2.83.0` | Exoscale, Outscale, Scaleway | +| OpenTofu | 1.12.5 with providers `outscale/outscale ~> 1.7`, `scaleway/scaleway 2.83.0` | Exoscale, Outscale, Scaleway | | `octl` | 0.0.32 | Outscale | | `exo` | 1.95.6 | Exoscale | @@ -856,18 +856,18 @@ same block is published in the body of every release. -397 routes are mounted across the three packs. The tables count *upstream operations* +398 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 -193 routes mounted. Of the 536 operations upstream declares: 36% served, +194 routes mounted. Of the 536 operations upstream declares: 36% served, 63% declined on purpose, 0% untriaged. | Group | Served | Declined | Untriaged | Upstream | |---|--:|--:|--:|--:| -| `instance` | 72 | 79 | 0 | 151 | +| `instance` | 73 | 78 | 0 | 151 | | `lb` | 43 | 64 | 0 | 107 | | `iam` | 5 | 78 | 0 | 83 | | `vpcgw` | 15 | 49 | 0 | 64 | @@ -876,7 +876,7 @@ what this emulator serves, declines on purpose, or has not triaged yet. | `block` | 22 | 5 | 0 | 27 | | `account` | 5 | 7 | 0 | 12 | | *… 2 smaller groups* | 10 | 8 | 0 | 18 | -| **Total** | **193** | **343** | **0** | **536** | +| **Total** | **194** | **342** | **0** | **536** | #### Exoscale diff --git a/coverage/evidence.json b/coverage/evidence.json index 4c7cb726..6a6e555f 100644 --- a/coverage/evidence.json +++ b/coverage/evidence.json @@ -8,7 +8,7 @@ "generated_from": { "contracts": "e969cbd8c5841bb9", "shapes": "cac046c206cc9fc8", - "suites": "2ff2d523b829feed" + "suites": "88882d1375a101e2" }, "operations": { "account/v3/ProjectAPI.CreateProject": { @@ -1847,6 +1847,15 @@ "behaviour": true, "negative": false }, + "instance/v2alpha1/API.DetachServerPrivateNetworkInterface": { + "driven": true, + "probed": "response", + "contract": "clean", + "dataplane": true, + "shape": "unobserved", + "behaviour": true, + "negative": false + }, "instance/v2alpha1/API.GetPlacementGroup": { "driven": true, "probed": "response", diff --git a/coverage/scaleway-coverage.json b/coverage/scaleway-coverage.json index 34c10ed4..157bd8bf 100644 --- a/coverage/scaleway-coverage.json +++ b/coverage/scaleway-coverage.json @@ -1,8 +1,8 @@ { "provider": "scaleway", "total": 536, - "implemented": 193, - "declined": 343, + "implemented": 194, + "declined": 342, "unknown": 0, "orphans": null, "products": [ @@ -87,8 +87,8 @@ { "product": "instance", "total": 151, - "implemented": 72, - "declined": 79, + "implemented": 73, + "declined": 78, "unknown": 0, "versions": [ { @@ -101,8 +101,8 @@ { "version": "v2alpha1", "total": 73, - "implemented": 10, - "declined": 63, + "implemented": 11, + "declined": 62, "unknown": 0 } ] @@ -234,8 +234,8 @@ { "group": "instance", "total": 151, - "implemented": 72, - "declined": 79, + "implemented": 73, + "declined": 78, "unknown": 0 }, { @@ -1841,42 +1841,42 @@ { "operation": "instance/v2alpha1/API.AddSecurityGroupRules", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.AttachServerFileSystem", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.AttachServerIP", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.AttachServerPrivateNetworkInterface", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.AttachServerVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.CheckTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -1895,28 +1895,28 @@ { "operation": "instance/v2alpha1/API.CreateSecurityGroup", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.CreateServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.CreateServerFromTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.CreateTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -1935,42 +1935,42 @@ { "operation": "instance/v2alpha1/API.DeleteSecurityGroup", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DeleteSecurityGroupRules", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DeleteServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DeleteTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DeleteTemplateUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DeleteUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -1984,28 +1984,27 @@ { "operation": "instance/v2alpha1/API.DetachServerFileSystem", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DetachServerIP", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.DetachServerPrivateNetworkInterface", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", "version": "v2alpha1", - "status": "declined" + "status": "implemented" }, { "operation": "instance/v2alpha1/API.DetachServerVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -2024,56 +2023,56 @@ { "operation": "instance/v2alpha1/API.GetResourceCounts", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetSecurityGroup", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetServerCloudInit", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetTemplateCloudInit", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetTemplateUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.GetUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -2092,119 +2091,119 @@ { "operation": "instance/v2alpha1/API.ListSecurityGroups", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.ListServerTypes", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.ListServers", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.ListTemplateUserDataKeys", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.ListTemplates", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.ListUserDataKeys", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.PauseServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.RebootServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetSecurityGroupRules", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetServerCloudInit", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetServerDefaultIP", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetTemplateCloudInit", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetTemplateUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.SetUserData", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.StartServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.StopAndDeleteServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.StopServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, @@ -2223,119 +2222,119 @@ { "operation": "instance/v2alpha1/API.UpdateSecurityGroup", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.UpdateSecurityGroupRule", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.UpdateServer", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/API.UpdateTemplate", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.CreateSnapshot", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.CreateVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.DeleteSnapshot", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.DeleteVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.ExportSnapshotToObjectStorage", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.GetSnapshot", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.GetVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.ImportSnapshotFromObjectStorage", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.ListSnapshots", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.ListVolumeTypes", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.ListVolumes", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.UpdateSnapshot", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, { "operation": "instance/v2alpha1/VolumeAPI.UpdateVolume", "product": "instance", - "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + "reason": "instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "version": "v2alpha1", "status": "declined" }, diff --git a/docs/clients.md b/docs/clients.md index 064860d7..44b0c704 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -76,13 +76,13 @@ Each row is one `required_providers` entry, read where it is written. | `tools/conformance/faults` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | | `tools/conformance/outscale/terraform` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | | `tools/conformance/outscale/terraform-doorway` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | -| `tools/conformance/scaleway/terraform` | `scaleway/scaleway` | `2.82.0` | exact: the version that answered | yes | +| `tools/conformance/scaleway/terraform` | `scaleway/scaleway` | `2.83.0` | exact: the version that answered | yes | | `examples/stacks/exoscale` | `exoscale/exoscale` | `>= 0.71.0` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | | `examples/stacks/outscale` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | | `examples/stacks/outscale/modules/net` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | -| `examples/stacks/scaleway` | `scaleway/scaleway` | `2.82.0` | exact: the version that answered | yes | +| `examples/stacks/scaleway` | `scaleway/scaleway` | `2.83.0` | exact: the version that answered | yes | | `examples/quickstart/outscale` | `outscale/outscale` | `~> 1.7` | constraint: resolved fresh on each run, so the version that answered is not knowable here | yes | -| `examples/quickstart/scaleway` | `scaleway/scaleway` | `2.82.0` | exact: the version that answered | yes | +| `examples/quickstart/scaleway` | `scaleway/scaleway` | `2.83.0` | exact: the version that answered | yes | Read the third column narrowly, because it is the one a consumer pins against. An **exact** constraint names the version that answered. A **constraint** is diff --git a/docs/confidence.md b/docs/confidence.md index ec81efce..1f71edf3 100644 --- a/docs/confidence.md +++ b/docs/confidence.md @@ -17,7 +17,7 @@ operation has earned — the generated block below carries the count — and -**397 operations mounted, 376 driven by a real client** in the recorded run. The +**398 operations mounted, 377 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/routes.md b/docs/routes.md index f2651413..3e7d4b77 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -24,7 +24,7 @@ up to. -397 operations served across 3 packs, counted from the record of the last +398 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 | 193 | 96 % (185) | 98 % (190) | 100 % (193) | 96 % (185) | 52 % (100) | 89 % (172) | 73 % (141) | -| **All three** | 397 | 95 % (376) | 97 % (386) | 98 % (389) | 95 % (376) | 57 % (227) | 87 % (344) | 64 % (255) | +| 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) | 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). -399 routes across 3 packs. Every route names the upstream operation it +400 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. @@ -273,6 +273,7 @@ disappears from the suite. | `POST` | `/instance/v1/zones/{zone}/volumes` | `instance/v1/API.CreateVolume` | `client` `contract` `runtime` `probe` `behaviour` `negative` | | `POST` | `/instance/v2alpha1/zones/{zone}/placement-groups` | `instance/v2alpha1/API.CreatePlacementGroup` | `client` `contract` `runtime` `probe` `behaviour` | | `POST` | `/instance/v2alpha1/zones/{zone}/private-network-interfaces` | `instance/v2alpha1/API.CreatePrivateNetworkInterface` | `client` `contract` `runtime` `probe-refusal` `behaviour` | +| `POST` | `/instance/v2alpha1/zones/{zone}/servers/{id}/detach-private-network-interface` | `instance/v2alpha1/API.DetachServerPrivateNetworkInterface` | `client` `contract` `runtime` `probe` `behaviour` | | `PUT` | `/instance/v1/zones/{zone}/placement_groups/{id}/servers` | `instance/v1/API.SetPlacementGroupServers` | `client` `contract` `runtime` `probe-refusal` `behaviour` | | `PUT` | `/instance/v1/zones/{zone}/placement_groups/{id}` | `instance/v1/API.SetPlacementGroup` | `client` `contract` `shape` `runtime` `probe` `behaviour` | | `PUT` | `/instance/v1/zones/{zone}/security_groups/{id}/rules` | `instance/v1/API.SetSecurityGroupRules` | `client` `contract` `shape` `runtime` `probe` `behaviour` `negative` | @@ -410,7 +411,7 @@ reason that outlived its cause. - `ipam` — 1 operation — no official client calls it: the CLI has no detach subcommand, and the provider detaches by deleting the NIC that carries the address - `vpc` — 1 operation — no official client asks for the flat list: `scw vpc` has no subnet subcommand, and the Terraform provider reads the subnets a private network publishes inline through GetPrivateNetwork -### Declined on purpose (343) +### Declined on purpose (342) Operations this pack knowingly does not serve, and why. Declining is a decision the drift gate records, which is what separates it from having @@ -432,7 +433,7 @@ are in `coverage/`, one artefact per provider. - `baremetal` — 2 operations — the product's settings are per-project switches on the real account, one type in the SDK today (smtp: whether a project's servers may send mail), and nothing here sends anything - `block` — 5 operations — it moves a snapshot's bytes through Object Storage, which is not emulated because the Terraform provider builds the S3 endpoint in code: supporting it needs DNS interception and a certificate, measured in docs/limits.md - `iam` — 78 operations — the emulator accepts every credential on purpose, so serving users, policies and keys would describe an access control that nothing here enforces -- `instance` — 62 operations — instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not +- `instance` — 61 operations — instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not - `instance` — 5 operations — the metadata service answers on the link-local address 169.254.42.42, from inside the machine, to a caller that carries no credentials - `instance` — 2 operations — capacity and quotas are the provider's fleet, and a local emulator that answered would be inventing headroom a client could plan against - `instance` — 2 operations — it mounts Scaleway's File Storage product, and there is no filesystem service behind this emulator for a machine to mount diff --git a/examples/quickstart/scaleway/main.tf b/examples/quickstart/scaleway/main.tf index b0640da5..a142b908 100644 --- a/examples/quickstart/scaleway/main.tf +++ b/examples/quickstart/scaleway/main.tf @@ -21,7 +21,7 @@ terraform { # apply proves the emulator answered whatever was newest that morning and # nothing anybody can replay. `feint docs --check` refuses an applied # example that pins nothing. - version = "2.82.0" + version = "2.83.0" } } } diff --git a/examples/stacks/scaleway/main.tf b/examples/stacks/scaleway/main.tf index 30fbad54..41459ff8 100644 --- a/examples/stacks/scaleway/main.tf +++ b/examples/stacks/scaleway/main.tf @@ -43,7 +43,10 @@ terraform { # Moved to 2.82.0 on 2026-09-14, with the conformance fixture, and the # move is measured rather than assumed: that release adds an `srn` # attribute to six products, three of them served here. - version = "2.82.0" + # 2.83.0 the same day, with the conformance fixture: its + # `fix(instance): detach private network interface before deleting it` + # touches the call sequence this stack exercises. + version = "2.83.0" } } } diff --git a/internal/cli/docs_proved_test.go b/internal/cli/docs_proved_test.go index a8ebee62..5fa472a8 100644 --- a/internal/cli/docs_proved_test.go +++ b/internal/cli/docs_proved_test.go @@ -138,7 +138,7 @@ func TestTheProvedPageSeparatesAnExactPinFromAConstraintAndFromNothing(t *testin // whatever the pin became. Written here, moving the pin costs one line and // somebody has to look at this test while doing it. exact := rowContaining(t, rendered, "tools/conformance/scaleway/terraform`") - if !strings.Contains(exact, "2.82.0") || !strings.Contains(exact, "exact") { + if !strings.Contains(exact, "2.83.0") || !strings.Contains(exact, "exact") { t.Errorf("the Scaleway fixture pins one version and the page does not say so:\n %s", exact) } diff --git a/internal/providers/scaleway/pack.go b/internal/providers/scaleway/pack.go index bf72efb9..7a6cdfcb 100644 --- a/internal/providers/scaleway/pack.go +++ b/internal/providers/scaleway/pack.go @@ -159,6 +159,13 @@ func (p *Pack) Routes() []emulator.Route { // decision and it was one only while nobody called the route. {Method: "PATCH", Path: privateNICsV2Path + "/{id}", Operation: "instance/v2alpha1/API.UpdatePrivateNetworkInterface", Handler: p.updatePrivateNetworkInterface}, {Method: "DELETE", Path: privateNICsV2Path + "/{id}", Operation: "instance/v2alpha1/API.DeletePrivateNetworkInterface", Handler: p.deletePrivateNetworkInterface}, + // The detach a client performs on the SERVER, not on the interface, and + // the only operation of v2alpha1's server family this pack serves. + // Provider 2.83.0 sends it before every delete of an interface + // (`fix(instance): detach private network interface before deleting it`) + // and a 501 there fails the destroy. servers_v2alpha1.go carries the + // recording and the reason the symmetric attach stays declined. + {Method: "POST", Path: serversV2Path + "/{id}/detach-private-network-interface", Operation: "instance/v2alpha1/API.DetachServerPrivateNetworkInterface", Handler: p.detachPrivateNetworkInterface}, // Placement groups through the alpha door. Same forcing client as the // interfaces above: provider 2.81.0 moved the resource's CRUD onto @@ -1219,7 +1226,16 @@ func (p *Pack) Declined() []emulator.Decline { emulator.Because("instance/v2alpha1.DetachAndDeletePrivateNetworkInterface folds a detach and a delete into one call, and no client this project drives sends it: the recorded transcript of a full Terraform apply on provider 2.81.0 shows only ListPrivateNetworkInterfaces on this API. The two halves it folds are reachable through the routes already mounted, and the alpha version is named so a promotion to a stable instance/v2 has this decided again", "instance/v2alpha1/API.DetachAndDeletePrivateNetworkInterface"), - emulator.Because("instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not", + // The claim below is now dated twice, and the dates are the point: it held + // for the whole alpha API until provider 2.81.0 moved private network + // interfaces and placement groups onto it, and it held for the server + // family until 2.83.0 sent DetachServerPrivateNetworkInterface before + // every interface delete. That one is served and has left this list; the + // symmetric AttachServerPrivateNetworkInterface stays, because the + // recording of a full apply plus destroy on 2.83.0 shows the detach twice + // and the attach never. Serving a half because its other half is served + // is surface nothing drives. + emulator.Because("instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "instance/v2alpha1/VolumeAPI.CreateSnapshot", "instance/v2alpha1/VolumeAPI.CreateVolume", "instance/v2alpha1/VolumeAPI.DeleteSnapshot", @@ -1251,7 +1267,6 @@ func (p *Pack) Declined() []emulator.Decline { "instance/v2alpha1/API.DeleteUserData", "instance/v2alpha1/API.DetachServerFileSystem", "instance/v2alpha1/API.DetachServerIP", - "instance/v2alpha1/API.DetachServerPrivateNetworkInterface", "instance/v2alpha1/API.DetachServerVolume", "instance/v2alpha1/API.GetResourceCounts", "instance/v2alpha1/API.GetSecurityGroup", diff --git a/internal/providers/scaleway/servers_v2alpha1.go b/internal/providers/scaleway/servers_v2alpha1.go new file mode 100644 index 00000000..9103dcdb --- /dev/null +++ b/internal/providers/scaleway/servers_v2alpha1.go @@ -0,0 +1,387 @@ +package scaleway + +import ( + "net/http" + "time" + + "github.com/stephrobert/feint/internal/core/emulator" + "github.com/stephrobert/feint/internal/core/resource" +) + +// The server as instance/v2alpha1 renders it, and the one operation a client +// drives on that door. +// +// # Why this file exists +// +// The Terraform provider released 2.83.0 on 14 September 2026 carrying +// `fix(instance): detach private network interface before deleting it` +// (upstream #4354), and every `terraform destroy` against this emulator broke: +// +// Error: scaleway-sdk-go: http error 501 Not Implemented: feint does not serve +// /instance/v2alpha1/zones/fr-par-1/servers/{id}/detach-private-network-interface +// +// This is the same afternoon privatenics_v2alpha1.go describes for 2.81.0, one +// release further on, and the drift machinery behaved the same way: the failure +// named the missing path instead of answering something wrong. +// +// # What the refusal said, and why it stopped being true +// +// The operation was in Declined(), under a reason that covered the whole alpha +// API: "no client this project drives reaches for these operations — a claim +// that held for the whole API until provider 2.81.0 moved private network +// interfaces and placement groups onto it". The sentence names its own expiry +// date, and 2.83.0 reached it. +// +// Only Detach is withdrawn from that list. `feint proxy` recorded a full apply +// plus destroy on 2.83.0: 35 v2alpha1 calls, of which the detach twice, and +// AttachServerPrivateNetworkInterface **never**. Serving the symmetric half +// because it is symmetric would be surface nothing drives, which is the trade +// this repository refuses on purpose — so the reason is split rather than +// deleted, and Attach keeps it. +// +// # What a detach does, and what it does not +// +// It dissociates; it does not delete. That is measured rather than reasoned, +// and the first version of this file got it wrong in the direction that still +// passed every suite. `feint proxy` on 2.83.0, four calls in order: +// +// 200 POST …/servers/{id}/detach-private-network-interface +// 200 GET …/private-network-interfaces/{id} <- still there +// 204 DELETE …/private-network-interfaces/{id} <- the client deletes it +// 404 GET …/private-network-interfaces/{id} +// +// which is `detach private network interface before deleting it` spelled out as +// two calls. Deleting at the detach — what reusing releaseNIC did — answered the +// client's read with a 404, so it never sent its own DELETE, and the leg went +// green for a reason that was not the stated one. Both behaviours pass the +// suite; only one matches what the client does. +// +// The SDK said so too, without any measurement: +// DetachAndDeletePrivateNetworkInterface exists as a separate operation, which +// is only meaningful if the plain one leaves the interface in place — and that +// folded form stays declined, because no client sends it. +// +// # The status enum is not the v1 one +// +// instance/v1 says `running`; instance/v2alpha1 says **`started`**, and its enum +// holds no `running` at all (contracts/scaleway.json, +// instance/v2alpha1…Server.Status). Echoing the stored state would answer a +// value the API description forbids, and the contract check would refuse the +// response before any client could complain. +// +// TestAServerRendersTheStatusVocabularyOfItsOwnApi fails without the mapping. + +// serversV2Path is the server family's door on the alpha API. Only one route +// under it is mounted, and the file header says why the others are not. +const serversV2Path = "/instance/v2alpha1/zones/{zone}/servers" + +// serverStatusV2 translates instance/v1's state vocabulary into the one +// instance/v2alpha1 declares. +// +// `stopped in place` has no v2alpha1 spelling. It becomes `stopped` rather than +// `unknown_status`: the machine is off either way, and answering "unknown" for +// a state this emulator knows exactly would be the lie the project exists to +// avoid. A state nobody mapped answers `unknown_status`, which is the enum's own +// escape hatch rather than an invented one. +func serverStatusV2(state string) string { + switch state { + case "running": + return "started" + case "stopped", "stopped in place": + return "stopped" + case "starting", "stopping", "locked": + return state + default: + return "unknown_status" + } +} + +// serverArchitectureV2 answers the enum's own vocabulary. The pack stores +// `x86_64`, which is a member; anything else says so rather than inventing. +func serverArchitectureV2(arch string) string { + switch arch { + case "x86_64", "aarch64": + return arch + default: + return "unknown_architecture" + } +} + +// serverVolumeTypeV2 translates instance/v1's volume vocabulary into +// instance/v2alpha1's, which is a different set and not a superset: +// +// v1 l_ssd, b_ssd, sbs_volume, scratch, unified +// v2alpha1 l_ssd, sbs, scratch, unknown_volume_type +// +// `sbs_volume` is spelled `sbs` there, and the contract check caught the +// difference before any client did — the same class as `running` / `started` +// above, met twice more in one response. +// +// `b_ssd` and `unified` have no v2alpha1 spelling. They answer +// `unknown_volume_type`, the enum's own escape hatch, rather than being mapped +// onto `sbs` because the products look alike: this pack serves what the API +// describes, and a correspondence nobody measured is exactly the invented format +// rule 4 forbids. A client reading `unknown_volume_type` learns that the +// emulator will not guess; a client reading a guessed `sbs` learns something +// possibly false. +// +// TestAServerRendersTheVolumeVocabularyOfItsOwnApi fails without this. +func serverVolumeTypeV2(kind string) string { + switch kind { + case "sbs_volume": + return "sbs" + case "l_ssd", "scratch", "sbs": + return kind + default: + return "unknown_volume_type" + } +} + +// interfaceStatusV2 is the vocabulary of an INTERFACE, which is not the +// vocabulary of a server: `unknown_status, available, syncing` on the public +// side, where a server says `started`. Answering the server's own status here +// was the first version of this file, and the contract check refused it. +func interfaceStatusV2(state string) string { + switch state { + case "available", "syncing": + return state + default: + return "available" + } +} + +// serverV2View renders a stored server in the shape instance/v2alpha1 declares. +// +// The fields come from the contract rather than from memory +// (contracts/scaleway.json, instance/v2alpha1…Server: nineteen of them), and +// every one is answered: the omission gate (#88) judges a served response +// against what its document declares, and a field left out of a response the +// emulator does carry is exactly what it reports. +// +// Two of them are empty by modelling rather than by oversight, and both are +// already stated elsewhere in this pack: this emulator runs no filesystem +// product, and it mints no Windows RDP password. +func (p *Pack) serverV2View(res *resource.Resource) map[string]any { + name, _ := res.Attrs["name"].(string) + serverType, _ := res.Attrs["commercial_type"].(string) + arch, _ := res.Attrs["arch"].(string) + detail, _ := res.Attrs["state_detail"].(string) + + out := map[string]any{ + "id": res.ID, + "name": name, + "project_id": res.Tenant.Project, + "zone": res.Tenant.Zone, + "tags": orEmpty(tagsOf(res)), + "server_type": serverType, + "status": serverStatusV2(res.State), + "status_detail": detail, + "architecture": serverArchitectureV2(arch), + "created_at": res.Created.Format(time.RFC3339), + "updated_at": res.Updated.Format(time.RFC3339), + "rescue_mode": false, + "placement_group_id": serverPlacementGroupID(res), + "boot_volume_id": serverBootVolumeID(res), + "volumes": p.serverVolumesV2(res), + "private_network_interfaces": p.serverInterfacesV2(res), + // No filesystem product is emulated, so a server attaches none. Empty + // rather than absent: the field is declared, and a client iterating it + // must find a list. + "filesystems": []any{}, + // Minted by the real cloud for Windows images this emulator does not + // serve. Null rather than an object of empty strings, which would read + // as a password that exists. + "windows_rdp_password": nil, + "public_network_interface": p.serverPublicInterfaceV2(res), + } + return out +} + +// serverPlacementGroupID answers the bare identifier v2alpha1 declares, where +// v1's own view serves an object. Stored under the v1 response key, so the two +// doors read one value rather than keeping two that can disagree. +func serverPlacementGroupID(res *resource.Resource) any { + switch v := res.Attrs[attrServerPlacementGroup].(type) { + case string: + if v == "" { + return nil + } + return v + default: + return nil + } +} + +// serverBootVolumeID is the identifier of volume "0", which is where this pack +// puts the root volume at create time. +func serverBootVolumeID(res *resource.Resource) any { + vols, ok := res.Attrs["volumes"].(map[string]any) + if !ok { + return nil + } + root, ok := vols["0"].(map[string]any) + if !ok { + return nil + } + if id, ok := root["id"].(string); ok && id != "" { + return id + } + return nil +} + +// serverVolumesV2 renders the attached volumes as the two fields v2alpha1 +// declares for them: an identifier and a type. The v1 view carries far more, +// and copying it here would answer a shape the API description does not have. +func (p *Pack) serverVolumesV2(res *resource.Resource) []any { + stored, ok := res.Attrs["volumes"].(map[string]any) + if !ok { + return []any{} + } + out := make([]any, 0, len(stored)) + for _, raw := range stored { + vol, ok := raw.(map[string]any) + if !ok { + continue + } + id, _ := vol["id"].(string) + if id == "" { + continue + } + kind, _ := vol["volume_type"].(string) + out = append(out, map[string]any{"id": id, "volume_type": serverVolumeTypeV2(kind)}) + } + return out +} + +// serverInterfacesV2 renders the server's private interfaces in the nested +// shape, which is the v2alpha1 interface minus the fields that only make sense +// on the top-level resource. +func (p *Pack) serverInterfacesV2(res *resource.Resource) []any { + out := make([]any, 0) + for _, nic := range p.env.Store.List(kindPrivateNIC, res.Tenant) { + if nic.Runtime[runtimeServerKey] != res.ID { + continue + } + view := p.privateNetworkInterfaceView(nic) + out = append(out, map[string]any{ + "id": view["id"], + "private_network_id": view["private_network_id"], + "mac_address": view["mac_address"], + "status": view["status"], + "ip_ids": view["ip_ids"], + // Declared on the nested shape and not on the top-level one. This + // pack attaches groups to servers rather than to interfaces, so + // there is no per-interface group to name. + "security_group_id": nil, + }) + } + return out +} + +// serverPublicInterfaceV2 renders the public side. A server with no public +// address has none, and answering an object of empty fields would describe an +// interface that does not exist. +func (p *Pack) serverPublicInterfaceV2(res *resource.Resource) any { + ips := p.publicIPsOf(res) + if len(ips) == 0 { + return nil + } + return map[string]any{ + "ips": ips, + // The emulator publishes no reverse for a server's own address, mints + // no MAC on the public side, and attaches groups to the server rather + // than to this interface. + "dns": nil, + "mac_address": nil, + "security_group_id": nil, + // The interface vocabulary, not the server one. + "status": interfaceStatusV2("available"), + } +} + +type detachPrivateNetworkInterfaceRequest struct { + PrivateNetworkInterfaceID string `json:"private_network_interface_id"` +} + +// detachPrivateNetworkInterface dissociates an interface from its server. +// +// The interface survives: DetachAndDeletePrivateNetworkInterface is a separate +// upstream operation, which would be meaningless if this one deleted. The +// provider deletes afterwards through the door it already used, and the store is +// shared, so both doors see one object. +// +// TestADetachedInterfaceSurvivesItsDetach fails if this deletes, and +// TestADetachLeavesTheInterfaceOffItsServer fails if it does nothing. +func (p *Pack) detachPrivateNetworkInterface(w http.ResponseWriter, r *http.Request) { + zone, ok := zoneOf(w, r) + if !ok { + return + } + serverID := r.PathValue("id") + server, found := p.env.Store.Get(Name, kindServer, serverID) + if !found || server.Tenant.Zone != zone { + writeNotFound(w, "server", serverID) + return + } + + var req detachPrivateNetworkInterfaceRequest + if err := emulator.DecodeJSON(r, &req); err != nil { + writeInvalidArguments(w, ArgumentError{ + ArgumentName: "private_network_interface_id", + Reason: "constraint", + HelpMessage: err.Error(), + }) + return + } + if req.PrivateNetworkInterfaceID == "" { + writeInvalidArguments(w, ArgumentError{ + ArgumentName: "private_network_interface_id", + Reason: "required", + }) + return + } + + nic, found := p.env.Store.Get(Name, kindPrivateNIC, req.PrivateNetworkInterfaceID) + if !found || nic.Tenant.Zone != zone { + writeNotFound(w, "private_nic", req.PrivateNetworkInterfaceID) + return + } + // An interface of another server is not this server's to detach. The + // identifier comes from a request body, so it is checked against what the + // store says rather than trusted because it resolved. + // + // TestADetachRefusesAnInterfaceOfAnotherServer fails without this. + if nic.Runtime[runtimeServerKey] != serverID { + writeNotFound(w, "private_nic", req.PrivateNetworkInterfaceID) + return + } + + // The per-server hold deletePrivateNetworkInterface takes, for the same + // reason: a detach crossing an attach on one machine is the race the driver + // serialises rather than the store. + unlock := p.binding().Serialise(serverID) + defer unlock() + + // Dissocier, pas supprimer, and the client's own next two calls are why: + // it reads the interface back (200) and then DELETEs it itself. Deleting here + // answered that read with a 404 and the client skipped its DELETE — green for + // a reason that was not the stated one. + // + // detachMachineFromNetwork first, because the names of both ends live on the + // resource and a detach that cannot name its machine is a detach that never + // happens. The addresses stay with the interface: it is still a NIC, just not + // this server's, and releaseNIC's address handling belongs to the delete. + p.detachMachineFromNetwork(r.Context(), nic) + _ = p.env.Store.Update(Name, kindPrivateNIC, nic.ID, func(stored *resource.Resource) error { + delete(stored.Runtime, runtimeServerKey) + stored.Updated = p.env.Now() + return nil + }) + + server, found = p.env.Store.Get(Name, kindServer, serverID) + if !found { + writeNotFound(w, "server", serverID) + return + } + emulator.WriteJSON(w, http.StatusOK, p.serverV2View(server)) +} diff --git a/internal/providers/scaleway/servers_v2alpha1_test.go b/internal/providers/scaleway/servers_v2alpha1_test.go new file mode 100644 index 00000000..56fc6b01 --- /dev/null +++ b/internal/providers/scaleway/servers_v2alpha1_test.go @@ -0,0 +1,317 @@ +package scaleway_test + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const v2alphaServers = "/instance/v2alpha1/zones/fr-par-1/servers" + +func detach(t *testing.T, ts *httptest.Server, serverID, body string) (int, map[string]any) { + t.Helper() + return do(t, ts, "POST", v2alphaServers+"/"+serverID+"/detach-private-network-interface", body) +} + +// A detach takes the interface off its server, and the server says so. +// +// Provider 2.83.0 sends this before every delete of an interface +// (`fix(instance): detach private network interface before deleting it`), and a +// 501 here failed every `terraform destroy` against this emulator. The answer is +// the server, so the assertion is on what the server publishes afterwards rather +// than on the status code alone. +func TestADetachLeavesTheInterfaceOffItsServer(t *testing.T) { + ts := newTestServer(t) + serverID, _, nic := attachedNIC(t, ts, "detach", "10.171.0.0/24") + nicID, _ := nic["id"].(string) + + status, before := detach(t, ts, serverID, `{"private_network_interface_id":"`+nicID+`"}`) + if status != http.StatusOK { + t.Fatalf("detach: expected 200, got %d (%v)", status, before) + } + + // The answer is a v2alpha1 server, and the interface is gone from it. + ifaces, _ := before["private_network_interfaces"].([]any) + for _, raw := range ifaces { + iface, _ := raw.(map[string]any) + if iface["id"] == nicID { + t.Errorf("the detached interface is still on the server it answered: %v", before) + } + } + + // And the v1 door agrees, because there is one store behind the two. + status, list := do(t, ts, "GET", zoneURL+"/servers/"+serverID+"/private_nics", "") + if status != http.StatusOK { + t.Fatalf("list v1 nics: expected 200, got %d (%v)", status, list) + } + nics, _ := list["private_nics"].([]any) + for _, raw := range nics { + got, _ := raw.(map[string]any) + if got["id"] == nicID { + t.Errorf("v1 still attaches the interface v2alpha1 detached: %v", list) + } + } +} + +// A detach dissociates; it does not delete. +// +// Measured through `feint proxy` on provider 2.83.0, and the recording is the +// whole argument — the four calls in order: +// +// 200 POST …/servers/{id}/detach-private-network-interface +// 200 GET …/private-network-interfaces/{id} <- still there +// 204 DELETE …/private-network-interfaces/{id} <- the client deletes it +// 404 GET …/private-network-interfaces/{id} +// +// Which is `fix(instance): detach private network interface before deleting it` +// spelled out: two calls, in that order. The first version of this handler +// reused releaseNIC and deleted at the detach — the client then got a 404 on its +// read and never issued the DELETE at all, so the suite passed for a reason that +// was not the one it claimed. +// +// The upstream SDK says the same thing without any measurement: +// DetachAndDeletePrivateNetworkInterface exists as a separate operation, which +// is only meaningful if the plain detach leaves the interface in place. +func TestADetachedInterfaceSurvivesItsDetach(t *testing.T) { + ts := newTestServer(t) + serverID, pnID, nic := attachedNIC(t, ts, "survives", "10.178.0.0/24") + nicID, _ := nic["id"].(string) + + status, body := detach(t, ts, serverID, `{"private_network_interface_id":"`+nicID+`"}`) + if status != http.StatusOK { + t.Fatalf("detach: expected 200, got %d (%v)", status, body) + } + + // The read the client makes next. It must answer, because the client deletes + // what it can still see. + status, after := do(t, ts, "GET", v2alphaNICs+"/"+nicID, "") + if status != http.StatusOK { + t.Fatalf("the interface is gone after a detach: GET answered %d (%v). "+ + "A detach that deletes makes the client skip its own DELETE", status, after) + } + if after["private_network_id"] != pnID { + t.Errorf("the surviving interface changed network: %v, want %v", + after["private_network_id"], pnID) + } + + // And the delete the client sends afterwards still works, which is the other + // half of the sequence. + if status, body := do(t, ts, "DELETE", v2alphaNICs+"/"+nicID, ""); status != http.StatusNoContent { + t.Errorf("deleting a detached interface answered %d, want 204 (%v)", status, body) + } + if status, _ := do(t, ts, "GET", v2alphaNICs+"/"+nicID, ""); status != http.StatusNotFound { + t.Errorf("the interface survived its own delete: GET answered %d", status) + } +} + +// An interface that belongs to another server is not this server's to detach. +// +// The identifier arrives in a request body, so it is checked against the store +// rather than trusted because it resolved to something. Without the check, a +// client could take an interface off a machine it never named. +func TestADetachRefusesAnInterfaceOfAnotherServer(t *testing.T) { + ts := newTestServer(t) + _, _, mine := attachedNIC(t, ts, "mine", "10.172.0.0/24") + otherServer, _, _ := attachedNIC(t, ts, "other", "10.173.0.0/24") + mineID, _ := mine["id"].(string) + + status, body := detach(t, ts, otherServer, `{"private_network_interface_id":"`+mineID+`"}`) + if status == http.StatusOK { + t.Fatalf("a server detached an interface of another server: %v", body) + } + + // The accepting half: the interface is still where it was, so the refusal + // refused rather than half-acted. + status, list := do(t, ts, "GET", v2alphaNICs+"/"+mineID, "") + if status != http.StatusOK { + t.Fatalf("read the interface back: expected 200, got %d (%v)", status, list) + } + if list["server_id"] == "" || list["server_id"] == nil { + t.Errorf("the refused detach took the interface off its server anyway: %v", list) + } +} + +// The server answers the status vocabulary of the API it is being read through. +// +// instance/v1 says `running`; instance/v2alpha1 declares an enum that holds +// `started` and no `running` at all. Echoing the stored state answers a value +// the API description forbids, and a client branching on the enum sees a state +// that is not in it. +func TestAServerRendersTheStatusVocabularyOfItsOwnApi(t *testing.T) { + ts := newTestServer(t) + serverID, _, nic := attachedNIC(t, ts, "status", "10.174.0.0/24") + nicID, _ := nic["id"].(string) + + status, _ := do(t, ts, "POST", zoneURL+"/servers/"+serverID+"/action", `{"action":"poweron"}`) + if status != http.StatusAccepted && status != http.StatusOK { + t.Fatalf("poweron: got %d", status) + } + // v1 is asked first, so the test compares two doors rather than one door + // against a constant it wrote itself. + _, v1 := do(t, ts, "GET", zoneURL+"/servers/"+serverID, "") + server, _ := v1["server"].(map[string]any) + if server["state"] != "running" { + t.Fatalf("v1 says %v, this test needs a running server to compare", server["state"]) + } + + _, answer := detach(t, ts, serverID, `{"private_network_interface_id":"`+nicID+`"}`) + if answer["status"] != "started" { + t.Errorf("v2alpha1 answers status %v for a server v1 calls running; the enum holds "+ + "`started` and no `running`", answer["status"]) + } +} + +// Every field the API description declares for this response is answered. +// +// The omission gate (#88) reports a declared field a served response leaves out, +// and it judges on the `fields` leg alone. This holds the same property one +// package in, so the shape is wrong here before it is wrong in a leg that takes +// minutes to run. +func TestADetachedServerCarriesEveryDeclaredField(t *testing.T) { + ts := newTestServer(t) + serverID, _, nic := attachedNIC(t, ts, "fields", "10.175.0.0/24") + nicID, _ := nic["id"].(string) + + status, answer := detach(t, ts, serverID, `{"private_network_interface_id":"`+nicID+`"}`) + if status != http.StatusOK { + t.Fatalf("detach: expected 200, got %d (%v)", status, answer) + } + + // The nineteen of contracts/scaleway.json, written out rather than read from + // the contract: reading it would compare the response against the same file + // the response is generated from, and pass whatever both became. + for _, field := range []string{ + "id", "name", "project_id", "zone", "tags", "server_type", "status", + "status_detail", "architecture", "created_at", "updated_at", "rescue_mode", + "placement_group_id", "boot_volume_id", "volumes", "private_network_interfaces", + "filesystems", "windows_rdp_password", "public_network_interface", + } { + if _, ok := answer[field]; !ok { + t.Errorf("the response omits the declared field %q", field) + } + } + if answer["id"] != serverID { + t.Errorf("the answer names server %v, the detach was sent to %v", answer["id"], serverID) + } +} + +// Every value that belongs to an enum is a member of ITS OWN enum. +// +// The first version of this file answered the server's status on the public +// interface and v1's `sbs_volume` in a v2alpha1 volume, and the tests above +// stayed green through both: they assert that a field is PRESENT, which says +// nothing about whether its value is one the API description allows. The +// contract check caught them, one leg and a minute later. +// +// Three enums, three vocabularies, and the point is that they do not coincide: +// a server is `started` where an interface is `available`, and a volume v1 calls +// `sbs_volume` is `sbs` here. +func TestAServerRendersTheVolumeVocabularyOfItsOwnApi(t *testing.T) { + ts := newTestServer(t) + + // The server carries a PUBLIC address, and that is not decoration: without + // one, `public_network_interface` is null and the assertion below runs on + // nothing. The first version of this test had no address, and falsify showed + // it — the mutation that answers a server's vocabulary on an interface came + // back green, because the interface it would have spoiled did not exist. + // + // The population a verdict needs is the one that triggers it, which is the + // trap CLAUDE.md names and this test walked into. + status, ip := do(t, ts, "POST", zoneURL+"/ips", `{"type":"routed_ipv4"}`) + if status != http.StatusCreated { + t.Fatalf("reserve an address: expected 201, got %d (%v)", status, ip) + } + reserved, _ := ip["ip"].(map[string]any) + ipID, _ := reserved["id"].(string) + + pnID, _ := privateNetwork(t, ts, `{"name":"vocab","subnets":["10.177.0.0/24"]}`) + serverID, _ := serverWith(t, ts, + `{"name":"vocab","commercial_type":"DEV1-S","public_ips":["`+ipID+`"]}`) + status, created := do(t, ts, "POST", + zoneURL+"/servers/"+serverID+"/private_nics", `{"private_network_id":"`+pnID+`"}`) + if status != http.StatusCreated { + t.Fatalf("attach an interface: expected 201, got %d (%v)", status, created) + } + nic, _ := created["private_nic"].(map[string]any) + nicID, _ := nic["id"].(string) + + status, answer := detach(t, ts, serverID, `{"private_network_interface_id":"`+nicID+`"}`) + if status != http.StatusOK { + t.Fatalf("detach: expected 200, got %d (%v)", status, answer) + } + + // contracts/scaleway.json, instance/v2alpha1…Server.Volume.VolumeType. + allowedVolume := map[string]bool{ + "unknown_volume_type": true, "l_ssd": true, "sbs": true, "scratch": true, + } + volumes, _ := answer["volumes"].([]any) + if len(volumes) == 0 { + t.Fatal("the server answered no volume, so this test would assert nothing") + } + for _, raw := range volumes { + vol, _ := raw.(map[string]any) + kind, _ := vol["volume_type"].(string) + if !allowedVolume[kind] { + t.Errorf("volume_type %q is not one this API declares; v1's own spelling is not v2alpha1's", kind) + } + } + + // …Server.PublicNetworkInterface.Status, which holds no `started`. + allowedInterface := map[string]bool{"unknown_status": true, "available": true, "syncing": true} + pub, ok := answer["public_network_interface"].(map[string]any) + if !ok { + t.Fatalf("the server answers no public interface, so this half asserts nothing: %v", + answer["public_network_interface"]) + } + st, _ := pub["status"].(string) + if !allowedInterface[st] { + t.Errorf("the public interface answers status %q, which is a SERVER's vocabulary, "+ + "not an interface's", st) + } + + // …Server.Status, which holds no `running`. + allowedServer := map[string]bool{ + "unknown_status": true, "started": true, "stopped": true, "paused": true, + "starting": true, "stopping": true, "pausing": true, "locked": true, "rebooting": true, + } + if st, _ := answer["status"].(string); !allowedServer[st] { + t.Errorf("the server answers status %q, which its own enum does not hold", st) + } +} + +// A detach names what it cannot find, and refuses an empty identifier. +func TestADetachRefusesWhatItCannotResolve(t *testing.T) { + ts := newTestServer(t) + serverID, _, _ := attachedNIC(t, ts, "refuse", "10.176.0.0/24") + + // The STATUS is the assertion, not merely "not 200". A body that names no + // interface is a malformed request — 400, `invalid_arguments`, naming the + // field — while an identifier nothing holds is a 404. Asserting only "not + // 200" let both collapse into the same check: falsify showed it, by removing + // the empty-value guard and watching this test stay green, because an empty + // identifier resolves to nothing and falls into the 404 below. + // + // The difference is what a client branches on, so it is what is held here. + for _, tc := range []struct { + name string + body string + want int + }{ + {"no identifier at all", `{}`, http.StatusBadRequest}, + {"an empty identifier", `{"private_network_interface_id":""}`, http.StatusBadRequest}, + {"an identifier nothing holds", + `{"private_network_interface_id":"a0000000-0000-4000-8000-000000000001"}`, + http.StatusNotFound}, + } { + status, body := detach(t, ts, serverID, tc.body) + if status != tc.want { + t.Errorf("%s: answered %d, want %d (%v)", tc.name, status, tc.want, body) + } + } + + // And a server nothing holds is a 404 rather than a detach on nothing. + if status, body := detach(t, ts, "a0000000-0000-4000-8000-000000000002", + `{"private_network_interface_id":"whatever"}`); status != http.StatusNotFound { + t.Errorf("a detach on an absent server answered %d, want 404 (%v)", status, body) + } +} diff --git a/tools/conformance/scaleway/terraform/main.tf b/tools/conformance/scaleway/terraform/main.tf index a9375358..490c8a8a 100644 --- a/tools/conformance/scaleway/terraform/main.tf +++ b/tools/conformance/scaleway/terraform/main.tf @@ -35,7 +35,20 @@ terraform { # `fix(instance): infer project_id from server if not explicit`. Every one # of those is a reason a newer provider could stop driving this fixture, # which is exactly why the pin is exact and why it moves deliberately. - version = "2.82.0" + # + # Moved again to 2.83.0 the same day, and that day is the argument for an + # exact pin rather than against it: 2.83.0 was tagged on GitHub at 15:06, + # absent from both registries at 17:2x, served by registry.terraform.io + # alone at 17:35, and by both at 18:1x. A floating constraint would have + # resolved to three different things in three hours, and to two different + # ones on the two engines this repository drives (#778). + # + # What it carries for this emulator: `fix(instance): detach private + # network interface before deleting it` (upstream #4354) changes the CALL + # SEQUENCE on private NICs, which is the family that turned every + # Terraform leg red the hour 2.81.0 shipped (#257). Plus + # `feat(lb): add support for backend host` on another served product. + version = "2.83.0" } } } diff --git a/tools/falsify/specs/a-server-detaches-the-interface-a-client-names.json b/tools/falsify/specs/a-server-detaches-the-interface-a-client-names.json new file mode 100644 index 00000000..5b7458a5 --- /dev/null +++ b/tools/falsify/specs/a-server-detaches-the-interface-a-client-names.json @@ -0,0 +1,61 @@ +{ + "package": "./internal/providers/scaleway/", + "mutations": [ + { + "label": "the detach answers 200 without dissociating anything, which is the success that reads like one and leaves the interface on its server", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\t\tdelete(stored.Runtime, runtimeServerKey)", + "replace": "\t\tif len(stored.ID) < 0 {\n\t\t\tdelete(stored.Runtime, runtimeServerKey)\n\t\t}", + "test": "TestADetachLeavesTheInterfaceOffItsServer" + }, + { + "label": "an interface of another server is detached on request, so a client takes one off a machine it never named", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\tif nic.Runtime[runtimeServerKey] != serverID {", + "replace": "\tif nic.Runtime[runtimeServerKey] != serverID && len(serverID) < 0 {", + "test": "TestADetachRefusesAnInterfaceOfAnotherServer" + }, + { + "label": "the stored v1 state is echoed, so a v2alpha1 client is handed `running`, which its own enum does not contain", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\tcase \"running\":\n\t\treturn \"started\"", + "replace": "\tcase \"running\":\n\t\treturn \"running\"", + "test": "TestAServerRendersTheStatusVocabularyOfItsOwnApi" + }, + { + "label": "a declared field is dropped from the answer, which is what the omission gate reports one leg and several minutes later", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\t\t\"boot_volume_id\": serverBootVolumeID(res),", + "replace": "\t\t\"boot_volume_id_dropped\": serverBootVolumeID(res),", + "test": "TestADetachedServerCarriesEveryDeclaredField" + }, + { + "label": "an empty identifier is accepted, so a body that names nothing detaches whatever resolves to the empty string", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\tif req.PrivateNetworkInterfaceID == \"\" {", + "replace": "\tif req.PrivateNetworkInterfaceID == \"\\x00never\" {", + "test": "TestADetachRefusesWhatItCannotResolve" + }, + { + "label": "v1's own volume spelling is echoed into a v2alpha1 answer, which its enum does not hold", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\tcase \"sbs_volume\":\n\t\treturn \"sbs\"", + "replace": "\tcase \"sbs_volume\":\n\t\treturn \"sbs_volume\"", + "test": "TestAServerRendersTheVolumeVocabularyOfItsOwnApi" + }, + { + "label": "the public interface answers the server's status vocabulary, where its own enum holds available and syncing alone", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\t\t\"status\": interfaceStatusV2(\"available\"),", + "replace": "\t\t\"status\": func() string { _ = interfaceStatusV2(\"available\"); return serverStatusV2(res.State) }(),", + "test": "TestAServerRendersTheVolumeVocabularyOfItsOwnApi" + }, + { + "label": "the detach deletes instead of dissociating, so the client's read answers 404 and it never sends the DELETE the upstream fix is named after", + "file": "internal/providers/scaleway/servers_v2alpha1.go", + "find": "\tp.detachMachineFromNetwork(r.Context(), nic)", + "replace": "\tp.detachMachineFromNetwork(r.Context(), nic)\n\tp.env.Store.Delete(Name, kindPrivateNIC, nic.ID)", + "test": "TestADetachedInterfaceSurvivesItsDetach" + } + ] +} diff --git a/tools/falsify/specs/artefact-reasons.json b/tools/falsify/specs/artefact-reasons.json index 2924c222..4a0c891d 100644 --- a/tools/falsify/specs/artefact-reasons.json +++ b/tools/falsify/specs/artefact-reasons.json @@ -4,8 +4,8 @@ { "label": "a pack edits a decline reason and the committed artefact keeps printing the old sentence (#298, replayed with #260's own edit)", "file": "internal/providers/scaleway/pack.go", - "find": "\t\temulator.Because(\"instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations — a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, which is why those two families are served and these are not\",", - "replace": "\t\temulator.Because(\"instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for it: every instance request the conformance suite makes lands on v1\",", + "find": "\t\temulator.Because(\"instance/v2alpha1 is an alpha rewrite Scaleway is still free to change, and no client this project drives reaches for these operations \u2014 a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", + "replace": "\t\temulator.Because(\"instance/v2alpha1 is an alpha rewrite that Scaleway is still entirely free to change, and no client this project drives reaches for these operations \u2014 a claim that held for the whole API until provider 2.81.0 moved private network interfaces and placement groups onto it, and for the server family until 2.83.0 reached the private-interface detach, which is why those are served and these are not", "test": "TestTheCommittedArtefactCarriesWhatThePacksDeclare" }, { @@ -24,5 +24,5 @@ "test": "TestCoverageExitsTwoWhenTheArtefactLagsThePack" } ], - "note": "The first mutation is not invented: it puts back the exact pre-#260 sentence that coverage/scaleway-coverage.json carried 67 times for four days while drift:check compared names and statuses and docs:check regenerated the README from the same stale artefact — two gates passing by comparing a stale artefact with itself. The three mutations fail differently on purpose: without the first, an edited reason outlives itself in the versioned copy; without the second, the comparison exists but is blind to the one field #298 is about; without the third, the comparison sees the skew and the gate exits 0 anyway, which is the deleted-call-site failure coverage() has already lived through once." + "note": "The first mutation is not invented: it puts back the exact pre-#260 sentence that coverage/scaleway-coverage.json carried 67 times for four days while drift:check compared names and statuses and docs:check regenerated the README from the same stale artefact \u2014 two gates passing by comparing a stale artefact with itself. The three mutations fail differently on purpose: without the first, an edited reason outlives itself in the versioned copy; without the second, the comparison exists but is blind to the one field #298 is about; without the third, the comparison sees the skew and the gate exits 0 anyway, which is the deleted-call-site failure coverage() has already lived through once." }