From 5ed383b69dd7641fc9e6907dafc738457279a81d Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sat, 10 Oct 2026 19:45:19 -0400 Subject: [PATCH 1/5] feat: add scheduler API operations and related tests - Implemented the scheduler package with actions for creating, updating, deleting, and listing VM backup policies. - Added support for pagination in listing scheduler actions. - Enhanced the Policy struct to safely handle JSON responses, including the ability to decode both numeric and quoted day values. - Created tests for the scheduler package to ensure correct functionality of all implemented methods. - Updated the Kubernetes API to include new fields for worker node size and autoscaling capabilities. - Added tests for Kubernetes cluster creation and scaling, ensuring compatibility with new plan structures. - Enhanced HTTP client with new methods for DELETE requests with results and PATCH requests. - Updated smoke tests to accommodate changes in Kubernetes and VM backup creation processes. Signed-off-by: ditahkk --- CHANGELOG.md | 14 + RELEASE_NOTES.md | 81 +++++ docs/command-taxonomy.md | 17 +- docs/commands.md | 61 +++- docs/development.md | 10 +- internal/commands/commands_test.go | 26 +- internal/commands/dns.go | 28 +- internal/commands/dns_status_test.go | 117 +++++++ internal/commands/kubernetes.go | 332 ++++++++++++++++-- internal/commands/kubernetes_test.go | 390 +++++++++++++++++++++ internal/commands/vmbackup.go | 117 ++++++- internal/commands/vmbackup_create_test.go | 277 +++++++++++++++ internal/commands/vmbackupschedule.go | 355 +++++++++++++++++++ internal/commands/vmbackupschedule_test.go | 266 ++++++++++++++ pkg/api/dns/dns.go | 58 ++- pkg/api/dns/dns_test.go | 69 ++++ pkg/api/kubernetes/kubernetes.go | 149 ++++++-- pkg/api/kubernetes/kubernetes_test.go | 188 ++++++++-- pkg/api/scheduler/scheduler.go | 234 +++++++++++++ pkg/api/scheduler/scheduler_test.go | 119 +++++++ pkg/httpclient/client.go | 10 + tests/smoke/cases.sh | 40 +-- 22 files changed, 2814 insertions(+), 144 deletions(-) create mode 100644 internal/commands/dns_status_test.go create mode 100644 internal/commands/kubernetes_test.go create mode 100644 internal/commands/vmbackup_create_test.go create mode 100644 internal/commands/vmbackupschedule.go create mode 100644 internal/commands/vmbackupschedule_test.go create mode 100644 pkg/api/scheduler/scheduler.go create mode 100644 pkg/api/scheduler/scheduler_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bb00008..e4bcea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ## [Unreleased] +### Added + +- **Kubernetes create uses separate control-plane, worker, and root-volume plans.** `zcp kubernetes create` now requires `--control-plane-plan`, `--worker-plan`, and `--storage-plan`, alongside `--storage-category`. The command sends the ZCP API `master_plan`, `worker_plan`, and `blockstorage_plan` fields instead of a single cluster plan. The root-volume plan applies to every node. The selected billing cycle applies to the resulting resource subscriptions: every control-plane and worker VM, and every root volume. The cluster object itself is not billed. Network, load balancer, and public-IP billing remains conditional on enabled platform packages and feature flags. Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. `zcp kubernetes get` now shows resource totals and configuration. `zcp kubernetes scale` enables autoscaling with minimum and maximum worker counts or disables it with a fixed worker count. +- **Scheduler-backed VM backup policies.** `zcp vm-backup schedule` creates, lists, reads, updates, pauses, resumes, runs, and deletes VM backup policies. Policies support hourly, daily, weekly, and monthly intervals, IANA timezones, retention counts, names, and descriptions. Weekly policies select a weekday; monthly policies select a day of the month. Policies can request an immediate first backup. +- **`everyOtherDay` policy warning.** The command exposes the API's `everyOtherDay` value. The current platform returns an error after persisting that policy, so the command directs users to list policies before retrying. + +### Fixed + +- **`vm-backup create` now reports the created schedule slug.** It prints the created schedule in table output and emits valid JSON or YAML for structured output. When the create endpoint returns only an acknowledgement, the command identifies the new schedule from a scoped pre-create snapshot and bounded read-only lookup. It never repeats an accepted create request. If the command cannot identify the schedule, the error tells you to list it before creating another one. _Fixes #76._ +- **DNS listings now retrieve every reported page.** `dns.Service.List` returns an error rather than a partial domain list if a later page fails or pagination metadata is inconsistent. +- **`dns show` now resolves an omitted status from the matching domain in `dns list`.** An explicit status in the detail response is preserved. If the matching list entry also lacks status, output remains `-`. If the list lookup fails, the command returns an error. _Follow-up to #69._ + +## [v0.0.31] - 2026-10-09 + ### Security - **Go toolchain `1.26.8` -> `1.26.9` and `golang.org/x/net` `v0.58.0` -> `v0.60.0`.** Resolves the reachable standard-library and `x/net` vulnerabilities reported by `govulncheck`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 338ee7b..24e0a57 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,84 @@ +# Unreleased + +The next release will include the following fixes. No release version or date +has been selected. + +## Kubernetes resource plans and billing + +`zcp kubernetes create` now requires separate resource selections for the +control plane, worker nodes, and one root-volume plan used for every node: + +```bash +zcp kubernetes create --name my-cluster --version v1.37.0 \ + --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul \ + --storage-plan b2g1 --storage-category pro-nvme \ + --region yul-1 --project default-9 --billing-cycle hourly \ + --workers 3 --enable-csi --ssh-key mykey +``` + +The cluster object has no recurring charge. The selected billing cycle applies +to the subscriptions created for each control-plane VM, worker VM, and root +volume. Network, load balancer, and public-IP subscriptions depend on the +platform packages and feature flags enabled for those resources. + +`zcp kubernetes get` now shows resource totals, separate control-plane and +worker configuration, root-volume count, network, and autoscaling status when +the API returns them. `zcp kubernetes scale` supports manual worker counts, +enabling autoscaling with `--min-workers` and `--max-workers`, and disabling +autoscaling with an explicit `--workers` count. + +Use `--enable-csi` to request Cloud Storage Integration for a supported +cluster. Creating a high-availability cluster requires at least two control +nodes. + +## VM backup schedules + +`zcp vm-backup schedule` manages the scheduler-backed VM backup policies shown +in the cloud portal. Create policies with `hourly`, `dailyAt`, +`everyOtherDay`, `weeklyOn`, or `monthlyOn`, an IANA timezone, a retention +count, and an optional request for an immediate backup. Weekly policies use Sunday `0` +through Saturday `6`. Monthly policies use day `1` through `28`, matching the +provider's supported range. + +```bash +zcp vm-backup schedule create my-vm --name nightly --interval dailyAt --at 01:00 \ + --timezone America/Toronto --retention 7 --immediate +zcp vm-backup schedule list +zcp vm-backup schedule pause +zcp vm-backup schedule run-now +``` + +Policies support get, update, pause, resume, run-now, and delete operations. +Deleting a policy leaves backups it has already created in place. +Policy output includes the last-run result and any reported error; an active +policy does not by itself confirm a completed backup. + +The current platform rejects `everyOtherDay` after persisting a +policy. If it returns an error, list policies before retrying and remove any +unintended policy. + +## VM backup create output + +`zcp vm-backup create` will print the created schedule, including its slug. It +will honor `--output json` and `--output yaml` without mixing status text into +structured output. When the create endpoint provides only an acknowledgement, +the command will identify one new matching schedule from a scoped snapshot and +read-only lookup. It will not repeat an accepted create request. If it cannot +identify the schedule, it will tell you to list schedules before trying again. + +## DNS status and pagination + +`zcp dns show ` will preserve a status supplied by the detail endpoint. +When that endpoint omits status, it will use the exactly matching entry from +`zcp dns list`. If no status is available, the display remains `-`. If the +lookup fails, the command returns an error. + +`dns.Service.List` will retrieve every reported page and return an error rather +than a partial result when a later page fails or pagination metadata is +inconsistent. + +--- + # zcp v0.0.31 Release Notes v0.0.31 fixes ACL rule listings that stopped at the first API page, adds diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index d6db0af..30fa11c 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -189,11 +189,11 @@ zcp │ ├── kubernetes (alias: k8s) Kubernetes cluster operations │ ├── list List Kubernetes clusters -│ ├── get Show details for a single cluster -│ ├── create Create a Kubernetes cluster +│ ├── get Show details and returned resource overview for a single cluster +│ ├── create Create a Kubernetes cluster (--control-plane-plan, --worker-plan, --storage-plan; optional --enable-csi) │ ├── start Start a stopped cluster │ ├── stop Stop a running cluster -│ ├── scale Scale worker node count (--workers N, --wait) +│ ├── scale Set worker count or autoscaling (--workers N, --enable-autoscaling --min-workers N --max-workers N, --disable-autoscaling --workers N) │ ├── upgrade Change the compute plan of a cluster (--plan) │ ├── upgrade-version Upgrade the Kubernetes version (--version v1.x.y) │ ├── get-config Download kubeconfig (--output path, --print) @@ -401,7 +401,16 @@ zcp ├── vm-backup VM backup operations │ ├── list List VM backups │ ├── create Create a VM backup (--interval dailyAt|hourlyAt) -│ └── delete Delete a VM backup by submitting a service-cancellation request (--yes to skip prompt) +│ ├── delete Delete a VM backup by submitting a service-cancellation request (--yes to skip prompt) +│ └── schedule Scheduler-backed VM backup policies +│ ├── list List policies +│ ├── get Show a policy +│ ├── create Create a policy (--interval, --timezone, --retention; --at for non-hourly; --day for weekly/monthly; --name and --immediate optional) +│ ├── update Update interval, time, timezone, retention, and day +│ ├── pause Pause a policy +│ ├── resume Resume a policy +│ ├── run-now Request an immediate backup +│ └── delete Delete a policy (--yes to skip prompt) │ ├── cloud-provider Cloud provider operations │ └── list List available cloud providers diff --git a/docs/commands.md b/docs/commands.md index 4060a7c..9e368c5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -267,7 +267,7 @@ zcp ip release zcp ip static-nat enable --instance --network # Firewall rules (ingress) -zcp firewall list +zcp firewall list --ip zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 # Egress rules @@ -463,11 +463,35 @@ zcp backup delete zcp vm-backup list zcp vm-backup create --interval dailyAt --plan backup-yul \ --pseudo-service vm-backup --billing-cycle hourly --region yul-1 --project default-9 +# Create output includes the new schedule slug; JSON/YAML output is suitable for automation. +zcp vm-backup create --interval dailyAt --plan backup-yul \ + --pseudo-service vm-backup --billing-cycle hourly --region yul-1 --project default-9 --output json zcp vm-backup delete + +# Scheduler-backed VM backup policies. These use an IANA timezone and retain a +# specified number of backups. `--at` accepts whole hours as `HH:00`. For weeklyOn use Sunday=0 through Saturday=6; +# for monthlyOn use day 1 through 28, matching the provider's supported range. +zcp vm-backup schedule create --name nightly --interval dailyAt --at 01:00 \ + --timezone America/Toronto --retention 7 --immediate +zcp vm-backup schedule create --name weekly --interval weeklyOn --day 0 --at 02:00 \ + --timezone UTC --retention 4 +zcp vm-backup schedule list +zcp vm-backup schedule get +zcp vm-backup schedule update --interval monthlyOn --day 1 --at 03:00 --timezone UTC --retention 3 +zcp vm-backup schedule pause +zcp vm-backup schedule resume +zcp vm-backup schedule run-now +zcp vm-backup schedule delete --yes ``` `vm-backup delete` submits a service-cancellation request, the same workflow `instance delete` uses, since the VM backup API route does not support direct deletion. +`vm-backup schedule` manages the current scheduler-backed policies. `--immediate` requests one backup when the policy is created. `run-now` requests another backup without waiting for the next scheduled time. Pausing retains existing backups, and deleting a policy does not delete backups it already created. + +Use `zcp vm-backup schedule get ` to read the policy's last-run status and any reported error. A policy marked active does not by itself confirm that its last backup completed. + +The current platform rejects `everyOtherDay` with an error after persisting a policy. The CLI exposes the API interval for compatibility, but after such an error, list schedules before retrying and remove any unintended policy. + --- ## Autoscale @@ -535,40 +559,61 @@ zcp project icon list ```bash # 'k8s' is accepted as an alias for 'kubernetes' zcp kubernetes list + +# Select independent control-plane and worker compute plans, plus one +# root-volume plan used for every node. zcp kubernetes create \ --name my-cluster \ - --version v1.36.1 \ - --plan k8s-la-yul-1 \ + --version v1.37.0 \ + --control-plane-plan k8s-cpi-yul \ + --worker-plan k8s-li-yul \ + --storage-plan b2g1 \ + --storage-category pro-nvme \ --region yul-1 \ --project default-9 \ --billing-cycle hourly \ --workers 3 \ - --storage-category pro-nvme \ + --enable-csi \ --ssh-key mykey # HA cluster with multiple control nodes zcp kubernetes create \ --name ha-cluster \ - --version v1.36.1 \ - --plan k8s-la-yul-1 \ + --version v1.37.0 \ + --control-plane-plan k8s-cpi-yul \ + --worker-plan k8s-4xli-yul \ + --storage-plan b2g1 \ + --storage-category pro-nvme \ --region yul-1 \ --project default-9 \ --billing-cycle hourly \ --workers 3 \ --control-nodes 3 \ --ha \ - --storage-category pro-nvme \ + --enable-csi \ --ssh-key mykey +# The selected billing cycle applies to the resource subscriptions created for +# control-plane nodes, worker nodes, and their root volumes. Network, load +# balancer, and public-IP billing depends on the enabled platform packages and +# feature flags. +# Use --enable-csi to request Cloud Storage Integration for supported clusters. + # Kubeconfig zcp kubernetes get-config +# Cluster overview, including resource plans, totals, root-volume count, +# network, and autoscaling status when returned by the API. +zcp kubernetes get + # Lifecycle zcp kubernetes start zcp kubernetes stop zcp kubernetes scale --workers 5 +zcp kubernetes scale --enable-autoscaling --min-workers 2 --max-workers 5 +zcp kubernetes scale --disable-autoscaling --workers 3 zcp kubernetes upgrade --plan k8s-xla-yul-1 -zcp kubernetes upgrade-version --version v1.36.1 +zcp kubernetes upgrade-version --version v1.37.0 # Delete a cluster zcp kubernetes delete diff --git a/docs/development.md b/docs/development.md index 52aacc6..6d026a0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,12 +6,12 @@ This guide explains how to set up a development environment for ZCP CLI, underst ## Prerequisites -| Tool | Version | Notes | -| ------------ | ------- | ------------------------------------------------- | +| Tool | Version | Notes | +| ------------ | ------- | ----------------------------------------------------- | | Go language | 1.26.0 | Language minimum, from the `go` directive in `go.mod` | -| Go toolchain | 1.26.9 | Selected by the `toolchain` directive in `go.mod` | -| Make | Any | GNU Make for build targets | -| Git | Any | Required for version embedding via `git describe` | +| Go toolchain | 1.26.9 | Selected by the `toolchain` directive in `go.mod` | +| Make | Any | GNU Make for build targets | +| Git | Any | Required for version embedding via `git describe` | Install Go from [https://go.dev/dl/](https://go.dev/dl/). Verify your installation: diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 692a362..b506ce2 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -150,9 +150,9 @@ func TestK8sCreateRequiresBillingCycle(t *testing.T) { root.SetOut(&bytes.Buffer{}) root.SetErr(&bytes.Buffer{}) root.SetArgs([]string{"kubernetes", "create", - "--name", "test", "--version", "v1.28.4", "--plan", "k8s-1", + "--name", "test", "--version", "v1.28.4", "--control-plane-plan", "k8s-control-1", "--worker-plan", "k8s-worker-1", "--storage-plan", "k8s-root-volume-1", "--cloud-provider", "nimbo", "--region", "noida", "--project", "default-9", - "--workers", "1", "--ssh-key", "mykey"}) + "--workers", "1", "--storage-category", "nvme", "--ssh-key", "mykey"}) err := root.Execute() if err == nil { @@ -267,13 +267,27 @@ func TestVMBackupAtOutOfRange(t *testing.T) { } func TestVMBackupAtValidValues(t *testing.T) { - // at=12, immediate=1 should pass validation and reach the API. A stub - // server stands in for the API so this test never touches the network. + // at=12, immediate=1 should pass validation, create the schedule, and + // resolve its slug through the scoped listing. A stub server stands in for + // the API so this test never touches the network. var gotPath string + listCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"status":"Success","message":"ok","data":null}`) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[]}`) + return + } + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vmb-new","virtual_machine":{"slug":"my-vm"},"interval":"dailyAt","at":12}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/my-vm/backups": + gotPath = r.URL.Path + fmt.Fprint(w, `{"status":"Success","message":"ok","data":null}`) + default: + http.NotFound(w, r) + } })) defer srv.Close() diff --git a/internal/commands/dns.go b/internal/commands/dns.go index 1767a5d..a52c5f2 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -84,9 +84,8 @@ func newDNSShowCmd() *cobra.Command { return cmd } -// domainStatusCell renders a domain's status for display. The show endpoint -// does not return a status field, so print "-" rather than a misleading -// "false" (see issue #51); the list endpoint does return one. +// domainStatusCell renders a domain's status for display. A missing status is +// displayed as "-" rather than being confused with an explicit false. func domainStatusCell(d *dns.Domain) string { if !d.StatusKnown { return "-" @@ -108,6 +107,11 @@ func runDNSShow(cmd *cobra.Command, slug string) error { if err != nil { return fmt.Errorf("dns show: %w", err) } + if !domain.StatusKnown { + if err := resolveDNSDomainStatus(ctx, svc, domain); err != nil { + return fmt.Errorf("dns show: %w", err) + } + } // Print domain details detailHeaders := []string{"FIELD", "VALUE"} @@ -144,6 +148,24 @@ func runDNSShow(cmd *cobra.Command, slug string) error { return nil } +// resolveDNSDomainStatus fills a status omitted by the detail endpoint from a +// matching list entry that reports it. A missing matching entry remains unknown +// rather than being guessed from another field. +func resolveDNSDomainStatus(ctx context.Context, svc *dns.Service, domain *dns.Domain) error { + domains, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("resolving status for DNS domain %s: %w", domain.Slug, err) + } + for i := range domains { + if domains[i].Slug == domain.Slug && domains[i].StatusKnown { + domain.Status = domains[i].Status + domain.StatusKnown = true + break + } + } + return nil +} + func newDNSCreateCmd() *cobra.Command { var name, project, dnsProvider string var cloudProvider, region string diff --git a/internal/commands/dns_status_test.go b/internal/commands/dns_status_test.go new file mode 100644 index 0000000..1a031e9 --- /dev/null +++ b/internal/commands/dns_status_test.go @@ -0,0 +1,117 @@ +package commands + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDNSShowResolvesOmittedStatusFromList(t *testing.T) { + var listCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/dns/domains/example-com-1": + fmt.Fprint(w, `{"status":"Success","data":{"slug":"example-com-1","name":"example.com"}}`) + case "/dns/domains": + listCalls++ + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":1,"data":[{"slug":"example-com-1","status":false}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL) + if err != nil { + t.Fatalf("dns show error = %v", err) + } + if !strings.Contains(out, "false") { + t.Errorf("dns show output = %q, want resolved explicit false", out) + } + if listCalls != 1 { + t.Errorf("list calls = %d, want 1", listCalls) + } +} + +func TestDNSShowPreservesExplicitFalseWithoutListLookup(t *testing.T) { + var listCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/dns/domains/example-com-1" { + fmt.Fprint(w, `{"status":"Success","data":{"slug":"example-com-1","name":"example.com","status":false}}`) + return + } + if r.URL.Path == "/dns/domains" { + listCalls++ + } + http.NotFound(w, r) + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL) + if err != nil { + t.Fatalf("dns show error = %v", err) + } + if !strings.Contains(out, "false") { + t.Errorf("dns show output = %q, want explicit false", out) + } + if listCalls != 0 { + t.Errorf("list calls = %d, want no lookup when show reports status", listCalls) + } +} + +func TestDNSShowLeavesStatusUnknownWhenListHasNoMatchingSlug(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/dns/domains/example-com-1": + fmt.Fprint(w, `{"status":"Success","data":{"slug":"example-com-1","name":"example.com"}}`) + case "/dns/domains": + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":1,"data":[{"slug":"other-domain","status":true}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL) + if err != nil { + t.Fatalf("dns show error = %v", err) + } + if !strings.Contains(out, "-") || strings.Contains(out, "true") { + t.Errorf("dns show output = %q, want unknown status marker without a fabricated value", out) + } +} + +func TestDNSShowReturnsListLookupErrorRatherThanFabricatingStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/dns/domains/example-com-1": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","data":{"slug":"example-com-1","name":"example.com"}}`) + case "/dns/domains": + http.Error(w, "unavailable", http.StatusServiceUnavailable) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL) + if err == nil { + t.Fatal("dns show error = nil, want list lookup error") + } + if !strings.Contains(err.Error(), "resolving status") { + t.Errorf("dns show error = %q, want status resolution context", err) + } + if strings.Contains(out, "true") || strings.Contains(out, "false") { + t.Errorf("dns show output = %q, must not fabricate a status", out) + } +} diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index 4c84ad7..2f7ad8c 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -3,6 +3,7 @@ package commands import ( "bufio" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -76,7 +77,7 @@ func runK8sClusterList(cmd *cobra.Command) error { c.Name, c.State, c.Version, - strconv.Itoa(c.NodeSize), + strconv.Itoa(k8sWorkerNodeCount(c)), strconv.Itoa(c.ControlNodes), strconv.FormatBool(c.EnableHA), regionName, @@ -127,7 +128,7 @@ func runK8sClusterGet(cmd *cobra.Command, slug string) error { regionName = c.Region.Name } version := c.Version - workers := strconv.Itoa(c.NodeSize) + workers := strconv.Itoa(k8sWorkerNodeCount(*c)) controlNodes := strconv.Itoa(c.ControlNodes) endpoint := "" @@ -165,9 +166,199 @@ func runK8sClusterGet(cmd *cobra.Command, slug string) error { {"Region", regionName}, {"Created", c.CreatedAt}, } + if c.Meta != nil { + rows = append(rows, + []string{"Total CPU", c.Meta.CPU}, + []string{"Total RAM", k8sMemoryString(c.Meta.FormattedMemory, c.Meta.Memory)}, + ) + autoscaling := k8sAutoscalingStatus(c.Meta.AutoscalingEnabled, c.Autoscale != nil) + rows = append(rows, []string{"Autoscaling", autoscaling}) + if autoscaling == "Enabled" { + rows = append(rows, + []string{"Minimum workers", k8sRawValue(c.Meta.MinSize)}, + []string{"Maximum workers", k8sRawValue(c.Meta.MaxSize)}, + ) + } + } + if c.Offering != nil { + controlPlan, controlCPU, controlMemory, controlStorage := k8sPlanDetails( + c.Offering.MasterPlan, + c.Offering.MasterCustomCPU, + c.Offering.MasterCustomMemory, + c.Offering.MasterCustomStorage, + c.Offering.FormattedMasterCustomMemory, + c.Offering.FormattedMasterCustomStorage, + c.Offering.CPU, + c.Offering.Memory, + c.Offering.Storage, + c.Offering.FormattedMemory, + c.Offering.FormattedStorage, + ) + workerPlan, workerCPU, workerMemory, workerStorage := k8sPlanDetails( + c.Offering.WorkerPlan, + c.Offering.WorkerCustomCPU, + c.Offering.WorkerCustomMemory, + c.Offering.WorkerCustomStorage, + c.Offering.FormattedWorkerCustomMemory, + c.Offering.FormattedWorkerCustomStorage, + c.Offering.CPU, + c.Offering.Memory, + c.Offering.Storage, + c.Offering.FormattedMemory, + c.Offering.FormattedStorage, + ) + rows = append(rows, + []string{"Control-plane plan", controlPlan}, + []string{"Control-plane CPU", controlCPU}, + []string{"Control-plane memory", controlMemory}, + []string{"Control-plane storage", controlStorage}, + []string{"Worker plan", workerPlan}, + []string{"Worker CPU", workerCPU}, + []string{"Worker memory", workerMemory}, + []string{"Worker storage", workerStorage}, + ) + } + rootVolumes := 0 + for _, volume := range c.BlockStorages { + if volume.IsRoot { + rootVolumes++ + } + } + if rootVolumes > 0 { + rows = append(rows, []string{"Root volumes", strconv.Itoa(rootVolumes)}) + } + if c.Network != nil { + rows = append(rows, []string{"Network", k8sFirstNonEmpty(c.Network.Name, c.Network.Slug)}) + } return printer.PrintTable(headers, rows) } +func k8sWorkerNodeCount(cluster kubernetes.Cluster) int { + if cluster.WorkerNodeSize > 0 { + return cluster.WorkerNodeSize + } + return cluster.NodeSize +} + +func k8sObservedWorkerCount(cluster *kubernetes.Cluster) int { + workers := k8sWorkerNodeCount(*cluster) + if cluster.Meta != nil && cluster.Meta.Size != "" { + if n, err := strconv.Atoi(cluster.Meta.Size); err == nil { + workers = n + } + } + return workers +} + +func k8sRawValue(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var value interface{} + if err := json.Unmarshal(raw, &value); err != nil || value == nil { + return "" + } + return fmt.Sprint(value) +} + +func k8sAutoscalingEnabled(raw json.RawMessage) bool { + switch strings.ToLower(k8sRawValue(raw)) { + case "", "0", "false", "no": + return false + default: + return true + } +} + +func k8sAutoscalingStatus(raw json.RawMessage, autoscalePresent bool) string { + if autoscalePresent { + return "Enabled" + } + if len(raw) == 0 || string(raw) == "null" { + return "Unknown" + } + if k8sAutoscalingEnabled(raw) { + return "Enabled" + } + return "Disabled" +} + +func k8sFirstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func k8sPlanDetails(plan *kubernetes.NodePlan, customCPU, customMemory, customStorage json.RawMessage, formattedCustomMemory, formattedCustomStorage string, fallbackCPU, fallbackMemory, fallbackStorage json.RawMessage, formattedFallbackMemory, formattedFallbackStorage string) (name, cpu, memory, storage string) { + if plan != nil { + name = k8sFirstNonEmpty(plan.Name, plan.Slug) + if plan.Attribute != nil { + cpu = k8sCPUValue(k8sRawValue(plan.Attribute.FormattedCPU), plan.Attribute.CPU) + memory = k8sMemoryValue(plan.Attribute.FormattedMemory, plan.Attribute.Memory) + storage = k8sStorageValue(plan.Attribute.FormattedStorage, plan.Attribute.Storage) + } + } + cpu = k8sFirstNonEmpty(cpu, k8sCPUValue("", customCPU)) + memory = k8sFirstNonEmpty(memory, k8sMemoryValue(formattedCustomMemory, customMemory)) + storage = k8sFirstNonEmpty(storage, k8sStorageValue(formattedCustomStorage, customStorage)) + if plan == nil { + cpu = k8sFirstNonEmpty(cpu, k8sCPUValue("", fallbackCPU)) + memory = k8sFirstNonEmpty(memory, k8sMemoryValue(formattedFallbackMemory, fallbackMemory)) + storage = k8sFirstNonEmpty(storage, k8sStorageValue(formattedFallbackStorage, fallbackStorage)) + } + if name == "" && (cpu != "" || memory != "" || storage != "") { + name = "Custom" + } + return k8sFirstNonEmpty(name, "-"), k8sFirstNonEmpty(cpu, "-"), k8sFirstNonEmpty(memory, "-"), k8sFirstNonEmpty(storage, "-") +} + +func k8sCPUValue(formatted string, raw json.RawMessage) string { + if formatted != "" { + if _, err := strconv.ParseFloat(formatted, 64); err == nil { + return formatted + " Cores" + } + return formatted + } + if value := k8sRawValue(raw); value != "" { + return value + " Cores" + } + return "" +} + +func k8sMemoryValue(formatted string, raw json.RawMessage) string { + return k8sMemoryString(formatted, k8sRawValue(raw)) +} + +func k8sMemoryString(formatted, value string) string { + if formatted != "" { + return formatted + } + if value == "" { + return "" + } + amount, err := strconv.ParseFloat(value, 64) + if err != nil { + return value + " MB" + } + if amount >= 1024 { + return fmt.Sprintf("%g GB", amount/1024) + } + return fmt.Sprintf("%g MB", amount) +} + +func k8sStorageValue(formatted string, raw json.RawMessage) string { + if formatted != "" { + return formatted + } + if value := k8sRawValue(raw); value != "" { + return value + " GB" + } + return "" +} + func newK8sClusterCreateCmd() *cobra.Command { var ( name string @@ -180,7 +371,10 @@ func newK8sClusterCreateCmd() *cobra.Command { project string billingCycle string enableHA bool - plan string + enableCSI bool + controlPlanePlan string + workerPlan string + storagePlan string storageCategory string sshKey string authMethod string @@ -191,8 +385,8 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new Kubernetes cluster", - Example: ` zcp kubernetes create --name my-cluster --version v1.36.1 --plan k8s-la-yul-1 --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --storage-category pro-nvme --ssh-key mykey - zcp kubernetes create --name ha-cluster --version v1.36.1 --plan k8s-la-yul-1 --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --control-nodes 3 --ha --storage-category pro-nvme --ssh-key mykey`, + Example: ` zcp kubernetes create --name my-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul --storage-plan b2g1 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --ssh-key mykey + zcp kubernetes create --name ha-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-4xli-yul --storage-plan b2g1 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --control-nodes 3 --ha --ssh-key mykey`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -200,8 +394,14 @@ func newK8sClusterCreateCmd() *cobra.Command { if version == "" { return fmt.Errorf("--version is required") } - if plan == "" { - return fmt.Errorf("--plan is required") + if controlPlanePlan == "" { + return fmt.Errorf("--control-plane-plan is required") + } + if workerPlan == "" { + return fmt.Errorf("--worker-plan is required") + } + if storagePlan == "" { + return fmt.Errorf("--storage-plan is required") } cloudProvider = resolveCloudProvider(cmd, cloudProvider) if cloudProvider == "" { @@ -228,8 +428,8 @@ func newK8sClusterCreateCmd() *cobra.Command { if sshKey == "" && authMethod == "ssh-key" { return fmt.Errorf("--ssh-key is required when --auth-method is ssh-key") } - if enableHA && controlNodes < 3 { - fmt.Fprintf(os.Stderr, "WARNING: --ha is set but --control-nodes is %d; HA clusters typically require >= 3 control nodes\n", controlNodes) + if enableHA && controlNodes < 2 { + return fmt.Errorf("--control-nodes must be >= 2 when --ha is set") } return runK8sClusterCreate(cmd, kubernetes.CreateRequest{ Name: name, @@ -243,8 +443,11 @@ func newK8sClusterCreateCmd() *cobra.Command { Project: project, BillingCycle: billingCycle, EnableHA: enableHA, + EnableCSI: enableCSI, Networks: []string{}, - Plan: plan, + MasterPlan: controlPlanePlan, + WorkerPlan: workerPlan, + BlockstoragePlan: storagePlan, WithPoolCard: false, IsCustomPlan: false, CustomPlan: nil, @@ -259,7 +462,7 @@ func newK8sClusterCreateCmd() *cobra.Command { }, } cmd.Flags().StringVar(&name, "name", "", "Cluster name (required)") - cmd.Flags().StringVar(&version, "version", "", "Kubernetes version, e.g. v1.36.1 (required)") + cmd.Flags().StringVar(&version, "version", "", "Kubernetes version, e.g. v1.37.0 (required)") cmd.Flags().IntVar(&nodeSize, "workers", 0, "Number of worker nodes (required, >= 1)") cmd.Flags().IntVar(&controlNodes, "control-nodes", 1, "Number of control plane nodes (default 1)") cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (optional; auto-detected, override only)") @@ -268,7 +471,10 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly, monthly (required)") cmd.Flags().BoolVar(&enableHA, "ha", false, "Enable high availability") - cmd.Flags().StringVar(&plan, "plan", "", "Plan slug (required)") + cmd.Flags().BoolVar(&enableCSI, "enable-csi", false, "Enable Cloud Storage Integration (CSI)") + cmd.Flags().StringVar(&controlPlanePlan, "control-plane-plan", "", "Control-plane node plan slug (required)") + cmd.Flags().StringVar(&workerPlan, "worker-plan", "", "Worker node plan slug (required)") + cmd.Flags().StringVar(&storagePlan, "storage-plan", "", "Root-volume plan slug applied to each control-plane and worker node (required)") cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. pro-nvme, nvme, ssd (required)") cmd.Flags().StringVar(&sshKey, "ssh-key", "", "SSH key name") cmd.Flags().StringVar(&authMethod, "auth-method", "ssh-key", "Authentication method: ssh-key or password") @@ -298,7 +504,7 @@ func runK8sClusterCreate(cmd *cobra.Command, req kubernetes.CreateRequest) error cluster.Name, cluster.State, cluster.Version, - strconv.Itoa(cluster.NodeSize), + strconv.Itoa(k8sWorkerNodeCount(*cluster)), strconv.Itoa(cluster.ControlNodes), strconv.FormatBool(cluster.EnableHA), }} @@ -383,20 +589,46 @@ func runK8sClusterStop(cmd *cobra.Command, slug string, yes bool) error { } func newK8sClusterScaleCmd() *cobra.Command { - var workers int - var wait bool + var ( + workers int + minWorkers int + maxWorkers int + enableAutoscaling bool + disableAutoscaling bool + wait bool + ) cmd := &cobra.Command{ Use: "scale ", - Short: "Scale the number of worker nodes on a Kubernetes cluster", + Short: "Scale workers or configure worker autoscaling on a Kubernetes cluster", Args: exactArgs(1), Example: ` zcp kubernetes scale my-cluster --workers 5 - zcp k8s scale my-cluster --workers 3 --wait`, + zcp kubernetes scale my-cluster --enable-autoscaling --min-workers 2 --max-workers 5 + zcp kubernetes scale my-cluster --disable-autoscaling --workers 3 --wait`, RunE: func(cmd *cobra.Command, args []string) error { slug := args[0] - if workers < 1 { + if enableAutoscaling && disableAutoscaling { + return fmt.Errorf("--enable-autoscaling and --disable-autoscaling cannot be used together") + } + if enableAutoscaling { + if workers != 0 { + return fmt.Errorf("--workers cannot be used with --enable-autoscaling") + } + if minWorkers < 1 { + return fmt.Errorf("--min-workers must be >= 1 when enabling autoscaling") + } + if maxWorkers < minWorkers { + return fmt.Errorf("--max-workers must be >= --min-workers when enabling autoscaling") + } + if wait { + return fmt.Errorf("--wait cannot be used with --enable-autoscaling") + } + } else if workers < 1 { return fmt.Errorf("--workers must be >= 1") } + if !enableAutoscaling && (minWorkers != 0 || maxWorkers != 0) { + return fmt.Errorf("--min-workers and --max-workers require --enable-autoscaling") + } _, client, _, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -414,22 +646,33 @@ func newK8sClusterScaleCmd() *cobra.Command { default: return fmt.Errorf("cluster %q is in state %q — scale requires Running or Scaling state", slug, current.State) } - - currentWorkers := current.NodeSize - if current.Meta != nil && current.Meta.Size != "" { - if n, aerr := strconv.Atoi(current.Meta.Size); aerr == nil { - currentWorkers = n + if enableAutoscaling { + if err := svc.EnableAutoscaling(ctx, slug, minWorkers, maxWorkers); err != nil { + return fmt.Errorf("kubernetes scale: %w", err) } + fmt.Fprintf(os.Stdout, "Enabling autoscaling for %q with %d–%d worker(s) requested.\n", slug, minWorkers, maxWorkers) + return nil } - if currentWorkers == workers { + + currentWorkers := k8sObservedWorkerCount(current) + if !disableAutoscaling && currentWorkers == workers { fmt.Fprintf(os.Stdout, "Cluster %q already has %d worker(s) — no change made.\n", slug, workers) return nil } - if err := svc.Scale(ctx, slug, workers); err != nil { + if disableAutoscaling { + err = svc.DisableAutoscaling(ctx, slug, workers) + } else { + err = svc.Scale(ctx, slug, workers) + } + if err != nil { return fmt.Errorf("kubernetes scale: %w", err) } - fmt.Fprintf(os.Stdout, "Scaling %q from %d → %d worker(s) requested.\n", slug, currentWorkers, workers) + if disableAutoscaling { + fmt.Fprintf(os.Stdout, "Disabling autoscaling for %q with %d worker(s) requested.\n", slug, workers) + } else { + fmt.Fprintf(os.Stdout, "Scaling %q from %d → %d worker(s) requested.\n", slug, currentWorkers, workers) + } if wait { const maxWait = 10 * time.Minute @@ -446,18 +689,15 @@ func newK8sClusterScaleCmd() *cobra.Command { if err != nil { return fmt.Errorf("polling cluster state: %w", err) } - workerCount := c.NodeSize - if c.Meta != nil && c.Meta.Size != "" { - if n, aerr := strconv.Atoi(c.Meta.Size); aerr == nil { - workerCount = n - } - } + workerCount := k8sObservedWorkerCount(c) switch c.State { case "Scaling": // still in progress case "Running": - fmt.Fprintf(os.Stdout, "Done — state: %s, workers: %d\n", c.State, workerCount) - return nil + if k8sScaleComplete(c, workers, disableAutoscaling) { + fmt.Fprintf(os.Stdout, "Done — state: %s, workers: %d\n", c.State, workerCount) + return nil + } default: return fmt.Errorf("cluster %q entered unexpected state %q during scaling", slug, c.State) } @@ -468,12 +708,28 @@ func newK8sClusterScaleCmd() *cobra.Command { return nil }, } - cmd.Flags().IntVar(&workers, "workers", 0, "Target number of worker nodes (required)") + cmd.Flags().IntVar(&workers, "workers", 0, "Target worker count; required unless enabling autoscaling") + cmd.Flags().BoolVar(&enableAutoscaling, "enable-autoscaling", false, "Enable worker autoscaling") + cmd.Flags().BoolVar(&disableAutoscaling, "disable-autoscaling", false, "Disable worker autoscaling") + cmd.Flags().IntVar(&minWorkers, "min-workers", 0, "Minimum workers when enabling autoscaling") + cmd.Flags().IntVar(&maxWorkers, "max-workers", 0, "Maximum workers when enabling autoscaling") cmd.Flags().BoolVar(&wait, "wait", false, "Block until scaling completes") - _ = cmd.MarkFlagRequired("workers") return cmd } +func k8sScaleComplete(cluster *kubernetes.Cluster, workers int, autoscalingDisabled bool) bool { + if cluster.State != "Running" || k8sObservedWorkerCount(cluster) != workers { + return false + } + if autoscalingDisabled && cluster.Autoscale != nil { + return false + } + if autoscalingDisabled && cluster.Meta != nil && len(cluster.Meta.AutoscalingEnabled) > 0 && string(cluster.Meta.AutoscalingEnabled) != "null" && k8sAutoscalingEnabled(cluster.Meta.AutoscalingEnabled) { + return false + } + return true +} + func newK8sGetConfigCmd() *cobra.Command { var ( outputPath string @@ -599,8 +855,8 @@ func newK8sClusterUpgradeVersionCmd() *cobra.Command { Use: "upgrade-version ", Short: "Upgrade the Kubernetes version of a cluster", Args: exactArgs(1), - Example: ` zcp kubernetes upgrade-version my-cluster --version v1.35.1 - zcp kubernetes upgrade-version my-cluster --version v1.36.1`, + Example: ` zcp kubernetes upgrade-version my-cluster --version v1.36.4 + zcp kubernetes upgrade-version my-cluster --version v1.37.0`, RunE: func(cmd *cobra.Command, args []string) error { if version == "" { return fmt.Errorf("--version is required") diff --git a/internal/commands/kubernetes_test.go b/internal/commands/kubernetes_test.go new file mode 100644 index 0000000..58203ec --- /dev/null +++ b/internal/commands/kubernetes_test.go @@ -0,0 +1,390 @@ +package commands + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/zsoftly/zcp-cli/pkg/api/kubernetes" +) + +func TestK8sWorkerNodeCount(t *testing.T) { + tests := []struct { + name string + cluster kubernetes.Cluster + want int + }{ + { + name: "worker node size", + cluster: kubernetes.Cluster{NodeSize: 3, WorkerNodeSize: 4}, + want: 4, + }, + { + name: "legacy node size", + cluster: kubernetes.Cluster{NodeSize: 3}, + want: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := k8sWorkerNodeCount(tt.cluster); got != tt.want { + t.Errorf("k8sWorkerNodeCount() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestK8sScaleComplete(t *testing.T) { + tests := []struct { + name string + cluster kubernetes.Cluster + workers int + autoscalingDisabled bool + want bool + }{ + { + name: "matching worker count", + cluster: kubernetes.Cluster{State: "Running", WorkerNodeSize: 3}, + workers: 3, + want: true, + }, + { + name: "running before worker count updates", + cluster: kubernetes.Cluster{State: "Running", WorkerNodeSize: 2}, + workers: 3, + want: false, + }, + { + name: "platform worker count", + cluster: kubernetes.Cluster{State: "Running", NodeSize: 1, Meta: &kubernetes.ClusterMeta{Size: "3"}}, + workers: 3, + want: true, + }, + { + name: "autoscaling still enabled", + cluster: kubernetes.Cluster{State: "Running", WorkerNodeSize: 3, Meta: &kubernetes.ClusterMeta{AutoscalingEnabled: json.RawMessage("true")}}, + workers: 3, + autoscalingDisabled: true, + want: false, + }, + { + name: "autoscaling disabled", + cluster: kubernetes.Cluster{State: "Running", WorkerNodeSize: 3, Meta: &kubernetes.ClusterMeta{AutoscalingEnabled: json.RawMessage("false")}}, + workers: 3, + autoscalingDisabled: true, + want: true, + }, + { + name: "autoscaling relation still present", + cluster: kubernetes.Cluster{State: "Running", WorkerNodeSize: 3, Autoscale: &kubernetes.Autoscale{}}, + workers: 3, + autoscalingDisabled: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := k8sScaleComplete(&tt.cluster, tt.workers, tt.autoscalingDisabled); got != tt.want { + t.Errorf("k8sScaleComplete() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestK8sCreateSendsSeparateResourcePlans(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + + var ( + request map[string]interface{} + requestErr error + requestMu sync.Mutex + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/kubernetes-clusters" { + http.NotFound(w, r) + return + } + decoded := map[string]interface{}{} + err := json.NewDecoder(r.Body).Decode(&decoded) + requestMu.Lock() + request, requestErr = decoded, err + requestMu.Unlock() + if 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-cluster","name":"new-cluster","state":"Creating","version":"v1.36.1","worker_node_size":3,"control_nodes":1}}`)) + })) + defer srv.Close() + + _, _, err := execCmd(t, NewKubernetesCmd(), + "create", + "--name", "new-cluster", + "--version", "v1.36.1", + "--control-plane-plan", "k8s-cpi-yul", + "--worker-plan", "k8s-li-yul", + "--storage-plan", "b2g1", + "--storage-category", "pro-nvme", + "--cloud-provider", "nimbo", + "--region", "yul-1", + "--project", "test", + "--billing-cycle", "hourly", + "--workers", "3", + "--enable-csi", + "--ssh-key", "test-key", + "--api-url", srv.URL, + ) + if err != nil { + t.Fatalf("kubernetes create: %v", err) + } + requestMu.Lock() + defer requestMu.Unlock() + if requestErr != nil { + t.Fatalf("decode request: %v", requestErr) + } + + for field, want := range map[string]interface{}{ + "master_plan": "k8s-cpi-yul", + "worker_plan": "k8s-li-yul", + "blockstorage_plan": "b2g1", + "billing_cycle": "hourly", + "node_size": float64(3), + "worker_node_size": float64(3), + "control_nodes": float64(1), + "enable_csi": true, + } { + if got := request[field]; got != want { + t.Errorf("%s = %#v, want %#v", field, got, want) + } + } + if _, ok := request["plan"]; ok { + t.Error("legacy plan must be omitted from a modern Kubernetes create request") + } +} + +func TestK8sGetShowsSafeResourceOverview(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/kubernetes-clusters/cluster-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status":"Success", + "data":{ + "slug":"cluster-1","name":"cluster-1","state":"Running","node_size":2,"worker_node_size":2,"control_nodes":1, + "meta":{"size":"2","control_nodes":"1","cpu_number":"6","memory":"12288","autoscaling_enabled":true,"min_size":1,"max_size":4,"kubernetes_version_name":"Kubernetes 1.37.0","config":{"configdata":"must-not-print"}}, + "offering":{"master_plan":{"name":"control","attribute":{"formatted_cpu":4,"cpu":4,"formatted_memory":"8 GB","formatted_storage":"100 GB"}},"worker_plan":null,"master_custom_cpu":null,"master_custom_memory":null,"master_custom_storage":null,"worker_custom_cpu":null,"worker_custom_memory":null,"worker_custom_storage":null,"cpu":2,"memory":4096,"storage":100,"formatted_memory":"4 GB","formatted_storage":"100 GB"}, + "blockstorages":[{"name":"root-1","is_root":true},{"name":"root-2","is_root":true},{"name":"data-1","is_root":false}], + "network":{"name":"cluster-network","slug":"cluster-network"} + } + }`)) + })) + defer srv.Close() + + stdout, _, err := execCmd(t, NewKubernetesCmd(), "get", "cluster-1", "--api-url", srv.URL) + if err != nil { + t.Fatalf("kubernetes get: %v", err) + } + for _, want := range []string{ + "Kubernetes 1.37.0", "Total CPU", "6", "Total RAM", "12 GB", + "Autoscaling", "Enabled", "Minimum workers", "Maximum workers", + "Custom", "2 Cores", "4 Cores", "4 GB", "100 GB", "Control-plane storage", + "Root volumes", "2", "cluster-network", + } { + if !strings.Contains(stdout, want) { + t.Errorf("output missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, "must-not-print") { + t.Errorf("output leaks kubeconfig: %q", stdout) + } +} + +func TestK8sGetShowsUnknownAutoscalingWhenOmitted(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/kubernetes-clusters/cluster-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","state":"Running","meta":{}}}`)) + })) + defer srv.Close() + + stdout, _, err := execCmd(t, NewKubernetesCmd(), "get", "cluster-1", "--api-url", srv.URL) + if err != nil { + t.Fatalf("kubernetes get: %v", err) + } + if !strings.Contains(stdout, "Autoscaling") || !strings.Contains(stdout, "Unknown") { + t.Errorf("output must show unknown autoscaling status when omitted:\n%s", stdout) + } +} + +func TestK8sAutoscalingStatus(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + }{ + {name: "omitted", want: "Unknown"}, + {name: "null", raw: json.RawMessage("null"), want: "Unknown"}, + {name: "empty string", raw: json.RawMessage(`""`), want: "Disabled"}, + {name: "enabled", raw: json.RawMessage("true"), want: "Enabled"}, + {name: "disabled", raw: json.RawMessage("false"), want: "Disabled"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := k8sAutoscalingStatus(tt.raw, false); got != tt.want { + t.Errorf("k8sAutoscalingStatus() = %q, want %q", got, tt.want) + } + }) + } + if got := k8sAutoscalingStatus(nil, true); got != "Enabled" { + t.Errorf("k8sAutoscalingStatus() = %q with an autoscale relation, want Enabled", got) + } +} + +func TestK8sPlanDetailsDoesNotUseLegacyFallbackForNamedPlan(t *testing.T) { + name, cpu, memory, storage := k8sPlanDetails( + &kubernetes.NodePlan{Name: "modern-plan"}, + nil, nil, nil, "", "", + json.RawMessage("2"), json.RawMessage("4096"), json.RawMessage("100"), "4 GB", "100 GB", + ) + if name != "modern-plan" || cpu != "-" || memory != "-" || storage != "-" { + t.Errorf("k8sPlanDetails() = (%q, %q, %q, %q), want modern plan with unavailable attributes", name, cpu, memory, storage) + } +} + +func TestK8sScaleAutoscalingModes(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + for _, tt := range []struct { + name string + args []string + want map[string]interface{} + }{ + { + name: "enable", + args: []string{"--enable-autoscaling", "--min-workers", "2", "--max-workers", "5"}, + want: map[string]interface{}{"autoscale": float64(1), "min_cluster_size": float64(2), "max_cluster_size": float64(5)}, + }, + { + name: "disable", + args: []string{"--disable-autoscaling", "--workers", "3"}, + want: map[string]interface{}{"autoscale": float64(0), "node_size": float64(3)}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + var ( + body map[string]interface{} + mu sync.Mutex + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","state":"Running","node_size":2}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/kubernetes-clusters/cluster-1/scale": + decoded := map[string]interface{}{} + if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { + t.Errorf("decode request: %v", err) + } + mu.Lock() + body = decoded + mu.Unlock() + _, _ = w.Write([]byte(`{"status":"Success"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + args := append([]string{"scale", "cluster-1", "--api-url", srv.URL}, tt.args...) + if _, _, err := execCmd(t, NewKubernetesCmd(), args...); err != nil { + t.Fatalf("kubernetes scale: %v", err) + } + mu.Lock() + defer mu.Unlock() + for field, want := range tt.want { + if got := body[field]; got != want { + t.Errorf("%s = %#v, want %#v", field, got, want) + } + } + if tt.name == "enable" { + if _, ok := body["node_size"]; ok { + t.Errorf("enable payload includes node_size = %#v", body["node_size"]) + } + } + }) + } +} + +func TestK8sCreateRequiresSeparateResourcePlans(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "control plane", + args: []string{"--worker-plan", "k8s-worker-1", "--storage-plan", "k8s-root-volume-1"}, + want: "--control-plane-plan is required", + }, + { + name: "worker", + args: []string{"--control-plane-plan", "k8s-control-1", "--storage-plan", "k8s-root-volume-1"}, + want: "--worker-plan is required", + }, + { + name: "storage", + args: []string{"--control-plane-plan", "k8s-control-1", "--worker-plan", "k8s-worker-1"}, + want: "--storage-plan is required", + }, + { + name: "high availability control nodes", + args: []string{"--control-plane-plan", "k8s-control-1", "--worker-plan", "k8s-worker-1", "--storage-plan", "k8s-root-volume-1", "--ha"}, + want: "--control-nodes must be >= 2 when --ha is set", + }, + } + + baseArgs := []string{ + "kubernetes", "create", + "--name", "test-cluster", + "--version", "v1.36.1", + "--cloud-provider", "nimbo", + "--region", "yul-1", + "--project", "test", + "--billing-cycle", "hourly", + "--workers", "1", + "--storage-category", "pro-nvme", + "--ssh-key", "mykey", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newTestRoot() + root.AddCommand(NewKubernetesCmd()) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append(baseArgs, tt.args...)) + + err := root.Execute() + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error = %q, want %q", err, tt.want) + } + }) + } +} diff --git a/internal/commands/vmbackup.go b/internal/commands/vmbackup.go index 6b87207..c7fe2df 100644 --- a/internal/commands/vmbackup.go +++ b/internal/commands/vmbackup.go @@ -5,11 +5,13 @@ import ( "context" "fmt" "os" + "sort" "strconv" "strings" "time" "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/output" "github.com/zsoftly/zcp-cli/pkg/api/apierrors" "github.com/zsoftly/zcp-cli/pkg/api/vmbackup" ) @@ -23,6 +25,7 @@ func NewVMBackupCmd() *cobra.Command { cmd.AddCommand(newVMBackupListCmd()) cmd.AddCommand(newVMBackupCreateCmd()) cmd.AddCommand(newVMBackupDeleteCmd()) + cmd.AddCommand(newVMBackupScheduleCmd()) return cmd } @@ -217,7 +220,7 @@ func runVMBackupDelete(cmd *cobra.Command, slug string) error { } func runVMBackupCreate(cmd *cobra.Command, vmSlug string, req vmbackup.CreateRequest) error { - _, client, _, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } @@ -226,12 +229,118 @@ func runVMBackupCreate(cmd *cobra.Command, vmSlug string, req vmbackup.CreateReq ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() + // The create endpoint usually returns only an acknowledgement. Take a scoped + // baseline before creating. Use a later list to identify the new schedule + // without adopting an existing matching schedule. + before, err := svc.List(ctx, req.Region, req.Project) + if err != nil { + return fmt.Errorf("vm-backup create: listing existing backups before create: %w", err) + } + known := make(map[string]struct{}, len(before)) + for _, backup := range before { + if backup.Slug != "" { + known[backup.Slug] = struct{}{} + } + } + resp, err := svc.Create(ctx, vmSlug, req) if err != nil { return fmt.Errorf("vm-backup create: %w", err) } - fmt.Fprintf(cmd.ErrOrStderr(), "VM backup created: %s. %s\n", resp.Status, resp.Message) - fmt.Fprintln(cmd.ErrOrStderr(), "Run 'zcp vm-backup list' to see the schedule slug.") - return nil + if slug := vmBackupActionSlug(resp); slug != "" { + if _, exists := known[slug]; !exists { + return printVMBackupCreated(printer, &vmbackup.VMBackup{ + Slug: slug, + VirtualMachine: &vmbackup.VMRef{Slug: vmSlug}, + Interval: req.Interval, + At: req.At, + }) + } + } + + created, err := findCreatedVMBackup(ctx, svc, known, vmSlug, req) + if err != nil { + return fmt.Errorf("VM backup for %q was created, but its schedule slug could not be determined: %w. Run 'zcp vm-backup list' to find it before creating another schedule", vmSlug, err) + } + return printVMBackupCreated(printer, created) +} + +// vmBackupCreateLookupAttempts bounds the read-only discovery performed after +// the API accepts a create request. A create is never retried. +const vmBackupCreateLookupAttempts = 3 + +// vmBackupActionSlug returns a slug only from an object response containing a +// non-empty slug. The current API commonly returns an empty data field, which +// is why runVMBackupCreate also resolves the new schedule through the list +// endpoint. +func vmBackupActionSlug(resp *vmbackup.ActionResponse) string { + if resp == nil { + return "" + } + data, ok := resp.Data.(map[string]interface{}) + if !ok { + return "" + } + slug, _ := data["slug"].(string) + return strings.TrimSpace(slug) +} + +func findCreatedVMBackup(ctx context.Context, svc *vmbackup.Service, known map[string]struct{}, vmSlug string, req vmbackup.CreateRequest) (*vmbackup.VMBackup, error) { + candidates := make(map[string]*vmbackup.VMBackup) + for attempt := 0; attempt < vmBackupCreateLookupAttempts; attempt++ { + backups, err := svc.List(ctx, req.Region, req.Project) + if err != nil { + return nil, fmt.Errorf("listing backups after create: %w", err) + } + + for i := range backups { + backup := &backups[i] + if backup.Slug == "" || backup.VMSlug() != vmSlug || backup.Interval != req.Interval || backup.At != req.At { + continue + } + if _, exists := known[backup.Slug]; exists { + continue + } + candidates[backup.Slug] = backup + } + if attempt+1 < vmBackupCreateLookupAttempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } + } + if len(candidates) == 1 { + for _, backup := range candidates { + return backup, nil + } + } + if len(candidates) > 1 { + slugs := make([]string, 0, len(candidates)) + for slug := range candidates { + slugs = append(slugs, slug) + } + sort.Strings(slugs) + return nil, fmt.Errorf("%d new matching backups appeared (%s); cannot determine which schedule was created", len(slugs), strings.Join(slugs, ", ")) + } + return nil, fmt.Errorf("the new schedule did not appear in %d list attempts", vmBackupCreateLookupAttempts) +} + +func printVMBackupCreated(printer *output.Printer, backup *vmbackup.VMBackup) error { + if printer.Format() == output.FormatJSON || printer.Format() == output.FormatYAML { + return printer.Print(backup) + } + return printer.PrintTable( + []string{"FIELD", "VALUE"}, + [][]string{ + {"Slug", backup.Slug}, + {"Name", backup.Name}, + {"VM", backup.VMSlug()}, + {"Interval", backup.Interval}, + {"At", strconv.Itoa(backup.At)}, + {"Scheduled At", backup.ScheduledAt}, + }, + ) } diff --git a/internal/commands/vmbackup_create_test.go b/internal/commands/vmbackup_create_test.go new file mode 100644 index 0000000..9b74ece --- /dev/null +++ b/internal/commands/vmbackup_create_test.go @@ -0,0 +1,277 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/zsoftly/zcp-cli/pkg/api/vmbackup" + "gopkg.in/yaml.v3" +) + +func vmBackupCreateArgs(apiURL string, extra ...string) []string { + args := []string{ + "create", "vm-1", + "--cloud-provider", "zsoftly", + "--region", "yul-1", + "--project", "project-1", + "--billing-cycle", "hourly", + "--plan", "backup-yul", + "--pseudo-service", "vm-backup", + "--interval", "dailyAt", + "--at", "3", + "--api-url", apiURL, + } + return append(args, extra...) +} + +func writeVMBackupList(w http.ResponseWriter, data string) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"Success","current_page":1,"last_page":1,"data":%s}`, data) +} + +func TestVMBackupCreatePrintsResolvedSlugAsJSON(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + listCalls, postCalls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + writeVMBackupList(w, `[]`) + return + } + writeVMBackupList(w, `[{"slug":"vmb-new","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":"3"}]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + postCalls++ + fmt.Fprint(w, `{"status":"Success","message":"accepted","data":{}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, stderr, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL, "--output", "json")...) + if err != nil { + t.Fatalf("vm-backup create: %v", err) + } + if postCalls != 1 || listCalls != 4 { + t.Fatalf("post calls = %d, list calls = %d, want 1 and 4", postCalls, listCalls) + } + if stderr != "" || strings.Contains(stdout, "accepted") { + t.Fatalf("stdout=%q stderr=%q, status text must not contaminate structured output", stdout, stderr) + } + var got vmbackup.VMBackup + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("JSON output = %q: %v", stdout, err) + } + if got.Slug != "vmb-new" || got.VMSlug() != "vm-1" || got.Interval != "dailyAt" || got.At != 3 { + t.Fatalf("created backup = %#v, want slug=vmb-new vm=vm-1 interval=dailyAt at=3", got) + } +} + +func TestVMBackupCreatePrintsResolvedSlugAsTable(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + writeVMBackupList(w, `[]`) + return + } + writeVMBackupList(w, `[{"slug":"vmb-new","name":"daily","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + fmt.Fprint(w, `{"status":"Success","data":null}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, stderr, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL)...) + if err != nil || !strings.Contains(stdout, "vmb-new") || stderr != "" { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout, stderr, err) + } +} + +func TestVMBackupCreateExcludesPreexistingMatchingSchedule(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + writeVMBackupList(w, `[{"slug":"vmb-old","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + return + } + writeVMBackupList(w, `[{"slug":"vmb-old","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3},{"slug":"vmb-new","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + fmt.Fprint(w, `{"status":"Success","data":{}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL, "--output", "json")...) + if err != nil || !strings.Contains(stdout, `"slug": "vmb-new"`) || strings.Contains(stdout, "vmb-old") { + t.Fatalf("stdout=%q err=%v", stdout, err) + } +} + +func TestVMBackupCreateUsesSlugFromActionResponse(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + writeVMBackupList(w, `[]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + fmt.Fprint(w, `{"status":"Success","data":{"slug":"vmb-from-action"}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL, "--output", "json")...) + if err != nil || listCalls != 1 || !strings.Contains(stdout, `"slug": "vmb-from-action"`) || !strings.Contains(stdout, `"interval": "dailyAt"`) || !strings.Contains(stdout, `"at": 3`) || !strings.Contains(stdout, `"virtual_machine"`) { + t.Fatalf("stdout=%q list calls=%d err=%v", stdout, listCalls, err) + } +} + +func TestVMBackupCreatePrintsValidYAML(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + writeVMBackupList(w, `[]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + fmt.Fprint(w, `{"status":"Success","data":{"slug":"vmb-yaml"}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, stderr, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL, "--output", "yaml")...) + if err != nil || stderr != "" { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout, stderr, err) + } + var got map[string]interface{} + if err := yaml.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("YAML output = %q: %v", stdout, err) + } + if got["slug"] != "vmb-yaml" { + t.Fatalf("slug = %#v, want vmb-yaml", got["slug"]) + } +} + +func TestVMBackupCreateReportsAmbiguousLookupAfterCreate(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + postCalls, listCalls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + writeVMBackupList(w, `[]`) + return + } + writeVMBackupList(w, `[{"slug":"vmb-one","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3},{"slug":"vmb-two","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + postCalls++ + fmt.Fprint(w, `{"status":"Success","data":{}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + _, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL)...) + if err == nil || !strings.Contains(err.Error(), "was created") || !strings.Contains(err.Error(), "cannot determine") || postCalls != 1 { + t.Fatalf("err=%v post calls=%d", err, postCalls) + } +} + +func TestVMBackupCreateRejectsCandidateWhenLaterPollIsAmbiguous(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + postCalls, listCalls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + switch listCalls { + case 1: + writeVMBackupList(w, `[]`) + case 2: + writeVMBackupList(w, `[{"slug":"vmb-other","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + default: + writeVMBackupList(w, `[{"slug":"vmb-other","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3},{"slug":"vmb-created","virtual_machine":{"slug":"vm-1"},"interval":"dailyAt","at":3}]`) + } + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + postCalls++ + fmt.Fprint(w, `{"status":"Success","data":{}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + _, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL)...) + if err == nil || !strings.Contains(err.Error(), "cannot determine") || postCalls != 1 || listCalls != 4 { + t.Fatalf("err=%v post calls=%d list calls=%d", err, postCalls, listCalls) + } +} + +func TestVMBackupCreateReportsFailedLookupAfterCreate(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + listCalls, postCalls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/backups": + listCalls++ + if listCalls == 1 { + writeVMBackupList(w, `[]`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":{}}`) + case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/vm-1/backups": + postCalls++ + fmt.Fprint(w, `{"status":"Success","data":{}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + _, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL)...) + if err == nil || postCalls != 1 || !strings.Contains(err.Error(), "was created") || !strings.Contains(err.Error(), "listing backups after create") { + t.Fatalf("err=%v post calls=%d", err, postCalls) + } +} + +func TestVMBackupCreateDoesNotPostWhenBaselineListFails(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + postCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + postCalled = true + } + http.Error(w, `{"message":"list unavailable"}`, http.StatusServiceUnavailable) + })) + defer srv.Close() + + _, _, err := execCmd(t, NewVMBackupCmd(), vmBackupCreateArgs(srv.URL)...) + if err == nil || !strings.Contains(err.Error(), "listing existing backups before create") || postCalled { + t.Fatalf("err=%v post called=%t", err, postCalled) + } +} diff --git a/internal/commands/vmbackupschedule.go b/internal/commands/vmbackupschedule.go new file mode 100644 index 0000000..e054568 --- /dev/null +++ b/internal/commands/vmbackupschedule.go @@ -0,0 +1,355 @@ +package commands + +import ( + "context" + "fmt" + "os" + "strings" + "time" + _ "time/tzdata" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/output" + "github.com/zsoftly/zcp-cli/pkg/api/instance" + "github.com/zsoftly/zcp-cli/pkg/api/scheduler" +) + +func newVMBackupScheduleCmd() *cobra.Command { + cmd := &cobra.Command{Use: "schedule", Short: "Manage VM backup schedules"} + cmd.AddCommand(newVMBackupScheduleListCmd(), newVMBackupScheduleGetCmd(), newVMBackupScheduleCreateCmd(), newVMBackupScheduleUpdateCmd(), newVMBackupScheduleDeleteCmd(), newVMBackupSchedulePauseCmd(), newVMBackupScheduleResumeCmd(), newVMBackupScheduleRunNowCmd()) + return cmd +} + +func schedulerService(cmd *cobra.Command) (*scheduler.Service, context.Context, context.CancelFunc, error) { + _, client, _, err := buildClientAndPrinter(cmd) + if err != nil { + return nil, nil, nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + return scheduler.NewService(client), ctx, cancel, nil +} + +func scopedSchedule(cmd *cobra.Command, svc *scheduler.Service, ctx context.Context, id string) (*scheduler.Policy, error) { + p, err := svc.Get(ctx, id) + if err != nil { + return nil, err + } + region, project := scopedRegionProject(cmd) + if p.Action != "Virtual Machine Backup" || p.Region == nil || p.Project == nil || p.Region.Slug != region || p.Project.Slug != project { + return nil, fmt.Errorf("schedule %q is not a VM backup in region %q and project %q", id, region, project) + } + return p, nil +} + +func scopedVM(cmd *cobra.Command, ctx context.Context, vmSlug string) error { + _, client, _, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + region, project := scopedRegionProject(cmd) + vms, err := instance.NewService(client).List(ctx, region, project) + if err != nil { + return fmt.Errorf("listing VMs in scope: %w", err) + } + for _, vm := range vms { + if vm.Slug == vmSlug { + return nil + } + } + return fmt.Errorf("VM %q was not found in region %q and project %q", vmSlug, region, project) +} + +func printSchedule(cmd *cobra.Command, p *scheduler.Policy) error { + _, _, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + if outputFormat(cmd) != output.FormatTable { + return printer.Print(p) + } + day := "-" + if p.Day != nil { + day = fmt.Sprint(*p.Day) + } + return printer.PrintTable([]string{"ID", "NAME", "SLUG", "VM", "INTERVAL", "DAY", "AT", "TIMEZONE", "RETENTION", "STATUS", "LAST RUN", "LAST RESULT", "LAST ERROR", "NEXT SCHEDULED"}, [][]string{{p.ID, p.Name, p.Slug, scheduleVM(p), p.Interval, day, p.At, p.Timezone, fmt.Sprint(p.RetentionPolicy), p.Status, p.LastRunTime, p.LastRunStatus, p.LastErrorMessage, p.NextScheduledAt}}) +} + +func scheduleVM(p *scheduler.Policy) string { + if p.Actionable != nil { + return p.Actionable.Slug + } + return "-" +} + +func outputFormat(cmd *cobra.Command) output.Format { + if v := os.Getenv("ZCP_OUTPUT"); v != "" { + return output.ParseFormat(v) + } + v, _ := cmd.Root().PersistentFlags().GetString("output") + return output.ParseFormat(v) +} + +func newVMBackupScheduleListCmd() *cobra.Command { + return &cobra.Command{Use: "list", Short: "List VM backup schedules", RunE: func(cmd *cobra.Command, args []string) error { + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + region, project := scopedRegionProject(cmd) + all, err := svc.List(ctx, region, project) + if err != nil { + return fmt.Errorf("vm-backup schedule list: %w", err) + } + _, _, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + if outputFormat(cmd) != output.FormatTable { + return printer.Print(all) + } + rows := make([][]string, 0, len(all)) + for _, p := range all { + day := "-" + if p.Day != nil { + day = fmt.Sprint(*p.Day) + } + rows = append(rows, []string{p.ID, p.Name, p.Slug, scheduleVM(&p), p.Interval, day, p.At, p.Timezone, fmt.Sprint(p.RetentionPolicy), p.Status, p.LastRunTime, p.LastRunStatus, p.LastErrorMessage, p.NextScheduledAt}) + } + return printer.PrintTable([]string{"ID", "NAME", "SLUG", "VM", "INTERVAL", "DAY", "AT", "TIMEZONE", "RETENTION", "STATUS", "LAST RUN", "LAST RESULT", "LAST ERROR", "NEXT SCHEDULED"}, rows) + }} +} +func newVMBackupScheduleGetCmd() *cobra.Command { + return &cobra.Command{Use: "get ", Short: "Show a VM backup schedule", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + p, err := scopedSchedule(cmd, svc, ctx, args[0]) + if err != nil { + return fmt.Errorf("vm-backup schedule get: %w", err) + } + return printSchedule(cmd, p) + }} +} + +func scheduleFlags(cmd *cobra.Command, interval, at, timezone *string, day *int, retention *int) { + cmd.Flags().StringVar(interval, "interval", "", "Schedule interval: hourly, dailyAt, everyOtherDay, weeklyOn, or monthlyOn") + cmd.Flags().StringVar(at, "at", "", "Time as a whole hour, HH:00 (defaults to 00:00 for hourly)") + cmd.Flags().StringVar(timezone, "timezone", "", "IANA timezone, for example America/Toronto or UTC") + cmd.Flags().IntVar(day, "day", -1, "Weekday 0-6 for weeklyOn, or day 1-28 for monthlyOn") + cmd.Flags().IntVar(retention, "retention", 0, "Maximum snapshots/backups to retain (minimum 1)") +} +func validateSchedule(interval, at, timezone string, day, retention int) (*int, error) { + switch interval { + case "hourly", "dailyAt", "everyOtherDay", "weeklyOn", "monthlyOn": + default: + return nil, fmt.Errorf("--interval must be hourly, dailyAt, everyOtherDay, weeklyOn, or monthlyOn") + } + if strings.TrimSpace(timezone) == "" { + return nil, fmt.Errorf("--timezone is required") + } + if timezone == "Local" { + return nil, fmt.Errorf("--timezone must be an IANA timezone, not Local") + } + if _, err := time.LoadLocation(timezone); err != nil { + return nil, fmt.Errorf("--timezone must be a valid IANA timezone: %w", err) + } + if retention < 1 { + return nil, fmt.Errorf("--retention must be at least 1") + } + if at != "" { + if _, err := time.Parse("15:04", at); err != nil { + return nil, fmt.Errorf("--at must be HH:MM") + } + if !strings.HasSuffix(at, ":00") { + return nil, fmt.Errorf("--at must be a whole hour (HH:00)") + } + } else if interval != "hourly" { + return nil, fmt.Errorf("--at must be HH:MM") + } + if interval == "weeklyOn" { + if day < 0 || day > 6 { + return nil, fmt.Errorf("--day must be 0 through 6 for weeklyOn") + } + return &day, nil + } + if interval == "monthlyOn" { + if day < 1 || day > 28 { + return nil, fmt.Errorf("--day must be 1 through 28 for monthlyOn") + } + return &day, nil + } + if day != -1 { + return nil, fmt.Errorf("--day is valid only with weeklyOn or monthlyOn") + } + return nil, nil +} + +func newVMBackupScheduleCreateCmd() *cobra.Command { + var interval, at, tz, name, description string + var day, retention int + var immediate bool + cmd := &cobra.Command{Use: "create ", Short: "Create a VM backup schedule", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if interval == "hourly" && at == "" { + at = "00:00" + } + d, err := validateSchedule(interval, at, tz, day, retention) + if err != nil { + return err + } + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + if err := scopedVM(cmd, ctx, args[0]); err != nil { + return err + } + i := 0 + if immediate { + i = 1 + } + var nameValue, descriptionValue *string + if v := strings.TrimSpace(name); v != "" { + nameValue = &v + } + if v := strings.TrimSpace(description); v != "" { + descriptionValue = &v + } + p, err := svc.Create(ctx, scheduler.CreateRequest{Service: "Virtual Machine", Slug: args[0], Action: "VM Backup", Name: nameValue, Description: descriptionValue, Interval: interval, Day: d, At: at, Timezone: tz, RetentionPolicy: retention, TakeOneImmediately: i, Config: scheduler.Config{TakeOneImmediately: i}}) + if err != nil { + return fmt.Errorf("vm-backup schedule create: the API may have accepted the request before returning an error (%w). Run 'zcp vm-backup schedule list' before retrying", err) + } + return printSchedule(cmd, p) + }} + scheduleFlags(cmd, &interval, &at, &tz, &day, &retention) + cmd.Flags().StringVar(&name, "name", "", "Optional policy name") + cmd.Flags().StringVar(&description, "description", "", "Policy description") + cmd.Flags().BoolVar(&immediate, "immediate", false, "Request one immediate backup") + _ = cmd.MarkFlagRequired("interval") + _ = cmd.MarkFlagRequired("timezone") + _ = cmd.MarkFlagRequired("retention") + return cmd +} +func newVMBackupScheduleUpdateCmd() *cobra.Command { + var interval, at, tz string + var day, retention int + cmd := &cobra.Command{Use: "update ", Short: "Update a VM backup schedule", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if interval == "hourly" && at == "" { + at = "00:00" + } + d, err := validateSchedule(interval, at, tz, day, retention) + if err != nil { + return err + } + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + if _, err := scopedSchedule(cmd, svc, ctx, args[0]); err != nil { + return err + } + p, err := svc.Update(ctx, args[0], scheduler.UpdateRequest{Interval: interval, Day: d, At: at, Timezone: tz, RetentionPolicy: retention}) + if err != nil { + return fmt.Errorf("vm-backup schedule update: %w", err) + } + return printSchedule(cmd, p) + }} + scheduleFlags(cmd, &interval, &at, &tz, &day, &retention) + _ = cmd.MarkFlagRequired("interval") + _ = cmd.MarkFlagRequired("timezone") + _ = cmd.MarkFlagRequired("retention") + return cmd +} +func newVMBackupScheduleDeleteCmd() *cobra.Command { + var yes bool + cmd := &cobra.Command{Use: "delete ", Short: "Delete a VM backup schedule", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !yes && !confirmAction(cmd, "Delete VM backup schedule %q? This cannot be undone.", args[0]) { + return nil + } + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + if _, err := scopedSchedule(cmd, svc, ctx, args[0]); err != nil { + return err + } + result, err := svc.Delete(ctx, args[0]) + if err != nil { + return fmt.Errorf("vm-backup schedule delete: %w", err) + } + _, _, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + if outputFormat(cmd) != output.FormatTable { + return printer.Print(result) + } + printer.Fprintf("%s\n", result.Message) + return nil + }} + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} +func scheduleActionCmd(use, short string, action func(*scheduler.Service, context.Context, string) (*scheduler.Policy, error)) *cobra.Command { + return &cobra.Command{Use: use + " ", Short: short, Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + if _, err := scopedSchedule(cmd, svc, ctx, args[0]); err != nil { + return err + } + p, err := action(svc, ctx, args[0]) + if err != nil { + return fmt.Errorf("vm-backup schedule %s: %w", use, err) + } + return printSchedule(cmd, p) + }} +} +func newVMBackupSchedulePauseCmd() *cobra.Command { + return scheduleActionCmd("pause", "Pause a VM backup schedule", func(s *scheduler.Service, c context.Context, id string) (*scheduler.Policy, error) { + return s.Pause(c, id) + }) +} +func newVMBackupScheduleResumeCmd() *cobra.Command { + return scheduleActionCmd("resume", "Resume a VM backup schedule", func(s *scheduler.Service, c context.Context, id string) (*scheduler.Policy, error) { + return s.Resume(c, id) + }) +} +func newVMBackupScheduleRunNowCmd() *cobra.Command { + var yes bool + cmd := &cobra.Command{Use: "run-now ", Short: "Run a VM backup schedule now", Args: exactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !yes && !confirmAction(cmd, "Run VM backup schedule %q now?", args[0]) { + return nil + } + svc, ctx, cancel, err := schedulerService(cmd) + if err != nil { + return err + } + defer cancel() + if _, err := scopedSchedule(cmd, svc, ctx, args[0]); err != nil { + return err + } + result, err := svc.RunNow(ctx, args[0]) + if err != nil { + return fmt.Errorf("vm-backup schedule run-now: %w", err) + } + _, _, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + if outputFormat(cmd) != output.FormatTable { + return printer.Print(result) + } + printer.Fprintf("%s\n", result.Message) + return nil + }} + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} diff --git a/internal/commands/vmbackupschedule_test.go b/internal/commands/vmbackupschedule_test.go new file mode 100644 index 0000000..9f9f9b8 --- /dev/null +++ b/internal/commands/vmbackupschedule_test.go @@ -0,0 +1,266 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestVMBackupScheduleCreateSendsPortalContract(t *testing.T) { + var got map[string]any + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/virtual-machines" { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + return + } + if r.Method != http.MethodPost || r.URL.Path != "/scheduler-actions" { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + fmt.Fprint(w, `{"data":{"id":"policy-1","name":"nightly","slug":"vm-1","interval":"weeklyOn","day":0,"at":"13:00","timezone":"America/Toronto","retention_policy":2,"status":"active"}}`) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + out, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "nightly", "--interval", "weeklyOn", "--day", "0", "--at", "13:00", "--timezone", "America/Toronto", "--retention", "2", "--immediate", "--api-url", s.URL, "--output", "json") + if err != nil { + t.Fatal(err) + } + if got["service"] != "Virtual Machine" || got["action"] != "VM Backup" || got["take_one_immediately"].(float64) != 1 { + t.Fatalf("request=%v", got) + } + if got["day"].(float64) != 0 { + t.Fatalf("day=%v", got["day"]) + } + if !strings.Contains(out, `"timezone": "America/Toronto"`) { + t.Fatalf("output=%s", out) + } +} + +func TestVMBackupScheduleValidation(t *testing.T) { + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "x", "--interval", "monthlyOn", "--day", "0", "--at", "13:00", "--timezone", "UTC", "--retention", "1") + if err == nil || !strings.Contains(err.Error(), "--day must be 1 through 28") { + t.Fatalf("err=%v", err) + } +} + +func TestVMBackupScheduleHourlyDefaultsTime(t *testing.T) { + var got map[string]any + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/virtual-machines" { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + return + } + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + fmt.Fprint(w, `{"data":{"id":"policy-1"}}`) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "hourly", "--interval", "hourly", "--timezone", "UTC", "--retention", "1", "--api-url", s.URL) + if err != nil { + t.Fatal(err) + } + if got["at"] != "00:00" { + t.Fatalf("at=%v", got["at"]) + } +} + +func TestVMBackupScheduleCreateAllowsUnnamedPolicy(t *testing.T) { + var got map[string]any + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + return + } + if r.Method == http.MethodPost { + _ = json.NewDecoder(r.Body).Decode(&got) + fmt.Fprint(w, `{"data":{"id":"p","name":"Scheduler: VirtualMachineBackup for VirtualMachine(vm-1)"}}`) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--interval", "dailyAt", "--at", "13:00", "--timezone", "UTC", "--retention", "1", "--api-url", s.URL) + if err != nil || got["name"] != nil || got["description"] != nil { + t.Fatalf("err=%v request=%v", err, got) + } +} + +func TestVMBackupScheduleRejectsInvalidTimezone(t *testing.T) { + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "x", "--interval", "dailyAt", "--at", "13:00", "--timezone", "not/a-zone", "--retention", "1") + if err == nil || !strings.Contains(err.Error(), "valid IANA timezone") { + t.Fatalf("err=%v", err) + } +} + +func TestVMBackupScheduleValidationBoundaries(t *testing.T) { + cases := []struct { + interval, at, tz string + day, retention int + }{ + {"dailyAt", "13:17", "UTC", -1, 1}, {"dailyAt", "13:00", "Local", -1, 1}, {"weeklyOn", "13:00", "UTC", 7, 1}, {"monthlyOn", "13:00", "UTC", 29, 1}, {"dailyAt", "13:00", "UTC", -1, 0}, + } + for _, tc := range cases { + if _, err := validateSchedule(tc.interval, tc.at, tc.tz, tc.day, tc.retention); err == nil { + t.Fatalf("expected validation error for %+v", tc) + } + } + if _, err := validateSchedule("monthlyOn", "13:00", "UTC", 28, 1); err != nil { + t.Fatal(err) + } +} + +func TestVMBackupSchedulePauseRejectsScopeAndMissingReferencesBeforeWrite(t *testing.T) { + for _, body := range []string{ + `{"data":{"id":"p","action":"Virtual Machine Backup","region":{"slug":"other"},"project":{"slug":"test"}}}`, + `{"data":{"id":"p","action":"Virtual Machine Backup"}}`, + } { + writes := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, body) + return + } + writes++ + http.NotFound(w, r) + })) + t.Setenv("ZCP_BEARER_TOKEN", "test") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "pause", "p", "--api-url", s.URL) + s.Close() + if err == nil || writes != 0 { + t.Fatalf("body=%s err=%v writes=%d", body, err, writes) + } + } +} + +func TestVMBackupScheduleDeleteJSONReturnsActionResponse(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"data":{"action":"Virtual Machine Backup","region":{"slug":"yul-1"},"project":{"slug":"test"}}}`) + return + } + if r.Method == http.MethodDelete { + fmt.Fprint(w, `{"status":"success","message":"deleted"}`) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "test") + out, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "delete", "p", "--yes", "--api-url", s.URL, "--output", "json") + if err != nil || !strings.Contains(out, `"message": "deleted"`) { + t.Fatalf("err=%v out=%s", err, out) + } +} + +func TestVMBackupScheduleOutputFormatHonorsUppercaseEnvironment(t *testing.T) { + t.Setenv("ZCP_OUTPUT", "TABLE") + cmd := newTestRoot() + if got := outputFormat(cmd); string(got) != "table" { + t.Fatalf("format=%q", got) + } +} + +func TestVMBackupScheduleCreateRejectsVMOutsideScopeBeforePost(t *testing.T) { + posts := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/virtual-machines" { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"other"}]}`) + return + } + if r.Method == http.MethodPost { + posts++ + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "x", "--interval", "dailyAt", "--at", "13:00", "--timezone", "UTC", "--retention", "1", "--api-url", s.URL) + if err == nil || posts != 0 { + t.Fatalf("err=%v posts=%d", err, posts) + } +} + +func TestVMBackupScheduleRunNowUsesAcknowledgement(t *testing.T) { + writes := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"data":{"id":"p","action":"Virtual Machine Backup","region":{"slug":"yul-1"},"project":{"slug":"test"}}}`) + return + } + if r.Method == http.MethodPost { + writes++ + fmt.Fprint(w, `{"status":"success","message":"Virtual Machine Backup triggered successfully for vm"}`) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "test") + out, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "run-now", "p", "--yes", "--api-url", s.URL, "--output", "json") + if err != nil || writes != 1 || !strings.Contains(out, `"message": "Virtual Machine Backup triggered successfully for vm"`) { + t.Fatalf("err=%v writes=%d out=%s", err, writes, out) + } +} + +func TestVMBackupScheduleCreateDoesNotRetryAfterServerFailure(t *testing.T) { + posts := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + return + } + if r.Method == http.MethodPost { + posts++ + http.Error(w, "failed", http.StatusInternalServerError) + return + } + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--name", "x", "--interval", "dailyAt", "--at", "13:00", "--timezone", "UTC", "--retention", "1", "--api-url", s.URL) + if err == nil || posts != 1 || !strings.Contains(err.Error(), "schedule list") { + t.Fatalf("err=%v posts=%d", err, posts) + } +} + +func TestVMBackupSchedulePauseRejectsWrongActionBeforeWrite(t *testing.T) { + writes := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"data":{"id":"p","action":"Block Storage Snapshot","region":{"slug":"yul-1"},"project":{"slug":"test"}}}`) + return + } + writes++ + http.NotFound(w, r) + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "test") + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "pause", "p", "--api-url", s.URL) + if err == nil || writes != 0 { + t.Fatalf("err=%v writes=%d", err, writes) + } +} diff --git a/pkg/api/dns/dns.go b/pkg/api/dns/dns.go index 4541801..2e43897 100644 --- a/pkg/api/dns/dns.go +++ b/pkg/api/dns/dns.go @@ -119,7 +119,7 @@ type envelopeList struct { Message string `json:"message"` CurrentPage int `json:"current_page"` Data []Domain `json:"data"` - Total int `json:"total"` + Total *int `json:"total"` } // envelopeSingle wraps a single-object response. @@ -139,15 +139,57 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns all DNS domains. +const maxListPages = 1000 + +// List returns all DNS domains. When the endpoint omits pagination metadata, +// it preserves the legacy single-response behavior. func (s *Service) List(ctx context.Context) ([]Domain, error) { - q := url.Values{} - q.Set("include", "dns_provider") - var resp envelopeList - if err := s.client.Get(ctx, "/dns/domains", q, &resp); err != nil { - return nil, fmt.Errorf("listing DNS domains: %w", err) + var domains []Domain + var reportedTotal *int + + for page := 1; page <= maxListPages; page++ { + q := url.Values{} + q.Set("include", "dns_provider") + q.Set("page", strconv.Itoa(page)) + + var resp envelopeList + if err := s.client.Get(ctx, "/dns/domains", q, &resp); err != nil { + return nil, fmt.Errorf("listing DNS domains: %w", err) + } + if page > 1 && resp.CurrentPage == 0 { + return nil, fmt.Errorf("listing DNS domains: requested page %d but the API did not return pagination metadata", page) + } + if resp.CurrentPage > 0 && resp.CurrentPage != page { + return nil, fmt.Errorf("listing DNS domains: requested page %d but the API returned page %d", page, resp.CurrentPage) + } + if resp.Total != nil && *resp.Total < 0 { + return nil, fmt.Errorf("listing DNS domains: API returned invalid negative total %d", *resp.Total) + } + if reportedTotal == nil { + reportedTotal = resp.Total + } else if resp.Total == nil || *resp.Total != *reportedTotal { + return nil, fmt.Errorf("listing DNS domains: pagination total changed or was omitted on page %d", page) + } + if reportedTotal == nil { + return resp.Data, nil + } + if *reportedTotal == 0 && len(resp.Data) > 0 { + return nil, fmt.Errorf("listing DNS domains: API reported total 0 with %d domains", len(resp.Data)) + } + if len(domains)+len(resp.Data) > *reportedTotal { + return nil, fmt.Errorf("listing DNS domains: API returned more domains than reported total %d", *reportedTotal) + } + + domains = append(domains, resp.Data...) + if len(domains) == *reportedTotal { + return domains, nil + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("listing DNS domains: page %d was empty before reaching reported total %d", page, *reportedTotal) + } } - return resp.Data, nil + + return nil, fmt.Errorf("listing DNS domains: exceeded %d pages without reaching the reported total", maxListPages) } // Show returns details for a single DNS domain by slug, including records. diff --git a/pkg/api/dns/dns_test.go b/pkg/api/dns/dns_test.go index dec496a..d2d8233 100644 --- a/pkg/api/dns/dns_test.go +++ b/pkg/api/dns/dns_test.go @@ -3,8 +3,10 @@ package dns_test import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -62,6 +64,73 @@ func TestDNSDomainList(t *testing.T) { } } +func TestDNSDomainListPaginatesAndReturnsEveryDomain(t *testing.T) { + var requestedPages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPages = append(requestedPages, r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("page") { + case "1": + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":3,"data":[{"slug":"first","status":true},{"slug":"second","status":false}]}`) + case "2": + fmt.Fprint(w, `{"status":"Success","current_page":2,"total":3,"data":[{"slug":"third","status":true}]}`) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + })) + defer srv.Close() + + domains, err := dns.NewService(newClient(srv.URL)).List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(domains) != 3 || domains[2].Slug != "third" { + t.Fatalf("List() domains = %+v, want all three pages", domains) + } + if got, want := strings.Join(requestedPages, ","), "1,2"; got != want { + t.Errorf("requested pages = %q, want %q", got, want) + } +} + +func TestDNSDomainListReturnsNoPartialResultsOnLaterPageFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "1": + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":2,"data":[{"slug":"first","status":true}]}`) + case "2": + http.Error(w, "unavailable", http.StatusServiceUnavailable) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + })) + defer srv.Close() + + domains, err := dns.NewService(newClient(srv.URL)).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want later-page failure") + } + if domains != nil { + t.Errorf("List() domains = %+v, want nil on later-page failure", domains) + } +} + +func TestDNSDomainListRejectsExplicitZeroTotalWithDomains(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":0,"data":[{"slug":"first","status":true}]}`) + })) + defer srv.Close() + + domains, err := dns.NewService(newClient(srv.URL)).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want invalid zero total error") + } + if domains != nil { + t.Errorf("List() domains = %+v, want nil with invalid total", domains) + } +} + func TestDNSDomainShow(t *testing.T) { expected := dns.Domain{ ID: "1", diff --git a/pkg/api/kubernetes/kubernetes.go b/pkg/api/kubernetes/kubernetes.go index 5dbb819..5c67e88 100644 --- a/pkg/api/kubernetes/kubernetes.go +++ b/pkg/api/kubernetes/kubernetes.go @@ -21,6 +21,12 @@ type KubeconfigData struct { type ClusterMeta struct { ControlNodes string `json:"control_nodes"` Size string `json:"size"` + CPU string `json:"cpu_number"` + Memory string `json:"memory"` + FormattedMemory string `json:"formated_memory"` + AutoscalingEnabled json.RawMessage `json:"autoscaling_enabled"` + MinSize json.RawMessage `json:"min_size"` + MaxSize json.RawMessage `json:"max_size"` KubernetesVersionName string `json:"kubernetes_version_name"` KubernetesVersionID string `json:"kubernetes_version_id"` IPAddress string `json:"ipaddress"` @@ -30,6 +36,60 @@ type ClusterMeta struct { Config *KubeconfigData `json:"config,omitempty"` } +// PlanAttribute describes the resources assigned by a Kubernetes node plan. +type PlanAttribute struct { + FormattedCPU json.RawMessage `json:"formatted_cpu"` + CPU json.RawMessage `json:"cpu"` + FormattedMemory string `json:"formatted_memory"` + Memory json.RawMessage `json:"memory"` + FormattedStorage string `json:"formatted_storage"` + Storage json.RawMessage `json:"storage"` +} + +// NodePlan describes a Kubernetes control-plane or worker plan returned with a cluster. +type NodePlan struct { + Name string `json:"name"` + Slug string `json:"slug"` + Attribute *PlanAttribute `json:"attribute"` +} + +// Offering describes the plans and custom resource values used by a Kubernetes cluster. +type Offering struct { + CPU json.RawMessage `json:"cpu"` + Memory json.RawMessage `json:"memory"` + Storage json.RawMessage `json:"storage"` + FormattedMemory string `json:"formatted_memory"` + FormattedStorage string `json:"formatted_storage"` + MasterPlan *NodePlan `json:"master_plan"` + WorkerPlan *NodePlan `json:"worker_plan"` + MasterCustomCPU json.RawMessage `json:"master_custom_cpu"` + MasterCustomMemory json.RawMessage `json:"master_custom_memory"` + MasterCustomStorage json.RawMessage `json:"master_custom_storage"` + FormattedMasterCustomMemory string `json:"formatted_master_custom_memory"` + FormattedMasterCustomStorage string `json:"formatted_master_custom_storage"` + WorkerCustomCPU json.RawMessage `json:"worker_custom_cpu"` + WorkerCustomMemory json.RawMessage `json:"worker_custom_memory"` + WorkerCustomStorage json.RawMessage `json:"worker_custom_storage"` + FormattedWorkerCustomMemory string `json:"formatted_worker_custom_memory"` + FormattedWorkerCustomStorage string `json:"formatted_worker_custom_storage"` +} + +// Network holds the safe network identity returned with a cluster. +type Network struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// BlockStorage holds the safe volume identity returned with a cluster. +type BlockStorage struct { + Name string `json:"name"` + Slug string `json:"slug"` + IsRoot bool `json:"is_root"` +} + +// Autoscale marks a cluster with autoscaling enabled in the portal response. +type Autoscale struct{} + // Cluster represents a ZCP managed Kubernetes cluster from the STKCNSL API. type Cluster struct { ID string `json:"id"` @@ -48,6 +108,7 @@ type Cluster struct { PublicIP *string `json:"public_ip"` PrivateIP *string `json:"private_ip"` NodeSize int `json:"node_size"` + WorkerNodeSize int `json:"worker_node_size"` ControlNodes int `json:"control_nodes"` Version string `json:"version"` EnableHA bool `json:"enable_ha"` @@ -65,6 +126,10 @@ type Cluster struct { ServiceDisplayName string `json:"service_display_name"` AllTimeConsumption float64 `json:"all_time_consumption"` Meta *ClusterMeta `json:"meta,omitempty"` + Offering *Offering `json:"offering,omitempty"` + Network *Network `json:"network,omitempty"` + BlockStorages []BlockStorage `json:"blockstorages,omitempty"` + Autoscale *Autoscale `json:"autoscale,omitempty"` } // BillingCycle represents the billing cycle attached to a cluster. @@ -93,29 +158,36 @@ type Project struct { // CreateRequest holds parameters for creating a Kubernetes cluster. type CreateRequest struct { - Name string `json:"name"` - Version string `json:"version"` - NodeSize int `json:"node_size"` - WorkerNodeSize int `json:"worker_node_size"` - ControlNodes int `json:"control_nodes"` - CloudProvider string `json:"cloud_provider"` - CloudProviderSetup string `json:"cloud_provider_setup,omitempty"` - Region string `json:"region"` - Project string `json:"project"` - BillingCycle string `json:"billing_cycle"` - EnableHA bool `json:"enable_ha"` - Networks []string `json:"networks"` - Plan string `json:"plan"` - WithPoolCard bool `json:"with_pool_card"` - IsCustomPlan bool `json:"is_custom_plan"` - CustomPlan interface{} `json:"custom_plan"` - VirtualMachine string `json:"virtual_machine"` - Coupon *string `json:"coupon"` - StorageCategory string `json:"storage_category"` - SSHKey string `json:"ssh_key"` - AuthMethod string `json:"authMethod"` - Username string `json:"username"` - Password string `json:"password"` + Name string `json:"name"` + Version string `json:"version"` + NodeSize int `json:"node_size"` + WorkerNodeSize int `json:"worker_node_size"` + ControlNodes int `json:"control_nodes"` + CloudProvider string `json:"cloud_provider"` + CloudProviderSetup string `json:"cloud_provider_setup,omitempty"` + Region string `json:"region"` + Project string `json:"project"` + BillingCycle string `json:"billing_cycle"` + EnableHA bool `json:"enable_ha"` + EnableCSI bool `json:"enable_csi"` + Networks []string `json:"networks"` + // Plan is retained for API compatibility with older Kubernetes cluster + // creation flows. ZCP Kubernetes clusters use MasterPlan and + // WorkerPlan instead. + Plan string `json:"plan,omitempty"` + MasterPlan string `json:"master_plan,omitempty"` + WorkerPlan string `json:"worker_plan,omitempty"` + BlockstoragePlan string `json:"blockstorage_plan,omitempty"` + WithPoolCard bool `json:"with_pool_card"` + IsCustomPlan bool `json:"is_custom_plan"` + CustomPlan interface{} `json:"custom_plan"` + VirtualMachine string `json:"virtual_machine"` + Coupon *string `json:"coupon"` + StorageCategory string `json:"storage_category"` + SSHKey string `json:"ssh_key"` + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password"` } // UpgradeRequest holds parameters for upgrading (changing plan of) a Kubernetes cluster. @@ -266,15 +338,42 @@ func (s *Service) Get(ctx context.Context, slug string) (*Cluster, error) { return &resp.Data, nil } -// ScaleRequest holds the worker node count for a scale operation. +// ScaleRequest holds the worker node count for a manual scale operation. type ScaleRequest struct { NodeSize int `json:"node_size"` } +type autoscaleRequest struct { + NodeSize *int `json:"node_size,omitempty"` + Autoscale *int `json:"autoscale,omitempty"` + MinClusterSize *int `json:"min_cluster_size,omitempty"` + MaxClusterSize *int `json:"max_cluster_size,omitempty"` +} + // Scale changes the number of worker nodes on a running cluster. func (s *Service) Scale(ctx context.Context, slug string, nodeSize int) error { + return s.scale(ctx, slug, ScaleRequest{NodeSize: nodeSize}) +} + +// EnableAutoscaling enables worker autoscaling with the supplied bounds. +func (s *Service) EnableAutoscaling(ctx context.Context, slug string, minWorkers, maxWorkers int) error { + autoscale := 1 + return s.scale(ctx, slug, autoscaleRequest{ + Autoscale: &autoscale, + MinClusterSize: &minWorkers, + MaxClusterSize: &maxWorkers, + }) +} + +// DisableAutoscaling disables worker autoscaling and sets the worker count. +func (s *Service) DisableAutoscaling(ctx context.Context, slug string, workers int) error { + autoscale := 0 + return s.scale(ctx, slug, autoscaleRequest{NodeSize: &workers, Autoscale: &autoscale}) +} + +func (s *Service) scale(ctx context.Context, slug string, req interface{}) error { var resp messageResponse - if err := s.client.Put(ctx, fmt.Sprintf("/kubernetes-clusters/%s/scale", slug), nil, ScaleRequest{NodeSize: nodeSize}, &resp); err != nil { + if err := s.client.Put(ctx, fmt.Sprintf("/kubernetes-clusters/%s/scale", slug), nil, req, &resp); err != nil { return fmt.Errorf("scaling kubernetes cluster %s: %w", slug, err) } return nil diff --git a/pkg/api/kubernetes/kubernetes_test.go b/pkg/api/kubernetes/kubernetes_test.go index 23550f0..be18e75 100644 --- a/pkg/api/kubernetes/kubernetes_test.go +++ b/pkg/api/kubernetes/kubernetes_test.go @@ -31,16 +31,17 @@ func TestKubernetesListClusters(t *testing.T) { "message": "OK", "data": []map[string]interface{}{ { - "id": "abc-123", - "name": "my-cluster", - "slug": "my-cluster", - "state": "Running", - "version": "v1.28.4", - "node_size": 3, - "control_nodes": 1, - "enable_ha": false, - "created_at": "2026-04-04T17:09:26.000000Z", - "updated_at": "2026-04-04T17:10:20.000000Z", + "id": "abc-123", + "name": "my-cluster", + "slug": "my-cluster", + "state": "Running", + "version": "v1.28.4", + "node_size": 3, + "worker_node_size": 4, + "control_nodes": 1, + "enable_ha": false, + "created_at": "2026-04-04T17:09:26.000000Z", + "updated_at": "2026-04-04T17:10:20.000000Z", }, }, "current_page": 1, @@ -76,6 +77,9 @@ func TestKubernetesListClusters(t *testing.T) { if clusters[0].NodeSize != 3 { t.Errorf("NodeSize = %d, want %d", clusters[0].NodeSize, 3) } + if clusters[0].WorkerNodeSize != 4 { + t.Errorf("WorkerNodeSize = %d, want %d", clusters[0].WorkerNodeSize, 4) + } if clusters[0].ControlNodes != 1 { t.Errorf("ControlNodes = %d, want %d", clusters[0].ControlNodes, 1) } @@ -161,19 +165,23 @@ func TestKubernetesCreate(t *testing.T) { svc := kubernetes.NewService(newTestClient(srv)) req := kubernetes.CreateRequest{ - Name: "test-cluster", - Version: "v1.28.4", - NodeSize: 3, - ControlNodes: 1, - CloudProvider: "nimbo", - Region: "noida", - Project: "default-59", - BillingCycle: "monthly", - EnableHA: false, - Networks: []string{}, - Plan: "k8s-plan-1", - SSHKey: "mykey", - AuthMethod: "ssh-key", + Name: "test-cluster", + Version: "v1.28.4", + NodeSize: 3, + ControlNodes: 1, + CloudProvider: "nimbo", + Region: "noida", + Project: "default-59", + BillingCycle: "monthly", + EnableHA: false, + EnableCSI: true, + Networks: []string{}, + Plan: "k8s-plan-1", + MasterPlan: "k8s-control-1", + WorkerPlan: "k8s-worker-1", + BlockstoragePlan: "k8s-root-volume-1", + SSHKey: "mykey", + AuthMethod: "ssh-key", } cluster, err := svc.Create(context.Background(), req) if err != nil { @@ -201,11 +209,78 @@ func TestKubernetesCreate(t *testing.T) { if gotBody["plan"] != "k8s-plan-1" { t.Errorf("body plan = %v, want %q", gotBody["plan"], "k8s-plan-1") } + if gotBody["master_plan"] != "k8s-control-1" { + t.Errorf("body master_plan = %v, want %q", gotBody["master_plan"], "k8s-control-1") + } + if gotBody["worker_plan"] != "k8s-worker-1" { + t.Errorf("body worker_plan = %v, want %q", gotBody["worker_plan"], "k8s-worker-1") + } + if gotBody["blockstorage_plan"] != "k8s-root-volume-1" { + t.Errorf("body blockstorage_plan = %v, want %q", gotBody["blockstorage_plan"], "k8s-root-volume-1") + } + if gotBody["enable_csi"] != true { + t.Errorf("body enable_csi = %v, want true", gotBody["enable_csi"]) + } if gotBody["ssh_key"] != "mykey" { t.Errorf("body ssh_key = %v, want %q", gotBody["ssh_key"], "mykey") } } +func TestKubernetesCreateModernPlanPayloadOmitsLegacyPlan(t *testing.T) { + var gotBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/kubernetes-clusters" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method: %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": map[string]interface{}{}, + }) + })) + defer srv.Close() + + svc := kubernetes.NewService(newTestClient(srv)) + _, err := svc.Create(context.Background(), kubernetes.CreateRequest{ + Name: "test-cluster", + Version: "v1.36.1", + NodeSize: 3, + WorkerNodeSize: 3, + ControlNodes: 1, + CloudProvider: "nimbo", + Region: "yul-1", + Project: "test", + BillingCycle: "hourly", + Networks: []string{}, + MasterPlan: "k8s-cpi-yul", + WorkerPlan: "k8s-li-yul", + BlockstoragePlan: "b2g1", + StorageCategory: "pro-nvme", + SSHKey: "mykey", + AuthMethod: "ssh-key", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, ok := gotBody["plan"]; ok { + t.Errorf("body includes legacy plan = %v, want omitted", gotBody["plan"]) + } + if gotBody["master_plan"] != "k8s-cpi-yul" { + t.Errorf("body master_plan = %v, want %q", gotBody["master_plan"], "k8s-cpi-yul") + } + if gotBody["worker_plan"] != "k8s-li-yul" { + t.Errorf("body worker_plan = %v, want %q", gotBody["worker_plan"], "k8s-li-yul") + } + if gotBody["blockstorage_plan"] != "b2g1" { + t.Errorf("body blockstorage_plan = %v, want %q", gotBody["blockstorage_plan"], "b2g1") + } +} + func TestKubernetesStart(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/kubernetes-clusters/my-cluster/start" { @@ -358,6 +433,73 @@ func TestKubernetesScale(t *testing.T) { } } +func TestKubernetesScaleRequestLegacyPayload(t *testing.T) { + payload, err := json.Marshal(kubernetes.ScaleRequest{NodeSize: 3}) + if err != nil { + t.Fatalf("marshal ScaleRequest: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("unmarshal ScaleRequest payload: %v", err) + } + if body["node_size"] != float64(3) { + t.Errorf("node_size = %#v, want 3", body["node_size"]) + } +} + +func TestKubernetesAutoscalingPayloads(t *testing.T) { + var bodies []map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/kubernetes-clusters/my-cluster/scale" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body := map[string]interface{}{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + bodies = append(bodies, body) + json.NewEncoder(w).Encode(map[string]interface{}{"status": "Success"}) + })) + defer srv.Close() + + svc := kubernetes.NewService(newTestClient(srv)) + if err := svc.EnableAutoscaling(context.Background(), "my-cluster", 2, 5); err != nil { + t.Fatalf("EnableAutoscaling() error = %v", err) + } + if err := svc.DisableAutoscaling(context.Background(), "my-cluster", 3); err != nil { + t.Fatalf("DisableAutoscaling() error = %v", err) + } + if len(bodies) != 2 { + t.Fatalf("requests = %d, want 2", len(bodies)) + } + if got := bodies[0]; got["autoscale"] != float64(1) || got["min_cluster_size"] != float64(2) || got["max_cluster_size"] != float64(5) { + t.Errorf("enable payload = %#v", got) + } else if _, ok := got["node_size"]; ok { + t.Errorf("enable payload includes node_size = %#v", got["node_size"]) + } + if got := bodies[1]; got["autoscale"] != float64(0) || got["node_size"] != float64(3) { + t.Errorf("disable payload = %#v", got) + } +} + +func TestKubernetesGetDecodesNumericPlanCPU(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/kubernetes-clusters/my-cluster" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"my-cluster","offering":{"master_plan":{"slug":"k8s-cpi-yul","attribute":{"formatted_cpu":4,"cpu":4}}}}}`)) + })) + defer srv.Close() + + cluster, err := kubernetes.NewService(newTestClient(srv)).Get(context.Background(), "my-cluster") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if cluster.Offering == nil || cluster.Offering.MasterPlan == nil || string(cluster.Offering.MasterPlan.Attribute.FormattedCPU) != "4" { + t.Fatalf("Get() did not decode numeric formatted_cpu: %#v", cluster.Offering) + } +} + func TestKubernetesScale_Error(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) diff --git a/pkg/api/scheduler/scheduler.go b/pkg/api/scheduler/scheduler.go new file mode 100644 index 0000000..4c50eca --- /dev/null +++ b/pkg/api/scheduler/scheduler.go @@ -0,0 +1,234 @@ +// Package scheduler provides scheduler-action API operations. +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "github.com/zsoftly/zcp-cli/pkg/api/response" + "github.com/zsoftly/zcp-cli/pkg/httpclient" +) + +const maxListPages = 1000 + +// Policy is the safe, user-facing portion of a scheduler action response. +// It retains only the actionable VM reference and omits provider and account +// objects, which contain unrelated and sometimes sensitive response data. +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Slug string `json:"slug"` + Action string `json:"action"` + Interval string `json:"interval"` + Day *int `json:"day,omitempty"` + At string `json:"at"` + RetentionPolicy int `json:"retention_policy"` + Status string `json:"status"` + Timezone string `json:"timezone"` + LastExecutedAt string `json:"last_executed_at,omitempty"` + NextScheduledAt string `json:"next_scheduled_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Actionable *VMRef `json:"actionable,omitempty"` + Project *ScopeRef `json:"project,omitempty"` + Region *ScopeRef `json:"region,omitempty"` + LastRunStatus string `json:"last_run_status,omitempty"` + LastRunTime string `json:"last_run_time,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` + IsHealthy *bool `json:"is_healthy,omitempty"` +} + +type ScopeRef struct { + Slug string `json:"slug"` +} + +// VMRef is the safe VM reference embedded in a scheduler action. +type VMRef struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// UnmarshalJSON accepts both the numeric day sent on create and the quoted +// day returned by scheduler responses. A missing or null day remains nil. +func (p *Policy) UnmarshalJSON(data []byte) error { + type policyAlias Policy + aux := struct { + Day json.RawMessage `json:"day"` + *policyAlias + }{policyAlias: (*policyAlias)(p)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if len(aux.Day) == 0 || string(aux.Day) == "null" { + p.Day = nil + return nil + } + v, err := response.ParseFlexInt(aux.Day) + if err != nil { + return fmt.Errorf("scheduler: field \"day\": %w", err) + } + p.Day = &v + return nil +} + +// CreateRequest creates a virtual-machine backup policy. +type CreateRequest struct { + Service string `json:"service"` + Slug string `json:"slug"` + Action string `json:"action"` + Name *string `json:"name"` + Description *string `json:"description"` + Interval string `json:"interval"` + Day *int `json:"day"` + At string `json:"at"` + Timezone string `json:"timezone"` + RetentionPolicy int `json:"retention_policy"` + TakeOneImmediately int `json:"take_one_immediately"` + Config Config `json:"config"` +} + +// Config holds the documented VM-backup creation setting. +type Config struct { + TakeOneImmediately int `json:"take_one_immediately"` +} + +// UpdateRequest contains the scheduler fields accepted by the dashboard's +// update endpoint. A nil Day is serialized as null for non-day intervals. +type UpdateRequest struct { + Interval string `json:"interval"` + Day *int `json:"day"` + At string `json:"at"` + Timezone string `json:"timezone"` + RetentionPolicy int `json:"retention_policy"` +} + +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data Policy `json:"data"` +} + +// ActionResponse is returned by asynchronous scheduler triggers and deletes. +type ActionResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type listEnvelope struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Policy `json:"data"` + Total *int `json:"total"` +} + +// Service provides scheduler-action API operations. +type Service struct{ client *httpclient.Client } + +func NewService(client *httpclient.Client) *Service { return &Service{client: client} } + +// List returns every VM backup scheduler action in the selected region and project. +func (s *Service) List(ctx context.Context, region, project string) ([]Policy, error) { + var policies []Policy + var reportedTotal *int + for page := 1; page <= maxListPages; page++ { + q := url.Values{} + if region != "" { + q.Set("filter[region]", region) + } + if project != "" { + q.Set("filter[project]", project) + } + q.Set("actionable_type", "Virtual Machine Backup") + q.Set("page", strconv.Itoa(page)) + var resp listEnvelope + if err := s.client.Get(ctx, "/scheduler-actions", q, &resp); err != nil { + return nil, fmt.Errorf("listing scheduler actions: %w", err) + } + if page > 1 && resp.CurrentPage == 0 { + return nil, fmt.Errorf("listing scheduler actions: requested page %d but the API did not return pagination metadata", page) + } + if resp.CurrentPage > 0 && resp.CurrentPage != page { + return nil, fmt.Errorf("listing scheduler actions: requested page %d but the API returned page %d", page, resp.CurrentPage) + } + if resp.Total != nil && *resp.Total < 0 { + return nil, fmt.Errorf("listing scheduler actions: API returned invalid negative total %d", *resp.Total) + } + if reportedTotal == nil { + reportedTotal = resp.Total + } else if resp.Total == nil || *reportedTotal != *resp.Total { + return nil, fmt.Errorf("listing scheduler actions: pagination total changed or was omitted on page %d", page) + } + if reportedTotal == nil { + return resp.Data, nil + } + if *reportedTotal == 0 && len(resp.Data) > 0 { + return nil, fmt.Errorf("listing scheduler actions: API reported total 0 with %d actions", len(resp.Data)) + } + if len(policies)+len(resp.Data) > *reportedTotal { + return nil, fmt.Errorf("listing scheduler actions: API returned more actions than reported total %d", *reportedTotal) + } + policies = append(policies, resp.Data...) + if len(policies) == *reportedTotal { + return policies, nil + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("listing scheduler actions: page %d was empty before reaching reported total %d", page, *reportedTotal) + } + } + return nil, fmt.Errorf("listing scheduler actions: exceeded %d pages without reaching the reported total", maxListPages) +} + +func (s *Service) Get(ctx context.Context, id string) (*Policy, error) { + var r envelope + if err := s.client.Get(ctx, "/scheduler-actions/"+id, nil, &r); err != nil { + return nil, fmt.Errorf("getting scheduler action %s: %w", id, err) + } + return &r.Data, nil +} +func (s *Service) Create(ctx context.Context, req CreateRequest) (*Policy, error) { + var r envelope + if err := s.client.Post(ctx, "/scheduler-actions", req, &r); err != nil { + return nil, fmt.Errorf("creating scheduler action: %w", err) + } + return &r.Data, nil +} +func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (*Policy, error) { + var r envelope + if err := s.client.Put(ctx, "/scheduler-actions/"+id, nil, req, &r); err != nil { + return nil, fmt.Errorf("updating scheduler action %s: %w", id, err) + } + return &r.Data, nil +} +func (s *Service) Delete(ctx context.Context, id string) (*ActionResponse, error) { + var r ActionResponse + if err := s.client.DeleteWithResult(ctx, "/scheduler-actions/"+id, nil, &r); err != nil { + return nil, fmt.Errorf("deleting scheduler action %s: %w", id, err) + } + return &r, nil +} +func (s *Service) Pause(ctx context.Context, id string) (*Policy, error) { + var r envelope + if err := s.client.Patch(ctx, "/scheduler-actions/"+id+"/pause", nil, &r); err != nil { + return nil, fmt.Errorf("pausing scheduler action %s: %w", id, err) + } + return &r.Data, nil +} +func (s *Service) Resume(ctx context.Context, id string) (*Policy, error) { + var r envelope + if err := s.client.Patch(ctx, "/scheduler-actions/"+id+"/resume", nil, &r); err != nil { + return nil, fmt.Errorf("resuming scheduler action %s: %w", id, err) + } + return &r.Data, nil +} +func (s *Service) RunNow(ctx context.Context, id string) (*ActionResponse, error) { + var r ActionResponse + if err := s.client.Post(ctx, "/scheduler/"+id+"/run-now", nil, &r); err != nil { + return nil, fmt.Errorf("running scheduler action %s: %w", id, err) + } + return &r, nil +} diff --git a/pkg/api/scheduler/scheduler_test.go b/pkg/api/scheduler/scheduler_test.go new file mode 100644 index 0000000..855ef38 --- /dev/null +++ b/pkg/api/scheduler/scheduler_test.go @@ -0,0 +1,119 @@ +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/zsoftly/zcp-cli/pkg/httpclient" +) + +func testService(s *httptest.Server) *Service { + return NewService(httpclient.New(httpclient.Options{BaseURL: s.URL, BearerToken: "test", MaxRetries: 0})) +} + +func TestListFetchesAllPages(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("actionable_type") != "Virtual Machine Backup" { + t.Errorf("actionable_type = %q", r.URL.Query().Get("actionable_type")) + } + switch r.URL.Query().Get("page") { + case "1": + fmt.Fprint(w, `{"current_page":1,"total":2,"data":[{"id":"1"}]}`) + case "2": + fmt.Fprint(w, `{"current_page":2,"total":2,"data":[{"id":"2"}]}`) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + got, err := testService(s).List(context.Background(), "yul-1", "test") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[1].ID != "2" { + t.Fatalf("policies=%+v", got) + } +} + +func TestListReturnsNoPartialResultsWhenLaterPageFails(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "1" { + fmt.Fprint(w, `{"current_page":1,"total":2,"data":[{"id":"1"}]}`) + return + } + http.Error(w, "failed", http.StatusInternalServerError) + })) + defer s.Close() + got, err := testService(s).List(context.Background(), "", "") + if err == nil || got != nil { + t.Fatalf("got=%+v err=%v, want nil/error", got, err) + } +} + +func TestMutationsUseSchedulerRoutes(t *testing.T) { + paths := make([]string, 0, 4) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + fmt.Fprint(w, `{"data":{"id":"policy"}}`) + })) + defer s.Close() + svc := testService(s) + ctx := context.Background() + if _, err := svc.Update(ctx, "policy", UpdateRequest{Interval: "dailyAt", At: "01:00", Timezone: "UTC", RetentionPolicy: 1}); err != nil { + t.Fatal(err) + } + if _, err := svc.Pause(ctx, "policy"); err != nil { + t.Fatal(err) + } + if _, err := svc.Resume(ctx, "policy"); err != nil { + t.Fatal(err) + } + if _, err := svc.RunNow(ctx, "policy"); err != nil { + t.Fatal(err) + } + want := []string{"PUT /scheduler-actions/policy", "PATCH /scheduler-actions/policy/pause", "PATCH /scheduler-actions/policy/resume", "POST /scheduler/policy/run-now"} + for i := range want { + if paths[i] != want[i] { + t.Errorf("path %d = %q, want %q", i, paths[i], want[i]) + } + } +} + +func TestPolicyDecodesQuotedDay(t *testing.T) { + var policy Policy + if err := json.Unmarshal([]byte(`{"day":"0"}`), &policy); err != nil { + t.Fatal(err) + } + if policy.Day == nil || *policy.Day != 0 { + t.Fatalf("day=%v", policy.Day) + } +} + +func TestPolicyOutputOmitsProviderAndAccountData(t *testing.T) { + var policy Policy + if err := json.Unmarshal([]byte(`{"id":"policy","config":{"token":"secret"},"project":{"slug":"test","token":"secret"},"actionable":{"slug":"vm","access_key_token":"secret"}}`), &policy); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + if string(encoded) == "" || strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "config") { + t.Fatalf("unsafe output: %s", encoded) + } +} + +func TestPolicyPreservesFailedLastRunState(t *testing.T) { + var policy Policy + if err := json.Unmarshal([]byte(`{"last_run_status":"failed","last_run_time":"2026-10-10T01:00:00Z","last_error_message":"provider error","is_healthy":false}`), &policy); err != nil { + t.Fatal(err) + } + if policy.IsHealthy == nil || *policy.IsHealthy || policy.LastRunStatus != "failed" || policy.LastErrorMessage != "provider error" { + t.Fatalf("policy=%+v", policy) + } +} diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index 126312d..7ede2ba 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -82,6 +82,11 @@ func (c *Client) Delete(ctx context.Context, path string, query url.Values) erro return c.do(ctx, http.MethodDelete, path, query, nil, nil) } +// DeleteWithResult performs a DELETE request and decodes its JSON response. +func (c *Client) DeleteWithResult(ctx context.Context, path string, query url.Values, result interface{}) error { + return c.do(ctx, http.MethodDelete, path, query, nil, result) +} + // Put performs a PUT request. query params are optional (pass nil if not needed). // body is optional (pass nil for query-only PUTs like start/stop operations). // result is optional (pass nil to discard response body). @@ -89,6 +94,11 @@ func (c *Client) Put(ctx context.Context, path string, query url.Values, body in return c.do(ctx, http.MethodPut, path, query, body, result) } +// Patch performs a PATCH request with a JSON body. +func (c *Client) Patch(ctx context.Context, path string, body interface{}, result interface{}) error { + return c.do(ctx, http.MethodPatch, path, nil, body, result) +} + // isRetryable reports whether a response status code or network error warrants a retry. func isRetryable(statusCode int, err error) bool { if err != nil { diff --git a/tests/smoke/cases.sh b/tests/smoke/cases.sh index 5c2135e..3e70678 100755 --- a/tests/smoke/cases.sh +++ b/tests/smoke/cases.sh @@ -132,12 +132,13 @@ _read_objectstorage() { # ═══════════════════════════════════════════════════════════════════════════ # FIXTURES (created once, reused, torn down at the end) # ═══════════════════════════════════════════════════════════════════════════ -FX_SSHKEY=""; FX_NETWORK=""; FX_IP=""; FX_VM=""; FX_VMIP=""; FX_VOLUME="" +FX_SSHKEY=""; FX_SSHKEY_NAME=""; FX_NETWORK=""; FX_IP=""; FX_VM=""; FX_VMIP=""; FX_VOLUME="" fx_sshkey() { - [[ -n "$FX_SSHKEY" ]] && return 0 + [[ -n "$FX_SSHKEY" && -n "$FX_SSHKEY_NAME" ]] && return 0 local name pub out name="$(rname k)" + FX_SSHKEY_NAME="$name" # ephemeral throwaway key pub="$(ssh-keygen -t ed25519 -N '' -C "$name" -f "/tmp/${name}" >/dev/null 2>&1 && cat "/tmp/${name}.pub")" rm -f "/tmp/${name}" "/tmp/${name}.pub" 2>/dev/null @@ -426,20 +427,12 @@ lc_backup() { lc_vmbackup() { local vm out s; fx_vm; vm="$FX_VM"; [[ -z "$vm" ]] && { _mark_skip "vm-backup (no VM fixture)"; return; } - capture out -- zcp vm-backup create "$vm" --at 3 --immediate 0 \ + capture out -- zcp vm-backup create "$vm" --interval dailyAt --at 3 --immediate 0 \ --cloud-provider "$(det_cp)" --project "$(det_project)" --region "$(det_region)" \ --billing-cycle "$(det_billing_cycle)" --plan "$(det_backup_plan)" \ - --pseudo-service "Virtual Machine Backup" - # The create response carries no slug, so resolve it from the list by the - # VM it was scheduled for. - # CLI json keys derive from the table headers (vm, slug, created); the raw API - # nests the VM under virtual_machine. Accept both and take the newest match. - s="$(zcp vm-backup list -o json 2>/dev/null | jq -r --arg v "$vm" \ - '[(if type=="array" then .[] else (.data // [])[] end) - | select((.vm // .virtual_machine.slug // .virtual_machine_id)==$v)] - | sort_by(.created // .created_at) | last | .slug // empty')" - if [[ -n "$s" && "$s" != "null" ]]; then _mark_pass "vm-backup → $s"; defer vm-backup "$s" - else _mark_skip "vm-backup (create flags vary by env)"; fi + --pseudo-service "vm-backup" -o json || { _mark_fail "vm-backup create"; return; } + s="$(_jq_slug <<<"$out")" + _lc_result "vm-backup" "$s" && defer vm-backup "$s" } lc_loadbalancer() { @@ -545,14 +538,21 @@ lc_iso() { } lc_kubernetes() { - local out; local ver plan setup - ver="$(api_get '/kubernetes-clusters/versions' | jq -r --arg rid "$(det_region_id)" '.data[]|select(.region_id==$rid)|.slug' | head -1)" - plan="$(api_get "/plans/service/Kubernetes?region=$(det_region)" | jq -r '.data[]|select((.name//"")|test("YUL|YOW";"i"))|.slug' | head -1)" + local out plans storage_plans ver master_plan worker_plan storage_plan storage_category setup + fx_sshkey || { _mark_skip "kubernetes (could not create SSH-key fixture)"; return; } + ver="$(api_get '/kubernetes-clusters/versions' | jq -r --arg rid "$(det_region_id)" '.data[]|select(.region_id==$rid and (.version // "") != "")|.version' | head -1)" + plans="$(api_get "/plans/service/Kubernetes?filter%5Bregion%5D=$(det_region)")" + worker_plan="$(jq -r '.data[]|select(.attribute.package_for=="worker")|.slug' <<<"$plans" | head -1)" + storage_category="$(jq -r --arg worker "$worker_plan" '.data[]|select(.slug==$worker)|.storage_category.slug' <<<"$plans")" + master_plan="$(jq -r --arg category "$storage_category" '.data[]|select(.attribute.package_for=="master" and .storage_category.slug==$category)|.slug' <<<"$plans" | head -1)" + storage_plans="$(api_get "/plans/service/Block%20Storage?filter%5Bregion%5D=$(det_region)&filter%5Bstorage_category%5D=$storage_category")" + storage_plan="$(jq -r '.data[]|.slug' <<<"$storage_plans" | head -1)" setup="$(api_get '/regions' | jq -r --arg s "$(det_region)" '.data[]|select(.slug==$s)|.cloud_provider_setup.slug' | head -1)" - [[ -z "$ver" || -z "$plan" ]] && { _mark_skip "kubernetes (no version/plan for region)"; return; } - out="$(zcp kubernetes create --name "$(rname k8s)" --version "$ver" --plan "$plan" \ + [[ -z "$ver" || -z "$master_plan" || -z "$worker_plan" || -z "$storage_plan" || -z "$storage_category" ]] && { _mark_skip "kubernetes (no compatible control-plane, worker, and storage plans for region)"; return; } + out="$(zcp kubernetes create --name "$(rname k8s)" --version "$ver" \ + --control-plane-plan "$master_plan" --worker-plan "$worker_plan" --storage-plan "$storage_plan" --storage-category "$storage_category" \ --region "$(det_region)" --project "$(det_project)" --cloud-provider "$(det_cp)" \ - --billing-cycle "$(det_billing_cycle)" --workers 1 --cloud-provider-setup "${setup:-zcp-apc}" -y 2>&1)"; local rc=$? + --billing-cycle "$(det_billing_cycle)" --workers 1 --ssh-key "$FX_SSHKEY_NAME" --cloud-provider-setup "${setup:-zcp-apc}" -y 2>&1)"; local rc=$? if [[ $rc -ne 0 ]]; then if grep -qiE 'quota not found' <<<"$out"; then _mark_skip "kubernetes (account has no k8s quota — env limitation)" From 9cde738e07ea25d1f73999888ae7ed1ee0e68d9c Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sat, 10 Oct 2026 20:05:12 -0400 Subject: [PATCH 2/5] feat: implement FindBySlug method for DNS domain retrieval and update related tests Signed-off-by: ditahkk --- CHANGELOG.md | 2 +- internal/commands/dns.go | 11 +- internal/commands/dns_status_test.go | 6 +- pkg/api/dns/dns.go | 37 +++++++ pkg/api/dns/dns_test.go | 146 +++++++++++++++++++++++++++ pkg/api/scheduler/scheduler_test.go | 10 +- pkg/httpclient/client.go | 2 +- 7 files changed, 203 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bcea7..de4fdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using - **`vm-backup create` now reports the created schedule slug.** It prints the created schedule in table output and emits valid JSON or YAML for structured output. When the create endpoint returns only an acknowledgement, the command identifies the new schedule from a scoped pre-create snapshot and bounded read-only lookup. It never repeats an accepted create request. If the command cannot identify the schedule, the error tells you to list it before creating another one. _Fixes #76._ - **DNS listings now retrieve every reported page.** `dns.Service.List` returns an error rather than a partial domain list if a later page fails or pagination metadata is inconsistent. -- **`dns show` now resolves an omitted status from the matching domain in `dns list`.** An explicit status in the detail response is preserved. If the matching list entry also lacks status, output remains `-`. If the list lookup fails, the command returns an error. _Follow-up to #69._ +- **`dns show` now resolves an omitted status from the matching domain in `dns list`.** It stops reading pages as soon as it finds that domain. An explicit status in the detail response is preserved. If the matching list entry also lacks status, output remains `-`. If the list lookup fails, the command returns an error. _Follow-up to #69._ ## [v0.0.31] - 2026-10-09 diff --git a/internal/commands/dns.go b/internal/commands/dns.go index a52c5f2..7c4961f 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -152,16 +152,13 @@ func runDNSShow(cmd *cobra.Command, slug string) error { // matching list entry that reports it. A missing matching entry remains unknown // rather than being guessed from another field. func resolveDNSDomainStatus(ctx context.Context, svc *dns.Service, domain *dns.Domain) error { - domains, err := svc.List(ctx) + listed, err := svc.FindBySlug(ctx, domain.Slug) if err != nil { return fmt.Errorf("resolving status for DNS domain %s: %w", domain.Slug, err) } - for i := range domains { - if domains[i].Slug == domain.Slug && domains[i].StatusKnown { - domain.Status = domains[i].Status - domain.StatusKnown = true - break - } + if listed != nil && listed.StatusKnown { + domain.Status = listed.Status + domain.StatusKnown = true } return nil } diff --git a/internal/commands/dns_status_test.go b/internal/commands/dns_status_test.go index 1a031e9..92fd9cd 100644 --- a/internal/commands/dns_status_test.go +++ b/internal/commands/dns_status_test.go @@ -17,7 +17,11 @@ func TestDNSShowResolvesOmittedStatusFromList(t *testing.T) { fmt.Fprint(w, `{"status":"Success","data":{"slug":"example-com-1","name":"example.com"}}`) case "/dns/domains": listCalls++ - fmt.Fprint(w, `{"status":"Success","current_page":1,"total":1,"data":[{"slug":"example-com-1","status":false}]}`) + if r.URL.Query().Get("page") != "1" { + http.Error(w, "later page must not be requested", http.StatusInternalServerError) + return + } + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":2,"data":[{"slug":"example-com-1","status":false}]}`) default: http.NotFound(w, r) } diff --git a/pkg/api/dns/dns.go b/pkg/api/dns/dns.go index 2e43897..1e184a4 100644 --- a/pkg/api/dns/dns.go +++ b/pkg/api/dns/dns.go @@ -144,6 +144,23 @@ const maxListPages = 1000 // List returns all DNS domains. When the endpoint omits pagination metadata, // it preserves the legacy single-response behavior. func (s *Service) List(ctx context.Context) ([]Domain, error) { + return s.list(ctx, "") +} + +// FindBySlug returns the DNS domain with the given slug, or nil when it is not +// present. It stops fetching pages as soon as it finds a match. +func (s *Service) FindBySlug(ctx context.Context, slug string) (*Domain, error) { + if slug == "" { + return nil, nil + } + domains, err := s.list(ctx, slug) + if err != nil { + return nil, err + } + return findDomain(domains, slug), nil +} + +func (s *Service) list(ctx context.Context, slug string) ([]Domain, error) { var domains []Domain var reportedTotal *int @@ -171,6 +188,12 @@ func (s *Service) List(ctx context.Context) ([]Domain, error) { return nil, fmt.Errorf("listing DNS domains: pagination total changed or was omitted on page %d", page) } if reportedTotal == nil { + if slug != "" { + if domain := findDomain(resp.Data, slug); domain != nil { + return []Domain{*domain}, nil + } + return nil, nil + } return resp.Data, nil } if *reportedTotal == 0 && len(resp.Data) > 0 { @@ -180,6 +203,11 @@ func (s *Service) List(ctx context.Context) ([]Domain, error) { return nil, fmt.Errorf("listing DNS domains: API returned more domains than reported total %d", *reportedTotal) } + if slug != "" { + if domain := findDomain(resp.Data, slug); domain != nil { + return []Domain{*domain}, nil + } + } domains = append(domains, resp.Data...) if len(domains) == *reportedTotal { return domains, nil @@ -192,6 +220,15 @@ func (s *Service) List(ctx context.Context) ([]Domain, error) { return nil, fmt.Errorf("listing DNS domains: exceeded %d pages without reaching the reported total", maxListPages) } +func findDomain(domains []Domain, slug string) *Domain { + for i := range domains { + if domains[i].Slug == slug { + return &domains[i] + } + } + return nil +} + // Show returns details for a single DNS domain by slug, including records. func (s *Service) Show(ctx context.Context, slug string) (*Domain, error) { q := url.Values{} diff --git a/pkg/api/dns/dns_test.go b/pkg/api/dns/dns_test.go index d2d8233..f3b7dd8 100644 --- a/pkg/api/dns/dns_test.go +++ b/pkg/api/dns/dns_test.go @@ -115,6 +115,152 @@ func TestDNSDomainListReturnsNoPartialResultsOnLaterPageFailure(t *testing.T) { } } +func TestDNSDomainFindBySlugStopsAfterMatchingFirstPage(t *testing.T) { + var requestedPages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPages = append(requestedPages, r.URL.Query().Get("page")) + if r.URL.Query().Get("page") == "1" { + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":2,"data":[{"slug":"target","status":false}]}`) + return + } + http.Error(w, "later page must not be requested", http.StatusInternalServerError) + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain == nil || domain.Status || !domain.StatusKnown { + t.Fatalf("FindBySlug() domain = %+v, want known false status", domain) + } + if got, want := strings.Join(requestedPages, ","), "1"; got != want { + t.Errorf("requested pages = %q, want %q", got, want) + } +} + +func TestDNSDomainFindBySlugFetchesLaterPage(t *testing.T) { + var requestedPages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPages = append(requestedPages, r.URL.Query().Get("page")) + switch r.URL.Query().Get("page") { + case "1": + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":2,"data":[{"slug":"other","status":true}]}`) + case "2": + fmt.Fprint(w, `{"status":"Success","current_page":2,"total":2,"data":[{"slug":"target","status":true}]}`) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain == nil || domain.Slug != "target" { + t.Fatalf("FindBySlug() domain = %+v, want target", domain) + } + if got, want := strings.Join(requestedPages, ","), "1,2"; got != want { + t.Errorf("requested pages = %q, want %q", got, want) + } +} + +func TestDNSDomainFindBySlugReturnsNilWhenNoMatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":1,"data":[{"slug":"other","status":true}]}`) + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain != nil { + t.Errorf("FindBySlug() domain = %+v, want nil", domain) + } +} + +func TestDNSDomainFindBySlugReturnsNilForEmptySlug(t *testing.T) { + domain, err := dns.NewService(newClient("http://127.0.0.1:1")).FindBySlug(context.Background(), "") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain != nil { + t.Errorf("FindBySlug() domain = %+v, want nil", domain) + } +} + +func TestDNSDomainFindBySlugPreservesUnknownStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":1,"data":[{"slug":"target"}]}`) + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain == nil || domain.StatusKnown { + t.Errorf("FindBySlug() domain = %+v, want unknown status", domain) + } +} + +func TestDNSDomainFindBySlugHandlesResponseWithoutPaginationMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"status":"Success","data":[{"slug":"target","status":true}]}`) + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain == nil || domain.Slug != "target" { + t.Errorf("FindBySlug() domain = %+v, want target", domain) + } +} + +func TestDNSDomainFindBySlugReturnsNilWithoutPaginationMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"status":"Success","data":[{"slug":"other","status":true}]}`) + })) + defer srv.Close() + + domain, err := dns.NewService(newClient(srv.URL)).FindBySlug(context.Background(), "target") + if err != nil { + t.Fatalf("FindBySlug() error = %v", err) + } + if domain != nil { + t.Errorf("FindBySlug() domain = %+v, want nil", domain) + } +} + +func TestDNSDomainFindBySlugReturnsLaterPageError(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Query().Get("page") == "1" { + fmt.Fprint(w, `{"status":"Success","current_page":1,"total":2,"data":[{"slug":"other","status":true}]}`) + return + } + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{BaseURL: srv.URL, BearerToken: "test-token", Timeout: 5 * time.Second, MaxRetries: -1}) + domain, err := dns.NewService(client).FindBySlug(context.Background(), "target") + if err == nil { + t.Fatal("FindBySlug() error = nil, want later-page error") + } + if domain != nil { + t.Errorf("FindBySlug() domain = %+v, want nil on later-page error", domain) + } + if requests != 2 { + t.Errorf("requests = %d, want 2", requests) + } +} + func TestDNSDomainListRejectsExplicitZeroTotalWithDomains(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/api/scheduler/scheduler_test.go b/pkg/api/scheduler/scheduler_test.go index 855ef38..5519708 100644 --- a/pkg/api/scheduler/scheduler_test.go +++ b/pkg/api/scheduler/scheduler_test.go @@ -13,7 +13,7 @@ import ( ) func testService(s *httptest.Server) *Service { - return NewService(httpclient.New(httpclient.Options{BaseURL: s.URL, BearerToken: "test", MaxRetries: 0})) + return NewService(httpclient.New(httpclient.Options{BaseURL: s.URL, BearerToken: "test", MaxRetries: -1})) } func TestListFetchesAllPages(t *testing.T) { @@ -41,7 +41,9 @@ func TestListFetchesAllPages(t *testing.T) { } func TestListReturnsNoPartialResultsWhenLaterPageFails(t *testing.T) { + var requests int s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ if r.URL.Query().Get("page") == "1" { fmt.Fprint(w, `{"current_page":1,"total":2,"data":[{"id":"1"}]}`) return @@ -53,6 +55,9 @@ func TestListReturnsNoPartialResultsWhenLaterPageFails(t *testing.T) { if err == nil || got != nil { t.Fatalf("got=%+v err=%v, want nil/error", got, err) } + if requests != 2 { + t.Errorf("requests = %d, want 2", requests) + } } func TestMutationsUseSchedulerRoutes(t *testing.T) { @@ -77,6 +82,9 @@ func TestMutationsUseSchedulerRoutes(t *testing.T) { t.Fatal(err) } want := []string{"PUT /scheduler-actions/policy", "PATCH /scheduler-actions/policy/pause", "PATCH /scheduler-actions/policy/resume", "POST /scheduler/policy/run-now"} + if len(paths) != len(want) { + t.Fatalf("request count = %d, want %d", len(paths), len(want)) + } for i := range want { if paths[i] != want[i] { t.Errorf("path %d = %q, want %q", i, paths[i], want[i]) diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index 7ede2ba..0fd30c6 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -26,7 +26,7 @@ type Options struct { // DebugOut is where debug output is written (defaults to os.Stderr in New). DebugOut io.Writer // MaxRetries is the number of times to retry GET requests on transient failures. - // Default is 3. Set to 0 to disable retries. + // Default is 3 when zero. Set a negative value to disable retries. MaxRetries int // RetryGETs controls whether GET requests are retried on transient failures. // Default is true. From f773b47a2ab6d16d71b0fa5006c0d9360da70425 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sat, 10 Oct 2026 23:50:27 -0400 Subject: [PATCH 3/5] feat: enhance VM and Kubernetes plan handling with root disk capacity - Added support for root disk capacity in VM and Kubernetes create requests. - Updated the plan command to display tags for VM plans. - Introduced tests to validate the new root disk capacity feature. - Enhanced the smoke tests to ensure root disk size is specified for Kubernetes and VM creations. - Updated API models to include block storage custom plans and billing cycles. - Improved tag parsing logic in the plan model to prioritize new tag structure. Signed-off-by: ditahkk --- .github/workflows/build.yml | 10 + .github/workflows/smoke.yml | 1 + CHANGELOG.md | 5 + RELEASE_NOTES.md | 24 ++- cmd/zcp/root/root_test.go | 2 +- docs/command-taxonomy.md | 6 +- docs/commands.md | 47 ++--- docs/configuration.md | 6 +- internal/commands/commands_test.go | 1 + internal/commands/instance.go | 141 +++++-------- internal/commands/instance_create_test.go | 129 +++++++----- internal/commands/kubernetes.go | 166 ++++++++++----- internal/commands/kubernetes_test.go | 245 ++++++++++++++++++++-- internal/commands/plan.go | 6 +- internal/commands/plan_test.go | 45 ++++ pkg/api/instance/instance.go | 72 ++++--- pkg/api/instance/instance_test.go | 34 ++- pkg/api/kubernetes/kubernetes.go | 49 +++-- pkg/api/kubernetes/kubernetes_test.go | 124 ++++++++--- pkg/api/plan/plan.go | 14 +- pkg/api/plan/plan_test.go | 2 + tests/smoke/README.md | 5 + tests/smoke/cases.sh | 29 ++- tests/smoke/cases_test.sh | 49 +++++ tests/smoke/lib.sh | 6 +- 25 files changed, 881 insertions(+), 337 deletions(-) create mode 100644 internal/commands/plan_test.go create mode 100644 tests/smoke/cases_test.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8feb313..b8a1428 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,7 @@ jobs: - 'go.mod' - 'go.sum' - 'Makefile' + - 'tests/smoke/**' scripts: - 'scripts/**' workflow: @@ -131,12 +132,21 @@ jobs: - name: Get dependencies run: go mod download + - name: Install jq + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y jq + - name: Run go vet run: go vet ./... - name: Run tests run: go test -v ./... + - name: Test smoke ownership checks + if: matrix.os == 'ubuntu-latest' + shell: bash + run: bash tests/smoke/cases_test.sh + - name: Run tests (race detector) run: go test -race ./... diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index d1e0d52..38c89d3 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -111,6 +111,7 @@ jobs: ZCP_SMOKE_REGION: ${{ vars.ZCP_SMOKE_REGION }} ZCP_SMOKE_PROJECT: ${{ vars.ZCP_SMOKE_PROJECT }} ZCP_SMOKE_CLOUD_PROVIDER: ${{ vars.ZCP_SMOKE_CLOUD_PROVIDER }} + ZCP_SMOKE_ROOT_DISK_SIZE: ${{ vars.ZCP_SMOKE_ROOT_DISK_SIZE }} run: | if [ "${{ needs.preflight.outputs.services }}" = "all" ]; then ARGS=(--bin ./bin/zcp) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4fdbc..45a1bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Fixed - **`vm-backup create` now reports the created schedule slug.** It prints the created schedule in table output and emits valid JSON or YAML for structured output. When the create endpoint returns only an acknowledgement, the command identifies the new schedule from a scoped pre-create snapshot and bounded read-only lookup. It never repeats an accepted create request. If the command cannot identify the schedule, the error tells you to list it before creating another one. _Fixes #76._ +- **`kubernetes delete` now submits an immediate service-cancellation request.** The command reads the cluster first, uses its reported billing cycle when available, and reports that deletion was requested rather than completed. - **DNS listings now retrieve every reported page.** `dns.Service.List` returns an error rather than a partial domain list if a later page fails or pagination metadata is inconsistent. - **`dns show` now resolves an omitted status from the matching domain in `dns list`.** It stops reading pages as soon as it finds that domain. An explicit status in the detail response is preserved. If the matching list entry also lacks status, output remains `-`. If the list lookup fails, the command returns an error. _Follow-up to #69._ +### Changed + +- **Instance and Kubernetes creation now select root storage separately.** `zcp instance create` requires a named `--plan`, `--blockstorage-plan`, and `--root-disk-size`. `zcp kubernetes create` requires `--control-plane-plan`, `--worker-plan`, `--storage-plan`, and `--root-disk-size`. The root storage tier and capacity are sent separately from fixed compute plans. `zcp kubernetes get` lists each root volume's reported capacity separately from compute-plan storage. The retired instance `--cpu`, `--memory`, and `--disk` flags no longer select custom compute. Use `zcp plan vm` to choose a plan; its output includes `TAG`. + ## [v0.0.31] - 2026-10-09 ### Security diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 24e0a57..466a3c1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,15 +3,26 @@ The next release will include the following fixes. No release version or date has been selected. +## Fixed compute plans for instances + +`zcp instance create` now requires a named `--plan`. The `--cpu`, `--memory`, +and `--disk` flags have been retired. Use `zcp plan vm` to select a plan; its +output includes `TAG`. + +For compute-only plans, the price covers compute only. Root disks and network +resources are billed separately. Every new instance now requires +`--blockstorage-plan` and `--root-disk-size` alongside its compute plan. + ## Kubernetes resource plans and billing -`zcp kubernetes create` now requires separate resource selections for the -control plane, worker nodes, and one root-volume plan used for every node: +`zcp kubernetes create` now requires separate selections for the control-plane +plan, worker plan, root-storage tier, and root-disk capacity used for every +node: ```bash zcp kubernetes create --name my-cluster --version v1.37.0 \ --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul \ - --storage-plan b2g1 --storage-category pro-nvme \ + --storage-plan b2g1 --root-disk-size 100 --storage-category pro-nvme \ --region yul-1 --project default-9 --billing-cycle hourly \ --workers 3 --enable-csi --ssh-key mykey ``` @@ -22,11 +33,16 @@ volume. Network, load balancer, and public-IP subscriptions depend on the platform packages and feature flags enabled for those resources. `zcp kubernetes get` now shows resource totals, separate control-plane and -worker configuration, root-volume count, network, and autoscaling status when +worker configuration, root-volume count, and per-volume capacities separately +from compute-plan storage. It also shows network and autoscaling status when the API returns them. `zcp kubernetes scale` supports manual worker counts, enabling autoscaling with `--min-workers` and `--max-workers`, and disabling autoscaling with an explicit `--workers` count. +`zcp kubernetes delete` submits an immediate cancellation request. A successful +response confirms that the request was accepted; removal completes in the +background. + Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. Creating a high-availability cluster requires at least two control nodes. diff --git a/cmd/zcp/root/root_test.go b/cmd/zcp/root/root_test.go index 10899e3..5803fd2 100644 --- a/cmd/zcp/root/root_test.go +++ b/cmd/zcp/root/root_test.go @@ -44,7 +44,7 @@ profiles: rootCmd.SetOut(&bytes.Buffer{}) rootCmd.SetErr(&bytes.Buffer{}) - rootCmd.SetArgs([]string{"instance", "create", "--name", "test-vm"}) + rootCmd.SetArgs([]string{"instance", "create", "--name", "test-vm", "--plan", "ca2sxs"}) err := rootCmd.Execute() if err == nil { diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index 30fa11c..2af58aa 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -64,7 +64,7 @@ zcp ├── instance VM instance operations │ ├── list List instances │ ├── get Show details for a single instance -│ ├── create Create a new instance (--plan for fixed plans, or --cpu/--memory/--disk for custom plans; --network-type Isolated|L2|Vpc; --network-plan for Isolated/L2, --vr-plan for Vpc, or --networks to attach existing ones with --default-network) +│ ├── create Create a new instance (--plan, --blockstorage-plan, and --root-disk-size required; --network-type Isolated|L2|Vpc; --network-plan for Isolated/L2, --vr-plan for Vpc, or --networks to attach existing ones with --default-network) │ ├── start Start a stopped instance │ ├── stop Stop a running instance │ ├── reboot Reboot a running instance @@ -190,7 +190,7 @@ zcp ├── kubernetes (alias: k8s) Kubernetes cluster operations │ ├── list List Kubernetes clusters │ ├── get Show details and returned resource overview for a single cluster -│ ├── create Create a Kubernetes cluster (--control-plane-plan, --worker-plan, --storage-plan; optional --enable-csi) +│ ├── create Create a Kubernetes cluster (--control-plane-plan, --worker-plan, --storage-plan, --root-disk-size; optional --enable-csi) │ ├── start Start a stopped cluster │ ├── stop Stop a running cluster │ ├── scale Set worker count or autoscaling (--workers N, --enable-autoscaling --min-workers N --max-workers N, --disable-autoscaling --workers N) @@ -330,7 +330,7 @@ zcp │ └── cancel-service Submit a service cancellation request │ ├── plan List service plans and pricing -│ ├── vm List Virtual Machine plans +│ ├── vm List Virtual Machine plans, including TAG │ ├── router List Virtual Router plans │ ├── storage List Block Storage plans │ ├── lb List Load Balancer plans diff --git a/docs/commands.md b/docs/commands.md index 9e368c5..c9818c1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,7 +53,7 @@ zcp billing-cycle list # billing-cycle slugs for --billing-cycle 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 vm # Virtual Machine plans, including the TAG column 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 @@ -82,6 +82,11 @@ Instance subcommands accept any unique reference to the VM (its **instance ID** 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. +Select a named compute plan with `zcp plan vm`; the listing includes each plan's +`TAG`. For compute-only plans, the price covers compute only. Root disks and +network resources are billed separately. Every new instance needs a named root +storage tier and an explicit root-disk capacity in GB. + ```bash # List and inspect zcp instance list @@ -95,32 +100,21 @@ zcp instance create \ --project default-9 \ --region yul-1 \ --template ubuntu-2604-lts-1 \ - --plan ca2sl \ + --plan ca2cxs \ --billing-cycle hourly \ --network-plan pnet-yul \ --storage-category pro-nvme \ --blockstorage-plan b2g1 \ + --root-disk-size 100 \ --ssh-key mykey -# Custom plan create. Omit --plan and pass CPU, memory in GB, and root disk in GB. -zcp instance create \ - --name my-custom-vm \ - --project default-9 \ - --region yul-1 \ - --template ubuntu-2604-lts-1 \ - --cpu 2 \ - --memory 4 \ - --disk 45 \ - --billing-cycle hourly \ - --network-plan pnet-yul \ - --storage-category pro-nvme - zcp instance create ... --wait # L2 networks cannot carry a public IP: pass --is-public=false (default: true) zcp instance create --name my-l2-vm --project default-9 --region yul-1 \ - --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly \ + --template ubuntu-2604-lts-1 --plan ca2cxs --billing-cycle hourly \ --network-plan l2net-yul --network-type L2 --storage-category pro-nvme \ + --blockstorage-plan b2g1 --root-disk-size 100 \ --is-public=false # --network-type accepts Isolated (default), L2 or Vpc. @@ -129,15 +123,16 @@ zcp instance create --name my-l2-vm --project default-9 --region yul-1 \ # Create inside a VPC, building a new network from a virtual router plan zcp instance create --name my-vpc-vm --project default-9 --region yul-1 \ - --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly \ - --network-type Vpc --vr-plan --storage-category pro-nvme + --template ubuntu-2604-lts-1 --plan ca2cxs --billing-cycle hourly \ + --network-type Vpc --vr-plan --storage-category pro-nvme \ + --blockstorage-plan b2g1 --root-disk-size 100 # Attach existing networks instead of creating one. With more than one network, # --default-network is required and must be one of the values in --networks. zcp instance create --name my-multi-net-vm --project default-9 --region yul-1 \ - --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly \ + --template ubuntu-2604-lts-1 --plan ca2cxs --billing-cycle hourly \ --network-type Vpc --networks net-a,net-b --default-network net-a \ - --storage-category pro-nvme + --storage-category pro-nvme --blockstorage-plan b2g1 --root-disk-size 100 # Lifecycle zcp instance start @@ -561,13 +556,14 @@ zcp project icon list zcp kubernetes list # Select independent control-plane and worker compute plans, plus one -# root-volume plan used for every node. +# root-volume tier and capacity used for every node. zcp kubernetes create \ --name my-cluster \ --version v1.37.0 \ --control-plane-plan k8s-cpi-yul \ --worker-plan k8s-li-yul \ --storage-plan b2g1 \ + --root-disk-size 100 \ --storage-category pro-nvme \ --region yul-1 \ --project default-9 \ @@ -583,6 +579,7 @@ zcp kubernetes create \ --control-plane-plan k8s-cpi-yul \ --worker-plan k8s-4xli-yul \ --storage-plan b2g1 \ + --root-disk-size 100 \ --storage-category pro-nvme \ --region yul-1 \ --project default-9 \ @@ -602,8 +599,9 @@ zcp kubernetes create \ # Kubeconfig zcp kubernetes get-config -# Cluster overview, including resource plans, totals, root-volume count, -# network, and autoscaling status when returned by the API. +# Cluster overview, including resource plans, totals, root-volume count and +# per-volume capacities, compute-plan storage, network, and autoscaling status +# when returned by the API. zcp kubernetes get # Lifecycle @@ -615,7 +613,8 @@ zcp kubernetes scale --disable-autoscaling --workers 3 zcp kubernetes upgrade --plan k8s-xla-yul-1 zcp kubernetes upgrade-version --version v1.37.0 -# Delete a cluster +# Delete a cluster. This submits an asynchronous cancellation request; a +# successful response confirms the request was accepted, not that deletion has finished. zcp kubernetes delete ``` diff --git a/docs/configuration.md b/docs/configuration.md index 765bd4a..669fae0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ profiles: staging: name: staging bearer_token: STAGING_BEARER_TOKEN - api_url: https://staging-api.zcp.zsoftly.ca + api_url: https://stg-cloud.zcp.zsoftly.ca/backend/api production: name: production @@ -153,8 +153,8 @@ zcp profile add default --region yul-1 --project default-9 zcp profile add staging \ --bearer-token YOUR_STAGING_TOKEN \ --region yul-1 \ - --project default-9 \ - --api-url https://staging-api.zcp.zsoftly.ca + --project default \ + --api-url-override https://stg-cloud.zcp.zsoftly.ca/backend/api ``` ### Switching the Active Profile diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index b506ce2..e0013df 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -151,6 +151,7 @@ func TestK8sCreateRequiresBillingCycle(t *testing.T) { root.SetErr(&bytes.Buffer{}) root.SetArgs([]string{"kubernetes", "create", "--name", "test", "--version", "v1.28.4", "--control-plane-plan", "k8s-control-1", "--worker-plan", "k8s-worker-1", "--storage-plan", "k8s-root-volume-1", + "--root-disk-size", "100", "--cloud-provider", "nimbo", "--region", "noida", "--project", "default-9", "--workers", "1", "--storage-category", "nvme", "--ssh-key", "mykey"}) diff --git a/internal/commands/instance.go b/internal/commands/instance.go index de89ce1..6bf0dde 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -26,11 +26,6 @@ const ( NetworkTypeVpc = "Vpc" ) -const ( - customPlanCPUMin = 2 - customPlanMemoryMaxGB = 256 -) - // instanceGetRetryWait controls the backoff between transient-routing-error retries. // Overridden in tests to avoid real sleeps. var instanceGetRetryWait = func(attempt int) time.Duration { @@ -373,12 +368,10 @@ func newInstanceCreateCmd() *cobra.Command { storageCategory string computeCategory string blockstoragePlan string + rootDiskSize int networkPlan string userData string userDataFile string - cpu int - memory int - disk int wait bool isPublic bool networks []string @@ -395,13 +388,16 @@ func newInstanceCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new virtual machine", - Example: ` zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly --network-plan pnet-yul --storage-category premium-ssd - zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly --network-plan pnet-yul --storage-category premium-ssd --wait - zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2sl --billing-cycle hourly --network-plan pnet-yul --storage-category premium-ssd --ssh-key mykey # import the key first with 'zcp ssh-key import'`, + Example: ` zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2cxs --blockstorage-plan b2g1 --root-disk-size 100 --billing-cycle hourly --network-plan pnet-yul --storage-category pro-nvme + zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2cxs --blockstorage-plan b2g1 --root-disk-size 100 --billing-cycle hourly --network-plan pnet-yul --storage-category pro-nvme --wait + zcp instance create --name my-vm --project default-9 --region yul-1 --template ubuntu-2604-lts-1 --plan ca2cxs --blockstorage-plan b2g1 --root-disk-size 100 --billing-cycle hourly --network-plan pnet-yul --storage-category pro-nvme --ssh-key mykey # import the key first with 'zcp ssh-key import'`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + if plan == "" { + return fmt.Errorf("--plan is required") + } cloudProvider = resolveCloudProvider(cmd, cloudProvider) if cloudProvider == "" { return fmt.Errorf("could not determine cloud provider — run 'zcp auth validate' to detect it, or pass --cloud-provider (see 'zcp cloud-provider list')") @@ -423,6 +419,22 @@ func newInstanceCreateCmd() *cobra.Command { if storageCategory == "" { return fmt.Errorf("--storage-category is required") } + rootDiskSizeSet := cmd.Flags().Changed("root-disk-size") + if blockstoragePlan == "" && !rootDiskSizeSet { + return fmt.Errorf("--blockstorage-plan and --root-disk-size are required") + } + if blockstoragePlan == "" { + return fmt.Errorf("--blockstorage-plan is required when --root-disk-size is set") + } + if blockstoragePlan == "custom_plan" { + return fmt.Errorf("--blockstorage-plan must name a storage tier; custom_plan is not supported") + } + if !rootDiskSizeSet { + return fmt.Errorf("--root-disk-size is required when --blockstorage-plan is set") + } + if rootDiskSize <= 0 { + return fmt.Errorf("--root-disk-size must be > 0") + } if userData != "" && userDataFile != "" { return fmt.Errorf("--user-data and --user-data-file are mutually exclusive") } @@ -494,45 +506,34 @@ func newInstanceCreateCmd() *cobra.Command { userDataPtr = &userData } - resolvedPlan, customPlan, err := resolveInstanceCreatePlan( - plan, - cpu, memory, disk, - cmd.Flags().Changed("cpu"), - cmd.Flags().Changed("memory"), - cmd.Flags().Changed("disk"), - ) - if err != nil { - return err - } - req := instance.CreateRequest{ - Name: name, - CloudProvider: cloudProvider, - Project: project, - Region: region, - BootSource: "image", - Server: "cloud-compute", - Template: template, - IsPublic: isPublic, - NetworkType: networkType, - Networks: networks, - BillingCycle: billingCycle, - SSHKey: sshKeyPtr, - AuthMethod: authMethod, - Password: passwordPtr, - Plan: resolvedPlan, - CustomPlan: customPlan, - OSFamily: "Linux", - TemplateType: "Operating System", - Hostname: h, - Addons: []string{}, - StorageCategory: storageCategory, - ComputeCategory: computeCategory, - BlockstoragePlan: blockstoragePlan, - NetworkPlan: networkPlan, - DefaultNetwork: defaultNetwork, - VrPlan: vrPlan, - UserData: userDataPtr, + Name: name, + CloudProvider: cloudProvider, + Project: project, + Region: region, + BootSource: "image", + Server: "cloud-compute", + Template: template, + IsPublic: isPublic, + NetworkType: networkType, + Networks: networks, + BillingCycle: billingCycle, + SSHKey: sshKeyPtr, + AuthMethod: authMethod, + Password: passwordPtr, + Plan: plan, + OSFamily: "Linux", + TemplateType: "Operating System", + Hostname: h, + Addons: []string{}, + StorageCategory: storageCategory, + ComputeCategory: computeCategory, + BlockstoragePlan: blockstoragePlan, + BlockstorageCustomPlan: &instance.BlockstorageCustomPlan{Storage: rootDiskSize}, + NetworkPlan: networkPlan, + DefaultNetwork: defaultNetwork, + VrPlan: vrPlan, + UserData: userDataPtr, } return runInstanceCreate(cmd, req, wait) }, @@ -542,62 +543,26 @@ func newInstanceCreateCmd() *cobra.Command { cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") cmd.Flags().StringVar(&template, "template", "", "Template slug (required)") - cmd.Flags().StringVar(&plan, "plan", "", "Plan slug (required unless --cpu, --memory, and --disk are provided for a custom plan; e.g. ca2sxs - see: zcp plan vm)") + cmd.Flags().StringVar(&plan, "plan", "", "Compute plan slug (required; see: zcp plan vm)") cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug: hourly, monthly, etc. (required)") cmd.Flags().StringVar(&networkType, "network-type", "Isolated", "Network type: Isolated, L2 or Vpc (required)") cmd.Flags().StringVar(&sshKey, "ssh-key", "", "Name of an existing SSH key to attach for login (optional; see 'zcp ssh-key list')") cmd.Flags().StringVar(&hostname, "hostname", "", "Hostname (defaults to --name)") cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category (required; e.g. premium-ssd - see: zcp plan storage)") cmd.Flags().StringVar(&computeCategory, "compute-category", "", "Compute category slug (optional)") - cmd.Flags().StringVar(&blockstoragePlan, "blockstorage-plan", "", "Block storage plan slug (optional, e.g. b2g1 — see: zcp plan storage)") + cmd.Flags().StringVar(&blockstoragePlan, "blockstorage-plan", "", "Root storage tier slug (required; see: zcp plan storage)") + cmd.Flags().IntVar(&rootDiskSize, "root-disk-size", 0, "Root disk capacity in GB for the selected storage tier (required)") cmd.Flags().StringVar(&networkPlan, "network-plan", "", "Network plan slug (optional; required when creating an Isolated or L2 network type — see: zcp plan network)") cmd.Flags().StringVar(&vrPlan, "vr-plan", "", "Virtual router plan slug (optional; required when creating a VPC — see: zcp plan router)") cmd.Flags().StringVar(&defaultNetwork, "default-network", "", "Default network slug (optional; required when attaching multiple networks)") cmd.Flags().StringSliceVar(&networks, "networks", []string{}, "List of network slugs to attach to the instance (optional; see: zcp network list)") cmd.Flags().StringVar(&userData, "user-data", "", "Startup script content (cloud-init / bash)") cmd.Flags().StringVar(&userDataFile, "user-data-file", "", "Path to a file containing the startup script") - cmd.Flags().IntVar(&cpu, "cpu", 0, "Number of vCPUs for a custom plan (e.g. 2)") - cmd.Flags().IntVar(&memory, "memory", 0, "RAM in GB for a custom plan (e.g. 4)") - cmd.Flags().IntVar(&disk, "disk", 0, "Root disk size in GB for a custom plan (e.g. 50)") cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state") cmd.Flags().BoolVar(&isPublic, "is-public", true, "Assign a public IP address") return cmd } -func resolveInstanceCreatePlan(plan string, cpu, memory, disk int, cpuSet, memorySet, diskSet bool) (string, *instance.CustomPlan, error) { - customSet := cpuSet || memorySet || diskSet - customComplete := cpuSet && memorySet && diskSet - - if plan == "" && !customComplete { - return "", nil, fmt.Errorf("--plan is required unless --cpu, --memory, and --disk are all provided for a custom plan") - } - if plan != "" && customSet { - return "", nil, fmt.Errorf("--plan cannot be used with --cpu, --memory, or --disk; omit --plan for a custom plan") - } - if !customSet { - return plan, nil, nil - } - - if cpu < customPlanCPUMin { - return "", nil, fmt.Errorf("invalid value for --cpu: must be at least %d vCPU", customPlanCPUMin) - } - if memory <= 0 { - return "", nil, fmt.Errorf("invalid value for --memory: must be > 0 GB") - } - if memory > customPlanMemoryMaxGB { - return "", nil, fmt.Errorf("invalid value for --memory: must be less than or equal to %d GB", customPlanMemoryMaxGB) - } - if disk <= 0 { - return "", nil, fmt.Errorf("invalid value for --disk: must be > 0 GB") - } - - return "", &instance.CustomPlan{ - CPU: strconv.Itoa(cpu), - Memory: strconv.Itoa(memory), - Storage: strconv.Itoa(disk), - }, nil -} - func runInstanceCreate(cmd *cobra.Command, req instance.CreateRequest, wait bool) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { diff --git a/internal/commands/instance_create_test.go b/internal/commands/instance_create_test.go index 6e986a2..c73df32 100644 --- a/internal/commands/instance_create_test.go +++ b/internal/commands/instance_create_test.go @@ -1,80 +1,113 @@ package commands import ( + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" ) -func TestResolveInstanceCreatePlanFixedPlan(t *testing.T) { - plan, customPlan, err := resolveInstanceCreatePlan("ca1hxs", 0, 0, 0, false, false, false) - if err != nil { - t.Fatalf("resolveInstanceCreatePlan() error = %v", err) - } - if plan != "ca1hxs" { - t.Fatalf("plan = %v, want ca1hxs", plan) - } - if customPlan != nil { - t.Fatalf("customPlan = %#v, want nil", customPlan) +func instanceCreateArgs(apiURL string) []string { + return []string{ + "--name", "new-vm", + "--cloud-provider", "test-cloud", + "--project", "test-project", + "--region", "test-region", + "--template", "ubuntu", + "--billing-cycle", "hourly", + "--storage-category", "premium-ssd", + "--network-plan", "network-plan", + "--api-url", apiURL, } } -func TestResolveInstanceCreatePlanCustomPlan(t *testing.T) { - plan, customPlan, err := resolveInstanceCreatePlan("", 2, 4, 45, true, true, true) - if err != nil { - t.Fatalf("resolveInstanceCreatePlan() error = %v", err) - } - if plan != "" { - t.Fatalf("plan = %v, want empty", plan) - } - if customPlan == nil { - t.Fatal("customPlan = nil, want custom sizing") +func TestInstanceCreateRequiresPlan(t *testing.T) { + _, _, err := execCmd(t, newInstanceCreateCmd(), instanceCreateArgs("http://example.invalid")...) + if err == nil { + t.Fatal("expected missing-plan error") } - if customPlan.CPU != "2" || customPlan.Memory != "4" || customPlan.Storage != "45" { - t.Fatalf("customPlan = %#v, want cpu=2 memory=4 storage=45", customPlan) + if !strings.Contains(err.Error(), "--plan is required") { + t.Fatalf("error = %q, want missing-plan error", err) } } -func TestResolveInstanceCreatePlanRequiresPlanOrCompleteCustomPlan(t *testing.T) { - _, _, err := resolveInstanceCreatePlan("", 2, 4, 0, true, true, false) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "--plan is required unless --cpu, --memory, and --disk are all provided") { - t.Fatalf("error = %q", err) +func TestInstanceCreateRejectsRetiredSizingFlags(t *testing.T) { + for _, flag := range []string{"--cpu", "--memory", "--disk"} { + t.Run(flag, func(t *testing.T) { + args := append(instanceCreateArgs("http://example.invalid"), "--plan", "ca2sxs", flag, "2") + _, _, err := execCmd(t, newInstanceCreateCmd(), args...) + if err == nil { + t.Fatalf("expected %s to be rejected", flag) + } + if !strings.Contains(err.Error(), "unknown flag") || !strings.Contains(err.Error(), flag) { + t.Fatalf("error = %q, want unknown %s flag", err, flag) + } + }) } } -func TestResolveInstanceCreatePlanRejectsMixedPlanAndCustomFlags(t *testing.T) { - _, _, err := resolveInstanceCreatePlan("ca1hxs", 2, 0, 0, true, false, false) - if err == nil { - t.Fatal("expected error") +func TestInstanceCreateSendsNamedPlansAndRootDiskCapacity(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 != "/virtual-machines" { + 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-vm","name":"new-vm","state":"Creating"}}`)) + })) + defer srv.Close() + + args := append(instanceCreateArgs(srv.URL), "--plan", "ca2sxs", "--blockstorage-plan", "b2g1", "--root-disk-size", "100") + _, _, err := execCmd(t, newInstanceCreateCmd(), args...) + if err != nil { + t.Fatalf("instance create: %v", err) + } + if got := request["plan"]; got != "ca2sxs" { + t.Errorf("plan = %#v, want %q", got, "ca2sxs") } - if !strings.Contains(err.Error(), "--plan cannot be used with --cpu, --memory, or --disk") { - t.Fatalf("error = %q", err) + if got, ok := request["custom_plan"]; !ok || got != nil { + t.Errorf("custom_plan = %#v, want null", got) + } + if got := request["blockstorage_plan"]; got != "b2g1" { + t.Errorf("blockstorage_plan = %#v, want %q", got, "b2g1") + } + rootDisk, ok := request["blockstorage_custom_plan"].(map[string]any) + if !ok { + t.Fatalf("blockstorage_custom_plan = %#v, want object", request["blockstorage_custom_plan"]) + } + if got := rootDisk["storage"]; got != float64(100) { + t.Errorf("blockstorage_custom_plan.storage = %#v, want %d", got, 100) } } -func TestResolveInstanceCreatePlanLocalCustomValidation(t *testing.T) { +func TestInstanceCreateRequiresNamedRootStorageAndCapacity(t *testing.T) { tests := []struct { name string - cpu int - mem int - disk int + args []string want string }{ - {name: "cpu below minimum", cpu: 1, mem: 4, disk: 45, want: "at least 2 vCPU"}, - {name: "memory above maximum", cpu: 2, mem: 257, disk: 45, want: "less than or equal to 256 GB"}, - {name: "disk non-positive", cpu: 2, mem: 4, disk: 0, want: "must be > 0 GB"}, + {name: "both missing", want: "--blockstorage-plan and --root-disk-size are required"}, + {name: "tier only", args: []string{"--blockstorage-plan", "b2g1"}, want: "--root-disk-size is required"}, + {name: "capacity only", args: []string{"--root-disk-size", "100"}, want: "--blockstorage-plan is required"}, + {name: "zero capacity", args: []string{"--blockstorage-plan", "b2g1", "--root-disk-size", "0"}, want: "--root-disk-size must be > 0"}, + {name: "unnamed tier", args: []string{"--blockstorage-plan", "custom_plan", "--root-disk-size", "100"}, want: "must name a storage tier"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, err := resolveInstanceCreatePlan("", tt.cpu, tt.mem, tt.disk, true, true, true) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), tt.want) { - t.Fatalf("error = %q, want containing %q", err, tt.want) + args := append(instanceCreateArgs("http://example.invalid"), "--plan", "ca2sxs") + args = append(args, tt.args...) + _, _, err := execCmd(t, newInstanceCreateCmd(), args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) } }) } diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index 2f7ad8c..40cd5bc 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/zsoftly/zcp-cli/pkg/api/apierrors" + "github.com/zsoftly/zcp-cli/pkg/api/billing" "github.com/zsoftly/zcp-cli/pkg/api/kubernetes" ) @@ -211,18 +212,23 @@ func runK8sClusterGet(cmd *cobra.Command, slug string) error { []string{"Control-plane plan", controlPlan}, []string{"Control-plane CPU", controlCPU}, []string{"Control-plane memory", controlMemory}, - []string{"Control-plane storage", controlStorage}, + []string{"Control-plane plan storage", controlStorage}, []string{"Worker plan", workerPlan}, []string{"Worker CPU", workerCPU}, []string{"Worker memory", workerMemory}, - []string{"Worker storage", workerStorage}, + []string{"Worker plan storage", workerStorage}, ) } rootVolumes := 0 for _, volume := range c.BlockStorages { - if volume.IsRoot { - rootVolumes++ + if !volume.IsRoot { + continue } + rootVolumes++ + rows = append(rows, []string{ + "Root volume " + k8sFirstNonEmpty(volume.Name, volume.Slug, "-"), + k8sFirstNonEmpty(k8sStorageValue("", volume.Size), "-"), + }) } if rootVolumes > 0 { rows = append(rows, []string{"Root volumes", strconv.Itoa(rootVolumes)}) @@ -375,6 +381,7 @@ func newK8sClusterCreateCmd() *cobra.Command { controlPlanePlan string workerPlan string storagePlan string + rootDiskSize int storageCategory string sshKey string authMethod string @@ -385,8 +392,8 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a new Kubernetes cluster", - Example: ` zcp kubernetes create --name my-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul --storage-plan b2g1 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --ssh-key mykey - zcp kubernetes create --name ha-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-4xli-yul --storage-plan b2g1 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --control-nodes 3 --ha --ssh-key mykey`, + Example: ` zcp kubernetes create --name my-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul --storage-plan b2g1 --root-disk-size 100 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --ssh-key mykey + zcp kubernetes create --name ha-cluster --version v1.37.0 --control-plane-plan k8s-cpi-yul --worker-plan k8s-4xli-yul --storage-plan b2g1 --root-disk-size 100 --storage-category pro-nvme --region yul-1 --project default-9 --billing-cycle hourly --workers 3 --control-nodes 3 --ha --ssh-key mykey`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") @@ -403,6 +410,15 @@ func newK8sClusterCreateCmd() *cobra.Command { if storagePlan == "" { return fmt.Errorf("--storage-plan is required") } + if storagePlan == "custom_plan" { + return fmt.Errorf("--storage-plan must name a storage tier; custom_plan is not supported") + } + if !cmd.Flags().Changed("root-disk-size") { + return fmt.Errorf("--root-disk-size is required when --storage-plan is set") + } + if rootDiskSize <= 0 { + return fmt.Errorf("--root-disk-size must be > 0") + } cloudProvider = resolveCloudProvider(cmd, cloudProvider) if cloudProvider == "" { return fmt.Errorf("could not determine cloud provider — run 'zcp auth validate' to detect it, or pass --cloud-provider (see 'zcp cloud-provider list')") @@ -431,33 +447,40 @@ func newK8sClusterCreateCmd() *cobra.Command { if enableHA && controlNodes < 2 { return fmt.Errorf("--control-nodes must be >= 2 when --ha is set") } + fixedPlan := false return runK8sClusterCreate(cmd, kubernetes.CreateRequest{ - Name: name, - Version: version, - NodeSize: nodeSize, - WorkerNodeSize: nodeSize, - ControlNodes: controlNodes, - CloudProvider: cloudProvider, - CloudProviderSetup: cloudProviderSetup, - Region: region, - Project: project, - BillingCycle: billingCycle, - EnableHA: enableHA, - EnableCSI: enableCSI, - Networks: []string{}, - MasterPlan: controlPlanePlan, - WorkerPlan: workerPlan, - BlockstoragePlan: storagePlan, - WithPoolCard: false, - IsCustomPlan: false, - CustomPlan: nil, - VirtualMachine: "", - Coupon: nil, - StorageCategory: storageCategory, - SSHKey: sshKey, - AuthMethod: authMethod, - Username: username, - Password: password, + Name: name, + Version: version, + NodeSize: nodeSize, + WorkerNodeSize: nodeSize, + ControlNodes: controlNodes, + CloudProvider: cloudProvider, + CloudProviderSetup: cloudProviderSetup, + Region: region, + Project: project, + BillingCycle: billingCycle, + EnableHA: enableHA, + EnableCSI: enableCSI, + Networks: []string{}, + MasterPlan: controlPlanePlan, + WorkerPlan: workerPlan, + BlockstoragePlan: storagePlan, + BlockstorageCustomPlan: &kubernetes.BlockstorageCustomPlan{Storage: rootDiskSize}, + IsK8sCustomPlan: &fixedPlan, + MasterCustomPlan: json.RawMessage("null"), + IsK8sMasterCustomPlan: &fixedPlan, + WorkerCustomPlan: json.RawMessage("null"), + IsK8sWorkerCustomPlan: &fixedPlan, + WithPoolCard: false, + IsCustomPlan: false, + CustomPlan: nil, + VirtualMachine: "", + Coupon: nil, + StorageCategory: storageCategory, + SSHKey: sshKey, + AuthMethod: authMethod, + Username: username, + Password: password, }) }, } @@ -475,6 +498,7 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd.Flags().StringVar(&controlPlanePlan, "control-plane-plan", "", "Control-plane node plan slug (required)") cmd.Flags().StringVar(&workerPlan, "worker-plan", "", "Worker node plan slug (required)") cmd.Flags().StringVar(&storagePlan, "storage-plan", "", "Root-volume plan slug applied to each control-plane and worker node (required)") + cmd.Flags().IntVar(&rootDiskSize, "root-disk-size", 0, "Root-volume capacity in GB for the selected storage tier (required)") cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. pro-nvme, nvme, ssd (required)") cmd.Flags().StringVar(&sshKey, "ssh-key", "", "SSH key name") cmd.Flags().StringVar(&authMethod, "auth-method", "ssh-key", "Authentication method: ssh-key or password") @@ -826,28 +850,74 @@ func newK8sClusterDeleteCmd() *cobra.Command { return nil } } - _, client, _, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - svc := kubernetes.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - if err := svc.Delete(ctx, slug); err != nil { - if apierrors.IsResourceNotFound(err) { - fmt.Fprintf(os.Stderr, "Kubernetes cluster %q not found — already deleted.\n", slug) - return nil - } - return fmt.Errorf("kubernetes delete: %w", err) - } - fmt.Fprintf(os.Stdout, "Kubernetes cluster %q deletion requested.\n", slug) - return nil + return runK8sClusterDelete(cmd, slug) }, } cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } +func runK8sClusterDelete(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + cluster, err := kubernetes.NewService(client).Get(ctx, slug) + if err != nil { + if apierrors.IsResourceNotFound(err) { + fmt.Fprintf(os.Stderr, "Kubernetes cluster %q not found — already deleted.\n", slug) + return nil + } + return fmt.Errorf("kubernetes delete: %w", err) + } + + deletePublicIP := true + req := billing.CancelServiceRequest{ + ServiceName: "Kubernetes", + Reason: "not_needed_anymore", + Type: "Immediate", + Status: "Pending", + BillingCycle: k8sCancelBillingCycle(cluster), + DeletePublicIP: &deletePublicIP, + } + if err := billing.NewService(client).CancelService(ctx, cluster.Slug, req); err != nil { + if apierrors.IsResourceNotFound(err) { + fmt.Fprintf(os.Stderr, "Kubernetes cluster %q not found — already deleted.\n", cluster.Slug) + return nil + } + return fmt.Errorf("kubernetes delete: %w", err) + } + + printer.Fprintf("Deletion requested for %q; the Kubernetes cluster is being removed in the background.\n", cluster.Slug) + return nil +} + +func k8sCancelBillingCycle(cluster *kubernetes.Cluster) string { + if cluster == nil { + return "" + } + cycles := make([]*kubernetes.BillingCycle, 0, 2) + if cluster.Offering != nil { + cycles = append(cycles, cluster.Offering.BillingCycle) + } + cycles = append(cycles, cluster.BillingCycle) + for _, cycle := range cycles { + if cycle == nil { + continue + } + for _, value := range []string{cycle.Unit, cycle.Slug, cycle.Name} { + if unit, ok := billingCycleUnit(value); ok { + return unit + } + } + } + return "" +} + func newK8sClusterUpgradeVersionCmd() *cobra.Command { var version string diff --git a/internal/commands/kubernetes_test.go b/internal/commands/kubernetes_test.go index 58203ec..86d35b1 100644 --- a/internal/commands/kubernetes_test.go +++ b/internal/commands/kubernetes_test.go @@ -131,6 +131,7 @@ func TestK8sCreateSendsSeparateResourcePlans(t *testing.T) { "--control-plane-plan", "k8s-cpi-yul", "--worker-plan", "k8s-li-yul", "--storage-plan", "b2g1", + "--root-disk-size", "100", "--storage-category", "pro-nvme", "--cloud-provider", "nimbo", "--region", "yul-1", @@ -151,14 +152,17 @@ func TestK8sCreateSendsSeparateResourcePlans(t *testing.T) { } for field, want := range map[string]interface{}{ - "master_plan": "k8s-cpi-yul", - "worker_plan": "k8s-li-yul", - "blockstorage_plan": "b2g1", - "billing_cycle": "hourly", - "node_size": float64(3), - "worker_node_size": float64(3), - "control_nodes": float64(1), - "enable_csi": true, + "master_plan": "k8s-cpi-yul", + "worker_plan": "k8s-li-yul", + "blockstorage_plan": "b2g1", + "billing_cycle": "hourly", + "node_size": float64(3), + "worker_node_size": float64(3), + "control_nodes": float64(1), + "enable_csi": true, + "is_k8s_custom_plan": false, + "is_k8s_master_custom_plan": false, + "is_k8s_worker_custom_plan": false, } { if got := request[field]; got != want { t.Errorf("%s = %#v, want %#v", field, got, want) @@ -167,6 +171,15 @@ func TestK8sCreateSendsSeparateResourcePlans(t *testing.T) { if _, ok := request["plan"]; ok { t.Error("legacy plan must be omitted from a modern Kubernetes create request") } + rootDisk, ok := request["blockstorage_custom_plan"].(map[string]interface{}) + if !ok || rootDisk["storage"] != float64(100) { + t.Errorf("blockstorage_custom_plan = %#v, want storage=100", request["blockstorage_custom_plan"]) + } + for _, field := range []string{"master_custom_plan", "worker_custom_plan"} { + if value, ok := request[field]; !ok || value != nil { + t.Errorf("%s = %#v, want null", field, value) + } + } } func TestK8sGetShowsSafeResourceOverview(t *testing.T) { @@ -182,8 +195,8 @@ func TestK8sGetShowsSafeResourceOverview(t *testing.T) { "data":{ "slug":"cluster-1","name":"cluster-1","state":"Running","node_size":2,"worker_node_size":2,"control_nodes":1, "meta":{"size":"2","control_nodes":"1","cpu_number":"6","memory":"12288","autoscaling_enabled":true,"min_size":1,"max_size":4,"kubernetes_version_name":"Kubernetes 1.37.0","config":{"configdata":"must-not-print"}}, - "offering":{"master_plan":{"name":"control","attribute":{"formatted_cpu":4,"cpu":4,"formatted_memory":"8 GB","formatted_storage":"100 GB"}},"worker_plan":null,"master_custom_cpu":null,"master_custom_memory":null,"master_custom_storage":null,"worker_custom_cpu":null,"worker_custom_memory":null,"worker_custom_storage":null,"cpu":2,"memory":4096,"storage":100,"formatted_memory":"4 GB","formatted_storage":"100 GB"}, - "blockstorages":[{"name":"root-1","is_root":true},{"name":"root-2","is_root":true},{"name":"data-1","is_root":false}], + "offering":{"master_plan":{"name":"control","attribute":{"formatted_cpu":4,"cpu":4,"formatted_memory":"8 GB","formatted_storage":"0 GB"}},"worker_plan":null,"master_custom_cpu":null,"master_custom_memory":null,"master_custom_storage":null,"worker_custom_cpu":null,"worker_custom_memory":null,"worker_custom_storage":null,"cpu":2,"memory":4096,"storage":0,"formatted_memory":"4 GB","formatted_storage":"0 GB"}, + "blockstorages":[{"name":"root-1","is_root":true,"size":"100"},{"name":"root-2","is_root":true,"size":50},{"name":"root-3","is_root":true},{"name":"data-1","is_root":false,"size":500}], "network":{"name":"cluster-network","slug":"cluster-network"} } }`)) @@ -197,8 +210,8 @@ func TestK8sGetShowsSafeResourceOverview(t *testing.T) { for _, want := range []string{ "Kubernetes 1.37.0", "Total CPU", "6", "Total RAM", "12 GB", "Autoscaling", "Enabled", "Minimum workers", "Maximum workers", - "Custom", "2 Cores", "4 Cores", "4 GB", "100 GB", "Control-plane storage", - "Root volumes", "2", "cluster-network", + "Custom", "2 Cores", "4 Cores", "4 GB", "Control-plane plan storage", "0 GB", + "Root volume root-1", "100 GB", "Root volume root-2", "50 GB", "Root volume root-3", "Root volumes", "3", "cluster-network", } { if !strings.Contains(stdout, want) { t.Errorf("output missing %q:\n%s", want, stdout) @@ -207,6 +220,171 @@ func TestK8sGetShowsSafeResourceOverview(t *testing.T) { if strings.Contains(stdout, "must-not-print") { t.Errorf("output leaks kubeconfig: %q", stdout) } + if strings.Count(stdout, "Root volume ") != 3 { + t.Errorf("root volume rows = %d, want 3:\n%s", strings.Count(stdout, "Root volume "), stdout) + } + missingSize := false + for _, line := range strings.Split(stdout, "\n") { + if strings.Join(strings.Fields(line), " ") == "Root volume root-3 -" { + missingSize = true + break + } + } + if !missingSize { + t.Errorf("missing root-volume capacity must render as -:\n%s", stdout) + } +} + +func TestK8sDeleteSubmitsServiceCancellation(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + var getCount, postCount, deleteCount int + var request map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + getCount++ + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","offering":{"billing_cycle":{"unit":"hour","slug":"monthly","name":"Monthly"}}}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/cluster-1": + postCount++ + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode cancellation request: %v", err) + } + _, _ = w.Write([]byte(`{"status":"Success"}`)) + case r.Method == http.MethodDelete: + deleteCount++ + http.Error(w, "direct delete must not be used", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + stdout, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL) + if err != nil { + t.Fatalf("kubernetes delete: %v", err) + } + if getCount != 1 || postCount != 1 || deleteCount != 0 { + t.Fatalf("requests GET=%d POST=%d DELETE=%d, want 1, 1, 0", getCount, postCount, deleteCount) + } + for field, want := range map[string]any{ + "service_name": "Kubernetes", + "reason": "not_needed_anymore", + "status": "Pending", + "type": "Immediate", + "billing_cycle": "hour", + "delete_public_ip": true, + } { + if got := request[field]; got != want { + t.Errorf("%s = %#v, want %#v", field, got, want) + } + } + if !strings.Contains(stdout, "Deletion requested") || strings.Contains(stdout, "deleted") { + t.Errorf("output = %q, want asynchronous deletion request", stdout) + } +} + +func TestK8sDeleteOmitsUnknownBillingCycle(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + var postCount, deleteCount int + var request map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/cluster-1": + postCount++ + _ = json.NewDecoder(r.Body).Decode(&request) + _, _ = w.Write([]byte(`{"status":"Success"}`)) + case r.Method == http.MethodDelete: + deleteCount++ + http.Error(w, "direct delete must not be used", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + if _, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL); err != nil { + t.Fatalf("kubernetes delete: %v", err) + } + if postCount != 1 || deleteCount != 0 { + t.Fatalf("requests POST=%d DELETE=%d, want 1, 0", postCount, deleteCount) + } + if _, ok := request["billing_cycle"]; ok { + t.Errorf("billing_cycle = %#v, want omitted", request["billing_cycle"]) + } +} + +func TestK8sDeleteStopsWhenGetFails(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + var postCount, deleteCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + http.Error(w, "unavailable", http.StatusInternalServerError) + return + } + if r.Method == http.MethodPost { + postCount++ + } + if r.Method == http.MethodDelete { + deleteCount++ + } + http.NotFound(w, r) + })) + defer srv.Close() + + if _, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL); err == nil { + t.Fatal("kubernetes delete succeeded after cluster lookup failed") + } + if postCount != 0 || deleteCount != 0 { + t.Errorf("requests POST=%d DELETE=%d, want 0, 0", postCount, deleteCount) + } +} + +func TestK8sDeleteNotFoundIsIdempotent(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + var postCount, deleteCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method == http.MethodPost { + postCount++ + } + if r.Method == http.MethodDelete { + deleteCount++ + } + http.NotFound(w, r) + })) + defer srv.Close() + + if _, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL); err != nil { + t.Fatalf("kubernetes delete: %v", err) + } + if postCount != 0 || deleteCount != 0 { + t.Errorf("requests POST=%d DELETE=%d, want 0, 0", postCount, deleteCount) + } +} + +func TestK8sCancelBillingCycle(t *testing.T) { + tests := []struct { + name string + cluster kubernetes.Cluster + want string + }{ + {name: "offering unit wins", cluster: kubernetes.Cluster{BillingCycle: &kubernetes.BillingCycle{Unit: "month"}, Offering: &kubernetes.Offering{BillingCycle: &kubernetes.BillingCycle{Unit: "hour"}}}, want: "hour"}, + {name: "top level slug", cluster: kubernetes.Cluster{BillingCycle: &kubernetes.BillingCycle{Slug: "monthly"}}, want: "month"}, + {name: "top level name", cluster: kubernetes.Cluster{BillingCycle: &kubernetes.BillingCycle{Name: "Hourly"}}, want: "hour"}, + {name: "unknown omitted", cluster: kubernetes.Cluster{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := k8sCancelBillingCycle(&tt.cluster); got != tt.want { + t.Errorf("k8sCancelBillingCycle() = %q, want %q", got, tt.want) + } + }) + } } func TestK8sGetShowsUnknownAutoscalingWhenOmitted(t *testing.T) { @@ -366,6 +544,7 @@ func TestK8sCreateRequiresSeparateResourcePlans(t *testing.T) { "--project", "test", "--billing-cycle", "hourly", "--workers", "1", + "--root-disk-size", "100", "--storage-category", "pro-nvme", "--ssh-key", "mykey", } @@ -388,3 +567,45 @@ func TestK8sCreateRequiresSeparateResourcePlans(t *testing.T) { }) } } + +func TestK8sCreateRequiresNamedRootStorageAndCapacity(t *testing.T) { + baseArgs := []string{ + "kubernetes", "create", + "--name", "test-cluster", + "--version", "v1.36.1", + "--control-plane-plan", "k8s-control-1", + "--worker-plan", "k8s-worker-1", + "--cloud-provider", "nimbo", + "--region", "yul-1", + "--project", "test", + "--billing-cycle", "hourly", + "--workers", "1", + "--storage-category", "pro-nvme", + "--ssh-key", "mykey", + } + tests := []struct { + name string + args []string + want string + }{ + {name: "missing capacity", args: []string{"--storage-plan", "b2g1"}, want: "--root-disk-size is required"}, + {name: "zero capacity", args: []string{"--storage-plan", "b2g1", "--root-disk-size", "0"}, want: "--root-disk-size must be > 0"}, + {name: "negative capacity", args: []string{"--storage-plan", "b2g1", "--root-disk-size", "-1"}, want: "--root-disk-size must be > 0"}, + {name: "unnamed tier", args: []string{"--storage-plan", "custom_plan", "--root-disk-size", "100"}, want: "must name a storage tier"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newTestRoot() + root.AddCommand(NewKubernetesCmd()) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append(baseArgs, tt.args...)) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} diff --git a/internal/commands/plan.go b/internal/commands/plan.go index 28dc44a..5be71ef 100644 --- a/internal/commands/plan.go +++ b/internal/commands/plan.go @@ -103,6 +103,9 @@ func newPlanVMCmd() *cobra.Command { return &cobra.Command{ Use: "vm", Short: "List Virtual Machine plans", + Long: `List Virtual Machine plans. + +For compute-only plans, root disks and network charges are separate.`, Example: ` zcp plan vm --region yul-1 zcp plan vm --region yul-1 --output json`, RunE: func(cmd *cobra.Command, args []string) error { @@ -123,13 +126,14 @@ func newPlanVMCmd() *cobra.Command { return fmt.Errorf("plan vm: %w", err) } - headers := []string{"ID", "SLUG", "NAME", "CPU", "MEMORY", "STORAGE", "HOURLY", "MONTHLY", "ACTIVE"} + headers := []string{"ID", "SLUG", "NAME", "TAG", "CPU", "MEMORY", "STORAGE", "HOURLY", "MONTHLY", "ACTIVE"} rows := make([][]string, 0, len(plans)) for _, p := range plans { rows = append(rows, []string{ p.ID, p.Slug, p.Name, + p.ParsedTag(), p.Attribute.FormattedCPU.String(), p.Attribute.FormattedMemory, p.Attribute.FormattedStorage, diff --git a/internal/commands/plan_test.go b/internal/commands/plan_test.go new file mode 100644 index 0000000..5abfc64 --- /dev/null +++ b/internal/commands/plan_test.go @@ -0,0 +1,45 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPlanVMDisplaysTag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/plans/service/Virtual Machine" { + http.NotFound(w, r) + return + } + fmt.Fprint(w, `{"status":"Success","data":[{"id":"plan-1","slug":"compute-small","name":"Compute Small","attribute":{"formatted_cpu":1,"formatted_memory":"1 GB"},"tag":{"tag":"Recommended"},"status":true}]}`) + })) + defer srv.Close() + + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + root := newTestRoot() + root.PersistentFlags().String("region", "", "") + root.AddCommand(newPlanVMCmd()) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"vm", "--region", "test-region", "--api-url", srv.URL, "--output", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("plan vm error = %v", err) + } + + output := stdout.String() + + var rows []map[string]string + if err := json.Unmarshal([]byte(output), &rows); err != nil { + t.Fatalf("decode output: %v\noutput: %s", err, output) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if got := rows[0]["tag"]; got != "Recommended" { + t.Errorf("tag = %q, want %q", got, "Recommended") + } +} diff --git a/pkg/api/instance/instance.go b/pkg/api/instance/instance.go index 896f0ef..4efa844 100644 --- a/pkg/api/instance/instance.go +++ b/pkg/api/instance/instance.go @@ -254,39 +254,45 @@ type Addon struct { // CreateRequest holds parameters for creating a VM via STKCNSL. type CreateRequest struct { - Name string `json:"name"` - CloudProvider string `json:"cloud_provider"` - Project string `json:"project"` - Region string `json:"region"` - BootSource string `json:"boot_source"` - Server string `json:"server,omitempty"` - Template string `json:"template"` - IsPublic bool `json:"is_public"` - NetworkType string `json:"network_type"` - Networks []string `json:"networks"` - BillingCycle string `json:"billing_cycle"` - SSHKey *string `json:"ssh_key"` - AuthMethod string `json:"authMethod,omitempty"` - Plan string `json:"plan"` - CustomPlan *CustomPlan `json:"custom_plan"` - OSFamily string `json:"os_family,omitempty"` - TemplateType string `json:"template_type,omitempty"` - Hostname string `json:"hostname"` - Username string `json:"username,omitempty"` - Password *string `json:"password"` - Coupon *string `json:"coupon"` - Addons []string `json:"addons"` - UserData *string `json:"user_data"` - StorageCategory string `json:"storage_category,omitempty"` - ComputeCategory string `json:"compute_category,omitempty"` - BlockstoragePlan string `json:"blockstorage_plan,omitempty"` - NetworkPlan string `json:"network_plan,omitempty"` - VrPlan string `json:"vr_plan,omitempty"` - DefaultNetwork string `json:"default_network,omitempty"` - IsVNF bool `json:"is_vnf"` - IsVMPasswordRequired bool `json:"is_vm_password_required"` - IsVMSSHRequired bool `json:"is_vm_ssh_required"` - IsFreeTrial bool `json:"is_free_trial_plan"` + Name string `json:"name"` + CloudProvider string `json:"cloud_provider"` + Project string `json:"project"` + Region string `json:"region"` + BootSource string `json:"boot_source"` + Server string `json:"server,omitempty"` + Template string `json:"template"` + IsPublic bool `json:"is_public"` + NetworkType string `json:"network_type"` + Networks []string `json:"networks"` + BillingCycle string `json:"billing_cycle"` + SSHKey *string `json:"ssh_key"` + AuthMethod string `json:"authMethod,omitempty"` + Plan string `json:"plan"` + CustomPlan *CustomPlan `json:"custom_plan"` + OSFamily string `json:"os_family,omitempty"` + TemplateType string `json:"template_type,omitempty"` + Hostname string `json:"hostname"` + Username string `json:"username,omitempty"` + Password *string `json:"password"` + Coupon *string `json:"coupon"` + Addons []string `json:"addons"` + UserData *string `json:"user_data"` + StorageCategory string `json:"storage_category,omitempty"` + ComputeCategory string `json:"compute_category,omitempty"` + BlockstoragePlan string `json:"blockstorage_plan,omitempty"` + BlockstorageCustomPlan *BlockstorageCustomPlan `json:"blockstorage_custom_plan,omitempty"` + NetworkPlan string `json:"network_plan,omitempty"` + VrPlan string `json:"vr_plan,omitempty"` + DefaultNetwork string `json:"default_network,omitempty"` + IsVNF bool `json:"is_vnf"` + IsVMPasswordRequired bool `json:"is_vm_password_required"` + IsVMSSHRequired bool `json:"is_vm_ssh_required"` + IsFreeTrial bool `json:"is_free_trial_plan"` +} + +// BlockstorageCustomPlan holds the root-disk capacity in GB for a named storage tier. +type BlockstorageCustomPlan struct { + Storage int `json:"storage"` } func (r CreateRequest) MarshalJSON() ([]byte, error) { diff --git a/pkg/api/instance/instance_test.go b/pkg/api/instance/instance_test.go index 78e3601..72e6128 100644 --- a/pkg/api/instance/instance_test.go +++ b/pkg/api/instance/instance_test.go @@ -49,13 +49,15 @@ func TestCreate(t *testing.T) { svc := instance.NewService(newClient(srv.URL)) req := instance.CreateRequest{ - Name: "test-vm", - BillingCycle: "hourly", - IsPublic: true, - NetworkType: "Vpc", - Networks: []string{"Vpc-1", "Vpc-2"}, - VrPlan: "vpc-net", - DefaultNetwork: "Vpc-1", + Name: "test-vm", + BillingCycle: "hourly", + IsPublic: true, + NetworkType: "Vpc", + Networks: []string{"Vpc-1", "Vpc-2"}, + VrPlan: "vpc-net", + DefaultNetwork: "Vpc-1", + BlockstoragePlan: "b2g1", + BlockstorageCustomPlan: &instance.BlockstorageCustomPlan{Storage: 100}, } vm, err := svc.Create(context.Background(), req) if err != nil { @@ -89,6 +91,24 @@ func TestCreate(t *testing.T) { if gotBody["default_network"] != "Vpc-1" { t.Errorf("default_network = %v, want %q", gotBody["default_network"], "Vpc-1") } + rootDisk, ok := gotBody["blockstorage_custom_plan"].(map[string]interface{}) + if !ok || rootDisk["storage"] != float64(100) { + t.Errorf("blockstorage_custom_plan = %#v, want storage=100", gotBody["blockstorage_custom_plan"]) + } +} + +func TestCreateOmitsRootDiskCapacityWhenUnset(t *testing.T) { + b, err := json.Marshal(instance.CreateRequest{Name: "test-vm"}) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var body map[string]any + if err := json.Unmarshal(b, &body); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if _, ok := body["blockstorage_custom_plan"]; ok { + t.Errorf("payload includes unset blockstorage_custom_plan = %#v", body["blockstorage_custom_plan"]) + } } func TestList(t *testing.T) { diff --git a/pkg/api/kubernetes/kubernetes.go b/pkg/api/kubernetes/kubernetes.go index 5c67e88..46f02ff 100644 --- a/pkg/api/kubernetes/kubernetes.go +++ b/pkg/api/kubernetes/kubernetes.go @@ -55,6 +55,7 @@ type NodePlan struct { // Offering describes the plans and custom resource values used by a Kubernetes cluster. type Offering struct { + BillingCycle *BillingCycle `json:"billing_cycle,omitempty"` CPU json.RawMessage `json:"cpu"` Memory json.RawMessage `json:"memory"` Storage json.RawMessage `json:"storage"` @@ -80,11 +81,12 @@ type Network struct { Slug string `json:"slug"` } -// BlockStorage holds the safe volume identity returned with a cluster. +// BlockStorage holds a volume identity and capacity returned with a cluster. type BlockStorage struct { - Name string `json:"name"` - Slug string `json:"slug"` - IsRoot bool `json:"is_root"` + Name string `json:"name"` + Slug string `json:"slug"` + IsRoot bool `json:"is_root"` + Size json.RawMessage `json:"size"` } // Autoscale marks a cluster with autoscaling enabled in the portal response. @@ -174,20 +176,31 @@ type CreateRequest struct { // Plan is retained for API compatibility with older Kubernetes cluster // creation flows. ZCP Kubernetes clusters use MasterPlan and // WorkerPlan instead. - Plan string `json:"plan,omitempty"` - MasterPlan string `json:"master_plan,omitempty"` - WorkerPlan string `json:"worker_plan,omitempty"` - BlockstoragePlan string `json:"blockstorage_plan,omitempty"` - WithPoolCard bool `json:"with_pool_card"` - IsCustomPlan bool `json:"is_custom_plan"` - CustomPlan interface{} `json:"custom_plan"` - VirtualMachine string `json:"virtual_machine"` - Coupon *string `json:"coupon"` - StorageCategory string `json:"storage_category"` - SSHKey string `json:"ssh_key"` - AuthMethod string `json:"authMethod"` - Username string `json:"username"` - Password string `json:"password"` + Plan string `json:"plan,omitempty"` + MasterPlan string `json:"master_plan,omitempty"` + WorkerPlan string `json:"worker_plan,omitempty"` + BlockstoragePlan string `json:"blockstorage_plan,omitempty"` + BlockstorageCustomPlan *BlockstorageCustomPlan `json:"blockstorage_custom_plan,omitempty"` + IsK8sCustomPlan *bool `json:"is_k8s_custom_plan,omitempty"` + MasterCustomPlan json.RawMessage `json:"master_custom_plan,omitempty"` + IsK8sMasterCustomPlan *bool `json:"is_k8s_master_custom_plan,omitempty"` + WorkerCustomPlan json.RawMessage `json:"worker_custom_plan,omitempty"` + IsK8sWorkerCustomPlan *bool `json:"is_k8s_worker_custom_plan,omitempty"` + WithPoolCard bool `json:"with_pool_card"` + IsCustomPlan bool `json:"is_custom_plan"` + CustomPlan interface{} `json:"custom_plan"` + VirtualMachine string `json:"virtual_machine"` + Coupon *string `json:"coupon"` + StorageCategory string `json:"storage_category"` + SSHKey string `json:"ssh_key"` + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password"` +} + +// BlockstorageCustomPlan holds the root-volume capacity in GB for a named storage tier. +type BlockstorageCustomPlan struct { + Storage int `json:"storage"` } // UpgradeRequest holds parameters for upgrading (changing plan of) a Kubernetes cluster. diff --git a/pkg/api/kubernetes/kubernetes_test.go b/pkg/api/kubernetes/kubernetes_test.go index be18e75..6e50c44 100644 --- a/pkg/api/kubernetes/kubernetes_test.go +++ b/pkg/api/kubernetes/kubernetes_test.go @@ -165,23 +165,24 @@ func TestKubernetesCreate(t *testing.T) { svc := kubernetes.NewService(newTestClient(srv)) req := kubernetes.CreateRequest{ - Name: "test-cluster", - Version: "v1.28.4", - NodeSize: 3, - ControlNodes: 1, - CloudProvider: "nimbo", - Region: "noida", - Project: "default-59", - BillingCycle: "monthly", - EnableHA: false, - EnableCSI: true, - Networks: []string{}, - Plan: "k8s-plan-1", - MasterPlan: "k8s-control-1", - WorkerPlan: "k8s-worker-1", - BlockstoragePlan: "k8s-root-volume-1", - SSHKey: "mykey", - AuthMethod: "ssh-key", + Name: "test-cluster", + Version: "v1.28.4", + NodeSize: 3, + ControlNodes: 1, + CloudProvider: "nimbo", + Region: "noida", + Project: "default-59", + BillingCycle: "monthly", + EnableHA: false, + EnableCSI: true, + Networks: []string{}, + Plan: "k8s-plan-1", + MasterPlan: "k8s-control-1", + WorkerPlan: "k8s-worker-1", + BlockstoragePlan: "k8s-root-volume-1", + BlockstorageCustomPlan: &kubernetes.BlockstorageCustomPlan{Storage: 100}, + SSHKey: "mykey", + AuthMethod: "ssh-key", } cluster, err := svc.Create(context.Background(), req) if err != nil { @@ -218,6 +219,10 @@ func TestKubernetesCreate(t *testing.T) { if gotBody["blockstorage_plan"] != "k8s-root-volume-1" { t.Errorf("body blockstorage_plan = %v, want %q", gotBody["blockstorage_plan"], "k8s-root-volume-1") } + rootDisk, ok := gotBody["blockstorage_custom_plan"].(map[string]interface{}) + if !ok || rootDisk["storage"] != float64(100) { + t.Errorf("body blockstorage_custom_plan = %#v, want storage=100", gotBody["blockstorage_custom_plan"]) + } if gotBody["enable_csi"] != true { t.Errorf("body enable_csi = %v, want true", gotBody["enable_csi"]) } @@ -247,22 +252,28 @@ func TestKubernetesCreateModernPlanPayloadOmitsLegacyPlan(t *testing.T) { svc := kubernetes.NewService(newTestClient(srv)) _, err := svc.Create(context.Background(), kubernetes.CreateRequest{ - Name: "test-cluster", - Version: "v1.36.1", - NodeSize: 3, - WorkerNodeSize: 3, - ControlNodes: 1, - CloudProvider: "nimbo", - Region: "yul-1", - Project: "test", - BillingCycle: "hourly", - Networks: []string{}, - MasterPlan: "k8s-cpi-yul", - WorkerPlan: "k8s-li-yul", - BlockstoragePlan: "b2g1", - StorageCategory: "pro-nvme", - SSHKey: "mykey", - AuthMethod: "ssh-key", + Name: "test-cluster", + Version: "v1.36.1", + NodeSize: 3, + WorkerNodeSize: 3, + ControlNodes: 1, + CloudProvider: "nimbo", + Region: "yul-1", + Project: "test", + BillingCycle: "hourly", + Networks: []string{}, + MasterPlan: "k8s-cpi-yul", + WorkerPlan: "k8s-li-yul", + BlockstoragePlan: "b2g1", + BlockstorageCustomPlan: &kubernetes.BlockstorageCustomPlan{Storage: 100}, + IsK8sCustomPlan: boolPointer(false), + MasterCustomPlan: json.RawMessage("null"), + IsK8sMasterCustomPlan: boolPointer(false), + WorkerCustomPlan: json.RawMessage("null"), + IsK8sWorkerCustomPlan: boolPointer(false), + StorageCategory: "pro-nvme", + SSHKey: "mykey", + AuthMethod: "ssh-key", }) if err != nil { t.Fatalf("Create() error = %v", err) @@ -279,6 +290,53 @@ func TestKubernetesCreateModernPlanPayloadOmitsLegacyPlan(t *testing.T) { if gotBody["blockstorage_plan"] != "b2g1" { t.Errorf("body blockstorage_plan = %v, want %q", gotBody["blockstorage_plan"], "b2g1") } + rootDisk, ok := gotBody["blockstorage_custom_plan"].(map[string]interface{}) + if !ok || rootDisk["storage"] != float64(100) { + t.Errorf("body blockstorage_custom_plan = %#v, want storage=100", gotBody["blockstorage_custom_plan"]) + } + for field, want := range map[string]bool{ + "is_k8s_custom_plan": false, + "is_k8s_master_custom_plan": false, + "is_k8s_worker_custom_plan": false, + } { + if got, ok := gotBody[field].(bool); !ok || got != want { + t.Errorf("body %s = %#v, want %#v", field, gotBody[field], want) + } + } + for _, field := range []string{"master_custom_plan", "worker_custom_plan"} { + if value, ok := gotBody[field]; !ok || value != nil { + t.Errorf("body %s = %#v, want null", field, value) + } + } +} + +func TestKubernetesCreateOmitsRootDiskCapacityWhenUnset(t *testing.T) { + b, err := json.Marshal(kubernetes.CreateRequest{Name: "test-cluster"}) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var body map[string]any + if err := json.Unmarshal(b, &body); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if _, ok := body["blockstorage_custom_plan"]; ok { + t.Errorf("payload includes unset blockstorage_custom_plan = %#v", body["blockstorage_custom_plan"]) + } + for _, field := range []string{ + "is_k8s_custom_plan", + "master_custom_plan", + "is_k8s_master_custom_plan", + "worker_custom_plan", + "is_k8s_worker_custom_plan", + } { + if value, ok := body[field]; ok { + t.Errorf("payload includes unset %s = %#v", field, value) + } + } +} + +func boolPointer(value bool) *bool { + return &value } func TestKubernetesStart(t *testing.T) { diff --git a/pkg/api/plan/plan.go b/pkg/api/plan/plan.go index 885702e..69a26dc 100644 --- a/pkg/api/plan/plan.go +++ b/pkg/api/plan/plan.go @@ -93,6 +93,7 @@ type Attribute struct { // Tag holds optional marketing label data. type Tag struct { Label string `json:"label"` + Tag string `json:"tag"` Value string `json:"value"` Color string `json:"color"` } @@ -143,16 +144,21 @@ type Plan struct { UpdatedAt string `json:"updated_at"` } -// ParsedTag returns the tag label if present, or "-" if the tag field is an -// empty array or missing. +// ParsedTag returns the tag string, falling back to the legacy label, or "-" +// if the tag field is an empty array or missing. func (p *Plan) ParsedTag() string { if len(p.Tag) == 0 { return "-" } // Try object first var t Tag - if err := json.Unmarshal(p.Tag, &t); err == nil && t.Label != "" { - return t.Label + if err := json.Unmarshal(p.Tag, &t); err == nil { + if t.Tag != "" { + return t.Tag + } + if t.Label != "" { + return t.Label + } } return "-" } diff --git a/pkg/api/plan/plan_test.go b/pkg/api/plan/plan_test.go index d00e736..f1e6707 100644 --- a/pkg/api/plan/plan_test.go +++ b/pkg/api/plan/plan_test.go @@ -173,7 +173,9 @@ func TestParsedTag(t *testing.T) { want string }{ {"empty array", `[]`, "-"}, + {"object with tag", `{"tag":"Recommended"}`, "Recommended"}, {"object with label", `{"label":"Recommended","value":"Recommended","color":"red"}`, "Recommended"}, + {"object with tag and label", `{"tag":"Recommended","label":"Legacy"}`, "Recommended"}, {"empty object", `{}`, "-"}, {"null-like", ``, "-"}, } diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 5236a4b..6d9fb9d 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -73,6 +73,7 @@ ZCP_SMOKE_PROJECT project slug (default: first project ZCP_SMOKE_TEMPLATE region-scoped template slug (default: an Ubuntu image in region) ZCP_SMOKE_VM_PLAN VM plan slug (default: cheapest active plan) ZCP_SMOKE_BLOCKSTORAGE_PLAN block storage plan slug +ZCP_SMOKE_ROOT_DISK_SIZE root disk capacity in GB for lifecycle fixtures (default: 100) ZCP_SMOKE_IP_PLAN IP plan slug ZCP_SMOKE_NETWORK_PLAN network/internet plan slug (default: inet-) ZCP_SMOKE_STORAGE_CAT storage category slug (default: pro-nvme) @@ -80,6 +81,10 @@ ZCP_SMOKE_BILLING_CYCLE billing cycle slug (default: hourly) ZCP_SMOKE_LIFECYCLE=1 same as passing --lifecycle ``` +The 100 GB fallback applies only to disposable smoke fixtures. It is not a +default in `zcp instance create` or `zcp kubernetes create`; both commands +require an explicit root-disk capacity. + > **Slug gotcha:** create endpoints want the _slug_, not the display name > (`ca2s`, not `ca2.s`). Since v0.0.16 every `zcp plan ` table includes a > SLUG column. Templates/plans are also diff --git a/tests/smoke/cases.sh b/tests/smoke/cases.sh index 3e70678..5ac41db 100755 --- a/tests/smoke/cases.sh +++ b/tests/smoke/cases.sh @@ -179,11 +179,12 @@ fx_ip() { # IP). Waits up to ~3min for Running. Its SourceNAT IP is cached in FX_VMIP. fx_vm() { [[ -n "$FX_VM" ]] && return 0 - local name out; name="$(rname vm)" + local name out root_disk_size; name="$(rname vm)"; root_disk_size="$(det_root_disk_size)" + [[ -z "$root_disk_size" ]] && { echo "[fx_vm] set ZCP_SMOKE_ROOT_DISK_SIZE to create a root disk"; return 1; } capture out -- zcp instance create --name "$name" \ --cloud-provider "$(det_cp)" --project "$(det_project)" --region "$(det_region)" \ --template "$(det_template)" --plan "$(det_vm_plan)" \ - --storage-category "$(det_storage_cat)" --blockstorage-plan "$(det_blockstorage_plan)" \ + --storage-category "$(det_storage_cat)" --blockstorage-plan "$(det_blockstorage_plan)" --root-disk-size "$root_disk_size" \ --network-plan "$(det_network_plan)" --billing-cycle "$(det_billing_cycle)" -y -o json \ || { echo "[fx_vm] instance create failed: ${out:0:200}"; return 1; } FX_VM="$(_jq_slug <<<"$out")" @@ -538,7 +539,7 @@ lc_iso() { } lc_kubernetes() { - local out plans storage_plans ver master_plan worker_plan storage_plan storage_category setup + local out plans storage_plans ver master_plan worker_plan storage_plan storage_category setup root_disk_size name s matches rc fx_sshkey || { _mark_skip "kubernetes (could not create SSH-key fixture)"; return; } ver="$(api_get '/kubernetes-clusters/versions' | jq -r --arg rid "$(det_region_id)" '.data[]|select(.region_id==$rid and (.version // "") != "")|.version' | head -1)" plans="$(api_get "/plans/service/Kubernetes?filter%5Bregion%5D=$(det_region)")" @@ -548,11 +549,13 @@ lc_kubernetes() { storage_plans="$(api_get "/plans/service/Block%20Storage?filter%5Bregion%5D=$(det_region)&filter%5Bstorage_category%5D=$storage_category")" storage_plan="$(jq -r '.data[]|.slug' <<<"$storage_plans" | head -1)" setup="$(api_get '/regions' | jq -r --arg s "$(det_region)" '.data[]|select(.slug==$s)|.cloud_provider_setup.slug' | head -1)" - [[ -z "$ver" || -z "$master_plan" || -z "$worker_plan" || -z "$storage_plan" || -z "$storage_category" ]] && { _mark_skip "kubernetes (no compatible control-plane, worker, and storage plans for region)"; return; } - out="$(zcp kubernetes create --name "$(rname k8s)" --version "$ver" \ - --control-plane-plan "$master_plan" --worker-plan "$worker_plan" --storage-plan "$storage_plan" --storage-category "$storage_category" \ + root_disk_size="$(det_root_disk_size)" + [[ -z "$ver" || -z "$master_plan" || -z "$worker_plan" || -z "$storage_plan" || -z "$storage_category" || -z "$root_disk_size" ]] && { _mark_skip "kubernetes (no compatible control-plane, worker, storage plans, and root disk capacity for region)"; return; } + name="$(rname k8s)" + out="$(zcp kubernetes create --name "$name" --version "$ver" \ + --control-plane-plan "$master_plan" --worker-plan "$worker_plan" --storage-plan "$storage_plan" --root-disk-size "$root_disk_size" --storage-category "$storage_category" \ --region "$(det_region)" --project "$(det_project)" --cloud-provider "$(det_cp)" \ - --billing-cycle "$(det_billing_cycle)" --workers 1 --ssh-key "$FX_SSHKEY_NAME" --cloud-provider-setup "${setup:-zcp-apc}" -y 2>&1)"; local rc=$? + --billing-cycle "$(det_billing_cycle)" --workers 1 --ssh-key "$FX_SSHKEY_NAME" --cloud-provider-setup "${setup:-zcp-apc}" -y -o json 2>&1)"; rc=$? if [[ $rc -ne 0 ]]; then if grep -qiE 'quota not found' <<<"$out"; then _mark_skip "kubernetes (account has no k8s quota — env limitation)" @@ -560,8 +563,16 @@ lc_kubernetes() { _mark_fail "kubernetes create"; sed -n '1,2p' <<<"$out" | sed 's/^/ /' fi else - local s; s="$(zcp kubernetes list -o json 2>/dev/null | jq -r '(.[]//.data[])|.slug' | head -1)" - _mark_pass "kubernetes create"; [[ -n "$s" ]] && defer cancel "$s" "Kubernetes" + s="$(_jq_slug <<<"$out")" + if [[ -z "$s" ]]; then + matches="$(zcp kubernetes list -o json 2>/dev/null | jq -r --arg n "$name" '(.[]//.data[])|select(.name==$n)|.slug' | sort -u)" + if [[ $(wc -l <<<"$matches") -ne 1 || -z "$matches" ]]; then + _mark_fail "kubernetes create returned no unique owned slug" + return + fi + s="$matches" + fi + _mark_pass "kubernetes create"; defer cancel "$s" "Kubernetes" fi } diff --git a/tests/smoke/cases_test.sh b/tests/smoke/cases_test.sh new file mode 100644 index 0000000..666784d --- /dev/null +++ b/tests/smoke/cases_test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cases.sh" + +rname() { printf 'owned-k8s'; } +fx_sshkey() { FX_SSHKEY_NAME='fixture-key'; } +det_region() { printf 'yul-1'; } +det_region_id() { printf 'region-id'; } +det_project() { printf 'project'; } +det_cp() { printf 'provider'; } +det_billing_cycle() { printf 'hourly'; } +det_root_disk_size() { printf '100'; } + +api_get() { + case "$1" in + /kubernetes-clusters/versions) + printf '%s' '{"data":[{"region_id":"region-id","version":"v1.37.0"}]}' + ;; + /plans/service/Kubernetes*) + printf '%s' '{"data":[{"slug":"worker","attribute":{"package_for":"worker"},"storage_category":{"slug":"pro-nvme"}},{"slug":"control","attribute":{"package_for":"master"},"storage_category":{"slug":"pro-nvme"}}]}' + ;; + /plans/service/Block*) + printf '%s' '{"data":[{"slug":"b2g1"}]}' + ;; + /regions) + printf '%s' '{"data":[{"slug":"yul-1","cloud_provider_setup":{"slug":"setup"}}]}' + ;; + esac +} + +zcp() { + if [[ "$1" == kubernetes && "$2" == create ]]; then + printf '%s' '{"data":{}}' + return + fi + if [[ "$1" == kubernetes && "$2" == list ]]; then + printf '%s' '[{"name":"existing-cluster","slug":"existing-cluster"},{"name":"owned-k8s","slug":"owned-cluster"}]' + return + fi + return 1 +} + +lc_kubernetes + +[[ ${#CLEANUP_STACK[@]} -eq 1 ]] +[[ "${CLEANUP_STACK[0]}" == 'cancel|owned-cluster|Kubernetes' ]] diff --git a/tests/smoke/lib.sh b/tests/smoke/lib.sh index e2a4028..765256a 100755 --- a/tests/smoke/lib.sh +++ b/tests/smoke/lib.sh @@ -234,8 +234,12 @@ det_vm_plan() { } det_blockstorage_plan() { if [[ -n "${ZCP_SMOKE_BLOCKSTORAGE_PLAN:-}" ]]; then printf '%s' "$ZCP_SMOKE_BLOCKSTORAGE_PLAN"; return; fi - api_get "/plans/service/Block%20Storage?region=$(det_region)" | jq -r '.data[0].slug // .data[0].name' | head -1 + api_get "/plans/service/Block%20Storage?filter%5Bregion%5D=$(det_region)&filter%5Bstorage_category%5D=$(det_storage_cat)" \ + | jq -r '.data[0].slug // empty' | head -1 } +# The smoke suite uses 100 GB only as its disposable fixture capacity. It is +# not a CLI default; instance and Kubernetes create always require the flag. +det_root_disk_size() { printf '%s' "${ZCP_SMOKE_ROOT_DISK_SIZE:-100}"; } det_router_plan() { api_get "/plans/service/Virtual%20Router?region=$(det_region)" | jq -r '.data[0].slug // .data[0].name' | head -1 } From 1a243f8fb90af92b93f1386dea8cdcc92a3140b1 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sun, 11 Oct 2026 10:02:11 -0400 Subject: [PATCH 4/5] feat: update compute, Kubernetes, and backup commands Signed-off-by: ditahkk --- CHANGELOG.md | 5 + RELEASE_NOTES.md | 18 ++- docs/command-taxonomy.md | 1 + docs/commands.md | 12 +- internal/commands/commands_test.go | 74 ++++++++++ internal/commands/dns_status_test.go | 17 ++- internal/commands/instance.go | 27 +++- internal/commands/kubernetes.go | 49 ++++++- internal/commands/kubernetes_test.go | 153 +++++++++++++++++++- internal/commands/vmbackup.go | 113 +++++++++++++++ internal/commands/vmbackup_restore_test.go | 161 +++++++++++++++++++++ internal/commands/vmbackupschedule.go | 10 +- internal/commands/vmbackupschedule_test.go | 31 +++- pkg/api/billing/billing.go | 6 + pkg/api/billing/billing_test.go | 26 ++++ pkg/api/kubernetes/kubernetes.go | 22 +++ pkg/api/kubernetes/kubernetes_test.go | 25 ++++ pkg/api/scheduler/scheduler.go | 12 +- pkg/api/scheduler/scheduler_test.go | 35 ++++- pkg/api/vmbackup/vmbackup.go | 77 ++++++++++ pkg/api/vmbackup/vmbackup_test.go | 51 +++++++ tests/smoke/cases.sh | 3 +- tests/smoke/lib.sh | 2 +- 23 files changed, 889 insertions(+), 41 deletions(-) create mode 100644 internal/commands/vmbackup_restore_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a1bc7..c2326ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ### Added +- **Safe VM backup restore.** `zcp vm-backup restore ` verifies that the VM is stopped and that the selected backup is ready, belongs to that VM, and is in the selected region and project before it requests a restore. The command does not stop the VM automatically. It reports when the restore request is accepted, not when the restore is complete. - **Kubernetes create uses separate control-plane, worker, and root-volume plans.** `zcp kubernetes create` now requires `--control-plane-plan`, `--worker-plan`, and `--storage-plan`, alongside `--storage-category`. The command sends the ZCP API `master_plan`, `worker_plan`, and `blockstorage_plan` fields instead of a single cluster plan. The root-volume plan applies to every node. The selected billing cycle applies to the resulting resource subscriptions: every control-plane and worker VM, and every root volume. The cluster object itself is not billed. Network, load balancer, and public-IP billing remains conditional on enabled platform packages and feature flags. Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. `zcp kubernetes get` now shows resource totals and configuration. `zcp kubernetes scale` enables autoscaling with minimum and maximum worker counts or disables it with a fixed worker count. - **Scheduler-backed VM backup policies.** `zcp vm-backup schedule` creates, lists, reads, updates, pauses, resumes, runs, and deletes VM backup policies. Policies support hourly, daily, weekly, and monthly intervals, IANA timezones, retention counts, names, and descriptions. Weekly policies select a weekday; monthly policies select a day of the month. Policies can request an immediate first backup. - **`everyOtherDay` policy warning.** The command exposes the API's `everyOtherDay` value. The current platform returns an error after persisting that policy, so the command directs users to list policies before retrying. ### Fixed +- Kubernetes output now shows `-` for high availability when the API does not report its state. +- The client now escapes VM backup schedule IDs in API paths. Schedule identifiers containing reserved URL characters now reach the intended scheduler action route. - **`vm-backup create` now reports the created schedule slug.** It prints the created schedule in table output and emits valid JSON or YAML for structured output. When the create endpoint returns only an acknowledgement, the command identifies the new schedule from a scoped pre-create snapshot and bounded read-only lookup. It never repeats an accepted create request. If the command cannot identify the schedule, the error tells you to list it before creating another one. _Fixes #76._ - **`kubernetes delete` now submits an immediate service-cancellation request.** The command reads the cluster first, uses its reported billing cycle when available, and reports that deletion was requested rather than completed. +- **Service-cancellation commands now reject API error envelopes.** A cancellation response with an API status other than `Success`, including one returned with HTTP 200, now returns the server error instead of reporting the request as accepted. +- **Instance and Kubernetes deletion commands now check the current lifecycle state before sending a cancellation request.** Both refuse deletion when the current state is unavailable or Starting. Kubernetes deletion also refuses a Destroying state, while still allowing failed clusters to be cleaned up. - **DNS listings now retrieve every reported page.** `dns.Service.List` returns an error rather than a partial domain list if a later page fails or pagination metadata is inconsistent. - **`dns show` now resolves an omitted status from the matching domain in `dns list`.** It stops reading pages as soon as it finds that domain. An explicit status in the detail response is preserved. If the matching list entry also lacks status, output remains `-`. If the list lookup fails, the command returns an error. _Follow-up to #69._ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 466a3c1..1bbad97 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -42,6 +42,12 @@ autoscaling with an explicit `--workers` count. `zcp kubernetes delete` submits an immediate cancellation request. A successful response confirms that the request was accepted; removal completes in the background. +A response whose API status is not `Success`, including an HTTP 200 response, +is reported as an error instead of as an accepted cancellation request. +Deletion is refused while the cluster is Starting, Destroying, or its current +state cannot be verified. Failed clusters remain eligible for cleanup. +`zcp instance delete` also refuses deletion while a VM is Starting or its live +state cannot be verified. Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. Creating a high-availability cluster requires at least two control @@ -65,7 +71,8 @@ zcp vm-backup schedule run-now ``` Policies support get, update, pause, resume, run-now, and delete operations. -Deleting a policy leaves backups it has already created in place. +Delete all backups or snapshots created by a policy before deleting it. Pause the +policy to retain those resources without scheduling more. Policy output includes the last-run result and any reported error; an active policy does not by itself confirm a completed backup. @@ -73,6 +80,15 @@ The current platform rejects `everyOtherDay` after persisting a policy. If it returns an error, list policies before retrying and remove any unintended policy. +## VM backup restore + +`zcp vm-backup restore --yes` restores a stopped VM +from a ready backup. It verifies the selected VM, backup ownership, region, and +project before sending the request. Restoring overwrites the VM's current disks. +Stop the VM with `zcp instance stop --wait` first. A successful +response only confirms the request was accepted. Wait for the VM to leave +`Restoring`, then use `zcp instance logs ` to check for failures. + ## VM backup create output `zcp vm-backup create` will print the created schedule, including its slug. It diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index 2af58aa..5e322ae 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -401,6 +401,7 @@ zcp ├── vm-backup VM backup operations │ ├── list List VM backups │ ├── create Create a VM backup (--interval dailyAt|hourlyAt) +│ ├── restore Restore a stopped VM from a ready backup (--yes to skip prompt) │ ├── delete Delete a VM backup by submitting a service-cancellation request (--yes to skip prompt) │ └── schedule Scheduler-backed VM backup policies │ ├── list List policies diff --git a/docs/commands.md b/docs/commands.md index c9818c1..4bad067 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -185,9 +185,9 @@ zcp instance delete --delete-public-ip=false # keep the VM's auto-assig `delete` submits an immediate service-cancellation request (the same workflow the CMP Web UI uses), so the VM's auto-assigned public IP is released along with the VM. Deletion is asynchronous — a successful response means the request was accepted, not -that the VM is already gone; poll `zcp instance get ` to confirm. The VM must be -in a destroyable state (a VM mid-transition such as Starting/Stopping is rejected until -it settles). +that the VM is already gone; poll `zcp instance get ` to confirm. Deletion is +refused while the VM is Starting or its current state cannot be verified. Wait until +the current state is available, then retry. The `--wait` flag on `create`, `start`, and `stop` polls the API until the instance reaches the target state, printing progress to stderr. @@ -462,6 +462,8 @@ zcp vm-backup create --interval dailyAt --plan backup-yul \ zcp vm-backup create --interval dailyAt --plan backup-yul \ --pseudo-service vm-backup --billing-cycle hourly --region yul-1 --project default-9 --output json zcp vm-backup delete +zcp instance stop --wait +zcp vm-backup restore --yes # Scheduler-backed VM backup policies. These use an IANA timezone and retain a # specified number of backups. `--at` accepts whole hours as `HH:00`. For weeklyOn use Sunday=0 through Saturday=6; @@ -481,7 +483,9 @@ zcp vm-backup schedule delete --yes `vm-backup delete` submits a service-cancellation request, the same workflow `instance delete` uses, since the VM backup API route does not support direct deletion. -`vm-backup schedule` manages the current scheduler-backed policies. `--immediate` requests one backup when the policy is created. `run-now` requests another backup without waiting for the next scheduled time. Pausing retains existing backups, and deleting a policy does not delete backups it already created. +`vm-backup restore` overwrites the VM's current disks. It only accepts a stopped VM and a ready backup that belongs to that VM in the selected region and project. Stop the VM with `zcp instance stop --wait` first. A successful restore response only confirms the request was accepted. Wait for the VM to leave `Restoring`, then use `zcp instance logs ` to check for failures. + +`vm-backup schedule` manages the current scheduler-backed policies. `--immediate` requests one backup when the policy is created. `run-now` requests another backup without waiting for the next scheduled time. Delete all backups or snapshots created by a policy before deleting it. Pause the policy to retain those resources without scheduling more. Use `zcp vm-backup schedule get ` to read the policy's last-run status and any reported error. A policy marked active does not by itself confirm that its last backup completed. diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index e0013df..ccf8241 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -424,6 +424,8 @@ func TestInstanceDeleteUsesServiceCancel(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, vmListWithBillingCycle) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/billing/service-cancel-requests/"): cancelPath = r.URL.Path json.NewDecoder(r.Body).Decode(&cancelBody) @@ -466,6 +468,66 @@ func TestInstanceDeleteUsesServiceCancel(t *testing.T) { } } +func TestInstanceDeleteUsesLiveStateBeforeCancellation(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-tok") + for _, tt := range []struct { + name string + metaState string + metaCode int + want string + cancel bool + }{ + {name: "cached state is stale", metaState: "Running", metaCode: http.StatusOK, cancel: true}, + {name: "starting", metaState: "Starting", metaCode: http.StatusOK, want: "while it is Starting"}, + {name: "unknown", metaState: "", metaCode: http.StatusOK, want: "current state is unknown"}, + {name: "meta unavailable", metaCode: http.StatusInternalServerError, want: "could not determine the current state"}, + {name: "metadata missing after lookup", metaCode: http.StatusNotFound, want: "could not determine the current state"}, + } { + t.Run(tt.name, func(t *testing.T) { + var cancelCount int + cachedVM := vmListWithBillingCycle + if tt.name == "cached state is stale" { + cachedVM = strings.Replace(cachedVM, `"state":"Running"`, `"state":"Starting"`, 1) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": + fmt.Fprint(w, cachedVM) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm/meta": + if tt.metaCode != http.StatusOK { + http.Error(w, "meta unavailable", tt.metaCode) + return + } + fmt.Fprintf(w, `{"status":"Success","data":{"state":%q}}`, tt.metaState) + case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/test-vm": + cancelCount++ + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + _, _, err := execCmd(t, NewInstanceCmd(), "delete", "test-vm", "--yes", "--api-url", srv.URL) + if tt.cancel { + if err != nil { + t.Fatalf("delete error = %v", err) + } + if cancelCount != 1 { + t.Errorf("cancellation requests = %d, want 1", cancelCount) + } + return + } + if tt.want != "" && (err == nil || !strings.Contains(err.Error(), tt.want)) { + t.Fatalf("delete error = %v, want %q", err, tt.want) + } + if cancelCount != 0 { + t.Errorf("cancellation requests = %d, want 0", cancelCount) + } + }) + } +} + // TestInstanceDeleteRetainPublicIP verifies --delete-public-ip=false sends // delete_public_ip:false so the auto-assigned IP is kept. func TestInstanceDeleteRetainPublicIP(t *testing.T) { @@ -475,6 +537,8 @@ func TestInstanceDeleteRetainPublicIP(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, vmListWithBillingCycle) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/billing/service-cancel-requests/"): json.NewDecoder(r.Body).Decode(&cancelBody) w.Header().Set("Content-Type", "application/json") @@ -506,6 +570,8 @@ func TestInstanceDeleteForceIsNoOp(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, vmListWithBillingCycle) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/test-vm/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/billing/service-cancel-requests/"): sawCancel.Store(true) w.Header().Set("Content-Type", "application/json") @@ -831,6 +897,10 @@ func TestInstanceDeleteRequiresBillingCycle(t *testing.T) { fmt.Fprint(w, vmListNoBillingCycle) return } + if r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/nc-vm/meta" { + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) + return + } if r.Method == http.MethodPost { t.Error("cancel must not be submitted when the billing cycle can't be determined") } @@ -854,6 +924,8 @@ func TestInstanceDeleteBillingCycleOverride(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, vmListNoBillingCycle) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/nc-vm/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/billing/service-cancel-requests/"): json.NewDecoder(r.Body).Decode(&cancelBody) fmt.Fprint(w, `{"status":"Success","data":null}`) @@ -886,6 +958,8 @@ func TestInstanceDeleteDerivesBillingCycleFromOffering(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, vmListOfferingBillingCycle) + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines/off-vm/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Running"}}`) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/billing/service-cancel-requests/"): json.NewDecoder(r.Body).Decode(&cancelBody) fmt.Fprint(w, `{"status":"Success","data":null}`) diff --git a/internal/commands/dns_status_test.go b/internal/commands/dns_status_test.go index 92fd9cd..3099947 100644 --- a/internal/commands/dns_status_test.go +++ b/internal/commands/dns_status_test.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -84,13 +85,23 @@ func TestDNSShowLeavesStatusUnknownWhenListHasNoMatchingSlug(t *testing.T) { defer srv.Close() t.Setenv("ZCP_BEARER_TOKEN", "test-token") - out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL) + out, _, err := execCmd(t, NewDNSCmd(), "show", "example-com-1", "--api-url", srv.URL, "--output", "json") if err != nil { t.Fatalf("dns show error = %v", err) } - if !strings.Contains(out, "-") || strings.Contains(out, "true") { - t.Errorf("dns show output = %q, want unknown status marker without a fabricated value", out) + var rows []map[string]string + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("dns show JSON output = %q: %v", out, err) + } + for _, row := range rows { + if row["field"] == "Status" { + if row["value"] != "-" { + t.Errorf("status value = %q, want unknown marker '-'", row["value"]) + } + return + } } + t.Errorf("dns show rows = %#v, missing Status row", rows) } func TestDNSShowReturnsListLookupErrorRatherThanFabricatingStatus(t *testing.T) { diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 6bf0dde..2bb8a58 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -654,6 +654,20 @@ func runInstanceStart(cmd *cobra.Command, slug string, wait bool) error { return printer.PrintTable(headers, rows) } +// validateInstanceDeleteState uses the state returned by the live meta endpoint. +// The portal disables deletion while a VM is starting; sending cancellation in +// that window is rejected by the API. +func validateInstanceDeleteState(slug, state string) error { + state = strings.TrimSpace(state) + if state == "" { + return fmt.Errorf("cannot delete instance %q because its current state is unknown; retry when the current state is available", slug) + } + if strings.EqualFold(state, "Starting") { + return fmt.Errorf("cannot delete instance %q while it is Starting; wait until it finishes starting, then retry", slug) + } + return nil +} + // ---------- stop ---------- func newInstanceStopCmd() *cobra.Command { @@ -1433,9 +1447,9 @@ uses — so the VM's auto-assigned public IP is released along with the VM (unle successful response means the request was accepted, not that the VM is already gone. Poll with 'zcp instance get ' to confirm removal. -The VM must be in a destroyable state; a VM that is mid-transition (e.g. -Starting/Stopping) is rejected until it settles. Manually-acquired and source-NAT -IPs are never touched by this command — release those with 'zcp ip release'.`, +Deletion is refused while the VM is Starting or its current state cannot be +verified. Manually-acquired and source-NAT IPs are never touched by this command. +Release those with 'zcp ip release'.`, Args: exactArgs(1), Example: ` zcp instance delete my-vm zcp instance delete my-vm --yes @@ -1497,6 +1511,13 @@ func runInstanceDelete(cmd *cobra.Command, slug string, deletePublicIP bool, bil } return err } + meta, err := svc.Meta(ctx, vm.Slug) + if err != nil { + return fmt.Errorf("instance delete: could not determine the current state for %q: %w", vm.Slug, err) + } + if err := validateInstanceDeleteState(vm.Slug, meta.State); err != nil { + return err + } // The cancellation body requires the VM's billing cycle. Prefer the --billing-cycle // override (already validated); otherwise derive it from the VM. Don't guess a default: diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index 40cd5bc..2d78915 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -80,7 +80,7 @@ func runK8sClusterList(cmd *cobra.Command) error { c.Version, strconv.Itoa(k8sWorkerNodeCount(c)), strconv.Itoa(c.ControlNodes), - strconv.FormatBool(c.EnableHA), + k8sHAStatus(c), regionName, c.CreatedAt, }) @@ -160,7 +160,7 @@ func runK8sClusterGet(cmd *cobra.Command, slug string) error { {"Version", version}, {"Workers", workers}, {"Control Nodes", controlNodes}, - {"HA", strconv.FormatBool(c.EnableHA)}, + {"HA", k8sHAStatus(*c)}, {"Public IP", publicIP}, {"Private IP", privateIP}, {"Endpoint", endpoint}, @@ -414,7 +414,7 @@ func newK8sClusterCreateCmd() *cobra.Command { return fmt.Errorf("--storage-plan must name a storage tier; custom_plan is not supported") } if !cmd.Flags().Changed("root-disk-size") { - return fmt.Errorf("--root-disk-size is required when --storage-plan is set") + return fmt.Errorf("--root-disk-size is required") } if rootDiskSize <= 0 { return fmt.Errorf("--root-disk-size must be > 0") @@ -530,11 +530,18 @@ func runK8sClusterCreate(cmd *cobra.Command, req kubernetes.CreateRequest) error cluster.Version, strconv.Itoa(k8sWorkerNodeCount(*cluster)), strconv.Itoa(cluster.ControlNodes), - strconv.FormatBool(cluster.EnableHA), + k8sHAStatus(*cluster), }} return printer.PrintTable(headers, rows) } +func k8sHAStatus(c kubernetes.Cluster) string { + if !c.EnableHAKnown { + return "-" + } + return strconv.FormatBool(c.EnableHA) +} + func newK8sClusterStartCmd() *cobra.Command { cmd := &cobra.Command{ Use: "start ", @@ -835,7 +842,12 @@ func newK8sClusterDeleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "delete ", Short: "Permanently delete a Kubernetes cluster", - Args: exactArgs(1), + Long: `Permanently delete a Kubernetes cluster. + +Deletion is refused while the cluster is Starting, Destroying, or its current +state cannot be verified. Wait for the lifecycle operation to finish, then +retry the command.`, + Args: exactArgs(1), Example: ` zcp kubernetes delete my-cluster zcp kubernetes delete my-cluster --yes`, RunE: func(cmd *cobra.Command, args []string) error { @@ -874,6 +886,9 @@ func runK8sClusterDelete(cmd *cobra.Command, slug string) error { } return fmt.Errorf("kubernetes delete: %w", err) } + if err := validateK8sDeleteState(cluster); err != nil { + return err + } deletePublicIP := true req := billing.CancelServiceRequest{ @@ -896,6 +911,30 @@ func runK8sClusterDelete(cmd *cobra.Command, slug string) error { return nil } +// validateK8sDeleteState follows the portal's lifecycle controls. Prefer the +// platform-reconciled meta state because the top-level state can lag while a +// cluster is provisioning or failing. +func validateK8sDeleteState(cluster *kubernetes.Cluster) error { + state := "" + if cluster != nil && cluster.Meta != nil { + state = strings.TrimSpace(cluster.Meta.State) + } + if state == "" && cluster != nil { + state = strings.TrimSpace(cluster.State) + } + if state == "" { + return fmt.Errorf("cannot delete Kubernetes cluster %q because its current state is unknown; run 'zcp kubernetes get %s' and retry when the state is available", cluster.Slug, cluster.Slug) + } + + switch strings.ToLower(state) { + case "starting": + return fmt.Errorf("cannot delete Kubernetes cluster %q while it is Starting; wait until provisioning finishes or the cluster reaches Error, then retry", cluster.Slug) + case "destroying": + return fmt.Errorf("cannot delete Kubernetes cluster %q while it is Destroying; wait for removal to finish", cluster.Slug) + } + return nil +} + func k8sCancelBillingCycle(cluster *kubernetes.Cluster) string { if cluster == nil { return "" diff --git a/internal/commands/kubernetes_test.go b/internal/commands/kubernetes_test.go index 86d35b1..7391465 100644 --- a/internal/commands/kubernetes_test.go +++ b/internal/commands/kubernetes_test.go @@ -3,6 +3,7 @@ package commands import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -235,6 +236,93 @@ func TestK8sGetShowsSafeResourceOverview(t *testing.T) { } } +func TestK8sOutputShowsUnknownHAWhenOmitted(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + cluster := `{"slug":"cluster-1","name":"cluster-1","state":"Running","version":"v1.37.0","node_size":1,"worker_node_size":1,"control_nodes":3}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters": + fmt.Fprintf(w, `{"status":"Success","data":[%s]}`, cluster) + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + fmt.Fprintf(w, `{"status":"Success","data":%s}`, cluster) + case r.Method == http.MethodPost && r.URL.Path == "/kubernetes-clusters": + fmt.Fprintf(w, `{"status":"Success","data":%s}`, cluster) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + listOut, _, err := execCmd(t, NewKubernetesCmd(), "list", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("kubernetes list: %v", err) + } + var listRows []map[string]string + if err := json.Unmarshal([]byte(listOut), &listRows); err != nil { + t.Fatalf("kubernetes list JSON = %q: %v", listOut, err) + } + if len(listRows) != 1 || listRows[0]["ha"] != "-" { + t.Errorf("kubernetes list HA = %#v, want '-'", listRows) + } + + getOut, _, err := execCmd(t, NewKubernetesCmd(), "get", "cluster-1", "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("kubernetes get: %v", err) + } + var getRows []map[string]string + if err := json.Unmarshal([]byte(getOut), &getRows); err != nil { + t.Fatalf("kubernetes get JSON = %q: %v", getOut, err) + } + foundHA := false + for _, row := range getRows { + if row["field"] == "HA" { + foundHA = true + if row["value"] != "-" { + t.Errorf("kubernetes get HA = %q, want '-'", row["value"]) + } + break + } + } + if !foundHA { + t.Errorf("kubernetes get rows = %#v, missing HA", getRows) + } + + createOut, _, err := execCmd(t, NewKubernetesCmd(), "create", + "--name", "cluster-1", "--version", "v1.37.0", + "--control-plane-plan", "control", "--worker-plan", "worker", "--storage-plan", "storage", "--root-disk-size", "100", "--storage-category", "pro-nvme", + "--cloud-provider", "nimbo", "--region", "yul-1", "--project", "test", "--billing-cycle", "hourly", "--workers", "1", "--ssh-key", "key", + "--api-url", srv.URL, "--output", "json") + if err != nil { + t.Fatalf("kubernetes create: %v", err) + } + var createRows []map[string]string + if err := json.Unmarshal([]byte(createOut), &createRows); err != nil { + t.Fatalf("kubernetes create JSON = %q: %v", createOut, err) + } + if len(createRows) != 1 || createRows[0]["ha"] != "-" { + t.Errorf("kubernetes create HA = %#v, want '-'", createRows) + } +} + +func TestK8sHAStatusPreservesExplicitValues(t *testing.T) { + for _, tt := range []struct { + name string + c kubernetes.Cluster + want string + }{ + {name: "unknown", c: kubernetes.Cluster{ControlNodes: 3}, want: "-"}, + {name: "disabled", c: kubernetes.Cluster{EnableHAKnown: true}, want: "false"}, + {name: "enabled", c: kubernetes.Cluster{EnableHAKnown: true, EnableHA: true}, want: "true"}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := k8sHAStatus(tt.c); got != tt.want { + t.Errorf("k8sHAStatus(%+v) = %q, want %q", tt.c, got, tt.want) + } + }) + } +} + func TestK8sDeleteSubmitsServiceCancellation(t *testing.T) { t.Setenv("ZCP_BEARER_TOKEN", "test-token") var getCount, postCount, deleteCount int @@ -243,7 +331,7 @@ func TestK8sDeleteSubmitsServiceCancellation(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": getCount++ - _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","offering":{"billing_cycle":{"unit":"hour","slug":"monthly","name":"Monthly"}}}}`)) + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","state":"Starting","meta":{"state":"Running"},"offering":{"billing_cycle":{"unit":"hour","slug":"monthly","name":"Monthly"}}}}`)) case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/cluster-1": postCount++ if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -290,7 +378,7 @@ func TestK8sDeleteOmitsUnknownBillingCycle(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": - _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1"}}`)) + _, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"cluster-1","state":"Running"}}`)) case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/cluster-1": postCount++ _ = json.NewDecoder(r.Body).Decode(&request) @@ -367,6 +455,67 @@ func TestK8sDeleteNotFoundIsIdempotent(t *testing.T) { } } +func TestK8sDeleteRejectsUnavailableStates(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + for _, tt := range []struct { + name string + state string + want string + }{ + {name: "starting", state: "Starting", want: "while it is Starting"}, + {name: "destroying", state: "Destroying", want: "while it is Destroying"}, + {name: "unknown", state: "", want: "current state is unknown"}, + } { + t.Run(tt.name, func(t *testing.T) { + var postCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + fmt.Fprintf(w, `{"status":"Success","data":{"slug":"cluster-1","state":%q}}`, tt.state) + case r.Method == http.MethodPost: + postCount++ + http.Error(w, "cancellation must not be submitted", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + _, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("delete error = %v, want %q", err, tt.want) + } + if postCount != 0 { + t.Errorf("cancellation requests = %d, want 0", postCount) + } + }) + } +} + +func TestK8sDeleteAllowsErrorState(t *testing.T) { + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + var postCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/kubernetes-clusters/cluster-1": + fmt.Fprint(w, `{"status":"Success","data":{"slug":"cluster-1","state":"Error"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/billing/service-cancel-requests/cluster-1": + postCount++ + fmt.Fprint(w, `{"status":"Success"}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + if _, _, err := execCmd(t, NewKubernetesCmd(), "delete", "cluster-1", "--yes", "--api-url", srv.URL); err != nil { + t.Fatalf("delete Error-state cluster: %v", err) + } + if postCount != 1 { + t.Errorf("cancellation requests = %d, want 1", postCount) + } +} + func TestK8sCancelBillingCycle(t *testing.T) { tests := []struct { name string diff --git a/internal/commands/vmbackup.go b/internal/commands/vmbackup.go index c7fe2df..0183676 100644 --- a/internal/commands/vmbackup.go +++ b/internal/commands/vmbackup.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/zsoftly/zcp-cli/internal/output" "github.com/zsoftly/zcp-cli/pkg/api/apierrors" + "github.com/zsoftly/zcp-cli/pkg/api/instance" "github.com/zsoftly/zcp-cli/pkg/api/vmbackup" ) @@ -25,6 +26,7 @@ func NewVMBackupCmd() *cobra.Command { cmd.AddCommand(newVMBackupListCmd()) cmd.AddCommand(newVMBackupCreateCmd()) cmd.AddCommand(newVMBackupDeleteCmd()) + cmd.AddCommand(newVMBackupRestoreCmd()) cmd.AddCommand(newVMBackupScheduleCmd()) return cmd } @@ -75,6 +77,117 @@ func runVMBackupList(cmd *cobra.Command) error { return printer.PrintTable(headers, rows) } +// ─── Restore ──────────────────────────────────────────────────────────────── + +func newVMBackupRestoreCmd() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "restore ", + Short: "Restore a stopped VM from a backup (DESTRUCTIVE)", + Long: `Restore a stopped VM from a backup. + +Restoring overwrites the VM's current disks. The command verifies the VM is +stopped, the backup belongs to that VM in the selected region and project, and +the backup is ready before it sends the restore request. A successful response +only confirms the request was accepted. Wait for the VM to leave Restoring and +check the activity log for failures.`, + Args: exactArgs(2), + Example: ` zcp vm-backup restore my-vm provider-backup-my-vm-2026-10-11 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runVMBackupRestore(cmd, args[0], args[1], yes) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} + +func runVMBackupRestore(cmd *cobra.Command, vmSlug, backupSlug string, yes bool) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + region, project := scopedRegionProject(cmd) + vm, err := resolveVMBackupRestoreVM(ctx, instance.NewService(client), region, project, vmSlug) + if err != nil { + return fmt.Errorf("vm-backup restore: %w", err) + } + meta, err := instance.NewService(client).Meta(ctx, vm.Slug) + if err != nil { + return fmt.Errorf("vm-backup restore: could not determine the current state of VM %q: %w", vm.Slug, err) + } + if !strings.EqualFold(meta.State, "Stopped") { + if strings.EqualFold(meta.State, "Running") { + return fmt.Errorf("vm-backup restore: VM %q must be Stopped before restore (current state: %s). Run `zcp instance stop %s --wait` and retry", vm.Slug, meta.State, vm.Slug) + } + return fmt.Errorf("vm-backup restore: VM %q must be Stopped before restore (current state: %s)", vm.Slug, stateOrUnknown(meta.State)) + } + + svc := vmbackup.NewService(client) + snapshots, err := svc.ListSnapshots(ctx, region, project) + if err != nil { + return fmt.Errorf("vm-backup restore: %w", err) + } + backup, err := scopedVMBackupSnapshot(snapshots, backupSlug, vm.Slug, region, project) + if err != nil { + return fmt.Errorf("vm-backup restore: %w", err) + } + if backup.IsValidForRestore == nil || !*backup.IsValidForRestore { + return fmt.Errorf("vm-backup restore: backup %q is not ready for restore (state: %s, status: %s)", backupSlug, stateOrUnknown(backup.State), stateOrUnknown(backup.Status)) + } + if !yes && !confirmAction(cmd, "Restore backup %q onto VM %q? This overwrites the VM's current disks and cannot be undone.", backupSlug, vm.Slug) { + return nil + } + + if _, err := svc.Restore(ctx, vm.Slug, backupSlug); err != nil { + return fmt.Errorf("vm-backup restore: %w", err) + } + printer.Fprintf("Restore requested for VM backup %q on VM %q. The restore is not complete. Wait for the VM to leave Restoring; check the activity log for failures.\n", backupSlug, vm.Slug) + return nil +} + +func resolveVMBackupRestoreVM(ctx context.Context, svc *instance.Service, region, project, slug string) (*instance.VirtualMachine, error) { + vms, err := svc.List(ctx, region, project) + if err != nil { + return nil, fmt.Errorf("listing VMs in scope: %w", err) + } + for i := range vms { + if vms[i].Slug == slug { + return &vms[i], nil + } + } + return nil, fmt.Errorf("VM %q was not found in region %q and project %q", slug, region, project) +} + +func scopedVMBackupSnapshot(snapshots []vmbackup.SnapshotBackup, backupSlug, vmSlug, region, project string) (*vmbackup.SnapshotBackup, error) { + for i := range snapshots { + backup := &snapshots[i] + if backup.Slug != backupSlug { + continue + } + if backup.DeletedAt != nil { + return nil, fmt.Errorf("backup %q is deleted", backupSlug) + } + if backup.VirtualMachine == nil || backup.VirtualMachine.Slug != vmSlug { + return nil, fmt.Errorf("backup %q does not belong to VM %q", backupSlug, vmSlug) + } + if backup.Region == nil || backup.Region.Slug != region || backup.Project == nil || backup.Project.Slug != project { + return nil, fmt.Errorf("backup %q is not in region %q and project %q", backupSlug, region, project) + } + return backup, nil + } + return nil, fmt.Errorf("backup %q was not found in region %q and project %q", backupSlug, region, project) +} + +func stateOrUnknown(state string) string { + if strings.TrimSpace(state) == "" { + return "unknown" + } + return state +} + // ─── Create ───────────────────────────────────────────────────────────────── func newVMBackupCreateCmd() *cobra.Command { diff --git a/internal/commands/vmbackup_restore_test.go b/internal/commands/vmbackup_restore_test.go new file mode 100644 index 0000000..d8c3c00 --- /dev/null +++ b/internal/commands/vmbackup_restore_test.go @@ -0,0 +1,161 @@ +package commands + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestVMBackupRestoreRequiresStoppedVM(t *testing.T) { + for _, state := range []string{"Running", "Starting", ""} { + t.Run(stateOrUnknown(state), func(t *testing.T) { + posts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/virtual-machines": + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + case "/virtual-machines/vm-1/meta": + fmt.Fprintf(w, `{"status":"Success","data":{"state":%q}}`, state) + default: + if r.Method == http.MethodPost { + posts++ + } + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "default") + + _, _, err := execCmd(t, NewVMBackupCmd(), "restore", "vm-1", "backup-1", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), "must be Stopped before restore") { + t.Fatalf("restore error = %v, want stopped-state error", err) + } + if posts != 0 { + t.Fatalf("restore POST requests = %d, want 0", posts) + } + }) + } +} + +func TestVMBackupRestoreStopsOnStateReadError(t *testing.T) { + posts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/virtual-machines": + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + case "/virtual-machines/vm-1/meta": + http.Error(w, `{"status":"Error","message":"unavailable"}`, http.StatusBadGateway) + default: + if r.Method == http.MethodPost { + posts++ + } + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "default") + + _, _, err := execCmd(t, NewVMBackupCmd(), "restore", "vm-1", "backup-1", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), "could not determine the current state") { + t.Fatalf("restore error = %v, want state-read error", err) + } + if posts != 0 { + t.Fatalf("restore POST requests = %d, want 0", posts) + } +} + +func TestVMBackupRestoreChecksSnapshotScopeAndReadiness(t *testing.T) { + tests := []struct { + name string + snapshot string + want string + }{ + {"wrong VM", `{"slug":"backup-1","virtual_machine":{"slug":"vm-2"},"region":{"slug":"yul-1"},"project":{"slug":"default"},"is_valid_for_restore":true}`, "does not belong"}, + {"wrong project", `{"slug":"backup-1","virtual_machine":{"slug":"vm-1"},"region":{"slug":"yul-1"},"project":{"slug":"other"},"is_valid_for_restore":true}`, "not in region"}, + {"not ready", `{"slug":"backup-1","state":"Pending","status":"pending","virtual_machine":{"slug":"vm-1"},"region":{"slug":"yul-1"},"project":{"slug":"default"},"is_valid_for_restore":false}`, "not ready for restore"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + posts := 0 + srv := restoreTestServer(t, `[`+tt.snapshot+`]`, &posts, `{"status":"Success","message":"Ok"}`) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "default") + + _, _, err := execCmd(t, NewVMBackupCmd(), "restore", "vm-1", "backup-1", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("restore error = %v, want %q", err, tt.want) + } + if posts != 0 { + t.Fatalf("restore POST requests = %d, want 0", posts) + } + }) + } +} + +func TestVMBackupRestoreRequestsReadyOwnedBackup(t *testing.T) { + posts := 0 + srv := restoreTestServer(t, `[{"slug":"backup-1","state":"BackedUp","status":"backedup","virtual_machine":{"slug":"vm-1"},"region":{"slug":"yul-1"},"project":{"slug":"default"},"is_valid_for_restore":true}]`, &posts, `{"status":"Success","message":"Ok"}`) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "default") + + stdout, _, err := execCmd(t, NewVMBackupCmd(), "restore", "vm-1", "backup-1", "--yes", "--api-url", srv.URL) + if err != nil { + t.Fatalf("restore error = %v", err) + } + if posts != 1 { + t.Fatalf("restore POST requests = %d, want 1", posts) + } + if !strings.Contains(stdout, "Restore requested") || !strings.Contains(stdout, "not complete") { + t.Fatalf("stdout = %q, want asynchronous restore message", stdout) + } +} + +func TestVMBackupRestorePropagatesErrorEnvelope(t *testing.T) { + posts := 0 + srv := restoreTestServer(t, `[{"slug":"backup-1","state":"BackedUp","status":"backedup","virtual_machine":{"slug":"vm-1"},"region":{"slug":"yul-1"},"project":{"slug":"default"},"is_valid_for_restore":true}]`, &posts, `{"status":"Error","message":"restore unavailable"}`) + defer srv.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test-token") + t.Setenv("ZCP_REGION", "yul-1") + t.Setenv("ZCP_PROJECT", "default") + + _, _, err := execCmd(t, NewVMBackupCmd(), "restore", "vm-1", "backup-1", "--yes", "--api-url", srv.URL) + if err == nil || !strings.Contains(err.Error(), "restore unavailable") { + t.Fatalf("restore error = %v, want body error", err) + } + if posts != 1 { + t.Fatalf("restore POST requests = %d, want 1", posts) + } +} + +func restoreTestServer(t *testing.T, snapshots string, posts *int, restoreBody string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/virtual-machines": + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + case "/virtual-machines/vm-1/meta": + fmt.Fprint(w, `{"status":"Success","data":{"state":"Stopped"}}`) + case "/virtual-machines/backups/snapshots": + fmt.Fprintf(w, `{"status":"Success","current_page":1,"last_page":1,"data":%s}`, snapshots) + case "/virtual-machines/vm-1/backups/snapshots/restore/backup-1": + if r.Method != http.MethodPost { + http.Error(w, "want POST", http.StatusMethodNotAllowed) + return + } + *posts++ + fmt.Fprint(w, restoreBody) + default: + http.NotFound(w, r) + } + })) +} diff --git a/internal/commands/vmbackupschedule.go b/internal/commands/vmbackupschedule.go index e054568..bac9291 100644 --- a/internal/commands/vmbackupschedule.go +++ b/internal/commands/vmbackupschedule.go @@ -199,14 +199,16 @@ func newVMBackupScheduleCreateCmd() *cobra.Command { if err != nil { return err } - svc, ctx, cancel, err := schedulerService(cmd) + svc, inventoryCtx, inventoryCancel, err := schedulerService(cmd) if err != nil { return err } - defer cancel() - if err := scopedVM(cmd, ctx, args[0]); err != nil { + defer inventoryCancel() + if err := scopedVM(cmd, inventoryCtx, args[0]); err != nil { return err } + createCtx, createCancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer createCancel() i := 0 if immediate { i = 1 @@ -218,7 +220,7 @@ func newVMBackupScheduleCreateCmd() *cobra.Command { if v := strings.TrimSpace(description); v != "" { descriptionValue = &v } - p, err := svc.Create(ctx, scheduler.CreateRequest{Service: "Virtual Machine", Slug: args[0], Action: "VM Backup", Name: nameValue, Description: descriptionValue, Interval: interval, Day: d, At: at, Timezone: tz, RetentionPolicy: retention, TakeOneImmediately: i, Config: scheduler.Config{TakeOneImmediately: i}}) + p, err := svc.Create(createCtx, scheduler.CreateRequest{Service: "Virtual Machine", Slug: args[0], Action: "VM Backup", Name: nameValue, Description: descriptionValue, Interval: interval, Day: d, At: at, Timezone: tz, RetentionPolicy: retention, TakeOneImmediately: i, Config: scheduler.Config{TakeOneImmediately: i}}) if err != nil { return fmt.Errorf("vm-backup schedule create: the API may have accepted the request before returning an error (%w). Run 'zcp vm-backup schedule list' before retrying", err) } diff --git a/internal/commands/vmbackupschedule_test.go b/internal/commands/vmbackupschedule_test.go index 9f9f9b8..4eed624 100644 --- a/internal/commands/vmbackupschedule_test.go +++ b/internal/commands/vmbackupschedule_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestVMBackupScheduleCreateSendsPortalContract(t *testing.T) { @@ -21,7 +22,9 @@ func TestVMBackupScheduleCreateSendsPortalContract(t *testing.T) { return } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatal(err) + t.Errorf("decode request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return } fmt.Fprint(w, `{"data":{"id":"policy-1","name":"nightly","slug":"vm-1","interval":"weeklyOn","day":0,"at":"13:00","timezone":"America/Toronto","retention_policy":2,"status":"active"}}`) })) @@ -61,7 +64,9 @@ func TestVMBackupScheduleHourlyDefaultsTime(t *testing.T) { return } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatal(err) + t.Errorf("decode request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return } fmt.Fprint(w, `{"data":{"id":"policy-1"}}`) })) @@ -245,6 +250,28 @@ func TestVMBackupScheduleCreateDoesNotRetryAfterServerFailure(t *testing.T) { } } +func TestVMBackupScheduleCreateGetsFreshTimeoutAfterVMInventory(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines": + time.Sleep(600 * time.Millisecond) + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"vm-1"}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/scheduler-actions": + time.Sleep(600 * time.Millisecond) + fmt.Fprint(w, `{"data":{"id":"policy-1"}}`) + default: + http.NotFound(w, r) + } + })) + defer s.Close() + t.Setenv("ZCP_BEARER_TOKEN", "test") + + _, _, err := execCmd(t, NewVMBackupCmd(), "schedule", "create", "vm-1", "--interval", "dailyAt", "--at", "13:00", "--timezone", "UTC", "--retention", "1", "--timeout", "1", "--api-url", s.URL) + if err != nil { + t.Fatalf("vm-backup schedule create: %v", err) + } +} + func TestVMBackupSchedulePauseRejectsWrongActionBeforeWrite(t *testing.T) { writes := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/api/billing/billing.go b/pkg/api/billing/billing.go index 4e3cdbb..26d2369 100644 --- a/pkg/api/billing/billing.go +++ b/pkg/api/billing/billing.go @@ -386,6 +386,12 @@ func (s *Service) CancelService(ctx context.Context, slug string, req CancelServ if err := s.client.Post(ctx, "/billing/service-cancel-requests/"+slug, req, &env); err != nil { return fmt.Errorf("cancelling service %s: %w", slug, err) } + if env.Status != "Success" { + if strings.TrimSpace(env.Message) != "" { + return fmt.Errorf("cancelling service %s: %s", slug, env.Message) + } + return fmt.Errorf("cancelling service %s: unexpected response status %q", slug, env.Status) + } return nil } diff --git a/pkg/api/billing/billing_test.go b/pkg/api/billing/billing_test.go index cd64d79..06bc2fe 100644 --- a/pkg/api/billing/billing_test.go +++ b/pkg/api/billing/billing_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -436,6 +437,31 @@ func TestCancelService(t *testing.T) { } } +func TestCancelServiceRejectsErrorEnvelope(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(map[string]interface{}{ + "status": "Error", + "message": "service cannot be cancelled while it is starting", + "data": nil, + }) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + err := svc.CancelService(context.Background(), "my-subscription-slug", billing.CancelServiceRequest{ + ServiceName: "Virtual Machine", + Reason: "not_needed_anymore", + Type: "Immediate", + }) + if err == nil { + t.Fatal("CancelService() error = nil, want API error") + } + if !strings.Contains(err.Error(), "service cannot be cancelled while it is starting") { + t.Errorf("CancelService() error = %q, want API message", err) + } +} + // TestCancelServiceReleasesPublicIP verifies the full VM-cancellation body — including // billing_cycle, status and delete_public_ip — is serialized and sent. This is the // endpoint that actually releases a VM's auto-assigned public IP (the direct DELETE diff --git a/pkg/api/kubernetes/kubernetes.go b/pkg/api/kubernetes/kubernetes.go index 46f02ff..7d26fc8 100644 --- a/pkg/api/kubernetes/kubernetes.go +++ b/pkg/api/kubernetes/kubernetes.go @@ -132,6 +132,28 @@ type Cluster struct { Network *Network `json:"network,omitempty"` BlockStorages []BlockStorage `json:"blockstorages,omitempty"` Autoscale *Autoscale `json:"autoscale,omitempty"` + // EnableHAKnown reports whether the API response carried an "enable_ha" + // value. Some cluster detail responses omit it, so EnableHA alone would + // otherwise look like an explicit false. + EnableHAKnown bool `json:"-"` +} + +// clusterAlias avoids infinite recursion in Cluster.UnmarshalJSON. +type clusterAlias Cluster + +// UnmarshalJSON records whether the API reported high availability, allowing +// callers to distinguish disabled from unavailable. +func (c *Cluster) UnmarshalJSON(data []byte) error { + aux := struct { + EnableHA *bool `json:"enable_ha"` + *clusterAlias + }{clusterAlias: (*clusterAlias)(c)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + c.EnableHAKnown = aux.EnableHA != nil + c.EnableHA = aux.EnableHA != nil && *aux.EnableHA + return nil } // BillingCycle represents the billing cycle attached to a cluster. diff --git a/pkg/api/kubernetes/kubernetes_test.go b/pkg/api/kubernetes/kubernetes_test.go index 6e50c44..57e9aef 100644 --- a/pkg/api/kubernetes/kubernetes_test.go +++ b/pkg/api/kubernetes/kubernetes_test.go @@ -133,6 +133,31 @@ func TestKubernetesListClustersEmpty(t *testing.T) { } } +func TestClusterEnableHAKnown(t *testing.T) { + tests := []struct { + name string + body string + wantKnown bool + wantHA bool + }{ + {name: "enabled", body: `{"enable_ha":true}`, wantKnown: true, wantHA: true}, + {name: "disabled", body: `{"enable_ha":false}`, wantKnown: true, wantHA: false}, + {name: "omitted", body: `{}`, wantKnown: false, wantHA: false}, + {name: "null", body: `{"enable_ha":null}`, wantKnown: false, wantHA: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cluster kubernetes.Cluster + if err := json.Unmarshal([]byte(tt.body), &cluster); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cluster.EnableHAKnown != tt.wantKnown || cluster.EnableHA != tt.wantHA { + t.Errorf("EnableHAKnown=%v EnableHA=%v, want %v/%v", cluster.EnableHAKnown, cluster.EnableHA, tt.wantKnown, tt.wantHA) + } + }) + } +} + func TestKubernetesCreate(t *testing.T) { var gotBody map[string]interface{} diff --git a/pkg/api/scheduler/scheduler.go b/pkg/api/scheduler/scheduler.go index 4c50eca..c45d75f 100644 --- a/pkg/api/scheduler/scheduler.go +++ b/pkg/api/scheduler/scheduler.go @@ -185,7 +185,7 @@ func (s *Service) List(ctx context.Context, region, project string) ([]Policy, e func (s *Service) Get(ctx context.Context, id string) (*Policy, error) { var r envelope - if err := s.client.Get(ctx, "/scheduler-actions/"+id, nil, &r); err != nil { + if err := s.client.Get(ctx, "/scheduler-actions/"+url.PathEscape(id), nil, &r); err != nil { return nil, fmt.Errorf("getting scheduler action %s: %w", id, err) } return &r.Data, nil @@ -199,35 +199,35 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (*Policy, error } func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (*Policy, error) { var r envelope - if err := s.client.Put(ctx, "/scheduler-actions/"+id, nil, req, &r); err != nil { + if err := s.client.Put(ctx, "/scheduler-actions/"+url.PathEscape(id), nil, req, &r); err != nil { return nil, fmt.Errorf("updating scheduler action %s: %w", id, err) } return &r.Data, nil } func (s *Service) Delete(ctx context.Context, id string) (*ActionResponse, error) { var r ActionResponse - if err := s.client.DeleteWithResult(ctx, "/scheduler-actions/"+id, nil, &r); err != nil { + if err := s.client.DeleteWithResult(ctx, "/scheduler-actions/"+url.PathEscape(id), nil, &r); err != nil { return nil, fmt.Errorf("deleting scheduler action %s: %w", id, err) } return &r, nil } func (s *Service) Pause(ctx context.Context, id string) (*Policy, error) { var r envelope - if err := s.client.Patch(ctx, "/scheduler-actions/"+id+"/pause", nil, &r); err != nil { + if err := s.client.Patch(ctx, "/scheduler-actions/"+url.PathEscape(id)+"/pause", nil, &r); err != nil { return nil, fmt.Errorf("pausing scheduler action %s: %w", id, err) } return &r.Data, nil } func (s *Service) Resume(ctx context.Context, id string) (*Policy, error) { var r envelope - if err := s.client.Patch(ctx, "/scheduler-actions/"+id+"/resume", nil, &r); err != nil { + if err := s.client.Patch(ctx, "/scheduler-actions/"+url.PathEscape(id)+"/resume", nil, &r); err != nil { return nil, fmt.Errorf("resuming scheduler action %s: %w", id, err) } return &r.Data, nil } func (s *Service) RunNow(ctx context.Context, id string) (*ActionResponse, error) { var r ActionResponse - if err := s.client.Post(ctx, "/scheduler/"+id+"/run-now", nil, &r); err != nil { + if err := s.client.Post(ctx, "/scheduler/"+url.PathEscape(id)+"/run-now", nil, &r); err != nil { return nil, fmt.Errorf("running scheduler action %s: %w", id, err) } return &r, nil diff --git a/pkg/api/scheduler/scheduler_test.go b/pkg/api/scheduler/scheduler_test.go index 5519708..477f7b8 100644 --- a/pkg/api/scheduler/scheduler_test.go +++ b/pkg/api/scheduler/scheduler_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -60,28 +61,46 @@ func TestListReturnsNoPartialResultsWhenLaterPageFails(t *testing.T) { } } -func TestMutationsUseSchedulerRoutes(t *testing.T) { - paths := make([]string, 0, 4) +func TestIDOperationsEscapeSchedulerRoutes(t *testing.T) { + paths := make([]string, 0, 6) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.Method+" "+r.URL.Path) + paths = append(paths, r.Method+" "+r.URL.EscapedPath()) + if r.URL.RawQuery != "" { + t.Errorf("raw query = %q, want empty", r.URL.RawQuery) + } fmt.Fprint(w, `{"data":{"id":"policy"}}`) })) defer s.Close() svc := testService(s) ctx := context.Background() - if _, err := svc.Update(ctx, "policy", UpdateRequest{Interval: "dailyAt", At: "01:00", Timezone: "UTC", RetentionPolicy: 1}); err != nil { + id := "policy/child?unexpected=query" + escapedID := url.PathEscape(id) + if _, err := svc.Get(ctx, id); err != nil { + t.Fatal(err) + } + if _, err := svc.Update(ctx, id, UpdateRequest{Interval: "dailyAt", At: "01:00", Timezone: "UTC", RetentionPolicy: 1}); err != nil { t.Fatal(err) } - if _, err := svc.Pause(ctx, "policy"); err != nil { + if _, err := svc.Delete(ctx, id); err != nil { t.Fatal(err) } - if _, err := svc.Resume(ctx, "policy"); err != nil { + if _, err := svc.Pause(ctx, id); err != nil { t.Fatal(err) } - if _, err := svc.RunNow(ctx, "policy"); err != nil { + if _, err := svc.Resume(ctx, id); err != nil { t.Fatal(err) } - want := []string{"PUT /scheduler-actions/policy", "PATCH /scheduler-actions/policy/pause", "PATCH /scheduler-actions/policy/resume", "POST /scheduler/policy/run-now"} + if _, err := svc.RunNow(ctx, id); err != nil { + t.Fatal(err) + } + want := []string{ + "GET /scheduler-actions/" + escapedID, + "PUT /scheduler-actions/" + escapedID, + "DELETE /scheduler-actions/" + escapedID, + "PATCH /scheduler-actions/" + escapedID + "/pause", + "PATCH /scheduler-actions/" + escapedID + "/resume", + "POST /scheduler/" + escapedID + "/run-now", + } if len(paths) != len(want) { t.Fatalf("request count = %d, want %d", len(paths), len(want)) } diff --git a/pkg/api/vmbackup/vmbackup.go b/pkg/api/vmbackup/vmbackup.go index e178b5d..c0d3abd 100644 --- a/pkg/api/vmbackup/vmbackup.go +++ b/pkg/api/vmbackup/vmbackup.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/url" + "strings" "github.com/zsoftly/zcp-cli/pkg/api/billing" "github.com/zsoftly/zcp-cli/pkg/api/response" @@ -51,6 +52,25 @@ type VMRef struct { Name string `json:"name"` } +// ScopeRef identifies a region or project nested in a backup snapshot response. +type ScopeRef struct { + ID string `json:"id"` + Slug string `json:"slug"` +} + +// SnapshotBackup represents a restorable VM backup snapshot. +type SnapshotBackup struct { + ID string `json:"id"` + Slug string `json:"slug"` + State string `json:"state"` + Status string `json:"status"` + IsValidForRestore *bool `json:"is_valid_for_restore"` + VirtualMachine *VMRef `json:"virtual_machine"` + Region *ScopeRef `json:"region"` + Project *ScopeRef `json:"project"` + DeletedAt *string `json:"deleted_at"` +} + // VMBackup represents a STKCNSL VM backup. type VMBackup struct { ID string `json:"id"` @@ -196,6 +216,63 @@ func (s *Service) Create(ctx context.Context, vmSlug string, req CreateRequest) return &resp, nil } +// ListSnapshots returns all VM backup snapshots in the requested region and +// project. The requested relations identify the owning VM and scope before a +// destructive restore request is made. +func (s *Service) ListSnapshots(ctx context.Context, region, project string) ([]SnapshotBackup, error) { + var all []SnapshotBackup + for page := 1; page <= maxListPages; page++ { + q := url.Values{"include": {"region,project,virtual_machine"}} + if region != "" { + q.Set("filter[region]", region) + } + if project != "" { + q.Set("filter[project]", project) + } + if page > 1 { + q.Set("page", fmt.Sprintf("%d", page)) + } + + var env Envelope + if err := s.client.Get(ctx, "/virtual-machines/backups/snapshots", q, &env); err != nil { + return nil, fmt.Errorf("listing VM backup snapshots: %w", err) + } + if env.Status != "Success" { + return nil, fmt.Errorf("listing VM backup snapshots: %s", env.Message) + } + var snapshots []SnapshotBackup + if err := json.Unmarshal(env.Data, &snapshots); err != nil { + return nil, fmt.Errorf("decoding VM backup snapshots: %w", err) + } + if env.CurrentPage > 0 && env.CurrentPage != page { + return nil, fmt.Errorf("listing VM backup snapshots: requested page %d but the API returned page %d", page, env.CurrentPage) + } + all = append(all, snapshots...) + if len(snapshots) == 0 || env.LastPage <= 0 || page >= env.LastPage { + return all, nil + } + } + return nil, fmt.Errorf("listing VM backup snapshots: exceeded %d pages without reaching the last page", maxListPages) +} + +// Restore requests restoration of a VM backup snapshot. The request is +// accepted asynchronously; a successful response does not mean restoration +// has completed. +func (s *Service) Restore(ctx context.Context, vmSlug, backupSlug string) (*ActionResponse, error) { + var resp ActionResponse + path := "/virtual-machines/" + url.PathEscape(vmSlug) + "/backups/snapshots/restore/" + url.PathEscape(backupSlug) + if err := s.client.Post(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("restoring VM backup %s on %s: %w", backupSlug, vmSlug, err) + } + if !strings.EqualFold(strings.TrimSpace(resp.Status), "Success") { + if strings.TrimSpace(resp.Message) != "" { + return nil, fmt.Errorf("restoring VM backup %s on %s: %s", backupSlug, vmSlug, resp.Message) + } + return nil, fmt.Errorf("restoring VM backup %s on %s: unexpected response status %q", backupSlug, vmSlug, resp.Status) + } + return &resp, nil +} + // Delete requests deletion of a VM backup schedule. // // The route api/virtual-machines/backups/{slug} rejects DELETE ("Supported diff --git a/pkg/api/vmbackup/vmbackup_test.go b/pkg/api/vmbackup/vmbackup_test.go index 0dc72c4..5e38353 100644 --- a/pkg/api/vmbackup/vmbackup_test.go +++ b/pkg/api/vmbackup/vmbackup_test.go @@ -168,6 +168,57 @@ func TestVMBackupDelete_Error(t *testing.T) { } } +func TestVMBackupListSnapshotsAndRestore(t *testing.T) { + var restorePath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.URL.Path != "/virtual-machines/backups/snapshots" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("include"); got != "region,project,virtual_machine" { + http.Error(w, "missing restore relations", http.StatusBadRequest) + return + } + fmt.Fprint(w, `{"status":"Success","current_page":1,"last_page":1,"data":[{"slug":"backup-1","is_valid_for_restore":true,"virtual_machine":{"slug":"vm-1"},"region":{"slug":"yul-1"},"project":{"slug":"default"}}]}`) + case http.MethodPost: + restorePath = r.URL.EscapedPath() + fmt.Fprint(w, `{"status":"Success","message":"Ok"}`) + default: + http.Error(w, "want GET or POST", http.StatusMethodNotAllowed) + } + })) + defer srv.Close() + + svc := vmbackup.NewService(newTestClient(t, srv)) + snapshots, err := svc.ListSnapshots(context.Background(), "yul-1", "default") + if err != nil { + t.Fatalf("ListSnapshots() error = %v", err) + } + if len(snapshots) != 1 || snapshots[0].VirtualMachine == nil || snapshots[0].VirtualMachine.Slug != "vm-1" || snapshots[0].IsValidForRestore == nil || !*snapshots[0].IsValidForRestore { + t.Fatalf("snapshots = %#v, want ready snapshot for vm-1", snapshots) + } + if _, err := svc.Restore(context.Background(), "vm/a", "backup?b"); err != nil { + t.Fatalf("Restore() error = %v", err) + } + if restorePath != "/virtual-machines/vm%2Fa/backups/snapshots/restore/backup%3Fb" { + t.Errorf("restore escaped path = %q", restorePath) + } +} + +func TestVMBackupRestoreRejectsErrorEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"status":"Error","message":"VM must be stopped"}`) + })) + defer srv.Close() + + _, err := vmbackup.NewService(newTestClient(t, srv)).Restore(context.Background(), "vm-1", "backup-1") + if err == nil || !strings.Contains(err.Error(), "VM must be stopped") { + t.Fatalf("Restore() error = %v, want response message", err) + } +} + func TestVMBackupListPagination(t *testing.T) { page1 := `{"status":"Success","message":"Ok","current_page":1,"last_page":2,"total":2, "data":[{"id":"vmb-1","name":"a","slug":"vmb-a"}]}` diff --git a/tests/smoke/cases.sh b/tests/smoke/cases.sh index 5ac41db..3d9bf2e 100755 --- a/tests/smoke/cases.sh +++ b/tests/smoke/cases.sh @@ -180,7 +180,6 @@ fx_ip() { fx_vm() { [[ -n "$FX_VM" ]] && return 0 local name out root_disk_size; name="$(rname vm)"; root_disk_size="$(det_root_disk_size)" - [[ -z "$root_disk_size" ]] && { echo "[fx_vm] set ZCP_SMOKE_ROOT_DISK_SIZE to create a root disk"; return 1; } capture out -- zcp instance create --name "$name" \ --cloud-provider "$(det_cp)" --project "$(det_project)" --region "$(det_region)" \ --template "$(det_template)" --plan "$(det_vm_plan)" \ @@ -550,7 +549,7 @@ lc_kubernetes() { storage_plan="$(jq -r '.data[]|.slug' <<<"$storage_plans" | head -1)" setup="$(api_get '/regions' | jq -r --arg s "$(det_region)" '.data[]|select(.slug==$s)|.cloud_provider_setup.slug' | head -1)" root_disk_size="$(det_root_disk_size)" - [[ -z "$ver" || -z "$master_plan" || -z "$worker_plan" || -z "$storage_plan" || -z "$storage_category" || -z "$root_disk_size" ]] && { _mark_skip "kubernetes (no compatible control-plane, worker, storage plans, and root disk capacity for region)"; return; } + [[ -z "$ver" || -z "$master_plan" || -z "$worker_plan" || -z "$storage_plan" || -z "$storage_category" ]] && { _mark_skip "kubernetes (no compatible control-plane, worker, and storage plans for region)"; return; } name="$(rname k8s)" out="$(zcp kubernetes create --name "$name" --version "$ver" \ --control-plane-plan "$master_plan" --worker-plan "$worker_plan" --storage-plan "$storage_plan" --root-disk-size "$root_disk_size" --storage-category "$storage_category" \ diff --git a/tests/smoke/lib.sh b/tests/smoke/lib.sh index 765256a..02eca6c 100755 --- a/tests/smoke/lib.sh +++ b/tests/smoke/lib.sh @@ -235,7 +235,7 @@ det_vm_plan() { det_blockstorage_plan() { if [[ -n "${ZCP_SMOKE_BLOCKSTORAGE_PLAN:-}" ]]; then printf '%s' "$ZCP_SMOKE_BLOCKSTORAGE_PLAN"; return; fi api_get "/plans/service/Block%20Storage?filter%5Bregion%5D=$(det_region)&filter%5Bstorage_category%5D=$(det_storage_cat)" \ - | jq -r '.data[0].slug // empty' | head -1 + | jq -r '.data[0].slug // empty' } # The smoke suite uses 100 GB only as its disposable fixture capacity. It is # not a CLI default; instance and Kubernetes create always require the flag. From 7e9eafcf9a29a86ef1ba60eea5ddf6f66655ff4a Mon Sep 17 00:00:00 2001 From: ditahkk Date: Sun, 11 Oct 2026 10:39:54 -0400 Subject: [PATCH 5/5] chore: prepare v1.0.0 release Signed-off-by: ditahkk --- CHANGELOG.md | 10 +- RELEASE_NOTES.md | 251 +++++++++++-------------- docs/commands.md | 15 +- internal/commands/dns.go | 1 + pkg/api/network/network.go | 6 - pkg/api/objectstorage/objectstorage.go | 8 - pkg/httpclient/client_retry_test.go | 12 -- 7 files changed, 132 insertions(+), 171 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2326ec..fefa932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using ## [Unreleased] +## [v1.0.0] - 2026-10-11 + ### Added - **Safe VM backup restore.** `zcp vm-backup restore ` verifies that the VM is stopped and that the selected backup is ready, belongs to that VM, and is in the selected region and project before it requests a restore. The command does not stop the VM automatically. It reports when the restore request is accepted, not when the restore is complete. -- **Kubernetes create uses separate control-plane, worker, and root-volume plans.** `zcp kubernetes create` now requires `--control-plane-plan`, `--worker-plan`, and `--storage-plan`, alongside `--storage-category`. The command sends the ZCP API `master_plan`, `worker_plan`, and `blockstorage_plan` fields instead of a single cluster plan. The root-volume plan applies to every node. The selected billing cycle applies to the resulting resource subscriptions: every control-plane and worker VM, and every root volume. The cluster object itself is not billed. Network, load balancer, and public-IP billing remains conditional on enabled platform packages and feature flags. Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. `zcp kubernetes get` now shows resource totals and configuration. `zcp kubernetes scale` enables autoscaling with minimum and maximum worker counts or disables it with a fixed worker count. +- **Kubernetes create uses separate control-plane, worker, and root-volume plans.** `zcp kubernetes create` now requires `--control-plane-plan`, `--worker-plan`, and `--storage-plan`, alongside `--storage-category`. The command sends the ZCP API `master_plan`, `worker_plan`, and `blockstorage_plan` fields instead of a single cluster plan. The root-volume plan applies to every node. Under the platform's resource-based model, the cluster object is not charged; charges for nodes, root volumes, and enabled network resources are determined by the platform. Use `--enable-csi` to request Cloud Storage Integration for a supported cluster. `zcp kubernetes get` now shows resource totals and configuration. `zcp kubernetes scale` submits autoscaling settings with minimum and maximum worker counts, or disables autoscaling with a fixed worker count. See the known limitations below. - **Scheduler-backed VM backup policies.** `zcp vm-backup schedule` creates, lists, reads, updates, pauses, resumes, runs, and deletes VM backup policies. Policies support hourly, daily, weekly, and monthly intervals, IANA timezones, retention counts, names, and descriptions. Weekly policies select a weekday; monthly policies select a day of the month. Policies can request an immediate first backup. - **`everyOtherDay` policy warning.** The command exposes the API's `everyOtherDay` value. The current platform returns an error after persisting that policy, so the command directs users to list policies before retrying. @@ -29,6 +31,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using - **Instance and Kubernetes creation now select root storage separately.** `zcp instance create` requires a named `--plan`, `--blockstorage-plan`, and `--root-disk-size`. `zcp kubernetes create` requires `--control-plane-plan`, `--worker-plan`, `--storage-plan`, and `--root-disk-size`. The root storage tier and capacity are sent separately from fixed compute plans. `zcp kubernetes get` lists each root volume's reported capacity separately from compute-plan storage. The retired instance `--cpu`, `--memory`, and `--disk` flags no longer select custom compute. Use `zcp plan vm` to choose a plan; its output includes `TAG`. +### 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. +- **`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. + ## [v0.0.31] - 2026-10-09 ### Security diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1bbad97..c352b0a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,166 +1,142 @@ -# Unreleased +# zcp v1.0.0 Release Notes -The next release will include the following fixes. No release version or date -has been selected. +v1.0.0 requires separate compute and root-storage selections for new instances +and Kubernetes clusters. It adds VM backup schedule commands, restore +preflight checks, lifecycle-state checks for deletion, and complete DNS lists. -## Fixed compute plans for instances +Replace every quoted placeholder in the examples with a value from your account. -`zcp instance create` now requires a named `--plan`. The `--cpu`, `--memory`, -and `--disk` flags have been retired. Use `zcp plan vm` to select a plan; its -output includes `TAG`. - -For compute-only plans, the price covers compute only. Root disks and network -resources are billed separately. Every new instance now requires -`--blockstorage-plan` and `--root-disk-size` alongside its compute plan. +## Highlights -## Kubernetes resource plans and billing +### Choose compute and root storage separately -`zcp kubernetes create` now requires separate selections for the control-plane -plan, worker plan, root-storage tier, and root-disk capacity used for every -node: +New instances use a named compute plan, a named root-storage plan, and an +explicit root-disk capacity. Kubernetes clusters use distinct control-plane, +worker, and root-storage plans. This makes the resources selected for compute +and root storage explicit in the request. ```bash -zcp kubernetes create --name my-cluster --version v1.37.0 \ - --control-plane-plan k8s-cpi-yul --worker-plan k8s-li-yul \ - --storage-plan b2g1 --root-disk-size 100 --storage-category pro-nvme \ - --region yul-1 --project default-9 --billing-cycle hourly \ - --workers 3 --enable-csi --ssh-key mykey +zcp plan vm --region yul-1 +zcp plan storage --region yul-1 + +zcp instance create \ + --name my-vm \ + --template ubuntu-2604-lts-1 \ + --plan "" \ + --blockstorage-plan "" \ + --root-disk-size 100 \ + --network-plan "" \ + --storage-category "" \ + --billing-cycle hourly \ + --region yul-1 \ + --project default-9 ``` -The cluster object has no recurring charge. The selected billing cycle applies -to the subscriptions created for each control-plane VM, worker VM, and root -volume. Network, load balancer, and public-IP subscriptions depend on the -platform packages and feature flags enabled for those resources. - -`zcp kubernetes get` now shows resource totals, separate control-plane and -worker configuration, root-volume count, and per-volume capacities separately -from compute-plan storage. It also shows network and autoscaling status when -the API returns them. `zcp kubernetes scale` supports manual worker counts, -enabling autoscaling with `--min-workers` and `--max-workers`, and disabling -autoscaling with an explicit `--workers` count. - -`zcp kubernetes delete` submits an immediate cancellation request. A successful -response confirms that the request was accepted; removal completes in the -background. -A response whose API status is not `Success`, including an HTTP 200 response, -is reported as an error instead of as an accepted cancellation request. -Deletion is refused while the cluster is Starting, Destroying, or its current -state cannot be verified. Failed clusters remain eligible for cleanup. -`zcp instance delete` also refuses deletion while a VM is Starting or its live -state cannot be verified. - -Use `--enable-csi` to request Cloud Storage Integration for a supported -cluster. Creating a high-availability cluster requires at least two control -nodes. - -## VM backup schedules - -`zcp vm-backup schedule` manages the scheduler-backed VM backup policies shown -in the cloud portal. Create policies with `hourly`, `dailyAt`, -`everyOtherDay`, `weeklyOn`, or `monthlyOn`, an IANA timezone, a retention -count, and an optional request for an immediate backup. Weekly policies use Sunday `0` -through Saturday `6`. Monthly policies use day `1` through `28`, matching the -provider's supported range. +Kubernetes creation now selects each node role and the root volume separately. +The root-storage selection and capacity apply to every cluster node. ```bash -zcp vm-backup schedule create my-vm --name nightly --interval dailyAt --at 01:00 \ - --timezone America/Toronto --retention 7 --immediate -zcp vm-backup schedule list -zcp vm-backup schedule pause -zcp vm-backup schedule run-now +zcp kubernetes create \ + --name my-cluster \ + --version "" \ + --control-plane-plan "" \ + --worker-plan "" \ + --storage-plan "" \ + --root-disk-size 100 \ + --storage-category "" \ + --workers 3 \ + --billing-cycle hourly \ + --region yul-1 \ + --project default-9 \ + --ssh-key "" ``` -Policies support get, update, pause, resume, run-now, and delete operations. -Delete all backups or snapshots created by a policy before deleting it. Pause the -policy to retain those resources without scheduling more. -Policy output includes the last-run result and any reported error; an active -policy does not by itself confirm a completed backup. - -The current platform rejects `everyOtherDay` after persisting a -policy. If it returns an error, list policies before retrying and remove any -unintended policy. - -## VM backup restore - -`zcp vm-backup restore --yes` restores a stopped VM -from a ready backup. It verifies the selected VM, backup ownership, region, and -project before sending the request. Restoring overwrites the VM's current disks. -Stop the VM with `zcp instance stop --wait` first. A successful -response only confirms the request was accepted. Wait for the VM to leave -`Restoring`, then use `zcp instance logs ` to check for failures. - -## VM backup create output - -`zcp vm-backup create` will print the created schedule, including its slug. It -will honor `--output json` and `--output yaml` without mixing status text into -structured output. When the create endpoint provides only an acknowledgement, -the command will identify one new matching schedule from a scoped snapshot and -read-only lookup. It will not repeat an accepted create request. If it cannot -identify the schedule, it will tell you to list schedules before trying again. - -## DNS status and pagination - -`zcp dns show ` will preserve a status supplied by the detail endpoint. -When that endpoint omits status, it will use the exactly matching entry from -`zcp dns list`. If no status is available, the display remains `-`. If the -lookup fails, the command returns an error. +`zcp kubernetes get` now displays reported node resources, root volumes, +network details, and autoscaling status when the API provides them. Use +`--enable-csi` on create to request Cloud Storage Integration for a supported +cluster. -`dns.Service.List` will retrieve every reported page and return an error rather -than a partial result when a later page fails or pagination metadata is -inconsistent. +### Manage VM backup schedules ---- +`zcp vm-backup schedule` can create, list, get, update, pause, resume, run-now, +and delete VM backup schedules. It supports hourly, daily, weekly, and monthly +intervals, IANA timezones, retention counts, and an optional immediate backup. -# zcp v0.0.31 Release Notes - -v0.0.31 fixes ACL rule listings that stopped at the first API page, adds -bounded ACL rule listing controls, and includes security updates to the Go -toolchain and networking dependencies. +```bash +zcp vm-backup schedule create my-vm \ + --name nightly \ + --interval dailyAt \ + --at 01:00 \ + --timezone America/Toronto \ + --retention 7 \ + --immediate -## Highlights +zcp vm-backup schedule list +zcp vm-backup schedule pause "" +zcp vm-backup schedule run-now "" +``` -**List every rule in an ACL.** `zcp acl rules` now retrieves every API page, so -ACLs with more than the default page size return their complete rule set. +`zcp vm-backup restore ` overwrites the VM's current +disks. It verifies that the VM is stopped and the backup is ready, belongs to +that VM, and is in the selected region and project before submitting the restore +request. It prompts for confirmation by default; use `--yes` to skip the prompt +when scripting an intentional restore: ```bash -zcp acl rules +zcp vm-backup restore "" "" --yes ``` -**Safer API pagination.** If a later page fails or reports inconsistent -pagination metadata, the command returns an error instead of a partial rule -list. A response that explicitly reports `total: 0` must contain no rules. +`zcp vm-backup create` also returns the created schedule slug in table, JSON, +and YAML output. -**Choose how many ACL rules to return.** Use `--max-items` for a bounded -result, `--starting-token` to resume it, `--page-size` to set the API request -size, or `--no-paginate` to request one API page. Bounded and single-page JSON -and YAML output use an object with a `rules` array and optional `next_token`. -Table output prints the continuation token on standard error. +### Safer lifecycle and DNS behavior -```bash -zcp acl rules --max-items 25 --output json -zcp acl rules --max-items 25 --starting-token '' --output json -zcp acl rules --no-paginate --output yaml -``` +- Instance and Kubernetes deletion commands check the current lifecycle state before + requesting immediate cancellation. API error envelopes are reported even when + returned with HTTP 200. +- `zcp dns list` retrieves every reported page and fails rather than returning a + partial list if a later page fails or its pagination metadata is inconsistent. +- `zcp dns show` uses the matching list entry when the detail response omits a + domain status. It shows `-` only when the API reports no status. -`--no-paginate` cannot be combined with the other pagination flags. A -continuation token applies only to the same ACL and retains its page size. +## Breaking changes and migration -**Security updates.** The selected Go toolchain is now Go 1.26.9 and -`golang.org/x/net` is now v0.60.0. These updates resolve the reachable -standard-library and networking vulnerabilities reported by `govulncheck`. +`zcp instance create` no longer accepts `--cpu`, `--memory`, or `--disk` to +select a custom compute plan. Choose a named compute plan with `zcp plan vm`. +Every new instance must also pass both `--blockstorage-plan` and +`--root-disk-size`. -## Go library consumers +`zcp kubernetes create` now requires `--control-plane-plan`, `--worker-plan`, +`--storage-plan`, and `--root-disk-size`. Replace a former single cluster-plan +selection with plans for both node roles and the shared root-storage tier. -`acl.Service.ListRules` now returns rules from every page. Callers receive an -error and no partial rule list when a later page cannot be retrieved or its -pagination metadata is inconsistent. Terraform provider maintainers must adopt -this `zcp-cli` module release to receive this behavior. +The root-storage plan must be a named available tier. A custom root-storage +plan is not supported by these commands. + +## Go library consumers ---- +The Go module adds scheduler-backed VM backup APIs, VM backup restore support, +DNS `FindBySlug`, richer Kubernetes response types, and root-storage fields on +instance and Kubernetes create requests. No exported request fields were +removed. DNS list callers now receive an error rather than a partial result when +pagination fails. Consumers that construct instance or Kubernetes create +requests should populate the separate root-storage fields before moving to this +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. +- 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. +- Kubernetes create/delete and VM backup restore operations run asynchronously. + A successful command confirms acceptance of the request, not completion of + the operation. Check the resource's current status and, after a restore, its + activity logs. ## Installation and upgrade -The install script installs the latest release and upgrades an existing +The install scripts install the latest release and upgrade an existing installation in place. **Linux / macOS** @@ -175,19 +151,20 @@ curl -fsSL https://github.com/zsoftly/zcp-cli/releases/latest/download/install.s irm https://github.com/zsoftly/zcp-cli/releases/latest/download/install.ps1 | iex ``` -**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`. +**Manual download:** download the binary or archive for your platform from the +[Releases](https://github.com/zsoftly/zcp-cli/releases) page. Extract an archive +first. Rename the platform binary to `zcp` (or `zcp.exe` on Windows). On Linux +or macOS, run `chmod +x zcp`, then place it on your `PATH`. **Verify:** ```bash -zcp version # zcp version v0.0.31 +zcp version # zcp version v1.0.0 ``` -First-time setup after installing: +First-time setup: ```bash -zcp profile add default --region yul-1 --project default-9 # prompts for bearer token +zcp profile add default --region yul-1 --project default-9 zcp auth validate ``` diff --git a/docs/commands.md b/docs/commands.md index 4bad067..74e7a2a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -83,9 +83,10 @@ shows the `ID` column to copy from. If a name is ambiguous (two VMs share it), t command lists the matching IDs and asks you to use one. Select a named compute plan with `zcp plan vm`; the listing includes each plan's -`TAG`. For compute-only plans, the price covers compute only. Root disks and -network resources are billed separately. Every new instance needs a named root -storage tier and an explicit root-disk capacity in GB. +`TAG`. Compute and root-storage selections are sent separately. The platform +determines charges for compute, root disks, and network resources. Every new +instance needs a named root-storage tier and an explicit root-disk capacity in +GB. ```bash # List and inspect @@ -594,10 +595,10 @@ zcp kubernetes create \ --enable-csi \ --ssh-key mykey -# The selected billing cycle applies to the resource subscriptions created for -# control-plane nodes, worker nodes, and their root volumes. Network, load -# balancer, and public-IP billing depends on the enabled platform packages and -# feature flags. +# The selected plans and billing cycle describe the control-plane, worker, and +# root-volume resources in the request. Charges are determined by the platform. +# Network, load balancer, and public-IP charges depend on enabled platform +# packages and feature flags. # Use --enable-csi to request Cloud Storage Integration for supported clusters. # Kubeconfig diff --git a/internal/commands/dns.go b/internal/commands/dns.go index 7c4961f..3067b36 100644 --- a/internal/commands/dns.go +++ b/internal/commands/dns.go @@ -485,6 +485,7 @@ func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() + //lint:ignore SA1019 --record-id intentionally supports legacy DNS deployments that expose numeric record IDs. 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) diff --git a/pkg/api/network/network.go b/pkg/api/network/network.go index 7e79894..3c2ae98 100644 --- a/pkg/api/network/network.go +++ b/pkg/api/network/network.go @@ -173,12 +173,6 @@ type CreateEgressRuleRequest struct { ICMPCode string `json:"icmp_code,omitempty"` } -// apiResponse is the STKCNSL standard envelope. -type apiResponse struct { - Status string `json:"status"` - Data any `json:"-"` -} - type listNetworkResponse struct { Status string `json:"status"` Data []Network `json:"data"` diff --git a/pkg/api/objectstorage/objectstorage.go b/pkg/api/objectstorage/objectstorage.go index 4fc4a22..bccd6e6 100644 --- a/pkg/api/objectstorage/objectstorage.go +++ b/pkg/api/objectstorage/objectstorage.go @@ -320,14 +320,6 @@ type keySingleResponse struct { Data Key `json:"data"` } -// objectListResponse is the API envelope for the object list endpoint. -// The live API wraps files inside data.files[], not data[]. -type objectListResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data ObjectListData `json:"data"` -} - // Service provides object storage API operations. type Service struct { client *httpclient.Client diff --git a/pkg/httpclient/client_retry_test.go b/pkg/httpclient/client_retry_test.go index 3a1501c..5c35f81 100644 --- a/pkg/httpclient/client_retry_test.go +++ b/pkg/httpclient/client_retry_test.go @@ -12,18 +12,6 @@ import ( "github.com/zsoftly/zcp-cli/pkg/httpclient" ) -// newFastClient returns a client pointing at srv with a short timeout and -// MaxRetries set to max so tests don't wait for real backoff durations. -// Tests that need to exercise backoff timing override Timeout themselves. -func newTestClient(srv *httptest.Server, maxRetries int) *httpclient.Client { - return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - BearerToken: "tok", - Timeout: 5 * time.Second, - MaxRetries: maxRetries, - }) -} - // TestRetryGET500EventuallySucceeds verifies that a GET retries on 500 and // succeeds once the server starts returning 200. func TestRetryGET500EventuallySucceeds(t *testing.T) {