diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7aa5550..712fa15 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,7 +124,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: true - name: Get dependencies @@ -191,7 +191,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: true - name: Download dependencies @@ -230,7 +230,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: true - name: Get dependencies diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 83db5b8..0482b94 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -95,7 +95,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v5 - with: { go-version: '1.26.4', cache: true } + with: { go-version: '1.26.5', cache: true } - name: Build zcp binary run: make build diff --git a/CHANGELOG.md b/CHANGELOG.md index cdceaf4..d713532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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). +## [v0.0.23] - 2026-07-08 + +### 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 CloudStack/APC 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.) +- **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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 065b992..916eba0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,36 +1,39 @@ -# zcp v0.0.22 Release Notes - -## DNS records now display and delete correctly - -The live DNS backend (PowerDNS) models records as record **sets** addressed by -name and type. PowerDNS exposes no record IDs and returns values in a -`contents` array. The CLI previously decoded neither, so on PowerDNS-backed -deployments record tables printed blank ID and CONTENT columns, and -`dns record-delete` demanded a numeric `--record-id` those deployments never -expose. Record deletion was impossible there. Backends that do expose record -IDs keep the legacy `--record-id` path. - -This release aligns the CLI with how the backend actually works, verified live -end to end (create → show → delete → confirm gone). - -Highlights: - -- **Record content is visible again.** `zcp dns show` and `record-create` - tables show real values (multi-value sets joined, e.g. - `ns1.zsoftly.ca., ns2.zsoftly.ca.`), and the dead ID column is gone. -- **`dns record-delete` works, by name and type.** -- **Record names are relative.** The backend appends the zone; the help text - now says so (passing an FQDN used to silently create - `www.example.com.example.com.`). -- **`egress create` retries its lookup and reports honestly** when the backend - silently drops an accepted rule (a platform-side issue found while testing). -- **`docs/commands.md` is now machine-validated:** all 264 examples checked - against the built CLI. Six sections documented commands that did not exist - and are rewritten to the real trees. -- **L2 instances work, and `instance create` examples run as pasted** thanks - to first-time contributor @cokerrd: a new `--is-public` flag unblocks - `--network-type L2`, and the required `--network-plan`/`--storage-category` - flags are now in the examples and validated client-side. +# zcp v0.0.23 Release Notes + +## `instance --wait` now reports the VM's real state + +`zcp instance create --wait` (and `start --wait` / `stop --wait`) previously +polled the CMP's cached list/show endpoint, which can keep reporting `Starting` +for many minutes after a VM is actually `Running`. On affected deployments +`--wait` could hang until it timed out even though the VM was already up. + +`--wait` now polls the live `GET /virtual-machines/{slug}/meta` endpoint, which +performs a real-time reconcile against the underlying platform (CloudStack/APC) +and returns the authoritative state. Verified live end to end: with `--wait`, +`create` returned `Running` from `/meta` while the plain `instance list` still +showed `Starting`. + +This is a client-side workaround for a CMP background state-sync issue (the +platform's own reconciliation is unreliable; state only refreshes on demand). +The on-demand `/meta` sync is authoritative, so the CLI polls it. + +## `instance delete --delete-public-ip` help corrected — the flag is currently a no-op + +The flag advertised that deleting a VM releases its auto-assigned public IP, but +that never worked against the live API: the `DELETE` endpoint ignores it, and +the IP-releasing `PUT .../destroy` endpoint currently rejects API-token auth (a +CMP bug, reported and under fix). Until that lands, the help text, confirmation +prompt, and command examples now state plainly that the IP is **not** released +automatically, and that you must free it manually: + +```bash +zcp instance delete my-vm --yes +zcp ip release --yes +``` + +No behavior change — this corrects misleading messaging only. The real fix +(routing `instance delete` through `PUT .../destroy`) is implemented and +verified at the request level, and is held until the API accepts token auth. --- @@ -58,7 +61,7 @@ place it on your `PATH`. **Verify:** ```bash -zcp version # zcp version v0.0.22 +zcp version # zcp version v0.0.23 ``` First-time setup after installing: @@ -72,71 +75,23 @@ zcp auth validate ## Fixed -### DNS record display and deletion +### `instance --wait` reflects the real state ```bash -# Records show their content; sets are addressed by NAME + TYPE (no IDs) -zcp dns show example-com -# NAME TYPE CONTENT TTL -# www.example.com. A 192.0.2.50 3600 -# example.com. NS ns1.zsoftly.ca., ns2.zsoftly.ca. 3600 - -# Create with a RELATIVE name (the backend appends the zone) -zcp dns record-create --domain example-com --name www --type A --content 192.0.2.50 - -# Delete by name and type (relative or fully qualified both work) -zcp dns record-delete --domain example-com --name www --type A +# Create and wait: returns when the VM is actually Running (polled via /meta), +# even while `instance list` still reports Starting. +zcp instance create --name my-vm --project default-9 --region yul-1 \ + --template ubuntu-2604-lts-1 --plan ca2m --billing-cycle hourly \ + --network-plan pnet-yul --storage-category premium-ssd --wait ``` -The legacy `--record-id` flag remains for deployments whose DNS backend exposes -record IDs. SDK consumers get `DeleteRecordByName`, `CanonicalRecordFQDN`, and -`Record.Contents`; the ID-based `DeleteRecord` is deprecated. - -### Egress rule creation reporting - -The create endpoint returns no body, so the CLI resolves the new rule from the -rule list. It now retries that lookup (3 attempts over ~4s) before giving up, -and when the rule never appears (the API can return 200 yet create nothing on -some networks), the error says the backend may have dropped the rule, pointing -at the platform rather than the CLI. +SDK consumers get a new `instance.Service.Meta(ctx, slug)` method that returns +the live, hypervisor-synced view (authoritative `state`). -### L2 instance creation and complete create examples (community) - -Contributed by @cokerrd, our first outside contributor. Two fixes to -`instance create`, both verified against the live API: +### `instance delete --delete-public-ip` messaging ```bash -# L2 networks cannot carry a public IP. The new --is-public flag (default: -# true) unblocks them; the CLI rejects the invalid combination client-side. -zcp instance create --name my-l2-vm --template ubuntu-2604-lts-1 --plan ca2sl \ - --billing-cycle hourly --network-plan l2net-yul --network-type L2 \ - --storage-category premium-ssd --is-public=false \ - --region yul-1 --project default-9 - -# --network-plan and --storage-category are required by the API and are now -# in every example, marked required in help, and validated client-side. -zcp instance create --name my-vm --template ubuntu-2604-lts-1 --plan ca2sl \ - --billing-cycle hourly --network-plan pnet-yul --storage-category premium-ssd \ - --region yul-1 --project default-9 +# The auto-assigned public IP is NOT released automatically yet (known CMP API +# bug, under fix). Free it manually after deleting: +zcp instance delete my-vm --yes && zcp ip release --yes ``` - -### Command reference corrected and machine-validated - -Six sections of `docs/commands.md` documented commands that do not exist -(`monitoring create`, `vpn create --vpc`, `support close`, `dashboard status`, -among others) or missed required flags (`ip allocate` without `--plan`/ -`--billing-cycle`). All are rewritten to the real command trees, including -the previously undocumented `kubernetes scale/get-config/upgrade-version/delete` -and `loadbalancer attach-vm/detach-vm/delete-rule`. Every example in the -reference is now validated automatically against the CLI (command paths and -flags; 264 examples). - ---- - -## New Contributors - -- @cokerrd made their first contributions in - [#25](https://github.com/zsoftly/zcp-cli/pull/25) and - [#27](https://github.com/zsoftly/zcp-cli/pull/27), fixing L2 instance - creation and the `instance create` examples. Both fixes were verified - against the live platform before merge. Welcome, and thank you! diff --git a/go.mod b/go.mod index 45c68fa..2aa2f2e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/zsoftly/zcp-cli go 1.25.0 -toolchain go1.26.4 +toolchain go1.26.5 require ( github.com/olekukonko/tablewriter v0.0.5 diff --git a/internal/commands/instance.go b/internal/commands/instance.go index e9b907d..52bcc3f 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -1368,13 +1368,16 @@ func newInstanceDeleteCmd() *cobra.Command { Example: ` zcp instance delete my-vm zcp instance delete my-vm --yes zcp instance delete my-vm --force --yes - zcp instance delete my-vm --delete-public-ip=false # keep the auto-assigned public IP`, + # the auto-assigned public IP is NOT released yet (known CMP API bug); free it manually: + zcp instance delete my-vm --yes && zcp ip release --yes`, RunE: func(cmd *cobra.Command, args []string) error { slug := args[0] if !yes && !autoApproved(cmd) { ipNote := "" if deletePublicIP { - ipNote = " Its auto-assigned public IP will also be released." + // --delete-public-ip is currently a no-op: the CMP endpoint that + // releases the IP rejects token auth (known bug, fix in progress). + ipNote = " NOTE: the auto-assigned public IP is NOT released automatically yet (known CMP API bug, fix in progress) — release it manually afterward with 'zcp ip release '." } fmt.Fprintf(os.Stderr, "WARNING: Delete %q is permanent and cannot be undone.%s [y/N]: ", slug, ipNote) scanner := bufio.NewScanner(os.Stdin) @@ -1390,7 +1393,7 @@ func newInstanceDeleteCmd() *cobra.Command { } cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") cmd.Flags().BoolVar(&force, "force", false, "Force immediate expunge from hypervisor (passes expunge=true)") - cmd.Flags().BoolVar(&deletePublicIP, "delete-public-ip", true, "Release the public IP(s) auto-assigned to the VM at creation. Manually-acquired and source-NAT IPs are unaffected. Use --delete-public-ip=false to keep them (e.g. if the IP is reused by NAT/LB/shared networks)") + cmd.Flags().BoolVar(&deletePublicIP, "delete-public-ip", true, "Request release of the VM's auto-assigned public IP on delete. NOTE: currently a no-op — the CMP endpoint that releases the IP rejects API-token auth (known bug, fix in progress); until it lands, the IP is left Allocated, so release it manually with 'zcp ip release ' after deleting. Manually-acquired and source-NAT IPs are unaffected either way") return cmd } diff --git a/pkg/api/instance/instance.go b/pkg/api/instance/instance.go index 02945a7..ce6d515 100644 --- a/pkg/api/instance/instance.go +++ b/pkg/api/instance/instance.go @@ -581,7 +581,41 @@ func (s *Service) Delete(ctx context.Context, slug string, expunge, deletePublic return nil } +// 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 (CloudStack/APC) +// and updates the stored state before returning, so it reports the true state even when the +// cached list/show endpoints lag — a Running VM can otherwise stay "Starting" in list/show +// until the platform's own reconciliation catches up. +type VMMeta struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` +} + +// Meta returns the live, hypervisor-synced view of a VM (see VMMeta). Prefer this over Get when +// you need the authoritative current lifecycle state (for example when polling for Running), +// because Get/List can report a stale state for several minutes after a state change. +func (s *Service) Meta(ctx context.Context, slug string) (*VMMeta, error) { + var env SingleEnvelope + if err := s.client.Get(ctx, "/virtual-machines/"+slug+"/meta", nil, &env); err != nil { + return nil, fmt.Errorf("getting virtual machine meta %s: %w", slug, err) + } + if env.Status != "Success" { + return nil, fmt.Errorf("getting virtual machine meta %s: %s", slug, env.Message) + } + var meta VMMeta + if err := json.Unmarshal(env.Data, &meta); err != nil { + return nil, fmt.Errorf("decoding virtual machine meta: %w", err) + } + return &meta, nil +} + // WaitForState polls the VM until it reaches one of the target states or the context is cancelled. +// +// It polls the meta endpoint rather than Get: /meta forces a live reconcile with the hypervisor +// and reports the true state, whereas the cached Get/List can keep reporting "Starting" long +// after the VM is actually Running (the CMP's background reconciliation is unreliable). Calling +// meta also reconciles the stored state, so the follow-up Get returns a consistent object. func (s *Service) WaitForState(ctx context.Context, slug string, targetStates []string, pollInterval time.Duration) (*VirtualMachine, error) { if pollInterval == 0 { pollInterval = 5 * time.Second @@ -593,12 +627,18 @@ func (s *Service) WaitForState(ctx context.Context, slug string, targetStates [] case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: - vm, err := s.Get(ctx, slug) + meta, err := s.Meta(ctx, slug) if err != nil { return nil, err } for _, target := range targetStates { - if strings.EqualFold(vm.State, target) { + if strings.EqualFold(meta.State, target) { + vm, err := s.Get(ctx, slug) + if err != nil { + return nil, err + } + // meta is authoritative; surface it even if Get is momentarily stale. + vm.State = meta.State return vm, nil } }