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/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/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..bd569d76 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 @@ -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: @@ -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/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/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/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/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)) + } +} 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