Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ip-slug>` 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
Expand Down
143 changes: 49 additions & 94 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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 <ip-slug> --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.

---

Expand Down Expand Up @@ -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:
Expand All @@ -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 <ip-slug> --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!
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions internal/commands/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ip-slug> --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 <ip-slug>'."
}
fmt.Fprintf(os.Stderr, "WARNING: Delete %q is permanent and cannot be undone.%s [y/N]: ", slug, ipNote)
scanner := bufio.NewScanner(os.Stdin)
Expand All @@ -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 <ip-slug>' after deleting. Manually-acquired and source-NAT IPs are unaffected either way")
return cmd
}

Expand Down
44 changes: 42 additions & 2 deletions pkg/api/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
Loading