diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..74f1a3c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Normalize all text files to LF, including on Windows checkouts. +# +# gofmt requires LF; on windows-latest runners actions/checkout inherits +# core.autocrlf=true from the runner image, which rewrites every file to CRLF +# at checkout and makes the CI formatting gate flag the whole repo. Checkout +# attributes take precedence over core.autocrlf, so this pins LF everywhere. +# Every tracked blob is already LF (verified 2026-07-06), so no renormalization +# is needed. +* text=auto eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15d183a..7aa5550 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,8 +143,12 @@ jobs: shell: bash run: | if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + # .gitattributes pins eol=lf at checkout; this is the fallback. + # checkout-index --force rewrites every file from the index, + # unlike 'git checkout HEAD -- .', which skips paths the stat + # cache considers clean and silently leaves CRLF in place. git config core.autocrlf false - git checkout HEAD -- . + git checkout-index --force --all fi UNFORMATTED=$(gofmt -s -l .) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e62feb..05158ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.22] - 2026-07-06 + +### Fixed + +- **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. +- **`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). + +### Added + +- **SDK (`pkg/api/dns`):** `DeleteRecordByName(domain, fqdn, type)`, `CanonicalRecordFQDN(name, zone)`, and `Record.Contents []string`. These are the primitives the Terraform provider's `zcp_dns_record` resource also relies on. + ## [v0.0.21] - 2026-07-02 ### Fixed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4d92b84..d7962e1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,130 +1,108 @@ -# zcp v0.0.21 Release Notes +# zcp v0.0.22 Release Notes -## Profile defaults now work everywhere, including create commands +## DNS records now display and delete correctly -`zcp profile add default --region yul-1 --project default-9` stores your default scope -so you never repeat `--region`/`--project`. Until now that promise only held for -list/get commands: create and mutate commands (e.g. `network create`, `instance create`) -resolved their own scope from flags and environment variables only, so a fully -configured user still hit `--region is required`. That gap is closed: configure once, -and scoped commands pick the defaults up. +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: -- **Profile default region/project are honored by create/mutate commands.** The root - scope gate now injects the resolved scope (flag > env > profile default, respecting - `--profile`) onto the command's flags. Verified end-to-end against the production API. -- **First-run setup points at the production defaults.** The installers print - copy-paste setup for `zcp profile add default --region yul-1 --project default-9`; - every account's initial project is `default-9` (like `us-east-1` on AWS). -- **All command examples use slugs verified against the live production catalog.** - Broken template, backup, and virtual-router plan slugs are fixed. +- **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. --- -## Fixed +## Installation and upgrade -### Profile default region/project honored by create/mutate commands +The install script installs the latest release and upgrades an existing +installation in place. -```bash -zcp profile add default --region yul-1 --project default-9 -zcp auth validate +**Linux / macOS** -# Previously: Error: --region is required -# Now: creates the network in yul-1 / default-9 from your profile defaults -zcp network create --name my-net --network-plan inet-yul --billing-cycle hourly +```bash +curl -fsSL https://github.com/zsoftly/zcp-cli/releases/latest/download/install.sh | bash ``` -Explicit `--region`/`--project` flags and `ZCP_REGION`/`ZCP_PROJECT` still take -precedence over the profile default, and `--profile ` selects which profile's -defaults apply. Two command groups manage their own scope by design and are -unaffected: `dns create` (fixed `default` region; still needs an explicit -`--project`) and `object-storage create/list` (object-storage `os-*` regions). - -### First-run examples point at the production defaults - -The Unix and Windows installers now end with copy-paste setup commands -(`zcp profile add default --region yul-1 --project default-9`, `zcp auth validate`) -plus matching `ZCP_REGION`/`ZCP_PROJECT` examples for scripts. README, -configuration docs, and command examples consistently use `yul-1` as the primary -compute region and YUL-compatible plan slugs. +**Windows (PowerShell)** -### Command examples verified against the live production catalog +```powershell +irm https://github.com/zsoftly/zcp-cli/releases/latest/download/install.ps1 | iex +``` -Examples that referenced nonexistent slugs are fixed: +**Manual download:** grab your platform's binary from the +[Releases](https://github.com/zsoftly/zcp-cli/releases) page, `chmod +x`, and +place it on your `PATH`. -| Was | Now | Where | -| --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ | -| `ubuntu-2604-lts` | `ubuntu-2604-lts-1` | instance/autoscale `--template` (template slugs are region-specific; this is yul-1's) | -| `backup-1`, `backup-basic` | `backup-yul` | `backup create`, `vm-backup create` `--plan` (backup plans are now enabled in the catalog) | -| `virtual-private-cloud-vpc` | `virtual-private-cloud-vpc-1` | `virtual-router create --plan` | +**Verify:** -The docs Backup section was rewritten to show the real `backup create` flags -(`--volume`/`--interval`/`--plan` …) and to drop nonexistent `backup get`/`backup -restore` subcommands. Verified live in yul-1: `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`. +```bash +zcp version # zcp version v0.0.22 +``` -## Upgrade notes +First-time setup after installing: -No breaking changes. If you have profile defaults configured (`zcp profile add` -with `--region`/`--project`), create/mutate commands that previously errored -without explicit flags now use those defaults automatically; pass `--region`/ -`--project` (or set `ZCP_REGION`/`ZCP_PROJECT`) to override per invocation. +```bash +zcp profile add default --region yul-1 --project default-9 # prompts for bearer token +zcp auth validate +``` --- -## Installation +## Fixed -### Linux / macOS / WSL (one-liner) +### DNS record display and deletion ```bash -curl -fsSL https://github.com/zsoftly/zcp-cli/releases/latest/download/install.sh | bash -``` - -Installs `zcp` to `/usr/local/bin` (you may be prompted for `sudo`). Set `INSTALL_DIR` to -choose another location, e.g. `INSTALL_DIR="$HOME/.local/bin"`. +# 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 -### Windows (PowerShell) +# 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 -```powershell -irm https://github.com/zsoftly/zcp-cli/releases/latest/download/install.ps1 | iex +# Delete by name and type (relative or fully qualified both work) +zcp dns record-delete --domain example-com --name www --type A ``` -Installs `zcp.exe` to `%LOCALAPPDATA%\Programs\zcp`. +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. -### Manual download +### Egress rule creation reporting -Grab the binary for your platform from the -[Releases page](https://github.com/zsoftly/zcp-cli/releases), make it executable, and put it on -your `PATH`. +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. -| OS | Arch | Asset | -| ------- | ------------- | ----------------------- | -| Linux | x86_64 | `zcp-linux-amd64` | -| Linux | ARM64 | `zcp-linux-arm64` | -| macOS | Intel | `zcp-darwin-amd64` | -| macOS | Apple Silicon | `zcp-darwin-arm64` | -| Windows | x86_64 | `zcp-windows-amd64.exe` | -| Windows | ARM64 | `zcp-windows-arm64.exe` | +### Command reference corrected and machine-validated -```bash -# Linux amd64 example -curl -Lo zcp https://github.com/zsoftly/zcp-cli/releases/latest/download/zcp-linux-amd64 -chmod +x zcp -sudo mv zcp /usr/local/bin/zcp -``` - -```powershell -# Windows amd64 example (PowerShell) -irm https://github.com/zsoftly/zcp-cli/releases/latest/download/zcp-windows-amd64.exe -OutFile zcp.exe -# then move zcp.exe to a directory on your PATH -``` - -### Verify - -```bash -zcp version -zcp --help -``` +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). diff --git a/docs/commands.md b/docs/commands.md index e4ae48a..cc0291f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,11 +1,11 @@ -# ZCP CLI — Command Reference +# ZCP CLI Command Reference Copy-paste examples for every resource the CLI manages. Use `zcp --help` for the full flag list of any command. > All examples use working defaults: region `yul-1`, project `default-9` (every > account's initial project), and billing cycle `hourly`. Substitute your own values -> as needed — run `zcp region list` and `zcp project list` to see what is available +> as needed. Run `zcp region list` and `zcp project list` to see what is available > to your account. ## The cloud provider is automatic @@ -41,7 +41,7 @@ each one works on its own; drop them if you have configured defaults. See ## Discovery -Start here — these read-only commands show what your account can provision. +Start here: these read-only commands show what your account can provision. ```bash # Regions, cloud providers, and other catalog data @@ -54,7 +54,7 @@ zcp storage-category list # storage-category slugs for --storage-category # Plans by service type (preferred over legacy 'offering' commands) zcp plan vm # Virtual Machine plans -zcp plan storage # Block Storage plans — shows storage category slug and pool per plan +zcp plan storage # Block Storage plans: shows storage category slug and pool per plan zcp plan kubernetes # Kubernetes plans zcp plan lb # Load Balancer plans zcp plan router # Virtual Router plans @@ -64,7 +64,7 @@ zcp plan template # My Template plans zcp plan iso # ISO plans zcp plan backup # Backup plans zcp plan network # network plan slugs for --network-plan -zcp plan object-storage # Object Storage plans — slugs for object-storage create --plan +zcp plan object-storage # Object Storage plans: slugs for object-storage create --plan # Images and catalogs zcp template list # VM templates @@ -77,8 +77,8 @@ zcp store list # Store ## Compute -Instance subcommands accept any unique reference to the VM — its **instance ID** -(`vm_id`), **name**, or **slug** — wherever `` appears below. `zcp instance list` +Instance subcommands accept any unique reference to the VM (its **instance ID** +(`vm_id`), **name**, or **slug**) wherever `` appears below. `zcp instance list` shows the `ID` column to copy from. If a name is ambiguous (two VMs share it), the command lists the matching IDs and asks you to use one. @@ -87,7 +87,7 @@ command lists the matching IDs and asks you to use one. zcp instance list zcp instance get -# Create — use --wait to block until the instance is Running +# Create. Use --wait to block until the instance is Running zcp instance create \ --name my-vm \ --project default-9 \ @@ -113,7 +113,7 @@ zcp instance change-plan --plan --billing-cycle hourly # Change hostname zcp instance change-hostname --hostname new-hostname -# Change OS (DESTRUCTIVE — reinstalls the VM) +# Change OS (DESTRUCTIVE: reinstalls the VM) zcp instance change-os --template ubuntu-2604-lts-1 # Change startup script @@ -206,22 +206,25 @@ zcp network create --name my-net --network-plan inet-yul --billing-cycle hourly zcp network update --name "New Name" zcp network delete # also releases the SOURCE-NAT IP; use after VMs are removed -# VPC subnets (tiers) — optionally attach a custom ACL at creation +# VPC subnets (tiers): optionally attach a custom ACL at creation zcp network create --name web-tier --vpc --acl \ --gateway 10.1.1.1 --netmask 255.255.255.0 --billing-cycle hourly \ --region yul-1 --project default-9 -# Public IP addresses +# Public IP addresses. Plan slugs come from `zcp plan ip` zcp ip list -zcp ip allocate --network +zcp ip allocate --network --plan --billing-cycle hourly zcp ip release -zcp ip static-nat enable --instance --network +zcp ip static-nat enable --instance # Firewall rules (ingress) zcp firewall list zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 # Egress rules +# Known issue: on some networks the API accepts the create but the rule never +# appears in the list (the backend drops it silently). The CLI retries the +# lookup and reports this explicitly when it happens. zcp egress list zcp egress create --network --protocol tcp @@ -255,7 +258,7 @@ zcp vpc create \ --billing-cycle hourly \ --storage-category pro-nvme -# 1. add a tier (subnet) inside the VPC — REQUIRED before any VM can use it +# 1. add a tier (subnet) inside the VPC. REQUIRED before any VM can use it zcp network create --name web-tier --vpc my-vpc \ --gateway 10.1.1.1 --netmask 255.255.255.0 \ --billing-cycle hourly --region yul-1 --project default-9 @@ -275,18 +278,36 @@ zcp acl delete-rule web-acl zcp acl replace --network --acl web-acl --vpc zcp acl delete web-acl -# Public load balancers +# Public load balancers. Acquires a new public IP by default; pass --ip to reuse one zcp loadbalancer list -zcp loadbalancer create --ip --name my-lb --network \ - --billing-cycle hourly --public-port 80 --private-port 8080 --algorithm roundrobin +zcp loadbalancer create --name my-lb --network \ + --billing-cycle hourly --public-port 80 --private-port 8080 --algorithm roundrobin \ + --region yul-1 --project default-9 zcp loadbalancer create-rule --name api-rule \ --public-port 8443 --private-port 443 --protocol tcp --algorithm leastconn +zcp loadbalancer attach-vm --vm +zcp loadbalancer detach-vm --vm +zcp loadbalancer delete-rule zcp loadbalancer delete -# VPN gateways and connections -zcp vpn list -zcp vpn create --vpc --name my-vpn -zcp vpn delete +# Site-to-site VPN: a gateway on the VPC plus a customer gateway for the remote end +zcp vpc vpn-gateway list +zcp vpc vpn-gateway create +zcp vpc vpn-gateway delete +zcp vpn customer-gateway list +zcp vpn customer-gateway create --name office --gateway 203.0.113.99 \ + --cidr 192.168.10.0/24 --psk '' \ + --ike-policy aes128-sha1-dh5 --esp-policy aes128-sha1 \ + --region yul-1 --project default-9 +zcp vpn customer-gateway delete + +# Remote access VPN: enable it on a public IP, then add VPN users +zcp ip vpn enable +zcp ip vpn list +zcp ip vpn disable +zcp vpn user create --username alice --region yul-1 --project default-9 # prompts for password +zcp vpn user list +zcp vpn user delete ``` --- @@ -296,7 +317,7 @@ zcp vpn delete ```bash # SSH keys (--project and --region are required on import) # Constraints: --name <= 20 chars and unique; the public key material must also -# be unique — re-importing a key you already have (even under a new name) is +# be unique. Re-importing a key you already have (even under a new name) is # rejected with "The public key has already been taken." Delete the old key # first to rename/replace it. zcp ssh-key list @@ -305,7 +326,7 @@ zcp ssh-key delete zcp instance create ... --ssh-key mykey # reference the key by name on a new VM # Affinity groups -# --type must be one of (quote it — values contain a space): +# --type must be one of (quote it, values contain a space): # "host affinity" strict: instances always on the SAME host # "host anti-affinity" strict: instances always on DIFFERENT hosts # "non-strict host affinity" best-effort same host (falls back if no capacity) @@ -320,7 +341,7 @@ zcp affinity-group delete ## Access control (sub-users, roles, permissions) -Account-level — these commands are **not** region/project-scoped. +Account-level: these commands are **not** region/project-scoped. ```bash # Permissions: the read-only catalog you build roles from @@ -367,8 +388,8 @@ zcp dns create --name example.com --project default-9 zcp dns record-create --domain --name www --type A --content 192.0.2.1 zcp dns record-create --domain --name mail --type MX --content mail.example.com --ttl 3600 -# Delete a record or domain -zcp dns record-delete --domain --record-id 42 +# Delete a record set (records are addressed by name and type) or a domain +zcp dns record-delete --domain --name www --type A zcp dns delete ``` @@ -377,7 +398,7 @@ zcp dns delete ## Backup ```bash -# Block storage (volume) backups — plans are region-specific: zcp plan backup +# Block storage (volume) backups. Plans are region-specific: zcp plan backup zcp backup list zcp backup create --volume root-1234 --interval dailyAt --at 1 --immediate 1 \ --plan backup-yul --billing-cycle hourly --region yul-1 --project default-9 @@ -419,11 +440,16 @@ zcp autoscale condition create web-group --name cpu-low --metric cpu --operator ## Monitoring +Read-only metrics; alerting is configured in the Web UI. + ```bash -zcp monitoring list -zcp monitoring get -zcp monitoring create --instance --type cpu --threshold 80 -zcp monitoring delete +zcp monitoring global # account-wide resource overview +zcp monitoring charts # chart data +zcp monitoring cpu # per-VM metrics +zcp monitoring memory +zcp monitoring disk +zcp monitoring disk-io +zcp monitoring network ``` --- @@ -477,13 +503,18 @@ zcp kubernetes create \ --storage-category pro-nvme \ --ssh-key mykey -# Start / stop / upgrade +# Kubeconfig +zcp kubernetes get-config + +# Lifecycle zcp kubernetes start zcp kubernetes stop +zcp kubernetes scale --workers 5 zcp kubernetes upgrade --plan k8s-xla-yul-1 +zcp kubernetes upgrade-version --version v1.36.1 -# To cancel/delete a cluster, use billing cancel-service: -zcp billing cancel-service --service "Kubernetes" --reason not_needed_anymore +# Delete a cluster +zcp kubernetes delete ``` --- @@ -495,7 +526,7 @@ zcp billing cancel-service --service "Kubernetes" --reason n zcp object-storage list zcp object-storage get -# Create an object storage instance — use an object-storage region (os-yul / os-yow) +# Create an object storage instance. Use an object-storage region (os-yul / os-yow) # and a plan slug from `zcp plan object-storage`. The storage category is derived # from the plan automatically, so you do not pass --storage-category. zcp object-storage create \ @@ -564,7 +595,7 @@ zcp object-storage bucket cors delete zcp object-storage bucket empty zcp object-storage bucket delete --purge -# Objects — list, inspect metadata, upload, download, share, delete +# Objects: list, inspect metadata, upload, download, share, delete zcp object-storage object list zcp object-storage object get # metadata only zcp object-storage object put ./photo.jpg @@ -590,14 +621,14 @@ zcp object-storage object delete --version-id # delet zcp object-storage object restore # undelete (remove latest delete marker) ``` -### Two backends — and what is CLI-only +### Two backends, and what is CLI-only Object storage spans two backends, and this determines what is reachable outside the CLI: -- **ZCP REST API (also available in the Web UI / CMP):** instance lifecycle — - `create`, `list`, `get`, `delete`, `resize`, `credentials` — and basic bucket - management — `bucket create`, `bucket list`, `bucket get`, `bucket delete`. +- **ZCP REST API (also available in the Web UI / CMP):** instance lifecycle + (`create`, `list`, `get`, `delete`, `resize`, `credentials`) and basic bucket + management (`bucket create`, `bucket list`, `bucket get`, `bucket delete`). `object get` also goes through the REST API (it returns object metadata only). - **Direct to the Ceph RADOS Gateway over the S3 protocol** (AWS Signature V4, @@ -613,10 +644,10 @@ the CLI: > **CLI-only (not yet on the REST API or Web UI):** every S3-direct operation in > the second group above is available **only through this CLI**. The CMP has not > yet exposed these operations via the ZCP REST API or the Web UI, so they cannot -> be performed there — the CLI talks straight to Ceph RGW. Only the REST-backed +> be performed there; the CLI talks straight to Ceph RGW. Only the REST-backed > operations in the first group are mirrored in the Web UI. -`object get` returns metadata only — use `object download` to fetch the contents, +`object get` returns metadata only. Use `object download` to fetch the contents, or `object url` to mint a time-limited link a client can use without ZCP credentials (works even when the bucket is private). @@ -671,10 +702,14 @@ zcp billing budget-alert-set --amount 500 --threshold 80 --enabled ## Support ```bash -zcp support list -zcp support get -zcp support create --subject "Issue title" --description "Details" -zcp support close +zcp support ticket list +zcp support ticket show +zcp support ticket create --subject "Issue title" --description "Details" --priority high +zcp support ticket reply --message "More details" +zcp support ticket replies +zcp support ticket summary +zcp support ticket delete +zcp support faq list ``` --- @@ -683,7 +718,7 @@ zcp support close ```bash zcp dashboard summary -zcp dashboard status +zcp dashboard cancel-service --slug ``` --- diff --git a/docs/development/RELEASE.md b/docs/development/RELEASE.md index 647bff8..5984177 100644 --- a/docs/development/RELEASE.md +++ b/docs/development/RELEASE.md @@ -2,6 +2,19 @@ Tag-based releases. Push version tag → automated builds → GitHub Release. +## Prepare release content (before tagging) + +1. **`CHANGELOG.md`**: add the new `[vX.Y.Z] - YYYY-MM-DD` entry (Keep a + Changelog format, matching the existing entries). +2. **`RELEASE_NOTES.md`**: rewrite for this release only. `build.yml` uploads + it verbatim as the GitHub release body (`body_path`). Required sections: + - A short narrative of the headline change, with highlights. + - **Installation and upgrade (ALWAYS included).** The install one-liners + (Linux/macOS `install.sh`, Windows `install.ps1`), the manual-download + pointer, and a `zcp version` verify line showing the new version. Copy the + section from the previous release notes and bump the version. + - Fixed / Added details with copy-paste examples. + ## Steps ```bash @@ -29,23 +42,25 @@ The documentation site carries a unified, user-facing changelog at generated). After cutting a release, add the new version to the **CLI (`zcp`)** section of **both** files: -1. Add a `### vX.Y.Z — ` entry at the top of the CLI section, summarizing - the **user-facing** highlights only — skip internal struct/JSON-tag/test changes. +1. Add a `### vX.Y.Z - ` entry at the top of the CLI section, summarizing + the **user-facing** highlights only. Skip internal struct/JSON-tag/test changes. 2. **Keep it vendor-neutral.** Public Cloud docs must not name internal backends (Ceph, RGW, CloudStack, etc.). Use "S3-compatible", "the platform API", and the like. 3. In `zcp-docs`, run `pnpm fmt && pnpm build` (the build validates internal links). -> **Future automation — gated.** Auto-generating the docs CLI section from this repo's +> **Future automation (gated).** Auto-generating the docs CLI section from this repo's > `CHANGELOG.md` is intentionally **not** wired up. The prerequisite is making **this -> `CHANGELOG.md` vendor-neutral at the source** — until it carries no internal backend +> `CHANGELOG.md` vendor-neutral at the source**. Until it carries no internal backend > names, the docs changelog must stay a curated hand-written mirror. Treat "neutral source > changelog" as the gate before building any pull-from-GitHub generation. ## Version Format -`..` (semver, no `v` prefix) +`v..` (semver, **with** the `v` prefix: the release +workflow only triggers on tags matching `v[0-9]*`, and `install.sh`/version +injection expect it). -Examples: `0.0.1`, `0.1.0`, `1.0.0` +Examples: `v0.0.22`, `v0.1.0`, `v1.0.0` ## Build Artifacts @@ -60,10 +75,10 @@ Examples: `0.0.1`, `0.1.0`, `1.0.0` Also included in the release: -- `install.sh` — Unix one-liner installer -- `install.ps1` — Windows one-liner installer +- `install.sh`: Unix one-liner installer +- `install.ps1`: Windows one-liner installer - Archives (`.tar.gz` for Unix, `.zip` for Windows) -- `checksums.txt` — SHA256 checksums for all artifacts +- `checksums.txt`: SHA256 checksums for all artifacts ## Troubleshooting diff --git a/internal/commands/dns.go b/internal/commands/dns.go index 02bb144..78a0d79 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -116,11 +116,12 @@ func runDNSShow(cmd *cobra.Command, slug string) error { if len(domain.Records) > 0 { fmt.Fprintln(os.Stderr) fmt.Fprintf(os.Stderr, "Records (%d):\n", len(domain.Records)) - recHeaders := []string{"ID", "NAME", "TYPE", "CONTENT", "TTL"} + // The live DNS backend returns record sets without IDs, so the table + // is keyed by name and type. + recHeaders := []string{"NAME", "TYPE", "CONTENT", "TTL"} recRows := make([][]string, 0, len(domain.Records)) for _, r := range domain.Records { recRows = append(recRows, []string{ - r.ID, r.Name, r.Type, r.Content, @@ -250,7 +251,7 @@ func runDNSDelete(cmd *cobra.Command, slug string, yes bool) error { if err := svc.Delete(ctx, slug); err != nil { if apierrors.IsResourceNotFound(err) { - fmt.Fprintf(os.Stderr, "DNS domain %q not found — already deleted.\n", slug) + fmt.Fprintf(os.Stderr, "DNS domain %q not found (already deleted).\n", slug) return nil } return fmt.Errorf("dns delete: %w", err) @@ -294,7 +295,7 @@ func newDNSRecordCreateCmd() *cobra.Command { }, } cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") - cmd.Flags().StringVar(&name, "name", "", "Record name / subdomain (required)") + cmd.Flags().StringVar(&name, "name", "", "Relative record name, e.g. www. The backend appends the zone (required)") cmd.Flags().StringVar(&recordType, "type", "", "Record type: A, AAAA, CNAME, MX, TXT, etc. (required)") cmd.Flags().StringVar(&content, "content", "", "Record content / value (required)") cmd.Flags().IntVar(&ttl, "ttl", 14400, "Time-to-live in seconds (default: 14400)") @@ -318,11 +319,10 @@ func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRec // Show the record table for the domain if len(domain.Records) > 0 { - headers := []string{"ID", "NAME", "TYPE", "CONTENT", "TTL"} + headers := []string{"NAME", "TYPE", "CONTENT", "TTL"} rows := make([][]string, 0, len(domain.Records)) for _, r := range domain.Records { rows = append(rows, []string{ - r.ID, r.Name, r.Type, r.Content, @@ -337,31 +337,78 @@ func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRec } func newDNSRecordDeleteCmd() *cobra.Command { - var domain string + var domain, name, recordType string var recordID int var yes bool cmd := &cobra.Command{ Use: "record-delete", - Short: "Delete a DNS record", - Example: ` zcp dns record-delete --domain example-com-1 --record-id 42 - zcp dns record-delete --domain example-com-1 --record-id 42 --yes`, + Short: "Delete a DNS record set by name and type", + Example: ` zcp dns record-delete --domain example-com-1 --name www --type A + zcp dns record-delete --domain example-com-1 --name www --type A --yes`, RunE: func(cmd *cobra.Command, args []string) error { if domain == "" { return fmt.Errorf("--domain is required (use the domain slug)") } - if recordID <= 0 { - return fmt.Errorf("--record-id is required") + if recordID > 0 { + // Legacy numeric-ID path; only works on deployments whose DNS + // backend exposes record IDs. + return runDNSRecordDelete(cmd, domain, recordID, yes) } - return runDNSRecordDelete(cmd, domain, recordID, yes) + if name == "" || recordType == "" { + return fmt.Errorf("--name and --type are required (records are addressed by name and type)") + } + return runDNSRecordDeleteByName(cmd, domain, name, recordType, yes) }, } cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") - cmd.Flags().IntVar(&recordID, "record-id", 0, "Record ID to delete (required; use 'dns show' to find IDs)") + cmd.Flags().StringVar(&name, "name", "", "Record name, relative (www) or fully qualified (www.example.com.)") + cmd.Flags().StringVar(&recordType, "type", "", "Record type: A, AAAA, CNAME, MX, TXT, etc.") + cmd.Flags().IntVar(&recordID, "record-id", 0, "Legacy: numeric record ID (most deployments do not expose IDs)") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } +func runDNSRecordDeleteByName(cmd *cobra.Command, domainSlug, name, recordType string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete DNS record %s %q on domain %q? [y/N]: ", recordType, name, domainSlug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + // Resolve the stored FQDN: the backend appends the zone to relative names. + domain, err := svc.Show(ctx, domainSlug) + if err != nil { + return fmt.Errorf("dns record-delete: resolving domain %s: %w", domainSlug, err) + } + fqdn := dns.CanonicalRecordFQDN(name, domain.Name) + + if err := svc.DeleteRecordByName(ctx, domainSlug, fqdn, recordType); err != nil { + if apierrors.IsResourceNotFound(err) { + fmt.Fprintf(os.Stderr, "DNS record %s %q not found (already deleted).\n", recordType, fqdn) + return nil + } + return fmt.Errorf("dns record-delete: %w", err) + } + + printer.Fprintf("DNS record %s %q deleted from domain %q.\n", recordType, fqdn, domainSlug) + return nil +} + func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes bool) error { if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete DNS record %d on domain %q? [y/N]: ", recordID, domainSlug) @@ -385,7 +432,7 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes if err := svc.DeleteRecord(ctx, domainSlug, recordID); err != nil { if apierrors.IsResourceNotFound(err) { - fmt.Fprintf(os.Stderr, "DNS record %d not found — already deleted.\n", recordID) + fmt.Fprintf(os.Stderr, "DNS record %d not found (already deleted).\n", recordID) return nil } return fmt.Errorf("dns record-delete: %w", err) diff --git a/pkg/api/dns/dns.go b/pkg/api/dns/dns.go index 20cea8c..cfe89e9 100644 --- a/pkg/api/dns/dns.go +++ b/pkg/api/dns/dns.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "strconv" + "strings" "github.com/zsoftly/zcp-cli/pkg/httpclient" ) @@ -27,16 +28,37 @@ type Domain struct { } // Record represents a single DNS record within a domain. +// +// The live PowerDNS-backed API returns record SETS (RRsets): no id, and the +// values under a "contents" array rather than a "content" string (verified +// live 2026-07-05). UnmarshalJSON accepts both shapes; Content carries the +// joined values for display and Contents the individual values. type Record struct { - ID string `json:"id"` - DomainID int `json:"domain_id"` - Name string `json:"name"` - Type string `json:"type"` - Content string `json:"content"` - TTL int `json:"ttl"` - Priority int `json:"priority,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + DomainID int `json:"domain_id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + Contents []string `json:"contents"` + TTL int `json:"ttl"` + Priority int `json:"priority,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UnmarshalJSON tolerates both record shapes: legacy rows with id/content and +// PowerDNS RRsets with a contents array and no id. +func (r *Record) UnmarshalJSON(b []byte) error { + type recordAlias Record + var v recordAlias + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *r = Record(v) + if r.Content == "" && len(r.Contents) > 0 { + r.Content = strings.Join(r.Contents, ", ") + } + return nil } // CreateDomainRequest holds parameters for creating a DNS domain. @@ -135,8 +157,10 @@ func (s *Service) CreateRecord(ctx context.Context, domainSlug string, req Creat return &resp.Data, nil } -// DeleteRecord removes a DNS record under the given domain slug. -// The record is identified by its ID in the request body. +// DeleteRecord removes a DNS record under the given domain slug by numeric ID. +// +// Deprecated: the live PowerDNS-backed API does not expose record IDs, so this +// cannot work there; use DeleteRecordByName instead. func (s *Service) DeleteRecord(ctx context.Context, domainSlug string, recordID int) error { path := "/dns/domains/" + domainSlug + "/records" q := url.Values{} @@ -146,3 +170,32 @@ func (s *Service) DeleteRecord(ctx context.Context, domainSlug string, recordID } return nil } + +// DeleteRecordByName removes a DNS record set identified by its fully +// qualified name and type, the addressing scheme the live PowerDNS-backed +// API uses (record sets carry no IDs). name should be the stored FQDN with a +// trailing dot (e.g. "www.example.com."). +func (s *Service) DeleteRecordByName(ctx context.Context, domainSlug, name, recordType string) error { + q := url.Values{} + q.Set("name", name) + q.Set("type", recordType) + if err := s.client.Delete(ctx, "/dns/domains/"+domainSlug+"/records", q); err != nil { + return fmt.Errorf("deleting DNS record %s %s on domain %s: %w", recordType, name, domainSlug, err) + } + return nil +} + +// CanonicalRecordFQDN builds the backend's stored record name for a record in +// the given zone: relative labels get the zone appended, absolute names are +// normalized, and the result always carries a trailing dot. +func CanonicalRecordFQDN(name, zoneName string) string { + n := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ".")) + zone := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(zoneName), ".")) + if n == "" || n == "@" || n == zone { + return zone + "." + } + if strings.HasSuffix(n, "."+zone) { + return n + "." + } + return n + "." + zone + "." +} diff --git a/pkg/api/dns/dns_test.go b/pkg/api/dns/dns_test.go index 16c87d3..5a6c8c8 100644 --- a/pkg/api/dns/dns_test.go +++ b/pkg/api/dns/dns_test.go @@ -281,3 +281,68 @@ func TestDNSDomainListRealShape(t *testing.T) { t.Errorf("Status = false, want true") } } + +func TestDNSRecordDecodeRRset(t *testing.T) { + // The live PowerDNS-backed API returns record sets: no id, contents array. + raw := []byte(`{"name":"www.example.com.","type":"A","ttl":3600,"contents":["192.0.2.10","192.0.2.11"]}`) + var rec dns.Record + if err := json.Unmarshal(raw, &rec); err != nil { + t.Fatalf("Unmarshal RRset: %v", err) + } + if rec.Content != "192.0.2.10, 192.0.2.11" { + t.Errorf("Content = %q, want joined contents", rec.Content) + } + if len(rec.Contents) != 2 { + t.Errorf("Contents = %v, want 2 values", rec.Contents) + } + + // Legacy shape with id/content still decodes. + raw = []byte(`{"id":"41","name":"www","type":"A","content":"192.0.2.10","ttl":600}`) + rec = dns.Record{} + if err := json.Unmarshal(raw, &rec); err != nil { + t.Fatalf("Unmarshal legacy: %v", err) + } + if rec.ID != "41" || rec.Content != "192.0.2.10" { + t.Errorf("legacy decode = %+v, want id 41 content 192.0.2.10", rec) + } +} + +func TestDNSDeleteRecordByName(t *testing.T) { + var gotPath, gotName, gotType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotName = r.URL.Query().Get("name") + gotType = r.URL.Query().Get("type") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"status": "Success", "message": "deleted"}) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + if err := svc.DeleteRecordByName(context.Background(), "example-com-1", "www.example.com.", "A"); err != nil { + t.Fatalf("DeleteRecordByName() error = %v", err) + } + if gotPath != "/dns/domains/example-com-1/records" { + t.Errorf("path = %q", gotPath) + } + if gotName != "www.example.com." || gotType != "A" { + t.Errorf("query = name %q type %q, want www.example.com. / A", gotName, gotType) + } +} + +func TestCanonicalRecordFQDN(t *testing.T) { + cases := []struct{ name, zone, want string }{ + {"www", "example.com", "www.example.com."}, + {"www", "example.com.", "www.example.com."}, + {"WWW.Example.com.", "example.com", "www.example.com."}, + {"www.example.com", "example.com", "www.example.com."}, + {"@", "example.com", "example.com."}, + {"", "example.com", "example.com."}, + {"example.com", "example.com", "example.com."}, + } + for _, c := range cases { + if got := dns.CanonicalRecordFQDN(c.name, c.zone); got != c.want { + t.Errorf("CanonicalRecordFQDN(%q, %q) = %q, want %q", c.name, c.zone, got, c.want) + } + } +} diff --git a/pkg/api/egress/egress.go b/pkg/api/egress/egress.go index 49b653d..5f6762e 100644 --- a/pkg/api/egress/egress.go +++ b/pkg/api/egress/egress.go @@ -15,6 +15,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/zsoftly/zcp-cli/pkg/httpclient" ) @@ -32,7 +33,7 @@ type EgressRule struct { Status string `json:"status"` } -// flexString trims quotes from a raw JSON scalar — the live API returns ports +// flexString trims quotes from a raw JSON scalar: the live API returns ports // and ICMP fields as numbers (80) while older deployments use strings ("80"). func flexString(raw json.RawMessage) string { v := strings.Trim(strings.TrimSpace(string(raw)), `"`) @@ -119,7 +120,7 @@ func (s *Service) List(ctx context.Context, networkSlug string) ([]EgressRule, e } // Create adds a new egress rule to a network. The create endpoint returns -// data:null — fall back to the list and return the rule matching the request. +// data:null, so fall back to the list and return the rule matching the request. func (s *Service) Create(ctx context.Context, req CreateRequest) (*EgressRule, error) { var resp singleEgressRuleResponse if err := s.client.Post(ctx, "/networks/"+req.NetworkSlug+"/egress-firewall-rules", req, &resp); err != nil { @@ -128,23 +129,44 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (*EgressRule, e if resp.Data.ID != "" { return &resp.Data, nil } - rules, lerr := s.List(ctx, req.NetworkSlug) - if lerr != nil { - return nil, fmt.Errorf("egress rule for network %s was created, but fetching it back failed: %w", req.NetworkSlug, lerr) - } - for i := range rules { - r := &rules[i] - if r.Protocol != req.Protocol || r.StartPort != req.StartPort || r.EndPort != req.EndPort { - continue + // The rule list can lag the accepted create; retry a few times before + // giving up so eventual consistency is not reported as a failure. + const listAttempts = 3 + for attempt := 1; attempt <= listAttempts; attempt++ { + rules, lerr := s.List(ctx, req.NetworkSlug) + if lerr != nil { + return nil, fmt.Errorf("egress rule for network %s was created, but fetching it back failed: %w", req.NetworkSlug, lerr) + } + for i := range rules { + r := &rules[i] + if r.Protocol != req.Protocol { + continue + } + if strings.EqualFold(req.Protocol, "icmp") { + // ICMP rules carry no ports; type and code are the only + // discriminators between rules to the same CIDR. + if r.ICMPType != req.ICMPType || r.ICMPCode != req.ICMPCode { + continue + } + } else if r.StartPort != req.StartPort || r.EndPort != req.EndPort { + continue + } + // The API echoes the requested CIDR in destcidr_list; top-level cidr + // is the network's source CIDR and must not be compared here. + if req.CIDR != "" && r.DestCIDR != req.CIDR { + continue + } + return r, nil } - // The API echoes the requested CIDR in destcidr_list; top-level cidr - // is the network's source CIDR and must not be compared here. - if req.CIDR != "" && r.DestCIDR != req.CIDR { - continue + if attempt < listAttempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } } - return r, nil } - return nil, fmt.Errorf("egress rule for network %s was created, but is not yet visible in the rule list — check with: zcp egress list --network %s", req.NetworkSlug, req.NetworkSlug) + return nil, fmt.Errorf("egress rule for network %s was accepted by the API but never appeared in the rule list. The backend may have silently dropped it. Check with: zcp egress list --network %s", req.NetworkSlug, req.NetworkSlug) } // Delete removes an egress rule by ID from the given network. diff --git a/pkg/api/egress/egress_test.go b/pkg/api/egress/egress_test.go index 3faf6f5..207d121 100644 --- a/pkg/api/egress/egress_test.go +++ b/pkg/api/egress/egress_test.go @@ -179,3 +179,33 @@ func TestCreateFallbackNoMatchErrors(t *testing.T) { t.Fatal("Create() = nil error, want explicit not-yet-visible error") } } + +// TestCreateFallbackMatchesICMPTypeAndCode verifies the fallback lookup picks +// the right ICMP rule when two rules to the same CIDR differ only in ICMP +// type/code (ICMP rules carry no ports to discriminate on). +func TestCreateFallbackMatchesICMPTypeAndCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + fmt.Fprint(w, `{"status":"Success","data":null}`) + return + } + fmt.Fprint(w, `{"status":"Success","data":[ + {"id":"echo-reply","protocol":"icmp","icmp_type":0,"icmp_code":0,"destcidr_list":"0.0.0.0/0","_original":{"state":"Active"}}, + {"id":"echo-request","protocol":"icmp","icmp_type":8,"icmp_code":0,"destcidr_list":"0.0.0.0/0","_original":{"state":"Active"}} + ]}`) + })) + defer srv.Close() + + svc := egress.NewService(newClient(srv.URL)) + + rule, err := svc.Create(context.Background(), egress.CreateRequest{ + NetworkSlug: "my-net", Protocol: "icmp", ICMPType: "8", ICMPCode: "0", CIDR: "0.0.0.0/0", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if rule.ID != "echo-request" { + t.Errorf("Create() matched rule %q, want %q (must discriminate on icmp_type/icmp_code)", rule.ID, "echo-request") + } +}