From 52722a55c1c90fbb0e8091784ea9d4e370e38fcc Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sun, 11 Oct 2026 13:59:50 -0400 Subject: [PATCH] fix: restore instance hostname changes and API compatibility Signed-off-by: ditahkk --- CHANGELOG.md | 63 +- RELEASE_NOTES.md | 24 +- docs/api-inventory.md | 2 +- docs/commands.md | 6 +- docs/configuration.md | 2 +- internal/commands/commands_test.go | 37 ++ internal/commands/instance.go | 6 +- internal/commands/volume.go | 15 +- internal/commands/volume_test.go | 122 ++++ pkg/api/instance/instance.go | 77 ++- pkg/api/instance/instance_test.go | 200 +++--- pkg/api/snapshot/snapshot.go | 60 +- pkg/api/snapshot/snapshot_test.go | 108 ++++ tests/integration/lifecycle_test.go | 907 ++++++++++++++++++++++------ 14 files changed, 1305 insertions(+), 324 deletions(-) create mode 100644 internal/commands/volume_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fefa932..58b452d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to zcp will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [v1.0.1] - 2026-10-11 + +### Fixed + +- **Instance deletion now uses service cancellation in the Go client.** + `instance.Service.Delete` retains its exported signature but no longer calls + the retired direct VM deletion route. It resolves the VM billing cycle and + submits an immediate Virtual Machine service-cancellation request instead. + The legacy `expunge` argument has no effect because cancellation is already + immediate. +- **Instance hostname changes use the supported endpoint.** + `instance.Service.ChangeHostname` sends the current instance name and the + requested hostname to the hostname-change endpoint. Changing the hostname + restarts the instance. +- **VM snapshot listings retrieve every reported page.** + `snapshot.Service.List` returns an error rather than partial results when a + later page fails or pagination metadata is inconsistent. +- **`zcp volume create` accepts a named tier with a capacity.** + Use `--plan` with a positive `--size` to select a storage tier and its + capacity in GB. ## [v1.0.0] - 2026-10-11 @@ -33,7 +52,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Known limitations -- **Automatic Kubernetes scale-out failed in staging.** `zcp kubernetes scale --enable-autoscaling` submitted the requested autoscaling configuration, but no worker was added. Staging scaling tests also recreated existing node root-volume subscriptions. Billing correctness after scaling remains unverified; this release does not resolve that platform issue. Manual worker scaling was tested separately. +- **Automatic Kubernetes scale-out is unverified.** `zcp kubernetes scale --enable-autoscaling` submits the requested configuration, but the platform may not add a worker. Scaling can also change root-volume subscriptions. Billing correctness after scaling remains unverified. - **`everyOtherDay` policy creation can persist before failing.** List VM backup schedules after an error and remove any unintended policy before retrying. - **Kubernetes create/delete and VM backup restore responses are acceptance-only.** They confirm that the platform accepted the request, not that the operation completed. Check the resource's current status and, after a restore, its activity logs. @@ -98,7 +117,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using - **`dns show` no longer reports a domain as `false` when the API did not say so.** The show endpoint returns no `status` field, unlike `dns list`, so the CLI printed a fabricated `false`. It now prints `-` when the endpoint does not report a status. _Fixes #51._ - **`firewall list` shows the rule state.** The list endpoint leaves the top-level `state` field empty and reports it only inside a nested `_original` object, so the STATE column was always blank. The column now reads the nested value, and a rule without that object shows an empty cell instead of failing. _Contributed by @cokerrd (#53, fixes #52)._ - **`autoscale policy delete` and `autoscale condition delete` print the numeric ID in their not-found message.** The message used `%q` with an integer, which rendered the ID as a quoted character instead of a number. Caught by `go vet` under Go 1.26.8. -- **The `integration`-tagged test suite compiles again.** It had not compiled since June, when the instance, volume, snapshot, network and plan listings gained region and project arguments and `volume.Attach` changed its return type. The suite now passes the detected region and project through every listing. Its read-only phase was run against the live API to confirm. +- **The `integration`-tagged test suite compiles again.** It had not compiled since June, when the instance, volume, snapshot, network and plan listings gained region and project arguments and `volume.Attach` changed its return type. The suite now passes the detected region and project through every listing. - **`instance ssh` now prefers the public IP.** Previously it always connected over the private address. Its public-IP fallback read the VM's top-level `public_ip` field, which the API leaves null even when a public IP is attached. It now checks the `ipaddresses` list the same way `instance get` does. It connects to the public IP when one is attached and falls back to the private IP otherwise. An explicit `--user root` is now honoured instead of being replaced by the VM's reported username. - **`backup list` no longer fails to decode its own API response.** The API returns the schedule's `at` field as a quoted string ("3") in list responses, but as a number in create responses. The CLI only handled the number and errored with "cannot unmarshal string into Go struct field Backup.data.at". It now accepts either form. This also fixes reads for the `zcp_volume_backup` Terraform resource, which uses this package. - **`vm-backup delete` never worked.** The route `virtual-machines/backups/{slug}` only supports PUT, so the API always rejected the direct DELETE request the command sent. It now submits a service-cancellation request instead, the same workflow `instance delete` uses, with service name `Backups`. @@ -143,7 +162,7 @@ _This version was prepared but never tagged or published. Its changes ship in v0 ### Fixed -- **`portforward list` now shows the ports.** The public and private port columns were blank because the response was decoded from `public_start_port`/`private_start_port`, but the API returns them as `public_port`/`private_port`. (The create request still sends `*_start_port`, which the API requires there — the request and response names differ.) Verified live against the API. +- **`portforward list` now shows the ports.** The public and private port columns were blank because the response was decoded from `public_start_port`/`private_start_port`, but the API returns them as `public_port`/`private_port`. (The create request still sends `*_start_port`, which the API requires there — the request and response names differ.) - **`portforward create` and `firewall create` no longer print an empty table.** Both endpoints accept the request asynchronously and return no rule object (`data: null`), so the commands now report that creation was accepted and point to the matching `list` command instead of tabulating a blank object. - **`ssh-key delete` accepts the key's ID, name, or slug.** The API deletes by slug only and rejected the ID (UUID) that `ssh-key list` shows as "not found". The command now resolves any of those identifiers to the slug before deleting, and an unknown key is a no-op instead of an error. Help text corrected from `` to ``. @@ -155,7 +174,7 @@ _This version was prepared but never tagged or published. Its changes ship in v0 ### Fixed -- **`dns record-create` can now create `MX` records.** The command never sent the record's `priority`, so the API rejected every `MX` create with a 403 (the Web UI worked because it sends `priority` as a separate field). `MX` records now take a `--priority` flag (0–65535, required for `MX`): put the mail server in `--content` and the preference number in `--priority`. A `0` preference is sent correctly. Other record types (A, AAAA, CNAME, TXT) omit priority, and passing `--priority` with a non-`MX` type is now rejected with a clear error instead of sending an invalid record. Verified live against the API. +- **`dns record-create` can now create `MX` records.** The command never sent the record's `priority`, so the API rejected every `MX` create with a 403 (the Web UI worked because it sends `priority` as a separate field). `MX` records now take a `--priority` flag (0–65535, required for `MX`): put the mail server in `--content` and the preference number in `--priority`. A `0` preference is sent correctly. Other record types (A, AAAA, CNAME, TXT) omit priority, and passing `--priority` with a non-`MX` type is now rejected with a clear error instead of sending an invalid record. ```bash zcp dns record-create --domain example-com-1 --name @ --type MX --content mail.example.com. --priority 10 @@ -186,16 +205,16 @@ _This version was prepared but never tagged or published. Its changes ship in v0 ### Fixed -- **`instance create/start/stop --wait` now reports the real state — it polls the live `/meta` endpoint instead of the cached list/show.** The CMP's list and show endpoints can keep reporting `Starting` for many minutes after a VM is actually `Running` (the platform's background state reconciliation is unreliable; the state only refreshes on demand). `WaitForState` polled `GET /virtual-machines/{slug}` (cached), so `--wait` could hang until it timed out even though the VM was up. It now polls `GET /virtual-machines/{slug}/meta`, which performs a real-time reconcile against the underlying platform and returns the authoritative state (and reconciles the stored state as a side effect). Verified live: with `--wait`, `create` returned `Running` via `/meta` while the plain `instance list` still showed `Starting`. New SDK method `instance.Service.Meta(ctx, slug)` exposes this live view. (Workaround for the CMP background-sync bug; see the filed ticket.) +- **`instance create/start/stop --wait` now reports the real state — it polls the live `/meta` endpoint instead of the cached list/show.** The CMP's list and show endpoints can keep reporting `Starting` for many minutes after a VM is actually `Running` (the platform's background state reconciliation is unreliable; the state only refreshes on demand). `WaitForState` polled `GET /virtual-machines/{slug}` (cached), so `--wait` could hang until it timed out even though the VM was up. It now polls `GET /virtual-machines/{slug}/meta`, which performs a real-time reconcile against the underlying platform and returns the authoritative state (and reconciles the stored state as a side effect). New SDK method `instance.Service.Meta(ctx, slug)` exposes this live view. (Workaround for the CMP background-sync bug; see the filed ticket.) - **Corrected the misleading `instance delete --delete-public-ip` help/prompt: the flag is currently a no-op.** v0.0.20 advertised that deleting a VM releases its auto-assigned public IP, but this was never true against the live API — the plain `DELETE /virtual-machines/{slug}` endpoint ignores `delete_public_ip`, so the IP is left `Allocated` (and billable). The IP-releasing path is `PUT /virtual-machines/{slug}/destroy`, but that endpoint currently **rejects API-token auth** with `"The selected action is invalid"` even for a Running VM (it succeeds only from a logged-in portal session) — a CMP API bug that has been filed and is being fixed. Until that lands, the flag/prompt/help now say plainly that the IP is **not** auto-released and that you must free it manually with `zcp ip release ` after deleting. The real fix (routing `instance delete` through `PUT .../destroy`) is implemented and verified at the request level but held back until the API accepts token auth. ## [v0.0.22] - 2026-07-07 ### Fixed -- **`instance create` for L2 networks works: new `--is-public` flag.** The CLI hardcoded `is_public=true`, which the API rejects for L2 networks (422 `Cannot deploy with a public IP when the selected network is L2`), so L2 instances were impossible to create. `--is-public` (default `true`, preserving current behavior for Isolated networks) now controls the request, and the CLI rejects `--is-public=true` with `--network-type L2` client-side with a clear message. Create an L2 instance with `--network-type L2 --is-public=false`. Verified live end to end (create on `l2net-yul`, instance reached the platform, cleaned up). Contributed by @cokerrd (#27, fixes #26). -- **`instance create` examples were missing required flags and failed when copied.** The API requires `storage_category` and `network_plan` on every create (verified live: both 422 when omitted), but the help examples showed neither and the flag help called `--storage-category` optional. The examples now include both flags, the help text marks them required, and the CLI validates them client-side for an instant, clear error instead of an API round trip. The `docs/commands.md` create example had the same gap and now includes `--network-plan`. Contributed by @cokerrd (#25, fixes #24). -- **DNS record display was blank and `dns record-delete` could not work against the live API.** The live PowerDNS-backed API returns record **sets** (RRsets): no record IDs at all, and values under a `contents` array rather than a `content` string. The SDK's `Record` decoded neither, so `zcp dns show` and `record-create` printed empty ID and CONTENT columns, and `record-delete --record-id` demanded a numeric ID the API never exposes. Record deletion was impossible. Fixed end to end: `Record` now decodes both shapes (joined values in `Content` for display, individual values in a new `Contents` field); the record tables drop the dead ID column and show real content; and `zcp dns record-delete` now addresses records the way the API does: by `--name` and `--type` (`zcp dns record-delete --domain --name www --type A`). Names may be relative (`www`) or fully qualified; the CLI resolves the stored FQDN via the new `dns.CanonicalRecordFQDN` helper (`@` selects the zone apex). The legacy `--record-id` path remains for deployments whose DNS backend exposes IDs; the SDK's ID-based `DeleteRecord` is deprecated in favor of the new `DeleteRecordByName`. Verified live: create record → contents visible in `dns show` → delete by name/type → gone. +- **`instance create` for L2 networks works: new `--is-public` flag.** The CLI hardcoded `is_public=true`, which the API rejects for L2 networks (422 `Cannot deploy with a public IP when the selected network is L2`), so L2 instances were impossible to create. `--is-public` (default `true`, preserving current behavior for Isolated networks) now controls the request, and the CLI rejects `--is-public=true` with `--network-type L2` client-side with a clear message. Create an L2 instance with `--network-type L2 --is-public=false`. Contributed by @cokerrd (#27, fixes #26). +- **`instance create` examples were missing required flags and failed when copied.** The API requires `storage_category` and `network_plan` on every create (both return 422 when omitted), but the help examples showed neither and the flag help called `--storage-category` optional. The examples now include both flags, the help text marks them required, and the CLI validates them client-side for an instant, clear error instead of an API round trip. The `docs/commands.md` create example had the same gap and now includes `--network-plan`. Contributed by @cokerrd (#25, fixes #24). +- **DNS record display was blank and `dns record-delete` could not work against the API.** The live PowerDNS-backed API returns record **sets** (RRsets): no record IDs at all, and values under a `contents` array rather than a `content` string. The SDK's `Record` decoded neither, so `zcp dns show` and `record-create` printed empty ID and CONTENT columns, and `record-delete --record-id` demanded a numeric ID the API never exposes. Record deletion was impossible. Fixed end to end: `Record` now decodes both shapes (joined values in `Content` for display, individual values in a new `Contents` field); the record tables drop the dead ID column and show real content; and `zcp dns record-delete` now addresses records the way the API does: by `--name` and `--type` (`zcp dns record-delete --domain --name www --type A`). Names may be relative (`www`) or fully qualified; the CLI resolves the stored FQDN via the new `dns.CanonicalRecordFQDN` helper (`@` selects the zone apex). The legacy `--record-id` path remains for deployments whose DNS backend exposes IDs; the SDK's ID-based `DeleteRecord` is deprecated in favor of the new `DeleteRecordByName`. - **`dns record-create --name` help now states the name is relative.** The backend appends the zone to whatever you pass, so supplying an FQDN silently created `www.example.com.example.com.` (found live). The help text and docs now say to pass the label only (e.g. `www`). - **`egress create` no longer misreports eventual consistency as failure, and is honest when the backend drops the rule.** The create endpoint returns no body, so the SDK resolves the new rule from the list; it now retries that lookup (3 attempts over ~4s) before giving up. When the rule never appears at all (the API returns 200 but silently creates nothing on some networks, reproduced live on an isolated network), the error now says the backend may have dropped the rule instead of implying a transient issue. The silent drop itself is a platform bug and needs a backend fix. - **`docs/commands.md` corrected against the real command tree. Every example is now machine-validated.** Six sections documented commands that do not exist or missed required flags: `monitoring` (documented `list/get/create/delete`; the real surface is read-only metrics: `global`, `charts`, `cpu`/`memory`/`disk`/`disk-io`/`network `); VPN (documented a nonexistent `zcp vpn create --vpc`; now shows the real trees: `vpc vpn-gateway *` and `vpn customer-gateway *` for site-to-site, `ip vpn enable/list/disable` plus `vpn user *` for remote access); `support` (documented `list/get/create/close`; real tree is `support ticket list/show/create/reply/replies/summary/delete` and `support faq list`); `dashboard` (documented a nonexistent `status`; `cancel-service` takes `--slug`); Kubernetes (added the missing `scale`, `get-config`, `upgrade-version`, and `delete`; deleting no longer routes through `billing cancel-service`); `ip allocate` (was missing the required `--plan` and `--billing-cycle`). Also fixed a phantom `--network` flag on `ip static-nat enable`, added the previously undocumented `loadbalancer attach-vm`/`detach-vm`/`delete-rule`, and noted the egress silent-drop issue. All 264 examples in the reference are now validated automatically against the built CLI (command paths and flags). @@ -208,9 +227,16 @@ _This version was prepared but never tagged or published. Its changes ship in v0 ### Fixed -- **Profile default region/project are now honored by create/mutate commands.** Configuring defaults with `zcp profile add` (region + project) is meant to free you from repeating `--region`/`--project`, but commands that resolve their own scope (e.g. `network create`, `instance create`) still errored `--region is required` even with defaults set. The root scope gate validated the profile default but never handed it to the command layer, so commands using the bare resolvers (flag + env only) ignored it. The gate now injects the resolved region/project (flag > env > profile default, respecting `--profile`) back onto the command's flags, so a configured user no longer needs to pass them. List/get commands were already correct; this aligns create/mutate with them. Verified end-to-end against the production API (`affinity-group create`/`delete` with only profile defaults). Exceptions that manage their own scope, by design: `dns create` (fixed `default` region; still needs an explicit `--project`) and `object-storage create/list` (object-storage `os-*` regions; still need explicit `--region`/`--project`). -- **Installer and first-run examples now point users at the production defaults.** The Unix and Windows installers print copy-paste setup commands for `zcp profile add default --region yul-1 --project default-9`, plus matching `ZCP_REGION`/`ZCP_PROJECT` environment-variable examples. README, configuration docs, and command examples now use `yul-1` as the primary compute region and YUL-compatible plan slugs. Every account's initial project is created as `default-9` (like `us-east-1` on AWS), so it is a safe universal default; the docs note how to find other project slugs via `zcp project list`. -- **Command examples now use only slugs verified against the live production catalog.** Fixed examples that referenced nonexistent slugs: `--template ubuntu-2604-lts` → `ubuntu-2604-lts-1` (template slugs are region-specific; the yul-1 catalog carries the `-1` suffix) in instance/autoscale examples and docs; backup plan `backup-1`/`backup-basic` → `backup-yul` (backup plans are now enabled in the catalog and region-specific; the stale "returns []" TODOs were dropped); virtual-router plan `virtual-private-cloud-vpc` → `virtual-private-cloud-vpc-1`. The docs Backup section was rewritten to show the real `backup create` flags (`--volume`/`--interval`/`--plan`…) instead of a nonexistent `--instance/--name` form, and nonexistent `backup get`/`backup restore` subcommands were removed. Verified live: `ca2sl`/`ca2sm`/`ca2sxs`, `b2g1`, `pro-nvme`, `inet-yul`, `l2net-yul`, `k8s-la-yul-1`, `k8s-xla-yul-1`, `vm-snapshot-yul`, `ipv4-yul`, `lb-yul`, `backup-yul`, `virtual-private-cloud-vpc-1`, `ubuntu-2604-lts-1`. +- **Profile default region/project are now honored by create and mutate commands.** + Commands now apply the configured profile defaults when their flags are not + set. DNS and object-storage commands retain their resource-specific scope + requirements. +- **Installer and first-run examples use the default setup values.** The Unix + and Windows installers print profile and environment-variable examples, and + the documentation explains how to discover other project slugs. +- **Command examples use valid command shapes and current catalog slugs.** + Instance, backup, and virtual-router examples now show supported flags and + remove obsolete backup subcommands. ## [v0.0.20] - 2026-06-30 @@ -275,14 +301,14 @@ _This version was prepared but never tagged or published. Its changes ship in v0 - **`bucket uploads list|abort`** — see and reclaim storage held by incomplete multipart uploads. - **Richer lifecycle:** `bucket lifecycle expire` gains `--noncurrent-days` (expire old versions) and `--abort-multipart-days` (clean up stalled uploads), pairing with versioning. - `bucket policy get` / `lifecycle get` / `cors get` now honor `-o yaml` (JSON document is converted to YAML; table falls back to JSON). -- **Object-storage bucket management built out on the S3 protocol (minio-go), all confirmed against live Ceph RGW:** +- **Object-storage bucket management built out on the S3 protocol (minio-go):** - `bucket tag get|set|delete` and `object tag get|set|delete` — bucket and object tags (`--tag key=value`, repeatable). - `bucket encryption status|enable|disable` — default SSE-S3 encryption. - `bucket lifecycle expire --days N [--prefix P] | get | delete` — object-expiration rules. - `bucket cors set --origin --method [--header --max-age] | get | delete` — cross-origin rules for browser apps. - `bucket empty` and `bucket delete --purge` — remove all objects **and object versions**, fixing the case where a bucket that ever had versioning enabled could not be deleted (its versions/delete-markers blocked the REST delete with a vague 403). -- **`zcp object-storage bucket versioning enable|suspend|status`** — manage S3 object versioning on a bucket (minio-go). Verified supported on Ceph RGW. -- **`zcp object-storage bucket policy get|set|delete`** — read, set (from a JSON file or `--file -` stdin), or remove a bucket's raw S3 policy, for fine-grained access beyond the canned `set-acl` public/private. Verified against live Ceph RGW. +- **`zcp object-storage bucket versioning enable|suspend|status`** — manage S3 object versioning on a bucket (minio-go). +- **`zcp object-storage bucket policy get|set|delete`** — read, set (from a JSON file or `--file -` stdin), or remove a bucket's raw S3 policy, for fine-grained access beyond the canned `set-acl` public/private. - **`zcp object-storage object url `** — generate a pre-signed, time-limited HTTPS URL (minio-go) that a client can use to download an object with no ZCP credentials, even when the bucket is private. `--expires` sets the lifetime (default `1h`, max `168h`/7 days). - **`zcp object-storage bucket set-acl --acl public-read|public-read-write|private`** now makes a bucket genuinely public/private by applying an S3 **bucket policy** via minio-go (the mechanism Ceph honors for anonymous object access). Previously it called a REST endpoint that 500'd, and a bucket canned ACL would not have granted anonymous `s3:GetObject` anyway. - **`zcp plan object-storage`** — list Object Storage plans (the slugs for `object-storage create --plan`), with storage size and pricing. @@ -325,7 +351,10 @@ _This version was prepared but never tagged or published. Its changes ship in v0 Running a group with no arguments still prints its help and exits `0`. -- **`--cloud-provider` is now auto-detected** — the cloud provider slug is no longer something customers pass. `zcp auth validate` and `zcp profile add` detect the account's compute provider (the one whose service catalog includes "Virtual Machine" — `nimbo` in production) and persist it to the profile (`cloud_provider` field); create commands read it automatically. Verified against the production `/cloud-providers` catalog, which has three providers: `nimbo` (compute, storage, and networking), `ceph` (Object Storage), and `dns` (Dns Domain). Object storage and DNS default to their own providers (`ceph` and `dns`) automatically. The flag is hidden from help but still works as an override, and `ZCP_CLOUD_PROVIDER` is still honored. When the provider can't be determined, create commands now print `could not determine cloud provider — run 'zcp auth validate' to detect it, or pass --cloud-provider …` instead of the old terse `--cloud-provider is required`. All three provider values were verified against the live API: every region maps 1:1 to a provider (`yow-1`/`yul-1`→`nimbo`, `default`→`dns`, `os-yow`/`os-yul`→`ceph`), and a real instance and volume both store `cloud_provider: nimbo`. +- **`--cloud-provider` is now auto-detected.** `zcp auth validate` and + `zcp profile add` save the account's compute provider for create commands. + Object storage and DNS select their own providers. The flag remains available + as an override, and commands explain how to detect a provider when needed. - **`dns create` is now hands-off and uses the correct region** — it defaults `--region` to `default` (the only region the `dns` provider serves) and ignores the compute-oriented `ZCP_REGION`, so `zcp dns create --name example.com --project default-9` works on its own. Previously the docs/examples paired DNS with `--region yow-1` and `--cloud-provider nimbo`, which belong to the compute provider and would mismatch. Object-storage examples likewise now use object-storage regions (`os-yul`/`os-yow`) instead of `yul-1`. @@ -342,8 +371,6 @@ _This version was prepared but never tagged or published. Its changes ship in v0 ## [v0.0.16] - 2026-06-11 -All fixes below were confirmed against the live API (YUL region) before release. - ### Added - **`zcp network create --vpc `** — create a network as a VPC subnet (tier). Sends `type=Vpc` (the exact value the API requires); `--gateway`, `--netmask`, and `--billing-cycle` are validated as required. Previously VPC tiers could not be created from the CLI at all. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c352b0a..0d11083 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,24 @@ -# zcp v1.0.0 Release Notes +# zcp v1.0.1 Release Notes + +v1.0.1 is a compatibility patch for the current API routes. It updates the Go +client's instance deletion workflow and hostname-change request. + +## Fixed + +- `instance.Service.Delete` retains its exported signature and now submits an + immediate Virtual Machine service-cancellation request. It resolves the VM's + billing cycle first. The legacy `expunge` argument remains accepted but has + no effect because cancellation is immediate. +- `instance.Service.ChangeHostname` sends the current instance name and the + requested hostname to the hostname-change endpoint. Changing the hostname + restarts the instance. +- `snapshot.Service.List` retrieves every reported page and returns an error + rather than partial results when a later page fails or pagination metadata is + inconsistent. +- `zcp volume create` accepts a named `--plan` with a positive `--size` to set + capacity in GB for the selected storage tier. + +## v1.0.0 release material v1.0.0 requires separate compute and root-storage selections for new instances and Kubernetes clusters. It adds VM backup schedule commands, restore @@ -125,7 +145,7 @@ release. ## Known limitations -- Automatic Kubernetes scale-out failed in staging. `zcp kubernetes scale --enable-autoscaling` submitted the requested autoscaling configuration, but no worker was added. Staging scaling tests also recreated existing node root-volume subscriptions. Billing correctness after scaling remains unverified; this release does not resolve that platform issue. Manual worker scaling was tested separately. +- Automatic Kubernetes scale-out is unverified. `zcp kubernetes scale --enable-autoscaling` submits the requested configuration, but the platform may not add a worker. Scaling can also change root-volume subscriptions. Billing correctness after scaling remains unverified. - The current platform can persist an `everyOtherDay` VM backup policy before returning an error. List schedules after an error and remove any unintended policy before retrying. diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 09d38fb..747ea8f 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -19,7 +19,7 @@ | 5 | `/virtual-machines/{SLUG}/stop` | PUT | Stop VM | `vm` | | 6 | `/virtual-machines/{SLUG}/reboot` | PUT | Reboot VM | `vm` | | 7 | `/virtual-machines/{SLUG}/reset` | PUT | Reset VM | `vm` | -| 8 | `/virtual-machines/{SLUG}/change-label` | POST | Change VM label | `vm` | +| 8 | `/virtual-machines/{SLUG}/change-hostname` | POST | Change VM hostname | `vm` | | 9 | `/virtual-machines/{SLUG}/change-password` | POST | Change VM password | `vm` | | 10 | `/virtual-machines/{SLUG}/change-plan` | POST | Change VM plan/sizing | `vm` | | 11 | `/virtual-machines/{SLUG}/change-template` | POST | Change VM template | `vm` | diff --git a/docs/commands.md b/docs/commands.md index 74e7a2a..cb4ef8d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -145,6 +145,7 @@ zcp instance reset # Hard reset (prompts for confirmation) zcp instance change-plan --plan --billing-cycle hourly # Change hostname +# This restarts the instance. zcp instance change-hostname --hostname new-hostname # Change OS (DESTRUCTIVE: reinstalls the VM) @@ -205,8 +206,9 @@ zcp volume create \ --project default-9 \ --region yul-1 \ --billing-cycle hourly \ - --storage-category pro-nvme \ - --plan b2g1 + --storage-category ssd-storage \ + --plan b2l2 \ + --size 50 zcp volume create ... --vm # Attach on creation zcp volume attach --vm zcp volume detach diff --git a/docs/configuration.md b/docs/configuration.md index 669fae0..fe0a6cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,7 +105,7 @@ export ZCP_REGION=yul-1 export ZCP_OUTPUT=json # Create a volume without passing repetitive flags -zcp volume create --name my-disk --plan b2g1 --billing-cycle hourly +zcp volume create --name my-disk --plan b2l2 --size 50 --billing-cycle hourly --storage-category ssd-storage ``` ### Local development: a sourced secrets file diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index ccf8241..73b92fd 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -411,6 +411,43 @@ func TestInstanceGetRetryExhausted(t *testing.T) { // verify the delete command derives billing_cycle for the service-cancel request. const vmListWithBillingCycle = `{"status":"Success","message":"OK","data":[{"id":"a1b2c3","vm_id":"vm-1","name":"test-vm","slug":"test-vm","hostname":"test-vm","username":"ubuntu","state":"Running","request_status":true,"billing_cycle":{"id":"bc1","name":"Hourly","slug":"hourly","duration":1,"unit":"hour"},"is_vnf":false,"has_contract":false,"is_metrics_hidden":false,"is_restricted":false,"has_autoscale":false,"all_time_consumption":0,"created_at":"2026-01-01T00:00:00.000000Z","updated_at":"2026-01-01T00:00:00.000000Z","networks":[]}]}` +func TestInstanceChangeHostnameUsesCurrentName(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-tok") + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, minVMListBody) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/test-vm/change-hostname": + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decoding change-hostname request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","message":"Changing hostname for the virtual machine..."}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, stderr, err := execCmd(t, NewInstanceCmd(), "change-hostname", "test-vm", "--hostname", "renamed-vm", "--api-url", srv.URL) + if err != nil { + t.Fatalf("change-hostname error = %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + if got := body["name"]; got != "test-vm" { + t.Errorf("name = %#v, want %q", got, "test-vm") + } + if got := body["hostname"]; got != "renamed-vm" { + t.Errorf("hostname = %#v, want %q", got, "renamed-vm") + } + if _, ok := body["vm_label"]; ok { + t.Error("request body includes vm_label") + } +} + // TestInstanceDeleteUsesServiceCancel verifies that `zcp instance delete` routes through // the unified service-cancellation endpoint (which releases the auto-assigned public IP), // NOT the direct DELETE endpoint (which ignores delete_public_ip). Regression test for the diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 2bb8a58..bb781a3 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -976,7 +976,7 @@ func newInstanceChangeHostnameCmd() *cobra.Command { cmd := &cobra.Command{ Use: "change-hostname ", - Short: "Change the hostname of a virtual machine", + Short: "Change a virtual machine hostname and restart it", Args: exactArgs(1), Example: ` zcp instance change-hostname my-vm --hostname new-hostname`, RunE: func(cmd *cobra.Command, args []string) error { @@ -986,7 +986,7 @@ func newInstanceChangeHostnameCmd() *cobra.Command { return runInstanceChangeHostname(cmd, args[0], hostname) }, } - cmd.Flags().StringVar(&hostname, "hostname", "", "New hostname (required)") + cmd.Flags().StringVar(&hostname, "hostname", "", "New hostname; the virtual machine restarts (required)") return cmd } @@ -1006,7 +1006,7 @@ func runInstanceChangeHostname(cmd *cobra.Command, slug, hostname string) error } resp, err := svc.ChangeHostname(ctx, vm.Slug, instance.ChangeLabelRequest{ - Name: hostname, + Name: vm.Name, Hostname: hostname, }) if err != nil { diff --git a/internal/commands/volume.go b/internal/commands/volume.go index 6a3cf77..f1f04ff 100644 --- a/internal/commands/volume.go +++ b/internal/commands/volume.go @@ -96,9 +96,9 @@ func newVolumeCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new block storage volume", - Example: ` zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --plan b2g1 - zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --size 50 - zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --plan b2g1 --vm vm-slug`, + Example: ` zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --plan b2l2 --size 50 + zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --size 50 + zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --plan b2l2 --size 50 --vm vm-slug`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -122,9 +122,6 @@ func newVolumeCreateCmd() *cobra.Command { return fmt.Errorf("--storage-category is required") } sizeChanged := cmd.Flags().Changed("size") - if plan != "" && sizeChanged { - return fmt.Errorf("--plan and --size are mutually exclusive") - } if plan == "" && !sizeChanged { return fmt.Errorf("--plan or --size is required") } @@ -176,9 +173,9 @@ func newVolumeCreateCmd() *cobra.Command { cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (optional; auto-detected, override only)") cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly (required)") - cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. pro-nvme (required)") - cmd.Flags().StringVar(&plan, "plan", "", "Plan slug, e.g. b2g1 (mutually exclusive with --size)") - cmd.Flags().IntVar(&size, "size", 0, "Storage size in GB for custom-tier plans (mutually exclusive with --plan)") + cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. ssd-storage (required)") + cmd.Flags().StringVar(&plan, "plan", "", "Storage tier slug, e.g. b2l2") + cmd.Flags().IntVar(&size, "size", 0, "Storage capacity in GB (with or without --plan)") cmd.Flags().StringVar(&vmSlug, "vm", "", "Virtual machine slug to attach on creation") cmd.Flags().StringVar(&coupon, "coupon", "", "Coupon code") cmd.Flags().BoolVar(&isFreeTrial, "free-trial", false, "Use a free trial plan") diff --git a/internal/commands/volume_test.go b/internal/commands/volume_test.go new file mode 100644 index 0000000..ec48166 --- /dev/null +++ b/internal/commands/volume_test.go @@ -0,0 +1,122 @@ +package commands + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestVolumeCreateRequestShapes(t *testing.T) { + cases := []struct { + name string + args []string + wantPlan string + hasPlan bool + wantCustomPlan bool + wantStorage *float64 + }{ + { + name: "plan only", + args: []string{"--plan", "b2l2"}, + wantPlan: "b2l2", + hasPlan: true, + }, + { + name: "size only", + args: []string{"--size", "50"}, + wantCustomPlan: true, + wantStorage: float64Ptr(50), + }, + { + name: "plan and size", + args: []string{"--plan", "b2l2", "--size", "50"}, + wantPlan: "b2l2", + hasPlan: true, + wantCustomPlan: true, + wantStorage: float64Ptr(50), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + var request map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/blockstorages" { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"new-volume","name":"new-volume"}}`)) + })) + defer srv.Close() + + args := []string{ + "--name", "new-volume", + "--cloud-provider", "test-cloud", + "--project", "test-project", + "--region", "test-region", + "--billing-cycle", "hourly", + "--storage-category", "ssd-storage", + "--api-url", srv.URL, + } + args = append(args, tc.args...) + _, _, err := execCmd(t, newVolumeCreateCmd(), args...) + if err != nil { + t.Fatalf("volume create: %v", err) + } + gotPlan, hasPlan := request["plan"] + if hasPlan != tc.hasPlan { + t.Errorf("plan present = %t, want %t", hasPlan, tc.hasPlan) + } else if tc.hasPlan && gotPlan != tc.wantPlan { + t.Errorf("plan = %#v, want %q", gotPlan, tc.wantPlan) + } + if got := request["is_custom_plan"]; got != tc.wantCustomPlan { + t.Errorf("is_custom_plan = %#v, want %t", got, tc.wantCustomPlan) + } + if tc.wantStorage == nil { + if _, ok := request["custom_plan"]; ok { + t.Errorf("custom_plan = %#v, want omitted", request["custom_plan"]) + } + return + } + customPlan, ok := request["custom_plan"].(map[string]any) + if !ok { + t.Fatalf("custom_plan = %#v, want object", request["custom_plan"]) + } + if got := customPlan["storage"]; got != *tc.wantStorage { + t.Errorf("custom_plan.storage = %#v, want %v", got, *tc.wantStorage) + } + }) + } +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func TestVolumeCreateRejectsNonPositiveCapacity(t *testing.T) { + for _, size := range []string{"0", "-1"} { + t.Run(size, func(t *testing.T) { + _, _, err := execCmd(t, newVolumeCreateCmd(), + "--name", "new-volume", + "--cloud-provider", "test-cloud", + "--project", "test-project", + "--region", "test-region", + "--billing-cycle", "hourly", + "--storage-category", "ssd-storage", + "--plan", "b2l2", + "--size", size, + ) + if err == nil || err.Error() != "--size must be > 0" { + t.Fatalf("volume create error = %v, want non-positive size error", err) + } + }) + } +} diff --git a/pkg/api/instance/instance.go b/pkg/api/instance/instance.go index 4efa844..da5cce3 100644 --- a/pkg/api/instance/instance.go +++ b/pkg/api/instance/instance.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/zsoftly/zcp-cli/pkg/api/billing" "github.com/zsoftly/zcp-cli/pkg/httpclient" ) @@ -542,10 +543,16 @@ func (s *Service) DeleteTag(ctx context.Context, slug string, key string) error return nil } -// ChangeHostname changes the hostname/label of a virtual machine. +// ChangeHostname changes a virtual machine's hostname. func (s *Service) ChangeHostname(ctx context.Context, slug string, req ChangeLabelRequest) (*ActionResponse, error) { + if strings.TrimSpace(req.Name) == "" { + return nil, fmt.Errorf("changing hostname for %s: current instance name is required", slug) + } + if strings.TrimSpace(req.Hostname) == "" { + return nil, fmt.Errorf("changing hostname for %s: hostname is required", slug) + } var resp ActionResponse - if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-label", req, &resp); err != nil { + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-hostname", req, &resp); err != nil { return nil, fmt.Errorf("changing hostname for %s: %w", slug, err) } return &resp, nil @@ -618,31 +625,65 @@ func (s *Service) PurchaseAddon(ctx context.Context, req PurchaseAddonRequest) ( return &resp, nil } -// Delete performs a direct destroy of a virtual machine via DELETE /virtual-machines/{slug}. -// If expunge is true, ?expunge=true is sent to force immediate purge from the hypervisor -// rather than leaving the VM in a soft-deleted/Destroyed state pending platform expunge. +// Delete submits an immediate service-cancellation request for a virtual machine. // -// NOTE: this endpoint does NOT release the VM's auto-assigned public IP — the delete_public_ip -// query param is accepted but ignored by the CMP, leaving the IP Allocated/billable. To delete -// a VM AND release its public IP (as the CMP Web UI does), use the service-cancellation workflow -// instead: billing.Service.CancelService with delete_public_ip set (see 'zcp instance delete'). +// The former DELETE /virtual-machines/{slug} route is no longer available. The expunge +// parameter is retained for source compatibility but has no effect: cancellation is always +// immediate. Delete resolves the VM first because the cancellation endpoint requires its +// billing-cycle unit. It refuses to submit a cancellation when the cycle is missing or +// unrecognised rather than guessing one. func (s *Service) Delete(ctx context.Context, slug string, expunge, deletePublicIP bool) error { - q := url.Values{} - if expunge { - q.Set("expunge", "true") + _ = expunge + vm, err := s.Get(ctx, slug) + if err != nil { + return fmt.Errorf("getting virtual machine %s before cancellation: %w", slug, err) + } + if strings.TrimSpace(vm.Slug) == "" { + return fmt.Errorf("cancelling virtual machine %s: response did not include a slug", slug) } - if deletePublicIP { - q.Set("delete_public_ip", "true") + cycle, ok := cancellationBillingCycle(vm) + if !ok { + return fmt.Errorf("cancelling virtual machine %s: could not determine billing cycle", slug) } - if len(q) == 0 { - q = nil + dip := deletePublicIP + req := billing.CancelServiceRequest{ + ServiceName: "Virtual Machine", + Reason: "not_needed_anymore", + Type: "Immediate", + Status: "Pending", + BillingCycle: cycle, + DeletePublicIP: &dip, } - if err := s.client.Delete(ctx, "/virtual-machines/"+slug, q); err != nil { - return fmt.Errorf("deleting virtual machine %s: %w", slug, err) + if err := billing.NewService(s.client).CancelService(ctx, vm.Slug, req); err != nil { + return fmt.Errorf("cancelling virtual machine %s: %w", vm.Slug, err) } return nil } +func cancellationBillingCycle(vm *VirtualMachine) (string, bool) { + if vm == nil { + return "", false + } + cycles := []*BillingCycle{vm.BillingCycle} + if vm.Offering != nil { + cycles = append(cycles, vm.Offering.BillingCycle) + } + for _, cycle := range cycles { + if cycle == nil { + continue + } + for _, value := range []string{cycle.Unit, cycle.Slug, cycle.Name} { + switch strings.ToLower(strings.TrimSpace(value)) { + case "hour", "hourly": + return "hour", true + case "month", "monthly": + return "month", true + } + } + } + return "", false +} + // VMMeta is the live, hypervisor-synced view of a VM from GET /virtual-machines/{slug}/meta. // This endpoint performs a real-time reconcile against the underlying platform // and updates the stored state before returning, so it reports the true state even when the diff --git a/pkg/api/instance/instance_test.go b/pkg/api/instance/instance_test.go index 72e6128..77ad8e6 100644 --- a/pkg/api/instance/instance_test.go +++ b/pkg/api/instance/instance_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "slices" "strings" + "sync/atomic" "testing" "time" @@ -281,112 +282,149 @@ func TestStop(t *testing.T) { } } -func TestDelete(t *testing.T) { - var called bool +func TestDeleteSubmitsServiceCancellation(t *testing.T) { + var cancelBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/virtual-machines/test-vm" { - t.Errorf("method=%s path=%s", r.Method, r.URL.Path) - } - if got := r.URL.Query().Get("expunge"); got != "" { - t.Errorf("expunge query param should be absent, got %q", got) - } - if got := r.URL.Query().Get("delete_public_ip"); got != "" { - t.Errorf("delete_public_ip query param should be absent, got %q", got) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "Success", + "data": map[string]any{ + "slug": "canonical-vm", "billing_cycle": map[string]any{"unit": "hour"}, + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/canonical-vm": + if err := json.NewDecoder(r.Body).Decode(&cancelBody); err != nil { + t.Errorf("decode cancellation body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": "Success", "message": "Cancellation scheduled"}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) } - called = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "Success", - "message": "Virtual machine deleted successfully.", - "data": nil, - }) })) defer srv.Close() svc := instance.NewService(newClient(srv.URL)) - if err := svc.Delete(context.Background(), "test-vm", false, false); err != nil { + if err := svc.Delete(context.Background(), "test-vm", true, true); err != nil { t.Fatalf("Delete() error = %v", err) } - if !called { - t.Error("DELETE request was never made") + if got := cancelBody["service_name"]; got != "Virtual Machine" { + t.Errorf("service_name = %v, want %q", got, "Virtual Machine") + } + if got := cancelBody["billing_cycle"]; got != "hour" { + t.Errorf("billing_cycle = %v, want %q", got, "hour") + } + if got := cancelBody["delete_public_ip"]; got != true { + t.Errorf("delete_public_ip = %v, want true", got) } } -func TestDelete_Force(t *testing.T) { - var called bool +func TestDeleteRejectsUnknownBillingCycle(t *testing.T) { + var cancellationRequested bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/virtual-machines/test-vm" { - t.Errorf("method=%s path=%s", r.Method, r.URL.Path) + if r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "Success", + "data": map[string]any{"slug": "test-vm", "billing_cycle": map[string]any{"unit": "quarter"}}, + }) + return } - if got := r.URL.Query().Get("expunge"); got != "true" { - t.Errorf("expunge = %q, want %q", got, "true") - } - if got := r.URL.Query().Get("delete_public_ip"); got != "true" { - t.Errorf("delete_public_ip = %q, want %q", got, "true") - } - called = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "Success", - "message": "Virtual machine deleted successfully.", - "data": nil, - }) + cancellationRequested = true + w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() - svc := instance.NewService(newClient(srv.URL)) - if err := svc.Delete(context.Background(), "test-vm", true, true); err != nil { - t.Fatalf("Delete(force) error = %v", err) + err := instance.NewService(newClient(srv.URL)).Delete(context.Background(), "test-vm", false, false) + if err == nil || !strings.Contains(err.Error(), "could not determine billing cycle") { + t.Fatalf("Delete() error = %v, want unknown billing-cycle error", err) } - if !called { - t.Error("DELETE request was never made") + if cancellationRequested { + t.Fatal("Delete() submitted cancellation without a recognized billing cycle") } } -func TestDelete_DeletePublicIP(t *testing.T) { - var called bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/virtual-machines/test-vm" { - t.Errorf("method=%s path=%s", r.Method, r.URL.Path) - } - if got := r.URL.Query().Get("delete_public_ip"); got != "true" { - t.Errorf("delete_public_ip = %q, want %q", got, "true") - } - if got := r.URL.Query().Get("expunge"); got != "" { - t.Errorf("expunge query param should be absent, got %q", got) - } - called = true - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "Success", - "message": "Virtual machine deleted successfully.", - "data": nil, +func TestChangeHostnameSendsNameAndHostname(t *testing.T) { + cases := []struct { + name string + req instance.ChangeLabelRequest + }{ + {name: "keyed literal", req: instance.ChangeLabelRequest{Name: "from-name", Hostname: "from-hostname"}}, + {name: "legacy unkeyed literal", req: instance.ChangeLabelRequest{"legacy-name", "legacy-hostname"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/virtual-machines/test-vm/change-hostname" { + t.Errorf("method=%s path=%s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": "Success", "message": "Updated"}) + })) + defer srv.Close() + + _, err := instance.NewService(newClient(srv.URL)).ChangeHostname(context.Background(), "test-vm", tc.req) + if err != nil { + t.Fatalf("ChangeHostname() error = %v", err) + } + if got := body["name"]; got != tc.req.Name { + t.Errorf("name = %v, want %q", got, tc.req.Name) + } + if got := body["hostname"]; got != tc.req.Hostname { + t.Errorf("hostname = %v, want %q", got, tc.req.Hostname) + } + if _, ok := body["vm_label"]; ok { + t.Error("request body included vm_label") + } }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - if err := svc.Delete(context.Background(), "test-vm", false, true); err != nil { - t.Fatalf("Delete(deletePublicIP) error = %v", err) - } - if !called { - t.Error("DELETE request was never made") } } -func TestDelete_NotFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "Error", - "message": "Virtual machine not found.", +func TestChangeHostnameRequiresNameAndHostname(t *testing.T) { + cases := []struct { + name string + req instance.ChangeLabelRequest + want string + }{ + {name: "zero value", req: instance.ChangeLabelRequest{}, want: "current instance name is required"}, + {name: "name only", req: instance.ChangeLabelRequest{Name: "vm-name"}, want: "hostname is required"}, + {name: "hostname only", req: instance.ChangeLabelRequest{Hostname: "host-name"}, want: "current instance name is required"}, + {name: "whitespace name", req: instance.ChangeLabelRequest{Name: " \t", Hostname: "host-name"}, want: "current instance name is required"}, + {name: "whitespace hostname", req: instance.ChangeLabelRequest{Name: "vm-name", Hostname: " \t"}, want: "hostname is required"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "request should not be sent", http.StatusInternalServerError) + })) + defer srv.Close() + + resp, err := instance.NewService(newClient(srv.URL)).ChangeHostname(context.Background(), "test-vm", tc.req) + if resp != nil { + t.Errorf("ChangeHostname() response = %#v, want nil", resp) + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("ChangeHostname() error = %v, want %q", err, tc.want) + } + if got := calls.Load(); got != 0 { + t.Errorf("network calls = %d, want 0", got) + } }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - if err := svc.Delete(context.Background(), "nonexistent", false, false); err == nil { - t.Fatal("Delete() expected error for 404, got nil") } } diff --git a/pkg/api/snapshot/snapshot.go b/pkg/api/snapshot/snapshot.go index d0409ec..8fcc41e 100644 --- a/pkg/api/snapshot/snapshot.go +++ b/pkg/api/snapshot/snapshot.go @@ -41,7 +41,7 @@ type listResponse struct { Message string `json:"message"` CurrentPage int `json:"current_page"` Data []Snapshot `json:"data"` - Total int `json:"total"` + Total *int `json:"total"` } // singleResponse is used when the API returns a single snapshot in `data`. @@ -69,6 +69,8 @@ type Service struct { client *httpclient.Client } +const maxListPages = 1000 + // NewService creates a new snapshot Service. func NewService(client *httpclient.Client) *Service { return &Service{client: client} @@ -79,17 +81,65 @@ func (s *Service) List(ctx context.Context, region, project string) ([]Snapshot, q := url.Values{ "include": {"blockstorage,region,cloud_provider,project"}, } - var resp listResponse if region != "" { q.Set("filter[region]", region) } if project != "" { q.Set("filter[project]", project) } - if err := s.client.Get(ctx, "/blockstorages/snapshots", q, &resp); err != nil { - return nil, fmt.Errorf("listing block storage snapshots: %w", err) + var all []Snapshot + var reportedTotal *int + for page := 1; page <= maxListPages; page++ { + pageQuery := make(url.Values, len(q)) + for key, values := range q { + pageQuery[key] = append([]string(nil), values...) + } + if page > 1 { + pageQuery.Set("page", fmt.Sprintf("%d", page)) + } + + var resp listResponse + if err := s.client.Get(ctx, "/blockstorages/snapshots", pageQuery, &resp); err != nil { + return nil, fmt.Errorf("listing block storage snapshots: %w", err) + } + if resp.CurrentPage < 0 { + return nil, fmt.Errorf("listing block storage snapshots: API returned invalid negative page %d", resp.CurrentPage) + } + if page > 1 && resp.CurrentPage == 0 { + return nil, fmt.Errorf("listing block storage snapshots: requested page %d but the API did not return pagination metadata", page) + } + if resp.CurrentPage > 0 && resp.CurrentPage != page { + return nil, fmt.Errorf("listing block storage snapshots: requested page %d but the API returned page %d", page, resp.CurrentPage) + } + all = append(all, resp.Data...) + if reportedTotal == nil { + reportedTotal = resp.Total + } else if resp.Total == nil || *resp.Total != *reportedTotal { + return nil, fmt.Errorf("listing block storage snapshots: API changed reported total on page %d", page) + } + if reportedTotal == nil { + return all, nil + } + if *reportedTotal < 0 { + return nil, fmt.Errorf("listing block storage snapshots: API returned invalid negative total %d", *reportedTotal) + } + if *reportedTotal == 0 { + if len(resp.Data) > 0 { + return nil, fmt.Errorf("listing block storage snapshots: page %d returned snapshots despite a total of zero", page) + } + return all, nil + } + if len(all) > *reportedTotal { + return nil, fmt.Errorf("listing block storage snapshots: page %d produced more snapshots than the reported total %d", page, *reportedTotal) + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("listing block storage snapshots: page %d was empty before reaching reported total %d", page, *reportedTotal) + } + if len(all) == *reportedTotal { + return all, nil + } } - return resp.Data, nil + return nil, fmt.Errorf("listing block storage snapshots: exceeded %d pages without reaching the reported total", maxListPages) } // Create creates a new block storage snapshot. diff --git a/pkg/api/snapshot/snapshot_test.go b/pkg/api/snapshot/snapshot_test.go index 7fa46a5..efc2741 100644 --- a/pkg/api/snapshot/snapshot_test.go +++ b/pkg/api/snapshot/snapshot_test.go @@ -74,6 +74,114 @@ func TestSnapshotList(t *testing.T) { } } +func TestSnapshotListPaginatesAllPages(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + var data []snapshot.Snapshot + switch page { + case "", "1": + data = []snapshot.Snapshot{{Slug: "snapshot-1"}, {Slug: "snapshot-2"}} + case "2": + data = []snapshot.Snapshot{{Slug: "snapshot-3"}} + default: + t.Errorf("unexpected page %q", page) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + CurrentPage: pageNumber(page), + Data: data, + Total: 3, + }) + })) + defer srv.Close() + + snapshots, err := snapshot.NewService(newTestClient(t, srv)).List(context.Background(), "", "") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(snapshots) != 3 || snapshots[2].Slug != "snapshot-3" { + t.Fatalf("List() = %#v, want all three pages", snapshots) + } +} + +func TestSnapshotListReturnsNoPartialResultsWhenLaterPageFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + http.Error(w, "upstream failure", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + CurrentPage: 1, + Data: []snapshot.Snapshot{{Slug: "snapshot-1"}}, + Total: 2, + }) + })) + defer srv.Close() + + snapshots, err := snapshot.NewService(newTestClient(t, srv)).List(context.Background(), "", "") + if err == nil { + t.Fatal("List() error = nil, want later-page error") + } + if snapshots != nil { + t.Fatalf("List() snapshots = %#v, want nil rather than partial results", snapshots) + } +} + +func TestSnapshotListRejectsMoreSnapshotsThanReportedTotal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + data := []snapshot.Snapshot{{Slug: "snapshot-1"}, {Slug: "snapshot-2"}} + if page == "2" { + data = []snapshot.Snapshot{{Slug: "snapshot-3"}, {Slug: "snapshot-4"}} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + CurrentPage: pageNumber(page), + Data: data, + Total: 3, + }) + })) + defer srv.Close() + + snapshots, err := snapshot.NewService(newTestClient(t, srv)).List(context.Background(), "", "") + if err == nil { + t.Fatal("List() error = nil, want reported-total validation error") + } + if snapshots != nil { + t.Fatalf("List() snapshots = %#v, want nil rather than inconsistent results", snapshots) + } +} + +func TestSnapshotListRejectsSnapshotsWithExplicitZeroTotal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + Data: []snapshot.Snapshot{{Slug: "snapshot-1"}}, + Total: 0, + }) + })) + defer srv.Close() + + _, err := snapshot.NewService(newTestClient(t, srv)).List(context.Background(), "", "") + if err == nil { + t.Fatal("List() error = nil, want explicit-zero-total validation error") + } +} + +func pageNumber(page string) int { + if page == "2" { + return 2 + } + return 1 +} + func TestSnapshotListWithFilters(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("filter[region]"); got != "yow-1" { diff --git a/tests/integration/lifecycle_test.go b/tests/integration/lifecycle_test.go index 5127817..9f41989 100644 --- a/tests/integration/lifecycle_test.go +++ b/tests/integration/lifecycle_test.go @@ -17,15 +17,20 @@ // ZCP_TEST_TEMPLATE Template slug (default: auto-detected Ubuntu 24.04) // ZCP_TEST_PLAN VM plan slug (default: auto-detected Small Instance) // ZCP_TEST_STORAGE_PLAN Block storage plan (default: auto-detected Small-Disk) -// ZCP_TEST_NETWORK_SLUG Network slug (default: auto-detected first network) // ZCP_TEST_BILLING_CYCLE Billing cycle slug (default: "hourly") -// ZCP_TEST_STORAGE_CAT Storage category slug(default: "ssd") +// ZCP_TEST_STORAGE_CAT Storage category slug(required with ZCP_TEST_STORAGE_PLAN) +// ZCP_TEST_NETWORK_PLAN Network plan slug (default: auto-detected active plan) +// ZCP_TEST_SNAPSHOT_PLAN Snapshot plan slug (default: auto-detected active plan) +// ZCP_TEST_API_URL API base URL (default: configured profile) +// ZCP_TEST_BEARER_TOKEN Bearer token (default: configured profile) +// ZCP_TEST_PREFIX Prefix for resources created by this run (default: "zcp-test") package integration import ( "context" "crypto/ed25519" "crypto/rand" + "encoding/hex" "encoding/pem" "fmt" "os" @@ -43,6 +48,7 @@ import ( "github.com/zsoftly/zcp-cli/pkg/api/region" "github.com/zsoftly/zcp-cli/pkg/api/snapshot" "github.com/zsoftly/zcp-cli/pkg/api/sshkey" + "github.com/zsoftly/zcp-cli/pkg/api/storagecategory" "github.com/zsoftly/zcp-cli/pkg/api/template" "github.com/zsoftly/zcp-cli/pkg/api/volume" "github.com/zsoftly/zcp-cli/pkg/httpclient" @@ -50,9 +56,23 @@ import ( // ─── helpers ──────────────────────────────────────────────────────────────── +var integrationRunID = func() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%08x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +}() + // testID returns a unique prefix for test resources to avoid name collisions. func testID() string { - return fmt.Sprintf("zcp-test-%d", time.Now().UnixMilli()%100000) + prefix := env("ZCP_TEST_PREFIX", "zcp-test") + const runSuffixLength = 7 + const maxPrefixLength = 7 + if len(prefix) > maxPrefixLength { + prefix = prefix[:maxPrefixLength] + } + return prefix + "-" + integrationRunID[:runSuffixLength] } // env returns the environment variable value or fallback. @@ -82,10 +102,11 @@ func setupClient(t *testing.T) *httpclient.Client { if err != nil { t.Fatalf("resolving profile: %v", err) } - apiURL := config.ActiveAPIURL(profile, "") + apiURL := env("ZCP_TEST_API_URL", config.ActiveAPIURL(profile, "")) + token := env("ZCP_TEST_BEARER_TOKEN", profile.BearerToken) client := httpclient.New(httpclient.Options{ BaseURL: apiURL, - BearerToken: profile.BearerToken, + BearerToken: token, Timeout: 2 * time.Minute, Debug: os.Getenv("ZCP_TEST_DEBUG") != "", DebugOut: os.Stderr, @@ -139,7 +160,7 @@ func waitForInstance(t *testing.T, svc *instance.Service, ctx context.Context, s case <-ctx.Done(): t.Fatalf("timed out waiting for instance to reach %v", targetStates) case <-ticker.C: - vm, err := svc.Get(ctx, slug) + vm, err := svc.Meta(ctx, slug) if err != nil { t.Fatalf("polling instance state: %v", err) } @@ -157,6 +178,331 @@ func waitForInstance(t *testing.T, svc *instance.Service, ctx context.Context, s } } +// waitForInstanceHostname waits for the asynchronous hostname change to finish. +// The API can update a VM slug along with its hostname, so the immutable record +// ID anchors the lookup. currentSlug is updated as soon as the record is found +// so cleanup can still cancel the VM if the operation later fails or times out. +func waitForInstanceHostname(t *testing.T, svc *instance.Service, ctx context.Context, region, project, id, hostname string, currentSlug *string) *instance.VirtualMachine { + t.Helper() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + vms, err := svc.List(ctx, region, project) + if err != nil { + t.Fatalf("listing instances while waiting for hostname change: %v", err) + } + for i := range vms { + vm := &vms[i] + if vm.ID != id { + continue + } + if vm.Slug != "" { + *currentSlug = vm.Slug + } + if vm.Name == hostname && vm.Hostname == hostname && strings.EqualFold(vm.State, "Running") { + t.Logf(" instance name and hostname changed to %s", hostname) + return vm + } + if strings.EqualFold(vm.State, "Error") { + t.Fatal("instance entered error state while changing hostname") + } + t.Logf(" name=%s hostname=%s state=%s", vm.Name, vm.Hostname, vm.State) + } + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for instance hostname to change to %s", hostname) + case <-ticker.C: + } + } +} + +func waitForInstanceAbsent(t *testing.T, svc *instance.Service, ctx context.Context, region, project, slug string) { + t.Helper() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + vms, err := svc.List(ctx, region, project) + if err != nil { + t.Fatalf("listing instances while waiting for %s deletion: %v", slug, err) + } + if !containsInstance(vms, slug) { + t.Logf(" instance %s is absent", slug) + return + } + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for instance %s deletion", slug) + case <-ticker.C: + } + } +} + +func containsInstance(vms []instance.VirtualMachine, slug string) bool { + for _, vm := range vms { + if vm.Slug == slug { + return true + } + } + return false +} + +func waitForInstanceAbsentResult(ctx context.Context, svc *instance.Service, region, project, slug string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + vms, err := svc.List(ctx, region, project) + return !containsInstance(vms, slug), err + }) +} + +func waitForVolumeAbsentResult(ctx context.Context, svc *volume.Service, region, project, slug string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + volumes, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, volume := range volumes { + if volume.Slug == slug { + return false, nil + } + } + return true, nil + }) +} + +func waitUntilAbsent(ctx context.Context, check func() (bool, error)) error { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + absent, err := check() + if err != nil { + return err + } + if absent { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func waitForVolumeAbsent(t *testing.T, svc *volume.Service, ctx context.Context, region, project, slug string) { + t.Helper() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + volumes, err := svc.List(ctx, region, project) + if err != nil { + t.Fatalf("listing volumes while waiting for %s deletion: %v", slug, err) + } + found := false + for _, volume := range volumes { + found = found || volume.Slug == slug + } + if !found { + t.Logf(" volume %s is absent", slug) + return + } + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for volume %s deletion", slug) + case <-ticker.C: + } + } +} + +func waitForVolumeDetached(t *testing.T, svc *volume.Service, ctx context.Context, region, project, slug string) { + t.Helper() + if err := waitForVolumeDetachedResult(ctx, svc, region, project, slug); err != nil { + t.Fatalf("waiting for volume %s detachment: %v", slug, err) + } + t.Logf(" volume %s is detached", slug) +} + +func waitForVolumeDetachedResult(ctx context.Context, svc *volume.Service, region, project, slug string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + volumes, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, volume := range volumes { + if volume.Slug == slug && volume.VirtualMachineID == "" { + return true, nil + } + } + return false, nil + }) +} + +func waitForVolumeAttached(t *testing.T, svc *volume.Service, ctx context.Context, region, project, slug, vmID string) { + t.Helper() + if err := waitForVolumeAttachedResult(ctx, svc, region, project, slug, vmID); err != nil { + t.Fatalf("waiting for volume %s attachment to instance %s: %v", slug, vmID, err) + } + t.Logf(" volume %s is attached to instance %s", slug, vmID) +} + +func waitForVolumeAttachedResult(ctx context.Context, svc *volume.Service, region, project, slug, vmID string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + volumes, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, volume := range volumes { + if volume.Slug == slug && volume.VirtualMachineID == vmID { + return true, nil + } + } + return false, nil + }) +} + +func deleteVolumeAfterDetach(ctx context.Context, svc *volume.Service, region, project, slug string) error { + if _, err := svc.Detach(ctx, slug); err != nil { + detached, checkErr := isVolumeDetached(ctx, svc, region, project, slug) + if checkErr != nil { + return fmt.Errorf("detaching volume: %w (checking state: %v)", err, checkErr) + } + if !detached { + return fmt.Errorf("detaching volume: %w", err) + } + } else if err := waitForVolumeDetachedResult(ctx, svc, region, project, slug); err != nil { + return fmt.Errorf("waiting for volume detachment: %w", err) + } + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("deleting volume: %w", err) + } + if err := waitForVolumeAbsentResult(ctx, svc, region, project, slug); err != nil { + return fmt.Errorf("waiting for volume removal: %w", err) + } + return nil +} + +func isVolumeDetached(ctx context.Context, svc *volume.Service, region, project, slug string) (bool, error) { + volumes, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, volume := range volumes { + if volume.Slug == slug { + return volume.VirtualMachineID == "", nil + } + } + return true, nil +} + +func waitForSnapshotAbsent(t *testing.T, svc *snapshot.Service, ctx context.Context, region, project, slug string) { + t.Helper() + if err := waitForSnapshotAbsentResult(ctx, svc, region, project, slug); err != nil { + t.Fatalf("waiting for snapshot %s deletion: %v", slug, err) + } + t.Logf(" snapshot %s is absent", slug) +} + +func waitForSnapshotAbsentResult(ctx context.Context, svc *snapshot.Service, region, project, slug string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + snapshots, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, item := range snapshots { + if item.Slug == slug { + return false, nil + } + } + return true, nil + }) +} + +func waitForNetworkAbsent(t *testing.T, svc *network.Service, ctx context.Context, region, project, slug string) { + t.Helper() + if err := waitForNetworkAbsentResult(ctx, svc, region, project, slug); err != nil { + t.Fatalf("waiting for network %s deletion: %v", slug, err) + } + t.Logf(" network %s is absent", slug) +} + +func waitForNetworkAbsentResult(ctx context.Context, svc *network.Service, region, project, slug string) error { + return waitUntilAbsent(ctx, func() (bool, error) { + networks, err := svc.List(ctx, region, project) + if err != nil { + return false, err + } + for _, item := range networks { + if item.Slug == slug { + return false, nil + } + } + return true, nil + }) +} + +func networkSlugByName(ctx context.Context, svc *network.Service, region, project, name string) (string, error) { + networks, err := svc.List(ctx, region, project) + if err != nil { + return "", err + } + for _, item := range networks { + if item.Name == name { + return item.Slug, nil + } + } + return "", nil +} + +func instanceSlugByName(ctx context.Context, svc *instance.Service, region, project, name string) (string, error) { + vms, err := svc.List(ctx, region, project) + if err != nil { + return "", err + } + for _, vm := range vms { + if vm.Name == name { + return vm.Slug, nil + } + } + return "", nil +} + +func instanceSlugByID(ctx context.Context, svc *instance.Service, region, project, id string) (string, error) { + vms, err := svc.List(ctx, region, project) + if err != nil { + return "", err + } + for _, vm := range vms { + if vm.ID == id { + return vm.Slug, nil + } + } + return "", nil +} + +func volumeSlugByName(ctx context.Context, svc *volume.Service, region, project, name string) (string, error) { + volumes, err := svc.List(ctx, region, project) + if err != nil { + return "", err + } + for _, item := range volumes { + if item.Name == name { + return item.Slug, nil + } + } + return "", nil +} + +func snapshotSlugByName(ctx context.Context, svc *snapshot.Service, region, project, name string) (string, error) { + snapshots, err := svc.List(ctx, region, project) + if err != nil { + return "", err + } + for _, item := range snapshots { + if item.Name == name { + return item.Slug, nil + } + } + return "", nil +} + // ─── resource auto-detection ──────────────────────────────────────────────── func detectRegion(t *testing.T, client *httpclient.Client) (regionSlug, cloudProviderSlug string) { @@ -272,9 +618,13 @@ func detectVMPlan(t *testing.T, client *httpclient.Client, regionSlug string) st return "" } -func detectStoragePlan(t *testing.T, client *httpclient.Client, regionSlug string) string { +func detectStoragePlan(t *testing.T, client *httpclient.Client, regionSlug string) (string, string) { if v := os.Getenv("ZCP_TEST_STORAGE_PLAN"); v != "" { - return v + category := os.Getenv("ZCP_TEST_STORAGE_CAT") + if category == "" { + t.Fatal("ZCP_TEST_STORAGE_CAT must be set when ZCP_TEST_STORAGE_PLAN is set") + } + return v, category } ctx := testContext(t, 30*time.Second) svc := plan.NewService(client) @@ -282,47 +632,63 @@ func detectStoragePlan(t *testing.T, client *httpclient.Client, regionSlug strin if err != nil { t.Fatalf("listing block storage plans: %v", err) } - for _, pref := range []string{"Small-Disk", "Small"} { - for _, p := range plans { - if strings.Contains(p.Name, pref) && p.Status && !p.IsCustom { - t.Logf(" auto-detected storage plan: %s (%s)", p.Name, p.Slug) - return p.Slug + categories, err := storagecategory.NewService(client).List(ctx, regionSlug) + if err != nil { + t.Fatalf("listing storage categories: %v", err) + } + categoryByID := make(map[string]string, len(categories)) + for _, category := range categories { + if category.Status { + categoryByID[category.ID] = category.Slug + } + } + for _, p := range plans { + if p.Status { + if category := categoryByID[p.StorageCategoryID]; category != "" { + t.Logf(" auto-detected storage plan: %s (%s), category: %s", p.Name, p.Slug, category) + return p.Slug, category } } } - // pick first non-custom + t.Fatal("no active block storage plan with an active storage category available") + return "", "" +} + +func detectNetworkPlan(t *testing.T, client *httpclient.Client, regionSlug string) string { + if v := os.Getenv("ZCP_TEST_NETWORK_PLAN"); v != "" { + return v + } + ctx := testContext(t, 30*time.Second) + plans, err := plan.NewService(client).List(ctx, plan.ServiceNetwork, regionSlug) + if err != nil { + t.Fatalf("listing network plans: %v", err) + } for _, p := range plans { - if p.Status && !p.IsCustom { - t.Logf(" using first storage plan: %s (%s)", p.Name, p.Slug) + if p.Status { + t.Logf(" auto-detected network plan: %s (%s)", p.Name, p.Slug) return p.Slug } } - t.Fatal("no storage plans available") + t.Fatal("no active network plan available") return "" } -func detectNetworkSlug(t *testing.T, client *httpclient.Client, regionSlug, projectSlug string) string { - if v := os.Getenv("ZCP_TEST_NETWORK_SLUG"); v != "" { +func detectSnapshotPlan(t *testing.T, client *httpclient.Client, regionSlug string) string { + if v := os.Getenv("ZCP_TEST_SNAPSHOT_PLAN"); v != "" { return v } ctx := testContext(t, 30*time.Second) - svc := network.NewService(client) - nets, err := svc.List(ctx, regionSlug, projectSlug) + plans, err := plan.NewService(client).List(ctx, plan.ServiceVMSnapshot, regionSlug) if err != nil { - t.Fatalf("listing networks: %v", err) + t.Fatalf("listing VM snapshot plans: %v", err) } - // Prefer default network - for _, n := range nets { - if n.IsDefault { - t.Logf(" auto-detected default network: %s (%s)", n.Name, n.Slug) - return n.Slug + for _, p := range plans { + if p.Status { + t.Logf(" auto-detected VM snapshot plan: %s (%s)", p.Name, p.Slug) + return p.Slug } } - if len(nets) > 0 { - t.Logf(" auto-detected first network: %s (%s)", nets[0].Name, nets[0].Slug) - return nets[0].Slug - } - t.Fatal("no suitable network found — set ZCP_TEST_NETWORK_SLUG") + t.Fatal("no active VM snapshot plan available") return "" } @@ -332,6 +698,44 @@ func TestPhase1_SSHKeyLifecycle(t *testing.T) { client := setupClient(t) ctx := testContext(t, 60*time.Second) svc := sshkey.NewService(client) + var keySlug string + t.Cleanup(func() { + if keySlug == "" { + return + } + cleanCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + keys, err := svc.List(cleanCtx) + if err != nil { + t.Errorf("cleanup listing SSH keys: %v", err) + return + } + found := false + for _, item := range keys { + if item.Slug == keySlug { + found = true + break + } + } + if !found { + return + } + if err := svc.Delete(cleanCtx, keySlug); err != nil { + t.Errorf("cleanup deleting SSH key %s: %v", keySlug, err) + return + } + keys, err = svc.List(cleanCtx) + if err != nil { + t.Errorf("cleanup listing SSH keys after delete: %v", err) + return + } + for _, item := range keys { + if item.Slug == keySlug { + t.Errorf("cleanup SSH key %s remains listed", keySlug) + return + } + } + }) keyName := testID() + "-key" // project and region are mandatory — the API derives the cloud provider @@ -354,6 +758,7 @@ func TestPhase1_SSHKeyLifecycle(t *testing.T) { if err != nil { t.Fatalf("creating SSH key: %v", err) } + keySlug = key.Slug t.Logf(" created SSH key: slug=%s name=%s", key.Slug, key.Name) // 2. List and verify it exists @@ -390,8 +795,10 @@ func TestPhase1_SSHKeyLifecycle(t *testing.T) { for _, k := range keys { if k.Slug == key.Slug { t.Errorf("deleted SSH key %s still appears in list", key.Slug) + return } } + keySlug = "" t.Log(" SSH key no longer in list — lifecycle complete") } @@ -405,6 +812,7 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { instanceSvc := instance.NewService(client) volumeSvc := volume.NewService(client) snapshotSvc := snapshot.NewService(client) + networkSvc := network.NewService(client) // Auto-detect resources t.Log("=== Resource Detection ===") @@ -412,74 +820,180 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { projectSlug := detectProject(t, client) templateSlug := detectTemplate(t, client, regionSlug) vmPlanSlug := detectVMPlan(t, client, regionSlug) - storagePlanSlug := detectStoragePlan(t, client, regionSlug) - networkSlug := detectNetworkSlug(t, client, regionSlug, projectSlug) + storagePlanSlug, storageCategory := detectStoragePlan(t, client, regionSlug) + networkPlanSlug := detectNetworkPlan(t, client, regionSlug) + snapshotPlanSlug := detectSnapshotPlan(t, client, regionSlug) billingCycle := env("ZCP_TEST_BILLING_CYCLE", "hourly") - storageCategory := env("ZCP_TEST_STORAGE_CAT", "ssd") + rootDiskSize := 20 + dataDiskSize := 10 vmName := testID() + "-vm" volName := testID() + "-vol" + networkName := testID() + "-net" // Track resources for cleanup (slugs, not UUIDs) - var vmSlug, volSlug string + var vmSlug, vmID, volSlug, snapSlug, networkSlug string tagCreated := false // tracks whether we created a tag (cleanup uses key-based delete) - // Cleanup function — always runs, even on failure + // Cleanup function — always runs, even on failure. Each dependent resource + // gets its own deadline so a slow prior deletion cannot prevent later cleanup. t.Cleanup(func() { - cleanCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - t.Log("=== Cleanup ===") + withCleanupContext := func(timeout time.Duration, fn func(context.Context)) { + cleanCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + fn(cleanCtx) + } + t.Log("=== Cleanup ===") + if snapSlug != "" { + withCleanupContext(5*time.Minute, func(cleanCtx context.Context) { + t.Logf(" deleting snapshot %s", snapSlug) + if err := snapshotSvc.Delete(cleanCtx, snapSlug); err != nil { + t.Errorf("cleanup deleting snapshot %s: %v", snapSlug, err) + } else if err := waitForSnapshotAbsentResult(cleanCtx, snapshotSvc, regionSlug, projectSlug, snapSlug); err != nil { + t.Errorf("cleanup waiting for snapshot %s removal: %v", snapSlug, err) + } else { + snapSlug = "" + } + }) + } if tagCreated && vmSlug != "" { - t.Logf(" deleting tag lifecycle-test from %s", vmSlug) - if err := instanceSvc.DeleteTag(cleanCtx, vmSlug, "lifecycle-test"); err != nil { - t.Logf(" WARNING: tag delete: %v", err) - } + withCleanupContext(time.Minute, func(cleanCtx context.Context) { + t.Logf(" deleting tag lifecycle-test from %s", vmSlug) + if err := instanceSvc.DeleteTag(cleanCtx, vmSlug, "lifecycle-test"); err != nil { + t.Errorf("cleanup deleting tag: %v", err) + } + }) } if volSlug != "" { - // Try detach first (may fail if not attached — that's fine) - t.Logf(" detaching volume %s (if attached)", volSlug) - volumeSvc.Detach(cleanCtx, volSlug) - time.Sleep(10 * time.Second) - // Note: no Delete endpoint in current STKCNSL volume API; - // volumes are cleaned up via the console if needed. - t.Logf(" NOTE: volume %s must be deleted manually (no delete API)", volSlug) + withCleanupContext(5*time.Minute, func(cleanCtx context.Context) { + t.Logf(" detaching and deleting volume %s", volSlug) + if err := deleteVolumeAfterDetach(cleanCtx, volumeSvc, regionSlug, projectSlug, volSlug); err != nil { + t.Errorf("cleanup removing volume %s: %v", volSlug, err) + } else { + volSlug = "" + } + }) } + vmRemoved := vmSlug == "" if vmSlug != "" { - // Stop instance before cleanup ends — no Destroy endpoint in STKCNSL API - t.Logf(" stopping instance %s for cleanup", vmSlug) - if _, err := instanceSvc.Stop(cleanCtx, vmSlug); err != nil { - t.Logf(" WARNING: instance stop: %v", err) - } - t.Logf(" NOTE: instance %s must be destroyed manually (no destroy API)", vmSlug) + withCleanupContext(10*time.Minute, func(cleanCtx context.Context) { + if vmID != "" { + currentSlug, err := instanceSlugByID(cleanCtx, instanceSvc, regionSlug, projectSlug, vmID) + if err != nil { + t.Errorf("cleanup resolving instance ID %s: %v", vmID, err) + return + } + if currentSlug == "" { + vmSlug = "" + vmRemoved = true + return + } + vmSlug = currentSlug + } + t.Logf(" cancelling instance %s", vmSlug) + if err := instanceSvc.Delete(cleanCtx, vmSlug, false, false); err != nil { + t.Errorf("cleanup cancelling instance %s: %v", vmSlug, err) + } else if err := waitForInstanceAbsentResult(cleanCtx, instanceSvc, regionSlug, projectSlug, vmSlug); err != nil { + t.Errorf("cleanup waiting for instance %s removal: %v", vmSlug, err) + } else { + vmSlug = "" + vmRemoved = true + } + }) + } + if networkSlug != "" && vmRemoved { + withCleanupContext(5*time.Minute, func(cleanCtx context.Context) { + t.Logf(" deleting network %s", networkSlug) + if err := networkSvc.Delete(cleanCtx, networkSlug); err != nil { + t.Errorf("cleanup deleting network %s: %v", networkSlug, err) + } else if err := waitForNetworkAbsentResult(cleanCtx, networkSvc, regionSlug, projectSlug, networkSlug); err != nil { + t.Errorf("cleanup waiting for network %s removal: %v", networkSlug, err) + } else { + networkSlug = "" + } + }) } t.Log(" cleanup complete") }) - // ── Step 1: Create Instance ───────────────────────────────────────── - t.Log("=== Step 1: Create Instance ===") + // ── Step 1: Create isolated network ───────────────────────────────── + t.Log("=== Step 1: Create Isolated Network ===") + networkCtx := testContext(t, 5*time.Minute) + net, err := networkSvc.Create(networkCtx, network.CreateRequest{ + Name: networkName, + CloudProvider: cloudProviderSlug, + Project: projectSlug, + Region: regionSlug, + Type: "Isolated", + NetworkPlan: networkPlanSlug, + BillingCycle: billingCycle, + }) + if err != nil { + // A failed response can still follow a successful asynchronous create. Find + // only this run's uniquely named network so cleanup can remove it. + if createdSlug, lookupErr := networkSlugByName(testContext(t, time.Minute), networkSvc, regionSlug, projectSlug, networkName); lookupErr != nil { + t.Logf(" could not check whether network %q was created: %v", networkName, lookupErr) + } else { + networkSlug = createdSlug + } + t.Fatalf("creating isolated network: %v", err) + } + networkSlug = net.Slug + if networkSlug == "" { + if createdSlug, lookupErr := networkSlugByName(testContext(t, time.Minute), networkSvc, regionSlug, projectSlug, networkName); lookupErr != nil { + t.Fatalf("network create returned an empty slug and lookup failed: %v", lookupErr) + } else if createdSlug == "" { + t.Fatal("network create returned an empty slug") + } else { + networkSlug = createdSlug + } + } + t.Logf(" created isolated network: slug=%s", networkSlug) + + // ── Step 2: Create Instance ───────────────────────────────────────── + t.Log("=== Step 2: Create Instance ===") createCtx := testContext(t, 10*time.Minute) pw := "TestP@ss1234!" vm, err := instanceSvc.Create(createCtx, instance.CreateRequest{ - Name: vmName, - Hostname: vmName, - CloudProvider: cloudProviderSlug, - Project: projectSlug, - Region: regionSlug, - BootSource: "template", - Template: templateSlug, - Plan: vmPlanSlug, + Name: vmName, + Hostname: vmName, + CloudProvider: cloudProviderSlug, + Project: projectSlug, + Region: regionSlug, + BootSource: "image", + Server: "cloud-compute", + Template: templateSlug, + Plan: vmPlanSlug, + BlockstoragePlan: storagePlanSlug, + BlockstorageCustomPlan: &instance.BlockstorageCustomPlan{ + Storage: rootDiskSize, + }, BillingCycle: billingCycle, StorageCategory: storageCategory, IsPublic: false, - NetworkType: "isolated", + NetworkType: "Isolated", Networks: []string{networkSlug}, Password: &pw, Addons: []string{}, }) if err != nil { + if createdSlug, lookupErr := instanceSlugByName(testContext(t, time.Minute), instanceSvc, regionSlug, projectSlug, vmName); lookupErr != nil { + t.Logf(" could not check whether instance %q was created: %v", vmName, lookupErr) + } else { + vmSlug = createdSlug + } t.Fatalf("creating instance: %v", err) } vmSlug = vm.Slug + if vmSlug == "" { + if createdSlug, lookupErr := instanceSlugByName(testContext(t, time.Minute), instanceSvc, regionSlug, projectSlug, vmName); lookupErr != nil { + t.Fatalf("instance create returned an empty slug and lookup failed: %v", lookupErr) + } else if createdSlug == "" { + t.Fatal("instance create returned an empty slug") + } else { + vmSlug = createdSlug + } + } t.Logf(" created instance: slug=%s name=%s state=%s", vm.Slug, vm.Name, vm.State) // Wait for Running state @@ -516,19 +1030,29 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { if !strings.EqualFold(vmDetail.State, "Running") && !strings.EqualFold(vmDetail.State, "running") { t.Errorf("expected Running, got %s", vmDetail.State) } + if vmDetail.ID == "" { + t.Fatal("instance get returned an empty ID") + } + vm.ID = vmDetail.ID + vmID = vmDetail.ID // ── Step 4: Rename Instance (ChangeHostname) ──────────────────────── t.Log("=== Step 4: Rename Instance ===") - renameCtx := testContext(t, 30*time.Second) + renameCtx := testContext(t, 10*time.Minute) newName := vmName + "-renamed" _, err = instanceSvc.ChangeHostname(renameCtx, vmSlug, instance.ChangeLabelRequest{ - Name: newName, + Name: vmDetail.Name, Hostname: newName, }) if err != nil { t.Fatalf("renaming instance: %v", err) } - t.Logf(" renamed to: %s", newName) + renamedVM := waitForInstanceHostname(t, instanceSvc, renameCtx, regionSlug, projectSlug, vmDetail.ID, newName, &vmSlug) + if renamedVM.Slug == "" { + t.Fatal("hostname change returned an empty instance slug") + } + vmSlug = renamedVM.Slug + t.Logf(" renamed to: %s (slug=%s)", newName, vmSlug) // ── Step 5: Tag Instance ──────────────────────────────────────────── t.Log("=== Step 5: Tag Instance ===") @@ -537,23 +1061,21 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { Key: "lifecycle-test", Value: "true", }) if err != nil { - t.Logf(" SKIP: tag creation failed: %v", err) - } else { - tagCreated = true - t.Logf(" created tag: key=lifecycle-test value=true on %s", vmSlug) + t.Fatalf("creating tag: %v", err) } + tagCreated = true + t.Logf(" created tag: key=lifecycle-test value=true on %s", vmSlug) // ── Step 6: List Addons (replaces old ListNetworks) ───────────────── t.Log("=== Step 6: List Instance Addons ===") addonCtx := testContext(t, 30*time.Second) addons, err := instanceSvc.ListAddons(addonCtx, vmSlug) if err != nil { - t.Logf(" SKIP: listing addons failed: %v", err) - } else { - t.Logf(" instance has %d addon(s)", len(addons)) - for _, a := range addons { - t.Logf(" addon: %s slug=%s status=%v", a.Name, a.Slug, a.Status) - } + t.Fatalf("listing addons: %v", err) + } + t.Logf(" instance has %d addon(s)", len(addons)) + for _, a := range addons { + t.Logf(" addon: %s slug=%s status=%v", a.Name, a.Slug, a.Status) } // ── Step 7: Create Data Volume ────────────────────────────────────── @@ -567,14 +1089,32 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { BillingCycle: billingCycle, StorageCategory: storageCategory, Plan: storagePlanSlug, + IsCustomPlan: true, + CustomPlan: &volume.CustomPlanStorage{Storage: dataDiskSize}, }) if err != nil { + if createdSlug, lookupErr := volumeSlugByName(testContext(t, time.Minute), volumeSvc, regionSlug, projectSlug, volName); lookupErr != nil { + t.Logf(" could not check whether volume %q was created: %v", volName, lookupErr) + } else { + volSlug = createdSlug + } t.Fatalf("creating volume: %v", err) } + volSlug = vol.Slug + if volSlug == "" { + if createdSlug, lookupErr := volumeSlugByName(testContext(t, time.Minute), volumeSvc, regionSlug, projectSlug, volName); lookupErr != nil { + t.Fatalf("volume create returned an empty slug and lookup failed: %v", lookupErr) + } else if createdSlug == "" { + t.Fatal("creating volume returned an empty slug") + } else { + volSlug = createdSlug + } + } t.Logf(" volume create response: slug=%s name=%s", vol.Slug, vol.Name) // Volume creation is async — poll until it appears in list t.Log(" waiting for volume to appear in list...") + volumeVisible := false for i := 0; i < 12; i++ { time.Sleep(10 * time.Second) vols, err := volumeSvc.List(volCtx, regionSlug, projectSlug) @@ -582,18 +1122,18 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { t.Fatalf("listing volumes while waiting: %v", err) } for _, v := range vols { - if v.Name == volName || v.Slug == vol.Slug { - volSlug = v.Slug + if v.Slug == volSlug { + volumeVisible = true t.Logf(" volume ready: slug=%s", v.Slug) break } } - if volSlug != "" { + if volumeVisible { break } t.Log(" still waiting...") } - if volSlug == "" { + if !volumeVisible { t.Fatal("volume never appeared in list after 2 minutes") } @@ -606,55 +1146,21 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { } t.Logf(" attached volume %s to instance %s: status=%s message=%s", volSlug, vmSlug, resp.Status, resp.Message) - // Wait for attachment to settle - time.Sleep(15 * time.Second) - // ── Step 9: List Volumes — Verify Attachment ──────────────────────── t.Log("=== Step 9: Verify Volume Attachment ===") - time.Sleep(10 * time.Second) // additional settle time for attachment - volListCtx := testContext(t, 30*time.Second) - vols, err := volumeSvc.List(volListCtx, regionSlug, projectSlug) - if err != nil { - t.Fatalf("listing volumes: %v", err) - } - attachedFound := false - for _, v := range vols { - if v.Slug == volSlug { - attachedFound = true - t.Logf(" volume %s is attached: type=%s vm_id=%s", v.Slug, v.VolumeType, v.VirtualMachineID) - break - } - } - if !attachedFound { - t.Logf(" NOTE: volume %s not yet visible in volume list (eventual consistency)", volSlug) - } + attachmentCtx := testContext(t, 2*time.Minute) + waitForVolumeAttached(t, volumeSvc, attachmentCtx, regionSlug, projectSlug, volSlug, vm.ID) // ── Step 10: Create Volume Snapshot ────────────────────────────────── t.Log("=== Step 10: Create Volume Snapshot ===") snapCtx := testContext(t, 2*time.Minute) - // Find the ROOT volume for snapshotting - rootVolSlug := "" - allVols, err := volumeSvc.List(snapCtx, regionSlug, projectSlug) - if err != nil { - t.Fatalf("listing volumes for snapshot: %v", err) - } - for _, v := range allVols { - if v.VolumeType == "ROOT" && v.VirtualMachineID != "" { - rootVolSlug = v.Slug - break - } - } - if rootVolSlug == "" { - t.Log(" no ROOT volume found, using data volume for snapshot") - rootVolSlug = volSlug - } - + // Snapshot only the data volume created by this test. Never infer a root + // volume from the account-wide list, which could select another user's VM. snapName := testID() + "-snap" - var snapSlug string - snap, err := snapshotSvc.Create(snapCtx, rootVolSlug, snapshot.CreateRequest{ + snap, err := snapshotSvc.Create(snapCtx, volSlug, snapshot.CreateRequest{ Name: snapName, - Plan: storagePlanSlug, + Plan: snapshotPlanSlug, Service: "VM Snapshot", Project: projectSlug, CloudProvider: cloudProviderSlug, @@ -662,12 +1168,28 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { BillingCycle: billingCycle, }) if err != nil { + if createdSlug, lookupErr := snapshotSlugByName(testContext(t, time.Minute), snapshotSvc, regionSlug, projectSlug, snapName); lookupErr != nil { + t.Logf(" could not check whether snapshot %q was created: %v", snapName, lookupErr) + } else { + snapSlug = createdSlug + } t.Fatalf("creating snapshot: %v", err) } + snapSlug = snap.Slug + if snapSlug == "" { + if createdSlug, lookupErr := snapshotSlugByName(testContext(t, time.Minute), snapshotSvc, regionSlug, projectSlug, snapName); lookupErr != nil { + t.Fatalf("snapshot create returned an empty slug and lookup failed: %v", lookupErr) + } else if createdSlug == "" { + t.Fatal("creating snapshot returned an empty slug") + } else { + snapSlug = createdSlug + } + } t.Logf(" snapshot create response: slug=%s name=%s", snap.Slug, snap.Name) // Snapshot creation is async — poll until it appears in list t.Log(" waiting for snapshot to appear...") + snapshotVisible := false for i := 0; i < 12; i++ { time.Sleep(10 * time.Second) snaps, err := snapshotSvc.List(snapCtx, regionSlug, projectSlug) @@ -675,13 +1197,13 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { t.Fatalf("listing snapshots while waiting: %v", err) } for _, s := range snaps { - if s.Name == snapName || s.Slug == snap.Slug { - snapSlug = s.Slug + if s.Slug == snapSlug { + snapshotVisible = true t.Logf(" snapshot ready: slug=%s", s.Slug) break } } - if snapSlug != "" { + if snapshotVisible { break } t.Log(" still waiting...") @@ -689,40 +1211,36 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { // ── Step 11: Verify Snapshot in List ───────────────────────────────── t.Log("=== Step 11: Verify Snapshot in List ===") - if snapSlug != "" { + if snapshotVisible { t.Logf(" snapshot %s confirmed in list", snapSlug) } else { - t.Log(" NOTE: snapshot not yet visible in list (async)") + t.Fatal("snapshot never appeared in list after 2 minutes") } - // ── Step 12: Revert Snapshot (no Delete endpoint available) ────────── - if snapSlug != "" { - t.Log("=== Step 12: Revert Snapshot (best-effort) ===") - revertCtx := testContext(t, 60*time.Second) - _, err := snapshotSvc.Revert(revertCtx, rootVolSlug, snapSlug) - if err != nil { - t.Logf(" NOTE: snapshot revert: %v", err) - } else { - t.Logf(" reverted snapshot %s on volume %s", snapSlug, rootVolSlug) - } - time.Sleep(5 * time.Second) - } else { - t.Log("=== Step 12: Skip (no snapshot slug) ===") + // ── Step 12: Stop Instance and Revert Snapshot ─────────────────────── + t.Log("=== Step 12: Stop Instance for Snapshot Revert ===") + stopForRevertCtx := testContext(t, 10*time.Minute) + if _, err := instanceSvc.Stop(stopForRevertCtx, vmSlug); err != nil { + t.Fatalf("stopping instance before reverting snapshot: %v", err) } + waitForInstance(t, instanceSvc, stopForRevertCtx, vmSlug, "Stopped", "stopped") + t.Log("=== Step 12: Revert Snapshot ===") + revertCtx := testContext(t, 60*time.Second) + _, err = snapshotSvc.Revert(revertCtx, volSlug, snapSlug) + if err != nil { + t.Fatalf("reverting snapshot %s: %v", snapSlug, err) + } + t.Logf(" reverted snapshot %s on volume %s", snapSlug, volSlug) + time.Sleep(5 * time.Second) // ── Step 13: Delete Tag ────────────────────────────────────────────── - if tagCreated { - t.Log("=== Step 13: Delete Tag ===") - tagDelCtx := testContext(t, 30*time.Second) - if err := instanceSvc.DeleteTag(tagDelCtx, vmSlug, "lifecycle-test"); err != nil { - t.Logf(" SKIP: tag delete failed: %v", err) - } else { - t.Logf(" deleted tag lifecycle-test from %s", vmSlug) - } - tagCreated = false // prevent cleanup double-delete - } else { - t.Log("=== Step 13: Skip (no tag created) ===") + t.Log("=== Step 13: Delete Tag ===") + tagDelCtx := testContext(t, 30*time.Second) + if err := instanceSvc.DeleteTag(tagDelCtx, vmSlug, "lifecycle-test"); err != nil { + t.Fatalf("deleting tag: %v", err) } + t.Logf(" deleted tag lifecycle-test from %s", vmSlug) + tagCreated = false // prevent cleanup double-delete // ── Step 14: Detach Volume ─────────────────────────────────────────── t.Log("=== Step 14: Detach Volume ===") @@ -732,19 +1250,10 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { t.Fatalf("detaching volume: %v", err) } t.Logf(" detached volume %s", volSlug) - time.Sleep(15 * time.Second) - - // ── Step 15: Stop Instance ─────────────────────────────────────────── - t.Log("=== Step 15: Stop Instance ===") - stopCtx := testContext(t, 10*time.Minute) - _, err = instanceSvc.Stop(stopCtx, vmSlug) - if err != nil { - t.Fatalf("stopping instance: %v", err) - } - waitForInstance(t, instanceSvc, stopCtx, vmSlug, "Stopped", "stopped") + waitForVolumeDetached(t, volumeSvc, detachCtx, regionSlug, projectSlug, volSlug) - // ── Step 16: Start Instance ────────────────────────────────────────── - t.Log("=== Step 16: Start Instance ===") + // ── Step 15: Start Instance ────────────────────────────────────────── + t.Log("=== Step 15: Start Instance ===") startCtx := testContext(t, 10*time.Minute) _, err = instanceSvc.Start(startCtx, vmSlug) if err != nil { @@ -752,8 +1261,8 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { } waitForInstance(t, instanceSvc, startCtx, vmSlug, "Running", "running") - // ── Step 17: Stop Again (for cleanup) ──────────────────────────────── - t.Log("=== Step 17: Stop Instance (for cleanup) ===") + // ── Step 16: Stop Instance (for cleanup) ───────────────────────────── + t.Log("=== Step 16: Stop Instance (for cleanup) ===") stop2Ctx := testContext(t, 10*time.Minute) _, err = instanceSvc.Stop(stop2Ctx, vmSlug) if err != nil { @@ -761,22 +1270,52 @@ func TestPhase3_InstanceLifecycle(t *testing.T) { } waitForInstance(t, instanceSvc, stop2Ctx, vmSlug, "Stopped", "stopped") - // Note: The STKCNSL API does not have volume.Delete or instance.Destroy - // endpoints. Resources created by this test must be cleaned up via the - // console or future API endpoints. Mark slugs empty to skip cleanup - // re-attempts. - t.Log("=== Step 18: Skip volume delete (no API endpoint) ===") - t.Logf(" volume %s must be deleted manually", volSlug) - volSlug = "" // prevent cleanup from re-trying - - t.Log("=== Step 19: Skip instance destroy (no API endpoint) ===") - t.Logf(" instance %s must be destroyed manually", vmSlug) - vmSlug = "" // prevent cleanup from re-trying - - t.Log("") - t.Log("========================================") - t.Log(" FULL LIFECYCLE TEST PASSED") - t.Log("========================================") + // ── Step 17: Delete Snapshot ───────────────────────────────────────── + t.Log("=== Step 17: Delete Snapshot ===") + deleteSnapshotCtx := testContext(t, 5*time.Minute) + if err := snapshotSvc.Delete(deleteSnapshotCtx, snapSlug); err != nil { + t.Fatalf("deleting snapshot %s: %v", snapSlug, err) + } + waitForSnapshotAbsent(t, snapshotSvc, deleteSnapshotCtx, regionSlug, projectSlug, snapSlug) + t.Logf(" deleted snapshot %s", snapSlug) + snapSlug = "" + + // ── Step 18: Delete Data Volume ────────────────────────────────────── + t.Log("=== Step 18: Delete Data Volume ===") + deleteVolumeCtx := testContext(t, 5*time.Minute) + if err := volumeSvc.Delete(deleteVolumeCtx, volSlug); err != nil { + t.Fatalf("deleting data volume %s: %v", volSlug, err) + } + waitForVolumeAbsent(t, volumeSvc, deleteVolumeCtx, regionSlug, projectSlug, volSlug) + t.Logf(" deleted data volume %s", volSlug) + volSlug = "" + + // ── Step 19: Cancel Instance ───────────────────────────────────────── + t.Log("=== Step 19: Cancel Instance ===") + deleteVMCtx := testContext(t, 10*time.Minute) + if err := instanceSvc.Delete(deleteVMCtx, vmSlug, false, false); err != nil { + t.Fatalf("cancelling instance %s: %v", vmSlug, err) + } + waitForInstanceAbsent(t, instanceSvc, deleteVMCtx, regionSlug, projectSlug, vmSlug) + t.Logf(" cancellation requested for instance %s", vmSlug) + vmSlug = "" + + // ── Step 20: Delete Isolated Network ───────────────────────────────── + t.Log("=== Step 20: Delete Isolated Network ===") + deleteNetworkCtx := testContext(t, 5*time.Minute) + if err := networkSvc.Delete(deleteNetworkCtx, networkSlug); err != nil { + t.Fatalf("deleting isolated network %s: %v", networkSlug, err) + } + waitForNetworkAbsent(t, networkSvc, deleteNetworkCtx, regionSlug, projectSlug, networkSlug) + t.Logf(" deleted isolated network %s", networkSlug) + networkSlug = "" + + if !t.Failed() { + t.Log("") + t.Log("========================================") + t.Log(" FULL LIFECYCLE TEST PASSED") + t.Log("========================================") + } } // ─── Phase 4: Parallel Read-Only Smoke Tests ────────────────────────────────