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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 45 additions & 18 deletions CHANGELOG.md

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
# zcp v1.0.0 Release Notes
# zcp v1.0.1 Release Notes

v1.0.1 is a compatibility patch for the current API routes. It updates the Go
client's instance deletion workflow and hostname-change request.

## Fixed

- `instance.Service.Delete` retains its exported signature and now submits an
immediate Virtual Machine service-cancellation request. It resolves the VM's
billing cycle first. The legacy `expunge` argument remains accepted but has
no effect because cancellation is immediate.
- `instance.Service.ChangeHostname` sends the current instance name and the
requested hostname to the hostname-change endpoint. Changing the hostname
restarts the instance.
- `snapshot.Service.List` retrieves every reported page and returns an error
rather than partial results when a later page fails or pagination metadata is
inconsistent.
- `zcp volume create` accepts a named `--plan` with a positive `--size` to set
capacity in GB for the selected storage tier.

## v1.0.0 release material

v1.0.0 requires separate compute and root-storage selections for new instances
and Kubernetes clusters. It adds VM backup schedule commands, restore
Expand Down Expand Up @@ -125,7 +145,7 @@ release.

## Known limitations

- Automatic Kubernetes scale-out failed in staging. `zcp kubernetes scale --enable-autoscaling` submitted the requested autoscaling configuration, but no worker was added. Staging scaling tests also recreated existing node root-volume subscriptions. Billing correctness after scaling remains unverified; this release does not resolve that platform issue. Manual worker scaling was tested separately.
- Automatic Kubernetes scale-out is unverified. `zcp kubernetes scale --enable-autoscaling` submits the requested configuration, but the platform may not add a worker. Scaling can also change root-volume subscriptions. Billing correctness after scaling remains unverified.
- The current platform can persist an `everyOtherDay` VM backup policy before
returning an error. List schedules after an error and remove any unintended
policy before retrying.
Expand Down
2 changes: 1 addition & 1 deletion docs/api-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
| 5 | `/virtual-machines/{SLUG}/stop` | PUT | Stop VM | `vm` |
| 6 | `/virtual-machines/{SLUG}/reboot` | PUT | Reboot VM | `vm` |
| 7 | `/virtual-machines/{SLUG}/reset` | PUT | Reset VM | `vm` |
| 8 | `/virtual-machines/{SLUG}/change-label` | POST | Change VM label | `vm` |
| 8 | `/virtual-machines/{SLUG}/change-hostname` | POST | Change VM hostname | `vm` |
| 9 | `/virtual-machines/{SLUG}/change-password` | POST | Change VM password | `vm` |
| 10 | `/virtual-machines/{SLUG}/change-plan` | POST | Change VM plan/sizing | `vm` |
| 11 | `/virtual-machines/{SLUG}/change-template` | POST | Change VM template | `vm` |
Expand Down
6 changes: 4 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ zcp instance reset <slug> # Hard reset (prompts for confirmation)
zcp instance change-plan <slug> --plan <new-plan> --billing-cycle hourly

# Change hostname
# This restarts the instance.
zcp instance change-hostname <slug> --hostname new-hostname

# Change OS (DESTRUCTIVE: reinstalls the VM)
Expand Down Expand Up @@ -205,8 +206,9 @@ zcp volume create \
--project default-9 \
--region yul-1 \
--billing-cycle hourly \
--storage-category pro-nvme \
--plan b2g1
--storage-category ssd-storage \
--plan b2l2 \
--size 50
zcp volume create ... --vm <vm-slug> # Attach on creation
zcp volume attach <volume-slug> --vm <vm-slug>
zcp volume detach <volume-slug>
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export ZCP_REGION=yul-1
export ZCP_OUTPUT=json

# Create a volume without passing repetitive flags
zcp volume create --name my-disk --plan b2g1 --billing-cycle hourly
zcp volume create --name my-disk --plan b2l2 --size 50 --billing-cycle hourly --storage-category ssd-storage
```

### Local development: a sourced secrets file
Expand Down
37 changes: 37 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,43 @@ func TestInstanceGetRetryExhausted(t *testing.T) {
// verify the delete command derives billing_cycle for the service-cancel request.
const vmListWithBillingCycle = `{"status":"Success","message":"OK","data":[{"id":"a1b2c3","vm_id":"vm-1","name":"test-vm","slug":"test-vm","hostname":"test-vm","username":"ubuntu","state":"Running","request_status":true,"billing_cycle":{"id":"bc1","name":"Hourly","slug":"hourly","duration":1,"unit":"hour"},"is_vnf":false,"has_contract":false,"is_metrics_hidden":false,"is_restricted":false,"has_autoscale":false,"all_time_consumption":0,"created_at":"2026-01-01T00:00:00.000000Z","updated_at":"2026-01-01T00:00:00.000000Z","networks":[]}]}`

func TestInstanceChangeHostnameUsesCurrentName(t *testing.T) {
t.Setenv("ZCP_BEARER_TOKEN", "test-tok")
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/virtual-machines":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, minVMListBody)
case r.Method == http.MethodPost && r.URL.Path == "/virtual-machines/test-vm/change-hostname":
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decoding change-hostname request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"Success","message":"Changing hostname for the virtual machine..."}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

stdout, stderr, err := execCmd(t, NewInstanceCmd(), "change-hostname", "test-vm", "--hostname", "renamed-vm", "--api-url", srv.URL)
if err != nil {
t.Fatalf("change-hostname error = %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}
if got := body["name"]; got != "test-vm" {
t.Errorf("name = %#v, want %q", got, "test-vm")
}
if got := body["hostname"]; got != "renamed-vm" {
t.Errorf("hostname = %#v, want %q", got, "renamed-vm")
}
if _, ok := body["vm_label"]; ok {
t.Error("request body includes vm_label")
}
}

// TestInstanceDeleteUsesServiceCancel verifies that `zcp instance delete` routes through
// the unified service-cancellation endpoint (which releases the auto-assigned public IP),
// NOT the direct DELETE endpoint (which ignores delete_public_ip). Regression test for the
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ func newInstanceChangeHostnameCmd() *cobra.Command {

cmd := &cobra.Command{
Use: "change-hostname <slug>",
Short: "Change the hostname of a virtual machine",
Short: "Change a virtual machine hostname and restart it",
Args: exactArgs(1),
Example: ` zcp instance change-hostname my-vm --hostname new-hostname`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -986,7 +986,7 @@ func newInstanceChangeHostnameCmd() *cobra.Command {
return runInstanceChangeHostname(cmd, args[0], hostname)
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "New hostname (required)")
cmd.Flags().StringVar(&hostname, "hostname", "", "New hostname; the virtual machine restarts (required)")
return cmd
}

Expand All @@ -1006,7 +1006,7 @@ func runInstanceChangeHostname(cmd *cobra.Command, slug, hostname string) error
}

resp, err := svc.ChangeHostname(ctx, vm.Slug, instance.ChangeLabelRequest{
Name: hostname,
Name: vm.Name,
Hostname: hostname,
})
if err != nil {
Expand Down
15 changes: 6 additions & 9 deletions internal/commands/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ func newVolumeCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "Create a new block storage volume",
Example: ` zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --plan b2g1
zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --size 50
zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category pro-nvme --plan b2g1 --vm vm-slug`,
Example: ` zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --plan b2l2 --size 50
zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --size 50
zcp volume create --name my-disk --project default-9 --region yul-1 --billing-cycle hourly --storage-category ssd-storage --plan b2l2 --size 50 --vm vm-slug`,
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("--name is required")
Expand All @@ -122,9 +122,6 @@ func newVolumeCreateCmd() *cobra.Command {
return fmt.Errorf("--storage-category is required")
}
sizeChanged := cmd.Flags().Changed("size")
if plan != "" && sizeChanged {
return fmt.Errorf("--plan and --size are mutually exclusive")
}
if plan == "" && !sizeChanged {
return fmt.Errorf("--plan or --size is required")
}
Expand Down Expand Up @@ -176,9 +173,9 @@ func newVolumeCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (optional; auto-detected, override only)")
cmd.Flags().StringVar(&region, "region", "", "Region slug (required)")
cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly (required)")
cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. pro-nvme (required)")
cmd.Flags().StringVar(&plan, "plan", "", "Plan slug, e.g. b2g1 (mutually exclusive with --size)")
cmd.Flags().IntVar(&size, "size", 0, "Storage size in GB for custom-tier plans (mutually exclusive with --plan)")
cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug, e.g. ssd-storage (required)")
cmd.Flags().StringVar(&plan, "plan", "", "Storage tier slug, e.g. b2l2")
cmd.Flags().IntVar(&size, "size", 0, "Storage capacity in GB (with or without --plan)")
cmd.Flags().StringVar(&vmSlug, "vm", "", "Virtual machine slug to attach on creation")
cmd.Flags().StringVar(&coupon, "coupon", "", "Coupon code")
cmd.Flags().BoolVar(&isFreeTrial, "free-trial", false, "Use a free trial plan")
Expand Down
122 changes: 122 additions & 0 deletions internal/commands/volume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package commands

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestVolumeCreateRequestShapes(t *testing.T) {
cases := []struct {
name string
args []string
wantPlan string
hasPlan bool
wantCustomPlan bool
wantStorage *float64
}{
{
name: "plan only",
args: []string{"--plan", "b2l2"},
wantPlan: "b2l2",
hasPlan: true,
},
{
name: "size only",
args: []string{"--size", "50"},
wantCustomPlan: true,
wantStorage: float64Ptr(50),
},
{
name: "plan and size",
args: []string{"--plan", "b2l2", "--size", "50"},
wantPlan: "b2l2",
hasPlan: true,
wantCustomPlan: true,
wantStorage: float64Ptr(50),
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ZCP_BEARER_TOKEN", "test-token")

var request map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/blockstorages" {
http.NotFound(w, r)
return
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"Success","data":{"slug":"new-volume","name":"new-volume"}}`))
}))
defer srv.Close()

args := []string{
"--name", "new-volume",
"--cloud-provider", "test-cloud",
"--project", "test-project",
"--region", "test-region",
"--billing-cycle", "hourly",
"--storage-category", "ssd-storage",
"--api-url", srv.URL,
}
args = append(args, tc.args...)
_, _, err := execCmd(t, newVolumeCreateCmd(), args...)
if err != nil {
t.Fatalf("volume create: %v", err)
}
gotPlan, hasPlan := request["plan"]
if hasPlan != tc.hasPlan {
t.Errorf("plan present = %t, want %t", hasPlan, tc.hasPlan)
} else if tc.hasPlan && gotPlan != tc.wantPlan {
t.Errorf("plan = %#v, want %q", gotPlan, tc.wantPlan)
}
if got := request["is_custom_plan"]; got != tc.wantCustomPlan {
t.Errorf("is_custom_plan = %#v, want %t", got, tc.wantCustomPlan)
}
if tc.wantStorage == nil {
if _, ok := request["custom_plan"]; ok {
t.Errorf("custom_plan = %#v, want omitted", request["custom_plan"])
}
return
}
customPlan, ok := request["custom_plan"].(map[string]any)
if !ok {
t.Fatalf("custom_plan = %#v, want object", request["custom_plan"])
}
if got := customPlan["storage"]; got != *tc.wantStorage {
t.Errorf("custom_plan.storage = %#v, want %v", got, *tc.wantStorage)
}
})
}
}

func float64Ptr(value float64) *float64 {
return &value
}

func TestVolumeCreateRejectsNonPositiveCapacity(t *testing.T) {
for _, size := range []string{"0", "-1"} {
t.Run(size, func(t *testing.T) {
_, _, err := execCmd(t, newVolumeCreateCmd(),
"--name", "new-volume",
"--cloud-provider", "test-cloud",
"--project", "test-project",
"--region", "test-region",
"--billing-cycle", "hourly",
"--storage-category", "ssd-storage",
"--plan", "b2l2",
"--size", size,
)
if err == nil || err.Error() != "--size must be > 0" {
t.Fatalf("volume create error = %v, want non-positive size error", err)
}
})
}
}
Loading
Loading