From 8340c02a6b3ea8e79ef242b7a23790a80b2226b2 Mon Sep 17 00:00:00 2001 From: Huyvux12 Date: Wed, 11 Nov 2026 18:40:26 +0000 Subject: [PATCH 1/3] chore(drift): triage the baremetal commitment operations, declined on a measured absence of caller Fixes #875 Assisted-by: Muse (Muse Spark 1.3) --- README.md | 4 ++-- coverage/scaleway-baseline.json | 5 +--- coverage/scaleway-coverage.json | 22 +++++++++-------- docs/limits.md | 2 +- docs/routes.md | 3 ++- internal/providers/scaleway/pack.go | 37 ++++++++++++++++++++++++++++- tools/drift/gate.sh | 4 ++-- 7 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 268f1521..4fa3824d 100644 --- a/README.md +++ b/README.md @@ -975,12 +975,12 @@ this scan covers: 37% served, 62% declined on purpose, 0% untriaged. | `lb` | 43 | 66 | 0 | 109 | | `iam` | 12 | 72 | 0 | 84 | | `vpcgw` | 15 | 49 | 0 | 64 | -| `baremetal` | 2 | 35 | 2 | 39 | +| `baremetal` | 2 | 37 | 0 | 39 | | `vpc` | 19 | 18 | 0 | 37 | | `block` | 24 | 3 | 0 | 27 | | `account` | 5 | 7 | 0 | 12 | | *… 3 smaller groups* | 11 | 8 | 0 | 19 | -| **Total** | **204** | **342** | **2** | **548** | +| **Total** | **204** | **344** | **0** | **548** | #### Exoscale diff --git a/coverage/scaleway-baseline.json b/coverage/scaleway-baseline.json index 5369b917..02e98107 100644 --- a/coverage/scaleway-baseline.json +++ b/coverage/scaleway-baseline.json @@ -13,8 +13,5 @@ "vpc", "vpcgw" ], - "unknown": [ - "baremetal/v1/API.UpdateServerCommitment", - "baremetal/v1/API.UpgradeServerCommitment" - ] + "unknown": [] } diff --git a/coverage/scaleway-coverage.json b/coverage/scaleway-coverage.json index 4283789f..4e3e164c 100644 --- a/coverage/scaleway-coverage.json +++ b/coverage/scaleway-coverage.json @@ -2,8 +2,8 @@ "provider": "scaleway", "total": 548, "implemented": 204, - "declined": 342, - "unknown": 2, + "declined": 344, + "unknown": 0, "orphans": null, "products": [ { @@ -26,15 +26,15 @@ "product": "baremetal", "total": 39, "implemented": 2, - "declined": 35, - "unknown": 2, + "declined": 37, + "unknown": 0, "versions": [ { "version": "v1", "total": 35, "implemented": 2, - "declined": 31, - "unknown": 2 + "declined": 33, + "unknown": 0 }, { "version": "v3", @@ -221,8 +221,8 @@ "group": "baremetal", "total": 39, "implemented": 2, - "declined": 35, - "unknown": 2 + "declined": 37, + "unknown": 0 }, { "group": "block", @@ -558,8 +558,9 @@ { "operation": "baremetal/v1/API.UpdateServerCommitment", "product": "baremetal", + "reason": "a server commitment is a paid billing term — UpdateServerCommitment toggles auto-renewal on an existing commitment and UpgradeServerCommitment starts or upgrades one to a period of one month to three years — and no client this project drives calls either: `scw baremetal commitment` is an empty parent node with no subcommand and the Terraform provider references neither method, both measured 2026-11-11 against a positive control; the server here is seeded through the state door, never ordered, so this is an absent caller rather than an absent capability, and the first client measured on either path reopens it", "version": "v1", - "status": "unknown" + "status": "declined" }, { "operation": "baremetal/v1/API.UpdateSetting", @@ -571,8 +572,9 @@ { "operation": "baremetal/v1/API.UpgradeServerCommitment", "product": "baremetal", + "reason": "a server commitment is a paid billing term — UpdateServerCommitment toggles auto-renewal on an existing commitment and UpgradeServerCommitment starts or upgrades one to a period of one month to three years — and no client this project drives calls either: `scw baremetal commitment` is an empty parent node with no subcommand and the Terraform provider references neither method, both measured 2026-11-11 against a positive control; the server here is seeded through the state door, never ordered, so this is an absent caller rather than an absent capability, and the first client measured on either path reopens it", "version": "v1", - "status": "unknown" + "status": "declined" }, { "operation": "baremetal/v1/API.ValidatePartitioningSchema", diff --git a/docs/limits.md b/docs/limits.md index 1aa09f0f..86fab9b1 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -651,7 +651,7 @@ paths before the run — which is what `feint doctor` now does. ## Elastic Metal is an inventory here, not a product (#631) Two operations of `baremetal/v1` are served, `ListServers` and `ListOffers`, -and the thirty-five others of the product are declined with their reason in +and the thirty-seven others of the product are declined with their reason in the pack. The listing exists because a fleet inventory has to enumerate the product to describe a mixed fleet, and it was measured before it was written, on 2026-09-06: `scw baremetal server list` (2.56.3) calls diff --git a/docs/routes.md b/docs/routes.md index 657b9af4..76619240 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -430,7 +430,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 (342) +### Declined on purpose (344) Operations this pack knowingly does not serve, and why. Declining is a decision the drift gate records, which is what separates it from having @@ -449,6 +449,7 @@ are in `coverage/`, one artefact per provider. - `baremetal` — 3 operations — BMC access is a remote console on the hardware's management controller, opened with a password the cloud generates for the session; there is no controller behind a seeded server and no session to open on it - `baremetal` — 3 operations — it reads one server by identifier, its events or its metrics, and the measured clients never do: an inventory enumerates (the stephrobert.scaleway plugin's own comment says it never reads one by one), and the events and metrics of a seeded server would describe a life it did not live here - `baremetal` — 3 operations — ordering Elastic Metal is a paid commitment on hardware the cloud delivers in minutes to hours, and a server here is seeded through the state door, never ordered: an order this emulator accepted would be a delivery nobody makes +- `baremetal` — 2 operations — a server commitment is a paid billing term — UpdateServerCommitment toggles auto-renewal on an existing commitment and UpgradeServerCommitment starts or upgrades one to a period of one month to three years — and no client this project drives calls either: `scw baremetal commitment` is an empty parent node with no subcommand and the Terraform provider references neither method, both measured 2026-11-11 against a positive control; the server here is seeded through the state door, never ordered, so this is an absent caller rather than an absent capability, and the first client measured on either path reopens it - `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` — 3 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` — 72 operations — the emulator accepts every credential on purpose, so serving users, policies and keys would describe an access control that nothing here enforces diff --git a/internal/providers/scaleway/pack.go b/internal/providers/scaleway/pack.go index 38708cf0..409a0296 100644 --- a/internal/providers/scaleway/pack.go +++ b/internal/providers/scaleway/pack.go @@ -1449,7 +1449,7 @@ func (p *Pack) Declined() []emulator.Decline { // fleet inventory enumerates its bare metal and does nothing else with // it, measured on `scw baremetal server list` and on the Python SDK's // list_servers_all; and ListOffers, because that same CLI command - // joins the catalogue right after the servers. These thirty-five are + // joins the catalogue right after the servers. These thirty-seven are // the rest of the product, and each says what the emulator would have // to have in order to answer. A bare-metal server here is seeded // through the state door, and that one fact is behind most of them. @@ -1459,6 +1459,41 @@ func (p *Pack) Declined() []emulator.Decline { "baremetal/v1/API.BatchCreateServers", "baremetal/v1/API.MigrateServerToMonthlyOffer"), + // The two commitment operations, arrived upstream with the baseline + // refresh of 2026-10-28 (#827) and untriaged since: + // + // UpdateServerCommitment -> toggle auto-renewal on a commitment + // UpgradeServerCommitment -> start a commitment, or upgrade one to + // a period of one month to three years + // + // Declined on the measured absence of a caller, and NOT on any claim + // that the emulator could not do it: toggling a boolean and recording + // a period on a seeded server would be ordinary work. What is missing + // is a client. Measured on 2026-11-11: + // + // `scw baremetal commitment` is an empty parent node — no + // subcommand, no verb — and the Terraform provider references + // neither method + // + // The second measurement was taken with a positive control first, + // because an empty code search is indistinguishable from a broken one: + // UpdateServer resolves in the Terraform provider + // (internal/services/baremetal/server.go) and in scw's + // baremetal_cli.go, where server create and update call + // api.CreateServer and api.UpdateServer — the commitment-period flags + // on those commands are fields of those two requests, not calls to + // these methods — so the two empty results are an absence rather + // than an outage. + // + // A commitment is a billing term on a server this emulator never + // ordered, and answering either would invent a paid term a client + // could plan against — the same refusal GetServerTypesAvailability + // carries for capacity and quotas. The first client measured on + // either path reopens the entry. + emulator.Because("a server commitment is a paid billing term — UpdateServerCommitment toggles auto-renewal on an existing commitment and UpgradeServerCommitment starts or upgrades one to a period of one month to three years — and no client this project drives calls either: `scw baremetal commitment` is an empty parent node with no subcommand and the Terraform provider references neither method, both measured 2026-11-11 against a positive control; the server here is seeded through the state door, never ordered, so this is an absent caller rather than an absent capability, and the first client measured on either path reopens it", + "baremetal/v1/API.UpdateServerCommitment", + "baremetal/v1/API.UpgradeServerCommitment"), + emulator.Because("it acts on the hardware behind a server — an install writes an OS to its disks, a reboot, a start and a stop cycle its power, a delete returns it to stock — and there is no hardware behind a seeded server for any of that to happen to", "baremetal/v1/API.InstallServer", "baremetal/v1/API.RebootServer", diff --git a/tools/drift/gate.sh b/tools/drift/gate.sh index eb3a4a4d..434b0930 100755 --- a/tools/drift/gate.sh +++ b/tools/drift/gate.sh @@ -34,8 +34,8 @@ OUTSCALE_SDK="${FEINT_SDK_OUTSCALE:-.upstream/osc-sdk-go}" # of the triage — two served, ten declined with a reason. # baremetal joined on 2026-09-06 with #631: a fleet inventory enumerates Elastic # Metal, and the route it needs is served, plus the catalogue read the CLI makes -# right after it. Adding it here is what puts the product's thirty-seven -# operations in front of the triage — two served, thirty-five declined with a +# right after it. Adding it here is what puts the product's thirty-nine +# operations in front of the triage — two served, thirty-seven declined with a # reason. # scw joined on 2026-09-15 with #776. It is not a product: it is the API # gateway that sits in front of all of them, and its one operation lives in From 67d677d8be8d8ed755a4ba0371ccfc8e5b44f69e Mon Sep 17 00:00:00 2001 From: Huyvux12 Date: Wed, 11 Nov 2026 19:25:43 +0000 Subject: [PATCH 2/3] chore(contracts): re-extract the scaleway contract from the current upstream The pre-push hook requires contracts/scaleway.json to match a fresh extraction. The committed contract predates the #827 baseline refresh: it lacked the two baremetal commitment operations triaged in the previous commit, and the current upstream OpenAPI adds further fields (e.g. application_id). This refresh adds the two operations' contract entries and the other upstream drift; docs/limits.md carries the regenerated schema count 712 -> 718. Assisted-by: Muse (Muse Spark 1.3) --- contracts/scaleway.json | 339 ++++++++++++++++++++++++++++++++++++++-- docs/limits.md | 2 +- 2 files changed, 329 insertions(+), 12 deletions(-) diff --git a/contracts/scaleway.json b/contracts/scaleway.json index ca0dd92f..4a60ca97 100644 --- a/contracts/scaleway.json +++ b/contracts/scaleway.json @@ -352,6 +352,13 @@ "request": "baremetal/v1.UpdateServer.Request", "response": "baremetal/v1.scaleway.baremetal.v1.Server" }, + "baremetal/v1.UpdateServerCommitment": { + "path": "/baremetal/v1/zones/{zone}/servers/{server_id}/update-commitment", + "method": "POST", + "group": "Commitment", + "request": "baremetal/v1.UpdateServerCommitment.Request", + "response": "baremetal/v1.scaleway.baremetal.v1.Server" + }, "baremetal/v1.UpdateSetting": { "path": "/baremetal/v1/zones/{zone}/settings/{setting_id}", "method": "PATCH", @@ -359,6 +366,13 @@ "request": "baremetal/v1.UpdateSetting.Request", "response": "baremetal/v1.scaleway.baremetal.v1.Setting" }, + "baremetal/v1.UpgradeServerCommitment": { + "path": "/baremetal/v1/zones/{zone}/servers/{server_id}/upgrade-commitment", + "method": "POST", + "group": "Commitment", + "request": "baremetal/v1.UpgradeServerCommitment.Request", + "response": "baremetal/v1.scaleway.baremetal.v1.Server" + }, "baremetal/v1.ValidatePartitioningSchema": { "path": "/baremetal/v1/zones/{zone}/partitioning-schemas/validate", "method": "POST", @@ -1009,6 +1023,12 @@ "items": { "type": "string" } + }, + "application_id": { + "type": "string" + }, + "user_id": { + "type": "string" } } }, @@ -1821,6 +1841,12 @@ }, "type": { "type": "string" + }, + "server": { + "type": "string" + }, + "detached": { + "type": "boolean" } } }, @@ -2321,7 +2347,36 @@ "path": "/instance/v2alpha1/zones/{zone}/servers/{server_id}", "method": "DELETE", "group": "Instances", - "noContent": 204 + "noContent": 204, + "query": { + "delete_all_ips": { + "type": "boolean" + }, + "delete_ip_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "delete_all_volumes": { + "type": "boolean" + }, + "delete_volume_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "keep_all_private_nics": { + "type": "boolean" + }, + "delete_private_nic_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } }, "instance/v2alpha1.DeleteTemplate": { "path": "/instance/v2alpha1/zones/{zone}/templates/{template_id}", @@ -2390,7 +2445,15 @@ "path": "/instance/v2alpha1/zones/{zone}/resource-counts", "method": "GET", "group": "Resource Counts", - "response": "instance/v2alpha1.scaleway.instance.v2alpha1.ResourceCounts" + "response": "instance/v2alpha1.scaleway.instance.v2alpha1.ResourceCounts", + "query": { + "organization_id": { + "type": "string" + }, + "project_id": { + "type": "string" + } + } }, "instance/v2alpha1.GetSecurityGroup": { "path": "/instance/v2alpha1/zones/{zone}/security-groups/{security_group_id}", @@ -2493,6 +2556,12 @@ "type": "string" } }, + "security_group_ids": { + "type": "array", + "items": { + "type": "string" + } + }, "private_network_ids": { "type": "array", "items": { @@ -2920,6 +2989,21 @@ "items": { "type": "string" } + }, + "zonal": { + "type": "string" + }, + "regional": { + "type": "boolean" + }, + "private_network_id": { + "type": "string" + }, + "subnet_id": { + "type": "string" + }, + "source_vpc_id": { + "type": "string" } } }, @@ -3573,6 +3657,15 @@ }, "type": { "ref": "marketplace/v2.scaleway.marketplace.v2.LocalImage.Type" + }, + "image_id": { + "type": "string" + }, + "version_id": { + "type": "string" + }, + "image_label": { + "type": "string" } } }, @@ -3696,15 +3789,8 @@ "path": "/vpc/v2/regions/{region}/object-storage-private-access/{vpc_id}/enable", "method": "POST", "group": "Object Storage private access", - "response": "vpc/v2.scaleway.vpc.v2.VPC", - "query": { - "private_network_ids": { - "type": "array", - "items": { - "type": "string" - } - } - } + "request": "vpc/v2.EnableObjectStoragePrivateAccess.Request", + "response": "vpc/v2.scaleway.vpc.v2.VPC" }, "vpc/v2.EnableRouting": { "path": "/vpc/v2/regions/{region}/vpcs/{vpc_id}/enable-routing", @@ -4674,6 +4760,9 @@ "offer_id" ], "properties": { + "commitment_period": { + "ref": "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod" + }, "description": { "type": "string" }, @@ -4723,6 +4812,12 @@ "hostname": { "type": "string" }, + "network_boot_persistent": { + "type": "boolean" + }, + "network_boot_url": { + "type": "string" + }, "os_id": { "type": "string" }, @@ -4811,6 +4906,9 @@ "protected": { "type": "boolean" }, + "scheduled_deletion": { + "type": "boolean" + }, "tags": { "type": "array" }, @@ -4819,6 +4917,14 @@ } } }, + "baremetal/v1.UpdateServerCommitment.Request": { + "closed": false, + "properties": { + "auto_renewal": { + "type": "boolean" + } + } + }, "baremetal/v1.UpdateSetting.Request": { "closed": false, "properties": { @@ -4827,6 +4933,14 @@ } } }, + "baremetal/v1.UpgradeServerCommitment.Request": { + "closed": false, + "properties": { + "period": { + "ref": "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod" + } + } + }, "baremetal/v1.ValidatePartitioningSchema.Request": { "closed": false, "properties": { @@ -4867,8 +4981,17 @@ "password": { "type": "string" }, + "server_id": { + "type": "string" + }, + "srn": { + "type": "string" + }, "url": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -4923,6 +5046,18 @@ "baremetal/v1.scaleway.baremetal.v1.CertificationOption": { "closed": false }, + "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod": { + "closed": false, + "type": "string", + "enum": [ + "unknown_period", + "one_month", + "six_months", + "one_year", + "two_years", + "three_years" + ] + }, "baremetal/v1.scaleway.baremetal.v1.CreateServerRequest": { "closed": false, "required": [ @@ -4932,6 +5067,9 @@ "zone" ], "properties": { + "commitment_period": { + "ref": "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod" + }, "description": { "type": "string" }, @@ -4987,6 +5125,12 @@ "hostname": { "type": "string" }, + "network_boot_persistent": { + "type": "boolean" + }, + "network_boot_url": { + "type": "string" + }, "os_id": { "type": "string" }, @@ -5260,6 +5404,12 @@ "name": { "type": "string" }, + "network_boot": { + "ref": "baremetal/v1.scaleway.baremetal.v1.OS.OSField" + }, + "network_boot_persistent_editable": { + "type": "boolean" + }, "password": { "ref": "baremetal/v1.scaleway.baremetal.v1.OS.OSField" }, @@ -5269,6 +5419,9 @@ "service_user": { "ref": "baremetal/v1.scaleway.baremetal.v1.OS.OSField" }, + "srn": { + "type": "string" + }, "ssh": { "ref": "baremetal/v1.scaleway.baremetal.v1.OS.OSField" }, @@ -5306,6 +5459,12 @@ "commercial_range": { "type": "string" }, + "commitments_available": { + "type": "array", + "items": { + "ref": "baremetal/v1.scaleway.baremetal.v1.Offer.Commitment" + } + }, "cpus": { "type": "array", "items": { @@ -5393,6 +5552,9 @@ "shared_bandwidth": { "type": "boolean" }, + "srn": { + "type": "string" + }, "stock": { "ref": "baremetal/v1.scaleway.baremetal.v1.Offer.Stock" }, @@ -5410,6 +5572,20 @@ } } }, + "baremetal/v1.scaleway.baremetal.v1.Offer.Commitment": { + "closed": false, + "properties": { + "discount_rate_percent": { + "type": "integer" + }, + "no_fee": { + "type": "boolean" + }, + "period": { + "ref": "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod" + } + } + }, "baremetal/v1.scaleway.baremetal.v1.Offer.OptionOffer": { "closed": false, "properties": { @@ -5495,6 +5671,12 @@ }, "remote_access": { "ref": "baremetal/v1.scaleway.baremetal.v1.RemoteAccessOption" + }, + "srn": { + "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -5730,6 +5912,9 @@ "boot_type": { "ref": "baremetal/v1.scaleway.baremetal.v1.Server.BootType" }, + "commitment": { + "ref": "baremetal/v1.scaleway.baremetal.v1.Server.Commitment" + }, "created_at": { "type": "string" }, @@ -5781,6 +5966,15 @@ "rescue_server": { "ref": "baremetal/v1.scaleway.baremetal.v1.Server.RescueServer" }, + "scheduled_deletion": { + "type": "boolean" + }, + "scheduled_deletion_at": { + "type": "string" + }, + "srn": { + "type": "string" + }, "status": { "ref": "baremetal/v1.scaleway.baremetal.v1.Server.Status" }, @@ -5810,12 +6004,41 @@ "rescue" ] }, + "baremetal/v1.scaleway.baremetal.v1.Server.Commitment": { + "closed": false, + "properties": { + "auto_renewal": { + "type": "boolean" + }, + "discount_rate_percent": { + "type": "integer" + }, + "end_at": { + "type": "string" + }, + "period": { + "ref": "baremetal/v1.scaleway.baremetal.v1.CommitmentPeriod" + }, + "renew_at": { + "type": "string" + }, + "started_at": { + "type": "string" + } + } + }, "baremetal/v1.scaleway.baremetal.v1.Server.Install": { "closed": false, "properties": { "hostname": { "type": "string" }, + "network_boot_persistent": { + "type": "boolean" + }, + "network_boot_url": { + "type": "string" + }, "os_id": { "type": "string" }, @@ -5967,8 +6190,14 @@ "project_id": { "type": "string" }, + "srn": { + "type": "string" + }, "type": { "ref": "baremetal/v1.scaleway.baremetal.v1.Setting.Type" + }, + "zone": { + "type": "string" } } }, @@ -8368,6 +8597,9 @@ "condition": { "type": "string" }, + "deny": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -8394,6 +8626,9 @@ "condition": { "type": "string" }, + "deny": { + "type": "boolean" + }, "organization_id": { "type": "string" }, @@ -10783,6 +11018,9 @@ "scratch_storage_max_volumes_count": { "type": "integer" }, + "sku": { + "type": "string" + }, "volumes_constraint": { "ref": "instance/v1.scaleway.instance.v1.ServerType.VolumeConstraintSizes" } @@ -11545,6 +11783,9 @@ "project_id": { "type": "string" }, + "security_group_id": { + "type": "string" + }, "server_id": { "type": "string" }, @@ -11830,6 +12071,9 @@ "instance/v2alpha1.UpdatePrivateNetworkInterface.Request": { "closed": false, "properties": { + "security_group_id": { + "type": "string" + }, "tags": { "type": "array" } @@ -12083,6 +12327,9 @@ "properties": { "private_network_id": { "type": "string" + }, + "security_group_id": { + "type": "string" } } }, @@ -12386,6 +12633,9 @@ "project_id": { "type": "string" }, + "security_group_id": { + "type": "string" + }, "server_id": { "type": "string" }, @@ -12447,6 +12697,9 @@ "project_id": { "type": "string" }, + "security_group_id": { + "type": "string" + }, "server_id": { "type": "string" }, @@ -12970,6 +13223,9 @@ "instance/v2alpha1.scaleway.instance.v2alpha1.Server.RuntimeInfo": { "closed": false, "properties": { + "physical_host_id": { + "type": "string" + }, "spot": { "type": "boolean" } @@ -13085,6 +13341,9 @@ "name": { "type": "string" }, + "sku": { + "type": "string" + }, "spot_info": { "ref": "instance/v2alpha1.scaleway.instance.v2alpha1.ServerType.SpotInfo" }, @@ -13553,6 +13812,7 @@ "instance_ip", "instance_private_nic", "lb_server", + "lb_ip", "fip_ip", "vpc_gateway", "vpc_gateway_network", @@ -13568,6 +13828,7 @@ "apple_silicon_private_nic", "serverless_container", "serverless_function", + "serverless_job", "vpn_gateway", "ddl_datalab", "kafka_cluster", @@ -14275,8 +14536,14 @@ "name": { "type": "string" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -14447,6 +14714,9 @@ "send_proxy_v2": { "type": "boolean" }, + "srn": { + "type": "string" + }, "ssl_bridging": { "type": "boolean" }, @@ -14470,6 +14740,9 @@ }, "updated_at": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -14544,6 +14817,9 @@ "not_valid_before": { "type": "string" }, + "srn": { + "type": "string" + }, "status": { "ref": "lb/v1.scaleway.lb.v1.Certificate.Status" }, @@ -14561,6 +14837,9 @@ }, "updated_at": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -14658,11 +14937,17 @@ "name": { "type": "string" }, + "srn": { + "type": "string" + }, "timeout_client": { "type": "number" }, "updated_at": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -14835,6 +15120,9 @@ "reverse": { "type": "string" }, + "srn": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -14894,6 +15182,9 @@ "route_count": { "type": "integer" }, + "srn": { + "type": "string" + }, "ssl_compatibility_level": { "ref": "lb/v1.scaleway.lb.v1.SSLCompatibilityLevel" }, @@ -14966,6 +15257,9 @@ "region": { "type": "string" }, + "srn": { + "type": "string" + }, "stock_status": { "ref": "lb/v1.scaleway.lb.v1.LbType.Stock" }, @@ -15330,8 +15624,14 @@ "match": { "ref": "lb/v1.scaleway.lb.v1.Route.Match" }, + "srn": { + "type": "string" + }, "updated_at": { "type": "string" + }, + "zone": { + "type": "string" } } }, @@ -15397,8 +15697,14 @@ "name": { "type": "string" }, + "srn": { + "type": "string" + }, "webhook_config": { "ref": "lb/v1.scaleway.lb.v1.Subscriber.WebhookConfig" + }, + "zone": { + "type": "string" } } }, @@ -15820,6 +16126,17 @@ } } }, + "vpc/v2.EnableObjectStoragePrivateAccess.Request": { + "closed": false, + "properties": { + "private_network_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "vpc/v2.SetAcl.Request": { "closed": false, "required": [ diff --git a/docs/limits.md b/docs/limits.md index 86fab9b1..77c96036 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -3347,7 +3347,7 @@ proof. |---|---|--:|---| | Exoscale | `2.0.0` | 473 | *assumed* by this emulator | | Outscale | `1.42.0` | 655 | **declared** by the provider | -| Scaleway | `instance/v1, instance/v2alpha1, vpc/v2, ipam/v1, iam/v1alpha1, marketplace/v2, block/v1, block/v1alpha1, lb/v1, vpcgw/v2, account/v3, baremetal/v1` | 712 | **declared** by the provider | +| Scaleway | `instance/v1, instance/v2alpha1, vpc/v2, ipam/v1, iam/v1alpha1, marketplace/v2, block/v1, block/v1alpha1, lb/v1, vpcgw/v2, account/v3, baremetal/v1` | 718 | **declared** by the provider | **Declared** means the provider wrote `additionalProperties: false` themselves: From 6ce30fbc3d361104ac08c1518ec1de6db262cdaf Mon Sep 17 00:00:00 2001 From: Huyvux12 Date: Thu, 12 Nov 2026 03:31:31 +0000 Subject: [PATCH 3/3] fix(scaleway): serve the seven query parameters the refreshed contract declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract re-extraction in the previous commit declares seven query parameters the handlers never named, which TestNoDeclaredQueryParameterIsDroppedByItsHandler refuses to let pass (#277). Five are served, two are refused with a reason — never dropped: - instance/v1 ListIPs: `server` filters by the attached server id and `detached` by whether one is attached at all (Attrs["server"] is {id, name} on attach, nil on detach). The SDK names them precisely one of a pair; the handler applies each filter it is given, so both together narrow conjunctively rather than inventing which one wins. - ipam/v1 ListIPs: `regional` is the opposite of the `zonal` filter the handler already serves — every address here is regional, so regional=false matches nothing and an unparseable value is a 400. - marketplace/v2 ListLocalImages: `image_id` resolves the label through labelByID, so an id alone still names its image; an id outside the table, or one that disagrees with a label also sent, answers empty. - marketplace/v2 ListLocalImages: `version_id` is refused with a 400 — this catalogue records no version for its images (docs/limits.md). - instance/v2alpha1 ListPrivateNetworkInterfaces: `security_group_ids` is refused with a 400 — this pack records no security group on a private interface (create/update ignore the security_group_id the SDK's requests carry), and the SDK lets create/update set the interface's own group, so deriving it from the attached server would be a guess. Measured: the Terraform provider references SecurityGroupIDs nowhere, so no client this project drives passes it. Each new behaviour carries a test that fails without its fix and passes with it. Part of the #875 PR: the triage there cannot be pushed without the re-extraction, and the re-extraction cannot land without these. Assisted-by: Muse (Muse Spark 1.3) --- docs/limits.md | 9 +++ internal/providers/scaleway/catalog.go | 25 +++++++++ .../catalog_local_image_filters_test.go | 52 ++++++++++++++++++ .../scaleway/ip_list_filters_test.go | 55 +++++++++++++++++++ internal/providers/scaleway/ipam.go | 11 ++++ .../providers/scaleway/ipam_regional_test.go | 46 ++++++++++++++++ internal/providers/scaleway/ips.go | 28 ++++++++++ .../scaleway/privatenics_v2alpha1.go | 16 ++++++ .../scaleway/privatenics_v2alpha1_test.go | 19 +++++++ 9 files changed, 261 insertions(+) create mode 100644 internal/providers/scaleway/catalog_local_image_filters_test.go create mode 100644 internal/providers/scaleway/ip_list_filters_test.go create mode 100644 internal/providers/scaleway/ipam_regional_test.go diff --git a/docs/limits.md b/docs/limits.md index 77c96036..bd569d76 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -3698,6 +3698,15 @@ real cloud part ways: - **`order_by=attached_at_*` on ipam ListIPs is refused with a 400.** This emulator records no attachment time, and sorting by a stand-in would answer an order nobody asked for. +- **`version_id` on marketplace ListLocalImages is refused with a 400.** + This catalogue records no version for its images, and answering the one + image for an arbitrary version would claim a version nobody recorded. + `image_id` is served: it names the same image the label names. +- **`security_group_ids` on instance/v2alpha1 ListPrivateNetworkInterfaces + is refused with a 400.** This pack records no security group on a private + interface — create and update ignore the `security_group_id` the SDK's + requests carry, and the view does not answer one — so filtering by it would + answer over data this emulator does not keep. - **`include_deleted` on block lists is read and never widens the answer.** Deletion here is immediate — the store retains no `deleted` volume or snapshot for the flag to reveal. The state filter is real code on the diff --git a/internal/providers/scaleway/catalog.go b/internal/providers/scaleway/catalog.go index 1f024719..b72c9fb0 100644 --- a/internal/providers/scaleway/catalog.go +++ b/internal/providers/scaleway/catalog.go @@ -265,6 +265,20 @@ func (p *Pack) listVolumeTypes(w http.ResponseWriter, r *http.Request) { func (p *Pack) listLocalImages(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() label := q.Get("image_label") + // `image_id` names the same image the label names: the UUID a client + // sends after resolving a label through the marketplace (labelByID). It + // resolves the label when none is given, so an id alone still names its + // image; an id outside the table, or one that disagrees with a label the + // client also sent, names no image this catalogue serves, so it answers + // empty like any other filter that matches nothing. + if imageID := q.Get("image_id"); imageID != "" { + resolved, known := labelByID[imageID] + if !known || (label != "" && label != resolved) { + writeEmptyLocalImages(w) + return + } + label = resolved + } if label == "" { label = defaultImageLabel } @@ -274,6 +288,17 @@ func (p *Pack) listLocalImages(w http.ResponseWriter, r *http.Request) { refuseUndeclaredImage(w, label) return } + // `version_id` is refused: this catalogue records no version for its + // images (docs/limits.md), and answering the one image for an arbitrary + // version would claim a version nobody recorded. + if versionID := q.Get("version_id"); versionID != "" { + writeInvalidArguments(w, ArgumentError{ + ArgumentName: "version_id", + Reason: "constraint", + HelpMessage: "feint records no version for its marketplace images; filter by image_id or image_label instead", + }) + return + } zone := q.Get("zone") if zone == "" { zone = "fr-par-1" diff --git a/internal/providers/scaleway/catalog_local_image_filters_test.go b/internal/providers/scaleway/catalog_local_image_filters_test.go new file mode 100644 index 00000000..37982ba6 --- /dev/null +++ b/internal/providers/scaleway/catalog_local_image_filters_test.go @@ -0,0 +1,52 @@ +package scaleway_test + +import ( + "net/http" + "testing" +) + +// `image_id` names the same image the label names, and `version_id` is +// refused: this catalogue records no version for its images (docs/limits.md), +// so answering the one image for an arbitrary version would claim a version +// nobody recorded (#277). +func TestListLocalImagesImageIDAndVersionID(t *testing.T) { + ts := newTestServer(t) + jammy := "22222222-2222-4222-8222-222222222222" + noble := "55555555-5555-4555-8555-555555555555" + + images := func(query string) []map[string]any { + t.Helper() + status, out := do(t, ts, "GET", "/marketplace/v2/local-images"+query, "") + if status != http.StatusOK { + t.Fatalf("GET /local-images%s: %d (%v)", query, status, out) + } + list, _ := out["local_images"].([]any) + got := make([]map[string]any, 0, len(list)) + for _, raw := range list { + img, _ := raw.(map[string]any) + got = append(got, img) + } + return got + } + + // An id alone names its image: the label is resolved from the id, not + // from the default, so the noble id answers the noble image. + if got := images("?image_id=" + noble); len(got) != 1 || got[0]["id"] != noble || got[0]["label"] != "ubuntu_noble" { + t.Errorf("image_id=%s answers %v, want the one noble image", noble, got) + } + if got := images("?image_id=" + jammy); len(got) != 1 || got[0]["id"] != jammy { + t.Errorf("image_id=%s answers %v, want the one jammy image", jammy, got) + } + // An id outside the table names nothing, and one that disagrees with a + // label the client also sent names nothing either. + if got := images("?image_id=99999999-9999-4999-8999-999999999999"); len(got) != 0 { + t.Errorf("an unknown image_id answers %v, want none", got) + } + if got := images("?image_label=ubuntu_noble&image_id=" + jammy); len(got) != 0 { + t.Errorf("a label and an id that disagree answer %v, want none", got) + } + // Any version_id is refused with a 400, not answered with the one image. + if status, _ := do(t, ts, "GET", "/marketplace/v2/local-images?version_id=1", ""); status != http.StatusBadRequest { + t.Errorf("version_id answered %d, want 400", status) + } +} diff --git a/internal/providers/scaleway/ip_list_filters_test.go b/internal/providers/scaleway/ip_list_filters_test.go new file mode 100644 index 00000000..e7921372 --- /dev/null +++ b/internal/providers/scaleway/ip_list_filters_test.go @@ -0,0 +1,55 @@ +package scaleway_test + +import ( + "net/http" + "testing" +) + +// The two filters the contract declared on ListIPs and the handler dropped +// (#277): `server` names the server an address is attached to, `detached` +// names the ones attached to nothing. Both are served conjunctively — an +// address attached to the named server is not detached, so the pair together +// narrows rather than the handler inventing which one wins. +func TestListIPsServerAndDetachedFiltersSelect(t *testing.T) { + ts := newTestServer(t) + a := createFlexibleIP(t, ts) + b := createFlexibleIP(t, ts) + serverID, _ := serverWith(t, ts, `{"name":"filter","commercial_type":"DEV1-S","image":"ubuntu_jammy","public_ips":["`+a+`"]}`) + + ids := func(query string) []string { + t.Helper() + status, out := do(t, ts, "GET", ipOrderZone+"/ips"+query, "") + if status != http.StatusOK { + t.Fatalf("GET /ips%s: %d (%v)", query, status, out) + } + list, _ := out["ips"].([]any) + got := make([]string, 0, len(list)) + for _, raw := range list { + ip, _ := raw.(map[string]any) + id, _ := ip["id"].(string) + got = append(got, id) + } + return got + } + + if got := ids("?detached=true"); len(got) != 1 || got[0] != b { + t.Errorf("detached=true lists %v, want only the unattached %s", got, b) + } + if got := ids("?detached=false"); len(got) != 1 || got[0] != a { + t.Errorf("detached=false lists %v, want only the attached %s", got, a) + } + if got := ids("?server=" + serverID); len(got) != 1 || got[0] != a { + t.Errorf("server=%s lists %v, want only the attached %s", serverID, got, a) + } + if got := ids("?server=00000000-0000-4000-8000-000000000000"); len(got) != 0 { + t.Errorf("an unknown server lists %v, want none", got) + } + // Both together narrow: the address attached to this server is not + // detached, so the pair answers empty rather than either filter alone. + if got := ids("?server=" + serverID + "&detached=true"); len(got) != 0 { + t.Errorf("server+detached lists %v, want none", got) + } + if status, _ := do(t, ts, "GET", ipOrderZone+"/ips?detached=banana", ""); status != http.StatusBadRequest { + t.Errorf("detached=banana answered %d, want 400", status) + } +} diff --git a/internal/providers/scaleway/ipam.go b/internal/providers/scaleway/ipam.go index 23a77280..476fb05d 100644 --- a/internal/providers/scaleway/ipam.go +++ b/internal/providers/scaleway/ipam.go @@ -259,6 +259,17 @@ func (p *Pack) listIPAMIPs(w http.ResponseWriter, r *http.Request) { if wantIPv6 || q.Get("zonal") != "" { all = all[:0] } + // `regional` is the opposite of the `zonal` filter above: every address + // served here is regional, so regional=true keeps everything and + // regional=false matches nothing. + wantRegional, present, err := queryBool(q, "regional") + if err != nil { + writeParseFailure(w, "regional", err) + return + } + if present && !wantRegional { + all = all[:0] + } // Four of the five orders their enum declares are served; attached_at is // refused because this emulator records no attachment time, and sorting by diff --git a/internal/providers/scaleway/ipam_regional_test.go b/internal/providers/scaleway/ipam_regional_test.go new file mode 100644 index 00000000..c3464ad0 --- /dev/null +++ b/internal/providers/scaleway/ipam_regional_test.go @@ -0,0 +1,46 @@ +package scaleway_test + +import ( + "fmt" + "net/http" + "testing" +) + +// `regional` is the opposite of the `zonal` filter the handler already +// served: every address booked here is regional, so regional=true keeps +// everything and regional=false matches nothing (#277). +func TestListIPAMIPsRegionalFilterSelects(t *testing.T) { + ts := newTestServer(t) + pnID, _ := createPN(t, ts, "10.62.0.0/24") + booked := bookIP(t, ts, fmt.Sprintf(`{"source":{"private_network_id":%q},"address":"10.62.0.9"}`, pnID)) + id, _ := booked["id"].(string) + if id == "" { + t.Fatalf("booked ip has no id in %v", booked) + } + + ids := func(query string) []string { + t.Helper() + status, out := do(t, ts, "GET", ipamRegion+"/ips"+query, "") + if status != http.StatusOK { + t.Fatalf("GET /ips%s: %d (%v)", query, status, out) + } + list, _ := out["ips"].([]any) + got := make([]string, 0, len(list)) + for _, raw := range list { + ip, _ := raw.(map[string]any) + ipID, _ := ip["id"].(string) + got = append(got, ipID) + } + return got + } + + if got := ids("?regional=true"); len(got) != 1 || got[0] != id { + t.Errorf("regional=true lists %v, want only the booked %s", got, id) + } + if got := ids("?regional=false"); len(got) != 0 { + t.Errorf("regional=false lists %v, want none: no zonal address is served here", got) + } + if status, _ := do(t, ts, "GET", ipamRegion+"/ips?regional=banana", ""); status != http.StatusBadRequest { + t.Errorf("regional=banana answered %d, want 400", status) + } +} diff --git a/internal/providers/scaleway/ips.go b/internal/providers/scaleway/ips.go index 7409387b..a2a70980 100644 --- a/internal/providers/scaleway/ips.go +++ b/internal/providers/scaleway/ips.go @@ -276,6 +276,14 @@ func (p *Pack) publicAddressesOf(server *resource.Resource) []string { return out } +// ipServerID is the server an address is attached to, or "" when it is +// detached: attach writes Attrs["server"] as {id, name}, detach writes nil. +func ipServerID(res *resource.Resource) string { + summary, _ := res.Attrs["server"].(map[string]any) + id, _ := summary["id"].(string) + return id +} + func (p *Pack) listIPs(w http.ResponseWriter, r *http.Request) { zone, ok := zoneOf(w, r) if !ok { @@ -300,6 +308,26 @@ func (p *Pack) listIPs(w http.ResponseWriter, r *http.Request) { return textOf(res.Attrs["type"]) == ipType }) } + // `server` and `detached` are precisely one of a pair in the SDK + // (ListIPsRequest: "Precisely one of Server, Detached must be set"), but + // the handler applies each filter it is given: an address attached to the + // named server is not detached, so both together narrow conjunctively + // rather than the handler inventing which one wins. + if serverID := q.Get("server"); serverID != "" { + all = filterResources(all, func(res *resource.Resource) bool { + return ipServerID(res) == serverID + }) + } + detached, present, err := queryBool(q, "detached") + if err != nil { + writeParseFailure(w, "detached", err) + return + } + if present { + all = filterResources(all, func(res *resource.Resource) bool { + return (ipServerID(res) == "") == detached + }) + } // "With these exact tags", like the servers list next door: a conjunction. if tags := csvValues(q, "tags"); len(tags) > 0 { all = filterResources(all, func(res *resource.Resource) bool { diff --git a/internal/providers/scaleway/privatenics_v2alpha1.go b/internal/providers/scaleway/privatenics_v2alpha1.go index ff421239..532fa4d2 100644 --- a/internal/providers/scaleway/privatenics_v2alpha1.go +++ b/internal/providers/scaleway/privatenics_v2alpha1.go @@ -68,6 +68,22 @@ func (p *Pack) listPrivateNetworkInterfaces(w http.ResponseWriter, r *http.Reque } q := r.URL.Query() + // `security_group_ids` is refused: this pack records no security group on + // a private interface — create and update ignore the security_group_id the + // SDK's requests carry, and the view does not answer one — so filtering by + // it would answer over data this emulator does not keep (docs/limits.md). + // The SDK lets create and update set the interface's own group, so + // deriving it from the attached server would be a guess, not a + // measurement. No client this project drives passes it: the Terraform + // provider references SecurityGroupIDs nowhere. + if ids := csvValues(q, "security_group_ids"); len(ids) > 0 { + writeInvalidArguments(w, ArgumentError{ + ArgumentName: "security_group_ids", + Reason: "constraint", + HelpMessage: "feint records no security group on a private interface; filter by server_ids, private_network_ids or tags instead", + }) + return + } servers := csvValues(q, "server_ids") networks := csvValues(q, "private_network_ids") tags := csvValues(q, "tags") diff --git a/internal/providers/scaleway/privatenics_v2alpha1_test.go b/internal/providers/scaleway/privatenics_v2alpha1_test.go index 62f9a61b..2bf6ada5 100644 --- a/internal/providers/scaleway/privatenics_v2alpha1_test.go +++ b/internal/providers/scaleway/privatenics_v2alpha1_test.go @@ -244,3 +244,22 @@ func TestAnInterfaceAnswersTheZoneItLivesIn(t *testing.T) { t.Errorf("the single read answers zone = %v, want fr-par-1", one["zone"]) } } + +// `security_group_ids` is refused with a 400: this pack records no security +// group on a private interface, so the filter cannot be served honestly +// (docs/limits.md). The refusal, not a silently unfiltered list, is the +// answer — #277's shape. +func TestTheV2alpha1SecurityGroupFilterIsRefused(t *testing.T) { + ts := newTestServer(t) + attachedNIC(t, ts, "sg", "10.173.0.0/24") + + status, _ := do(t, ts, "GET", v2alphaNICs+"?security_group_ids=11111111-1111-4111-8111-111111111111", "") + if status != http.StatusBadRequest { + t.Fatalf("security_group_ids answered %d, want 400", status) + } + // And the unfiltered list still serves the interface. + body := v2alphaList(t, ts, "") + if list, _ := body["private_network_interfaces"].([]any); len(list) != 1 { + t.Errorf("the unfiltered list has %d interface(s), want 1", len(list)) + } +}