diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 86f74c0..890dfc1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,31 +1,37 @@ --- name: Bug report about: Report a bug in zcp-cli -title: '[BUG] ' +title: "[BUG] " labels: bug -assignees: '' +assignees: "" --- ## Description + ## Steps to reproduce + ``` zcp --flags ``` ## Expected behavior + ## Actual behavior + ## Environment + - zcp version (`zcp version`): - OS / Architecture: - Shell: ## Debug output +
Debug output @@ -33,4 +39,5 @@ zcp --flags ``` paste here ``` +
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b78802d..3a6ddf7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,19 +1,23 @@ --- name: Feature request about: Suggest a new feature or improvement -title: '[FEATURE] ' +title: "[FEATURE] " labels: enhancement -assignees: '' +assignees: "" --- ## Summary + ## Motivation + ## Proposed behavior + ## Alternatives considered + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2986acb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +tmp/ +bin/ +vendor/ +node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1b59c87 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "proseWrap": "preserve", + "tabWidth": 2, + "useTabs": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d0f05..13b2e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to zcp will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.6] - 2026-04-08 + +### Changed + +- **API backend migration**: Migrated entire CLI from STKBILL to STKCNSL API backend +- **Authentication**: Switched from `apikey`/`secretkey` headers to Bearer token authentication +- **Config format**: Profile config now uses `bearer_token` instead of `apikey`/`secretkey` +- **API style**: All endpoints now use RESTful paths with slug identifiers instead of RPC-style paths with UUID query parameters +- **Response handling**: All API responses now parsed from `{status, message, data}` envelope format with built-in pagination support + +### Added + +- **`dns`**: Domain and record management (list, show, create, delete, record-create, record-delete) +- **`project`**: Project management (list, create, update, dashboard, icons, users) +- **`monitoring`**: Resource monitoring (global, per-VM CPU/memory/disk/network metrics) +- **`billing`**: Billing operations (costs, balance, invoices, subscriptions, usage, coupons, budget alerts) +- **`support`**: Support ticket management (CRUD, replies, feedback, FAQs) +- **`autoscale`**: VM autoscale groups with policies and conditions +- **`dashboard`**: Account service counts overview +- **`plan`**: Service plan listing for all resource types (VM, storage, K8s, LB, etc.) +- **`store`**: Store items and checkout +- **`marketplace`**: Marketplace app listing +- **`product`**: Product categories and listing +- **`iso`**: ISO image management (list, create, update, delete) +- **`affinity-group`**: Affinity group management (list, create, delete) +- **`backup`**: VM and block storage backup operations +- **`region`**: Region listing (replaces zone-based discovery) +- **`project delete`**: Delete projects with confirmation prompt +- **`--auto-approve` / `-y`**: Global flag to skip all confirmation prompts (useful for CI/CD automation) +- **`--blockstorage-plan`**: Required flag on `instance create` for selecting block storage plan size +- **`billing cancel-service`**: Now accepts `--service`, `--reason`, `--type` flags matching the API requirements +- **VM operations**: reboot, reset, tags, change-hostname, change-password, change-plan, change-OS, add-network, addons +- **Network egress rules**: list, create, delete egress firewall rules +- **VPC ACL management**: list, create, replace ACL rules +- **VPC VPN gateways**: list, create, delete +- **Load balancer rules**: create rules, attach VMs to rules +- **Discovery endpoints**: cloud-providers, currencies, storage-categories, billing-cycles, unit-pricings +- **Envelope helpers**: `GetEnvelope`/`PostEnvelope`/`PutEnvelope` on httpclient for clean response unwrapping +- **Generic response types**: `response.Envelope[T]` and `response.Single[T]` in new `api/response` package + +### Removed + +- **STKBILL API support**: All old `/restapi/` RPC-style endpoints removed +- **`apikey`/`secretkey` config fields**: Replaced by `bearer_token` +- **Zone-based filtering**: Replaced by region/slug-based resource identification + +**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.5...0.0.6 + +--- + ## [0.0.5] - 2026-03-31 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cfd311..b17544f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thank you for your interest in zcp-cli. Please use [GitHub Issues](https://github.com/zsoftly/zcp-cli/issues) to report bugs or request features. When filing a bug report, include: + - The `zcp` version (`zcp version`) - Your operating system and architecture - The exact command you ran diff --git a/Makefile b/Makefile index 7731a90..901029b 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ GO := go GOFMT := gofmt GOVET := $(GO) vet GOTEST := $(GO) test +PRETTIER := $(shell command -v prettier 2>/dev/null || echo "npx prettier") .DEFAULT_GOAL := help @@ -68,8 +69,14 @@ test-race: ## Run all tests with race detector ##@ Quality .PHONY: fmt -fmt: ## Format all Go source files +fmt: ## Format all Go source files and Markdown docs $(GOFMT) -w . + $(PRETTIER) --write '**/*.md' --prose-wrap preserve 2>/dev/null || true + +.PHONY: fmt-check +fmt-check: ## Check formatting without writing (useful in CI) + @test -z "$$($(GOFMT) -l .)" || { echo "Go files need formatting:"; $(GOFMT) -l .; exit 1; } + $(PRETTIER) --check '**/*.md' --prose-wrap preserve 2>/dev/null || { echo "Markdown files need formatting — run: make fmt"; exit 1; } .PHONY: vet vet: ## Run go vet diff --git a/README.md b/README.md index b07bf37..fbfe808 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The official command-line interface for the ZSoftly Cloud Platform ## Overview -ZCP CLI (`zcp`) is a full-featured command-line tool for managing resources on the ZSoftly Cloud Platform. It covers the complete lifecycle of cloud infrastructure: compute instances, block storage, snapshots, networks, VPCs, firewalls, load balancers, VPN gateways, SSH keys, Kubernetes clusters, and billing. All commands support table, JSON, and YAML output, making the CLI equally suited for interactive use and automation pipelines. +ZCP CLI (`zcp`) is a full-featured command-line tool for managing resources on the ZSoftly Cloud Platform. It covers the complete lifecycle of cloud infrastructure: compute instances, block storage, snapshots, networks, VPCs, firewalls, load balancers, VPN gateways, SSH keys, Kubernetes clusters, DNS, backups, autoscale policies, monitoring, projects, billing, and support. All commands support table, JSON, and YAML output, making the CLI equally suited for interactive use and automation pipelines. --- @@ -58,17 +58,17 @@ Requirements: Go 1.26.1+, GNU Make. ```bash # 1. Install (see above) -# 2. Add your first profile — prompts for API key and secret key +# 2. Add your first profile — prompts for bearer token zcp profile add default # 3. Confirm your credentials work zcp auth validate -# 4. Discover available zones -zcp zone list +# 4. Discover available regions +zcp region list # 5. List your instances -zcp instance list --zone +zcp instance list ``` --- @@ -84,22 +84,22 @@ zcp profile add default ``` You will be prompted for: -- API key -- Secret key -- API URL (leave blank to use the default: `https://cloud.zcp.zsoftly.ca`) + +- Bearer token +- API URL (leave blank to use the default) To add a named profile non-interactively: ```bash -zcp profile add staging --api-key YOUR_KEY --secret-key YOUR_SECRET +zcp profile add staging --bearer-token YOUR_TOKEN ``` ### Config File Location -| Platform | Path | -|--------------|-----------------------------------| -| Linux/macOS | `~/.config/zcp/config.yaml` | -| Windows | `%AppData%\zcp\config.yaml` | +| Platform | Path | +| ----------- | --------------------------- | +| Linux/macOS | `~/.config/zcp/config.yaml` | +| Windows | `%AppData%\zcp\config.yaml` | The file is created with `0600` permissions (owner read/write only) to protect credentials. @@ -119,8 +119,7 @@ zcp profile show staging zcp profile use staging # Update credentials or API URL on an existing profile -zcp profile update prod --api-key -zcp profile update prod --secret-key +zcp profile update prod --bearer-token zcp profile update prod --api-url-override https://custom.api.url # Rename a profile @@ -139,72 +138,107 @@ Use `zcp --help` for the full flag list of any command. ### Discovery ```bash -# Zones -zcp zone list -zcp zone list --uuid - -# Compute, storage, network, and VPC offerings +# Regions +zcp region list + +# Plans by service type (preferred over legacy 'offering' commands) +zcp plan vm # Virtual Machine plans +zcp plan storage # Block Storage plans +zcp plan kubernetes # Kubernetes plans +zcp plan lb # Load Balancer plans +zcp plan router # Virtual Router plans +zcp plan ip # IP Address plans +zcp plan vm-snapshot # VM Snapshot plans +zcp plan template # My Template plans +zcp plan iso # ISO plans +zcp plan backup # Backup plans + +# Legacy offerings (still available) zcp offering compute -zcp offering compute --zone zcp offering storage -zcp offering storage --zone zcp offering network zcp offering vpc # VM templates zcp template list -zcp template list --zone -# Resource availability +# Resource availability and quotas zcp resource available -zcp resource available --zone +zcp resource quota + +# Marketplace +zcp marketplace list + +# ISO images +zcp iso list + +# Store +zcp store list ``` ### Compute ```bash # List and inspect -zcp instance list --zone -zcp instance get --zone -zcp instance status +zcp instance list +zcp instance get # Create — use --wait to block until the instance is Running zcp instance create \ - --zone \ --name my-vm \ - --template \ - --compute-offering \ - --network - -zcp instance create ... --ssh-key mykey --wait + --cloud-provider nimbo \ + --project my-project \ + --region noida \ + --template ubuntu-22f \ + --plan bp-4vc-8gb \ + --billing-cycle hourly \ + --storage-category nvme \ + --blockstorage-plan 50-gb-2 \ + --ssh-key mykey + +zcp instance create ... --wait # Lifecycle -zcp instance start -zcp instance start --wait -zcp instance stop -zcp instance stop --force --wait -zcp instance reboot -zcp instance delete [--yes] [--expunge] +zcp instance start +zcp instance stop +zcp instance reboot +zcp instance reset # Hard reset (prompts for confirmation) -# Resize — change compute offering (instance must be stopped) -zcp instance resize --offering -zcp instance resize --offering --cpu 4 --memory 8192 +# Change plan (instance must be stopped) +zcp instance change-plan --plan --billing-cycle hourly -# Rename display name -zcp instance rename --display-name "My Web Server" +# Change hostname +zcp instance change-hostname --hostname new-hostname -# Recover from error state -zcp instance recover +# Change OS (DESTRUCTIVE — reinstalls the VM) +zcp instance change-os --template ubuntu-22f -# List attached networks and passwords -zcp instance network-list -zcp instance password-list --zone -zcp instance password-list --zone --instance +# Change startup script +zcp instance change-script --user-data "#!/bin/bash\napt update" + +# Change password +zcp instance change-password --password "newSecureP@ss" + +# Add a network to a running instance +zcp instance add-network --network + +# Activity logs +zcp instance logs + +# Tags +zcp instance tag-create --key env --value prod +zcp instance tag-delete --key env + +# Addons +zcp instance addons # Open an SSH session directly from the CLI -zcp instance ssh -zcp instance ssh --user ubuntu -zcp instance ssh --user root --identity-file ~/.ssh/my-key.pem --port 2222 +zcp instance ssh +zcp instance ssh --user ubuntu +zcp instance ssh --user root --identity-file ~/.ssh/my-key.pem --port 2222 + +# To cancel/delete an instance, use billing cancel-service: +zcp billing cancel-service --service "Virtual Machine" --reason not_needed_anymore ``` The `--wait` flag on `create`, `start`, and `stop` polls the API until the instance reaches the target state, printing progress to stderr. @@ -213,26 +247,33 @@ The `--wait` flag on `create`, `start`, and `stop` polls the API until the insta ```bash # Volumes -zcp volume list --zone -zcp volume create --zone --name my-disk --storage-offering -zcp volume attach --instance -zcp volume detach -zcp volume delete [--yes] +zcp volume list +zcp volume create \ + --name my-disk \ + --project my-project \ + --cloud-provider nimbo \ + --region noida \ + --billing-cycle hourly \ + --storage-category nvme \ + --plan 50-gb-2 +zcp volume create ... --vm # Attach on creation +zcp volume attach --vm +zcp volume detach # Snapshots zcp snapshot list -zcp snapshot create --volume --zone --name my-snapshot -zcp snapshot delete [--yes] +zcp snapshot create --volume --name my-snapshot +zcp snapshot delete # VM snapshots (whole-instance checkpoint) zcp vm-snapshot list -zcp vm-snapshot create --zone --name my-checkpoint --instance -zcp vm-snapshot revert [--yes] +zcp vm-snapshot create --name my-checkpoint --instance +zcp vm-snapshot revert # Snapshot policies (automated scheduling) zcp snapshot-policy list zcp snapshot-policy create \ - --volume \ + --volume \ --interval daily \ --time 02:00 \ --timezone America/Toronto \ @@ -243,36 +284,36 @@ zcp snapshot-policy create \ ```bash # Networks -zcp network list --zone -zcp network create --zone --name my-net --offering -zcp network delete [--yes] +zcp network list +zcp network create --name my-net --network-offering +zcp network delete # Public IP addresses -zcp ip list --zone -zcp ip allocate --network -zcp ip release [--yes] -zcp ip static-nat enable --instance --network +zcp ip list +zcp ip allocate --network +zcp ip release +zcp ip static-nat enable --instance --network # Firewall rules (ingress) -zcp firewall list --zone -zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 +zcp firewall list +zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 # Egress rules -zcp egress list --zone -zcp egress create --network --protocol tcp +zcp egress list +zcp egress create --network --protocol tcp # Port forwarding -zcp portforward list --zone +zcp portforward list zcp portforward create \ - --ip \ + --ip \ --protocol tcp \ --public-port 2222 \ --private-port 22 \ - --instance + --instance # Resource tags zcp tag list -zcp tag create --zone --resource --type Instance --key env --value prod +zcp tag create --resource --type Instance --key env --value prod ``` ### Advanced Networking @@ -280,28 +321,28 @@ zcp tag create --zone --resource --type Instance --key env -- ```bash # VPCs zcp vpc list -zcp vpc create --zone --name my-vpc --offering --cidr 10.0.0.0/16 -zcp vpc delete [--yes] +zcp vpc create --name my-vpc --vpc-offering --cidr 10.0.0.0/16 +zcp vpc delete # Network ACLs zcp acl list -zcp acl create --vpc --name my-acl -zcp acl delete [--yes] +zcp acl create --vpc --name my-acl +zcp acl delete # Public load balancers -zcp loadbalancer list --zone -zcp loadbalancer create --ip --name my-lb --algorithm roundrobin -zcp loadbalancer delete [--yes] +zcp loadbalancer list +zcp loadbalancer create --ip --name my-lb --algorithm roundrobin +zcp loadbalancer delete # Internal load balancers (VPC-scoped) -zcp internal-lb list --zone -zcp internal-lb create --network --name my-internal-lb -zcp internal-lb delete [--yes] +zcp internal-lb list +zcp internal-lb create --network --name my-internal-lb +zcp internal-lb delete # VPN gateways and connections -zcp vpn list --zone -zcp vpn create --vpc --name my-vpn -zcp vpn delete [--yes] +zcp vpn list +zcp vpn create --vpc --name my-vpn +zcp vpn delete ``` ### Security and Access @@ -310,40 +351,182 @@ zcp vpn delete [--yes] # SSH keys zcp ssh-key list zcp ssh-key create --name mykey --public-key "$(cat ~/.ssh/id_rsa.pub)" -zcp ssh-key delete [--yes] +zcp ssh-key delete # Security groups zcp security-group list zcp security-group create --name my-sg --description "Web tier" -zcp security-group delete [--yes] +zcp security-group delete + +# Affinity groups +zcp affinity-group list +zcp affinity-group create --name my-ag --type host-affinity +zcp affinity-group delete +``` + +### DNS + +```bash +# Domains +zcp dns list +zcp dns show + +# Create a domain +zcp dns create --name example.com --project my-project + +# Create a record +zcp dns record-create --domain --name www --type A --content 192.0.2.1 +zcp dns record-create --domain --name mail --type MX --content mail.example.com --ttl 3600 + +# Delete a record or domain +zcp dns record-delete --domain --record-id 42 +zcp dns delete +``` + +### Backup + +```bash +zcp backup list +zcp backup get +zcp backup create --instance --name my-backup +zcp backup restore +zcp backup delete +``` + +### Autoscale + +```bash +zcp autoscale list +zcp autoscale get +zcp autoscale create --name my-policy --min 1 --max 5 +zcp autoscale delete +``` + +### Monitoring + +```bash +zcp monitoring list +zcp monitoring get +zcp monitoring create --instance --type cpu --threshold 80 +zcp monitoring delete +``` + +### Project + +```bash +zcp project list +zcp project create --name my-project --icon cloud-15 --purpose "Development" +zcp project update --name "New Name" --description "Updated description" +zcp project delete +zcp project dashboard + +# Project users +zcp project user list +zcp project user add --email alice@example.com --role admin + +# Project icons +zcp project icon list ``` ### Kubernetes ```bash # 'k8s' is accepted as an alias for 'kubernetes' -zcp kubernetes list --zone -zcp kubernetes get -zcp kubernetes create --zone --name my-cluster --offering -zcp kubernetes delete [--yes] -zcp kubernetes kubeconfig # Download kubeconfig for kubectl access +zcp kubernetes list +zcp kubernetes create \ + --name my-cluster \ + --version v1.28.4 \ + --plan k8s-plan-1 \ + --region noida \ + --project default-59 \ + --cloud-provider nimbo \ + --billing-cycle monthly \ + --workers 3 \ + --ssh-key mykey + +# HA cluster with multiple control nodes +zcp kubernetes create \ + --name ha-cluster \ + --version v1.28.4 \ + --plan k8s-plan-1 \ + --region noida \ + --project default-59 \ + --cloud-provider nimbo \ + --billing-cycle monthly \ + --workers 3 \ + --control-nodes 3 \ + --ha \ + --ssh-key mykey + +# Start / stop / upgrade +zcp kubernetes start +zcp kubernetes stop +zcp kubernetes upgrade --plan k8s-plan-2 + +# To cancel/delete a cluster, use billing cancel-service: +zcp billing cancel-service --service "Kubernetes" --reason not_needed_anymore ``` ### Billing and Admin ```bash -# Usage records +# Account balance and costs +zcp billing balance +zcp billing costs +zcp billing monthly-usage +zcp billing usage +zcp billing credit-limit +zcp billing service-counts +zcp billing free-credits + +# Invoices and payments +zcp billing invoices +zcp billing invoices --page 2 +zcp billing invoices-count +zcp billing payments + +# Subscriptions +zcp billing subscriptions active +zcp billing subscriptions inactive +zcp billing contracts +zcp billing trials + +# Cancel a service (instances, volumes, IPs, etc.) +zcp billing cancel-service --service "Virtual Machine" --reason not_needed_anymore +zcp billing cancel-service --service "Block Storage" --reason not_needed_anymore --type Immediate +zcp billing cancel-requests + +# Coupons +zcp billing coupons +zcp billing redeem-coupon SAVE50 + +# Budget alerts +zcp billing budget-alert +zcp billing budget-alert-set --amount 500 --threshold 80 --enabled + +# Legacy usage and cost commands zcp usage list -zcp usage list --zone -zcp usage list --output csv - -# Cost summary zcp cost summary -zcp cost summary --zone # Admin operations (requires elevated permissions) zcp admin list-accounts -zcp admin get-account +zcp admin get-account +``` + +### Support + +```bash +zcp support list +zcp support get +zcp support create --subject "Issue title" --description "Details" +zcp support close +``` + +### Dashboard + +```bash +zcp dashboard summary +zcp dashboard status ``` ### Auth @@ -362,26 +545,26 @@ All listing commands support three output formats controlled by the `--output` ( **Table (default)** ```bash -zcp zone list +zcp region list ``` ``` -UUID NAME COUNTRY ACTIVE ----- ---- ------- ------ -3a7c1e2d-... Toronto Canada true -b91f4a8c-... Montreal Canada true +SLUG NAME COUNTRY ACTIVE +---- ---- ------- ------ +toronto Toronto Canada true +montreal Montreal Canada true ``` **JSON** ```bash -zcp zone list --output json +zcp region list --output json ``` ```json [ { - "uuid": "3a7c1e2d-...", + "slug": "toronto", "name": "Toronto", "country_name": "Canada", "is_active": "true" @@ -392,11 +575,11 @@ zcp zone list --output json **YAML** ```bash -zcp zone list --output yaml +zcp region list --output yaml ``` ```yaml -- uuid: 3a7c1e2d-... +- slug: toronto name: Toronto country_name: Canada is_active: "true" @@ -408,17 +591,16 @@ zcp zone list --output yaml These flags are available on every command: -| Flag | Default | Description | -|---------------|----------------------------------|----------------------------------------------------| -| `--profile` | active profile from config | Profile name to use for this invocation | -| `--output` | `table` | Output format: `table`, `json`, `yaml` | -| `--api-url` | `https://cloud.zcp.zsoftly.ca` | Override the API base URL | -| `--timeout` | `30` | HTTP request timeout in seconds | -| `--debug` | `false` | Enable debug output (requests/responses to stderr) | -| `--no-color` | `false` | Disable ANSI color in table output | -| `--pager` | `false` | Pipe table output through a pager (`less`) | - -The `-o` shorthand is accepted for `--output`. +| Flag | Short | Default | Description | +| ---------------- | ----- | -------------------------- | -------------------------------------------------- | +| `--profile` | | active profile from config | Profile name to use for this invocation | +| `--output` | `-o` | `table` | Output format: `table`, `json`, `yaml` | +| `--auto-approve` | `-y` | `false` | Skip all confirmation prompts (useful for CI) | +| `--api-url` | | from profile config | Override the API base URL | +| `--timeout` | | `30` | HTTP request timeout in seconds | +| `--debug` | | `false` | Enable debug output (requests/responses to stderr) | +| `--no-color` | | `false` | Disable ANSI color in table output | +| `--pager` | | `false` | Pipe table output through a pager (`less`) | --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 91919fd..1f8f7b8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,88 @@ -# zcp 0.0.5 Release Notes +# zcp 0.0.6 Release Notes ## What's New -### Delete verification +### API backend migration -Delete commands now verify the resource is actually removed. Previously, the API could -return success (HTTP 204) while silently failing — the CLI would report "deleted" when -the resource was still there. Now affected commands check after delete and warn if the -resource still exists. +The CLI now communicates with the STKCNSL API backend, replacing the previous STKBILL backend. This is a breaking change for configuration files — existing profiles using `apikey`/`secretkey` must be recreated with `bearer_token`. -Applies to: `vpc delete`, `network delete`, `volume delete`, `security-group delete` +### Bearer token authentication -### Volume list deduplication +Authentication now uses a single bearer token instead of separate API key and secret key. Update your profiles: -The API sometimes returns duplicate entries for the same volume. The CLI now deduplicates -by UUID before displaying results. +```bash +zcp profile add default --bearer-token YOUR_TOKEN +``` + +Config files now use `bearer_token` instead of `apikey`/`secretkey`. + +### 15 new command groups + +| Command | Description | +| -------------------- | ------------------------------------------------------ | +| `zcp dns` | DNS domain and record management | +| `zcp project` | Project management with users and dashboards | +| `zcp monitoring` | Global and per-VM resource monitoring | +| `zcp billing` | Costs, invoices, subscriptions, coupons, budget alerts | +| `zcp support` | Support tickets, replies, feedback, FAQs | +| `zcp autoscale` | VM autoscale groups with policies and conditions | +| `zcp dashboard` | Account service counts overview | +| `zcp plan` | Service plans for all resource types | +| `zcp store` | Store items and checkout | +| `zcp marketplace` | Marketplace app listing | +| `zcp product` | Product categories and listing | +| `zcp iso` | ISO image management | +| `zcp affinity-group` | Affinity group management | +| `zcp backup` | VM and block storage backups | +| `zcp region` | Region listing | + +### Expanded existing commands + +- **instance**: reboot, reset, tags, change-hostname, change-password, change-plan, change-OS, add-network, addons +- **instance create**: now requires `--blockstorage-plan` flag (e.g. `50-gb-2`, `100gb`) +- **project**: added `delete` subcommand with confirmation prompt +- **billing cancel-service**: now requires `--service` flag and supports `--reason`, `--type` +- **network**: egress firewall rule management +- **vpc**: ACL management, VPN gateway management +- **loadbalancer**: rule creation, VM attachment to rules +- **Discovery**: cloud-providers, currencies, storage-categories, billing-cycles, unit-pricings + +### Auto-approve for CI/CD + +All destructive commands now respect the global `--auto-approve` (or `-y`) flag, skipping confirmation prompts. Useful for scripting and automation pipelines: + +```bash +zcp -y project delete my-project +zcp -y billing cancel-service my-vm --service "Virtual Machine" +``` + +### RESTful API with pagination + +All endpoints now use clean RESTful paths with slug identifiers. List responses include pagination metadata (`current_page`, `per_page`, `total`). + +### VM creation example + +```bash +zcp instance create \ + --name my-vm \ + --cloud-provider nimbo \ + --project my-project \ + --region noida \ + --template ubuntu-22f \ + --plan bp-4vc-8gb \ + --billing-cycle hourly \ + --storage-category nvme \ + --blockstorage-plan 50-gb-2 \ + --ssh-key my-key +``` + +--- -### Friendlier error messages +## Breaking Changes -- `snapshot create` on a detached volume now says: - `volume must be attached to a running instance before taking a snapshot` - instead of the raw CloudStack error. -- `firewall list` on accounts with no IP addresses now returns an empty table - instead of `API error 412: Invalid IpAddress Details`. +- **Config format**: `apikey`/`secretkey` replaced by `bearer_token`. Run `zcp profile add` to reconfigure. +- **Zone commands**: `zcp zone list` still works but `zcp region list` is the new canonical command. +- **UUID flags**: Flags like `--zone-uuid`, `--uuid` replaced by slug-based identifiers. --- @@ -57,4 +117,4 @@ Download the binary for your platform from the assets below, make it executable, | Windows | amd64 | `zcp-windows-amd64.exe` | | Windows | arm64 | `zcp-windows-arm64.exe` | -**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.4...0.0.5 +**Full Changelog**: https://github.com/zsoftly/zcp-cli/compare/0.0.5...0.0.6 diff --git a/cmd/zcp/root/root.go b/cmd/zcp/root/root.go index ad135de..ca59f5a 100644 --- a/cmd/zcp/root/root.go +++ b/cmd/zcp/root/root.go @@ -11,13 +11,14 @@ import ( ) var ( - profileFlag string - outputFlag string - apiURLFlag string - timeoutFlag int - debugFlag bool - noColorFlag bool - pagerFlag bool + profileFlag string + outputFlag string + apiURLFlag string + timeoutFlag int + debugFlag bool + noColorFlag bool + pagerFlag bool + autoApproveFlag bool ) var rootCmd = &cobra.Command{ @@ -56,6 +57,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Enable debug output (written to stderr)") rootCmd.PersistentFlags().BoolVar(&noColorFlag, "no-color", false, "Disable color output") rootCmd.PersistentFlags().BoolVar(&pagerFlag, "pager", false, "Pipe table output through less (requires less in PATH)") + rootCmd.PersistentFlags().BoolVarP(&autoApproveFlag, "auto-approve", "y", false, "Skip all confirmation prompts (useful for automation/CI)") // Version subcommand rootCmd.AddCommand(newVersionCmd()) @@ -96,6 +98,22 @@ func init() { rootCmd.AddCommand(commands.NewHostCmd()) rootCmd.AddCommand(commands.NewAdminCmd()) + // STKCNSL API — new feature commands + rootCmd.AddCommand(commands.NewProjectCmd()) + rootCmd.AddCommand(commands.NewSupportCmd()) + rootCmd.AddCommand(commands.NewDNSCmd()) + rootCmd.AddCommand(commands.NewAutoscaleCmd()) + rootCmd.AddCommand(commands.NewISOCmd()) + rootCmd.AddCommand(commands.NewAffinityGroupCmd()) + rootCmd.AddCommand(commands.NewMonitoringCmd()) + rootCmd.AddCommand(commands.NewStoreCmd()) + rootCmd.AddCommand(commands.NewProductCmd()) + rootCmd.AddCommand(commands.NewMarketplaceCmd()) + rootCmd.AddCommand(commands.NewPlanCmd()) + rootCmd.AddCommand(commands.NewDashboardCmd()) + rootCmd.AddCommand(commands.NewBillingCmd()) + rootCmd.AddCommand(commands.NewBackupCmd()) + // Flag completions — static values, no network calls rootCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"table", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 9db7d67..a1e3536 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -1,343 +1,427 @@ # ZCP API Inventory -**Base URL**: `https://cloud.zcp.zsoftly.ca/` -**Spec Version**: OpenAPI 3.0.1 -**Total Endpoints**: 166 -**Auth**: `apikey` and `secretkey` HTTP request headers on every call +**Base URL**: `https://cloud.zcp.zsoftly.ca/api/v1` +**Auth**: Bearer token (`Authorization: Bearer `) +**Style**: RESTful, resource-oriented endpoints with `{SLUG}` path parameters --- ## Endpoint Table -| # | Path | Method | Summary | CLI Group | Phase | Scope | Async? | -|---|------|--------|---------|-----------|-------|-------|--------| -| 1 | `/restapi/asyncjob/resourceStatus` | GET | Resource Status | (internal) | 1 | Customer | — | -| 2 | `/restapi/availableResource/getAvailableResourceByDomain` | GET | Resources Availability | `resource` | 1 | Customer | No | -| 3 | `/restapi/compute/computeOfferingList` | GET | Compute Offering List | `offering compute` | 1 | Customer | No | -| 4 | `/restapi/compute/computeOfferingListWithPrice` | GET | Compute Offering With Price | `offering compute` | 1 | Customer | No | -| 5 | `/restapi/costestimate/additional-template-resize-cost` | GET | Additional Template Resize Cost | `cost` | 3 | Customer | No | -| 6 | `/restapi/costestimate/bandwidth-cost` | GET | Bandwidth Cost | `cost` | 3 | Customer | No | -| 7 | `/restapi/costestimate/compute-category-list` | GET | Compute Category List | `cost` | 3 | Customer | No | -| 8 | `/restapi/costestimate/compute-plan-list` | GET | Compute Offering Cost | `cost` | 3 | Customer | No | -| 9 | `/restapi/costestimate/compute-plan-types` | GET | Compute Plan Types | `cost` | 3 | Customer | No | -| 10 | `/restapi/costestimate/getpublickey` | GET | Get Public Key | `cost` | 3 | Public | No | -| 11 | `/restapi/costestimate/ip-cost` | GET | IP Address Cost | `cost` | 3 | Customer | No | -| 12 | `/restapi/costestimate/k8s-cost` | GET | Kubernetes Cost | `cost` | 3 | Customer | No | -| 13 | `/restapi/costestimate/kubernetes-version-list` | GET | Kubernetes Version List | `cost` | 3 | Customer | No | -| 14 | `/restapi/costestimate/list-all-support-category` | GET | Support Categories | `cost` | 3 | Customer | No | -| 15 | `/restapi/costestimate/list-all-support-plans` | GET | Support Plans | `cost` | 3 | Customer | No | -| 16 | `/restapi/costestimate/list-sfs-storage-offerings` | GET | List SFS Storage Offerings | `cost` | 3 | Customer | No | -| 17 | `/restapi/costestimate/loadbalancer-cost` | GET | Load Balancer Cost | `cost` | 3 | Customer | No | -| 18 | `/restapi/costestimate/multicurrency` | GET | Multi-Currency List | `cost` | 3 | Customer | No | -| 19 | `/restapi/costestimate/networkoffering-list` | GET | Network Offering List (cost) | `cost` | 3 | Customer | No | -| 20 | `/restapi/costestimate/object-storage-cost` | GET | Object Storage Cost | `cost` | 3 | Customer | No | -| 21 | `/restapi/costestimate/portforwarding-cost` | GET | Port Forwarding Cost | `cost` | 3 | Customer | No | -| 22 | `/restapi/costestimate/service-list` | GET | Service List | `cost` | 3 | Customer | No | -| 23 | `/restapi/costestimate/snapshot-cost` | GET | Snapshot Cost | `cost` | 3 | Customer | No | -| 24 | `/restapi/costestimate/storage-category-list` | GET | Storage Category List | `cost` | 3 | Customer | No | -| 25 | `/restapi/costestimate/storage-plan-list` | GET | Storage Plan List | `cost` | 3 | Customer | No | -| 26 | `/restapi/costestimate/tax` | GET | Tax | `cost` | 3 | Customer | No | -| 27 | `/restapi/costestimate/template-category-list` | GET | Template Category List | `cost` | 3 | Customer | No | -| 28 | `/restapi/costestimate/template-distribution-list` | GET | Template Distribution List | `cost` | 3 | Customer | No | -| 29 | `/restapi/costestimate/template-list` | GET | Template List (cost) | `cost` | 3 | Customer | No | -| 30 | `/restapi/costestimate/template-platform-list` | GET | Template Platform List | `cost` | 3 | Customer | No | -| 31 | `/restapi/costestimate/vm-scheduler-cost` | GET | VM Scheduler Cost | `cost` | 3 | Customer | No | -| 32 | `/restapi/costestimate/vm-snapshot-cost` | GET | VM Snapshot Cost | `cost` | 3 | Customer | No | -| 33 | `/restapi/costestimate/vpcoffering-list` | GET | VPC Offering List (cost) | `cost` | 3 | Customer | No | -| 34 | `/restapi/costestimate/vpn-user-cost` | GET | VPN User Cost | `cost` | 3 | Customer | No | -| 35 | `/restapi/costestimate/zone-list` | GET | Zone List (cost) | `cost` | 3 | Customer | No | -| 36 | `/restapi/egressrule/createEgressRule` | POST | Create Egress Rule | `egress` | 2 | Customer | No | -| 37 | `/restapi/egressrule/deleteEgressRule/{uuid}` | DELETE | Delete Egress Rule | `egress` | 2 | Customer | No | -| 38 | `/restapi/egressrule/egressRuleList` | GET | Egress Rule List | `egress` | 1 | Customer | No | -| 39 | `/restapi/firewallrule/createFirewallRule` | POST | Create Firewall Rule | `firewall` | 2 | Customer | No | -| 40 | `/restapi/firewallrule/deleteFirewallRule/{uuid}` | DELETE | Delete Firewall Rule | `firewall` | 2 | Customer | No | -| 41 | `/restapi/firewallrule/firewallRuleList` | GET | Firewall Rule List | `firewall` | 1 | Customer | No | -| 42 | `/restapi/host/hostList` | GET | Host List | `admin host` | 1 | Admin | No | -| 43 | `/restapi/instance/attachIso` | GET | Attach ISO to Instance | `instance` | 3 | Customer | No | -| 44 | `/restapi/instance/attachNetwork` | POST | Attach Network to Instance | `instance` | 2 | Customer | No | -| 45 | `/restapi/instance/createInstance` | POST | Create Instance | `instance` | 2 | Customer | No | -| 46 | `/restapi/instance/destroyInstance` | GET | Destroy Instance | `instance` | 2 | Customer | No | -| 47 | `/restapi/instance/detachIso` | GET | Detach ISO | `instance` | 3 | Customer | No | -| 48 | `/restapi/instance/detachNetwork` | POST | Detach Network from Instance | `instance` | 2 | Customer | No | -| 49 | `/restapi/instance/instanceList` | GET | Instance List | `instance` | 1 | Customer | No | -| 50 | `/restapi/instance/instanceNetworkList` | GET | Instance Network List | `instance` | 1 | Customer | No | -| 51 | `/restapi/instance/instancePasswordList` | GET | Instance Password List | `instance` | 2 | Customer | No | -| 52 | `/restapi/instance/recoverVm` | GET | Recover VM Instance | `instance` | 2 | Customer | No | -| 53 | `/restapi/instance/resetSSHkey` | GET | Reset Instance SSH Key | `instance` | 2 | Customer | No | -| 54 | `/restapi/instance/resizeVm` | GET | Resize VM Instance | `instance` | 2 | Customer | No | -| 55 | `/restapi/instance/startInstance` | GET | Start Instance | `instance` | 2 | Customer | No | -| 56 | `/restapi/instance/stopInstance` | GET | Stop Instance | `instance` | 2 | Customer | No | -| 57 | `/restapi/instance/updateInstanceName` | PUT | Update Instance Name | `instance` | 2 | Customer | No | -| 58 | `/restapi/instance/vmStatus` | GET | VM Status | `instance` | 1 | Customer | No | -| 59 | `/restapi/internallb/assignLbRule` | GET | Assign Internal LB Rule | `internal-lb` | 2 | Customer | No | -| 60 | `/restapi/internallb/createInternalLb` | POST | Create Internal LB | `internal-lb` | 2 | Customer | No | -| 61 | `/restapi/internallb/deleteInternalLb/{uuid}` | DELETE | Delete Internal LB | `internal-lb` | 2 | Customer | No | -| 62 | `/restapi/internallb/internalLbList` | GET | Internal LB List | `internal-lb` | 1 | Customer | No | -| 63 | `/restapi/invoice/changeInvoiceCost` | GET | Change Invoice Payment Cost | `admin invoice` | 3 | Admin | No | -| 64 | `/restapi/invoice/generateInvoice` | GET | Generate Invoice | `admin invoice` | 3 | Admin | No | -| 65 | `/restapi/invoice/getInvoicePaymentStatus` | GET | Invoice Payment Status | `admin invoice` | 3 | Admin | No | -| 66 | `/restapi/invoice/listByClient` | GET | Invoice List by Client | `admin invoice` | 3 | Admin | No | -| 67 | `/restapi/invoice/listTaxPendingInvoice` | GET | Tax Pending Invoice List | `admin invoice` | 3 | Admin | No | -| 68 | `/restapi/invoice/updateInvoiceStatus` | POST | Update Invoice Status | `admin invoice` | 3 | Admin | No | -| 69 | `/restapi/invoice/updateInvoiceTax` | POST | Update Invoice Tax | `admin invoice` | 3 | Admin | No | -| 70 | `/restapi/ipaddress/acquireIpAddress` | GET | Acquire IP Address | `ip` | 2 | Customer | No | -| 71 | `/restapi/ipaddress/disableStaticNat` | DELETE | Disable Static NAT | `ip` | 2 | Customer | No | -| 72 | `/restapi/ipaddress/disableremotevpnaccess` | DELETE | Disable Remote VPN Access | `ip` | 2 | Customer | No | -| 73 | `/restapi/ipaddress/enableStaticNat` | POST | Enable Static NAT | `ip` | 2 | Customer | No | -| 74 | `/restapi/ipaddress/enableremotevpnaccess` | GET | Enable Remote VPN Access | `ip` | 2 | Customer | No | -| 75 | `/restapi/ipaddress/ipAddressList` | GET | IP Address List | `ip` | 1 | Customer | No | -| 76 | `/restapi/ipaddress/releaseIpAddress` | DELETE | Release IP Address | `ip` | 2 | Customer | No | -| 77 | `/restapi/kubernetes/createKubernetes` | POST | Create Kubernetes Cluster | `kubernetes` | 2 | Customer | No | -| 78 | `/restapi/kubernetes/destroyKubernetes` | DELETE | Destroy Kubernetes Cluster | `kubernetes` | 2 | Customer | No | -| 79 | `/restapi/kubernetes/listCluster` | GET | List Kubernetes Clusters | `kubernetes` | 1 | Customer | No | -| 80 | `/restapi/kubernetes/listNodes` | GET | List Kubernetes Nodes | `kubernetes` | 1 | Customer | No | -| 81 | `/restapi/kubernetes/scaleKubernetes` | PUT | Scale Kubernetes Cluster | `kubernetes` | 2 | Customer | No | -| 82 | `/restapi/kubernetes/startKubernetes` | PUT | Start Kubernetes Cluster | `kubernetes` | 2 | Customer | No | -| 83 | `/restapi/kubernetes/stopKubernetes` | PUT | Stop Kubernetes Cluster | `kubernetes` | 2 | Customer | No | -| 84 | `/restapi/loadbalancerrule/createLoadBalancerRule` | POST | Create Load Balancer Rule | `loadbalancer` | 2 | Customer | No | -| 85 | `/restapi/loadbalancerrule/deleteLoadBalancerRule/{uuid}` | DELETE | Delete Load Balancer Rule | `loadbalancer` | 2 | Customer | No | -| 86 | `/restapi/loadbalancerrule/loadBalancerRuleList` | GET | Load Balancer Rule List | `loadbalancer` | 1 | Customer | No | -| 87 | `/restapi/loadbalancerrule/updateLoadBalancerRule` | PUT | Update Load Balancer Rule | `loadbalancer` | 2 | Customer | No | -| 88 | `/restapi/network/changeSecurityGroup` | GET | Change Network Security Group | `network` | 2 | Customer | No | -| 89 | `/restapi/network/createNetwork` | POST | Create Network | `network` | 2 | Customer | No | -| 90 | `/restapi/network/deleteNetwork/{uuid}` | DELETE | Delete Network | `network` | 2 | Customer | No | -| 91 | `/restapi/network/networkId` | GET | Get Network by ID | `network` | 1 | Customer | No | -| 92 | `/restapi/network/networkList` | GET | Network List | `network` | 1 | Customer | No | -| 93 | `/restapi/network/replaceAcl` | GET | Replace Network ACL | `network` | 2 | Customer | No | -| 94 | `/restapi/network/restartNetwork` | GET | Restart Network | `network` | 2 | Customer | No | -| 95 | `/restapi/network/updateNetwork` | PUT | Update Network | `network` | 2 | Customer | No | -| 96 | `/restapi/networkacllist/createNetworkAcl` | POST | Create Network ACL | `acl` | 2 | Customer | No | -| 97 | `/restapi/networkacllist/deleteNetworkAcl/{uuid}` | DELETE | Delete Network ACL | `acl` | 2 | Customer | No | -| 98 | `/restapi/networkacllist/networkAclList` | GET | Network ACL List | `acl` | 1 | Customer | No | -| 99 | `/restapi/networkoffering/networkOfferingList` | GET | Network Offering List | `offering network` | 1 | Customer | No | -| 100 | `/restapi/networkoffering/vpcNetworkOfferingList` | GET | VPC Network Offering List | `offering network` | 1 | Customer | No | -| 101 | `/restapi/portforwardingrule/createPortForwardingRule` | POST | Create Port Forwarding Rule | `portforward` | 2 | Customer | No | -| 102 | `/restapi/portforwardingrule/deletePortForwardingRule/{uuid}` | DELETE | Delete Port Forwarding Rule | `portforward` | 2 | Customer | No | -| 103 | `/restapi/portforwardingrule/portForwardingRuleList` | GET | Port Forwarding Rule List | `portforward` | 1 | Customer | No | -| 104 | `/restapi/resource-quota/get-resource-limit` | GET | Get Resource Quota Limits | `admin quota` | 1 | Admin | No | -| 105 | `/restapi/resourcetags/createTags` | POST | Create Resource Tags | `tag` | 2 | Customer | No | -| 106 | `/restapi/resourcetags/deleteResourceTag/{uuid}` | DELETE | Delete Resource Tag | `tag` | 2 | Customer | No | -| 107 | `/restapi/resourcetags/resourceTagsList` | GET | Resource Tags List | `tag` | 1 | Customer | No | -| 108 | `/restapi/securitygroup/createSecurityGroup` | POST | Create Security Group | `security-group` | 2 | Customer | No | -| 109 | `/restapi/securitygroup/createSecurityGroupEgressRule` | POST | Create SG Egress Rule | `security-group` | 2 | Customer | No | -| 110 | `/restapi/securitygroup/createSecurityGroupFirewallRule` | POST | Create SG Firewall Rule | `security-group` | 2 | Customer | No | -| 111 | `/restapi/securitygroup/createSecurityGroupPortForwardingRule` | POST | Create SG Port Forwarding Rule | `security-group` | 2 | Customer | No | -| 112 | `/restapi/securitygroup/deleteSecurityGroup/{uuid}` | DELETE | Delete Security Group | `security-group` | 2 | Customer | No | -| 113 | `/restapi/securitygroup/deleteSecurityGroupRule` | DELETE | Delete Security Group Rule | `security-group` | 2 | Customer | No | -| 114 | `/restapi/securitygroup/securityList` | GET | Security Group List | `security-group` | 1 | Customer | No | -| 115 | `/restapi/snapshot/createSnapshot` | POST | Create Snapshot | `snapshot` | 2 | Customer | No | -| 116 | `/restapi/snapshot/deleteSnapshot/{uuid}` | DELETE | Delete Snapshot | `snapshot` | 2 | Customer | No | -| 117 | `/restapi/snapshot/snapshotList` | GET | Snapshot List | `snapshot` | 1 | Customer | No | -| 118 | `/restapi/snapshotPolicy/createSnapshotPolicy` | POST | Create Snapshot Policy | `snapshot-policy` | 2 | Customer | No | -| 119 | `/restapi/snapshotPolicy/deleteSnapshotPolicy/{uuid}` | DELETE | Delete Snapshot Policy | `snapshot-policy` | 2 | Customer | No | -| 120 | `/restapi/snapshotPolicy/snapshotPolicyList` | GET | Snapshot Policy List | `snapshot-policy` | 1 | Customer | No | -| 121 | `/restapi/sshkey/createSSHkey` | POST | Create SSH Key | `ssh-key` | 2 | Customer | No | -| 122 | `/restapi/sshkey/deleteSSHkey/{uuid}` | DELETE | Delete SSH Key | `ssh-key` | 2 | Customer | No | -| 123 | `/restapi/sshkey/sshkeyList` | GET | SSH Key List | `ssh-key` | 1 | Customer | No | -| 124 | `/restapi/storage/storageOfferingList` | GET | Storage Offering List | `offering storage` | 1 | Customer | No | -| 125 | `/restapi/storage/storageOfferingListWithPrice` | GET | Storage Offering With Price | `offering storage` | 1 | Customer | No | -| 126 | `/restapi/template/templateList` | GET | Template List | `template` | 1 | Customer | No | -| 127 | `/restapi/usage/usageConsumptionList` | GET | Usage Consumption List | `usage` | 3 | Customer | No | -| 128 | `/restapi/usage/usageConsumptionListWithSubDomain` | GET | Usage Consumption With Sub-Domain | `usage` | 3 | Customer | No | -| 129 | `/restapi/usage/usageProgressStatus` | GET | Usage Progress Status | `usage` | 3 | Customer | No | -| 130 | `/restapi/usage/usageReportList` | GET | Usage Report List | `usage` | 3 | Customer | No | -| 131 | `/restapi/user/creditBalance` | GET | User Credit Balance | `admin user` | 3 | Admin | No | -| 132 | `/restapi/vmsnapshot/createVmSnapshot` | POST | Create VM Snapshot | `vm-snapshot` | 2 | Customer | Yes | -| 133 | `/restapi/vmsnapshot/deleteVmSnapshot/{uuid}` | DELETE | Delete VM Snapshot | `vm-snapshot` | 2 | Customer | No | -| 134 | `/restapi/vmsnapshot/revertToVmSnapshot` | GET | Revert to VM Snapshot | `vm-snapshot` | 2 | Customer | Yes | -| 135 | `/restapi/vmsnapshot/vmsnapshotList` | GET | VM Snapshot List | `vm-snapshot` | 1 | Customer | No | -| 136 | `/restapi/volume/attachVolume` | GET | Attach Volume | `volume` | 2 | Customer | Yes | -| 137 | `/restapi/volume/createVolume` | POST | Create Volume | `volume` | 2 | Customer | Yes | -| 138 | `/restapi/volume/deleteVolume/{uuid}` | DELETE | Delete Volume | `volume` | 2 | Customer | No | -| 139 | `/restapi/volume/detachVolume` | GET | Detach Volume | `volume` | 2 | Customer | Yes | -| 140 | `/restapi/volume/resizeVolume` | GET | Resize Volume | `volume` | 2 | Customer | Yes | -| 141 | `/restapi/volume/uploadVolume` | POST | Upload Volume | `volume` | 3 | Customer | Yes | -| 142 | `/restapi/volume/volumeList` | GET | Volume List | `volume` | 1 | Customer | No | -| 143 | `/restapi/vpc/createVpc` | POST | Create VPC | `vpc` | 2 | Customer | No | -| 144 | `/restapi/vpc/createVpcNetwork` | POST | Create VPC Network | `vpc` | 2 | Customer | No | -| 145 | `/restapi/vpc/deleteVpc/{uuid}` | DELETE | Delete VPC | `vpc` | 2 | Customer | No | -| 146 | `/restapi/vpc/restartVpc` | GET | Restart VPC | `vpc` | 2 | Customer | No | -| 147 | `/restapi/vpc/updateVpc` | PUT | Update VPC | `vpc` | 2 | Customer | No | -| 148 | `/restapi/vpc/updateVpcNetwork` | PUT | Update VPC Network | `vpc` | 2 | Customer | No | -| 149 | `/restapi/vpc/vpcId` | GET | Get VPC by ID | `vpc` | 1 | Customer | No | -| 150 | `/restapi/vpc/vpcList` | GET | VPC List | `vpc` | 1 | Customer | No | -| 151 | `/restapi/vpcoffering/vpcOfferingList` | GET | VPC Offering List | `offering vpc` | 1 | Customer | No | -| 152 | `/restapi/vpnconnection/addVpnConnection` | POST | Add VPN Connection | `vpn connection` | 2 | Customer | No | -| 153 | `/restapi/vpnconnection/deleteVpnConnection/{uuid}` | DELETE | Delete VPN Connection | `vpn connection` | 2 | Customer | No | -| 154 | `/restapi/vpnconnection/resetVpnConnection/{uuid}` | PUT | Reset VPN Connection | `vpn connection` | 2 | Customer | No | -| 155 | `/restapi/vpnconnection/vpnConnectionList` | GET | VPN Connection List | `vpn connection` | 1 | Customer | No | -| 156 | `/restapi/vpncustomergateway/addVpnCustomerGateway` | POST | Add VPN Customer Gateway | `vpn customer-gateway` | 2 | Customer | No | -| 157 | `/restapi/vpncustomergateway/deleteVpnCustomerGateway/{uuid}` | DELETE | Delete VPN Customer Gateway | `vpn customer-gateway` | 2 | Customer | No | -| 158 | `/restapi/vpncustomergateway/updateVpnCustomerGateway` | PUT | Update VPN Customer Gateway | `vpn customer-gateway` | 2 | Customer | No | -| 159 | `/restapi/vpncustomergateway/vpnCustomerGatewayList` | GET | VPN Customer Gateway List | `vpn customer-gateway` | 1 | Customer | No | -| 160 | `/restapi/vpngateway/addVpnGateway` | POST | Add VPN Gateway | `vpn gateway` | 2 | Customer | No | -| 161 | `/restapi/vpngateway/deleteVpnGateway/{uuid}` | DELETE | Delete VPN Gateway | `vpn gateway` | 2 | Customer | No | -| 162 | `/restapi/vpngateway/vpnGatewayList` | GET | VPN Gateway List | `vpn gateway` | 1 | Customer | No | -| 163 | `/restapi/vpnuser/addVpnUser` | POST | Add VPN User | `vpn user` | 2 | Customer | No | -| 164 | `/restapi/vpnuser/deleteVpnUser` | DELETE | Delete VPN User | `vpn user` | 2 | Customer | No | -| 165 | `/restapi/vpnuser/vpnUserlist` | GET | VPN User List | `vpn user` | 1 | Customer | No | -| 166 | `/restapi/zone/zonelist` | GET | Zone List | `zone` | 1 | Customer | No | - -**Phase key:** -- Phase 1 — Read-only discovery: list, get, status operations (building now) -- Phase 2 — Instance lifecycle, volume, network, and standard CRUD operations -- Phase 3 — Advanced/ancillary: cost estimates, usage reporting, ISO, upload, admin billing - -**Async key:** -- `Yes` — Response object contains a `jobId` field; poll `/restapi/asyncjob/resourceStatus?jobId=` for completion -- `No` — Operation returns the final result synchronously +### Compute (Virtual Machines) + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------------------------ | ------ | ------------------------ | --------- | +| 1 | `/virtual-machines` | GET | List all VMs | `vm` | +| 2 | `/virtual-machines` | POST | Create a VM | `vm` | +| 3 | `/virtual-machines/{SLUG}` | GET | Get VM details | `vm` | +| 4 | `/virtual-machines/{SLUG}/start` | PUT | Start VM | `vm` | +| 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` | +| 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` | +| 12 | `/virtual-machines/{SLUG}/change-startup-script` | POST | Change VM startup script | `vm` | +| 13 | `/virtual-machines/{SLUG}/add-network` | POST | Add network to VM | `vm` | +| 14 | `/virtual-machines/{SLUG}/tags` | POST | Add tags to VM | `vm` | +| 15 | `/virtual-machines/{SLUG}/tags` | DELETE | Remove tags from VM | `vm` | +| 16 | `/virtual-machines/{SLUG}/addons` | GET | List VM addons | `vm` | + +### Storage (Block Storage) + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------ | ------ | --------------------------- | --------- | +| 17 | `/blockstorages` | GET | List block storage volumes | `storage` | +| 18 | `/blockstorages` | POST | Create block storage volume | `storage` | +| 19 | `/blockstorages/{SLUG}/attach` | POST | Attach volume to VM | `storage` | +| 20 | `/blockstorages/{SLUG}/detach` | POST | Detach volume from VM | `storage` | + +### Snapshots + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------------------- | ------ | ------------------------- | ---------- | +| 21 | `/virtual-machines/snapshots` | GET | List all VM snapshots | `snapshot` | +| 22 | `/virtual-machines/{SLUG}/snapshots` | POST | Create VM snapshot | `snapshot` | +| 23 | `/virtual-machines/{SLUG}/snapshots/revert` | POST | Revert to VM snapshot | `snapshot` | +| 24 | `/blockstorages/snapshots` | GET | List all volume snapshots | `snapshot` | +| 25 | `/blockstorages/{SLUG}/snapshots` | POST | Create volume snapshot | `snapshot` | +| 26 | `/blockstorages/{SLUG}/snapshots/revert` | POST | Revert to volume snapshot | `snapshot` | + +### Backups + +| # | Path | Method | Summary | CLI Group | +| --- | ---------------------------------- | ------ | ----------------------- | --------- | +| 27 | `/virtual-machines/backups` | GET | List all VM backups | `backup` | +| 28 | `/virtual-machines/{SLUG}/backups` | POST | Create VM backup | `backup` | +| 29 | `/blockstorages/backups` | GET | List all volume backups | `backup` | +| 30 | `/blockstorages/{SLUG}/backups` | POST | Create volume backup | `backup` | + +### Kubernetes + +| # | Path | Method | Summary | CLI Group | +| --- | ----------------------------------------- | ------ | ------------------------- | ------------ | +| 31 | `/kubernetes-clusters` | GET | List Kubernetes clusters | `kubernetes` | +| 32 | `/kubernetes-clusters` | POST | Create Kubernetes cluster | `kubernetes` | +| 33 | `/kubernetes-clusters/{SLUG}/start` | PUT | Start Kubernetes cluster | `kubernetes` | +| 34 | `/kubernetes-clusters/{SLUG}/stop` | PUT | Stop Kubernetes cluster | `kubernetes` | +| 35 | `/kubernetes-clusters/{SLUG}/change-plan` | PUT | Change cluster plan | `kubernetes` | + +### Load Balancers + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------- | ------ | -------------------------- | --------- | +| 36 | `/load-balancers` | GET | List load balancers | `lb` | +| 37 | `/load-balancers` | POST | Create load balancer | `lb` | +| 38 | `/load-balancers/{SLUG}/rules` | POST | Add load balancer rule | `lb` | +| 39 | `/load-balancers/{SLUG}/attach` | POST | Attach VM to load balancer | `lb` | + +### Autoscale + +| # | Path | Method | Summary | CLI Group | +| --- | ----------------------------------- | ------ | -------------------------- | ----------- | +| 40 | `/autoscale` | GET | List autoscale groups | `autoscale` | +| 41 | `/autoscale` | POST | Create autoscale group | `autoscale` | +| 42 | `/autoscale/{SLUG}/change-plan` | POST | Change autoscale plan | `autoscale` | +| 43 | `/autoscale/{SLUG}/change-template` | POST | Change autoscale template | `autoscale` | +| 44 | `/autoscale/{SLUG}/enable` | PUT | Enable autoscale group | `autoscale` | +| 45 | `/autoscale/{SLUG}/disable` | PUT | Disable autoscale group | `autoscale` | +| 46 | `/autoscale/{SLUG}/policies` | GET | List autoscale policies | `autoscale` | +| 47 | `/autoscale/{SLUG}/policies` | POST | Create autoscale policy | `autoscale` | +| 48 | `/autoscale/{SLUG}/policies/{ID}` | PUT | Update autoscale policy | `autoscale` | +| 49 | `/autoscale/{SLUG}/policies/{ID}` | DELETE | Delete autoscale policy | `autoscale` | +| 50 | `/autoscale/{SLUG}/conditions` | GET | List autoscale conditions | `autoscale` | +| 51 | `/autoscale/{SLUG}/conditions` | POST | Create autoscale condition | `autoscale` | +| 52 | `/autoscale/{SLUG}/conditions/{ID}` | PUT | Update autoscale condition | `autoscale` | +| 53 | `/autoscale/{SLUG}/conditions/{ID}` | DELETE | Delete autoscale condition | `autoscale` | + +### Networks + +| # | Path | Method | Summary | CLI Group | +| --- | --------------------------------------------- | ------ | --------------------------- | --------- | +| 54 | `/networks` | GET | List networks | `network` | +| 55 | `/networks` | POST | Create network | `network` | +| 56 | `/networks/{SLUG}` | PUT | Update network | `network` | +| 57 | `/networks/categories` | GET | List network categories | `network` | +| 58 | `/networks/{SLUG}/egress-firewall-rules` | GET | List egress firewall rules | `network` | +| 59 | `/networks/{SLUG}/egress-firewall-rules` | POST | Create egress firewall rule | `network` | +| 60 | `/networks/{SLUG}/egress-firewall-rules/{ID}` | PUT | Update egress firewall rule | `network` | +| 61 | `/networks/{SLUG}/egress-firewall-rules/{ID}` | DELETE | Delete egress firewall rule | `network` | + +### Virtual Routers + +| # | Path | Method | Summary | CLI Group | +| --- | -------------------------------- | ------ | --------------------- | --------- | +| 62 | `/virtual-routers` | GET | List virtual routers | `router` | +| 63 | `/virtual-routers` | POST | Create virtual router | `router` | +| 64 | `/virtual-routers/{SLUG}/reboot` | GET | Reboot virtual router | `router` | + +### VPC + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------------ | ------ | ------------------ | --------- | +| 65 | `/vpcs` | GET | List VPCs | `vpc` | +| 66 | `/vpcs` | POST | Create VPC | `vpc` | +| 67 | `/vpcs/{SLUG}` | PUT | Update VPC | `vpc` | +| 68 | `/vpcs/{SLUG}/restart` | GET | Restart VPC | `vpc` | +| 69 | `/vpcs/{SLUG}/network-acl-list` | GET | List network ACLs | `vpc` | +| 70 | `/vpcs/{SLUG}/network-acl-list` | POST | Create network ACL | `vpc` | +| 71 | `/vpcs/{SLUG}/network-acl-list/{ID}` | PUT | Update network ACL | `vpc` | +| 72 | `/vpcs/{SLUG}/network-acl-list/{ID}` | DELETE | Delete network ACL | `vpc` | +| 73 | `/vpcs/{SLUG}/vpn-gateways` | GET | List VPN gateways | `vpc` | +| 74 | `/vpcs/{SLUG}/vpn-gateways` | POST | Create VPN gateway | `vpc` | +| 75 | `/vpcs/{SLUG}/vpn-gateways/{ID}` | PUT | Update VPN gateway | `vpc` | +| 76 | `/vpcs/{SLUG}/vpn-gateways/{ID}` | DELETE | Delete VPN gateway | `vpc` | + +### IP Addresses + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------------------------ | ------ | --------------------------- | --------- | +| 77 | `/ipaddresses` | GET | List IP addresses | `ip` | +| 78 | `/ipaddresses` | POST | Acquire IP address | `ip` | +| 79 | `/ipaddresses/{SLUG}/static-nat` | POST | Enable/disable static NAT | `ip` | +| 80 | `/ipaddresses/{SLUG}/firewall-rules` | GET | List firewall rules | `ip` | +| 81 | `/ipaddresses/{SLUG}/firewall-rules` | POST | Create firewall rule | `ip` | +| 82 | `/ipaddresses/{SLUG}/firewall-rules/{ID}` | PUT | Update firewall rule | `ip` | +| 83 | `/ipaddresses/{SLUG}/firewall-rules/{ID}` | DELETE | Delete firewall rule | `ip` | +| 84 | `/ipaddresses/{SLUG}/port-forwarding-rules` | GET | List port forwarding rules | `ip` | +| 85 | `/ipaddresses/{SLUG}/port-forwarding-rules` | POST | Create port forwarding rule | `ip` | +| 86 | `/ipaddresses/{SLUG}/port-forwarding-rules/{ID}` | PUT | Update port forwarding rule | `ip` | +| 87 | `/ipaddresses/{SLUG}/port-forwarding-rules/{ID}` | DELETE | Delete port forwarding rule | `ip` | +| 88 | `/ipaddresses/{SLUG}/remote-access-vpns` | GET | List remote access VPNs | `ip` | +| 89 | `/ipaddresses/{SLUG}/remote-access-vpns` | POST | Create remote access VPN | `ip` | +| 90 | `/ipaddresses/{SLUG}/remote-access-vpns/{ID}` | PUT | Update remote access VPN | `ip` | +| 91 | `/ipaddresses/{SLUG}/remote-access-vpns/{ID}` | DELETE | Delete remote access VPN | `ip` | + +### VPN + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------------- | ------ | --------------------------- | --------- | +| 92 | `/vpn-users` | GET | List VPN users | `vpn` | +| 93 | `/vpn-users` | POST | Create VPN user | `vpn` | +| 94 | `/vpn-users/{SLUG}` | PUT | Update VPN user | `vpn` | +| 95 | `/vpn-users/{SLUG}` | DELETE | Delete VPN user | `vpn` | +| 96 | `/vpn-customer-gateways` | GET | List VPN customer gateways | `vpn` | +| 97 | `/vpn-customer-gateways` | POST | Create VPN customer gateway | `vpn` | +| 98 | `/vpn-customer-gateways/{SLUG}` | PUT | Update VPN customer gateway | `vpn` | +| 99 | `/vpn-customer-gateways/{SLUG}` | DELETE | Delete VPN customer gateway | `vpn` | + +### DNS + +| # | Path | Method | Summary | CLI Group | +| --- | ---------------------------------- | ------ | ----------------- | --------- | +| 100 | `/dns/domains` | GET | List DNS domains | `dns` | +| 101 | `/dns/domains` | POST | Create DNS domain | `dns` | +| 102 | `/dns/domains/{SLUG}` | PUT | Update DNS domain | `dns` | +| 103 | `/dns/domains/{SLUG}` | DELETE | Delete DNS domain | `dns` | +| 104 | `/dns/domains/{SLUG}/records` | POST | Create DNS record | `dns` | +| 105 | `/dns/domains/{SLUG}/records/{ID}` | DELETE | Delete DNS record | `dns` | + +### Projects + +| # | Path | Method | Summary | CLI Group | +| --- | ---------------------------- | ------ | --------------------- | --------- | +| 106 | `/projects` | GET | List projects | `project` | +| 107 | `/projects` | POST | Create project | `project` | +| 108 | `/projects/{SLUG}` | PUT | Update project | `project` | +| 109 | `/projects/{SLUG}/dashboard` | GET | Get project dashboard | `project` | +| 110 | `/projects/{SLUG}/icons` | GET | Get project icons | `project` | +| 111 | `/projects/{SLUG}/users` | GET | List project users | `project` | +| 112 | `/projects/{SLUG}/users` | POST | Add user to project | `project` | + +### ISOs + +| # | Path | Method | Summary | CLI Group | +| --- | -------------- | ------ | ------------------- | --------- | +| 113 | `/isos` | GET | List ISOs | `iso` | +| 114 | `/isos` | POST | Upload/register ISO | `iso` | +| 115 | `/isos/{SLUG}` | PUT | Update ISO | `iso` | +| 116 | `/isos/{SLUG}` | DELETE | Delete ISO | `iso` | + +### Affinity Groups + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------- | ------ | --------------------- | ---------------- | +| 117 | `/affinity-groups` | GET | List affinity groups | `affinity-group` | +| 118 | `/affinity-groups` | POST | Create affinity group | `affinity-group` | +| 119 | `/affinity-groups/{SLUG}` | DELETE | Delete affinity group | `affinity-group` | + +### Templates + +| # | Path | Method | Summary | CLI Group | +| --- | --------------------------- | ------ | ----------------------- | ---------- | +| 120 | `/templates` | GET | List public templates | `template` | +| 121 | `/account/templates` | GET | List account templates | `template` | +| 122 | `/account/templates` | POST | Create account template | `template` | +| 123 | `/account/templates/{SLUG}` | PUT | Update account template | `template` | +| 124 | `/account/templates/{SLUG}` | DELETE | Delete account template | `template` | + +### Monitoring + +| # | Path | Method | Summary | CLI Group | +| --- | --------------------------------------- | ------ | -------------------------- | ------------ | +| 125 | `/monitoring/global` | GET | Global monitoring overview | `monitoring` | +| 126 | `/monitoring/charts` | GET | Monitoring chart data | `monitoring` | +| 127 | `/monitoring/{SLUG}/cpu-usage` | GET | VM CPU usage metrics | `monitoring` | +| 128 | `/monitoring/{SLUG}/disk-read-write` | GET | VM disk read/write metrics | `monitoring` | +| 129 | `/monitoring/{SLUG}/memory-usage` | GET | VM memory usage metrics | `monitoring` | +| 130 | `/monitoring/{SLUG}/network-traffic` | GET | VM network traffic metrics | `monitoring` | +| 131 | `/monitoring/{SLUG}/disk-io-read-write` | GET | VM disk I/O metrics | `monitoring` | + +### Billing + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------- | ------ | ------------------------- | --------- | +| 132 | `/billing/costs` | GET | Get current costs | `billing` | +| 133 | `/billing/balance` | GET | Get account balance | `billing` | +| 134 | `/billing/monthly-usage` | GET | Get monthly usage summary | `billing` | +| 135 | `/billing/credit-limit` | GET | Get credit limit | `billing` | +| 136 | `/billing/service-counts` | GET | Get service counts | `billing` | +| 137 | `/billing/subscriptions` | GET | List subscriptions | `billing` | +| 138 | `/billing/invoices` | GET | List invoices | `billing` | +| 139 | `/billing/usage` | GET | Get detailed usage | `billing` | +| 140 | `/billing/free-credits` | GET | Get free credits | `billing` | +| 141 | `/billing/contracts` | GET | List contracts | `billing` | +| 142 | `/billing/trials` | GET | List trials | `billing` | +| 143 | `/billing/payments` | GET | List payments | `billing` | +| 144 | `/billing/coupons` | GET | List coupons | `billing` | +| 145 | `/billing/coupons` | POST | Apply coupon | `billing` | +| 146 | `/billing/budget-alerts` | GET | List budget alerts | `billing` | +| 147 | `/billing/budget-alerts` | POST | Create budget alert | `billing` | +| 148 | `/billing/cancel-service` | POST | Cancel a service | `billing` | + +### Profile + +| # | Path | Method | Summary | CLI Group | +| --- | -------------------------- | ------ | ---------------------- | --------- | +| 149 | `/profile` | GET | Get user profile | `profile` | +| 150 | `/profile` | PUT | Update user profile | `profile` | +| 151 | `/profile/company-details` | PUT | Update company details | `profile` | +| 152 | `/profile/time-settings` | POST | Update time settings | `profile` | +| 153 | `/profile/api-enable` | POST | Enable API access | `profile` | +| 154 | `/profile/api-disable` | DELETE | Disable API access | `profile` | +| 155 | `/profile/activity-logs` | GET | Get activity logs | `profile` | + +### SSH Keys + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------ | ------ | -------------- | --------- | +| 156 | `/users/ssh-keys` | GET | List SSH keys | `ssh-key` | +| 157 | `/users/ssh-keys` | POST | Create SSH key | `ssh-key` | +| 158 | `/users/ssh-keys/{SLUG}` | DELETE | Delete SSH key | `ssh-key` | + +### Support + +| # | Path | Method | Summary | CLI Group | +| --- | --------------------------------- | ------ | --------------------- | --------- | +| 159 | `/support/tickets` | GET | List support tickets | `support` | +| 160 | `/support/tickets` | POST | Create support ticket | `support` | +| 161 | `/support/tickets/{SLUG}` | PUT | Update support ticket | `support` | +| 162 | `/support/tickets/{SLUG}` | DELETE | Delete support ticket | `support` | +| 163 | `/support/tickets/{SLUG}/replies` | GET | List ticket replies | `support` | +| 164 | `/support/tickets/{SLUG}/replies` | POST | Reply to ticket | `support` | +| 165 | `/support/feedback` | GET | List feedback | `support` | +| 166 | `/support/feedback` | POST | Submit feedback | `support` | +| 167 | `/support/faqs` | GET | List FAQs | `support` | + +### Plans + +| # | Path | Method | Summary | CLI Group | +| --- | ------------------------- | ------ | ------------------------ | --------- | +| 168 | `/plans/service/VM` | GET | List VM plans | `plan` | +| 169 | `/plans/service/Router` | GET | List router plans | `plan` | +| 170 | `/plans/service/Storage` | GET | List storage plans | `plan` | +| 171 | `/plans/service/LB` | GET | List load balancer plans | `plan` | +| 172 | `/plans/service/K8s` | GET | List Kubernetes plans | `plan` | +| 173 | `/plans/service/IP` | GET | List IP address plans | `plan` | +| 174 | `/plans/service/Snapshot` | GET | List snapshot plans | `plan` | +| 175 | `/plans/service/Template` | GET | List template plans | `plan` | +| 176 | `/plans/service/ISO` | GET | List ISO plans | `plan` | +| 177 | `/plans/service/Backups` | GET | List backup plans | `plan` | + +### Discovery + +| # | Path | Method | Summary | CLI Group | +| --- | --------------------- | ------ | ----------------------- | ----------- | +| 178 | `/regions` | GET | List regions | `discovery` | +| 179 | `/servers` | GET | List servers | `discovery` | +| 180 | `/cloud-providers` | GET | List cloud providers | `discovery` | +| 181 | `/currencies` | GET | List currencies | `discovery` | +| 182 | `/storage-categories` | GET | List storage categories | `discovery` | +| 183 | `/billing-cycles` | GET | List billing cycles | `discovery` | +| 184 | `/unit-pricings` | GET | List unit pricings | `discovery` | + +### Store + +| # | Path | Method | Summary | CLI Group | +| --- | ---------------------------- | ------ | ----------------------- | --------- | +| 185 | `/store/items` | GET | List store items | `store` | +| 186 | `/store/checkout` | POST | Checkout store cart | `store` | +| 187 | `/store/marketplace-apps` | GET | List marketplace apps | `store` | +| 188 | `/store/products/categories` | GET | List product categories | `store` | + +### Auth + +| # | Path | Method | Summary | CLI Group | +| --- | ----------------- | ------ | ---------------------------- | --------- | +| 189 | `/login` | POST | Log in (obtain Bearer token) | `auth` | +| 190 | `/register` | POST | Register new account | `auth` | +| 191 | `/reset-password` | POST | Reset password | `auth` | +| 192 | `/mfa/enable` | POST | Enable MFA | `auth` | +| 193 | `/mfa/disable` | POST | Disable MFA | `auth` | +| 194 | `/mfa/verify` | POST | Verify MFA code | `auth` | + +**Total endpoints**: 194 --- -## API Response Envelope Patterns +## Auth Model + +- **Mechanism**: Bearer token via `Authorization: Bearer ` header on every authenticated request +- **Login endpoint**: `POST /login` returns a Bearer token given valid credentials +- **Token management**: The CLI stores tokens per-profile in the local config directory +- **MFA support**: Optional MFA flow via `/mfa/enable`, `/mfa/verify`, `/mfa/disable` +- **No API key headers**: The old `apikey`/`secretkey` header pattern is replaced by Bearer tokens + +--- + +## API Response Patterns ### List Response -All list endpoints return a consistent two-field envelope: +List endpoints return a JSON array of resource objects, or a wrapper with a `data` array and pagination metadata: ```json { - "count": 3, - "": [ ... ] + "data": [ ... ], + "meta": { + "total": 42, + "page": 1, + "per_page": 25 + } } ``` -The list field name matches the schema and operation, for example: - -| Endpoint family | List field name | -|----------------|-----------------| -| instance | `listInstanceResponse` | -| volume | `listVolumeResponse` | -| network | `listNetworkResponse` | -| vpc | `listVpcResponse` | -| snapshot | `listSnapShotResponse` | -| vmsnapshot | `listVmSnapshotResponse` | -| securitygroup | `listSecurityGroupResponse` | -| sshkey | `listSSHKeyResponse` | -| ipaddress | `listIpAddressResponse` | -| loadbalancerrule | `listLoadBalancerRuleResponse` | -| portforwardingrule | `listPortForwardingResponse` | -| firewallrule | `listFirewallRuleResponse` | -| egressrule | `listEgressRuleResponse` | -| internallb | `listInternalLbResponse` | -| networkacllist | `listNetworkAclListResponse` | -| vpnconnection | `listVpnConnectionResponse` | -| vpncustomergateway | `listVpnCustomerGatewayResponse` | -| vpngateway | `listVpnGatewayResponse` | -| vpnuser | `listVpnUserResponse` | -| resourcetags | `kongCreateTagsResponse` | -| host | `listHostResponse` | -| invoice | `listInvoiceResponse` | - -### Async Job Response - -Operations that return a `jobId` (volume mutations, VM snapshot create/revert) embed the job fields inside the resource object itself. Poll for completion using: +### Single Resource Response -``` -GET /restapi/asyncjob/resourceStatus?jobId= -``` - -Async job response schema (`KongResourceApiResponse`): +GET/PUT/POST on a single resource returns the resource object directly: ```json { - "jobId": "string", - "resourceId": "string", - "resourceType": "string", - "status": "string", - "errorCode": "string", - "errorMessage": "string" + "slug": "abc-123", + "name": "my-resource", + "status": "running", + ... } ``` -`status` transitions: `IN_PROGRESS` → `SUCCEEDED` / `FAILED` - -Volume operations that embed `jobId` in their response items: -- `POST /restapi/volume/createVolume` -- `GET /restapi/volume/attachVolume` -- `GET /restapi/volume/detachVolume` -- `GET /restapi/volume/resizeVolume` -- `POST /restapi/volume/uploadVolume` - -VM snapshot operations that embed `jobId`: -- `POST /restapi/vmsnapshot/createVmSnapshot` -- `GET /restapi/vmsnapshot/revertToVmSnapshot` - -### Delete Response - -Most delete operations return HTTP 200 with an empty body (`{}`). Exceptions: - -| Path | Returns | -|------|---------| -| `DELETE /restapi/volume/deleteVolume/{uuid}` | `{uuid, status}` | -| `DELETE /restapi/vmsnapshot/deleteVmSnapshot/{uuid}` | `{uuid, status}` | - ### Error Response -HTTP 550 (API-level error) and HTTP 401 (auth error) are returned as: +Errors return standard HTTP status codes with a JSON body: ```json { - "listErrorResponse": { - "errorCode": "string", - "errorMsg": "string" + "error": { + "code": "not_found", + "message": "Resource not found" } } ``` -### Single Object Response +Common status codes: + +- `401` — Invalid or expired Bearer token +- `403` — Insufficient permissions +- `404` — Resource not found +- `422` — Validation error +- `429` — Rate limit exceeded -Some operations return a single flat object rather than a list: +### Delete Response -| Path | Schema | Fields | -|------|--------|--------| -| `GET /restapi/instance/vmStatus` | `KongInstanceStatusResponse` | `uuid`, `status` | -| `GET /restapi/user/creditBalance` | `KongUserBalanceResponse` | `userEmail`, `userType`, `balanceAmount`, `type` | -| `GET /restapi/costestimate/getpublickey` | `KongKeyResponse` | `apiKey`, `secretKey` | -| `POST /restapi/kubernetes/createKubernetes` | `KongKubernetesResponse` | `uuid`, `name`, `state`, `size`, `controlNodes`, ... | +Delete operations return HTTP 204 (No Content) with an empty body on success. --- -## Auth Model +## Resource Identifiers -- **Mechanism**: Two HTTP request headers on every call: `apikey` and `secretkey` -- **No login endpoint**: There is no session token or OAuth flow in the spec -- **Profile-based management**: The CLI manages named profiles storing `apikey`/`secretkey` pairs locally -- **Credential source**: Credentials are obtained out-of-band from the ZCP portal -- **No credential rotation endpoint**: The spec does not expose a key rotation API -- The `GET /restapi/costestimate/getpublickey` endpoint returns an `apiKey`/`secretKey` pair but requires no auth headers — this appears to be for the cost estimator widget, not the CLI +- Resources are identified by **SLUG** (a URL-friendly unique identifier) rather than UUIDs +- SLUGs appear in URL paths: `/virtual-machines/{SLUG}` +- Sub-resources use numeric or secondary IDs: `/ipaddresses/{SLUG}/firewall-rules/{ID}` --- ## Pagination -- **No cursor/token pagination**: The API does not support page tokens, `nextPageToken`, `offset`, or `limit` query parameters -- **Count field**: Every list response includes a `count` integer reflecting the total number of items returned -- **Full result sets**: All matching records are returned in a single response -- **UUID filter**: Most list endpoints accept a `uuid` query parameter to retrieve a single specific resource by UUID -- **Zone filter**: Most list endpoints accept a `zoneUuid` query parameter to scope results to a zone -- **No server-side sorting**: No `sort` or `orderBy` parameters are present in the spec +- List endpoints support `page` and `per_page` query parameters +- Response `meta` object includes `total`, `page`, and `per_page` fields +- Default page size varies by endpoint (typically 25) --- -## Key Request Body Schemas (Write Operations) - -| Operation | Schema | Required Fields | -|-----------|--------|----------------| -| Create Instance | `KongInstanceRequest` | `name`, `zoneUuid`, `templateUuid`, `computeOfferingUuid`, `networkUuid` | -| Create Volume | `KongVolumeRequest` | `name`, `zoneUuid`, `storageOfferingUuid`, `diskSize` | -| Create Network | `KongNetworkRequest` | `name`, `zoneUuid`, `networkOfferingUuid` | -| Create VPC | `KongVpcRequest` | `name`, `zoneUuid`, `vpcOfferingUuid`, `getcIDR` | -| Create Kubernetes | `KongKubernetesRequest` | `name`, `zoneUuid`, `kubernetesSupportedVersionUuid`, `computeOfferingUuid`, `transNetworkUuid`, `size` | -| Create Snapshot | `KongSnapShotRequest` | `name`, `zoneUuid`, `volumeUuid` | -| Create VM Snapshot | `KongVmSnapshotRequest` | `name`, `zoneUuid`, `virtualmachineUuid` | -| Create SSH Key | `KongSSHKeyRequest` | `name`, `publicKey` | -| Create Security Group | `KongSecurityGroupRequest` | `name` | -| Create Firewall Rule | `KongFirewallRuleRequest` | `ipAddressUuid`, `protocol` | -| Create Egress Rule | `KongEgressRuleRequest` | `networkUuid`, `protocol` | -| Create Port Forwarding Rule | `KongPortForwardingRequest` | `ipAddressUuid`, `protocol`, `privatePort`, `publicPort`, `virtualmachineUuid`, `networkUuid` | -| Create Load Balancer Rule | `KongLoadBalancerRuleRequest` | `name`, `publicIpUuid`, `publicport`, `privateport`, `networkUuid`, `algorithm` | -| Create VPN Connection | `KongVpnConnectionRequest` | `vpcUuid`, `customerGatewayUuid` | -| Create VPN Customer Gateway | `KongVpnCustomerGatewayRequest` | `name`, `gateway`, `cidrlist`, `ipsecpsk`, `ikepolicy`, `esppolicy` | -| Add VPN Gateway | `KongVpnGatewayRequest` | `vpcUuid` | -| Add VPN User | `KongVpnUserRequest` | `username`, `password` | +## Notes + +- All write operations (POST/PUT/DELETE) require a valid Bearer token +- Discovery endpoints (`/regions`, `/currencies`, etc.) may be publicly accessible +- The `/plans/service/{ServiceType}` pattern uses fixed service type values (VM, Router, Storage, LB, K8s, IP, Snapshot, Template, ISO, Backups) +- Monitoring endpoints require the target VM SLUG and return time-series data diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index 9024a4a..aa41da7 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -1,8 +1,8 @@ -# ZCP CLI Command Taxonomy +# ZCP CLI Command Taxonomy (v0.0.6) **CLI name**: `zcp` -**Base URL**: `https://cloud.zcp.zsoftly.ca/` -**API path prefix**: `/restapi/` +**Base URL**: `https://portal.webberstop.com/backend/api` +**Authentication**: Bearer token (`--bearer-token` during profile add) --- @@ -10,22 +10,23 @@ These flags apply to every command in the CLI: -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--profile` | string | `default` | Named credential profile to use | -| `--output` | string | `table` | Output format: `table`, `json`, or `yaml` | -| `--api-url` | string | (from profile) | Override the API base URL | -| `--timeout` | duration | `30s` | HTTP request timeout | -| `--debug` | bool | `false` | Print HTTP request/response details to stderr | -| `--no-color` | bool | `false` | Disable terminal color output | +| Flag | Type | Default | Description | +| ---------------- | ------ | ---------------- | --------------------------------------------- | +| `--profile` | string | (active profile) | Named credential profile to use | +| `--output`, `-o` | string | `table` | Output format: `table`, `json`, or `yaml` | +| `--api-url` | string | (from profile) | Override the API base URL | +| `--timeout` | int | `30` | HTTP request timeout in seconds | +| `--debug` | bool | `false` | Print HTTP request/response details to stderr | +| `--no-color` | bool | `false` | Disable terminal color output | +| `--pager` | bool | `false` | Pipe table output through less | --- ## Output Format Conventions -- **table** (default) — human-readable fixed-width columns; intended for interactive use -- **json** — raw JSON response object; suitable for `jq` pipelines and scripting -- **yaml** — YAML rendering of the same response object; suitable for config-driven workflows +- **table** (default) -- human-readable fixed-width columns; intended for interactive use +- **json** -- raw JSON response object; suitable for `jq` pipelines and scripting +- **yaml** -- YAML rendering of the same response object; suitable for config-driven workflows - All three formats are fully machine-parseable: no extra prose is written to stdout - Errors are always written to stderr regardless of `--output` - Exit codes: `0` = success, `1` = API error or CLI error @@ -36,382 +37,442 @@ These flags apply to every command in the CLI: ``` zcp -├── version Print CLI version and build info -├── completion Generate shell completion script (bash/zsh/fish/powershell) -│ -├── profile Manage credential profiles -│ ├── add Add a new named profile -│ ├── list List all saved profiles -│ ├── use Set the active default profile -│ ├── delete Remove a profile -│ └── show Show profile details (redacts secretkey) -│ -├── auth Authentication utilities -│ └── validate Validate that the active profile credentials are accepted -│ -├── zone Zone operations -│ └── list List available zones -│ -├── offering Service offering catalogues -│ ├── compute Compute offering operations -│ │ └── list List compute offerings (optionally with pricing) -│ ├── storage Storage offering operations -│ │ └── list List storage offerings (optionally with pricing) -│ ├── network Network offering operations -│ │ └── list List network offerings (standard and VPC) -│ └── vpc VPC offering operations -│ └── list List VPC offerings -│ -├── template VM template operations -│ └── list List available templates -│ -├── resource Resource availability -│ └── available Show available resources by domain -│ -├── instance VM instance operations -│ ├── list List instances -│ ├── status Get current power state of an instance -│ ├── create Create a new instance -│ ├── delete Destroy an instance (--expunge to permanently remove) -│ ├── start Start a stopped instance -│ ├── stop Stop a running instance (--force for forced shutdown) -│ ├── resize Resize an instance (offering, CPU, memory) -│ ├── recover Recover a destroyed (but not expunged) instance -│ ├── rename Update the display name of an instance -│ ├── reset-ssh-key Reset the SSH key on an instance -│ ├── attach-network Attach a network to an instance -│ ├── detach-network Detach a network from an instance -│ ├── attach-iso Attach an ISO image to an instance -│ ├── detach-iso Detach an ISO image from an instance -│ ├── networks List networks attached to an instance -│ └── passwords List saved passwords for an instance -│ -├── volume Block storage volume operations -│ ├── list List volumes -│ ├── create Create a new volume (async) -│ ├── delete Delete a volume -│ ├── attach Attach a volume to an instance (async) -│ ├── detach Detach a volume from an instance (async) -│ ├── resize Resize a volume (async) -│ └── upload Upload a volume from a URL (async) -│ -├── snapshot Volume snapshot operations -│ ├── list List snapshots -│ ├── create Create a snapshot of a volume -│ └── delete Delete a snapshot -│ -├── vm-snapshot VM (instance-level) snapshot operations -│ ├── list List VM snapshots -│ ├── create Create a VM snapshot (async) -│ ├── delete Delete a VM snapshot -│ └── revert Revert an instance to a VM snapshot (async) -│ -├── snapshot-policy Automated snapshot policy operations -│ ├── list List snapshot policies -│ ├── create Create a snapshot policy -│ └── delete Delete a snapshot policy -│ -├── network Network operations -│ ├── list List networks -│ ├── get Get a network by ID -│ ├── create Create a network -│ ├── delete Delete a network -│ ├── update Update network name/description/CIDR -│ ├── restart Restart a network -│ ├── replace-acl Replace the ACL on a network -│ └── change-security-group Change the security group on a network -│ -├── vpc VPC operations -│ ├── list List VPCs -│ ├── get Get a VPC by ID -│ ├── create Create a VPC -│ ├── delete Delete a VPC -│ ├── update Update VPC name/description -│ ├── restart Restart a VPC -│ ├── create-network Create a network inside a VPC -│ └── update-network Update a VPC network -│ -├── acl Network ACL operations -│ ├── list List network ACLs -│ ├── create Create a network ACL -│ └── delete Delete a network ACL -│ -├── ip Public IP address operations -│ ├── list List IP addresses -│ ├── acquire Acquire a new public IP address -│ ├── release Release a public IP address -│ ├── enable-static-nat Enable static NAT for an IP -│ ├── disable-static-nat Disable static NAT for an IP -│ ├── enable-vpn-access Enable remote VPN access on an IP -│ └── disable-vpn-access Disable remote VPN access on an IP -│ -├── firewall Firewall rule operations -│ ├── list List firewall rules -│ ├── create Create a firewall rule -│ └── delete Delete a firewall rule -│ -├── egress Egress rule operations -│ ├── list List egress rules -│ ├── create Create an egress rule -│ └── delete Delete an egress rule -│ -├── portforward Port forwarding rule operations -│ ├── list List port forwarding rules -│ ├── create Create a port forwarding rule -│ └── delete Delete a port forwarding rule -│ -├── loadbalancer Load balancer rule operations -│ ├── list List load balancer rules -│ ├── create Create a load balancer rule -│ ├── update Update a load balancer rule -│ └── delete Delete a load balancer rule -│ -├── internal-lb Internal load balancer operations -│ ├── list List internal LB rules -│ ├── create Create an internal LB -│ ├── assign Assign an LB rule to an internal LB -│ └── delete Delete an internal LB -│ -├── security-group Security group operations -│ ├── list List security groups -│ ├── create Create a security group -│ ├── delete Delete a security group -│ ├── add-firewall-rule Add a firewall (ingress) rule to a security group -│ ├── add-egress-rule Add an egress rule to a security group -│ ├── add-portforward-rule Add a port forwarding rule to a security group -│ └── delete-rule Delete a rule from a security group -│ -├── ssh-key SSH key operations -│ ├── list List SSH keys -│ ├── create Register a new SSH key (provide public key) -│ └── delete Delete an SSH key -│ -├── tag Resource tag operations -│ ├── list List resource tags -│ ├── create Add a tag to a resource -│ └── delete Remove a tag from a resource -│ -├── vpn VPN operations -│ ├── gateway VPN gateway operations -│ │ ├── list List VPN gateways -│ │ ├── create Add a VPN gateway (attached to VPC) -│ │ └── delete Delete a VPN gateway -│ ├── connection VPN connection (site-to-site) operations -│ │ ├── list List VPN connections -│ │ ├── create Add a VPN connection -│ │ ├── reset Reset a VPN connection -│ │ └── delete Delete a VPN connection -│ ├── customer-gateway VPN customer gateway operations -│ │ ├── list List VPN customer gateways -│ │ ├── create Add a VPN customer gateway -│ │ ├── update Update a VPN customer gateway -│ │ └── delete Delete a VPN customer gateway -│ └── user VPN remote-access user operations -│ ├── list List VPN users -│ ├── create Add a VPN user -│ └── delete Delete a VPN user -│ -├── kubernetes Kubernetes cluster operations -│ ├── list List Kubernetes clusters -│ ├── nodes List nodes in a Kubernetes cluster -│ ├── create Create a Kubernetes cluster -│ ├── delete Destroy a Kubernetes cluster -│ ├── start Start a stopped Kubernetes cluster -│ ├── stop Stop a running Kubernetes cluster -│ └── scale Scale a Kubernetes cluster (worker count) -│ -├── usage Usage and consumption reporting -│ ├── list List usage consumption records -│ ├── report List usage report summaries -│ └── progress Show current billing period progress status -│ -├── cost Cost estimation (read-only, no auth on some) -│ └── estimate Subcommands for each resource type cost query -│ ├── compute Compute offering cost plans -│ ├── storage Storage offering cost plans -│ ├── network Network offering costs -│ ├── vpc VPC offering costs -│ ├── ip IP address cost -│ ├── loadbalancer Load balancer cost -│ ├── portforward Port forwarding cost -│ ├── snapshot Snapshot cost -│ ├── vm-snapshot VM snapshot cost -│ ├── bandwidth Bandwidth cost -│ ├── kubernetes Kubernetes cost -│ ├── object-storage Object storage cost -│ ├── vpn-user VPN user cost -│ └── template Template cost and category info -│ -└── admin Administrator-only operations - ├── host Physical host operations - │ └── list List physical hosts - ├── quota Resource quota operations - │ └── show Show resource quota limits - ├── invoice Invoice and billing operations - │ ├── list List invoices by client - │ ├── list-tax-pending List invoices with pending tax - │ ├── payment-status Get payment status of an invoice - │ ├── update-status Update invoice payment status - │ ├── update-tax Update invoice tax details - │ ├── generate Generate an invoice PDF - │ └── change-cost Adjust invoice payment amount - └── user User administration - └── credit-balance Show credit balance for a user account +├── version Print CLI version and build info +├── completion Generate shell completion script (bash/zsh/fish/powershell) +│ +├── profile Manage credential profiles +│ ├── add Add a new named profile (prompts for --bearer-token) +│ ├── list List all saved profiles +│ ├── use Set the active default profile +│ ├── delete Remove a profile +│ ├── show Show profile details (redacts token) +│ ├── update Update an existing profile +│ └── rename Rename a profile +│ +├── auth Authentication utilities +│ └── validate Validate that the active profile credentials are accepted +│ +├── zone Zone operations +│ ├── list List availability zones +│ └── use Set the default zone for the active profile +│ +├── offering Service offering catalogues +│ ├── compute Compute offering operations +│ │ └── list List compute offerings (optionally with pricing) +│ ├── storage Storage offering operations +│ │ └── list List storage offerings (optionally with pricing) +│ ├── network Network offering operations +│ │ └── list List network offerings (standard and VPC) +│ └── vpc VPC offering operations +│ └── list List VPC offerings +│ +├── template VM template operations +│ ├── list List available public templates (--region filter) +│ ├── account-list List account (user-created) templates +│ ├── account-create Create an account template +│ └── account-delete Delete an account template +│ +├── resource Resource availability +│ └── available Show available resources by domain +│ +├── instance VM instance operations +│ ├── list List instances +│ ├── get Show details for a single instance +│ ├── create Create a new instance +│ ├── start Start a stopped instance +│ ├── stop Stop a running instance +│ ├── reboot Reboot a running instance +│ ├── reset Reset (hard reboot) an instance +│ ├── logs View instance console/activity logs +│ ├── tag-create Add a tag to an instance +│ ├── tag-delete Remove a tag from an instance +│ ├── change-hostname Change the hostname of an instance +│ ├── change-password Change the root password of an instance +│ ├── change-plan Resize an instance (change compute plan) +│ ├── change-os Reinstall with a different OS template +│ ├── change-script Update the startup script on an instance +│ ├── add-network Attach an additional network to an instance +│ ├── addons List available addons for an instance +│ ├── purchase-addon Purchase an addon for an instance +│ └── ssh Open an SSH session to an instance +│ +├── volume Block storage volume operations +│ ├── list List volumes +│ ├── create Create a new volume +│ ├── attach Attach a volume to an instance +│ └── detach Detach a volume from an instance +│ +├── snapshot Block storage snapshot operations +│ ├── list List snapshots +│ ├── create Create a snapshot of a volume +│ └── revert Revert a volume to a snapshot (destructive) +│ +├── vm-snapshot VM (instance-level) snapshot operations +│ ├── list List VM snapshots +│ ├── create Create a VM snapshot +│ ├── delete Delete a VM snapshot +│ └── revert Revert an instance to a VM snapshot +│ +├── snapshot-policy Automated snapshot policy operations +│ ├── list List snapshot policies +│ ├── create Create a snapshot policy +│ └── delete Delete a snapshot policy +│ +├── network Isolated network operations +│ ├── list List networks +│ ├── create Create a network +│ ├── update Update a network +│ └── categories List network categories +│ +├── vpc VPC operations +│ ├── list List VPCs (--zone filter) +│ ├── get Get a VPC by slug +│ ├── create Create a VPC +│ ├── update Update VPC name/description +│ ├── delete Delete a VPC +│ ├── restart Restart a VPC +│ ├── acl-list List ACL rules for a VPC +│ ├── acl-create-rule Create an ACL rule in a VPC +│ ├── acl-replace Replace the ACL on a VPC network +│ └── vpn-gateway VPN gateway operations within a VPC +│ ├── list List VPN gateways +│ ├── create Create a VPN gateway +│ └── delete Delete a VPN gateway +│ +├── acl Network ACL operations +│ ├── list List network ACLs +│ ├── create-rule Create an ACL rule +│ └── replace Replace the ACL on a network +│ +├── ip Public IP address operations +│ ├── list List IP addresses (--vpc filter) +│ ├── allocate Allocate a new public IP address +│ ├── static-nat Static NAT operations +│ │ └── enable Enable static NAT for an IP +│ └── vpn Remote access VPN on an IP +│ ├── list List VPN users for an IP +│ ├── enable Enable remote VPN access on an IP +│ └── disable Disable remote VPN access on an IP +│ +├── firewall Firewall rule operations +│ ├── list List firewall rules (--ip required) +│ ├── create Create a firewall rule +│ └── delete Delete a firewall rule +│ +├── egress Egress rule operations +│ ├── list List egress rules +│ ├── create Create an egress rule +│ └── delete Delete an egress rule +│ +├── portforward Port forwarding rule operations +│ ├── list List port forwarding rules +│ ├── create Create a port forwarding rule +│ └── delete Delete a port forwarding rule +│ +├── loadbalancer Load balancer operations +│ ├── list List load balancers +│ ├── create Create a load balancer +│ ├── create-rule Create a load balancer rule +│ └── attach-vm Attach a VM to a load balancer rule +│ +├── internal-lb Internal load balancer operations +│ ├── list List internal LB rules +│ ├── create Create an internal LB +│ └── delete Delete an internal LB +│ +├── security-group Security group operations +│ ├── list List security groups +│ ├── get Get a security group by slug +│ ├── create Create a security group +│ ├── delete Delete a security group +│ └── rule Security group rule operations +│ ├── add-firewall Add a firewall (ingress) rule +│ ├── add-egress Add an egress rule +│ └── delete Delete a rule from a security group +│ +├── ssh-key SSH key operations +│ ├── list List SSH keys +│ ├── import Import an SSH public key +│ └── delete Delete an SSH key +│ +├── tag Resource tag operations +│ ├── list List resource tags +│ ├── create Add a tag to a resource +│ └── delete Remove a tag from a resource +│ +├── vpn VPN operations +│ ├── customer-gateway VPN customer gateway operations +│ │ ├── list List VPN customer gateways +│ │ ├── create Add a VPN customer gateway +│ │ ├── update Update a VPN customer gateway +│ │ └── delete Delete a VPN customer gateway +│ └── user VPN remote-access user operations +│ ├── list List VPN users +│ ├── create Add a VPN user +│ └── delete Delete a VPN user +│ +├── kubernetes (alias: k8s) Kubernetes cluster operations +│ ├── list List Kubernetes clusters +│ ├── create Create a Kubernetes cluster +│ ├── start Start a stopped cluster +│ ├── stop Stop a running cluster +│ └── upgrade Upgrade a Kubernetes cluster version +│ +├── dns DNS domain and record operations +│ ├── list List DNS domains +│ ├── show Show DNS domain details and records +│ ├── create Create a DNS domain +│ ├── delete Delete a DNS domain (removes all records) +│ ├── record-create Create a DNS record +│ └── record-delete Delete a DNS record +│ +├── project Project management +│ ├── list List all projects +│ ├── create Create a new project +│ ├── update Update an existing project +│ ├── dashboard Show project dashboard services +│ ├── icon Project icon operations +│ │ └── list List available project icons +│ └── user Project user operations +│ ├── list List users in a project +│ └── add Add a user to a project +│ +├── monitoring Resource monitoring and VM metrics +│ ├── global Show global resource monitoring overview +│ ├── cpu Show CPU usage metrics for a VM +│ ├── memory Show memory usage metrics for a VM +│ ├── disk Show disk read/write metrics for a VM +│ ├── disk-io Show disk IO read/write metrics for a VM +│ ├── network Show network traffic metrics for a VM +│ └── charts Show monitoring charts data +│ +├── billing Billing, costs, usage, invoices, and payments +│ ├── balance Show account balance summary +│ ├── costs Show per-service cost breakdown +│ ├── monthly-usage Show month-by-month usage history +│ ├── service-counts Show active service counts by type +│ ├── credit-limit Show account credit limit +│ ├── invoices List billing invoices (--page) +│ ├── invoices-count Show total number of invoices +│ ├── usage Show detailed account usage +│ ├── free-credits Show available free credits +│ ├── subscriptions Subscription management +│ │ ├── active List active service subscriptions +│ │ └── inactive List inactive service subscriptions +│ ├── contracts List service contracts +│ ├── trials List active free trials +│ ├── cancel-requests List scheduled cancellation requests +│ ├── cancel-service Submit a service cancellation request +│ ├── payments List payment transactions (--page) +│ ├── coupons List coupons associated with the account +│ ├── redeem-coupon Apply a coupon code to the account +│ ├── budget-alert Show current budget alert settings +│ └── budget-alert-set Configure budget alert settings +│ +├── support Support tickets and FAQs +│ ├── ticket Ticket management +│ │ ├── list List support tickets +│ │ ├── create Create a support ticket +│ │ ├── show Show a support ticket +│ │ ├── delete Delete a support ticket +│ │ ├── summary Show ticket count summary +│ │ ├── reply Reply to a support ticket +│ │ ├── replies List replies for a support ticket +│ │ ├── feedback Get feedback for a support ticket +│ │ └── feedback-submit Submit feedback for a support ticket +│ └── faq FAQ management +│ └── list List FAQs +│ +├── autoscale VM autoscale groups, policies, and conditions +│ ├── list List autoscale groups +│ ├── create Create a new autoscale group +│ ├── enable Enable an autoscale group +│ ├── disable Disable an autoscale group +│ ├── change-plan Change the compute plan of a group +│ ├── change-template Change the template of a group +│ ├── policy Scale-up policy management +│ │ ├── create Create a scale-up policy +│ │ ├── update Update a scale-up policy +│ │ └── delete Delete a scale-up policy +│ └── condition Scale-down condition management +│ ├── create Create a scale-down condition +│ ├── update Update a scale-down condition +│ └── delete Delete a scale-down condition +│ +├── dashboard Account dashboard and service management +│ ├── summary Show a summary of active service counts +│ └── cancel-service Submit a service cancellation request +│ +├── plan List service plans and pricing +│ ├── vm List Virtual Machine plans +│ ├── router List Virtual Router plans +│ ├── storage List Block Storage plans +│ ├── lb List Load Balancer plans +│ ├── kubernetes (alias: k8s) List Kubernetes plans +│ ├── ip List IP Address plans +│ ├── vm-snapshot List VM Snapshot plans +│ ├── template List My Template plans +│ ├── iso List ISO plans +│ └── backup List Backup plans +│ +├── store Store and checkout +│ ├── list List store items (--sort, --page, --limit) +│ └── checkout Purchase a store product +│ +├── marketplace (alias: apps) Marketplace applications +│ └── list List marketplace applications (--region, --include) +│ +├── product Products and product categories +│ ├── categories List product categories +│ └── list List all products (--card-type, --card-slug, --include) +│ +├── iso ISO image management +│ ├── list List ISO images +│ ├── create Create (register) an ISO image +│ ├── update Update ISO permissions +│ └── delete Delete an ISO image +│ +├── affinity-group Affinity group management +│ ├── list List affinity groups +│ ├── create Create an affinity group +│ └── delete Delete an affinity group +│ +├── backup Block storage backup operations +│ ├── list List block storage backups +│ └── create Create a block storage backup +│ +├── usage Usage and consumption reporting +│ ├── consumption List usage consumption for a billing period +│ ├── report List usage report summaries +│ ├── status Show billing period progress status +│ └── credit Show credit balance/usage +│ +├── cost Pricing information +│ ├── currency List supported billing currencies and rates +│ └── tax Show tax information +│ +├── host Physical host operations (registered at root) +│ └── list List physical hosts +│ +└── admin Administrator-only operations + ├── host Physical host operations + │ └── list List physical hosts + ├── quota Resource quota operations + │ └── list List resource quota limits + └── invoice Invoice operations + ├── list List invoices + └── generate Generate an invoice PDF ``` --- -## CLI Group to API Path Mapping +## Authentication -| CLI Group | API Path Prefix | Notes | -|-----------|----------------|-------| -| `zone` | `/restapi/zone/` | | -| `offering compute` | `/restapi/compute/` | | -| `offering storage` | `/restapi/storage/` | | -| `offering network` | `/restapi/networkoffering/` | | -| `offering vpc` | `/restapi/vpcoffering/` | | -| `template` | `/restapi/template/` | | -| `resource` | `/restapi/availableResource/` | | -| `instance` | `/restapi/instance/` | | -| `volume` | `/restapi/volume/` | | -| `snapshot` | `/restapi/snapshot/` | | -| `vm-snapshot` | `/restapi/vmsnapshot/` | | -| `snapshot-policy` | `/restapi/snapshotPolicy/` | | -| `network` | `/restapi/network/` | | -| `vpc` | `/restapi/vpc/` | | -| `acl` | `/restapi/networkacllist/` | | -| `ip` | `/restapi/ipaddress/` | | -| `firewall` | `/restapi/firewallrule/` | | -| `egress` | `/restapi/egressrule/` | | -| `portforward` | `/restapi/portforwardingrule/` | | -| `loadbalancer` | `/restapi/loadbalancerrule/` | | -| `internal-lb` | `/restapi/internallb/` | | -| `security-group` | `/restapi/securitygroup/` | | -| `ssh-key` | `/restapi/sshkey/` | | -| `tag` | `/restapi/resourcetags/` | | -| `vpn gateway` | `/restapi/vpngateway/` | | -| `vpn connection` | `/restapi/vpnconnection/` | | -| `vpn customer-gateway` | `/restapi/vpncustomergateway/` | | -| `vpn user` | `/restapi/vpnuser/` | | -| `kubernetes` | `/restapi/kubernetes/` | | -| `usage` | `/restapi/usage/` | | -| `cost` | `/restapi/costestimate/` | | -| `admin host` | `/restapi/host/` | Admin only | -| `admin quota` | `/restapi/resource-quota/` | Admin only | -| `admin invoice` | `/restapi/invoice/` | Admin only | -| `admin user` | `/restapi/user/` | Admin only | -| (internal) | `/restapi/asyncjob/` | Used internally for job polling | +Profiles store a **bearer token** for authenticating with the ZCP API. When creating +a profile, the token is provided via `--bearer-token` or entered interactively: + +``` +zcp profile add default + Bearer Token: ******** +``` + +Each API request sends the token as an `Authorization: Bearer ` header. + +--- + +## Identifier Conventions + +v0.0.6 uses **slug-based identifiers** for most resources. Slugs are human-readable +strings assigned by the API (e.g., `my-vm-123`, `root-4153`, `example-com-1`). + +| Context | Flag / Argument | Example | +| --------------- | --------------------------------- | ---------------------------------------- | +| VM instance | positional `` or `--vm` | `zcp instance get my-vm-123` | +| Volume | `--volume` | `zcp snapshot create --volume root-4153` | +| DNS domain | positional `` or `--domain` | `zcp dns show example-com-1` | +| Project | `--project` | `--project default-60` | +| Region | `--region` | `--region yow-1` | +| VPC | `--vpc` | `zcp ip list --vpc my-vpc` | +| IP | `--ip` | `zcp firewall list --ip my-ip-slug` | +| Autoscale group | positional `` | `zcp autoscale enable web-group` | +| Zone (legacy) | `--zone` | `zcp zone list --zone ` | + +The `zone` command still uses UUIDs for backward compatibility. All other new +commands use slugs. --- -## Phase Assignment by CLI Group - -### Phase 1 — Read-Only Discovery (Current Build) - -These commands are the initial scope. All are read-only `list`/`get`/`status` operations. - -| CLI Group | Commands | API Endpoints | -|-----------|----------|---------------| -| `zone` | `list` | `GET /restapi/zone/zonelist` | -| `offering compute` | `list` | `GET /restapi/compute/computeOfferingList`, `computeOfferingListWithPrice` | -| `offering storage` | `list` | `GET /restapi/storage/storageOfferingList`, `storageOfferingListWithPrice` | -| `offering network` | `list` | `GET /restapi/networkoffering/networkOfferingList`, `vpcNetworkOfferingList` | -| `offering vpc` | `list` | `GET /restapi/vpcoffering/vpcOfferingList` | -| `template` | `list` | `GET /restapi/template/templateList` | -| `resource` | `available` | `GET /restapi/availableResource/getAvailableResourceByDomain` | -| `instance` | `list`, `status` | `GET /restapi/instance/instanceList`, `vmStatus` | -| `instance` | `networks` | `GET /restapi/instance/instanceNetworkList` | -| `volume` | `list` | `GET /restapi/volume/volumeList` | -| `snapshot` | `list` | `GET /restapi/snapshot/snapshotList` | -| `vm-snapshot` | `list` | `GET /restapi/vmsnapshot/vmsnapshotList` | -| `snapshot-policy` | `list` | `GET /restapi/snapshotPolicy/snapshotPolicyList` | -| `network` | `list`, `get` | `GET /restapi/network/networkList`, `networkId` | -| `vpc` | `list`, `get` | `GET /restapi/vpc/vpcList`, `vpcId` | -| `acl` | `list` | `GET /restapi/networkacllist/networkAclList` | -| `ip` | `list` | `GET /restapi/ipaddress/ipAddressList` | -| `firewall` | `list` | `GET /restapi/firewallrule/firewallRuleList` | -| `egress` | `list` | `GET /restapi/egressrule/egressRuleList` | -| `portforward` | `list` | `GET /restapi/portforwardingrule/portForwardingRuleList` | -| `loadbalancer` | `list` | `GET /restapi/loadbalancerrule/loadBalancerRuleList` | -| `internal-lb` | `list` | `GET /restapi/internallb/internalLbList` | -| `security-group` | `list` | `GET /restapi/securitygroup/securityList` | -| `ssh-key` | `list` | `GET /restapi/sshkey/sshkeyList` | -| `tag` | `list` | `GET /restapi/resourcetags/resourceTagsList` | -| `vpn gateway` | `list` | `GET /restapi/vpngateway/vpnGatewayList` | -| `vpn connection` | `list` | `GET /restapi/vpnconnection/vpnConnectionList` | -| `vpn customer-gateway` | `list` | `GET /restapi/vpncustomergateway/vpnCustomerGatewayList` | -| `vpn user` | `list` | `GET /restapi/vpnuser/vpnUserlist` | -| `kubernetes` | `list`, `nodes` | `GET /restapi/kubernetes/listCluster`, `listNodes` | -| `admin host` | `list` | `GET /restapi/host/hostList` | -| `admin quota` | `show` | `GET /restapi/resource-quota/get-resource-limit` | - -### Phase 2 — Instance Lifecycle, Volume, Network CRUD - -Mutating operations on core compute and networking resources. Includes async operations with job polling. - -| CLI Group | Commands | Notes | -|-----------|----------|-------| -| `instance` | `create`, `delete`, `start`, `stop`, `resize`, `recover`, `rename`, `reset-ssh-key`, `attach-network`, `detach-network`, `attach-iso`, `detach-iso`, `passwords` | | -| `volume` | `create`, `delete`, `attach`, `detach`, `resize` | `create`, `attach`, `detach`, `resize` are async | -| `snapshot` | `create`, `delete` | | -| `vm-snapshot` | `create`, `delete`, `revert` | `create`, `revert` are async | -| `snapshot-policy` | `create`, `delete` | | -| `network` | `create`, `delete`, `update`, `restart`, `replace-acl`, `change-security-group` | | -| `vpc` | `create`, `delete`, `update`, `restart`, `create-network`, `update-network` | | -| `acl` | `create`, `delete` | | -| `ip` | `acquire`, `release`, `enable-static-nat`, `disable-static-nat`, `enable-vpn-access`, `disable-vpn-access` | | -| `firewall` | `create`, `delete` | | -| `egress` | `create`, `delete` | | -| `portforward` | `create`, `delete` | | -| `loadbalancer` | `create`, `update`, `delete` | | -| `internal-lb` | `create`, `assign`, `delete` | | -| `security-group` | `create`, `delete`, `add-firewall-rule`, `add-egress-rule`, `add-portforward-rule`, `delete-rule` | | -| `ssh-key` | `create`, `delete` | | -| `tag` | `create`, `delete` | | -| `vpn gateway` | `create`, `delete` | | -| `vpn connection` | `create`, `reset`, `delete` | | -| `vpn customer-gateway` | `create`, `update`, `delete` | | -| `vpn user` | `create`, `delete` | | -| `kubernetes` | `create`, `delete`, `start`, `stop`, `scale` | | - -### Phase 3 — Advanced, Ancillary, and Admin - -Cost estimation, usage reporting, ISO/upload operations, and all admin billing/invoice operations. - -| CLI Group | Commands | Notes | -|-----------|----------|-------| -| `instance` | `attach-iso`, `detach-iso` | Moved from P2 for prioritization | -| `volume` | `upload` | Async, requires URL and checksum | -| `usage` | `list`, `report`, `progress` | May require admin for sub-domain variant | -| `cost` | All `estimate` subcommands | Read-only pricing queries | -| `admin invoice` | `list`, `list-tax-pending`, `payment-status`, `update-status`, `update-tax`, `generate`, `change-cost` | Admin only | -| `admin user` | `credit-balance` | Admin only | +## CLI Group to API Path Mapping + +| CLI Group | API Source | Notes | +| ------------------ | ----------------- | -------------------------------------------------------- | +| `zone` | ZCP API (STKBILL) | Zone list, legacy UUID identifiers | +| `offering compute` | ZCP API (STKBILL) | Compute offerings and pricing | +| `offering storage` | ZCP API (STKBILL) | Storage offerings and pricing | +| `offering network` | ZCP API (STKBILL) | Network offerings | +| `offering vpc` | ZCP API (STKBILL) | VPC offerings | +| `template` | ZCP API (STKCNSL) | Public and account templates | +| `resource` | ZCP API (STKBILL) | Available resources by domain | +| `instance` | ZCP API (STKCNSL) | Full VM lifecycle | +| `volume` | ZCP API (STKCNSL) | Block storage CRUD | +| `snapshot` | ZCP API (STKCNSL) | Block storage snapshots | +| `vm-snapshot` | ZCP API (STKBILL) | VM-level snapshots | +| `snapshot-policy` | ZCP API (STKBILL) | Automated snapshot policies | +| `network` | ZCP API (STKCNSL) | Isolated networks | +| `vpc` | ZCP API (STKCNSL) | VPCs, ACLs, VPN gateways | +| `acl` | ZCP API (STKBILL) | Network ACLs | +| `ip` | ZCP API (STKCNSL) | Public IPs, static NAT, VPN | +| `firewall` | ZCP API (STKCNSL) | Firewall rules | +| `egress` | ZCP API (STKBILL) | Egress rules | +| `portforward` | ZCP API (STKCNSL) | Port forwarding rules | +| `loadbalancer` | ZCP API (STKCNSL) | Load balancers and rules | +| `internal-lb` | ZCP API (STKBILL) | Internal load balancers | +| `security-group` | ZCP API (STKCNSL) | Security groups and rules | +| `ssh-key` | ZCP API (STKCNSL) | SSH key management | +| `tag` | ZCP API (STKBILL) | Resource tags | +| `vpn` | ZCP API (STKCNSL) | Customer gateways, VPN users | +| `kubernetes` | ZCP API (STKCNSL) | Kubernetes clusters | +| `dns` | ZCP API (STKCNSL) | DNS domains and records | +| `project` | ZCP API (STKCNSL) | Projects, icons, users | +| `monitoring` | ZCP API (STKCNSL) | Global and per-VM metrics | +| `billing` | ZCP API (STKCNSL) | Balance, costs, invoices, subscriptions, coupons, budget | +| `support` | ZCP API (STKCNSL) | Tickets, replies, feedback, FAQs | +| `autoscale` | ZCP API (STKCNSL) | Autoscale groups, policies, conditions | +| `dashboard` | ZCP API (STKCNSL) | Service counts, cancellations | +| `plan` | ZCP API (STKCNSL) | Service plans and pricing per type | +| `store` | ZCP API (STKCNSL) | Store items and checkout | +| `marketplace` | ZCP API (STKCNSL) | Marketplace app listings | +| `product` | ZCP API (STKCNSL) | Product categories and catalog | +| `iso` | ZCP API (STKCNSL) | ISO image management | +| `affinity-group` | ZCP API (STKCNSL) | Affinity/anti-affinity groups | +| `backup` | ZCP API (STKCNSL) | Block storage backups | +| `usage` | ZCP API (STKBILL) | Consumption, reports, status | +| `cost` | ZCP API (STKBILL) | Currencies and tax | +| `admin host` | ZCP API (STKBILL) | Admin only | +| `admin quota` | ZCP API (STKBILL) | Admin only | +| `admin invoice` | ZCP API (STKBILL) | Admin only | --- ## Async Operation Handling -Commands that trigger async API operations must poll for completion: +Commands that trigger async API operations poll for completion: 1. Issue the mutating request; the response item contains a `jobId` field -2. Poll `GET /restapi/asyncjob/resourceStatus?jobId=` until `status` is `SUCCEEDED` or `FAILED` +2. Poll the async job status endpoint until `status` is `SUCCEEDED` or `FAILED` 3. On `FAILED`, surface `errorCode` and `errorMessage` to the user **CLI behavior flags for async commands:** + - Default: poll with a spinner until completion, then print result - `--no-wait`: return immediately after the initial request and print the `jobId` - `--timeout`: maximum time to wait before giving up (inherits global `--timeout` default) -**Async commands (Phase 2):** -- `volume create`, `volume attach`, `volume detach`, `volume resize`, `volume upload` -- `vm-snapshot create`, `vm-snapshot revert` - --- ## Notes on API Quirks -- Several mutating operations use `GET` instead of `POST`/`DELETE` (e.g., `startInstance`, `stopInstance`, `destroyInstance`, `attachVolume`). The CLI must preserve these as-is; they cannot be changed to the semantically correct HTTP methods. -- `GET /restapi/instance/destroyInstance` accepts an `expunge` query param (`true`/`false`) to control whether the instance is permanently deleted or placed in a recoverable destroyed state. -- `DELETE /restapi/vpnuser/deleteVpnUser` does not take a `{uuid}` path param — the user identifier is passed as a query parameter instead. -- `GET /restapi/kubernetes/listCluster` returns an inline schema with no named `$ref` in the spec; the actual cluster list structure must be confirmed from a live response. -- `GET /restapi/costestimate/getpublickey` requires no auth headers; it is a public endpoint. +- Several mutating operations use `GET` instead of `POST`/`DELETE`. The CLI preserves these as-is; they cannot be changed to the semantically correct HTTP methods. +- The `zone` command still accepts `--zone ` for backward compatibility with the STKBILL zone list API. All STKCNSL-backed commands use slug-based identifiers. +- Destructive commands (`delete`, `revert`, `cancel-service`) require a `--yes` / `-y` flag to skip the interactive confirmation prompt. +- Some `billing` and `usage` commands return opaque JSON when the response schema is undefined; these fall through to raw `printer.Print()`. diff --git a/docs/configuration.md b/docs/configuration.md index 06ee41d..b995b12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,11 +8,11 @@ This document describes all configuration options for ZCP CLI, including the con ZCP CLI stores its configuration in a YAML file. The location depends on the operating system: -| Platform | Default Path | -|-------------|-------------------------------------| -| Linux | `~/.config/zcp/config.yaml` | -| macOS | `~/.config/zcp/config.yaml` | -| Windows | `%AppData%\zcp\config.yaml` | +| Platform | Default Path | +| -------- | --------------------------- | +| Linux | `~/.config/zcp/config.yaml` | +| macOS | `~/.config/zcp/config.yaml` | +| Windows | `%AppData%\zcp\config.yaml` | On Linux and macOS, the `XDG_CONFIG_HOME` environment variable is respected. If set, the config file will be located at `$XDG_CONFIG_HOME/zcp/config.yaml`. @@ -28,29 +28,26 @@ active_profile: default profiles: default: name: default - apikey: YOUR_API_KEY - secretkey: YOUR_SECRET_KEY - api_url: "" # Optional. Blank = use the default API URL. + bearer_token: YOUR_BEARER_TOKEN + api_url: "" # Optional. Blank = use the default API URL. staging: name: staging - apikey: STAGING_API_KEY - secretkey: STAGING_SECRET_KEY - api_url: https://staging.zcp.zsoftly.ca + bearer_token: STAGING_BEARER_TOKEN + api_url: https://staging-api.zcp.zsoftly.ca production: name: production - apikey: PROD_API_KEY - secretkey: PROD_SECRET_KEY + bearer_token: PROD_BEARER_TOKEN api_url: "" ``` ### Top-Level Fields -| Field | Type | Description | -|------------------|--------|----------------------------------------------------------| -| `active_profile` | string | Name of the profile used when `--profile` is not set. | -| `profiles` | map | Map of profile name to Profile object. | +| Field | Type | Description | +| ---------------- | ------ | ----------------------------------------------------- | +| `active_profile` | string | Name of the profile used when `--profile` is not set. | +| `profiles` | map | Map of profile name to Profile object. | --- @@ -58,17 +55,16 @@ profiles: Each profile supports the following fields: -| Field | YAML Key | Required | Description | -|-------------|-------------|----------|-----------------------------------------------------------------------------| -| Name | `name` | Yes | Must match the map key. Used for display in `zcp profile list`. | -| API Key | `apikey` | Yes | Your ZCP API key. Obtained from the ZSoftly Cloud Portal. | -| Secret Key | `secretkey` | Yes | Your ZCP secret key. Obtained from the ZSoftly Cloud Portal. | -| API URL | `api_url` | No | Override the API base URL for this profile. Blank uses the default. | +| Field | YAML Key | Required | Description | +| ------------ | -------------- | -------- | ------------------------------------------------------------------- | +| Name | `name` | Yes | Must match the map key. Used for display in `zcp profile list`. | +| Bearer Token | `bearer_token` | Yes | Your ZCP API bearer token. Obtained from the ZSoftly Cloud Portal. | +| API URL | `api_url` | No | Override the API base URL for this profile. Blank uses the default. | The default API URL when `api_url` is blank or omitted is: ``` -https://cloud.zcp.zsoftly.ca +https://api.zcp.zsoftly.ca ``` --- @@ -77,22 +73,20 @@ https://cloud.zcp.zsoftly.ca The following environment variables are evaluated at runtime and take precedence over the corresponding config file values. -| Variable | Overrides | Description | -|-------------------|---------------------------------|---------------------------------------------------------------| -| `ZCP_PROFILE` | `--profile` flag / active profile | Profile name to use for the current invocation. | -| `ZCP_API_KEY` | Profile `apikey` | API key, bypassing the config file entirely. | -| `ZCP_SECRET_KEY` | Profile `secretkey` | Secret key, bypassing the config file entirely. | -| `ZCP_API_URL` | Profile `api_url` / default URL | API base URL override. | -| `XDG_CONFIG_HOME` | Config file directory (Linux/macOS) | Overrides the base directory for the config file. | +| Variable | Overrides | Description | +| ------------------ | ----------------------------------- | ------------------------------------------------- | +| `ZCP_PROFILE` | `--profile` flag / active profile | Profile name to use for the current invocation. | +| `ZCP_BEARER_TOKEN` | Profile `bearer_token` | Bearer token, bypassing the config file entirely. | +| `ZCP_API_URL` | Profile `api_url` / default URL | API base URL override. | +| `XDG_CONFIG_HOME` | Config file directory (Linux/macOS) | Overrides the base directory for the config file. | Environment variables are useful for CI/CD pipelines where you do not want credentials stored in a file on disk. Example usage in a pipeline: ```bash -export ZCP_API_KEY=ci-api-key -export ZCP_SECRET_KEY=ci-secret-key -zcp zone list --output json +export ZCP_BEARER_TOKEN=ci-bearer-token +zcp region list --output json ``` --- @@ -109,9 +103,8 @@ zcp profile add # Non-interactive zcp profile add staging \ - --api-key YOUR_STAGING_KEY \ - --secret-key YOUR_STAGING_SECRET \ - --api-url https://staging.zcp.zsoftly.ca + --bearer-token YOUR_STAGING_TOKEN \ + --api-url https://staging-api.zcp.zsoftly.ca ``` ### Switching the Active Profile @@ -125,7 +118,7 @@ This updates `active_profile` in the config file. ### Per-Command Profile Override ```bash -zcp zone list --profile staging +zcp region list --profile staging ``` The `--profile` flag does not modify the config file. It applies only to the current invocation. @@ -142,7 +135,7 @@ Output includes the profile name, API URL, and whether it is currently active. ## Security Notes -**Never commit your config file to version control.** The file contains your API key and secret key in plaintext. +**Never commit your config file to version control.** The file contains your bearer token in plaintext. Add the config file to your `.gitignore`: @@ -154,6 +147,6 @@ Add the config file to your `.gitignore`: Additional recommendations: - The config file is created with `0600` permissions. Do not change these permissions. -- If you suspect your credentials have been compromised, rotate your API key immediately in the ZSoftly Cloud Portal. -- In shared or CI environments, prefer environment variable injection (`ZCP_API_KEY`, `ZCP_SECRET_KEY`) over config files on disk. -- Do not log or print the config struct in scripts; the secret key will appear in output. +- If you suspect your credentials have been compromised, rotate your bearer token immediately in the ZSoftly Cloud Portal. +- In shared or CI environments, prefer environment variable injection (`ZCP_BEARER_TOKEN`) over config files on disk. +- Do not log or print the config struct in scripts; the bearer token will appear in output. diff --git a/docs/development.md b/docs/development.md index d26a3af..00a3889 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,11 +6,11 @@ This guide explains how to set up a development environment for ZCP CLI, underst ## Prerequisites -| Tool | Minimum Version | Notes | -|-------|-----------------|----------------------------------------------------| -| Go | 1.26.1 | As declared in `go.mod` toolchain directive | -| Make | Any | GNU Make for build targets | -| Git | Any | Required for version embedding via `git describe` | +| Tool | Minimum Version | Notes | +| ---- | --------------- | ------------------------------------------------- | +| Go | 1.26.1 | As declared in `go.mod` toolchain directive | +| 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: @@ -53,11 +53,11 @@ zcp-cli/ ### Key Packages - `internal/config` — manages `~/.config/zcp/config.yaml`, profile resolution, and URL precedence logic. -- `internal/httpclient` — a single `Client` struct used by all API service packages. It injects `apikey` and `secretkey` headers, sets `User-Agent`, and delegates error parsing to `apierrors`. +- `internal/httpclient` — a single `Client` struct used by all API service packages. It injects the `Authorization: Bearer` header, sets `User-Agent`, and delegates error parsing to `apierrors`. Also provides `GetEnvelope`/`PostEnvelope`/`PutEnvelope` helpers for unwrapping the `{status, data}` response envelope. - `internal/output` — the `Printer` type renders tabular data in table, JSON, or YAML format. All commands use this for consistent output. -- `internal/api/apierrors` — parses ZCP's error envelope (`listErrorResponse`) into typed `APIError` values. -- `internal/api/waiters` — polls `/restapi/asyncjob/resourceStatus` until a job reaches `COMPLETE` or `FAILED`. -- `internal/commands` — one file per command group (e.g., `zone.go`, `offering.go`). Each file registers Cobra subcommands and implements `RunE` functions. +- `internal/api/apierrors` — parses ZCP API error envelopes into typed `APIError` values. +- `internal/api/response` — generic response envelope types (`Envelope[T]`, `Single[T]`) for the paginated API format. +- `internal/commands` — one file per command group (e.g., `instance.go`, `region.go`, `dns.go`). Each file registers Cobra subcommands and implements `RunE` functions. --- @@ -162,6 +162,7 @@ func (s *Service) List(ctx context.Context) ([]Network, error) { ### 2. Write tests for the service Create `internal/api/network/network_test.go` following the pattern used in `internal/api/zone/zone_test.go`: + - Spin up an `httptest.Server` that returns fixture JSON. - Assert on the path, query parameters, and decoded result. - Assert that HTTP error responses surface as errors. @@ -271,10 +272,10 @@ go mod verify Current direct dependencies: -| Package | Purpose | -|----------------------------------|----------------------------------| -| `github.com/spf13/cobra` | CLI framework | -| `github.com/olekukonko/tablewriter` | Terminal table rendering | -| `gopkg.in/yaml.v3` | YAML marshalling/unmarshalling | +| Package | Purpose | +| ----------------------------------- | ------------------------------ | +| `github.com/spf13/cobra` | CLI framework | +| `github.com/olekukonko/tablewriter` | Terminal table rendering | +| `gopkg.in/yaml.v3` | YAML marshalling/unmarshalling | Prefer the standard library where possible. Introduce new dependencies only when they provide significant value over a standard library implementation. diff --git a/docs/development/RELEASE.md b/docs/development/RELEASE.md index 5007c51..0c9bbc4 100644 --- a/docs/development/RELEASE.md +++ b/docs/development/RELEASE.md @@ -29,14 +29,14 @@ Examples: `0.0.1`, `0.1.0`, `1.0.0` ## Build Artifacts -| Platform | Binary | -| ------------- | ------------------------- | -| Linux amd64 | `zcp-linux-amd64` | -| Linux arm64 | `zcp-linux-arm64` | -| macOS amd64 | `zcp-darwin-amd64` | -| macOS arm64 | `zcp-darwin-arm64` | -| Windows amd64 | `zcp-windows-amd64.exe` | -| Windows arm64 | `zcp-windows-arm64.exe` | +| Platform | Binary | +| ------------- | ----------------------- | +| Linux amd64 | `zcp-linux-amd64` | +| Linux arm64 | `zcp-linux-arm64` | +| macOS amd64 | `zcp-darwin-amd64` | +| macOS arm64 | `zcp-darwin-arm64` | +| Windows amd64 | `zcp-windows-amd64.exe` | +| Windows arm64 | `zcp-windows-arm64.exe` | Also included in the release: diff --git a/go.mod b/go.mod index 4c9f349..0cf4f23 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,12 @@ module github.com/zsoftly/zcp-cli go 1.25.0 -toolchain go1.26.1 +toolchain go1.26.2 require ( github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.9.1 + golang.org/x/crypto v0.49.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,6 +16,5 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 8139993..65efeda 100644 --- a/go.sum +++ b/go.sum @@ -12,12 +12,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/api/acl/acl.go b/internal/api/acl/acl.go index 6dd1eae..be3d55e 100644 --- a/internal/api/acl/acl.go +++ b/internal/api/acl/acl.go @@ -3,44 +3,55 @@ package acl import ( "context" + "encoding/json" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) // NetworkACL represents a ZCP Network Access Control List. type NetworkACL struct { - UUID string `json:"uuid"` + Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` - IsActive bool `json:"isActive"` - ZoneUUID string `json:"zoneUuid"` - VPCUUID string `json:"vpcUuid"` + VPCSlug string `json:"vpcSlug"` } -// CreateRequest holds parameters for creating a Network ACL. -type CreateRequest struct { - Name string `json:"name"` - VPCUUID string `json:"vpcUuid"` - Description string `json:"description,omitempty"` +// ACLRuleCreateRequest holds parameters for creating a Network ACL rule. +type ACLRuleCreateRequest struct { + Protocol string `json:"protocol"` + CIDRList string `json:"cidrList,omitempty"` + StartPort int `json:"startPort,omitempty"` + EndPort int `json:"endPort,omitempty"` + TrafficType string `json:"trafficType,omitempty"` + Action string `json:"action"` + Number int `json:"number,omitempty"` + ICMPCode int `json:"icmpCode,omitempty"` + ICMPType int `json:"icmpType,omitempty"` } -type listNetworkAclListResponse struct { - Count int `json:"count"` - ListNetworkAclListResponse []NetworkACL `json:"listNetworkAclListResponse"` +// ACLRule represents a single ACL rule. +type ACLRule struct { + Slug string `json:"slug"` + Protocol string `json:"protocol"` + CIDRList string `json:"cidrList"` + StartPort int `json:"startPort"` + EndPort int `json:"endPort"` + TrafficType string `json:"trafficType"` + Action string `json:"action"` + Number int `json:"number"` } -// Network represents a minimal network result returned by ACL operations. -type Network struct { - UUID string `json:"uuid"` - Name string `json:"name"` +// ReplaceACLRequest holds parameters for replacing an ACL on a network. +type ReplaceACLRequest struct { + ACLSlug string `json:"aclSlug"` } -type listNetworkResponse struct { - Count int `json:"count"` - ListNetworkResponse []Network `json:"listNetworkResponse"` +// apiResponse is the STKCNSL response envelope. +type apiResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` } // Service provides Network ACL API operations. @@ -53,51 +64,38 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns network ACLs. zoneUUID is required; uuid and vpcUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, uuid, vpcUUID string) ([]NetworkACL, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if vpcUUID != "" { - q.Set("vpcUuid", vpcUUID) +// List returns network ACLs for a VPC by slug. +func (s *Service) List(ctx context.Context, vpcSlug string) ([]NetworkACL, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpcs/"+vpcSlug+"/network-acl-list", nil, &env); err != nil { + return nil, fmt.Errorf("listing network ACLs for VPC %s: %w", vpcSlug, err) } - var resp listNetworkAclListResponse - if err := s.client.Get(ctx, "/restapi/networkacllist/networkAclList", q, &resp); err != nil { - return nil, fmt.Errorf("listing network ACLs: %w", err) + var acls []NetworkACL + if err := json.Unmarshal(env.Data, &acls); err != nil { + return nil, fmt.Errorf("decoding network ACL list: %w", err) } - return resp.ListNetworkAclListResponse, nil + return acls, nil } -// Create provisions a new Network ACL. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*NetworkACL, error) { - var resp listNetworkAclListResponse - if err := s.client.Post(ctx, "/restapi/networkacllist/createNetworkAcl", req, &resp); err != nil { - return nil, fmt.Errorf("creating network ACL: %w", err) +// CreateRule creates a new ACL rule in a VPC. +func (s *Service) CreateRule(ctx context.Context, vpcSlug string, req ACLRuleCreateRequest) (*ACLRule, error) { + var env apiResponse + if err := s.client.Post(ctx, "/vpcs/"+vpcSlug+"/network-acl-list", req, &env); err != nil { + return nil, fmt.Errorf("creating ACL rule in VPC %s: %w", vpcSlug, err) } - if len(resp.ListNetworkAclListResponse) == 0 { - return nil, fmt.Errorf("create network ACL returned empty response") + var rule ACLRule + if err := json.Unmarshal(env.Data, &rule); err != nil { + return nil, fmt.Errorf("decoding created ACL rule: %w", err) } - return &resp.ListNetworkAclListResponse[0], nil + return &rule, nil } -// Delete removes a Network ACL by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/networkacllist/deleteNetworkAcl/"+uuid, nil); err != nil { - return fmt.Errorf("deleting network ACL %s: %w", uuid, err) +// ReplaceNetworkACL replaces the ACL on a network by slug. +func (s *Service) ReplaceNetworkACL(ctx context.Context, networkSlug, aclSlug string) error { + req := ReplaceACLRequest{ACLSlug: aclSlug} + var env apiResponse + if err := s.client.Post(ctx, "/networks/"+networkSlug+"/replace-acl-list", req, &env); err != nil { + return fmt.Errorf("replacing ACL on network %s: %w", networkSlug, err) } return nil } - -// ReplaceNetworkACL replaces the ACL on a network identified by networkUUID. -func (s *Service) ReplaceNetworkACL(ctx context.Context, networkUUID, aclUUID string) ([]Network, error) { - q := url.Values{ - "uuid": {networkUUID}, - "aclUuid": {aclUUID}, - } - var resp listNetworkResponse - if err := s.client.Get(ctx, "/restapi/network/replaceAcl", q, &resp); err != nil { - return nil, fmt.Errorf("replacing ACL on network %s: %w", networkUUID, err) - } - return resp.ListNetworkResponse, nil -} diff --git a/internal/api/acl/acl_test.go b/internal/api/acl/acl_test.go index eb52b6a..28dcd47 100644 --- a/internal/api/acl/acl_test.go +++ b/internal/api/acl/acl_test.go @@ -13,225 +13,77 @@ import ( ) func newClient(baseURL string) *httpclient.Client { - t := httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, - } - return httpclient.New(t) -} - -type listNetworkAclListResponse struct { - Count int `json:"count"` - ListNetworkAclListResponse []acl.NetworkACL `json:"listNetworkAclListResponse"` -} - -type listNetworkResponse struct { - Count int `json:"count"` - ListNetworkResponse []acl.Network `json:"listNetworkResponse"` + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) } func TestACLList(t *testing.T) { - expected := []acl.NetworkACL{ - {UUID: "acl-1", Name: "default-acl", ZoneUUID: "zone-1", VPCUUID: "vpc-1"}, - {UUID: "acl-2", Name: "strict-acl", ZoneUUID: "zone-1", VPCUUID: "vpc-1"}, - } - - var gotZone string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/networkacllist/networkAclList" { - http.NotFound(w, r) - return + if r.URL.Path != "/vpcs/my-vpc/network-acl-list" { + t.Errorf("path = %q", r.URL.Path) } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkAclListResponse{Count: len(expected), ListNetworkAclListResponse: expected}) - })) - defer srv.Close() - - svc := acl.NewService(newClient(srv.URL)) - acls, err := svc.List(context.Background(), "zone-1", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(acls) != 2 { - t.Fatalf("List() returned %d ACLs, want 2", len(acls)) - } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if acls[0].UUID != "acl-1" { - t.Errorf("acls[0].UUID = %q, want %q", acls[0].UUID, "acl-1") - } -} - -func TestACLListWithFilters(t *testing.T) { - expected := []acl.NetworkACL{ - {UUID: "acl-1", Name: "default-acl", ZoneUUID: "zone-1", VPCUUID: "vpc-1"}, - } - - var gotUUID, gotVPCUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - gotVPCUUID = r.URL.Query().Get("vpcUuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkAclListResponse{Count: 1, ListNetworkAclListResponse: expected}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": []map[string]string{ + {"slug": "acl-1", "name": "default-acl", "vpcSlug": "my-vpc"}, + }, + }) })) defer srv.Close() svc := acl.NewService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "acl-1", "vpc-1") + acls, err := svc.List(context.Background(), "my-vpc") if err != nil { t.Fatalf("List() error = %v", err) } - if gotUUID != "acl-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "acl-1") + if len(acls) != 1 { + t.Fatalf("got %d ACLs, want 1", len(acls)) } - if gotVPCUUID != "vpc-1" { - t.Errorf("vpcUuid query param = %q, want %q", gotVPCUUID, "vpc-1") + if acls[0].Slug != "acl-1" { + t.Errorf("slug = %q, want %q", acls[0].Slug, "acl-1") } } -func TestACLCreate(t *testing.T) { - created := acl.NetworkACL{ - UUID: "acl-new", - Name: "my-acl", - VPCUUID: "vpc-1", - } - - var gotBody map[string]interface{} +func TestACLCreateRule(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/networkacllist/createNetworkAcl" { - http.NotFound(w, r) - return + t.Errorf("method = %q, want POST", r.Method) } - json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkAclListResponse{Count: 1, ListNetworkAclListResponse: []acl.NetworkACL{created}}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": map[string]interface{}{"slug": "rule-1", "protocol": "tcp", "action": "allow"}, + }) })) defer srv.Close() svc := acl.NewService(newClient(srv.URL)) - req := acl.CreateRequest{ - Name: "my-acl", - VPCUUID: "vpc-1", - } - result, err := svc.Create(context.Background(), req) + rule, err := svc.CreateRule(context.Background(), "my-vpc", acl.ACLRuleCreateRequest{Protocol: "tcp", Action: "allow"}) if err != nil { - t.Fatalf("Create() error = %v", err) - } - if result.UUID != "acl-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "acl-new") - } - if gotBody["name"] != "my-acl" { - t.Errorf("body name = %v, want %q", gotBody["name"], "my-acl") + t.Fatalf("CreateRule() error = %v", err) } - if gotBody["vpcUuid"] != "vpc-1" { - t.Errorf("body vpcUuid = %v, want %q", gotBody["vpcUuid"], "vpc-1") - } -} - -func TestACLCreateEmptyResponse(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(listNetworkAclListResponse{Count: 0, ListNetworkAclListResponse: nil}) - })) - defer srv.Close() - - svc := acl.NewService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), acl.CreateRequest{Name: "x", VPCUUID: "vpc-1"}) - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") - } -} - -func TestACLDelete(t *testing.T) { - var gotPath, gotMethod string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - svc := acl.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "acl-del-1") - if err != nil { - t.Fatalf("Delete() error = %v", err) - } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) - } - if gotPath != "/restapi/networkacllist/deleteNetworkAcl/acl-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/networkacllist/deleteNetworkAcl/acl-del-1") - } -} - -func TestACLDeleteError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not found", http.StatusNotFound) - })) - defer srv.Close() - - svc := acl.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "missing") - if err == nil { - t.Fatal("Delete() expected error on 404, got nil") + if rule.Slug != "rule-1" { + t.Errorf("slug = %q, want %q", rule.Slug, "rule-1") } } func TestACLReplaceNetworkACL(t *testing.T) { - expected := []acl.Network{ - {UUID: "net-1", Name: "my-net"}, - } - - var gotNetUUID, gotACLUUID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/network/replaceAcl" { - http.NotFound(w, r) - return + if r.URL.Path != "/networks/my-net/replace-acl-list" { + t.Errorf("path = %q", r.URL.Path) } - gotNetUUID = r.URL.Query().Get("uuid") - gotACLUUID = r.URL.Query().Get("aclUuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkResponse{Count: 1, ListNetworkResponse: expected}) + json.NewEncoder(w).Encode(map[string]interface{}{"status": "Success", "data": nil}) })) defer srv.Close() svc := acl.NewService(newClient(srv.URL)) - nets, err := svc.ReplaceNetworkACL(context.Background(), "net-1", "acl-1") + err := svc.ReplaceNetworkACL(context.Background(), "my-net", "acl-1") if err != nil { t.Fatalf("ReplaceNetworkACL() error = %v", err) } - if len(nets) != 1 { - t.Fatalf("ReplaceNetworkACL() returned %d networks, want 1", len(nets)) - } - if gotNetUUID != "net-1" { - t.Errorf("uuid query param = %q, want %q", gotNetUUID, "net-1") - } - if gotACLUUID != "acl-1" { - t.Errorf("aclUuid query param = %q, want %q", gotACLUUID, "acl-1") - } -} - -func TestACLListError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "internal error", http.StatusInternalServerError) - })) - defer srv.Close() - - svc := acl.NewService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "", "") - if err == nil { - t.Fatal("List() expected error on 500, got nil") - } } diff --git a/internal/api/affinitygroup/affinitygroup.go b/internal/api/affinitygroup/affinitygroup.go new file mode 100644 index 0000000..109202b --- /dev/null +++ b/internal/api/affinitygroup/affinitygroup.go @@ -0,0 +1,114 @@ +// Package affinitygroup provides ZCP affinity group API operations. +package affinitygroup + +import ( + "context" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// AffinityGroup represents a ZCP affinity group. +type AffinityGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Type string `json:"type"` + State string `json:"state"` + Status string `json:"status"` + AffinityGroupID string `json:"affinity_group_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + ProjectID string `json:"project_id"` + AccountID string `json:"account_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Region *Region `json:"region,omitempty"` + Project *Project `json:"project,omitempty"` + CloudProvider *CloudProvider `json:"cloud_provider,omitempty"` +} + +// Region represents the region of an affinity group. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Project represents the project of an affinity group. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// CloudProvider represents the cloud provider of an affinity group. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// CreateRequest holds parameters for creating an affinity group. +type CreateRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Project string `json:"project"` + Region string `json:"region"` + CloudProvider string `json:"cloud_provider"` +} + +// listResponse is the STKCNSL paginated response envelope. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []AffinityGroup `json:"data"` + Total int `json:"total"` +} + +// singleResponse is the STKCNSL single-object response envelope. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data AffinityGroup `json:"data"` +} + +// Service provides affinity group API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new affinity group Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all affinity groups. +func (s *Service) List(ctx context.Context) ([]AffinityGroup, error) { + var resp listResponse + if err := s.client.Get(ctx, "/affinity-groups", nil, &resp); err != nil { + return nil, fmt.Errorf("listing affinity groups: %w", err) + } + return resp.Data, nil +} + +// Create provisions a new affinity group. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*AffinityGroup, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/affinity-groups", req, &resp); err != nil { + return nil, fmt.Errorf("creating affinity group: %w", err) + } + return &resp.Data, nil +} + +// Delete removes an affinity group by slug. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/affinity-groups/"+slug, nil); err != nil { + return fmt.Errorf("deleting affinity group %s: %w", slug, err) + } + return nil +} diff --git a/internal/api/apierrors/errors.go b/internal/api/apierrors/errors.go index 48f1a56..f3ff92c 100644 --- a/internal/api/apierrors/errors.go +++ b/internal/api/apierrors/errors.go @@ -39,13 +39,19 @@ func IsForbidden(err error) bool { return errors.As(err, &ae) && ae.StatusCode == 403 } -// apiErrorResponse mirrors the ZCP error envelope: -// { "listErrorResponse": { "errorCode": "...", "errorMsg": "..." } } +// apiErrorResponse mirrors the STKCNSL error envelope: +// { "status": "Error", "message": "...", "errors": { "field": ["..."] } } +// It also supports the legacy STKBILL format for backward compatibility. type apiErrorResponse struct { + // STKCNSL format + Status string `json:"status"` + Message string `json:"message"` + Errors map[string]json.RawMessage `json:"errors"` + + // Legacy STKBILL format ListErrorResponse *apiErrorMsg `json:"listErrorResponse"` ErrorCode string `json:"errorCode"` ErrorMsg string `json:"errorMsg"` - Message string `json:"message"` } type apiErrorMsg struct { @@ -60,13 +66,25 @@ func ParseResponse(statusCode int, body []byte) error { if len(body) > 0 { var resp apiErrorResponse if err := json.Unmarshal(body, &resp); err == nil { - if resp.ListErrorResponse != nil { + switch { + // STKCNSL format: {"status":"Error","message":"...","errors":{...}} + case resp.Status != "" && resp.Message != "": + ae.Code = resp.Status + ae.Message = resp.Message + // Append field-level errors if present. + if len(resp.Errors) > 0 { + if detail, err := json.Marshal(resp.Errors); err == nil { + ae.Message += " — " + string(detail) + } + } + // Legacy STKBILL envelope + case resp.ListErrorResponse != nil: ae.Code = resp.ListErrorResponse.ErrorCode ae.Message = resp.ListErrorResponse.ErrorMsg - } else if resp.ErrorMsg != "" { + case resp.ErrorMsg != "": ae.Code = resp.ErrorCode ae.Message = resp.ErrorMsg - } else if resp.Message != "" { + case resp.Message != "": ae.Message = resp.Message } } diff --git a/internal/api/apierrors/errors_test.go b/internal/api/apierrors/errors_test.go index b9f997d..0f752e8 100644 --- a/internal/api/apierrors/errors_test.go +++ b/internal/api/apierrors/errors_test.go @@ -3,12 +3,65 @@ package apierrors_test import ( "encoding/json" "errors" + "strings" "testing" "github.com/zsoftly/zcp-cli/internal/api/apierrors" ) -func TestParseResponseZCPEnvelope(t *testing.T) { +func TestParseResponseSTKCNSLFormat(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "status": "Error", + "message": "The given data was invalid.", + "errors": map[string]interface{}{ + "email": []string{"The email field is required."}, + }, + }) + + err := apierrors.ParseResponse(422, body) + if err == nil { + t.Fatal("expected error, got nil") + } + + var ae *apierrors.APIError + if !errors.As(err, &ae) { + t.Fatalf("expected *APIError, got %T", err) + } + + if ae.StatusCode != 422 { + t.Errorf("StatusCode = %d, want 422", ae.StatusCode) + } + if ae.Code != "Error" { + t.Errorf("Code = %q, want %q", ae.Code, "Error") + } + if !strings.Contains(ae.Message, "The given data was invalid.") { + t.Errorf("Message = %q, want it to contain %q", ae.Message, "The given data was invalid.") + } + if !strings.Contains(ae.Message, "email") { + t.Errorf("Message = %q, want it to contain field-level errors", ae.Message) + } +} + +func TestParseResponseSTKCNSLSimple(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "status": "Error", + "message": "Unauthenticated.", + }) + + err := apierrors.ParseResponse(401, body) + var ae *apierrors.APIError + if !errors.As(err, &ae) { + t.Fatalf("expected *APIError, got %T", err) + } + if ae.StatusCode != 401 { + t.Errorf("StatusCode = %d, want 401", ae.StatusCode) + } + if ae.Message != "Unauthenticated." { + t.Errorf("Message = %q, want %q", ae.Message, "Unauthenticated.") + } +} + +func TestParseResponseLegacyZCPEnvelope(t *testing.T) { body, _ := json.Marshal(map[string]interface{}{ "listErrorResponse": map[string]string{ "errorCode": "INVALID_CREDENTIALS", diff --git a/internal/api/autoscale/autoscale.go b/internal/api/autoscale/autoscale.go new file mode 100644 index 0000000..7524117 --- /dev/null +++ b/internal/api/autoscale/autoscale.go @@ -0,0 +1,293 @@ +// Package autoscale provides ZCP VM Autoscale API operations (STKCNSL). +package autoscale + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +const basePath = "/autoscale" + +// envelope is the STKCNSL standard response wrapper. +type envelope struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` +} + +// AutoscaleGroup represents a VM autoscale group. +type AutoscaleGroup struct { + Slug string `json:"slug"` + Name string `json:"name"` + State string `json:"state"` + Plan string `json:"plan"` + Template string `json:"template"` + MinInstances int `json:"minInstances"` + MaxInstances int `json:"maxInstances"` + CurrentCount int `json:"currentCount"` + CooldownPeriod int `json:"cooldownPeriod"` + ZoneSlug string `json:"zoneSlug"` + NetworkSlug string `json:"networkSlug"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Policies []Policy `json:"policies,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` +} + +// Policy represents a scale-up policy for an autoscale group. +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold int `json:"threshold"` + Duration int `json:"duration"` + ScaleAmount int `json:"scaleAmount"` + Cooldown int `json:"cooldown"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Condition represents a scale-down condition for an autoscale group. +type Condition struct { + ID string `json:"id"` + Name string `json:"name"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold int `json:"threshold"` + Duration int `json:"duration"` + ScaleAmount int `json:"scaleAmount"` + Cooldown int `json:"cooldown"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// CreateRequest holds parameters for creating an autoscale group. +type CreateRequest struct { + Name string `json:"name"` + Plan string `json:"plan"` + Template string `json:"template"` + MinInstances int `json:"minInstances"` + MaxInstances int `json:"maxInstances"` + CooldownPeriod int `json:"cooldownPeriod,omitempty"` + ZoneSlug string `json:"zoneSlug"` + NetworkSlug string `json:"networkSlug,omitempty"` +} + +// ChangePlanRequest holds parameters for changing an autoscale group's plan. +type ChangePlanRequest struct { + Plan string `json:"plan"` +} + +// ChangeTemplateRequest holds parameters for changing an autoscale group's template. +type ChangeTemplateRequest struct { + Template string `json:"template"` +} + +// PolicyRequest holds parameters for creating or updating a scale-up policy. +type PolicyRequest struct { + Name string `json:"name"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold int `json:"threshold"` + Duration int `json:"duration"` + ScaleAmount int `json:"scaleAmount"` + Cooldown int `json:"cooldown,omitempty"` +} + +// ConditionRequest holds parameters for creating or updating a scale-down condition. +type ConditionRequest struct { + Name string `json:"name"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold int `json:"threshold"` + Duration int `json:"duration"` + ScaleAmount int `json:"scaleAmount"` + Cooldown int `json:"cooldown,omitempty"` +} + +// Service provides autoscale API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new autoscale Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// decodeOne decodes a single object from the STKCNSL envelope data field. +func decodeOne[T any](raw json.RawMessage) (*T, error) { + var v T + if err := json.Unmarshal(raw, &v); err != nil { + return nil, fmt.Errorf("decoding response data: %w", err) + } + return &v, nil +} + +// decodeList decodes an array from the STKCNSL envelope data field. +func decodeList[T any](raw json.RawMessage) ([]T, error) { + var v []T + if err := json.Unmarshal(raw, &v); err != nil { + return nil, fmt.Errorf("decoding response data: %w", err) + } + return v, nil +} + +// List returns all autoscale groups. +func (s *Service) List(ctx context.Context) ([]AutoscaleGroup, error) { + var env envelope + if err := s.client.Get(ctx, basePath, nil, &env); err != nil { + return nil, fmt.Errorf("listing autoscale groups: %w", err) + } + groups, err := decodeList[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("listing autoscale groups: %w", err) + } + return groups, nil +} + +// Create provisions a new autoscale group. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*AutoscaleGroup, error) { + var env envelope + if err := s.client.Post(ctx, basePath, req, &env); err != nil { + return nil, fmt.Errorf("creating autoscale group: %w", err) + } + group, err := decodeOne[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("creating autoscale group: %w", err) + } + return group, nil +} + +// ChangePlan changes the compute plan of an autoscale group. +func (s *Service) ChangePlan(ctx context.Context, slug, plan string) (*AutoscaleGroup, error) { + path := fmt.Sprintf("%s/%s/change-plan", basePath, slug) + var env envelope + if err := s.client.Post(ctx, path, ChangePlanRequest{Plan: plan}, &env); err != nil { + return nil, fmt.Errorf("changing plan for autoscale group %s: %w", slug, err) + } + group, err := decodeOne[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("changing plan for autoscale group %s: %w", slug, err) + } + return group, nil +} + +// ChangeTemplate changes the template of an autoscale group. +func (s *Service) ChangeTemplate(ctx context.Context, slug, template string) (*AutoscaleGroup, error) { + path := fmt.Sprintf("%s/%s/change-template", basePath, slug) + var env envelope + if err := s.client.Post(ctx, path, ChangeTemplateRequest{Template: template}, &env); err != nil { + return nil, fmt.Errorf("changing template for autoscale group %s: %w", slug, err) + } + group, err := decodeOne[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("changing template for autoscale group %s: %w", slug, err) + } + return group, nil +} + +// Enable enables an autoscale group. +func (s *Service) Enable(ctx context.Context, slug string) (*AutoscaleGroup, error) { + path := fmt.Sprintf("%s/%s/enable", basePath, slug) + var env envelope + if err := s.client.Put(ctx, path, nil, nil, &env); err != nil { + return nil, fmt.Errorf("enabling autoscale group %s: %w", slug, err) + } + group, err := decodeOne[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("enabling autoscale group %s: %w", slug, err) + } + return group, nil +} + +// Disable disables an autoscale group. +func (s *Service) Disable(ctx context.Context, slug string) (*AutoscaleGroup, error) { + path := fmt.Sprintf("%s/%s/disable", basePath, slug) + var env envelope + if err := s.client.Put(ctx, path, nil, nil, &env); err != nil { + return nil, fmt.Errorf("disabling autoscale group %s: %w", slug, err) + } + group, err := decodeOne[AutoscaleGroup](env.Data) + if err != nil { + return nil, fmt.Errorf("disabling autoscale group %s: %w", slug, err) + } + return group, nil +} + +// CreatePolicy creates a scale-up policy on an autoscale group. +func (s *Service) CreatePolicy(ctx context.Context, slug string, req PolicyRequest) (*Policy, error) { + path := fmt.Sprintf("%s/%s/policies", basePath, slug) + var env envelope + if err := s.client.Post(ctx, path, req, &env); err != nil { + return nil, fmt.Errorf("creating policy for autoscale group %s: %w", slug, err) + } + policy, err := decodeOne[Policy](env.Data) + if err != nil { + return nil, fmt.Errorf("creating policy for autoscale group %s: %w", slug, err) + } + return policy, nil +} + +// UpdatePolicy updates a scale-up policy on an autoscale group. +func (s *Service) UpdatePolicy(ctx context.Context, slug string, policyID int, req PolicyRequest) (*Policy, error) { + path := fmt.Sprintf("%s/%s/policies/%d", basePath, slug, policyID) + var env envelope + if err := s.client.Put(ctx, path, nil, req, &env); err != nil { + return nil, fmt.Errorf("updating policy %d for autoscale group %s: %w", policyID, slug, err) + } + policy, err := decodeOne[Policy](env.Data) + if err != nil { + return nil, fmt.Errorf("updating policy %d for autoscale group %s: %w", policyID, slug, err) + } + return policy, nil +} + +// DeletePolicy deletes a scale-up policy from an autoscale group. +func (s *Service) DeletePolicy(ctx context.Context, slug string, policyID int) error { + path := fmt.Sprintf("%s/%s/policies/%d", basePath, slug, policyID) + if err := s.client.Delete(ctx, path, nil); err != nil { + return fmt.Errorf("deleting policy %d for autoscale group %s: %w", policyID, slug, err) + } + return nil +} + +// CreateCondition creates a scale-down condition on an autoscale group. +func (s *Service) CreateCondition(ctx context.Context, slug string, req ConditionRequest) (*Condition, error) { + path := fmt.Sprintf("%s/%s/conditions", basePath, slug) + var env envelope + if err := s.client.Post(ctx, path, req, &env); err != nil { + return nil, fmt.Errorf("creating condition for autoscale group %s: %w", slug, err) + } + cond, err := decodeOne[Condition](env.Data) + if err != nil { + return nil, fmt.Errorf("creating condition for autoscale group %s: %w", slug, err) + } + return cond, nil +} + +// UpdateCondition updates a scale-down condition on an autoscale group. +func (s *Service) UpdateCondition(ctx context.Context, slug string, conditionID int, req ConditionRequest) (*Condition, error) { + path := fmt.Sprintf("%s/%s/conditions/%d", basePath, slug, conditionID) + var env envelope + if err := s.client.Put(ctx, path, nil, req, &env); err != nil { + return nil, fmt.Errorf("updating condition %d for autoscale group %s: %w", conditionID, slug, err) + } + cond, err := decodeOne[Condition](env.Data) + if err != nil { + return nil, fmt.Errorf("updating condition %d for autoscale group %s: %w", conditionID, slug, err) + } + return cond, nil +} + +// DeleteCondition deletes a scale-down condition from an autoscale group. +func (s *Service) DeleteCondition(ctx context.Context, slug string, conditionID int) error { + path := fmt.Sprintf("%s/%s/conditions/%d", basePath, slug, conditionID) + if err := s.client.Delete(ctx, path, nil); err != nil { + return fmt.Errorf("deleting condition %d for autoscale group %s: %w", conditionID, slug, err) + } + return nil +} diff --git a/internal/api/backup/backup.go b/internal/api/backup/backup.go new file mode 100644 index 0000000..0782d49 --- /dev/null +++ b/internal/api/backup/backup.go @@ -0,0 +1,98 @@ +// Package backup provides ZCP block storage backup API operations +// targeting the STKCNSL API. +package backup + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Backup represents a STKCNSL block storage backup. +type Backup struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + BlockstorageID string `json:"blockstorage_id"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + Interval string `json:"interval"` + At int `json:"at"` + Immediate bool `json:"immediate"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + HasContract bool `json:"has_contract"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` +} + +// listResponse is the STKCNSL paginated envelope for block storage backups. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Backup `json:"data"` + Total int `json:"total"` +} + +// singleResponse is used when the API returns a single backup in `data`. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Backup `json:"data"` +} + +// CreateRequest holds parameters for creating a block storage backup. +type CreateRequest struct { + Interval string `json:"interval"` + At int `json:"at"` + Immediate int `json:"immediate"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + BillingCycle string `json:"billing_cycle"` + Plan string `json:"plan"` + PseudoService string `json:"psudo_service"` + Project string `json:"project"` +} + +// Service provides block storage backup API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new backup Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns block storage backups. +func (s *Service) List(ctx context.Context) ([]Backup, error) { + q := url.Values{} + var resp listResponse + if err := s.client.Get(ctx, "/blockstorages/backups", q, &resp); err != nil { + return nil, fmt.Errorf("listing block storage backups: %w", err) + } + return resp.Data, nil +} + +// Create creates a new block storage backup. +func (s *Service) Create(ctx context.Context, blockstorageSlug string, req CreateRequest) (*Backup, error) { + var resp singleResponse + path := fmt.Sprintf("/blockstorages/%s/backups", blockstorageSlug) + if err := s.client.Post(ctx, path, req, &resp); err != nil { + return nil, fmt.Errorf("creating block storage backup: %w", err) + } + return &resp.Data, nil +} diff --git a/internal/api/backup/backup_test.go b/internal/api/backup/backup_test.go new file mode 100644 index 0000000..a1b6b1b --- /dev/null +++ b/internal/api/backup/backup_test.go @@ -0,0 +1,126 @@ +package backup_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/backup" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []backup.Backup `json:"data"` + Total int `json:"total"` +} + +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data backup.Backup `json:"data"` +} + +func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { + t.Helper() + return httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +func TestBackupList(t *testing.T) { + expected := []backup.Backup{ + {ID: "bak-1", Name: "backup-a", Slug: "backup-a", BlockstorageID: "vol-1"}, + {ID: "bak-2", Name: "backup-b", Slug: "backup-b", BlockstorageID: "vol-2"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/blockstorages/backups" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + Message: "Ok", + Data: expected, + Total: len(expected), + }) + })) + defer srv.Close() + + svc := backup.NewService(newTestClient(t, srv)) + backups, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(backups) != 2 { + t.Fatalf("List() returned %d backups, want 2", len(backups)) + } + if backups[0].ID != "bak-1" { + t.Errorf("backups[0].ID = %q, want %q", backups[0].ID, "bak-1") + } +} + +func TestBackupCreate(t *testing.T) { + expectedBackup := backup.Backup{ + ID: "bak-new", + Name: "my-backup", + Slug: "my-backup", + BlockstorageID: "vol-1", + Interval: "dailyAt", + At: 1, + Immediate: true, + } + + var gotPath string + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "want POST", http.StatusMethodNotAllowed) + return + } + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{ + Status: "Success", + Message: "Ok", + Data: expectedBackup, + }) + })) + defer srv.Close() + + svc := backup.NewService(newTestClient(t, srv)) + req := backup.CreateRequest{ + Interval: "dailyAt", + At: 1, + Immediate: 1, + CloudProvider: "nimbo", + Region: "noida", + BillingCycle: "hourly", + Plan: "backup-1", + PseudoService: "Virtual Machine Backup", + Project: "default-73", + } + bak, err := svc.Create(context.Background(), "root-4153", req) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if bak.ID != "bak-new" { + t.Errorf("bak.ID = %q, want %q", bak.ID, "bak-new") + } + if gotPath != "/blockstorages/root-4153/backups" { + t.Errorf("path = %q, want %q", gotPath, "/blockstorages/root-4153/backups") + } + if gotBody["interval"] != "dailyAt" { + t.Errorf("body interval = %v, want %q", gotBody["interval"], "dailyAt") + } +} diff --git a/internal/api/billing/billing.go b/internal/api/billing/billing.go new file mode 100644 index 0000000..4a0592a --- /dev/null +++ b/internal/api/billing/billing.go @@ -0,0 +1,424 @@ +// Package billing provides ZCP billing, analytics, and account API operations. +package billing + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Balance represents account balance information. +type Balance struct { + AvailableBalance float64 `json:"available_balance"` + AvailableNetBalance float64 `json:"available_net_balance"` + Deposited float64 `json:"deposited"` + Charged float64 `json:"charged"` + Due float64 `json:"due"` + Usage float64 `json:"usage"` + CurrentUsage float64 `json:"current_usage"` + HourlyUsage float64 `json:"hourly_usage"` + CurrentHourlyRate float64 `json:"current_hourly_rate"` + AllTimeUsage float64 `json:"all_time_usage"` + EstimatedHourlyUsage float64 `json:"estimated_hourly_usage"` + CurrentMonthUsage float64 `json:"current_month_usage"` + AvailableFreeCredits float64 `json:"available_free_credits"` + FreeCreditBalance float64 `json:"free_credit_balance"` + TotalPayouts float64 `json:"total_payouts"` + UnpaidInvoices float64 `json:"unpaid_invoices"` + BillingCycleUsage map[string]string `json:"billing_cycle_usage"` + DepositedPayments float64 `json:"deposited_payments"` + SubscriptionAmount float64 `json:"subscription_amount"` +} + +// ServiceCost represents cost for a single service category. +type ServiceCost struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + TotalCost float64 `json:"total_cost"` +} + +// MonthlyUsage represents usage for a single month. +type MonthlyUsage struct { + Month string `json:"month"` + Year string `json:"year"` + Cost json.Number `json:"cost"` +} + +// CreditLimit represents the account credit limit. +type CreditLimit struct { + Limit string `json:"limit"` + UsageAmount float64 `json:"usage_amount"` + AvailableToSpend float64 `json:"available_to_spend"` +} + +// BillingCycle holds billing cycle info within a subscription. +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Duration int `json:"duration"` + Unit string `json:"unit"` + IsEnabled bool `json:"is_enabled"` +} + +// SubscriptionProject holds project info within a subscription. +type SubscriptionProject struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Purpose string `json:"purpose"` + Description string `json:"description"` +} + +// Subscription represents an active or inactive service subscription. +type Subscription struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Product string `json:"product"` + ProductDisplayName string `json:"product_display_name"` + CustomerName string `json:"customer_name"` + CustomerID string `json:"customer_id"` + BillingCycle BillingCycle `json:"billing_cycle"` + InvoiceItemsCount int `json:"invoice_items_count"` + RenewAt string `json:"renew_at"` + Quantity string `json:"quantity"` + Price string `json:"price"` + TotalUsage string `json:"total_usage"` + TotalUsageWithTax string `json:"total_usage_with_tax"` + Project SubscriptionProject `json:"project"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + Rule string `json:"rule"` + HasContract bool `json:"has_contract"` + CreatedAt string `json:"created_at"` + AccountCRN string `json:"account_crn"` +} + +// InvoiceItem represents a line item on an invoice. +type InvoiceItem struct { + ID string `json:"id"` + InvoiceID string `json:"invoice_id"` + Item string `json:"item"` + Quantity string `json:"quantity"` + Description string `json:"description"` + Rate string `json:"rate"` + SubAmount string `json:"sub_amount"` + Amount string `json:"amount"` + ServiceName string `json:"service_name"` + ItemDisplayName string `json:"item_display_name"` +} + +// Invoice represents a ZCP billing invoice. +type Invoice struct { + ID string `json:"id"` + Number int `json:"number"` + CustomNumber string `json:"custom_number"` + Amount string `json:"amount"` + SubAmount string `json:"sub_amount"` + TaxPercent string `json:"tax_percent"` + TaxAmount float64 `json:"tax_amount"` + Type string `json:"type"` + Status string `json:"status"` + InvoiceAt string `json:"invoice_at"` + DueAt string `json:"due_at"` + PaidAt string `json:"paid_at"` + CreatedAt string `json:"created_at"` + Items []InvoiceItem `json:"items"` + InvoiceViewURL string `json:"invoice_view_url"` + InvoiceDownloadURL string `json:"invoice_download_url"` + InvoiceValue string `json:"invoice_value"` + Total string `json:"total"` + PaymentMethods string `json:"payment_methods"` + GeneratedBy string `json:"generated_by"` +} + +// envelope is the standard STKCNSL API response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// paginatedEnvelope extends envelope with pagination fields. +type paginatedEnvelope struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data json.RawMessage `json:"data"` + Total int `json:"total"` + LastPage int `json:"last_page"` +} + +// Service provides billing API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new billing Service. +func NewService(client *httpclient.Client) *Service { return &Service{client: client} } + +// GetBalance returns the account balance summary. +func (s *Service) GetBalance(ctx context.Context) (*Balance, error) { + var env envelope + if err := s.client.Get(ctx, "/account/balance", nil, &env); err != nil { + return nil, fmt.Errorf("getting account balance: %w", err) + } + var bal Balance + if err := json.Unmarshal(env.Data, &bal); err != nil { + return nil, fmt.Errorf("decoding account balance: %w", err) + } + return &bal, nil +} + +// ListServiceCosts returns per-service cost breakdown. +func (s *Service) ListServiceCosts(ctx context.Context) ([]ServiceCost, error) { + var env envelope + if err := s.client.Get(ctx, "/analytics/services/costs", nil, &env); err != nil { + return nil, fmt.Errorf("listing service costs: %w", err) + } + var costs []ServiceCost + if err := json.Unmarshal(env.Data, &costs); err != nil { + return nil, fmt.Errorf("decoding service costs: %w", err) + } + return costs, nil +} + +// ListMonthlyUsage returns month-by-month usage data. +func (s *Service) ListMonthlyUsage(ctx context.Context) ([]MonthlyUsage, error) { + var env envelope + if err := s.client.Get(ctx, "/analytics/month-wise-usage", nil, &env); err != nil { + return nil, fmt.Errorf("listing monthly usage: %w", err) + } + var usage []MonthlyUsage + if err := json.Unmarshal(env.Data, &usage); err != nil { + return nil, fmt.Errorf("decoding monthly usage: %w", err) + } + return usage, nil +} + +// GetServiceCounts returns a map of service name to count. +func (s *Service) GetServiceCounts(ctx context.Context) (map[string]int, error) { + var env envelope + if err := s.client.Get(ctx, "/analytics/account/services/counts", nil, &env); err != nil { + return nil, fmt.Errorf("getting service counts: %w", err) + } + var counts map[string]int + if err := json.Unmarshal(env.Data, &counts); err != nil { + return nil, fmt.Errorf("decoding service counts: %w", err) + } + return counts, nil +} + +// GetCreditLimit returns the account credit limit information. +func (s *Service) GetCreditLimit(ctx context.Context) (*CreditLimit, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/credit-limit", nil, &env); err != nil { + return nil, fmt.Errorf("getting credit limit: %w", err) + } + var limit CreditLimit + if err := json.Unmarshal(env.Data, &limit); err != nil { + return nil, fmt.Errorf("decoding credit limit: %w", err) + } + return &limit, nil +} + +// ListInvoices returns a paginated list of invoices. +func (s *Service) ListInvoices(ctx context.Context, page int) ([]Invoice, int, error) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + var env paginatedEnvelope + if err := s.client.Get(ctx, "/billing/invoices", q, &env); err != nil { + return nil, 0, fmt.Errorf("listing invoices: %w", err) + } + var invoices []Invoice + if err := json.Unmarshal(env.Data, &invoices); err != nil { + return nil, 0, fmt.Errorf("decoding invoices: %w", err) + } + return invoices, env.Total, nil +} + +// GetInvoiceCount returns the total number of invoices. +func (s *Service) GetInvoiceCount(ctx context.Context) (int, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/invoices-count", nil, &env); err != nil { + return 0, fmt.Errorf("getting invoice count: %w", err) + } + var count int + if err := json.Unmarshal(env.Data, &count); err != nil { + return 0, fmt.Errorf("decoding invoice count: %w", err) + } + return count, nil +} + +// ListActiveSubscriptions returns active service subscriptions. +func (s *Service) ListActiveSubscriptions(ctx context.Context, page int) ([]Subscription, int, error) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + var env paginatedEnvelope + if err := s.client.Get(ctx, "/billing/subscriptions/active", q, &env); err != nil { + return nil, 0, fmt.Errorf("listing active subscriptions: %w", err) + } + var subs []Subscription + if err := json.Unmarshal(env.Data, &subs); err != nil { + return nil, 0, fmt.Errorf("decoding active subscriptions: %w", err) + } + return subs, env.Total, nil +} + +// ListInactiveSubscriptions returns inactive service subscriptions. +func (s *Service) ListInactiveSubscriptions(ctx context.Context, page int) ([]Subscription, int, error) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + var env paginatedEnvelope + if err := s.client.Get(ctx, "/billing/subscriptions/inactive", q, &env); err != nil { + return nil, 0, fmt.Errorf("listing inactive subscriptions: %w", err) + } + var subs []Subscription + if err := json.Unmarshal(env.Data, &subs); err != nil { + return nil, 0, fmt.Errorf("decoding inactive subscriptions: %w", err) + } + return subs, env.Total, nil +} + +// GetAccountUsage returns raw billing account usage data. +func (s *Service) GetAccountUsage(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/account/usage", nil, &env); err != nil { + return nil, fmt.Errorf("getting account usage: %w", err) + } + return env.Data, nil +} + +// GetFreeCredits returns free credits information. +func (s *Service) GetFreeCredits(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/account/free-credits", nil, &env); err != nil { + return nil, fmt.Errorf("getting free credits: %w", err) + } + return env.Data, nil +} + +// ListServiceContracts returns service contracts. +func (s *Service) ListServiceContracts(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/subscriptions/service-contracts", nil, &env); err != nil { + return nil, fmt.Errorf("listing service contracts: %w", err) + } + return env.Data, nil +} + +// ListServiceTrials returns active free trials. +func (s *Service) ListServiceTrials(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/subscriptions/service-trials", nil, &env); err != nil { + return nil, fmt.Errorf("listing service trials: %w", err) + } + return env.Data, nil +} + +// ListCancelRequests returns scheduled service cancellation requests. +func (s *Service) ListCancelRequests(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/service-cancel-requests", nil, &env); err != nil { + return nil, fmt.Errorf("listing cancel requests: %w", err) + } + return env.Data, nil +} + +// CancelServiceRequest holds parameters for service cancellation. +type CancelServiceRequest struct { + ServiceName string `json:"service_name"` + Reason string `json:"reason"` + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + +// CancelService submits a cancellation request for a service by subscription slug. +func (s *Service) CancelService(ctx context.Context, slug string, req CancelServiceRequest) error { + var env envelope + if err := s.client.Post(ctx, "/billing/service-cancel-requests/"+slug, req, &env); err != nil { + return fmt.Errorf("cancelling service %s: %w", slug, err) + } + return nil +} + +// ListPayments returns payment transactions for the account. +func (s *Service) ListPayments(ctx context.Context, page int) (json.RawMessage, error) { + q := url.Values{} + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + var env paginatedEnvelope + if err := s.client.Get(ctx, "/account/payments", q, &env); err != nil { + return nil, fmt.Errorf("listing payments: %w", err) + } + return env.Data, nil +} + +// ListCoupons returns coupons associated with the account. +func (s *Service) ListCoupons(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/account/coupons", nil, &env); err != nil { + return nil, fmt.Errorf("listing coupons: %w", err) + } + return env.Data, nil +} + +// RedeemCouponRequest holds coupon redemption parameters. +type RedeemCouponRequest struct { + Code string `json:"code"` +} + +// RedeemCoupon applies a coupon code to the account. +func (s *Service) RedeemCoupon(ctx context.Context, code string) (json.RawMessage, error) { + req := RedeemCouponRequest{Code: code} + var env envelope + if err := s.client.Post(ctx, "/account/coupons", req, &env); err != nil { + return nil, fmt.Errorf("redeeming coupon %s: %w", code, err) + } + return env.Data, nil +} + +// BudgetAlert represents budget alert settings. +type BudgetAlert struct { + Amount float64 `json:"amount"` + Threshold float64 `json:"threshold"` + IsEnabled bool `json:"is_enabled"` +} + +// GetBudgetAlert returns the current budget alert settings. +func (s *Service) GetBudgetAlert(ctx context.Context) (json.RawMessage, error) { + var env envelope + if err := s.client.Get(ctx, "/billing/budget-alert-settings", nil, &env); err != nil { + return nil, fmt.Errorf("getting budget alert settings: %w", err) + } + return env.Data, nil +} + +// SetBudgetAlertRequest holds budget alert configuration. +type SetBudgetAlertRequest struct { + Amount float64 `json:"amount"` + Threshold float64 `json:"threshold"` + IsEnabled bool `json:"is_enabled"` +} + +// SetBudgetAlert updates budget alert settings. +func (s *Service) SetBudgetAlert(ctx context.Context, req SetBudgetAlertRequest) (json.RawMessage, error) { + var env envelope + if err := s.client.Post(ctx, "/billing/budget-alert-settings", req, &env); err != nil { + return nil, fmt.Errorf("setting budget alert: %w", err) + } + return env.Data, nil +} diff --git a/internal/api/billing/billing_test.go b/internal/api/billing/billing_test.go new file mode 100644 index 0000000..625f34f --- /dev/null +++ b/internal/api/billing/billing_test.go @@ -0,0 +1,418 @@ +package billing_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/billing" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +func envelope(data interface{}) []byte { + d, _ := json.Marshal(data) + resp := map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(d), + } + b, _ := json.Marshal(resp) + return b +} + +func paginatedEnvelope(data interface{}, total int) []byte { + d, _ := json.Marshal(data) + resp := map[string]interface{}{ + "status": "Success", + "message": "OK", + "current_page": 1, + "data": json.RawMessage(d), + "total": total, + "last_page": 1, + } + b, _ := json.Marshal(resp) + return b +} + +func TestGetBalance(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/account/balance" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(map[string]interface{}{ + "available_balance": 3644.36, + "deposited": 5000.0, + "current_hourly_rate": 14.20, + "current_month_usage": 2343.95, + })) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + bal, err := svc.GetBalance(context.Background()) + if err != nil { + t.Fatalf("GetBalance() error = %v", err) + } + if bal.AvailableBalance != 3644.36 { + t.Errorf("AvailableBalance = %v, want 3644.36", bal.AvailableBalance) + } + if bal.Deposited != 5000.0 { + t.Errorf("Deposited = %v, want 5000", bal.Deposited) + } +} + +func TestGetBalanceError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + _, err := svc.GetBalance(context.Background()) + if err == nil { + t.Fatal("GetBalance() expected error on 401, got nil") + } +} + +func TestListServiceCosts(t *testing.T) { + costs := []billing.ServiceCost{ + {Name: "Virtual Machine", DisplayName: "Instances", TotalCost: 554.54}, + {Name: "Block Storage", DisplayName: "Volume", TotalCost: 102.77}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/analytics/services/costs" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(costs)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, err := svc.ListServiceCosts(context.Background()) + if err != nil { + t.Fatalf("ListServiceCosts() error = %v", err) + } + if len(result) != 2 { + t.Fatalf("ListServiceCosts() returned %d items, want 2", len(result)) + } + if result[0].Name != "Virtual Machine" { + t.Errorf("result[0].Name = %q, want %q", result[0].Name, "Virtual Machine") + } + if result[0].TotalCost != 554.54 { + t.Errorf("result[0].TotalCost = %v, want 554.54", result[0].TotalCost) + } +} + +func TestListServiceCostsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server error", http.StatusInternalServerError) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + _, err := svc.ListServiceCosts(context.Background()) + if err == nil { + t.Fatal("ListServiceCosts() expected error on 500, got nil") + } +} + +func TestListMonthlyUsage(t *testing.T) { + usage := []map[string]interface{}{ + {"month": "Jan", "year": "2026", "cost": "2343.95"}, + {"month": "Feb", "year": "2026", "cost": 0}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/analytics/month-wise-usage" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(usage)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, err := svc.ListMonthlyUsage(context.Background()) + if err != nil { + t.Fatalf("ListMonthlyUsage() error = %v", err) + } + if len(result) != 2 { + t.Fatalf("ListMonthlyUsage() returned %d items, want 2", len(result)) + } + if result[0].Month != "Jan" { + t.Errorf("result[0].Month = %q, want %q", result[0].Month, "Jan") + } +} + +func TestGetServiceCounts(t *testing.T) { + counts := map[string]int{ + "Virtual Machine": 1, + "Block Storage": 1, + "Network": 2, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/analytics/account/services/counts" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(counts)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, err := svc.GetServiceCounts(context.Background()) + if err != nil { + t.Fatalf("GetServiceCounts() error = %v", err) + } + if result["Virtual Machine"] != 1 { + t.Errorf("Virtual Machine count = %d, want 1", result["Virtual Machine"]) + } + if result["Network"] != 2 { + t.Errorf("Network count = %d, want 2", result["Network"]) + } +} + +func TestGetCreditLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/credit-limit" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(map[string]interface{}{ + "limit": "1000", + "usage_amount": 0, + "available_to_spend": 1000, + })) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, err := svc.GetCreditLimit(context.Background()) + if err != nil { + t.Fatalf("GetCreditLimit() error = %v", err) + } + if result.Limit != "1000" { + t.Errorf("Limit = %q, want %q", result.Limit, "1000") + } + if result.AvailableToSpend != 1000 { + t.Errorf("AvailableToSpend = %v, want 1000", result.AvailableToSpend) + } +} + +func TestListInvoices(t *testing.T) { + invoices := []billing.Invoice{ + { + ID: "inv-1", + Number: 1611, + CustomNumber: "INV-2026-1611", + Amount: "5900", + Status: "PAID", + Type: "PAYABLE", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/invoices" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(paginatedEnvelope(invoices, 1)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, total, err := svc.ListInvoices(context.Background(), 0) + if err != nil { + t.Fatalf("ListInvoices() error = %v", err) + } + if total != 1 { + t.Errorf("total = %d, want 1", total) + } + if len(result) != 1 { + t.Fatalf("ListInvoices() returned %d items, want 1", len(result)) + } + if result[0].CustomNumber != "INV-2026-1611" { + t.Errorf("result[0].CustomNumber = %q, want %q", result[0].CustomNumber, "INV-2026-1611") + } +} + +func TestGetInvoiceCount(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/invoices-count" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(1)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + count, err := svc.GetInvoiceCount(context.Background()) + if err != nil { + t.Fatalf("GetInvoiceCount() error = %v", err) + } + if count != 1 { + t.Errorf("count = %d, want 1", count) + } +} + +func TestListActiveSubscriptions(t *testing.T) { + subs := []billing.Subscription{ + { + ID: "sub-1", + Name: "demo-vm", + Product: "Virtual Machine", + ProductDisplayName: "Instances", + Price: "9.40", + TotalUsage: "554.54", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/subscriptions/active" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(paginatedEnvelope(subs, 1)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, total, err := svc.ListActiveSubscriptions(context.Background(), 0) + if err != nil { + t.Fatalf("ListActiveSubscriptions() error = %v", err) + } + if total != 1 { + t.Errorf("total = %d, want 1", total) + } + if len(result) != 1 { + t.Fatalf("ListActiveSubscriptions() returned %d items, want 1", len(result)) + } + if result[0].Product != "Virtual Machine" { + t.Errorf("result[0].Product = %q, want %q", result[0].Product, "Virtual Machine") + } +} + +func TestListInactiveSubscriptions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/subscriptions/inactive" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(paginatedEnvelope([]billing.Subscription{}, 0)) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + result, total, err := svc.ListInactiveSubscriptions(context.Background(), 0) + if err != nil { + t.Fatalf("ListInactiveSubscriptions() error = %v", err) + } + if total != 0 { + t.Errorf("total = %d, want 0", total) + } + if len(result) != 0 { + t.Fatalf("ListInactiveSubscriptions() returned %d items, want 0", len(result)) + } +} + +func TestRedeemCoupon(t *testing.T) { + var gotCode string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/account/coupons" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var req billing.RedeemCouponRequest + json.NewDecoder(r.Body).Decode(&req) + gotCode = req.Code + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(map[string]string{"message": "Coupon applied"})) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + _, err := svc.RedeemCoupon(context.Background(), "SAVE50") + if err != nil { + t.Fatalf("RedeemCoupon() error = %v", err) + } + if gotCode != "SAVE50" { + t.Errorf("code = %q, want %q", gotCode, "SAVE50") + } +} + +func TestCancelService(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(map[string]string{"message": "Cancellation scheduled"})) + })) + 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.Fatalf("CancelService() error = %v", err) + } + if gotPath != "/billing/service-cancel-requests/my-subscription-slug" { + t.Errorf("path = %q, want %q", gotPath, "/billing/service-cancel-requests/my-subscription-slug") + } +} + +func TestSetBudgetAlert(t *testing.T) { + var gotReq billing.SetBudgetAlertRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/budget-alert-settings" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + w.Write(envelope(map[string]string{"message": "Budget alert updated"})) + })) + defer srv.Close() + + svc := billing.NewService(newClient(srv.URL)) + _, err := svc.SetBudgetAlert(context.Background(), billing.SetBudgetAlertRequest{ + Amount: 500.0, + Threshold: 80.0, + IsEnabled: true, + }) + if err != nil { + t.Fatalf("SetBudgetAlert() error = %v", err) + } + if gotReq.Amount != 500.0 { + t.Errorf("Amount = %v, want 500.0", gotReq.Amount) + } + if gotReq.Threshold != 80.0 { + t.Errorf("Threshold = %v, want 80.0", gotReq.Threshold) + } + if !gotReq.IsEnabled { + t.Error("IsEnabled = false, want true") + } +} diff --git a/internal/api/billingcycle/billingcycle.go b/internal/api/billingcycle/billingcycle.go new file mode 100644 index 0000000..d34c65c --- /dev/null +++ b/internal/api/billingcycle/billingcycle.go @@ -0,0 +1,67 @@ +// Package billingcycle provides ZCP billing cycle API operations (STKCNSL). +package billingcycle + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// PaymentMode represents a payment mode associated with a billing cycle. +type PaymentMode struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Status bool `json:"status"` +} + +// BillingCycle represents a STKCNSL billing cycle +// (e.g. Hourly, Monthly, Quarterly, Yearly). +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Duration int `json:"duration"` + Unit string `json:"unit"` + IsEnabled bool `json:"is_enabled"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + PaymentModes []PaymentMode `json:"payment_modes"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides billing cycle API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new billing cycle Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all billing cycles. +func (s *Service) List(ctx context.Context) ([]BillingCycle, error) { + var env envelope + if err := s.client.Get(ctx, "/billing-cycles", nil, &env); err != nil { + return nil, fmt.Errorf("listing billing cycles: %w", err) + } + + var cycles []BillingCycle + if err := json.Unmarshal(env.Data, &cycles); err != nil { + return nil, fmt.Errorf("decoding billing cycles: %w", err) + } + + return cycles, nil +} diff --git a/internal/api/billingcycle/billingcycle_test.go b/internal/api/billingcycle/billingcycle_test.go new file mode 100644 index 0000000..2f20815 --- /dev/null +++ b/internal/api/billingcycle/billingcycle_test.go @@ -0,0 +1,103 @@ +package billingcycle_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/billingcycle" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestBillingCycleList(t *testing.T) { + expected := []billingcycle.BillingCycle{ + { + ID: "bc-1", + Name: "Hourly", + Slug: "hourly", + Duration: 1, + Unit: "hour", + IsEnabled: true, + PaymentModes: []billingcycle.PaymentMode{ + {ID: "pm-1", Name: "PREPAID", Slug: "prepaid", DisplayName: "Online Payment", Status: true}, + }, + }, + { + ID: "bc-2", + Name: "Monthly", + Slug: "monthly", + Duration: 1, + Unit: "month", + IsEnabled: true, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing-cycles" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := billingcycle.NewService(client) + cycles, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(cycles) != 2 { + t.Fatalf("List() returned %d cycles, want 2", len(cycles)) + } + if cycles[0].Slug != "hourly" { + t.Errorf("cycles[0].Slug = %q, want %q", cycles[0].Slug, "hourly") + } + if cycles[0].Unit != "hour" { + t.Errorf("cycles[0].Unit = %q, want %q", cycles[0].Unit, "hour") + } + if len(cycles[0].PaymentModes) != 1 { + t.Fatalf("cycles[0].PaymentModes has %d items, want 1", len(cycles[0].PaymentModes)) + } + if cycles[0].PaymentModes[0].DisplayName != "Online Payment" { + t.Errorf("cycles[0].PaymentModes[0].DisplayName = %q, want %q", + cycles[0].PaymentModes[0].DisplayName, "Online Payment") + } +} + +func TestBillingCycleListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := billingcycle.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/cloudprovider/cloudprovider.go b/internal/api/cloudprovider/cloudprovider.go new file mode 100644 index 0000000..a90417a --- /dev/null +++ b/internal/api/cloudprovider/cloudprovider.go @@ -0,0 +1,59 @@ +// Package cloudprovider provides ZCP cloud provider API operations (STKCNSL). +package cloudprovider + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// CloudProvider represents a STKCNSL cloud provider. +type CloudProvider struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` + Description string `json:"description"` + IsMultiRegionSetup bool `json:"is_multi_region_setup"` + Status bool `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + SortOrder *int `json:"sort_order"` + Icon string `json:"icon"` + Services []string `json:"services"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides cloud provider API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new cloud provider Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all cloud providers. +func (s *Service) List(ctx context.Context) ([]CloudProvider, error) { + var env envelope + if err := s.client.Get(ctx, "/cloud-providers", nil, &env); err != nil { + return nil, fmt.Errorf("listing cloud providers: %w", err) + } + + var providers []CloudProvider + if err := json.Unmarshal(env.Data, &providers); err != nil { + return nil, fmt.Errorf("decoding cloud providers: %w", err) + } + + return providers, nil +} diff --git a/internal/api/cloudprovider/cloudprovider_test.go b/internal/api/cloudprovider/cloudprovider_test.go new file mode 100644 index 0000000..fdcf1bb --- /dev/null +++ b/internal/api/cloudprovider/cloudprovider_test.go @@ -0,0 +1,94 @@ +package cloudprovider_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/cloudprovider" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestCloudProviderList(t *testing.T) { + expected := []cloudprovider.CloudProvider{ + { + ID: "cp-1", + Name: "nimbo", + DisplayName: "Webberstop Cloud", + Slug: "nimbo", + Status: true, + Services: []string{"Virtual Machine", "Block Storage"}, + }, + { + ID: "cp-2", + Name: "stratus", + DisplayName: "CS", + Slug: "stratus", + Status: true, + Services: []string{"IP Address", "VM Snapshot"}, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cloud-providers" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := cloudprovider.NewService(client) + providers, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(providers) != 2 { + t.Fatalf("List() returned %d providers, want 2", len(providers)) + } + if providers[0].DisplayName != "Webberstop Cloud" { + t.Errorf("providers[0].DisplayName = %q, want %q", + providers[0].DisplayName, "Webberstop Cloud") + } + if len(providers[0].Services) != 2 { + t.Errorf("providers[0].Services has %d items, want 2", len(providers[0].Services)) + } +} + +func TestCloudProviderListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := cloudprovider.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/cost/cost_test.go b/internal/api/cost/cost_test.go index 1f0d263..7833bff 100644 --- a/internal/api/cost/cost_test.go +++ b/internal/api/cost/cost_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/currency/currency.go b/internal/api/currency/currency.go new file mode 100644 index 0000000..08459ae --- /dev/null +++ b/internal/api/currency/currency.go @@ -0,0 +1,59 @@ +// Package currency provides ZCP currency API operations (STKCNSL). +package currency + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Currency represents a STKCNSL currency. +type Currency struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Locale string `json:"locale"` + CurrencyName string `json:"currency_name"` + Fraction string `json:"fraction"` + Status bool `json:"status"` + Default bool `json:"default"` + DecimalPlace int `json:"decimal_place"` + ResellerThreshold string `json:"reseller_threshold"` + CustomerThreshold string `json:"customer_threshold"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides currency API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new currency Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all currencies. +func (s *Service) List(ctx context.Context) ([]Currency, error) { + var env envelope + if err := s.client.Get(ctx, "/currencies", nil, &env); err != nil { + return nil, fmt.Errorf("listing currencies: %w", err) + } + + var currencies []Currency + if err := json.Unmarshal(env.Data, ¤cies); err != nil { + return nil, fmt.Errorf("decoding currencies: %w", err) + } + + return currencies, nil +} diff --git a/internal/api/currency/currency_test.go b/internal/api/currency/currency_test.go new file mode 100644 index 0000000..9b3839a --- /dev/null +++ b/internal/api/currency/currency_test.go @@ -0,0 +1,91 @@ +package currency_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/currency" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestCurrencyList(t *testing.T) { + expected := []currency.Currency{ + { + ID: "cur-1", + Name: "INR", + Slug: "inr", + Locale: "en_IN", + CurrencyName: "Rupees", + Fraction: "Paise", + Status: true, + Default: true, + DecimalPlace: 4, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/currencies" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := currency.NewService(client) + currencies, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(currencies) != 1 { + t.Fatalf("List() returned %d currencies, want 1", len(currencies)) + } + if currencies[0].Name != "INR" { + t.Errorf("currencies[0].Name = %q, want %q", currencies[0].Name, "INR") + } + if currencies[0].CurrencyName != "Rupees" { + t.Errorf("currencies[0].CurrencyName = %q, want %q", currencies[0].CurrencyName, "Rupees") + } + if !currencies[0].Default { + t.Errorf("currencies[0].Default = false, want true") + } +} + +func TestCurrencyListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := currency.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/dashboard/dashboard.go b/internal/api/dashboard/dashboard.go new file mode 100644 index 0000000..a85c375 --- /dev/null +++ b/internal/api/dashboard/dashboard.go @@ -0,0 +1,98 @@ +// Package dashboard provides STKCNSL dashboard and service cancellation API operations. +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// envelope is the standard STKCNSL response wrapper. +// All STKCNSL endpoints return {"status": "...", "data": ...}. +type envelope struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` +} + +// ServiceCounts holds the per-service resource counts returned by the +// analytics/account/services/counts endpoint. +type ServiceCounts struct { + Instance int `json:"instance"` + Kubernetes int `json:"kubernetes"` + Volume int `json:"volume"` + Snapshot int `json:"snapshot"` + Network int `json:"network"` + VPC int `json:"vpc"` + PublicIP int `json:"publicIp"` + Firewall int `json:"firewall"` + LoadBalancer int `json:"loadBalancer"` + VPN int `json:"vpn"` + SSHKey int `json:"sshKey"` + Template int `json:"template"` +} + +// CancelResponse holds the result of a service cancellation request. +type CancelResponse struct { + Message string `json:"message"` +} + +// Service provides dashboard API operations against the STKCNSL API. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new dashboard Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// GetServiceCounts returns a summary of active resource counts for the account. +func (s *Service) GetServiceCounts(ctx context.Context) (*ServiceCounts, error) { + var env envelope + if err := s.client.Get(ctx, "/analytics/account/services/counts", url.Values{}, &env); err != nil { + return nil, fmt.Errorf("getting service counts: %w", err) + } + + if env.Status != "Success" { + return nil, fmt.Errorf("unexpected response status: %s", env.Status) + } + + var counts ServiceCounts + if err := json.Unmarshal(env.Data, &counts); err != nil { + return nil, fmt.Errorf("decoding service counts: %w", err) + } + return &counts, nil +} + +// CancelServiceRequest holds the request body for service cancellation. +type CancelServiceRequest struct { + Reason string `json:"reason"` +} + +// CancelService submits a cancellation request for the given service slug. +func (s *Service) CancelService(ctx context.Context, serviceSlug, reason string) (*CancelResponse, error) { + if serviceSlug == "" { + return nil, fmt.Errorf("service slug is required") + } + + path := fmt.Sprintf("/billing/service-cancel-requests/%s", url.PathEscape(serviceSlug)) + body := CancelServiceRequest{Reason: reason} + + var env envelope + if err := s.client.Post(ctx, path, body, &env); err != nil { + return nil, fmt.Errorf("cancelling service %s: %w", serviceSlug, err) + } + + if env.Status != "Success" { + return nil, fmt.Errorf("unexpected response status: %s", env.Status) + } + + var resp CancelResponse + if err := json.Unmarshal(env.Data, &resp); err != nil { + return nil, fmt.Errorf("decoding cancel response: %w", err) + } + return &resp, nil +} diff --git a/internal/api/dashboard/dashboard_test.go b/internal/api/dashboard/dashboard_test.go new file mode 100644 index 0000000..b9fd5c5 --- /dev/null +++ b/internal/api/dashboard/dashboard_test.go @@ -0,0 +1,177 @@ +package dashboard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/dashboard" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +func TestGetServiceCounts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/analytics/account/services/counts" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": map[string]int{ + "instance": 5, + "kubernetes": 2, + "volume": 10, + "snapshot": 3, + "network": 4, + "vpc": 1, + "publicIp": 6, + "firewall": 7, + "loadBalancer": 2, + "vpn": 1, + "sshKey": 3, + "template": 8, + }, + }) + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + counts, err := svc.GetServiceCounts(context.Background()) + if err != nil { + t.Fatalf("GetServiceCounts() error = %v", err) + } + + if counts.Instance != 5 { + t.Errorf("Instance = %d, want 5", counts.Instance) + } + if counts.Kubernetes != 2 { + t.Errorf("Kubernetes = %d, want 2", counts.Kubernetes) + } + if counts.Volume != 10 { + t.Errorf("Volume = %d, want 10", counts.Volume) + } + if counts.PublicIP != 6 { + t.Errorf("PublicIP = %d, want 6", counts.PublicIP) + } + if counts.Template != 8 { + t.Errorf("Template = %d, want 8", counts.Template) + } +} + +func TestGetServiceCountsAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid credentials", + }) + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + _, err := svc.GetServiceCounts(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} + +func TestGetServiceCountsBadStatus(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", + "data": nil, + }) + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + _, err := svc.GetServiceCounts(context.Background()) + if err == nil { + t.Fatal("expected error for non-Success status, got nil") + } + if !strings.Contains(err.Error(), "unexpected response status") { + t.Errorf("error = %q, want it to contain 'unexpected response status'", err.Error()) + } +} + +func TestCancelService(t *testing.T) { + var gotPath, gotMethod string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": map[string]string{ + "message": "Cancellation request submitted", + }, + }) + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + resp, err := svc.CancelService(context.Background(), "vm-abc-123", "not_needed_anymore") + if err != nil { + t.Fatalf("CancelService() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %s, want POST", gotMethod) + } + if gotPath != "/billing/service-cancel-requests/vm-abc-123" { + t.Errorf("path = %q, want %q", gotPath, "/billing/service-cancel-requests/vm-abc-123") + } + if resp.Message != "Cancellation request submitted" { + t.Errorf("Message = %q, want %q", resp.Message, "Cancellation request submitted") + } +} + +func TestCancelServiceEmptySlug(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not have been called") + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + _, err := svc.CancelService(context.Background(), "", "test") + if err == nil { + t.Fatal("expected error for empty slug, got nil") + } + if !strings.Contains(err.Error(), "service slug is required") { + t.Errorf("error = %q, want it to contain 'service slug is required'", err.Error()) + } +} + +func TestCancelServiceAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Service not found", + }) + })) + defer srv.Close() + + svc := dashboard.NewService(newClient(srv.URL)) + _, err := svc.CancelService(context.Background(), "nonexistent-slug", "test") + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} diff --git a/internal/api/dns/dns.go b/internal/api/dns/dns.go new file mode 100644 index 0000000..7b21bfb --- /dev/null +++ b/internal/api/dns/dns.go @@ -0,0 +1,146 @@ +// Package dns provides ZCP DNS domain and record API operations (STKCNSL). +package dns + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Domain represents a DNS domain. +type Domain struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + AccountID string `json:"account_id"` + ProjectID int `json:"project_id"` + DNSProvider string `json:"dns_provider"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Records []Record `json:"records,omitempty"` + Project json.RawMessage `json:"project,omitempty"` +} + +// Record represents a single DNS record within a domain. +type Record struct { + ID string `json:"id"` + DomainID int `json:"domain_id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` + Priority int `json:"priority,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreateDomainRequest holds parameters for creating a DNS domain. +type CreateDomainRequest struct { + Name string `json:"name"` + Project string `json:"project"` + DNSProvider string `json:"dns_provider"` +} + +// CreateRecordRequest holds parameters for creating a DNS record. +type CreateRecordRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` +} + +// DeleteRecordRequest holds parameters for deleting a DNS record. +type DeleteRecordRequest struct { + RecordID int `json:"record_id"` +} + +// envelopeList wraps the standard paginated list response. +type envelopeList struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Domain `json:"data"` + Total int `json:"total"` +} + +// envelopeSingle wraps a single-object response. +type envelopeSingle struct { + Status string `json:"status"` + Message string `json:"message"` + Data Domain `json:"data"` +} + +// Service provides DNS API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new DNS Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all DNS domains. +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) + } + return resp.Data, 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{} + q.Set("dns_provider", "PowerDNS") + var resp envelopeSingle + if err := s.client.Get(ctx, "/dns/domains/"+slug, q, &resp); err != nil { + return nil, fmt.Errorf("showing DNS domain %s: %w", slug, err) + } + return &resp.Data, nil +} + +// Create creates a new DNS domain. +func (s *Service) Create(ctx context.Context, req CreateDomainRequest) (*Domain, error) { + var resp envelopeSingle + if err := s.client.Post(ctx, "/dns/domains", req, &resp); err != nil { + return nil, fmt.Errorf("creating DNS domain: %w", err) + } + return &resp.Data, nil +} + +// Delete removes a DNS domain by slug. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/dns/domains/"+slug, nil); err != nil { + return fmt.Errorf("deleting DNS domain %s: %w", slug, err) + } + return nil +} + +// CreateRecord creates a DNS record under the given domain slug. +func (s *Service) CreateRecord(ctx context.Context, domainSlug string, req CreateRecordRequest) (*Domain, error) { + var resp envelopeSingle + if err := s.client.Post(ctx, "/dns/domains/"+domainSlug+"/records", req, &resp); err != nil { + return nil, fmt.Errorf("creating DNS record on %s: %w", domainSlug, err) + } + return &resp.Data, nil +} + +// DeleteRecord removes a DNS record under the given domain slug. +// The record is identified by its ID in the request body. +func (s *Service) DeleteRecord(ctx context.Context, domainSlug string, recordID int) error { + path := "/dns/domains/" + domainSlug + "/records" + q := url.Values{} + q.Set("record_id", strconv.Itoa(recordID)) + if err := s.client.Delete(ctx, path, q); err != nil { + return fmt.Errorf("deleting DNS record %d on domain %s: %w", recordID, domainSlug, err) + } + return nil +} diff --git a/internal/api/dns/dns_test.go b/internal/api/dns/dns_test.go new file mode 100644 index 0000000..a84ae30 --- /dev/null +++ b/internal/api/dns/dns_test.go @@ -0,0 +1,256 @@ +package dns_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/dns" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +func TestDNSDomainList(t *testing.T) { + expected := []dns.Domain{ + {ID: "1", Name: "example.com", Slug: "example-com-1", Status: "active"}, + {ID: "2", Name: "test.org", Slug: "test-org-2", Status: "active"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.URL.Path != "/dns/domains" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": expected, + "total": len(expected), + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + domains, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if gotPath != "/dns/domains" { + t.Errorf("path = %q, want %q", gotPath, "/dns/domains") + } + if len(domains) != 2 { + t.Fatalf("List() returned %d domains, want 2", len(domains)) + } + if domains[0].Name != "example.com" { + t.Errorf("domains[0].Name = %q, want %q", domains[0].Name, "example.com") + } + if domains[1].Slug != "test-org-2" { + t.Errorf("domains[1].Slug = %q, want %q", domains[1].Slug, "test-org-2") + } +} + +func TestDNSDomainShow(t *testing.T) { + expected := dns.Domain{ + ID: "1", + Name: "example.com", + Slug: "example-com-1", + Records: []dns.Record{ + {ID: "10", Name: "www", Type: "A", Content: "192.0.2.1", TTL: 3600}, + }, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": expected, + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + domain, err := svc.Show(context.Background(), "example-com-1") + if err != nil { + t.Fatalf("Show() error = %v", err) + } + if gotPath != "/dns/domains/example-com-1" { + t.Errorf("path = %q, want %q", gotPath, "/dns/domains/example-com-1") + } + if domain.Name != "example.com" { + t.Errorf("domain.Name = %q, want %q", domain.Name, "example.com") + } + if len(domain.Records) != 1 { + t.Fatalf("domain.Records has %d entries, want 1", len(domain.Records)) + } + if domain.Records[0].Type != "A" { + t.Errorf("records[0].Type = %q, want %q", domain.Records[0].Type, "A") + } +} + +func TestDNSDomainCreate(t *testing.T) { + created := dns.Domain{ + ID: "3", + Name: "new.com", + Slug: "new-com-3", + DNSProvider: "powerdns", + Status: "active", + } + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/dns/domains" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "Created", + "data": created, + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + req := dns.CreateDomainRequest{ + Name: "new.com", + Project: "default-60", + DNSProvider: "powerdns", + } + domain, err := svc.Create(context.Background(), req) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if domain.Name != "new.com" { + t.Errorf("domain.Name = %q, want %q", domain.Name, "new.com") + } + if gotBody["name"] != "new.com" { + t.Errorf("body name = %v, want %q", gotBody["name"], "new.com") + } + if gotBody["dns_provider"] != "powerdns" { + t.Errorf("body dns_provider = %v, want %q", gotBody["dns_provider"], "powerdns") + } +} + +func TestDNSDomainDelete(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "Deleted", + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + err := svc.Delete(context.Background(), "example-com-1") + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + } + if gotPath != "/dns/domains/example-com-1" { + t.Errorf("path = %q, want %q", gotPath, "/dns/domains/example-com-1") + } +} + +func TestDNSRecordCreate(t *testing.T) { + domainWithRecord := dns.Domain{ + ID: "1", + Name: "example.com", + Slug: "example-com-1", + Records: []dns.Record{ + {ID: "20", Name: "mail", Type: "MX", Content: "mail.example.com", TTL: 3600}, + }, + } + + var gotPath string + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "Created", + "data": domainWithRecord, + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + req := dns.CreateRecordRequest{ + Name: "mail", + Type: "MX", + Content: "mail.example.com", + TTL: 3600, + } + domain, err := svc.CreateRecord(context.Background(), "example-com-1", req) + if err != nil { + t.Fatalf("CreateRecord() error = %v", err) + } + if gotPath != "/dns/domains/example-com-1/records" { + t.Errorf("path = %q, want %q", gotPath, "/dns/domains/example-com-1/records") + } + if domain.Name != "example.com" { + t.Errorf("domain.Name = %q, want %q", domain.Name, "example.com") + } + if gotBody["type"] != "MX" { + t.Errorf("body type = %v, want %q", gotBody["type"], "MX") + } +} + +func TestDNSRecordDelete(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "Deleted", + }) + })) + defer srv.Close() + + svc := dns.NewService(newClient(srv.URL)) + err := svc.DeleteRecord(context.Background(), "example-com-1", 42) + if err != nil { + t.Fatalf("DeleteRecord() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + } + if gotPath != "/dns/domains/example-com-1/records" { + t.Errorf("path = %q, want %q", gotPath, "/dns/domains/example-com-1/records") + } +} diff --git a/internal/api/egress/egress.go b/internal/api/egress/egress.go index ddc2a01..9002dd5 100644 --- a/internal/api/egress/egress.go +++ b/internal/api/egress/egress.go @@ -1,43 +1,53 @@ // Package egress provides ZCP egress rule API operations. +// +// In the STKCNSL API, egress rules are nested under networks: +// +// GET /networks/{SLUG}/egress-firewall-rules +// POST /networks/{SLUG}/egress-firewall-rules +// DELETE /networks/{SLUG}/egress-firewall-rules/{ID} +// +// This package delegates to the network package's egress methods but preserves +// the Service/NewService pattern for backward compatibility with the commands layer. package egress import ( "context" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) // EgressRule represents a ZCP egress firewall rule. type EgressRule struct { - UUID string `json:"uuid"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - Protocol string `json:"protocol"` - StartPort string `json:"startPort"` - EndPort string `json:"endPort"` - NetworkUUID string `json:"networkUuid"` - CIDRList string `json:"cidrList"` - ICMPType string `json:"icmpType"` - ICMPCode string `json:"icmpCode"` - ZoneUUID string `json:"zoneUuid"` + ID string `json:"id"` + Protocol string `json:"protocol"` + StartPort string `json:"start_port"` + EndPort string `json:"end_port"` + CIDR string `json:"cidr"` + ICMPType string `json:"icmp_type"` + ICMPCode string `json:"icmp_code"` + Status string `json:"status"` } // CreateRequest holds parameters for creating an egress rule. type CreateRequest struct { - NetworkUUID string `json:"networkUuid"` + NetworkSlug string `json:"-"` Protocol string `json:"protocol"` - StartPort string `json:"startPort,omitempty"` - EndPort string `json:"endPort,omitempty"` - CIDRList string `json:"cidrList,omitempty"` - ICMPType string `json:"icmpType,omitempty"` - ICMPCode string `json:"icmpCode,omitempty"` + StartPort string `json:"start_port,omitempty"` + EndPort string `json:"end_port,omitempty"` + CIDR string `json:"cidr,omitempty"` + ICMPType string `json:"icmp_type,omitempty"` + ICMPCode string `json:"icmp_code,omitempty"` } type listEgressRuleResponse struct { - Count int `json:"count"` - ListEgressRuleResponse []EgressRule `json:"listEgressRuleResponse"` + Status string `json:"status"` + Data []EgressRule `json:"data"` +} + +type singleEgressRuleResponse struct { + Status string `json:"status"` + Data EgressRule `json:"data"` } // Service provides egress rule API operations. @@ -50,38 +60,29 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns egress rules. zoneUUID is required; uuid and networkUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, uuid, networkUUID string) ([]EgressRule, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if networkUUID != "" { - q.Set("networkUuid", networkUUID) - } +// List returns egress rules for a network identified by slug. +func (s *Service) List(ctx context.Context, networkSlug string) ([]EgressRule, error) { var resp listEgressRuleResponse - if err := s.client.Get(ctx, "/restapi/egressrule/egressRuleList", q, &resp); err != nil { - return nil, fmt.Errorf("listing egress rules: %w", err) + if err := s.client.Get(ctx, "/networks/"+networkSlug+"/egress-firewall-rules", nil, &resp); err != nil { + return nil, fmt.Errorf("listing egress rules for network %s: %w", networkSlug, err) } - return resp.ListEgressRuleResponse, nil + return resp.Data, nil } -// Create adds a new egress rule. +// Create adds a new egress rule to a network. func (s *Service) Create(ctx context.Context, req CreateRequest) (*EgressRule, error) { - var resp listEgressRuleResponse - if err := s.client.Post(ctx, "/restapi/egressrule/createEgressRule", req, &resp); err != nil { - return nil, fmt.Errorf("creating egress rule: %w", err) - } - if len(resp.ListEgressRuleResponse) == 0 { - return nil, fmt.Errorf("create egress rule returned empty response") + var resp singleEgressRuleResponse + if err := s.client.Post(ctx, "/networks/"+req.NetworkSlug+"/egress-firewall-rules", req, &resp); err != nil { + return nil, fmt.Errorf("creating egress rule for network %s: %w", req.NetworkSlug, err) } - return &resp.ListEgressRuleResponse[0], nil + return &resp.Data, nil } -// Delete removes an egress rule by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/egressrule/deleteEgressRule/"+uuid, nil); err != nil { - return fmt.Errorf("deleting egress rule %s: %w", uuid, err) +// Delete removes an egress rule by ID from the given network. +func (s *Service) Delete(ctx context.Context, networkSlug string, ruleID string) error { + path := fmt.Sprintf("/networks/%s/egress-firewall-rules/%s", networkSlug, ruleID) + if err := s.client.Delete(ctx, path, nil); err != nil { + return fmt.Errorf("deleting egress rule %s for network %s: %w", ruleID, networkSlug, err) } return nil } diff --git a/internal/api/egress/egress_test.go b/internal/api/egress/egress_test.go index c6671e9..f691a91 100644 --- a/internal/api/egress/egress_test.go +++ b/internal/api/egress/egress_test.go @@ -3,6 +3,7 @@ package egress_test import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -14,85 +15,67 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listEgressRuleResponse struct { - Count int `json:"count"` - ListEgressRuleResponse []egress.EgressRule `json:"listEgressRuleResponse"` -} - func TestEgressList(t *testing.T) { expected := []egress.EgressRule{ - {UUID: "egr-1", Protocol: "tcp", StartPort: "80", EndPort: "80", ZoneUUID: "zone-1"}, - {UUID: "egr-2", Protocol: "all", ZoneUUID: "zone-1"}, + {ID: "1", Protocol: "tcp", StartPort: "80", EndPort: "80", Status: "Active"}, + {ID: "2", Protocol: "all", Status: "Active"}, } - var gotZone string + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/egressrule/egressRuleList" { - http.NotFound(w, r) - return - } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } + gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listEgressRuleResponse{Count: len(expected), ListEgressRuleResponse: expected}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) })) defer srv.Close() svc := egress.NewService(newClient(srv.URL)) - rules, err := svc.List(context.Background(), "zone-1", "", "") + rules, err := svc.List(context.Background(), "my-network") if err != nil { t.Fatalf("List() error = %v", err) } + if gotPath != "/networks/my-network/egress-firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/networks/my-network/egress-firewall-rules") + } if len(rules) != 2 { t.Fatalf("List() returned %d rules, want 2", len(rules)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if rules[0].UUID != "egr-1" { - t.Errorf("rules[0].UUID = %q, want %q", rules[0].UUID, "egr-1") + if rules[0].ID != "1" { + t.Errorf("rules[0].ID = %q, want %q", rules[0].ID, "1") } } func TestEgressCreate(t *testing.T) { created := egress.EgressRule{ - UUID: "egr-new", - Protocol: "tcp", - StartPort: "8080", - EndPort: "8080", - NetworkUUID: "net-1", - ZoneUUID: "zone-1", + ID: "10", Protocol: "tcp", StartPort: "8080", EndPort: "8080", Status: "Active", } var gotBody map[string]interface{} + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/egressrule/createEgressRule" { - http.NotFound(w, r) - return - } + gotPath = r.URL.Path + gotMethod = r.Method json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listEgressRuleResponse{Count: 1, ListEgressRuleResponse: []egress.EgressRule{created}}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) })) defer srv.Close() svc := egress.NewService(newClient(srv.URL)) req := egress.CreateRequest{ - NetworkUUID: "net-1", + NetworkSlug: "my-network", Protocol: "tcp", StartPort: "8080", EndPort: "8080", @@ -101,20 +84,20 @@ func TestEgressCreate(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if rule.UUID != "egr-new" { - t.Errorf("rule.UUID = %q, want %q", rule.UUID, "egr-new") + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/networks/my-network/egress-firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/networks/my-network/egress-firewall-rules") } - if gotBody["networkUuid"] != "net-1" { - t.Errorf("body networkUuid = %v, want %q", gotBody["networkUuid"], "net-1") + if rule.ID != "10" { + t.Errorf("rule.ID = %q, want %q", rule.ID, "10") } if gotBody["protocol"] != "tcp" { t.Errorf("body protocol = %v, want %q", gotBody["protocol"], "tcp") } - if gotBody["startPort"] != "8080" { - t.Errorf("body startPort = %v, want %q", gotBody["startPort"], "8080") - } - if gotBody["endPort"] != "8080" { - t.Errorf("body endPort = %v, want %q", gotBody["endPort"], "8080") + if gotBody["start_port"] != "8080" { + t.Errorf("body start_port = %v, want %q", gotBody["start_port"], "8080") } } @@ -128,14 +111,15 @@ func TestEgressDelete(t *testing.T) { defer srv.Close() svc := egress.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "egr-del-1") + err := svc.Delete(context.Background(), "my-network", "42") if err != nil { t.Fatalf("Delete() error = %v", err) } if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/egressrule/deleteEgressRule/egr-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/egressrule/deleteEgressRule/egr-del-1") + want := fmt.Sprintf("/networks/my-network/egress-firewall-rules/%s", "42") + if gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) } } diff --git a/internal/api/firewall/firewall.go b/internal/api/firewall/firewall.go index a3c5971..052b045 100644 --- a/internal/api/firewall/firewall.go +++ b/internal/api/firewall/firewall.go @@ -4,40 +4,47 @@ package firewall import ( "context" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// FirewallRule represents a ZCP firewall rule. +// FirewallRule represents a ZCP firewall rule from the STKCNSL API. type FirewallRule struct { - UUID string `json:"uuid"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - Protocol string `json:"protocol"` - StartPort string `json:"startPort"` - EndPort string `json:"endPort"` - CIDRList string `json:"cidrList"` - IPAddressUUID string `json:"ipAddressUuid"` - ICMPType string `json:"icmpType"` - ICMPCode string `json:"icmpCode"` - ZoneUUID string `json:"zoneUuid"` + ID string `json:"id"` + RuleID string `json:"rule_id"` + Protocol string `json:"protocol"` + StartPort interface{} `json:"start_port"` + EndPort interface{} `json:"end_port"` + CIDRList string `json:"cidr_list"` + DestinationCIDRList string `json:"destination_cidr_list"` + ICMPType string `json:"icmp_type"` + ICMPCode string `json:"icmp_code"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // CreateRequest holds parameters for creating a firewall rule. type CreateRequest struct { - IPAddressUUID string `json:"ipAddressUuid"` - Protocol string `json:"protocol"` - StartPort string `json:"startPort,omitempty"` - EndPort string `json:"endPort,omitempty"` - CIDRList string `json:"cidrList,omitempty"` - ICMPType string `json:"icmpType,omitempty"` - ICMPCode string `json:"icmpCode,omitempty"` + Protocol string `json:"protocol"` + CIDRList string `json:"cidr_list,omitempty"` + DestinationCIDRList string `json:"destination_cidr_list,omitempty"` + StartPort interface{} `json:"start_port,omitempty"` + EndPort interface{} `json:"end_port,omitempty"` } -type listFirewallRuleResponse struct { - Count int `json:"count"` - ListFirewallRuleResponse []FirewallRule `json:"listFirewallRuleResponse"` +// listResponse is the STKCNSL envelope for firewall rule lists. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []FirewallRule `json:"data"` +} + +// singleResponse is the STKCNSL envelope for a single firewall rule response. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data FirewallRule `json:"data"` } // Service provides firewall rule API operations. @@ -50,38 +57,31 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns firewall rules. zoneUUID is required; uuid and ipAddressUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, uuid, ipAddressUUID string) ([]FirewallRule, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if ipAddressUUID != "" { - q.Set("ipAddressUuid", ipAddressUUID) +// List returns firewall rules for a public IP address. +// ipSlug is the IP address slug (e.g. "1036521143"). +func (s *Service) List(ctx context.Context, ipSlug string) ([]FirewallRule, error) { + var resp listResponse + if err := s.client.Get(ctx, "/ipaddresses/"+ipSlug+"/firewall-rules", nil, &resp); err != nil { + return nil, fmt.Errorf("listing firewall rules for IP %s: %w", ipSlug, err) } - var resp listFirewallRuleResponse - if err := s.client.Get(ctx, "/restapi/firewallrule/firewallRuleList", q, &resp); err != nil { - return nil, fmt.Errorf("listing firewall rules: %w", err) - } - return resp.ListFirewallRuleResponse, nil + return resp.Data, nil } -// Create adds a new firewall rule. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*FirewallRule, error) { - var resp listFirewallRuleResponse - if err := s.client.Post(ctx, "/restapi/firewallrule/createFirewallRule", req, &resp); err != nil { - return nil, fmt.Errorf("creating firewall rule: %w", err) - } - if len(resp.ListFirewallRuleResponse) == 0 { - return nil, fmt.Errorf("create firewall rule returned empty response") +// Create adds a new firewall rule on a public IP address. +// ipSlug is the IP address slug. +func (s *Service) Create(ctx context.Context, ipSlug string, req CreateRequest) (*FirewallRule, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/ipaddresses/"+ipSlug+"/firewall-rules", req, &resp); err != nil { + return nil, fmt.Errorf("creating firewall rule for IP %s: %w", ipSlug, err) } - return &resp.ListFirewallRuleResponse[0], nil + return &resp.Data, nil } -// Delete removes a firewall rule by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/firewallrule/deleteFirewallRule/"+uuid, nil); err != nil { - return fmt.Errorf("deleting firewall rule %s: %w", uuid, err) +// Delete removes a firewall rule by ID from a public IP address. +// ipSlug is the IP address slug; ruleID is the firewall rule ID. +func (s *Service) Delete(ctx context.Context, ipSlug, ruleID string) error { + if err := s.client.Delete(ctx, "/ipaddresses/"+ipSlug+"/firewall-rules/"+ruleID, nil); err != nil { + return fmt.Errorf("deleting firewall rule %s for IP %s: %w", ruleID, ipSlug, err) } return nil } diff --git a/internal/api/firewall/firewall_test.go b/internal/api/firewall/firewall_test.go index 3e98ce7..9f41360 100644 --- a/internal/api/firewall/firewall_test.go +++ b/internal/api/firewall/firewall_test.go @@ -14,112 +14,110 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listFirewallRuleResponse struct { - Count int `json:"count"` - ListFirewallRuleResponse []firewall.FirewallRule `json:"listFirewallRuleResponse"` +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []firewall.FirewallRule `json:"data"` +} + +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data firewall.FirewallRule `json:"data"` } func TestFirewallList(t *testing.T) { expected := []firewall.FirewallRule{ - {UUID: "fw-1", Protocol: "tcp", StartPort: "80", EndPort: "80", ZoneUUID: "zone-1"}, - {UUID: "fw-2", Protocol: "udp", StartPort: "53", EndPort: "53", ZoneUUID: "zone-1"}, + {ID: "fw-1", Protocol: "tcp", StartPort: "80", EndPort: "80", CIDRList: "0.0.0.0/0"}, + {ID: "fw-2", Protocol: "udp", StartPort: "53", EndPort: "53", CIDRList: "10.0.0.0/8"}, } - var gotZone string + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/firewallrule/firewallRuleList" { - http.NotFound(w, r) - return - } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } + gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listFirewallRuleResponse{Count: len(expected), ListFirewallRuleResponse: expected}) + json.NewEncoder(w).Encode(listResponse{Status: "Success", Data: expected}) })) defer srv.Close() svc := firewall.NewService(newClient(srv.URL)) - rules, err := svc.List(context.Background(), "zone-1", "", "") + rules, err := svc.List(context.Background(), "1030011") if err != nil { t.Fatalf("List() error = %v", err) } + if gotPath != "/ipaddresses/1030011/firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/firewall-rules") + } if len(rules) != 2 { t.Fatalf("List() returned %d rules, want 2", len(rules)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") + if rules[0].ID != "fw-1" { + t.Errorf("rules[0].ID = %q, want %q", rules[0].ID, "fw-1") } - if rules[0].UUID != "fw-1" { - t.Errorf("rules[0].UUID = %q, want %q", rules[0].UUID, "fw-1") + if rules[0].StartPort != "80" { + t.Errorf("rules[0].StartPort = %v, want %q", rules[0].StartPort, "80") } } func TestFirewallCreate(t *testing.T) { created := firewall.FirewallRule{ - UUID: "fw-new", - Protocol: "tcp", - StartPort: "443", - EndPort: "443", - CIDRList: "0.0.0.0/0", - IPAddressUUID: "ip-1", - ZoneUUID: "zone-1", + ID: "fw-new", + Protocol: "tcp", + StartPort: "443", + EndPort: "443", + CIDRList: "0.0.0.0/0", + State: "Active", } var gotBody map[string]interface{} + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/firewallrule/createFirewallRule" { - http.NotFound(w, r) - return - } + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listFirewallRuleResponse{Count: 1, ListFirewallRuleResponse: []firewall.FirewallRule{created}}) + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Data: created}) })) defer srv.Close() svc := firewall.NewService(newClient(srv.URL)) req := firewall.CreateRequest{ - IPAddressUUID: "ip-1", - Protocol: "tcp", - StartPort: "443", - EndPort: "443", - CIDRList: "0.0.0.0/0", + Protocol: "tcp", + StartPort: "443", + EndPort: "443", + CIDRList: "0.0.0.0/0", } - rule, err := svc.Create(context.Background(), req) + rule, err := svc.Create(context.Background(), "1030011", req) if err != nil { t.Fatalf("Create() error = %v", err) } - if rule.UUID != "fw-new" { - t.Errorf("rule.UUID = %q, want %q", rule.UUID, "fw-new") + if gotPath != "/ipaddresses/1030011/firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/firewall-rules") } - if gotBody["ipAddressUuid"] != "ip-1" { - t.Errorf("body ipAddressUuid = %v, want %q", gotBody["ipAddressUuid"], "ip-1") + if rule.ID != "fw-new" { + t.Errorf("rule.ID = %q, want %q", rule.ID, "fw-new") } if gotBody["protocol"] != "tcp" { t.Errorf("body protocol = %v, want %q", gotBody["protocol"], "tcp") } - if gotBody["startPort"] != "443" { - t.Errorf("body startPort = %v, want %q", gotBody["startPort"], "443") + // JSON numbers are float64 + if gotBody["start_port"] != "443" { + t.Errorf("body start_port = %v, want %q", gotBody["start_port"], "443") } - if gotBody["endPort"] != "443" { - t.Errorf("body endPort = %v, want %q", gotBody["endPort"], "443") + if gotBody["end_port"] != "443" { + t.Errorf("body end_port = %v, want %q", gotBody["end_port"], "443") } - if gotBody["cidrList"] != "0.0.0.0/0" { - t.Errorf("body cidrList = %v, want %q", gotBody["cidrList"], "0.0.0.0/0") + if gotBody["cidr_list"] != "0.0.0.0/0" { + t.Errorf("body cidr_list = %v, want %q", gotBody["cidr_list"], "0.0.0.0/0") } } @@ -133,14 +131,14 @@ func TestFirewallDelete(t *testing.T) { defer srv.Close() svc := firewall.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "fw-del-1") + err := svc.Delete(context.Background(), "1030011", "fw-del-1") if err != nil { t.Fatalf("Delete() error = %v", err) } if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/firewallrule/deleteFirewallRule/fw-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/firewallrule/deleteFirewallRule/fw-del-1") + if gotPath != "/ipaddresses/1030011/firewall-rules/fw-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/firewall-rules/fw-del-1") } } diff --git a/internal/api/host/host_test.go b/internal/api/host/host_test.go index 000929c..c0c4816 100644 --- a/internal/api/host/host_test.go +++ b/internal/api/host/host_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/instance/instance.go b/internal/api/instance/instance.go index 340599f..9245e5d 100644 --- a/internal/api/instance/instance.go +++ b/internal/api/instance/instance.go @@ -1,8 +1,9 @@ -// Package instance provides ZCP instance (VM) API operations. +// Package instance provides ZCP virtual machine API operations (STKCNSL). package instance import ( "context" + "encoding/json" "fmt" "net/url" "strings" @@ -11,94 +12,283 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Instance represents a ZCP virtual machine. -type Instance struct { - UUID string `json:"uuid"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Description string `json:"description"` - State string `json:"state"` - IsActive bool `json:"isActive"` - Memory string `json:"memory"` - TemplateName string `json:"templateName"` - TemplateUUID string `json:"templateUuid"` - ComputeOfferingUUID string `json:"computeOfferingUuid"` - StorageOfferingUUID string `json:"storageOfferingUuid"` - NetworkName string `json:"networkName"` - NetworkUUID string `json:"networkUuid"` - PrivateIP string `json:"instancePrivateIp"` - ZoneUUID string `json:"zoneUuid"` - SSHKeyUUID string `json:"sshUuid"` - OwnerName string `json:"instanceOwnerName"` - RootDiskSize int64 `json:"rootDiskSize"` - VolumeSize string `json:"volumeSize"` - DiskSize int64 `json:"diskSize"` - CPUCore string `json:"cpuCore"` - Status string `json:"status"` -} - -// Network represents an attached network on an instance. -type Network struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Type string `json:"type"` - PrivateIP string `json:"privateIp"` - PublicIP string `json:"publicIp"` - Gateway string `json:"gateway"` - Netmask string `json:"netmask"` - DefaultNetwork bool `json:"defaultNetwork"` -} - -// Status holds the current state of an instance. -type Status struct { - UUID string `json:"uuid"` - Status string `json:"status"` -} - -// Password holds an instance console/OS password. -type Password struct { - UUID string `json:"uuid"` - Password string `json:"password"` +// ---------- Response envelope ---------- + +// Envelope wraps all paginated STKCNSL responses. +type Envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + CurrentPage int `json:"current_page"` + Data json.RawMessage `json:"data"` + Total int `json:"total"` +} + +// SingleEnvelope wraps single-object responses (show, create). +type SingleEnvelope struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + Data json.RawMessage `json:"data"` +} + +// ActionResponse wraps simple action responses (start/stop/reboot/reset). +type ActionResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + Data interface{} `json:"data"` +} + +// ---------- Core types ---------- + +// VirtualMachine represents a STKCNSL virtual machine. +type VirtualMachine struct { + ID string `json:"id"` + VMID string `json:"vm_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + Hostname string `json:"hostname"` + Username string `json:"username"` + State string `json:"state"` + PublicIP *string `json:"public_ip"` + PrivateIP *string `json:"private_ip"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` + IsVNF bool `json:"is_vnf"` + ConsoleURL *string `json:"console_url"` + Template *VMTemplate `json:"template"` + BillingCycle *BillingCycle `json:"billing_cycle"` + Region *Region `json:"region"` + CloudProvider *CloudProvider `json:"cloud_provider"` + StorageSetting *StorageSetting `json:"storage_setting"` + Icon string `json:"icon"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + HasContract bool `json:"has_contract"` + IsMetricsHidden bool `json:"is_metrics_hidden"` + IsRestricted bool `json:"is_restricted"` + HasAutoscale bool `json:"has_autoscale"` +} + +// VMTemplate represents the template/OS info on a VM. +type VMTemplate struct { + ID string `json:"id"` + TemplateID string `json:"template_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + ImageType string `json:"image_type"` + FileType string `json:"file_type"` + PasswordEnabled bool `json:"password_enabled"` + IconURL string `json:"icon_url"` + OperatingSystem *OperatingSystem `json:"operating_system"` + OSVersion *OSVersion `json:"operating_system_version"` + MarketPlaceApp *json.RawMessage `json:"market_place_app"` +} + +// OperatingSystem describes the OS family. +type OperatingSystem struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + VMDefaultUsername string `json:"vm_default_username"` + Family string `json:"family"` +} + +// OSVersion describes a specific OS version. +type OSVersion struct { + ID string `json:"id"` + Version string `json:"version"` + PricingType string `json:"pricing_type"` +} + +// BillingCycle represents a billing period. +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Duration int `json:"duration"` + Unit string `json:"unit"` +} + +// Region represents a cloud region. +type Region struct { + ID string `json:"id"` + RegionID string `json:"region_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` + CountryCode string `json:"country_code"` } -// CreateRequest holds parameters for creating an instance. +// CloudProvider represents the cloud provider. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// StorageSetting represents the storage configuration. +type StorageSetting struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + StorageCategory *StorageCategory `json:"storage_category"` +} + +// StorageCategory represents the type of storage (SSD, NVMe, etc.). +type StorageCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// ActivityLog represents a VM activity log entry. +type ActivityLog struct { + ID string `json:"id"` + Category string `json:"category"` + Action string `json:"action"` + Status string `json:"status"` + Error string `json:"error"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Project string `json:"project"` +} + +// Addon represents a VM addon. +type Addon struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status bool `json:"status"` + CreatedAt string `json:"created_at"` +} + +// ---------- Request types ---------- + +// CreateRequest holds parameters for creating a VM via STKCNSL. type CreateRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - TemplateUUID string `json:"templateUuid"` - ComputeOfferingUUID string `json:"computeOfferingUuid"` - NetworkUUID string `json:"networkUuid"` - StorageOfferingUUID string `json:"storageOfferingUuid,omitempty"` - DiskSize int `json:"diskSize,omitempty"` - RootDiskSize int `json:"rootDiskSize,omitempty"` - SSHKeyName string `json:"sshKeyName,omitempty"` - SecurityGroupName string `json:"securitygroupName,omitempty"` - HypervisorName string `json:"hypervisorName,omitempty"` - Memory string `json:"memory,omitempty"` - CPUCore string `json:"cpuCore,omitempty"` + 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"` + 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"` + 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"` +} + +// CustomPlan allows specifying custom CPU/memory/storage when using a custom plan. +type CustomPlan struct { + Storage string `json:"storage,omitempty"` + CPU string `json:"cpu,omitempty"` + Memory string `json:"memory,omitempty"` +} + +// ChangeLabelRequest holds parameters for changing a VM hostname. +type ChangeLabelRequest struct { + Name string `json:"name"` + Hostname string `json:"hostname"` +} + +// ChangePasswordRequest holds parameters for changing a VM password. +type ChangePasswordRequest struct { + Password string `json:"password"` + VM string `json:"vm"` +} + +// ChangePlanRequest holds parameters for changing a VM plan. +type ChangePlanRequest struct { + Plan string `json:"plan"` + Slug string `json:"slug"` + VM string `json:"vm"` + BillingCycle string `json:"billing_cycle"` +} + +// ChangeTemplateRequest holds parameters for changing a VM OS template. +type ChangeTemplateRequest struct { + Template string `json:"template"` +} + +// ChangeStartupScriptRequest holds parameters for changing a VM startup script. +type ChangeStartupScriptRequest struct { + UserData string `json:"user_data"` } -type listInstanceResponse struct { - Count int `json:"count"` - ListInstanceResponse []Instance `json:"listInstanceResponse"` +// AddNetworkRequest holds parameters for adding a network to a VM. +type AddNetworkRequest struct { + Network string `json:"network"` } -type listInstanceNetworkResponse struct { - Count int `json:"count"` - KongInstanceNetworkResponses []Network `json:"kongInstanceNetworkResponses"` +// TagRequest holds parameters for creating or deleting a tag on a VM. +type TagRequest struct { + Key string `json:"key"` + Value string `json:"value"` } -type instanceStatusResponse struct { - UUID string `json:"uuid"` - Status string `json:"status"` +// PurchaseAddonRequest holds parameters for purchasing a VM addon. +type PurchaseAddonRequest struct { + VirtualMachine string `json:"virtual_machine"` + OSFamily string `json:"os_family,omitempty"` + Project string `json:"project"` + Region string `json:"region"` + CloudProvider string `json:"cloud_provider"` + Addons []AddonInput `json:"addons"` + Service string `json:"service"` + BillingCycle string `json:"billing_cycle"` + Plan string `json:"plan,omitempty"` + Coupon *string `json:"coupon"` } -type listInstancePasswordResponse struct { - Count int `json:"count"` - ListInstancePasswordResponse []Password `json:"listInstancePasswordResponse"` +// AddonInput describes a single addon to purchase. +type AddonInput struct { + Category string `json:"category"` + ID string `json:"id"` + Slug string `json:"slug"` + Quantity int `json:"quantity"` } -// Service provides instance API operations. +// ---------- Service ---------- + +// Service provides virtual machine API operations. type Service struct { client *httpclient.Client } @@ -108,135 +298,201 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns instances. zoneUUID is required. vmUUID is an optional filter. -func (s *Service) List(ctx context.Context, zoneUUID, vmUUID string) ([]Instance, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if vmUUID != "" { - q.Set("vmUuid", vmUUID) +// List returns all virtual machines. +func (s *Service) List(ctx context.Context) ([]VirtualMachine, error) { + var env Envelope + if err := s.client.Get(ctx, "/virtual-machines", nil, &env); err != nil { + return nil, fmt.Errorf("listing virtual machines: %w", err) + } + if env.Status != "Success" { + return nil, fmt.Errorf("listing virtual machines: %s", env.Message) } - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/instanceList", q, &resp); err != nil { - return nil, fmt.Errorf("listing instances: %w", err) + var vms []VirtualMachine + if err := json.Unmarshal(env.Data, &vms); err != nil { + return nil, fmt.Errorf("decoding virtual machines: %w", err) } - return resp.ListInstanceResponse, nil + return vms, nil } -// Get returns a single instance by UUID. -func (s *Service) Get(ctx context.Context, zoneUUID, vmUUID string) (*Instance, error) { - instances, err := s.List(ctx, zoneUUID, vmUUID) - if err != nil { - return nil, err +// Get returns a single virtual machine by slug. +func (s *Service) Get(ctx context.Context, slug string) (*VirtualMachine, error) { + var env SingleEnvelope + if err := s.client.Get(ctx, "/virtual-machines/"+slug, nil, &env); err != nil { + return nil, fmt.Errorf("getting virtual machine %s: %w", slug, err) + } + if env.Status != "Success" { + return nil, fmt.Errorf("getting virtual machine %s: %s", slug, env.Message) } - if len(instances) == 0 { - return nil, fmt.Errorf("instance %q not found in zone %q", vmUUID, zoneUUID) + var vm VirtualMachine + if err := json.Unmarshal(env.Data, &vm); err != nil { + return nil, fmt.Errorf("decoding virtual machine: %w", err) } - return &instances[0], nil + return &vm, nil } -// Create provisions a new instance. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*Instance, error) { - var resp listInstanceResponse - if err := s.client.Post(ctx, "/restapi/instance/createInstance", req, &resp); err != nil { - return nil, fmt.Errorf("creating instance: %w", err) +// Create provisions a new virtual machine. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*VirtualMachine, error) { + var env SingleEnvelope + if err := s.client.Post(ctx, "/virtual-machines", req, &env); err != nil { + return nil, fmt.Errorf("creating virtual machine: %w", err) } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("create instance returned empty response") + if env.Status != "Success" { + return nil, fmt.Errorf("creating virtual machine: %s", env.Message) } - return &resp.ListInstanceResponse[0], nil + var vm VirtualMachine + if err := json.Unmarshal(env.Data, &vm); err != nil { + return nil, fmt.Errorf("decoding virtual machine: %w", err) + } + return &vm, nil } -// Start starts a stopped instance. Returns the updated instance. -func (s *Service) Start(ctx context.Context, uuid string) (*Instance, error) { - q := url.Values{"uuid": {uuid}} - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/startInstance", q, &resp); err != nil { - return nil, fmt.Errorf("starting instance %s: %w", uuid, err) +// Start starts a stopped virtual machine. +func (s *Service) Start(ctx context.Context, slug string) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Put(ctx, "/virtual-machines/"+slug+"/start", nil, nil, &resp); err != nil { + return nil, fmt.Errorf("starting virtual machine %s: %w", slug, err) } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("start returned empty response") + return &resp, nil +} + +// Stop stops a running virtual machine. +func (s *Service) Stop(ctx context.Context, slug string) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Put(ctx, "/virtual-machines/"+slug+"/stop", nil, nil, &resp); err != nil { + return nil, fmt.Errorf("stopping virtual machine %s: %w", slug, err) } - return &resp.ListInstanceResponse[0], nil + return &resp, nil } -// Stop stops a running instance. forceStop bypasses graceful shutdown. -func (s *Service) Stop(ctx context.Context, uuid string, forceStop bool) (*Instance, error) { - forceStr := "false" - if forceStop { - forceStr = "true" +// Reboot reboots a virtual machine. +func (s *Service) Reboot(ctx context.Context, slug string) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Put(ctx, "/virtual-machines/"+slug+"/reboot", nil, nil, &resp); err != nil { + return nil, fmt.Errorf("rebooting virtual machine %s: %w", slug, err) } - q := url.Values{"uuid": {uuid}, "forceStop": {forceStr}} - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/stopInstance", q, &resp); err != nil { - return nil, fmt.Errorf("stopping instance %s: %w", uuid, err) + return &resp, nil +} + +// Reset resets a virtual machine. +func (s *Service) Reset(ctx context.Context, slug string) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Put(ctx, "/virtual-machines/"+slug+"/reset", nil, nil, &resp); err != nil { + return nil, fmt.Errorf("resetting virtual machine %s: %w", slug, err) } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("stop returned empty response") + return &resp, nil +} + +// ActivityLogs returns activity logs for a virtual machine. +func (s *Service) ActivityLogs(ctx context.Context, slug string) ([]ActivityLog, error) { + var env Envelope + if err := s.client.Get(ctx, "/loggers/service/VirtualMachine/"+slug, nil, &env); err != nil { + return nil, fmt.Errorf("getting activity logs for %s: %w", slug, err) } - return &resp.ListInstanceResponse[0], nil + var logs []ActivityLog + if err := json.Unmarshal(env.Data, &logs); err != nil { + return nil, fmt.Errorf("decoding activity logs: %w", err) + } + return logs, nil } -// Destroy deletes an instance. expunge permanently removes the VM. -func (s *Service) Destroy(ctx context.Context, uuid string, expunge bool) error { - expungeStr := "false" - if expunge { - expungeStr = "true" +// CreateTag creates a tag on a virtual machine. +func (s *Service) CreateTag(ctx context.Context, slug string, req TagRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/tags", req, &resp); err != nil { + return nil, fmt.Errorf("creating tag on %s: %w", slug, err) } - q := url.Values{"uuid": {uuid}, "expunge": {expungeStr}} - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/destroyInstance", q, &resp); err != nil { - return fmt.Errorf("destroying instance %s: %w", uuid, err) + return &resp, nil +} + +// DeleteTag deletes a tag from a virtual machine. +func (s *Service) DeleteTag(ctx context.Context, slug string, key string) error { + q := url.Values{"key": {key}} + if err := s.client.Delete(ctx, "/virtual-machines/"+slug+"/tags", q); err != nil { + return fmt.Errorf("deleting tag from %s: %w", slug, err) } return nil } -// Recover recovers an instance from an error state. -func (s *Service) Recover(ctx context.Context, uuid string) (*Instance, error) { - q := url.Values{"uuid": {uuid}} - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/recoverVm", q, &resp); err != nil { - return nil, fmt.Errorf("recovering instance %s: %w", uuid, err) +// ChangeHostname changes the hostname/label of a virtual machine. +func (s *Service) ChangeHostname(ctx context.Context, slug string, req ChangeLabelRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-label", req, &resp); err != nil { + return nil, fmt.Errorf("changing hostname for %s: %w", slug, err) + } + return &resp, nil +} + +// ChangePassword resets the password of a virtual machine. +func (s *Service) ChangePassword(ctx context.Context, slug string, req ChangePasswordRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-password", req, &resp); err != nil { + return nil, fmt.Errorf("changing password for %s: %w", slug, err) } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("recover returned empty response") + return &resp, nil +} + +// ChangePlan changes the plan of a virtual machine. +func (s *Service) ChangePlan(ctx context.Context, slug string, req ChangePlanRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-plan", req, &resp); err != nil { + return nil, fmt.Errorf("changing plan for %s: %w", slug, err) } - return &resp.ListInstanceResponse[0], nil + return &resp, nil } -// Resize changes the compute offering for an instance (requires it to be stopped). -func (s *Service) Resize(ctx context.Context, uuid, offeringUUID, cpuCore, memory string) (*Instance, error) { - q := url.Values{"uuid": {uuid}, "offeringUuid": {offeringUUID}} - if cpuCore != "" { - q.Set("cpuCore", cpuCore) +// ChangeOS changes the OS template of a virtual machine. +func (s *Service) ChangeOS(ctx context.Context, slug string, req ChangeTemplateRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-template", req, &resp); err != nil { + return nil, fmt.Errorf("changing OS for %s: %w", slug, err) } - if memory != "" { - q.Set("memory", memory) + return &resp, nil +} + +// ChangeStartupScript changes the startup script of a virtual machine. +func (s *Service) ChangeStartupScript(ctx context.Context, slug string, req ChangeStartupScriptRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/change-startup-script", req, &resp); err != nil { + return nil, fmt.Errorf("changing startup script for %s: %w", slug, err) } - var resp listInstanceResponse - if err := s.client.Get(ctx, "/restapi/instance/resizeVm", q, &resp); err != nil { - return nil, fmt.Errorf("resizing instance %s: %w", uuid, err) + return &resp, nil +} + +// AddNetwork adds a network to a virtual machine. +func (s *Service) AddNetwork(ctx context.Context, slug string, req AddNetworkRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+slug+"/add-network", req, &resp); err != nil { + return nil, fmt.Errorf("adding network to %s: %w", slug, err) } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("resize returned empty response") + return &resp, nil +} + +// ListAddons returns addons for a virtual machine. +func (s *Service) ListAddons(ctx context.Context, slug string) ([]Addon, error) { + var env Envelope + if err := s.client.Get(ctx, "/virtual-machines/"+slug+"/addons", nil, &env); err != nil { + return nil, fmt.Errorf("listing addons for %s: %w", slug, err) } - return &resp.ListInstanceResponse[0], nil + var addons []Addon + if err := json.Unmarshal(env.Data, &addons); err != nil { + return nil, fmt.Errorf("decoding addons: %w", err) + } + return addons, nil } -// GetStatus returns the current operational status of an instance. -func (s *Service) GetStatus(ctx context.Context, uuid string) (*Status, error) { - q := url.Values{"uuid": {uuid}} - var resp instanceStatusResponse - if err := s.client.Get(ctx, "/restapi/instance/vmStatus", q, &resp); err != nil { - return nil, fmt.Errorf("getting status for instance %s: %w", uuid, err) +// PurchaseAddon purchases an addon for a virtual machine. +func (s *Service) PurchaseAddon(ctx context.Context, req PurchaseAddonRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/addons", req, &resp); err != nil { + return nil, fmt.Errorf("purchasing addon: %w", err) } - return &Status{UUID: resp.UUID, Status: resp.Status}, nil + return &resp, nil } -// WaitForState polls the instance status until it reaches one of the target states or the context is cancelled. -// targetStates should be the expected terminal state(s), e.g. "Running", "Stopped", "Destroyed". -// pollInterval controls how often to poll; pass 0 for the default (3s). -func (s *Service) WaitForState(ctx context.Context, uuid string, targetStates []string, pollInterval time.Duration) (*Status, error) { +// WaitForState polls the VM until it reaches one of the target states or the context is cancelled. +func (s *Service) WaitForState(ctx context.Context, slug string, targetStates []string, pollInterval time.Duration) (*VirtualMachine, error) { if pollInterval == 0 { - pollInterval = 3 * time.Second + pollInterval = 5 * time.Second } ticker := time.NewTicker(pollInterval) defer ticker.Stop() @@ -245,51 +501,23 @@ func (s *Service) WaitForState(ctx context.Context, uuid string, targetStates [] case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: - status, err := s.GetStatus(ctx, uuid) + vm, err := s.Get(ctx, slug) if err != nil { return nil, err } for _, target := range targetStates { - if strings.EqualFold(status.Status, target) { - return status, nil + if strings.EqualFold(vm.State, target) { + return vm, nil } } } } } -// ListNetworks returns the networks attached to an instance. -func (s *Service) ListNetworks(ctx context.Context, uuid string) ([]Network, error) { - q := url.Values{"uuid": {uuid}} - var resp listInstanceNetworkResponse - if err := s.client.Get(ctx, "/restapi/instance/instanceNetworkList", q, &resp); err != nil { - return nil, fmt.Errorf("listing networks for instance %s: %w", uuid, err) - } - return resp.KongInstanceNetworkResponses, nil -} - -// ListPasswords returns OS/console passwords for instances. zoneUUID required; vmUUID optional. -func (s *Service) ListPasswords(ctx context.Context, zoneUUID, vmUUID string) ([]Password, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if vmUUID != "" { - q.Set("uuid", vmUUID) - } - var resp listInstancePasswordResponse - if err := s.client.Get(ctx, "/restapi/instance/instancePasswordList", q, &resp); err != nil { - return nil, fmt.Errorf("listing passwords: %w", err) - } - return resp.ListInstancePasswordResponse, nil -} - -// Rename updates an instance's display name. -func (s *Service) Rename(ctx context.Context, uuid, displayName string) (*Instance, error) { - body := map[string]string{"uuid": uuid, "displayName": displayName} - var resp listInstanceResponse - if err := s.client.Put(ctx, "/restapi/instance/updateInstanceName", nil, body, &resp); err != nil { - return nil, fmt.Errorf("renaming instance %s: %w", uuid, err) - } - if len(resp.ListInstanceResponse) == 0 { - return nil, fmt.Errorf("rename returned empty response") +// StringVal safely dereferences a string pointer for display. +func StringVal(s *string) string { + if s == nil { + return "" } - return &resp.ListInstanceResponse[0], nil + return *s } diff --git a/internal/api/instance/instance_test.go b/internal/api/instance/instance_test.go index faae1ec..1893dcc 100644 --- a/internal/api/instance/instance_test.go +++ b/internal/api/instance/instance_test.go @@ -12,452 +12,97 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// helpers - func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listInstanceResponse struct { - Count int `json:"count"` - ListInstanceResponse []instance.Instance `json:"listInstanceResponse"` -} - -type listInstanceNetworkResponse struct { - Count int `json:"count"` - KongInstanceNetworkResponses []instance.Network `json:"kongInstanceNetworkResponses"` -} - -func makeInstance(uuid, name, state string) instance.Instance { - return instance.Instance{ - UUID: uuid, - Name: name, - State: state, - ZoneUUID: "zone-uuid-1", - PrivateIP: "10.0.0.1", - Memory: "2048", - } -} - -// TestInstanceList verifies the URL path, required zoneUuid param, and response parsing. -func TestInstanceList(t *testing.T) { - instances := []instance.Instance{ - makeInstance("vm-1", "web-01", "Running"), - makeInstance("vm-2", "db-01", "Stopped"), - } - +func TestList(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/instanceList" { - http.NotFound(w, r) - return + if r.URL.Path != "/virtual-machines" { + t.Errorf("path = %q", r.URL.Path) } - zoneUUID := r.URL.Query().Get("zoneUuid") - if zoneUUID == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return + vms := []instance.VirtualMachine{ + {ID: "vm-1", Name: "test-vm", Slug: "test-vm", State: "Running"}, } + data, _ := json.Marshal(vms) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: len(instances), - ListInstanceResponse: instances, + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", "data": json.RawMessage(data), "total": 1, }) })) defer srv.Close() svc := instance.NewService(newClient(srv.URL)) - - result, err := svc.List(context.Background(), "zone-uuid-1", "") + vms, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } - if len(result) != 2 { - t.Fatalf("List() returned %d instances, want 2", len(result)) - } - if result[0].UUID != "vm-1" { - t.Errorf("result[0].UUID = %q, want %q", result[0].UUID, "vm-1") - } - if result[1].Name != "db-01" { - t.Errorf("result[1].Name = %q, want %q", result[1].Name, "db-01") + if len(vms) != 1 { + t.Fatalf("got %d VMs, want 1", len(vms)) } -} - -// TestInstanceListZoneUUIDSent verifies zoneUuid is sent as a query param. -func TestInstanceListZoneUUIDSent(t *testing.T) { - var gotZone string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotZone = r.URL.Query().Get("zoneUuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{Count: 0}) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - svc.List(context.Background(), "my-zone-123", "") - - if gotZone != "my-zone-123" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "my-zone-123") + if vms[0].Slug != "test-vm" { + t.Errorf("slug = %q, want %q", vms[0].Slug, "test-vm") } } -// TestInstanceGet verifies vmUuid filter is sent and single result is returned. -func TestInstanceGet(t *testing.T) { - expected := makeInstance("vm-99", "target-vm", "Running") - +func TestGet(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vmUUID := r.URL.Query().Get("vmUuid") - if vmUUID != "vm-99" { - http.Error(w, "unexpected vmUuid", http.StatusBadRequest) - return + if r.URL.Path != "/virtual-machines/test-vm" { + t.Errorf("path = %q", r.URL.Path) } + vm := instance.VirtualMachine{ID: "vm-1", Name: "test-vm", Slug: "test-vm", State: "Running"} + data, _ := json.Marshal(vm) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: 1, - ListInstanceResponse: []instance.Instance{expected}, + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", "data": json.RawMessage(data), }) })) defer srv.Close() svc := instance.NewService(newClient(srv.URL)) - - inst, err := svc.Get(context.Background(), "zone-1", "vm-99") + vm, err := svc.Get(context.Background(), "test-vm") if err != nil { t.Fatalf("Get() error = %v", err) } - if inst.UUID != "vm-99" { - t.Errorf("inst.UUID = %q, want %q", inst.UUID, "vm-99") - } - if inst.Name != "target-vm" { - t.Errorf("inst.Name = %q, want %q", inst.Name, "target-vm") + if vm.Slug != "test-vm" { + t.Errorf("slug = %q, want %q", vm.Slug, "test-vm") } } -// TestInstanceGetNotFound verifies that an empty list returns an error. -func TestInstanceGetNotFound(t *testing.T) { +func TestStart(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(listInstanceResponse{Count: 0, ListInstanceResponse: nil}) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - _, err := svc.Get(context.Background(), "zone-1", "nonexistent-uuid") - if err == nil { - t.Fatal("Get() expected error for not found, got nil") - } -} - -// TestInstanceCreate verifies POST body and response parsing. -func TestInstanceCreate(t *testing.T) { - created := makeInstance("new-vm-1", "my-vm", "Running") - - var gotBody map[string]interface{} - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/instance/createInstance" { - http.NotFound(w, r) - return + if r.Method != http.MethodPut || r.URL.Path != "/virtual-machines/test-vm/start" { + t.Errorf("method=%s path=%s", r.Method, r.URL.Path) } - json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: 1, - ListInstanceResponse: []instance.Instance{created}, - }) + json.NewEncoder(w).Encode(map[string]interface{}{"status": "Success", "message": "OK"}) })) defer srv.Close() svc := instance.NewService(newClient(srv.URL)) - - req := instance.CreateRequest{ - Name: "my-vm", - ZoneUUID: "zone-1", - TemplateUUID: "tmpl-1", - ComputeOfferingUUID: "co-1", - NetworkUUID: "net-1", - } - - inst, err := svc.Create(context.Background(), req) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - if inst.UUID != "new-vm-1" { - t.Errorf("inst.UUID = %q, want %q", inst.UUID, "new-vm-1") - } - - if gotBody["name"] != "my-vm" { - t.Errorf("body[name] = %v, want %q", gotBody["name"], "my-vm") - } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body[zoneUuid] = %v, want %q", gotBody["zoneUuid"], "zone-1") - } -} - -// TestInstanceStart verifies GET with uuid and response parsing. -func TestInstanceStart(t *testing.T) { - started := makeInstance("vm-start-1", "web-01", "Running") - - var gotUUID string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/startInstance" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: 1, - ListInstanceResponse: []instance.Instance{started}, - }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - inst, err := svc.Start(context.Background(), "vm-start-1") + _, err := svc.Start(context.Background(), "test-vm") if err != nil { t.Fatalf("Start() error = %v", err) } - if gotUUID != "vm-start-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vm-start-1") - } - if inst.State != "Running" { - t.Errorf("inst.State = %q, want %q", inst.State, "Running") - } } -// TestInstanceStop verifies forceStop param values ("true"/"false"). -func TestInstanceStop(t *testing.T) { - stopped := makeInstance("vm-stop-1", "web-01", "Stopped") - - tests := []struct { - name string - force bool - wantForce string - }{ - {"graceful stop", false, "false"}, - {"force stop", true, "true"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var gotForce string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/stopInstance" { - http.NotFound(w, r) - return - } - gotForce = r.URL.Query().Get("forceStop") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: 1, - ListInstanceResponse: []instance.Instance{stopped}, - }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - _, err := svc.Stop(context.Background(), "vm-stop-1", tt.force) - if err != nil { - t.Fatalf("Stop() error = %v", err) - } - if gotForce != tt.wantForce { - t.Errorf("forceStop = %q, want %q", gotForce, tt.wantForce) - } - }) - } -} - -// TestInstanceDestroy verifies expunge param and no error on success. -func TestInstanceDestroy(t *testing.T) { - tests := []struct { - name string - expunge bool - wantExpunge string - }{ - {"soft delete", false, "false"}, - {"hard expunge", true, "true"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var gotExpunge, gotUUID string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/destroyInstance" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - gotExpunge = r.URL.Query().Get("expunge") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{Count: 0}) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - err := svc.Destroy(context.Background(), "vm-del-1", tt.expunge) - if err != nil { - t.Fatalf("Destroy() error = %v", err) - } - if gotUUID != "vm-del-1" { - t.Errorf("uuid = %q, want %q", gotUUID, "vm-del-1") - } - if gotExpunge != tt.wantExpunge { - t.Errorf("expunge = %q, want %q", gotExpunge, tt.wantExpunge) - } - }) - } -} - -// TestInstanceListNetworks verifies path and response parsing. -func TestInstanceListNetworks(t *testing.T) { - networks := []instance.Network{ - {UUID: "net-1", Name: "public", Type: "Shared", PrivateIP: "10.0.0.5", DefaultNetwork: true}, - {UUID: "net-2", Name: "private", Type: "Isolated", PrivateIP: "192.168.1.10"}, - } - - var gotUUID string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/instanceNetworkList" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceNetworkResponse{ - Count: len(networks), - KongInstanceNetworkResponses: networks, - }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - result, err := svc.ListNetworks(context.Background(), "vm-1") - if err != nil { - t.Fatalf("ListNetworks() error = %v", err) - } - if gotUUID != "vm-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vm-1") - } - if len(result) != 2 { - t.Fatalf("ListNetworks() returned %d networks, want 2", len(result)) - } - if result[0].Name != "public" { - t.Errorf("result[0].Name = %q, want %q", result[0].Name, "public") - } - if !result[0].DefaultNetwork { - t.Errorf("result[0].DefaultNetwork = false, want true") - } -} - -// TestInstanceGetStatus verifies path and status field. -func TestInstanceGetStatus(t *testing.T) { - type statusResponse struct { - UUID string `json:"uuid"` - Status string `json:"status"` - } - - var gotUUID string - +func TestStop(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/vmStatus" { - http.NotFound(w, r) - return + if r.Method != http.MethodPut || r.URL.Path != "/virtual-machines/test-vm/stop" { + t.Errorf("method=%s path=%s", r.Method, r.URL.Path) } - gotUUID = r.URL.Query().Get("uuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(statusResponse{ - UUID: "vm-status-1", - Status: "Running", - }) + json.NewEncoder(w).Encode(map[string]interface{}{"status": "Success", "message": "OK"}) })) defer srv.Close() svc := instance.NewService(newClient(srv.URL)) - - status, err := svc.GetStatus(context.Background(), "vm-status-1") + _, err := svc.Stop(context.Background(), "test-vm") if err != nil { - t.Fatalf("GetStatus() error = %v", err) - } - if gotUUID != "vm-status-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vm-status-1") - } - if status.UUID != "vm-status-1" { - t.Errorf("status.UUID = %q, want %q", status.UUID, "vm-status-1") - } - if status.Status != "Running" { - t.Errorf("status.Status = %q, want %q", status.Status, "Running") - } -} - -// TestInstanceRecover verifies the recoverVm path and response. -func TestInstanceRecover(t *testing.T) { - recovered := makeInstance("vm-rec-1", "web-01", "Running") - - var gotUUID string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/instance/recoverVm" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listInstanceResponse{ - Count: 1, - ListInstanceResponse: []instance.Instance{recovered}, - }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - inst, err := svc.Recover(context.Background(), "vm-rec-1") - if err != nil { - t.Fatalf("Recover() error = %v", err) - } - if gotUUID != "vm-rec-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vm-rec-1") - } - if inst.UUID != "vm-rec-1" { - t.Errorf("inst.UUID = %q, want %q", inst.UUID, "vm-rec-1") - } -} - -// TestInstanceListAPIError verifies that non-2xx responses return errors. -func TestInstanceListAPIError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]interface{}{ - "listErrorResponse": map[string]string{ - "errorCode": "UNAUTHORIZED", - "errorMsg": "Invalid API key", - }, - }) - })) - defer srv.Close() - - svc := instance.NewService(newClient(srv.URL)) - - _, err := svc.List(context.Background(), "zone-1", "") - if err == nil { - t.Fatal("List() expected error for 401, got nil") + t.Fatalf("Stop() error = %v", err) } } diff --git a/internal/api/internallb/internallb_test.go b/internal/api/internallb/internallb_test.go index d49eff0..8fcd92c 100644 --- a/internal/api/internallb/internallb_test.go +++ b/internal/api/internallb/internallb_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/invoice/invoice_test.go b/internal/api/invoice/invoice_test.go index b32b83f..7839f64 100644 --- a/internal/api/invoice/invoice_test.go +++ b/internal/api/invoice/invoice_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/ipaddress/ipaddress.go b/internal/api/ipaddress/ipaddress.go index 0eb6ada..1e84a34 100644 --- a/internal/api/ipaddress/ipaddress.go +++ b/internal/api/ipaddress/ipaddress.go @@ -9,44 +9,84 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// IPAddress represents a ZCP public IP address. +// IPAddress represents a ZCP public IP address from the STKCNSL API. type IPAddress struct { - UUID string `json:"uuid"` - PublicIPAddress string `json:"publicIpAddress"` - State string `json:"state"` - IsActive bool `json:"isActive"` - ZoneUUID string `json:"zoneUuid"` - ZoneName string `json:"zoneName"` - NetworkUUID string `json:"networkUuid"` - IsSourceNAT bool `json:"isSourcenat"` - Status string `json:"status"` + ID string `json:"id"` + IPID string `json:"ip_id"` + IPAddress string `json:"ipaddress"` + Type string `json:"type"` + NetworkID string `json:"network_id"` + VirtualMachineID string `json:"virtual_machine_id"` + VPCID string `json:"vpc_id"` + Strategy string `json:"strategy"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + RequestStatus bool `json:"request_status"` + IsManualAcquire bool `json:"is_manual_acquire"` + VirtualMachineName string `json:"virtual_machine_name"` + ServiceName string `json:"service_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt string `json:"deleted_at"` + FrozenAt string `json:"frozen_at"` + SuspendedAt string `json:"suspended_at"` + TerminatedAt string `json:"terminated_at"` } -// StaticNATConfig holds the result of a static NAT enable/disable operation. -type StaticNATConfig struct { - IPAddressUUID string `json:"ipAddressUuid"` - VMUUID string `json:"vmUuid"` - VMName string `json:"vmName"` - NetworkUUID string `json:"networkUuid"` - IsActive bool `json:"isActive"` - Status string `json:"status"` +// CreateRequest holds parameters for allocating a new public IP address. +type CreateRequest struct { + VPC string `json:"vpc,omitempty"` + Network string `json:"network,omitempty"` + Plan string `json:"plan"` + BillingCycle string `json:"billing_cycle"` } -// EnableStaticNATRequest holds parameters for enabling static NAT. -type EnableStaticNATRequest struct { - IPAddressUUID string `json:"ipAddressUuid"` - VMUUID string `json:"vmUuid"` - NetworkUUID string `json:"networkUuid"` +// StaticNATRequest holds parameters for enabling static NAT. +type StaticNATRequest struct { + VirtualMachine string `json:"virtual_machine"` } -type listIPAddressResponse struct { - Count int `json:"count"` - ListIpAddressResponse []IPAddress `json:"listIpAddressResponse"` +// RemoteAccessVPN represents a remote access VPN entry on an IP address. +type RemoteAccessVPN struct { + ID string `json:"id"` + PublicIP string `json:"public_ip"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } -type enableStaticNATResponse struct { - Count int `json:"count"` - KongAttachStaticNatResponse []StaticNATConfig `json:"kongAttachStaticNatResponse"` +// listResponse is the STKCNSL envelope for paginated IP address lists. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []IPAddress `json:"data"` +} + +// singleResponse is the STKCNSL envelope for single IP address responses. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data IPAddress `json:"data"` +} + +// vpnListResponse is the STKCNSL envelope for remote access VPN lists. +type vpnListResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []RemoteAccessVPN `json:"data"` +} + +// vpnSingleResponse is the STKCNSL envelope for a single VPN response. +type vpnSingleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data RemoteAccessVPN `json:"data"` } // Service provides IP address API operations. @@ -59,64 +99,62 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns public IP addresses. zoneUUID is required; networkUUID is an optional filter. -func (s *Service) List(ctx context.Context, zoneUUID, networkUUID string) ([]IPAddress, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if networkUUID != "" { - q.Set("networkUuid", networkUUID) +// List returns public IP addresses. Optional filters: vpcSlug. +func (s *Service) List(ctx context.Context, vpcSlug string) ([]IPAddress, error) { + q := url.Values{} + if vpcSlug != "" { + q.Set("filter[vpc]", vpcSlug) } - var resp listIPAddressResponse - if err := s.client.Get(ctx, "/restapi/ipaddress/ipAddressList", q, &resp); err != nil { + var resp listResponse + if err := s.client.Get(ctx, "/ipaddresses", q, &resp); err != nil { return nil, fmt.Errorf("listing IP addresses: %w", err) } - return resp.ListIpAddressResponse, nil + return resp.Data, nil } -// Acquire allocates a new public IP address for a network. -func (s *Service) Acquire(ctx context.Context, networkUUID, networkType string) (*IPAddress, error) { - q := url.Values{ - "networkUuid": {networkUUID}, - "networkType": {networkType}, - } - var resp listIPAddressResponse - if err := s.client.Get(ctx, "/restapi/ipaddress/acquireIpAddress", q, &resp); err != nil { - return nil, fmt.Errorf("acquiring IP address: %w", err) +// Allocate creates (allocates) a new public IP address. +func (s *Service) Allocate(ctx context.Context, req CreateRequest) (*IPAddress, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/ipaddresses", req, &resp); err != nil { + return nil, fmt.Errorf("allocating IP address: %w", err) } - if len(resp.ListIpAddressResponse) == 0 { - return nil, fmt.Errorf("acquire IP address returned empty response") - } - return &resp.ListIpAddressResponse[0], nil + return &resp.Data, nil } -// Release deallocates a public IP address by UUID. -func (s *Service) Release(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/ipaddress/releaseIpAddress", url.Values{"uuid": {uuid}}); err != nil { - return fmt.Errorf("releasing IP address %s: %w", uuid, err) +// EnableStaticNAT enables static NAT, associating a public IP with a VM. +// ipSlug is the IP address slug (e.g. "1036521143"). +// vmSlug is the virtual machine slug. +func (s *Service) EnableStaticNAT(ctx context.Context, ipSlug, vmSlug string) (*IPAddress, error) { + body := StaticNATRequest{VirtualMachine: vmSlug} + var resp singleResponse + if err := s.client.Post(ctx, "/ipaddresses/"+ipSlug+"/static-nat", body, &resp); err != nil { + return nil, fmt.Errorf("enabling static NAT for IP %s: %w", ipSlug, err) } - return nil + return &resp.Data, nil } -// EnableStaticNAT enables static NAT, associating a public IP with a VM. -func (s *Service) EnableStaticNAT(ctx context.Context, ipAddressUUID, vmUUID, networkUUID string) (*StaticNATConfig, error) { - req := EnableStaticNATRequest{ - IPAddressUUID: ipAddressUUID, - VMUUID: vmUUID, - NetworkUUID: networkUUID, +// ListRemoteAccessVPNs returns remote access VPNs for a public IP address. +func (s *Service) ListRemoteAccessVPNs(ctx context.Context, ipSlug string) ([]RemoteAccessVPN, error) { + var resp vpnListResponse + if err := s.client.Get(ctx, "/ipaddresses/"+ipSlug+"/remote-access-vpns", nil, &resp); err != nil { + return nil, fmt.Errorf("listing remote access VPNs for IP %s: %w", ipSlug, err) } - var resp enableStaticNATResponse - if err := s.client.Post(ctx, "/restapi/ipaddress/enableStaticNat", req, &resp); err != nil { - return nil, fmt.Errorf("enabling static NAT for IP %s: %w", ipAddressUUID, err) - } - if len(resp.KongAttachStaticNatResponse) == 0 { - return nil, fmt.Errorf("enable static NAT returned empty response") + return resp.Data, nil +} + +// EnableRemoteAccessVPN enables remote access VPN on a public IP address. +func (s *Service) EnableRemoteAccessVPN(ctx context.Context, ipSlug string) (*RemoteAccessVPN, error) { + var resp vpnSingleResponse + if err := s.client.Post(ctx, "/ipaddresses/"+ipSlug+"/remote-access-vpns", nil, &resp); err != nil { + return nil, fmt.Errorf("enabling remote access VPN for IP %s: %w", ipSlug, err) } - return &resp.KongAttachStaticNatResponse[0], nil + return &resp.Data, nil } -// DisableStaticNAT removes the static NAT association for a public IP address. -func (s *Service) DisableStaticNAT(ctx context.Context, ipAddressUUID string) error { - if err := s.client.Delete(ctx, "/restapi/ipaddress/disableStaticNat", url.Values{"ipAddressUuid": {ipAddressUUID}}); err != nil { - return fmt.Errorf("disabling static NAT for IP %s: %w", ipAddressUUID, err) +// DisableRemoteAccessVPN disables a remote access VPN on a public IP address. +func (s *Service) DisableRemoteAccessVPN(ctx context.Context, ipSlug, vpnID string) error { + if err := s.client.Delete(ctx, "/ipaddresses/"+ipSlug+"/remote-access-vpns/"+vpnID, nil); err != nil { + return fmt.Errorf("disabling remote access VPN %s for IP %s: %w", vpnID, ipSlug, err) } return nil } diff --git a/internal/api/ipaddress/ipaddress_test.go b/internal/api/ipaddress/ipaddress_test.go index 569e824..3064fff 100644 --- a/internal/api/ipaddress/ipaddress_test.go +++ b/internal/api/ipaddress/ipaddress_test.go @@ -14,191 +14,266 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listIPAddressResponse struct { - Count int `json:"count"` - ListIpAddressResponse []ipaddress.IPAddress `json:"listIpAddressResponse"` +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []ipaddress.IPAddress `json:"data"` } -type enableStaticNATResponse struct { - Count int `json:"count"` - KongAttachStaticNatResponse []ipaddress.StaticNATConfig `json:"kongAttachStaticNatResponse"` +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ipaddress.IPAddress `json:"data"` +} + +type vpnListResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []ipaddress.RemoteAccessVPN `json:"data"` +} + +type vpnSingleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ipaddress.RemoteAccessVPN `json:"data"` } func TestIPList(t *testing.T) { expected := []ipaddress.IPAddress{ - {UUID: "ip-1", PublicIPAddress: "203.0.113.1", ZoneUUID: "zone-1", State: "Allocated"}, - {UUID: "ip-2", PublicIPAddress: "203.0.113.2", ZoneUUID: "zone-1", State: "Allocated"}, + {ID: "id-1", Slug: "1030011", IPAddress: "103.0.0.11", Strategy: "SOURCE-NAT"}, + {ID: "id-2", Slug: "1030012", IPAddress: "103.0.0.12", Strategy: "STATIC-NAT"}, } - var gotZone string + var gotVPC string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/ipaddress/ipAddressList" { + if r.URL.Path != "/ipaddresses" { http.NotFound(w, r) return } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } + gotVPC = r.URL.Query().Get("filter[vpc]") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listIPAddressResponse{Count: len(expected), ListIpAddressResponse: expected}) + json.NewEncoder(w).Encode(listResponse{Status: "Success", Data: expected}) })) defer srv.Close() svc := ipaddress.NewService(newClient(srv.URL)) - ips, err := svc.List(context.Background(), "zone-1", "") + ips, err := svc.List(context.Background(), "my-vpc") if err != nil { t.Fatalf("List() error = %v", err) } if len(ips) != 2 { t.Fatalf("List() returned %d IPs, want 2", len(ips)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") + if gotVPC != "my-vpc" { + t.Errorf("filter[vpc] query param = %q, want %q", gotVPC, "my-vpc") + } + if ips[0].ID != "id-1" { + t.Errorf("ips[0].ID = %q, want %q", ips[0].ID, "id-1") } - if ips[0].UUID != "ip-1" { - t.Errorf("ips[0].UUID = %q, want %q", ips[0].UUID, "ip-1") + if ips[0].Strategy != "SOURCE-NAT" { + t.Errorf("ips[0].Strategy = %q, want %q", ips[0].Strategy, "SOURCE-NAT") } } -func TestIPAcquire(t *testing.T) { - acquired := ipaddress.IPAddress{ - UUID: "ip-new", - PublicIPAddress: "203.0.113.10", - ZoneUUID: "zone-1", - NetworkUUID: "net-1", - State: "Allocated", +func TestIPListNoFilter(t *testing.T) { + expected := []ipaddress.IPAddress{ + {ID: "id-1", Slug: "1030011", IPAddress: "103.0.0.11"}, } - var gotNetwork, gotNetworkType string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/ipaddress/acquireIpAddress" { + if r.URL.Path != "/ipaddresses" { http.NotFound(w, r) return } - gotNetwork = r.URL.Query().Get("networkUuid") - gotNetworkType = r.URL.Query().Get("networkType") + if r.URL.Query().Get("filter[vpc]") != "" { + t.Error("expected no filter[vpc] query param") + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listIPAddressResponse{Count: 1, ListIpAddressResponse: []ipaddress.IPAddress{acquired}}) + json.NewEncoder(w).Encode(listResponse{Status: "Success", Data: expected}) })) defer srv.Close() svc := ipaddress.NewService(newClient(srv.URL)) - ip, err := svc.Acquire(context.Background(), "net-1", "Isolated") + ips, err := svc.List(context.Background(), "") if err != nil { - t.Fatalf("Acquire() error = %v", err) - } - if ip.UUID != "ip-new" { - t.Errorf("ip.UUID = %q, want %q", ip.UUID, "ip-new") - } - if gotNetwork != "net-1" { - t.Errorf("networkUuid query param = %q, want %q", gotNetwork, "net-1") + t.Fatalf("List() error = %v", err) } - if gotNetworkType != "Isolated" { - t.Errorf("networkType query param = %q, want %q", gotNetworkType, "Isolated") + if len(ips) != 1 { + t.Fatalf("List() returned %d IPs, want 1", len(ips)) } } -func TestIPRelease(t *testing.T) { - var gotPath, gotMethod, gotUUID string +func TestIPAllocate(t *testing.T) { + allocated := ipaddress.IPAddress{ + ID: "id-new", + Slug: "10300113", + IPAddress: "103.0.0.113", + RegionID: "region-1", + } + + var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - gotUUID = r.URL.Query().Get("uuid") - w.WriteHeader(http.StatusNoContent) + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/ipaddresses" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Data: allocated}) })) defer srv.Close() svc := ipaddress.NewService(newClient(srv.URL)) - err := svc.Release(context.Background(), "ip-del-1") + ip, err := svc.Allocate(context.Background(), ipaddress.CreateRequest{ + Plan: "ip-plan", + BillingCycle: "hourly", + Network: "my-network", + }) if err != nil { - t.Fatalf("Release() error = %v", err) + t.Fatalf("Allocate() error = %v", err) } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + if ip.ID != "id-new" { + t.Errorf("ip.ID = %q, want %q", ip.ID, "id-new") } - if gotPath != "/restapi/ipaddress/releaseIpAddress" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/ipaddress/releaseIpAddress") + if gotBody["plan"] != "ip-plan" { + t.Errorf("body plan = %v, want %q", gotBody["plan"], "ip-plan") } - if gotUUID != "ip-del-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "ip-del-1") + if gotBody["billing_cycle"] != "hourly" { + t.Errorf("body billing_cycle = %v, want %q", gotBody["billing_cycle"], "hourly") + } + if gotBody["network"] != "my-network" { + t.Errorf("body network = %v, want %q", gotBody["network"], "my-network") } } func TestIPEnableStaticNAT(t *testing.T) { - natConfig := ipaddress.StaticNATConfig{ - IPAddressUUID: "ip-1", - VMUUID: "vm-1", - NetworkUUID: "net-1", - IsActive: true, - Status: "enabled", + natResult := ipaddress.IPAddress{ + ID: "id-1", + Slug: "1030011", + IPAddress: "103.0.0.11", + Strategy: "STATIC-NAT", + VirtualMachineName: "my-vm", } var gotBody map[string]interface{} + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/ipaddress/enableStaticNat" { - http.NotFound(w, r) - return - } + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(enableStaticNATResponse{Count: 1, KongAttachStaticNatResponse: []ipaddress.StaticNATConfig{natConfig}}) + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Data: natResult}) })) defer srv.Close() svc := ipaddress.NewService(newClient(srv.URL)) - result, err := svc.EnableStaticNAT(context.Background(), "ip-1", "vm-1", "net-1") + result, err := svc.EnableStaticNAT(context.Background(), "1030011", "my-vm") if err != nil { t.Fatalf("EnableStaticNAT() error = %v", err) } - if result.IPAddressUUID != "ip-1" { - t.Errorf("result.IPAddressUUID = %q, want %q", result.IPAddressUUID, "ip-1") + if gotPath != "/ipaddresses/1030011/static-nat" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/static-nat") + } + if result.Strategy != "STATIC-NAT" { + t.Errorf("result.Strategy = %q, want %q", result.Strategy, "STATIC-NAT") + } + if gotBody["virtual_machine"] != "my-vm" { + t.Errorf("body virtual_machine = %v, want %q", gotBody["virtual_machine"], "my-vm") + } +} + +func TestIPListRemoteAccessVPNs(t *testing.T) { + expected := []ipaddress.RemoteAccessVPN{ + {ID: "vpn-1", PublicIP: "103.0.0.11", State: "Running"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vpnListResponse{Status: "Success", Data: expected}) + })) + defer srv.Close() + + svc := ipaddress.NewService(newClient(srv.URL)) + vpns, err := svc.ListRemoteAccessVPNs(context.Background(), "1030011") + if err != nil { + t.Fatalf("ListRemoteAccessVPNs() error = %v", err) + } + if gotPath != "/ipaddresses/1030011/remote-access-vpns" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/remote-access-vpns") } - if gotBody["ipAddressUuid"] != "ip-1" { - t.Errorf("body ipAddressUuid = %v, want %q", gotBody["ipAddressUuid"], "ip-1") + if len(vpns) != 1 { + t.Fatalf("ListRemoteAccessVPNs() returned %d VPNs, want 1", len(vpns)) } - if gotBody["vmUuid"] != "vm-1" { - t.Errorf("body vmUuid = %v, want %q", gotBody["vmUuid"], "vm-1") + if vpns[0].ID != "vpn-1" { + t.Errorf("vpns[0].ID = %q, want %q", vpns[0].ID, "vpn-1") } - if gotBody["networkUuid"] != "net-1" { - t.Errorf("body networkUuid = %v, want %q", gotBody["networkUuid"], "net-1") +} + +func TestIPEnableRemoteAccessVPN(t *testing.T) { + vpn := ipaddress.RemoteAccessVPN{ + ID: "vpn-new", + PublicIP: "103.0.0.11", + State: "Running", + } + + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(vpnSingleResponse{Status: "Success", Data: vpn}) + })) + defer srv.Close() + + svc := ipaddress.NewService(newClient(srv.URL)) + result, err := svc.EnableRemoteAccessVPN(context.Background(), "1030011") + if err != nil { + t.Fatalf("EnableRemoteAccessVPN() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/ipaddresses/1030011/remote-access-vpns" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/remote-access-vpns") + } + if result.ID != "vpn-new" { + t.Errorf("result.ID = %q, want %q", result.ID, "vpn-new") } } -func TestIPDisableStaticNAT(t *testing.T) { - var gotPath, gotMethod, gotIPUUID string +func TestIPDisableRemoteAccessVPN(t *testing.T) { + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path - gotIPUUID = r.URL.Query().Get("ipAddressUuid") w.WriteHeader(http.StatusNoContent) })) defer srv.Close() svc := ipaddress.NewService(newClient(srv.URL)) - err := svc.DisableStaticNAT(context.Background(), "ip-1") + err := svc.DisableRemoteAccessVPN(context.Background(), "1030011", "vpn-1") if err != nil { - t.Fatalf("DisableStaticNAT() error = %v", err) + t.Fatalf("DisableRemoteAccessVPN() error = %v", err) } if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/ipaddress/disableStaticNat" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/ipaddress/disableStaticNat") - } - if gotIPUUID != "ip-1" { - t.Errorf("ipAddressUuid query param = %q, want %q", gotIPUUID, "ip-1") + if gotPath != "/ipaddresses/1030011/remote-access-vpns/vpn-1" { + t.Errorf("path = %q, want %q", gotPath, "/ipaddresses/1030011/remote-access-vpns/vpn-1") } } diff --git a/internal/api/iso/iso.go b/internal/api/iso/iso.go new file mode 100644 index 0000000..bba6ed8 --- /dev/null +++ b/internal/api/iso/iso.go @@ -0,0 +1,158 @@ +// Package iso provides ZCP ISO image API operations. +package iso + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// ISO represents a ZCP ISO image. +type ISO struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + ISOURL string `json:"url"` + State string `json:"state"` + Status string `json:"status"` + PasswordEnabled bool `json:"password_enabled"` + IsExtractable bool `json:"is_extractable"` + IsBootable bool `json:"is_bootable"` + ImageType string `json:"image_type"` + FileType string `json:"file_type"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + ProjectID string `json:"project_id"` + TemplateID string `json:"template_id"` + AccountID string `json:"account_id"` + OperatingSystemID string `json:"operating_system_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Region *Region `json:"region,omitempty"` + Project *Project `json:"project,omitempty"` + CloudProvider *CloudProvider `json:"cloud_provider,omitempty"` + OperatingSystem *OperatingSystem `json:"operating_system,omitempty"` +} + +// Region represents the region of an ISO. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Project represents the project of an ISO. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// CloudProvider represents the cloud provider of an ISO. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// OperatingSystem represents the OS associated with an ISO. +type OperatingSystem struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Family string `json:"family"` +} + +// CreateRequest holds parameters for creating an ISO. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + CloudProvider string `json:"cloud_provider"` + Project string `json:"project"` + Region string `json:"region"` + OSTypeID string `json:"os_type_id"` + ImageType string `json:"image_type"` + OperatingSystem string `json:"operating_system"` + OperatingSystemVersion string `json:"operating_system_version"` + BillingCycle string `json:"billing_cycle"` + PasswordEnabled bool `json:"password_enabled"` + IsExtractable bool `json:"is_extractable"` + IsBootable bool `json:"is_bootable"` + IsUploadFromLocal bool `json:"is_upload_from_local"` + Service string `json:"service"` + Coupon string `json:"coupon,omitempty"` +} + +// UpdateRequest holds parameters for updating ISO permissions. +type UpdateRequest struct { + PasswordEnabled bool `json:"password_enabled"` + IsExtractable bool `json:"is_extractable"` + IsBootable bool `json:"is_bootable"` +} + +// listResponse is the STKCNSL paginated response envelope. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []ISO `json:"data"` + Total int `json:"total"` +} + +// singleResponse is the STKCNSL single-object response envelope. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ISO `json:"data"` +} + +// Service provides ISO API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new ISO Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all ISO images. +func (s *Service) List(ctx context.Context) ([]ISO, error) { + q := url.Values{} + q.Set("include", "project,region,cloud_provider") + var resp listResponse + if err := s.client.Get(ctx, "/isos", q, &resp); err != nil { + return nil, fmt.Errorf("listing ISOs: %w", err) + } + return resp.Data, nil +} + +// Create registers a new ISO image. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*ISO, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/isos", req, &resp); err != nil { + return nil, fmt.Errorf("creating ISO: %w", err) + } + return &resp.Data, nil +} + +// Update modifies ISO permissions (password, extractable, bootable). +func (s *Service) Update(ctx context.Context, slug string, req UpdateRequest) error { + if err := s.client.Put(ctx, "/isos/"+slug+"/iso-permission", nil, req, nil); err != nil { + return fmt.Errorf("updating ISO %s: %w", slug, err) + } + return nil +} + +// Delete removes an ISO image by slug. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/isos/"+slug, nil); err != nil { + return fmt.Errorf("deleting ISO %s: %w", slug, err) + } + return nil +} diff --git a/internal/api/kubernetes/kubernetes.go b/internal/api/kubernetes/kubernetes.go index 4f282fa..e6614d1 100644 --- a/internal/api/kubernetes/kubernetes.go +++ b/internal/api/kubernetes/kubernetes.go @@ -3,75 +3,128 @@ package kubernetes import ( "context" + "encoding/json" "fmt" - "net/url" - "strconv" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Cluster represents a ZCP managed Kubernetes cluster. +// Cluster represents a ZCP managed Kubernetes cluster from the STKCNSL API. type Cluster struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description"` - State string `json:"state"` - Size int `json:"size"` - ControlNodes int `json:"controlNodes"` - NodeRootDiskSize int `json:"nodeRootDiskSize"` - TransNetworkUUID string `json:"transNetworkUuid"` - ExternalLoadbalancerIP string `json:"externalLoadbalancerIpaddress"` -} - -// Node represents a Kubernetes node (uses the instance response shape). -type Node struct { - UUID string `json:"uuid"` - Name string `json:"name"` - State string `json:"state"` - Memory string `json:"memory"` - PrivateIP string `json:"instancePrivateIp"` - ZoneUUID string `json:"zoneUuid"` - IsActive bool `json:"isActive"` -} - -// Version represents a supported Kubernetes version. -type Version struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description"` - IsActive bool `json:"isActive"` - MinMemory int `json:"minMemory"` - MinCPUNumber int `json:"minCpuNumber"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + State string `json:"state"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + Hostname string `json:"hostname"` + PublicIP *string `json:"public_ip"` + PrivateIP *string `json:"private_ip"` + NodeSize int `json:"node_size"` + ControlNodes int `json:"control_nodes"` + Version string `json:"version"` + EnableHA bool `json:"enable_ha"` + CustomPlan json.RawMessage `json:"custom_plan"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` + BillingCycle *BillingCycle `json:"billing_cycle,omitempty"` + Region *Region `json:"region,omitempty"` + Project *Project `json:"project,omitempty"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` +} + +// BillingCycle represents the billing cycle attached to a cluster. +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Duration int `json:"duration"` + Unit string `json:"unit"` +} + +// Region represents the region attached to a cluster. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` +} + +// Project represents the project attached to a cluster. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` } // CreateRequest holds parameters for creating a Kubernetes cluster. type CreateRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - VersionUUID string `json:"kubernetesSupportedVersionUuid"` - ComputeOfferingUUID string `json:"computeOfferingUuid"` - TransNetworkUUID string `json:"transNetworkUuid"` - Size int64 `json:"size"` - ControlNodes int64 `json:"controlNodes"` - SSHKeyName string `json:"sshKeyName"` - HAEnabled bool `json:"haEnabled"` - NodeRootDiskSize int64 `json:"nodeRootDiskSize,omitempty"` - Description string `json:"description,omitempty"` - ExternalLoadbalancerIP string `json:"externalLoadbalancerIpaddress,omitempty"` - DockerRegistryURL string `json:"dockerRegistryUrl,omitempty"` - DockerRegistryUsername string `json:"dockerRegistryUsername,omitempty"` - DockerRegistryPassword string `json:"dockerRegistryPassword,omitempty"` - DomainUUID string `json:"domainUuid,omitempty"` -} - -type listNodesResponse struct { - Count int `json:"count"` - ListInstanceResponse []Node `json:"listInstanceResponse"` -} - -type listVersionsResponse struct { - Count int `json:"count"` - ListKubernetesVersion []Version `json:"listKubernetesVersion"` + Name string `json:"name"` + Version string `json:"version"` + NodeSize int `json:"node_size"` + ControlNodes int `json:"control_nodes"` + CloudProvider string `json:"cloud_provider"` + 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"` +} + +// UpgradeRequest holds parameters for upgrading (changing plan of) a Kubernetes cluster. +type UpgradeRequest struct { + Plan string `json:"plan"` + Slug string `json:"slug"` + BillingCycle string `json:"billing_cycle"` + IsCustomPlan bool `json:"is_custom_plan"` + CustomPlan interface{} `json:"custom_plan"` +} + +// listResponse is the STKCNSL response envelope for paginated lists. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Cluster `json:"data"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// singleResponse is the STKCNSL response envelope for single-object responses. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Cluster `json:"data"` +} + +// messageResponse is the STKCNSL response envelope for action responses (start/stop/upgrade). +type messageResponse struct { + Status string `json:"status"` + Message string `json:"message"` } // Service provides Kubernetes API operations. @@ -84,94 +137,47 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns clusters. clusterUUID is an optional filter. -// NOTE: The API returns a single cluster object (not a list), so we wrap it. -func (s *Service) List(ctx context.Context, clusterUUID string) ([]Cluster, error) { - q := url.Values{} - if clusterUUID != "" { - q.Set("clusterUuid", clusterUUID) - } - // API returns a single cluster object, not an array - var cluster Cluster - if err := s.client.Get(ctx, "/restapi/kubernetes/listCluster", q, &cluster); err != nil { +// List returns all Kubernetes clusters. +func (s *Service) List(ctx context.Context) ([]Cluster, error) { + var resp listResponse + if err := s.client.Get(ctx, "/kubernetes-clusters", nil, &resp); err != nil { return nil, fmt.Errorf("listing kubernetes clusters: %w", err) } - if cluster.UUID == "" { - return []Cluster{}, nil - } - return []Cluster{cluster}, nil + return resp.Data, nil } // Create provisions a new Kubernetes cluster. func (s *Service) Create(ctx context.Context, req CreateRequest) (*Cluster, error) { - var cluster Cluster - if err := s.client.Post(ctx, "/restapi/kubernetes/createKubernetes", req, &cluster); err != nil { + var resp singleResponse + if err := s.client.Post(ctx, "/kubernetes-clusters", req, &resp); err != nil { return nil, fmt.Errorf("creating kubernetes cluster: %w", err) } - return &cluster, nil -} - -// Delete destroys a Kubernetes cluster. -func (s *Service) Delete(ctx context.Context, uuid string) error { - q := url.Values{"uuid": {uuid}} - if err := s.client.Delete(ctx, "/restapi/kubernetes/destroyKubernetes", q); err != nil { - return fmt.Errorf("destroying kubernetes cluster %s: %w", uuid, err) - } - return nil + return &resp.Data, nil } // Start starts a stopped Kubernetes cluster. -func (s *Service) Start(ctx context.Context, uuid string) (*Cluster, error) { - q := url.Values{"uuid": {uuid}} - var cluster Cluster - if err := s.client.Put(ctx, "/restapi/kubernetes/startKubernetes", q, nil, &cluster); err != nil { - return nil, fmt.Errorf("starting kubernetes cluster %s: %w", uuid, err) +func (s *Service) Start(ctx context.Context, slug string) error { + var resp messageResponse + if err := s.client.Put(ctx, fmt.Sprintf("/kubernetes-clusters/%s/start", slug), nil, nil, &resp); err != nil { + return fmt.Errorf("starting kubernetes cluster %s: %w", slug, err) } - return &cluster, nil + return nil } // Stop stops a running Kubernetes cluster. -func (s *Service) Stop(ctx context.Context, uuid string) (*Cluster, error) { - q := url.Values{"uuid": {uuid}} - var cluster Cluster - if err := s.client.Put(ctx, "/restapi/kubernetes/stopKubernetes", q, nil, &cluster); err != nil { - return nil, fmt.Errorf("stopping kubernetes cluster %s: %w", uuid, err) +func (s *Service) Stop(ctx context.Context, slug string) error { + var resp messageResponse + if err := s.client.Put(ctx, fmt.Sprintf("/kubernetes-clusters/%s/stop", slug), nil, nil, &resp); err != nil { + return fmt.Errorf("stopping kubernetes cluster %s: %w", slug, err) } - return &cluster, nil -} - -// Scale changes the worker node count for a cluster. -func (s *Service) Scale(ctx context.Context, uuid string, size int, autoscaling bool) (*Cluster, error) { - q := url.Values{ - "uuid": {uuid}, - "size": {strconv.Itoa(size)}, - } - if autoscaling { - q.Set("autoscalingEnabled", "true") - } - var cluster Cluster - if err := s.client.Put(ctx, "/restapi/kubernetes/scaleKubernetes", q, nil, &cluster); err != nil { - return nil, fmt.Errorf("scaling kubernetes cluster %s: %w", uuid, err) - } - return &cluster, nil -} - -// ListNodes returns the nodes in a Kubernetes cluster. -func (s *Service) ListNodes(ctx context.Context, clusterUUID string) ([]Node, error) { - q := url.Values{"clusterUuid": {clusterUUID}} - var resp listNodesResponse - if err := s.client.Get(ctx, "/restapi/kubernetes/listNodes", q, &resp); err != nil { - return nil, fmt.Errorf("listing nodes for cluster %s: %w", clusterUUID, err) - } - return resp.ListInstanceResponse, nil + return nil } -// ListVersions returns available Kubernetes versions for a zone. -func (s *Service) ListVersions(ctx context.Context, zoneUUID string) ([]Version, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - var resp listVersionsResponse - if err := s.client.Get(ctx, "/restapi/costestimate/kubernetes-version-list", q, &resp); err != nil { - return nil, fmt.Errorf("listing kubernetes versions: %w", err) +// Upgrade changes the plan for a Kubernetes cluster. +func (s *Service) Upgrade(ctx context.Context, slug string, req UpgradeRequest) error { + var resp messageResponse + if err := s.client.Put(ctx, fmt.Sprintf("/kubernetes-clusters/%s/change-plan", slug), nil, req, &resp); err != nil { + return fmt.Errorf("upgrading kubernetes cluster %s: %w", slug, err) } - return resp.ListKubernetesVersion, nil + return nil } diff --git a/internal/api/kubernetes/kubernetes_test.go b/internal/api/kubernetes/kubernetes_test.go index 74cc29e..682bbcf 100644 --- a/internal/api/kubernetes/kubernetes_test.go +++ b/internal/api/kubernetes/kubernetes_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/zsoftly/zcp-cli/internal/api/kubernetes" "github.com/zsoftly/zcp-cli/internal/httpclient" @@ -14,83 +13,89 @@ import ( func newTestClient(srv *httptest.Server) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, APIKey: "k", SecretKey: "s", Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "test-token", }) } -func TestKubernetesListCluster(t *testing.T) { +func TestKubernetesListClusters(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/listCluster" { + if r.URL.Path != "/kubernetes-clusters" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodGet { t.Errorf("unexpected method: %s", r.Method) } json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "cluster-1", - "name": "my-cluster", - "state": "Running", - "size": 3, - "controlNodes": 1, + "status": "Success", + "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", + }, + }, + "current_page": 1, + "last_page": 1, + "total": 1, }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) - clusters, err := svc.List(context.Background(), "") + clusters, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(clusters) != 1 { t.Fatalf("expected 1 cluster, got %d", len(clusters)) } - if clusters[0].UUID != "cluster-1" { - t.Errorf("UUID = %q, want %q", clusters[0].UUID, "cluster-1") + if clusters[0].ID != "abc-123" { + t.Errorf("ID = %q, want %q", clusters[0].ID, "abc-123") } if clusters[0].Name != "my-cluster" { t.Errorf("Name = %q, want %q", clusters[0].Name, "my-cluster") } + if clusters[0].Slug != "my-cluster" { + t.Errorf("Slug = %q, want %q", clusters[0].Slug, "my-cluster") + } if clusters[0].State != "Running" { t.Errorf("State = %q, want %q", clusters[0].State, "Running") } -} - -func TestKubernetesListClusterWithFilter(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got := r.URL.Query().Get("clusterUuid") - if got != "filter-uuid" { - t.Errorf("clusterUuid param = %q, want %q", got, "filter-uuid") - } - json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "filter-uuid", - "name": "filtered-cluster", - "state": "Stopped", - }) - })) - defer srv.Close() - - svc := kubernetes.NewService(newTestClient(srv)) - clusters, err := svc.List(context.Background(), "filter-uuid") - if err != nil { - t.Fatalf("List() error = %v", err) + if clusters[0].Version != "v1.28.4" { + t.Errorf("Version = %q, want %q", clusters[0].Version, "v1.28.4") } - if len(clusters) != 1 { - t.Fatalf("expected 1 cluster, got %d", len(clusters)) + if clusters[0].NodeSize != 3 { + t.Errorf("NodeSize = %d, want %d", clusters[0].NodeSize, 3) } - if clusters[0].UUID != "filter-uuid" { - t.Errorf("UUID = %q, want %q", clusters[0].UUID, "filter-uuid") + if clusters[0].ControlNodes != 1 { + t.Errorf("ControlNodes = %d, want %d", clusters[0].ControlNodes, 1) } } -func TestKubernetesListClusterEmpty(t *testing.T) { +func TestKubernetesListClustersEmpty(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Return an empty cluster object (no UUID) - json.NewEncoder(w).Encode(map[string]interface{}{}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": []interface{}{}, + "current_page": 1, + "last_page": 1, + "total": 0, + }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) - clusters, err := svc.List(context.Background(), "") + clusters, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } @@ -103,7 +108,7 @@ func TestKubernetesCreate(t *testing.T) { var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/createKubernetes" { + if r.URL.Path != "/kubernetes-clusters" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodPost { @@ -111,299 +116,152 @@ func TestKubernetesCreate(t *testing.T) { } json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "new-cluster", - "name": "test-cluster", - "state": "Starting", + "status": "Success", + "message": "OK", + "data": map[string]interface{}{ + "id": "new-123", + "name": "test-cluster", + "slug": "test-cluster", + "state": "Starting", + "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:09:26.000000Z", + }, }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) req := kubernetes.CreateRequest{ - Name: "test-cluster", - ZoneUUID: "zone-1", - VersionUUID: "version-1", - ComputeOfferingUUID: "offering-1", - TransNetworkUUID: "network-1", - Size: 3, - ControlNodes: 1, - SSHKeyName: "mykey", - HAEnabled: false, + 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", } cluster, err := svc.Create(context.Background(), req) if err != nil { t.Fatalf("Create() error = %v", err) } - if cluster.UUID != "new-cluster" { - t.Errorf("UUID = %q, want %q", cluster.UUID, "new-cluster") + if cluster.ID != "new-123" { + t.Errorf("ID = %q, want %q", cluster.ID, "new-123") } if cluster.Name != "test-cluster" { t.Errorf("Name = %q, want %q", cluster.Name, "test-cluster") } + if cluster.Slug != "test-cluster" { + t.Errorf("Slug = %q, want %q", cluster.Slug, "test-cluster") + } // Verify body fields were sent if gotBody["name"] != "test-cluster" { t.Errorf("body name = %v, want %q", gotBody["name"], "test-cluster") } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body zoneUuid = %v, want %q", gotBody["zoneUuid"], "zone-1") + if gotBody["version"] != "v1.28.4" { + t.Errorf("body version = %v, want %q", gotBody["version"], "v1.28.4") } - if gotBody["sshKeyName"] != "mykey" { - t.Errorf("body sshKeyName = %v, want %q", gotBody["sshKeyName"], "mykey") + if gotBody["region"] != "noida" { + t.Errorf("body region = %v, want %q", gotBody["region"], "noida") } -} - -func TestKubernetesDelete(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/destroyKubernetes" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method != http.MethodDelete { - t.Errorf("unexpected method: %s", r.Method) - } - got := r.URL.Query().Get("uuid") - if got != "cluster-del" { - t.Errorf("uuid param = %q, want %q", got, "cluster-del") - } - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - svc := kubernetes.NewService(newTestClient(srv)) - err := svc.Delete(context.Background(), "cluster-del") - if err != nil { - t.Fatalf("Delete() error = %v", err) + if gotBody["plan"] != "k8s-plan-1" { + t.Errorf("body plan = %v, want %q", gotBody["plan"], "k8s-plan-1") + } + if gotBody["ssh_key"] != "mykey" { + t.Errorf("body ssh_key = %v, want %q", gotBody["ssh_key"], "mykey") } } func TestKubernetesStart(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/startKubernetes" { + if r.URL.Path != "/kubernetes-clusters/my-cluster/start" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodPut { t.Errorf("unexpected method: %s, want PUT", r.Method) } - got := r.URL.Query().Get("uuid") - if got != "cluster-start" { - t.Errorf("uuid param = %q, want %q", got, "cluster-start") - } json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "cluster-start", - "name": "my-cluster", - "state": "Running", + "status": "Success", + "message": "Kubernetes cluster start initiated.", }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) - cluster, err := svc.Start(context.Background(), "cluster-start") + err := svc.Start(context.Background(), "my-cluster") if err != nil { t.Fatalf("Start() error = %v", err) } - if cluster.State != "Running" { - t.Errorf("State = %q, want %q", cluster.State, "Running") - } } func TestKubernetesStop(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/stopKubernetes" { + if r.URL.Path != "/kubernetes-clusters/my-cluster/stop" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodPut { t.Errorf("unexpected method: %s, want PUT", r.Method) } - got := r.URL.Query().Get("uuid") - if got != "cluster-stop" { - t.Errorf("uuid param = %q, want %q", got, "cluster-stop") - } json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "cluster-stop", - "name": "my-cluster", - "state": "Stopped", + "status": "Success", + "message": "Kubernetes cluster stop initiated.", }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) - cluster, err := svc.Stop(context.Background(), "cluster-stop") + err := svc.Stop(context.Background(), "my-cluster") if err != nil { t.Fatalf("Stop() error = %v", err) } - if cluster.State != "Stopped" { - t.Errorf("State = %q, want %q", cluster.State, "Stopped") - } } -func TestKubernetesScale(t *testing.T) { +func TestKubernetesUpgrade(t *testing.T) { + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/scaleKubernetes" { + if r.URL.Path != "/kubernetes-clusters/my-cluster/change-plan" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodPut { t.Errorf("unexpected method: %s, want PUT", r.Method) } - q := r.URL.Query() - if q.Get("uuid") != "cluster-scale" { - t.Errorf("uuid param = %q, want %q", q.Get("uuid"), "cluster-scale") - } - if q.Get("size") != "5" { - t.Errorf("size param = %q, want %q", q.Get("size"), "5") - } - if q.Get("autoscalingEnabled") != "true" { - t.Errorf("autoscalingEnabled param = %q, want %q", q.Get("autoscalingEnabled"), "true") - } - json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "cluster-scale", - "name": "my-cluster", - "state": "Running", - "size": 5, - }) - })) - defer srv.Close() - - svc := kubernetes.NewService(newTestClient(srv)) - cluster, err := svc.Scale(context.Background(), "cluster-scale", 5, true) - if err != nil { - t.Fatalf("Scale() error = %v", err) - } - if cluster.Size != 5 { - t.Errorf("Size = %d, want %d", cluster.Size, 5) - } -} - -func TestKubernetesScaleNoAutoscaling(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("autoscalingEnabled") != "" { - t.Errorf("autoscalingEnabled should not be set, got %q", q.Get("autoscalingEnabled")) - } - json.NewEncoder(w).Encode(map[string]interface{}{ - "uuid": "cluster-scale", - "state": "Running", - "size": 3, - }) - })) - defer srv.Close() - - svc := kubernetes.NewService(newTestClient(srv)) - _, err := svc.Scale(context.Background(), "cluster-scale", 3, false) - if err != nil { - t.Fatalf("Scale() error = %v", err) - } -} - -func TestKubernetesListNodes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/kubernetes/listNodes" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method != http.MethodGet { - t.Errorf("unexpected method: %s", r.Method) - } - got := r.URL.Query().Get("clusterUuid") - if got != "cluster-1" { - t.Errorf("clusterUuid param = %q, want %q", got, "cluster-1") - } - json.NewEncoder(w).Encode(map[string]interface{}{ - "count": 2, - "listInstanceResponse": []map[string]interface{}{ - { - "uuid": "node-1", - "name": "k8s-worker-1", - "state": "Running", - "memory": "4096", - "instancePrivateIp": "10.0.0.10", - "zoneUuid": "zone-1", - "isActive": true, - }, - { - "uuid": "node-2", - "name": "k8s-worker-2", - "state": "Running", - "memory": "4096", - "instancePrivateIp": "10.0.0.11", - "zoneUuid": "zone-1", - "isActive": true, - }, - }, - }) - })) - defer srv.Close() - - svc := kubernetes.NewService(newTestClient(srv)) - nodes, err := svc.ListNodes(context.Background(), "cluster-1") - if err != nil { - t.Fatalf("ListNodes() error = %v", err) - } - if len(nodes) != 2 { - t.Fatalf("expected 2 nodes, got %d", len(nodes)) - } - if nodes[0].UUID != "node-1" { - t.Errorf("nodes[0].UUID = %q, want %q", nodes[0].UUID, "node-1") - } - if nodes[0].PrivateIP != "10.0.0.10" { - t.Errorf("nodes[0].PrivateIP = %q, want %q", nodes[0].PrivateIP, "10.0.0.10") - } - if !nodes[0].IsActive { - t.Errorf("nodes[0].IsActive = false, want true") - } -} - -func TestKubernetesListVersions(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/costestimate/kubernetes-version-list" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method != http.MethodGet { - t.Errorf("unexpected method: %s", r.Method) - } - got := r.URL.Query().Get("zoneUuid") - if got != "zone-1" { - t.Errorf("zoneUuid param = %q, want %q", got, "zone-1") - } + json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]interface{}{ - "count": 2, - "listKubernetesVersion": []map[string]interface{}{ - { - "uuid": "ver-1", - "name": "1.28", - "description": "Kubernetes 1.28", - "isActive": true, - "minMemory": 4096, - "minCpuNumber": 2, - }, - { - "uuid": "ver-2", - "name": "1.27", - "description": "Kubernetes 1.27", - "isActive": false, - "minMemory": 4096, - "minCpuNumber": 2, - }, - }, + "status": "Success", + "message": "Kubernetes cluster upgrade initiated.", }) })) defer srv.Close() svc := kubernetes.NewService(newTestClient(srv)) - versions, err := svc.ListVersions(context.Background(), "zone-1") + req := kubernetes.UpgradeRequest{ + Plan: "k8s-plan-2", + Slug: "my-cluster", + BillingCycle: "hourly", + IsCustomPlan: false, + CustomPlan: nil, + } + err := svc.Upgrade(context.Background(), "my-cluster", req) if err != nil { - t.Fatalf("ListVersions() error = %v", err) - } - if len(versions) != 2 { - t.Fatalf("expected 2 versions, got %d", len(versions)) - } - if versions[0].UUID != "ver-1" { - t.Errorf("versions[0].UUID = %q, want %q", versions[0].UUID, "ver-1") + t.Fatalf("Upgrade() error = %v", err) } - if versions[0].Name != "1.28" { - t.Errorf("versions[0].Name = %q, want %q", versions[0].Name, "1.28") + if gotBody["plan"] != "k8s-plan-2" { + t.Errorf("body plan = %v, want %q", gotBody["plan"], "k8s-plan-2") } - if !versions[0].IsActive { - t.Errorf("versions[0].IsActive = false, want true") + if gotBody["slug"] != "my-cluster" { + t.Errorf("body slug = %v, want %q", gotBody["slug"], "my-cluster") } - if versions[1].IsActive { - t.Errorf("versions[1].IsActive = true, want false") + if gotBody["billing_cycle"] != "hourly" { + t.Errorf("body billing_cycle = %v, want %q", gotBody["billing_cycle"], "hourly") } } diff --git a/internal/api/loadbalancer/loadbalancer.go b/internal/api/loadbalancer/loadbalancer.go index 5c6e06c..d8928b6 100644 --- a/internal/api/loadbalancer/loadbalancer.go +++ b/internal/api/loadbalancer/loadbalancer.go @@ -1,53 +1,155 @@ -// Package loadbalancer provides ZCP Load Balancer Rule API operations. +// Package loadbalancer provides ZCP Load Balancer API operations (STKCNSL). package loadbalancer import ( "context" + "encoding/json" "fmt" "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Rule represents a ZCP load balancer rule. +// LoadBalancer represents a ZCP load balancer from the STKCNSL API. +type LoadBalancer struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + State string `json:"state"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + Rules []Rule `json:"load_balancer_rules,omitempty"` + IPAddress *IPAddress `json:"ipaddress,omitempty"` + Region *Region `json:"region,omitempty"` + Project *Project `json:"project,omitempty"` + CloudProvider *CloudProvider `json:"cloud_provider,omitempty"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` +} + +// Rule represents a load balancer rule. type Rule struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - State string `json:"state"` - IsActive bool `json:"isActive"` - Algorithm string `json:"algorithm"` - PublicPort string `json:"publicPort"` - PrivatePort string `json:"privatePort"` - IPAddressUUID string `json:"ipAddressUuid"` - StickinessName string `json:"stickinessName"` - ZoneUUID string `json:"zoneUuid"` -} - -// CreateRequest holds parameters for creating a load balancer rule. + ID string `json:"id"` + LoadBalancerID string `json:"load_balancer_id"` + Name string `json:"name"` + PublicPort string `json:"public_port"` + PrivatePort string `json:"private_port"` + Protocol string `json:"protocol"` + Algorithm string `json:"algorithm"` + StickyMethod string `json:"sticky_method"` + EnableTLSProtocol bool `json:"enable_tls_protocol"` + EnableProxyProtocol bool `json:"enable_proxy_protocol"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IPAddress is a nested IP address on a load balancer. +type IPAddress struct { + ID string `json:"id"` + IPAddress string `json:"ip_address"` + Slug string `json:"slug"` +} + +// Region is a nested region on a load balancer. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` +} + +// Project is a nested project on a load balancer. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// CloudProvider is a nested cloud provider on a load balancer. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// CreateRuleSpec describes a single rule to include when creating a load balancer. +type CreateRuleSpec struct { + Name string `json:"name"` + PublicPort string `json:"public_port"` + PrivatePort string `json:"private_port"` + Protocol string `json:"protocol"` + Algorithm string `json:"algorithm"` + StickyMethod string `json:"sticky_method,omitempty"` + EnableTLSProtocol bool `json:"enable_tls_protocol"` + EnableProxyProtocol bool `json:"enable_proxy_protocol"` + VirtualMachines []VMAttachment `json:"virtual_machines"` +} + +// VMAttachment identifies a VM to attach to a load balancer rule. +type VMAttachment struct { + Slug string `json:"slug"` + IPAddress string `json:"ipaddress,omitempty"` +} + +// CreateRequest holds parameters for creating a load balancer. type CreateRequest struct { - Name string `json:"name"` - PublicIPUUID string `json:"publicIpUuid"` - PublicPort string `json:"publicport"` - PrivatePort string `json:"privateport"` - Algorithm string `json:"algorithm"` - NetworkUUID string `json:"networkUuid,omitempty"` - StickinessPolicyUUID string `json:"stickinessPolicyUuid,omitempty"` + Name string `json:"name"` + CloudProvider string `json:"cloud_provider"` + Project string `json:"project"` + Region string `json:"region"` + Network string `json:"network"` + Plan string `json:"plan"` + BillingCycle string `json:"billing_cycle"` + AcquireNewIP bool `json:"aquire_new_ip"` + IPAddress *string `json:"ipaddress"` + Rules []CreateRuleSpec `json:"rules"` + IsVMSnapshot bool `json:"is_vm_snapshot"` + Coupon *string `json:"coupon"` } -// UpdateRequest holds parameters for updating a load balancer rule. -type UpdateRequest struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Algorithm string `json:"algorithm"` +// CreateRuleRequest holds parameters for adding rules to an existing load balancer. +type CreateRuleRequest struct { + Rules []CreateRuleSpec `json:"rules"` } -type listLoadBalancerRuleResponse struct { - Count int `json:"count"` - ListLoadBalancerRuleResponse []Rule `json:"listLoadBalancerRuleResponse"` +// AttachVMRequest holds parameters for attaching VMs to a load balancer rule. +type AttachVMRequest struct { + VirtualMachines []string `json:"virtual_machines"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + Project string `json:"project"` } -// Service provides load balancer rule API operations. +// listResponse is the paginated envelope returned by GET /load-balancers. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data json.RawMessage `json:"data"` + Total int `json:"total"` +} + +// singleResponse is the envelope for create/mutate operations. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides load balancer API operations. type Service struct { client *httpclient.Client } @@ -57,50 +159,50 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns load balancer rules in a zone. zoneUUID is required; uuid and ipAddressUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, uuid, ipAddressUUID string) ([]Rule, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) +// List returns all load balancers. The include parameter requests nested relations. +func (s *Service) List(ctx context.Context) ([]LoadBalancer, error) { + q := url.Values{ + "include": {"project,cloud_provider,ipaddress,region,load_balancer_rules,offering"}, } - if ipAddressUUID != "" { - q.Set("ipAddressUuid", ipAddressUUID) + var resp listResponse + if err := s.client.Get(ctx, "/load-balancers", q, &resp); err != nil { + return nil, fmt.Errorf("listing load balancers: %w", err) } - var resp listLoadBalancerRuleResponse - if err := s.client.Get(ctx, "/restapi/loadbalancerrule/loadBalancerRuleList", q, &resp); err != nil { - return nil, fmt.Errorf("listing load balancer rules: %w", err) + var lbs []LoadBalancer + if err := json.Unmarshal(resp.Data, &lbs); err != nil { + return nil, fmt.Errorf("decoding load balancers: %w", err) } - return resp.ListLoadBalancerRuleResponse, nil + return lbs, nil } -// Create provisions a new load balancer rule. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*Rule, error) { - var resp listLoadBalancerRuleResponse - if err := s.client.Post(ctx, "/restapi/loadbalancerrule/createLoadBalancerRule", req, &resp); err != nil { - return nil, fmt.Errorf("creating load balancer rule: %w", err) +// Create provisions a new load balancer. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*LoadBalancer, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/load-balancers", req, &resp); err != nil { + return nil, fmt.Errorf("creating load balancer: %w", err) } - if len(resp.ListLoadBalancerRuleResponse) == 0 { - return nil, fmt.Errorf("create load balancer rule returned empty response") + var lb LoadBalancer + if err := json.Unmarshal(resp.Data, &lb); err != nil { + return nil, fmt.Errorf("decoding load balancer: %w", err) } - return &resp.ListLoadBalancerRuleResponse[0], nil + return &lb, nil } -// Update modifies a load balancer rule's mutable attributes. -func (s *Service) Update(ctx context.Context, req UpdateRequest) (*Rule, error) { - var resp listLoadBalancerRuleResponse - if err := s.client.Put(ctx, "/restapi/loadbalancerrule/updateLoadBalancerRule", nil, req, &resp); err != nil { - return nil, fmt.Errorf("updating load balancer rule %s: %w", req.UUID, err) +// CreateRule adds rules to an existing load balancer. +func (s *Service) CreateRule(ctx context.Context, lbSlug string, req CreateRuleRequest) error { + var resp singleResponse + if err := s.client.Post(ctx, "/load-balancers/"+lbSlug+"/load-balancer-rules", req, &resp); err != nil { + return fmt.Errorf("creating rule on load balancer %s: %w", lbSlug, err) } - if len(resp.ListLoadBalancerRuleResponse) == 0 { - return nil, fmt.Errorf("update load balancer rule returned empty response") - } - return &resp.ListLoadBalancerRuleResponse[0], nil + return nil } -// Delete removes a load balancer rule by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/loadbalancerrule/deleteLoadBalancerRule/"+uuid, nil); err != nil { - return fmt.Errorf("deleting load balancer rule %s: %w", uuid, err) +// AttachVM attaches VMs to a load balancer rule. +func (s *Service) AttachVM(ctx context.Context, lbSlug, ruleID string, req AttachVMRequest) error { + path := fmt.Sprintf("/load-balancers/%s/load-balancer-rules/%s/attach", lbSlug, ruleID) + var resp singleResponse + if err := s.client.Post(ctx, path, req, &resp); err != nil { + return fmt.Errorf("attaching VMs to rule %s on load balancer %s: %w", ruleID, lbSlug, err) } return nil } diff --git a/internal/api/loadbalancer/loadbalancer_test.go b/internal/api/loadbalancer/loadbalancer_test.go index fec4722..ada9c1b 100644 --- a/internal/api/loadbalancer/loadbalancer_test.go +++ b/internal/api/loadbalancer/loadbalancer_test.go @@ -14,86 +14,103 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listLoadBalancerRuleResponse struct { - Count int `json:"count"` - ListLoadBalancerRuleResponse []loadbalancer.Rule `json:"listLoadBalancerRuleResponse"` +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data interface{} `json:"data"` + Total int `json:"total,omitempty"` } func TestLoadBalancerList(t *testing.T) { - expected := []loadbalancer.Rule{ - {UUID: "lb-1", Name: "web-lb", PublicPort: "80", PrivatePort: "8080", ZoneUUID: "zone-1"}, - {UUID: "lb-2", Name: "ssl-lb", PublicPort: "443", PrivatePort: "8443", ZoneUUID: "zone-1"}, + expected := []loadbalancer.LoadBalancer{ + { + ID: "lb-1", + Name: "web-lb", + Slug: "web-lb", + State: "Running", + IPAddress: &loadbalancer.IPAddress{ + ID: "ip-1", + IPAddress: "1.2.3.4", + Slug: "ip-1", + }, + Region: &loadbalancer.Region{ + ID: "region-1", + Name: "US East", + Slug: "us-east", + }, + }, + { + ID: "lb-2", + Name: "ssl-lb", + Slug: "ssl-lb", + State: "Running", + }, } - var gotZone string + var gotInclude string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/loadbalancerrule/loadBalancerRuleList" { + if r.URL.Path != "/load-balancers" { http.NotFound(w, r) return } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) + if r.Method != http.MethodGet { + http.Error(w, "expected GET", http.StatusMethodNotAllowed) return } + gotInclude = r.URL.Query().Get("include") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listLoadBalancerRuleResponse{Count: len(expected), ListLoadBalancerRuleResponse: expected}) + json.NewEncoder(w).Encode(envelope{Status: "Success", Message: "OK", Data: expected, Total: len(expected)}) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - rules, err := svc.List(context.Background(), "zone-1", "", "") + lbs, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } - if len(rules) != 2 { - t.Fatalf("List() returned %d rules, want 2", len(rules)) + if len(lbs) != 2 { + t.Fatalf("List() returned %d load balancers, want 2", len(lbs)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") + if gotInclude == "" { + t.Error("expected include query parameter to be set") } - if rules[0].UUID != "lb-1" { - t.Errorf("rules[0].UUID = %q, want %q", rules[0].UUID, "lb-1") + if lbs[0].Slug != "web-lb" { + t.Errorf("lbs[0].Slug = %q, want %q", lbs[0].Slug, "web-lb") + } + if lbs[0].IPAddress == nil || lbs[0].IPAddress.IPAddress != "1.2.3.4" { + t.Errorf("lbs[0].IPAddress.IPAddress = %v, want %q", lbs[0].IPAddress, "1.2.3.4") } } -func TestLoadBalancerListWithFilters(t *testing.T) { - var gotUUID, gotIPAddressUUID string +func TestLoadBalancerListEmpty(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - gotIPAddressUUID = r.URL.Query().Get("ipAddressUuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listLoadBalancerRuleResponse{Count: 0, ListLoadBalancerRuleResponse: nil}) + json.NewEncoder(w).Encode(envelope{Status: "Success", Message: "OK", Data: []loadbalancer.LoadBalancer{}, Total: 0}) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "lb-1", "ip-1") + lbs, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } - if gotUUID != "lb-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "lb-1") - } - if gotIPAddressUUID != "ip-1" { - t.Errorf("ipAddressUuid query param = %q, want %q", gotIPAddressUUID, "ip-1") + if len(lbs) != 0 { + t.Fatalf("List() returned %d load balancers, want 0", len(lbs)) } } func TestLoadBalancerCreate(t *testing.T) { - created := loadbalancer.Rule{ - UUID: "lb-new", - Name: "my-lb", - PublicPort: "80", - PrivatePort: "8080", - Algorithm: "roundrobin", + created := loadbalancer.LoadBalancer{ + ID: "lb-new", + Name: "my-lb", + Slug: "my-lb", + State: "Creating", } var gotBody map[string]interface{} @@ -102,158 +119,186 @@ func TestLoadBalancerCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/loadbalancerrule/createLoadBalancerRule" { + if r.URL.Path != "/load-balancers" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listLoadBalancerRuleResponse{Count: 1, ListLoadBalancerRuleResponse: []loadbalancer.Rule{created}}) + json.NewEncoder(w).Encode(envelope{Status: "Success", Message: "OK", Data: created}) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) req := loadbalancer.CreateRequest{ - Name: "my-lb", - PublicIPUUID: "ip-1", - PublicPort: "80", - PrivatePort: "8080", - Algorithm: "roundrobin", + Name: "my-lb", + CloudProvider: "nimbo", + Project: "default-33", + Region: "ixg-belagavi", + Network: "d-net-test", + Plan: "load-balancer", + BillingCycle: "hourly", + AcquireNewIP: true, + Rules: []loadbalancer.CreateRuleSpec{}, } result, err := svc.Create(context.Background(), req) if err != nil { t.Fatalf("Create() error = %v", err) } - if result.UUID != "lb-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "lb-new") + if result.Slug != "my-lb" { + t.Errorf("result.Slug = %q, want %q", result.Slug, "my-lb") } if gotBody["name"] != "my-lb" { t.Errorf("body name = %v, want %q", gotBody["name"], "my-lb") } - if gotBody["publicIpUuid"] != "ip-1" { - t.Errorf("body publicIpUuid = %v, want %q", gotBody["publicIpUuid"], "ip-1") + if gotBody["cloud_provider"] != "nimbo" { + t.Errorf("body cloud_provider = %v, want %q", gotBody["cloud_provider"], "nimbo") + } + if gotBody["billing_cycle"] != "hourly" { + t.Errorf("body billing_cycle = %v, want %q", gotBody["billing_cycle"], "hourly") } - if gotBody["algorithm"] != "roundrobin" { - t.Errorf("body algorithm = %v, want %q", gotBody["algorithm"], "roundrobin") + if gotBody["aquire_new_ip"] != true { + t.Errorf("body aquire_new_ip = %v, want true", gotBody["aquire_new_ip"]) } } -func TestLoadBalancerCreateEmptyResponse(t *testing.T) { +func TestLoadBalancerCreateRule(t *testing.T) { + var gotBody map[string]interface{} + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listLoadBalancerRuleResponse{Count: 0, ListLoadBalancerRuleResponse: nil}) + json.NewEncoder(w).Encode(envelope{Status: "Success", Message: "OK", Data: nil}) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), loadbalancer.CreateRequest{Name: "x", PublicIPUUID: "ip-1"}) - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") + req := loadbalancer.CreateRuleRequest{ + Rules: []loadbalancer.CreateRuleSpec{ + { + Name: "web-rule", + PublicPort: "80", + PrivatePort: "8080", + Protocol: "tcp", + Algorithm: "roundrobin", + VirtualMachines: []loadbalancer.VMAttachment{}, + }, + }, } -} - -func TestLoadBalancerUpdate(t *testing.T) { - updated := loadbalancer.Rule{ - UUID: "lb-1", - Name: "updated-lb", - Algorithm: "leastconn", + err := svc.CreateRule(context.Background(), "my-lb", req) + if err != nil { + t.Fatalf("CreateRule() error = %v", err) + } + if gotPath != "/load-balancers/my-lb/load-balancer-rules" { + t.Errorf("path = %q, want %q", gotPath, "/load-balancers/my-lb/load-balancer-rules") + } + rules, ok := gotBody["rules"].([]interface{}) + if !ok || len(rules) != 1 { + t.Fatalf("body rules length = %v, want 1", gotBody["rules"]) + } + rule := rules[0].(map[string]interface{}) + if rule["name"] != "web-rule" { + t.Errorf("rule name = %v, want %q", rule["name"], "web-rule") } + if rule["algorithm"] != "roundrobin" { + t.Errorf("rule algorithm = %v, want %q", rule["algorithm"], "roundrobin") + } +} +func TestLoadBalancerAttachVM(t *testing.T) { var gotBody map[string]interface{} + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - http.Error(w, "expected PUT", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/loadbalancerrule/updateLoadBalancerRule" { - http.NotFound(w, r) - return - } + gotMethod = r.Method + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listLoadBalancerRuleResponse{Count: 1, ListLoadBalancerRuleResponse: []loadbalancer.Rule{updated}}) + json.NewEncoder(w).Encode(envelope{Status: "Success", Message: "OK", Data: nil}) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - req := loadbalancer.UpdateRequest{ - UUID: "lb-1", - Name: "updated-lb", - Algorithm: "leastconn", + req := loadbalancer.AttachVMRequest{ + VirtualMachines: []string{"vm-slug-1", "vm-slug-2"}, + CloudProvider: "nimbo", + Region: "ixg-belagavi", + Project: "default-33", } - result, err := svc.Update(context.Background(), req) + err := svc.AttachVM(context.Background(), "my-lb", "rule-123", req) if err != nil { - t.Fatalf("Update() error = %v", err) + t.Fatalf("AttachVM() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) } - if result.UUID != "lb-1" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "lb-1") + if gotPath != "/load-balancers/my-lb/load-balancer-rules/rule-123/attach" { + t.Errorf("path = %q, want %q", gotPath, "/load-balancers/my-lb/load-balancer-rules/rule-123/attach") } - if gotBody["name"] != "updated-lb" { - t.Errorf("body name = %v, want %q", gotBody["name"], "updated-lb") + vms, ok := gotBody["virtual_machines"].([]interface{}) + if !ok || len(vms) != 2 { + t.Fatalf("body virtual_machines length = %v, want 2", gotBody["virtual_machines"]) } - if gotBody["algorithm"] != "leastconn" { - t.Errorf("body algorithm = %v, want %q", gotBody["algorithm"], "leastconn") + if vms[0] != "vm-slug-1" { + t.Errorf("virtual_machines[0] = %v, want %q", vms[0], "vm-slug-1") + } + if gotBody["cloud_provider"] != "nimbo" { + t.Errorf("body cloud_provider = %v, want %q", gotBody["cloud_provider"], "nimbo") } } -func TestLoadBalancerUpdateEmptyResponse(t *testing.T) { +func TestLoadBalancerListError(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(listLoadBalancerRuleResponse{Count: 0, ListLoadBalancerRuleResponse: nil}) + http.Error(w, "server error", http.StatusInternalServerError) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - _, err := svc.Update(context.Background(), loadbalancer.UpdateRequest{UUID: "lb-1", Name: "x"}) + _, err := svc.List(context.Background()) if err == nil { - t.Fatal("Update() expected error on empty response, got nil") + t.Fatal("List() expected error on 500, got nil") } } -func TestLoadBalancerDelete(t *testing.T) { - var gotPath, gotMethod string +func TestLoadBalancerCreateError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) + http.Error(w, "bad request", http.StatusBadRequest) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "lb-del-1") - if err != nil { - t.Fatalf("Delete() error = %v", err) - } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) - } - if gotPath != "/restapi/loadbalancerrule/deleteLoadBalancerRule/lb-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/loadbalancerrule/deleteLoadBalancerRule/lb-del-1") + _, err := svc.Create(context.Background(), loadbalancer.CreateRequest{Name: "x"}) + if err == nil { + t.Fatal("Create() expected error on 400, got nil") } } -func TestLoadBalancerDeleteError(t *testing.T) { +func TestLoadBalancerCreateRuleError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "missing") + err := svc.CreateRule(context.Background(), "missing-lb", loadbalancer.CreateRuleRequest{}) if err == nil { - t.Fatal("Delete() expected error on 404, got nil") + t.Fatal("CreateRule() expected error on 404, got nil") } } -func TestLoadBalancerListError(t *testing.T) { +func TestLoadBalancerAttachVMError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "server error", http.StatusInternalServerError) + http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() svc := loadbalancer.NewService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "", "") + err := svc.AttachVM(context.Background(), "missing-lb", "rule-1", loadbalancer.AttachVMRequest{}) if err == nil { - t.Fatal("List() expected error on 500, got nil") + t.Fatal("AttachVM() expected error on 404, got nil") } } diff --git a/internal/api/marketplace/marketplace.go b/internal/api/marketplace/marketplace.go new file mode 100644 index 0000000..33954d2 --- /dev/null +++ b/internal/api/marketplace/marketplace.go @@ -0,0 +1,77 @@ +// Package marketplace provides ZCP marketplace app API operations. +package marketplace + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// AppFile represents a file attached to a marketplace app. +type AppFile struct { + ID string `json:"id"` + MarketplaceAppID string `json:"marketplace_app_id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// App represents a marketplace application. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Category string `json:"category"` + URL string `json:"url"` + ShortDescription string `json:"short_description"` + AppMasterCategory string `json:"app_master_category"` + IsFeatured bool `json:"is_featured"` + DisplayOrder *int `json:"display_order"` + StartupScript *string `json:"startup_script"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Icon string `json:"icon"` + DarkThemeLogo string `json:"dark_theme_logo"` + TemplateID string `json:"template_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RegionID string `json:"region_id"` + Files []AppFile `json:"files"` +} + +// listAppsResponse wraps the marketplace apps response. +type listAppsResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []App `json:"data"` +} + +// Service provides marketplace API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new marketplace Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// ListApps returns all marketplace applications with optional filters. +func (s *Service) ListApps(ctx context.Context, region, include string) ([]App, error) { + q := url.Values{} + if region != "" { + q.Set("filter[region]", region) + } + if include != "" { + q.Set("include", include) + } + + var resp listAppsResponse + if err := s.client.Get(ctx, "/marketplace-apps", q, &resp); err != nil { + return nil, fmt.Errorf("listing marketplace apps: %w", err) + } + return resp.Data, nil +} diff --git a/internal/api/monitoring/monitoring.go b/internal/api/monitoring/monitoring.go new file mode 100644 index 0000000..88500e3 --- /dev/null +++ b/internal/api/monitoring/monitoring.go @@ -0,0 +1,194 @@ +// Package monitoring provides ZCP monitoring API operations (STKCNSL). +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// envelope is the STKCNSL response wrapper: {"status":"Success","data":...} +type envelope struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` +} + +// GlobalResource represents a single resource entry from the global monitoring overview. +type GlobalResource struct { + Name string `json:"name"` + Total float64 `json:"total"` + Used float64 `json:"used"` + Free float64 `json:"free"` + Unit string `json:"unit"` + Percentage float64 `json:"percentage"` +} + +// MetricPoint represents a single time-series data point for VM metrics. +type MetricPoint struct { + Timestamp string `json:"timestamp"` + Value float64 `json:"value"` + Unit string `json:"unit"` +} + +// DiskMetricPoint represents a single time-series data point for disk metrics +// that have separate read and write values. +type DiskMetricPoint struct { + Timestamp string `json:"timestamp"` + Read float64 `json:"read"` + Write float64 `json:"write"` + Unit string `json:"unit"` +} + +// NetworkMetricPoint represents a single time-series data point for network metrics +// that have separate incoming and outgoing values. +type NetworkMetricPoint struct { + Timestamp string `json:"timestamp"` + Incoming float64 `json:"incoming"` + Outgoing float64 `json:"outgoing"` + Unit string `json:"unit"` +} + +// Service provides monitoring API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new monitoring Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// decodeEnvelope unmarshals the STKCNSL response envelope and returns the inner data. +func decodeEnvelope(raw json.RawMessage) (json.RawMessage, error) { + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("decoding response envelope: %w", err) + } + if env.Status != "Success" { + return nil, fmt.Errorf("API returned status %q", env.Status) + } + return env.Data, nil +} + +// Global returns the global resource monitoring overview. +func (s *Service) Global(ctx context.Context) ([]GlobalResource, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/monitoring/global", url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching global monitoring: %w", err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var resources []GlobalResource + if err := json.Unmarshal(data, &resources); err != nil { + return nil, fmt.Errorf("decoding global resources: %w", err) + } + return resources, nil +} + +// CPUUsage returns CPU usage metrics for a VM. +func (s *Service) CPUUsage(ctx context.Context, vmSlug string) ([]MetricPoint, error) { + var raw json.RawMessage + path := fmt.Sprintf("/monitoring/%s/cpu-usage", url.PathEscape(vmSlug)) + if err := s.client.Get(ctx, path, url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching CPU usage for %s: %w", vmSlug, err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var points []MetricPoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, fmt.Errorf("decoding CPU usage: %w", err) + } + return points, nil +} + +// MemoryUsage returns memory usage metrics for a VM. +func (s *Service) MemoryUsage(ctx context.Context, vmSlug string) ([]MetricPoint, error) { + var raw json.RawMessage + path := fmt.Sprintf("/monitoring/%s/memory-usage", url.PathEscape(vmSlug)) + if err := s.client.Get(ctx, path, url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching memory usage for %s: %w", vmSlug, err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var points []MetricPoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, fmt.Errorf("decoding memory usage: %w", err) + } + return points, nil +} + +// DiskReadWrite returns disk read/write metrics for a VM. +func (s *Service) DiskReadWrite(ctx context.Context, vmSlug string) ([]DiskMetricPoint, error) { + var raw json.RawMessage + path := fmt.Sprintf("/monitoring/%s/disk-read-write", url.PathEscape(vmSlug)) + if err := s.client.Get(ctx, path, url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching disk read/write for %s: %w", vmSlug, err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var points []DiskMetricPoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, fmt.Errorf("decoding disk read/write: %w", err) + } + return points, nil +} + +// DiskIOReadWrite returns disk IO read/write metrics for a VM. +func (s *Service) DiskIOReadWrite(ctx context.Context, vmSlug string) ([]DiskMetricPoint, error) { + var raw json.RawMessage + path := fmt.Sprintf("/monitoring/%s/disk-io-read-write", url.PathEscape(vmSlug)) + if err := s.client.Get(ctx, path, url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching disk IO for %s: %w", vmSlug, err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var points []DiskMetricPoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, fmt.Errorf("decoding disk IO: %w", err) + } + return points, nil +} + +// NetworkTraffic returns network traffic metrics for a VM. +func (s *Service) NetworkTraffic(ctx context.Context, vmSlug string) ([]NetworkMetricPoint, error) { + var raw json.RawMessage + path := fmt.Sprintf("/monitoring/%s/network-traffic", url.PathEscape(vmSlug)) + if err := s.client.Get(ctx, path, url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching network traffic for %s: %w", vmSlug, err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + var points []NetworkMetricPoint + if err := json.Unmarshal(data, &points); err != nil { + return nil, fmt.Errorf("decoding network traffic: %w", err) + } + return points, nil +} + +// Charts returns the monitoring charts data. +func (s *Service) Charts(ctx context.Context) (json.RawMessage, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/monitoring/charts", url.Values{}, &raw); err != nil { + return nil, fmt.Errorf("fetching monitoring charts: %w", err) + } + data, err := decodeEnvelope(raw) + if err != nil { + return nil, err + } + return data, nil +} diff --git a/internal/api/monitoring/monitoring_test.go b/internal/api/monitoring/monitoring_test.go new file mode 100644 index 0000000..0c9d4da --- /dev/null +++ b/internal/api/monitoring/monitoring_test.go @@ -0,0 +1,285 @@ +package monitoring_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/monitoring" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { + t.Helper() + return httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +// wrapEnvelope returns a STKCNSL-style {"status":"Success","data":...} JSON body. +func wrapEnvelope(t *testing.T, data interface{}) []byte { + t.Helper() + d, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal data: %v", err) + } + env := map[string]interface{}{ + "status": "Success", + "data": json.RawMessage(d), + } + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + return b +} + +func TestGlobal(t *testing.T) { + resources := []monitoring.GlobalResource{ + {Name: "CPU", Total: 64, Used: 32, Free: 32, Unit: "cores", Percentage: 50}, + {Name: "Memory", Total: 128, Used: 96, Free: 32, Unit: "GB", Percentage: 75}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/monitoring/global" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, resources)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.Global(context.Background()) + if err != nil { + t.Fatalf("Global() error = %v", err) + } + if len(result) != 2 { + t.Fatalf("Global() returned %d resources, want 2", len(result)) + } + if result[0].Name != "CPU" { + t.Errorf("result[0].Name = %q, want %q", result[0].Name, "CPU") + } + if result[1].Percentage != 75 { + t.Errorf("result[1].Percentage = %v, want 75", result[1].Percentage) + } +} + +func TestCPUUsage(t *testing.T) { + points := []monitoring.MetricPoint{ + {Timestamp: "2026-04-06T10:00:00Z", Value: 45.2, Unit: "%"}, + {Timestamp: "2026-04-06T10:05:00Z", Value: 52.1, Unit: "%"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, points)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.CPUUsage(context.Background(), "my-vm-slug") + if err != nil { + t.Fatalf("CPUUsage() error = %v", err) + } + if gotPath != "/monitoring/my-vm-slug/cpu-usage" { + t.Errorf("path = %q, want %q", gotPath, "/monitoring/my-vm-slug/cpu-usage") + } + if len(result) != 2 { + t.Fatalf("CPUUsage() returned %d points, want 2", len(result)) + } + if result[0].Value != 45.2 { + t.Errorf("result[0].Value = %v, want 45.2", result[0].Value) + } +} + +func TestMemoryUsage(t *testing.T) { + points := []monitoring.MetricPoint{ + {Timestamp: "2026-04-06T10:00:00Z", Value: 78.5, Unit: "%"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, points)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.MemoryUsage(context.Background(), "vm-1") + if err != nil { + t.Fatalf("MemoryUsage() error = %v", err) + } + if gotPath != "/monitoring/vm-1/memory-usage" { + t.Errorf("path = %q, want %q", gotPath, "/monitoring/vm-1/memory-usage") + } + if len(result) != 1 { + t.Fatalf("MemoryUsage() returned %d points, want 1", len(result)) + } + if result[0].Value != 78.5 { + t.Errorf("result[0].Value = %v, want 78.5", result[0].Value) + } +} + +func TestDiskReadWrite(t *testing.T) { + points := []monitoring.DiskMetricPoint{ + {Timestamp: "2026-04-06T10:00:00Z", Read: 1024, Write: 512, Unit: "KB/s"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, points)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.DiskReadWrite(context.Background(), "vm-2") + if err != nil { + t.Fatalf("DiskReadWrite() error = %v", err) + } + if gotPath != "/monitoring/vm-2/disk-read-write" { + t.Errorf("path = %q, want %q", gotPath, "/monitoring/vm-2/disk-read-write") + } + if len(result) != 1 { + t.Fatalf("DiskReadWrite() returned %d points, want 1", len(result)) + } + if result[0].Read != 1024 { + t.Errorf("result[0].Read = %v, want 1024", result[0].Read) + } + if result[0].Write != 512 { + t.Errorf("result[0].Write = %v, want 512", result[0].Write) + } +} + +func TestDiskIOReadWrite(t *testing.T) { + points := []monitoring.DiskMetricPoint{ + {Timestamp: "2026-04-06T10:00:00Z", Read: 200, Write: 100, Unit: "IOPS"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, points)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.DiskIOReadWrite(context.Background(), "vm-3") + if err != nil { + t.Fatalf("DiskIOReadWrite() error = %v", err) + } + if gotPath != "/monitoring/vm-3/disk-io-read-write" { + t.Errorf("path = %q, want %q", gotPath, "/monitoring/vm-3/disk-io-read-write") + } + if len(result) != 1 { + t.Fatalf("DiskIOReadWrite() returned %d points, want 1", len(result)) + } + if result[0].Read != 200 { + t.Errorf("result[0].Read = %v, want 200", result[0].Read) + } +} + +func TestNetworkTraffic(t *testing.T) { + points := []monitoring.NetworkMetricPoint{ + {Timestamp: "2026-04-06T10:00:00Z", Incoming: 5000, Outgoing: 3000, Unit: "KB/s"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, points)) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.NetworkTraffic(context.Background(), "vm-4") + if err != nil { + t.Fatalf("NetworkTraffic() error = %v", err) + } + if gotPath != "/monitoring/vm-4/network-traffic" { + t.Errorf("path = %q, want %q", gotPath, "/monitoring/vm-4/network-traffic") + } + if len(result) != 1 { + t.Fatalf("NetworkTraffic() returned %d points, want 1", len(result)) + } + if result[0].Incoming != 5000 { + t.Errorf("result[0].Incoming = %v, want 5000", result[0].Incoming) + } + if result[0].Outgoing != 3000 { + t.Errorf("result[0].Outgoing = %v, want 3000", result[0].Outgoing) + } +} + +func TestCharts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/monitoring/charts" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(wrapEnvelope(t, map[string]string{"chart": "data"})) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + result, err := svc.Charts(context.Background()) + if err != nil { + t.Fatalf("Charts() error = %v", err) + } + if len(result) == 0 { + t.Error("Charts() returned empty result") + } + var v interface{} + if err := json.Unmarshal(result, &v); err != nil { + t.Errorf("Charts() result is not valid JSON: %v", err) + } +} + +func TestGlobalAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "listErrorResponse": map[string]string{ + "errorCode": "UNAUTHORIZED", + "errorMsg": "Invalid API key", + }, + }) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + _, err := svc.Global(context.Background()) + if err == nil { + t.Fatal("Global() expected error for 401, got nil") + } +} + +func TestGlobalNonSuccessStatus(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", + "data": nil, + }) + })) + defer srv.Close() + + svc := monitoring.NewService(newTestClient(t, srv)) + _, err := svc.Global(context.Background()) + if err == nil { + t.Fatal("Global() expected error for non-Success status, got nil") + } +} diff --git a/internal/api/network/network.go b/internal/api/network/network.go index acbae17..81759e8 100644 --- a/internal/api/network/network.go +++ b/internal/api/network/network.go @@ -4,54 +4,104 @@ package network import ( "context" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Network represents a ZCP virtual network. +// Network represents a ZCP isolated network. type Network struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - NetworkType string `json:"networkType"` - Gateway string `json:"gateway"` - CIDR string `json:"cIDR"` - ZoneUUID string `json:"zoneUuid"` - DomainName string `json:"domainName"` - NetworkOfferingUUID string `json:"networkOfferingUuid"` - NetworkACLList string `json:"networkAclList"` - CleanUpNetwork bool `json:"cleanUpNetwork"` - NetworkDomain string `json:"networkDomain"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + NetworkType string `json:"network_type"` + Gateway string `json:"gateway"` + CIDR string `json:"cidr"` + Netmask string `json:"netmask"` + DNS1 string `json:"dns1"` + DNS2 string `json:"dns2"` + ZoneSlug string `json:"zone_slug"` + ZoneName string `json:"zone_name"` + Category string `json:"category"` + Description string `json:"description"` + IsDefault bool `json:"is_default"` +} + +// Category represents a network category (offering). +type Category struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` +} + +// EgressRule represents a network egress firewall rule. +type EgressRule struct { + ID string `json:"id"` + Protocol string `json:"protocol"` + StartPort string `json:"start_port"` + EndPort string `json:"end_port"` + CIDR string `json:"cidr"` + ICMPType string `json:"icmp_type"` + ICMPCode string `json:"icmp_code"` + Status string `json:"status"` } // CreateRequest holds parameters for creating a network. type CreateRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - NetworkOfferingUUID string `json:"networkOfferingUuid"` - VirtualMachineUUID string `json:"virtualmachineUuid,omitempty"` - VPCUUID string `json:"vpcUuid,omitempty"` - Gateway string `json:"gateway,omitempty"` - Netmask string `json:"netmask,omitempty"` - ACLUUID string `json:"aclUuid,omitempty"` - // IsPublic is sent as a query parameter, not in the JSON body. - IsPublic bool `json:"-"` + Name string `json:"name"` + CategorySlug string `json:"category_slug"` + ZoneSlug string `json:"zone_slug,omitempty"` + Gateway string `json:"gateway,omitempty"` + Netmask string `json:"netmask,omitempty"` + Description string `json:"description,omitempty"` } // UpdateRequest holds parameters for updating a network. type UpdateRequest struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - CIDR string `json:"getcIDR,omitempty"` - NetworkDomain string `json:"networkDomain,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// CreateEgressRuleRequest holds parameters for creating an egress rule. +type CreateEgressRuleRequest struct { + Protocol string `json:"protocol"` + StartPort string `json:"start_port,omitempty"` + EndPort string `json:"end_port,omitempty"` + CIDR string `json:"cidr,omitempty"` + ICMPType string `json:"icmp_type,omitempty"` + 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 { - Count int `json:"count"` - ListNetworkResponse []Network `json:"listNetworkResponse"` + Status string `json:"status"` + Data []Network `json:"data"` +} + +type singleNetworkResponse struct { + Status string `json:"status"` + Data Network `json:"data"` +} + +type listCategoryResponse struct { + Status string `json:"status"` + Data []Category `json:"data"` +} + +type listEgressRuleResponse struct { + Status string `json:"status"` + Data []EgressRule `json:"data"` +} + +type singleEgressRuleResponse struct { + Status string `json:"status"` + Data EgressRule `json:"data"` } // Service provides network API operations. @@ -64,82 +114,65 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns networks in a zone. zoneUUID is required; networkUUID is an optional filter. -func (s *Service) List(ctx context.Context, zoneUUID, networkUUID string) ([]Network, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if networkUUID != "" { - q.Set("uuid", networkUUID) - } +// List returns all isolated networks. +func (s *Service) List(ctx context.Context) ([]Network, error) { var resp listNetworkResponse - if err := s.client.Get(ctx, "/restapi/network/networkList", q, &resp); err != nil { + if err := s.client.Get(ctx, "/networks", nil, &resp); err != nil { return nil, fmt.Errorf("listing networks: %w", err) } - return resp.ListNetworkResponse, nil -} - -// Get returns a single network by UUID using the dedicated networkId endpoint. -func (s *Service) Get(ctx context.Context, zoneUUID, uuid string) (*Network, error) { - q := url.Values{"uuid": {uuid}} - var resp listNetworkResponse - if err := s.client.Get(ctx, "/restapi/network/networkId", q, &resp); err != nil { - return nil, fmt.Errorf("getting network %s: %w", uuid, err) - } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("network %q not found", uuid) - } - return &resp.ListNetworkResponse[0], nil + return resp.Data, nil } -// Create provisions a new network. +// Create provisions a new isolated network. func (s *Service) Create(ctx context.Context, req CreateRequest) (*Network, error) { - path := "/restapi/network/createNetwork" - if req.IsPublic { - path += "?isPublic=true" - } - var resp listNetworkResponse - if err := s.client.Post(ctx, path, req, &resp); err != nil { + var resp singleNetworkResponse + if err := s.client.Post(ctx, "/networks", req, &resp); err != nil { return nil, fmt.Errorf("creating network: %w", err) } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("create network returned empty response") - } - return &resp.ListNetworkResponse[0], nil + return &resp.Data, nil } -// Delete removes a network by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/network/deleteNetwork/"+uuid, nil); err != nil { - return fmt.Errorf("deleting network %s: %w", uuid, err) +// Update modifies a network's mutable attributes. +func (s *Service) Update(ctx context.Context, slug string, req UpdateRequest) (*Network, error) { + var resp singleNetworkResponse + if err := s.client.Put(ctx, "/networks/"+slug, nil, req, &resp); err != nil { + return nil, fmt.Errorf("updating network %s: %w", slug, err) } - return nil + return &resp.Data, nil } -// Update modifies a network's mutable attributes. -// NOTE: The API defines this as PUT but we use Post since httpclient has no Put method. -func (s *Service) Update(ctx context.Context, req UpdateRequest) (*Network, error) { - var resp listNetworkResponse - if err := s.client.Post(ctx, "/restapi/network/updateNetwork", req, &resp); err != nil { - return nil, fmt.Errorf("updating network %s: %w", req.UUID, err) - } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("update network returned empty response") +// ListCategories returns available network categories (offerings). +func (s *Service) ListCategories(ctx context.Context) ([]Category, error) { + var resp listCategoryResponse + if err := s.client.Get(ctx, "/network/categories", nil, &resp); err != nil { + return nil, fmt.Errorf("listing network categories: %w", err) } - return &resp.ListNetworkResponse[0], nil + return resp.Data, nil } -// Restart restarts a network. cleanUp triggers cleanup of stale resources. -func (s *Service) Restart(ctx context.Context, uuid string, cleanUp bool) (*Network, error) { - cleanUpStr := "false" - if cleanUp { - cleanUpStr = "true" +// ListEgressRules returns egress firewall rules for a network. +func (s *Service) ListEgressRules(ctx context.Context, networkSlug string) ([]EgressRule, error) { + var resp listEgressRuleResponse + if err := s.client.Get(ctx, "/networks/"+networkSlug+"/egress-firewall-rules", nil, &resp); err != nil { + return nil, fmt.Errorf("listing egress rules for network %s: %w", networkSlug, err) } - q := url.Values{"uuid": {uuid}, "cleanUpNetwork": {cleanUpStr}} - var resp listNetworkResponse - if err := s.client.Get(ctx, "/restapi/network/restartNetwork", q, &resp); err != nil { - return nil, fmt.Errorf("restarting network %s: %w", uuid, err) + return resp.Data, nil +} + +// CreateEgressRule adds an egress firewall rule to a network. +func (s *Service) CreateEgressRule(ctx context.Context, networkSlug string, req CreateEgressRuleRequest) (*EgressRule, error) { + var resp singleEgressRuleResponse + if err := s.client.Post(ctx, "/networks/"+networkSlug+"/egress-firewall-rules", req, &resp); err != nil { + return nil, fmt.Errorf("creating egress rule for network %s: %w", networkSlug, err) } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("restart network returned empty response") + return &resp.Data, nil +} + +// DeleteEgressRule removes an egress firewall rule from a network. +func (s *Service) DeleteEgressRule(ctx context.Context, networkSlug string, ruleID string) error { + path := fmt.Sprintf("/networks/%s/egress-firewall-rules/%s", networkSlug, ruleID) + if err := s.client.Delete(ctx, path, nil); err != nil { + return fmt.Errorf("deleting egress rule %s for network %s: %w", ruleID, networkSlug, err) } - return &resp.ListNetworkResponse[0], nil + return nil } diff --git a/internal/api/network/network_test.go b/internal/api/network/network_test.go index 0dd40b1..b1cba6d 100644 --- a/internal/api/network/network_test.go +++ b/internal/api/network/network_test.go @@ -3,6 +3,7 @@ package network_test import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -16,67 +17,56 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listNetworkResponse struct { - Count int `json:"count"` - ListNetworkResponse []network.Network `json:"listNetworkResponse"` -} - -func makeNetwork(uuid, name, networkType string) network.Network { +func makeNetwork(slug, name string) network.Network { return network.Network{ - UUID: uuid, + ID: "1", + Slug: slug, Name: name, - Status: "Implemented", - IsActive: true, - NetworkType: networkType, + Status: "Active", + NetworkType: "Isolated", Gateway: "10.0.0.1", CIDR: "10.0.0.0/24", - ZoneUUID: "zone-uuid-1", + ZoneSlug: "yow-1", } } -// TestNetworkList verifies URL path, required zoneUuid param, and response parsing. +// TestNetworkList verifies URL path and response parsing. func TestNetworkList(t *testing.T) { networks := []network.Network{ - makeNetwork("net-1", "web-network", "Isolated"), - makeNetwork("net-2", "db-network", "Isolated"), + makeNetwork("web-network", "web-network"), + makeNetwork("db-network", "db-network"), } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/network/networkList" { + if r.URL.Path != "/networks" { http.NotFound(w, r) return } - zoneUUID := r.URL.Query().Get("zoneUuid") - if zoneUUID == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkResponse{ - Count: len(networks), - ListNetworkResponse: networks, + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": networks, }) })) defer srv.Close() svc := network.NewService(newClient(srv.URL)) - result, err := svc.List(context.Background(), "zone-uuid-1", "") + result, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(result) != 2 { t.Fatalf("List() returned %d networks, want 2", len(result)) } - if result[0].UUID != "net-1" { - t.Errorf("result[0].UUID = %q, want %q", result[0].UUID, "net-1") + if result[0].Slug != "web-network" { + t.Errorf("result[0].Slug = %q, want %q", result[0].Slug, "web-network") } if result[1].Name != "db-network" { t.Errorf("result[1].Name = %q, want %q", result[1].Name, "db-network") @@ -85,7 +75,7 @@ func TestNetworkList(t *testing.T) { // TestNetworkCreate verifies POST body and response parsing. func TestNetworkCreate(t *testing.T) { - created := makeNetwork("new-net-1", "my-network", "Isolated") + created := makeNetwork("my-network", "my-network") var gotBody map[string]interface{} @@ -94,15 +84,15 @@ func TestNetworkCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/network/createNetwork" { + if r.URL.Path != "/networks" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkResponse{ - Count: 1, - ListNetworkResponse: []network.Network{created}, + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, }) })) defer srv.Close() @@ -110,87 +100,197 @@ func TestNetworkCreate(t *testing.T) { svc := network.NewService(newClient(srv.URL)) req := network.CreateRequest{ - Name: "my-network", - ZoneUUID: "zone-1", - NetworkOfferingUUID: "offering-1", + Name: "my-network", + CategorySlug: "default-isolated", } net, err := svc.Create(context.Background(), req) if err != nil { t.Fatalf("Create() error = %v", err) } - if net.UUID != "new-net-1" { - t.Errorf("net.UUID = %q, want %q", net.UUID, "new-net-1") + if net.Slug != "my-network" { + t.Errorf("net.Slug = %q, want %q", net.Slug, "my-network") } if gotBody["name"] != "my-network" { t.Errorf("body[name] = %v, want %q", gotBody["name"], "my-network") } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body[zoneUuid] = %v, want %q", gotBody["zoneUuid"], "zone-1") - } - if gotBody["networkOfferingUuid"] != "offering-1" { - t.Errorf("body[networkOfferingUuid] = %v, want %q", gotBody["networkOfferingUuid"], "offering-1") + if gotBody["category_slug"] != "default-isolated" { + t.Errorf("body[category_slug] = %v, want %q", gotBody["category_slug"], "default-isolated") } } -// TestNetworkDelete verifies DELETE path includes uuid. -func TestNetworkDelete(t *testing.T) { - var gotPath string +// TestNetworkUpdate verifies PUT path and response parsing. +func TestNetworkUpdate(t *testing.T) { + updated := makeNetwork("my-network", "renamed-network") + updated.Name = "renamed-network" + + var gotPath, gotMethod string + var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - http.Error(w, "expected DELETE", http.StatusMethodNotAllowed) - return - } gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) + gotMethod = r.Method + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": updated, + }) })) defer srv.Close() svc := network.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "net-del-1") + net, err := svc.Update(context.Background(), "my-network", network.UpdateRequest{ + Name: "renamed-network", + }) if err != nil { - t.Fatalf("Delete() error = %v", err) + t.Fatalf("Update() error = %v", err) } - if gotPath != "/restapi/network/deleteNetwork/net-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/network/deleteNetwork/net-del-1") + if gotMethod != http.MethodPut { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPut) + } + if gotPath != "/networks/my-network" { + t.Errorf("path = %q, want %q", gotPath, "/networks/my-network") + } + if net.Name != "renamed-network" { + t.Errorf("net.Name = %q, want %q", net.Name, "renamed-network") } } -// TestNetworkGet verifies uuid param is sent and a single result is returned. -func TestNetworkGet(t *testing.T) { - expected := makeNetwork("net-99", "target-network", "Isolated") - - var gotUUID string +// TestListCategories verifies the network categories endpoint. +func TestListCategories(t *testing.T) { + categories := []network.Category{ + {ID: "1", Slug: "default-isolated", Name: "Default Isolated"}, + {ID: "2", Slug: "vpc-tier", Name: "VPC Tier"}, + } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/network/networkId" { + if r.URL.Path != "/network/categories" { http.NotFound(w, r) return } - gotUUID = r.URL.Query().Get("uuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listNetworkResponse{ - Count: 1, - ListNetworkResponse: []network.Network{expected}, + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": categories, + }) + })) + defer srv.Close() + + svc := network.NewService(newClient(srv.URL)) + result, err := svc.ListCategories(context.Background()) + if err != nil { + t.Fatalf("ListCategories() error = %v", err) + } + if len(result) != 2 { + t.Fatalf("ListCategories() returned %d, want 2", len(result)) + } + if result[0].Slug != "default-isolated" { + t.Errorf("result[0].Slug = %q, want %q", result[0].Slug, "default-isolated") + } +} + +// TestListEgressRules verifies the egress rules list endpoint. +func TestListEgressRules(t *testing.T) { + rules := []network.EgressRule{ + {ID: "1", Protocol: "tcp", StartPort: "80", EndPort: "80", Status: "Active"}, + {ID: "2", Protocol: "all", Status: "Active"}, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": rules, }) })) defer srv.Close() svc := network.NewService(newClient(srv.URL)) + result, err := svc.ListEgressRules(context.Background(), "my-network") + if err != nil { + t.Fatalf("ListEgressRules() error = %v", err) + } + if gotPath != "/networks/my-network/egress-firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/networks/my-network/egress-firewall-rules") + } + if len(result) != 2 { + t.Fatalf("ListEgressRules() returned %d, want 2", len(result)) + } + if result[0].Protocol != "tcp" { + t.Errorf("result[0].Protocol = %q, want %q", result[0].Protocol, "tcp") + } +} + +// TestCreateEgressRule verifies POST body and path for egress rule creation. +func TestCreateEgressRule(t *testing.T) { + created := network.EgressRule{ + ID: "10", Protocol: "tcp", StartPort: "443", EndPort: "443", Status: "Active", + } + + var gotPath, gotMethod string + var gotBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) + })) + defer srv.Close() - net, err := svc.Get(context.Background(), "zone-1", "net-99") + svc := network.NewService(newClient(srv.URL)) + rule, err := svc.CreateEgressRule(context.Background(), "my-network", network.CreateEgressRuleRequest{ + Protocol: "tcp", + StartPort: "443", + EndPort: "443", + }) if err != nil { - t.Fatalf("Get() error = %v", err) + t.Fatalf("CreateEgressRule() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/networks/my-network/egress-firewall-rules" { + t.Errorf("path = %q, want %q", gotPath, "/networks/my-network/egress-firewall-rules") + } + if rule.ID != "10" { + t.Errorf("rule.ID = %q, want %q", rule.ID, "10") } - if gotUUID != "net-99" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "net-99") + if gotBody["protocol"] != "tcp" { + t.Errorf("body[protocol] = %v, want %q", gotBody["protocol"], "tcp") + } +} + +// TestDeleteEgressRule verifies DELETE path includes slug and rule ID. +func TestDeleteEgressRule(t *testing.T) { + var gotPath, gotMethod string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := network.NewService(newClient(srv.URL)) + + err := svc.DeleteEgressRule(context.Background(), "my-network", "42") + if err != nil { + t.Fatalf("DeleteEgressRule() error = %v", err) } - if net.UUID != "net-99" { - t.Errorf("net.UUID = %q, want %q", net.UUID, "net-99") + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if net.Name != "target-network" { - t.Errorf("net.Name = %q, want %q", net.Name, "target-network") + want := fmt.Sprintf("/networks/my-network/egress-firewall-rules/%s", "42") + if gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) } } diff --git a/internal/api/plan/plan.go b/internal/api/plan/plan.go new file mode 100644 index 0000000..f66944f --- /dev/null +++ b/internal/api/plan/plan.go @@ -0,0 +1,141 @@ +// Package plan provides STKCNSL service plan listing operations. +package plan + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// ServiceType identifies a STKCNSL service for plan lookups. +type ServiceType string + +const ( + ServiceVM ServiceType = "Virtual Machine" + ServiceVirtualRouter ServiceType = "Virtual Router" + ServiceBlockStorage ServiceType = "Block Storage" + ServiceLoadBalancer ServiceType = "Load Balancer" + ServiceKubernetes ServiceType = "Kubernetes" + ServiceIPAddress ServiceType = "IP Address" + ServiceVMSnapshot ServiceType = "VM Snapshot" + ServiceMyTemplate ServiceType = "My Template" + ServiceISO ServiceType = "ISO" + ServiceBackups ServiceType = "Backups" +) + +// Attribute holds the resource attributes embedded in a plan. +// Fields are decoded as json.RawMessage because shapes differ across service +// types; the typed helpers below extract what the CLI actually needs. +type Attribute struct { + CPU json.Number `json:"cpu"` + Memory json.Number `json:"memory"` + Storage json.Number `json:"storage"` + Size json.Number `json:"size"` + MemoryUnit string `json:"memory_unit"` + StorageUnit string `json:"storage_unit"` + FormattedMemory string `json:"formatted_memory"` + FormattedStorage string `json:"formatted_storage"` + FormattedSize string `json:"formatted_size"` + FormattedCPU json.Number `json:"formatted_cpu"` + ComputeOfferingID string `json:"compute_offering_id"` + DiskOfferingID string `json:"disk_offering_id"` + NetworkRate string `json:"network_rate"` + VPCOfferingID string `json:"vpc_offering_id"` + FormattedMemoryUnit string `json:"formatted_memory_unit"` + FormattedSizeUnit string `json:"formatted_size_unit"` +} + +// Tag holds optional marketing label data. +type Tag struct { + Label string `json:"label"` + Value string `json:"value"` + Color string `json:"color"` +} + +// BillingCycle represents a billing cycle (hourly, monthly, etc.). +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Duration int `json:"duration"` + Unit string `json:"unit"` + IsEnabled bool `json:"is_enabled"` + SortOrder int `json:"sort_order"` +} + +// Currency represents a currency. +type Currency struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CurrencyName string `json:"currency_name"` +} + +// Price represents a single price entry for a plan. +type Price struct { + ID string `json:"id"` + Amount string `json:"amount"` + OTC string `json:"otc"` + Currency Currency `json:"currency"` + BillingCycle BillingCycle `json:"billing_cycle"` +} + +// Plan represents a STKCNSL service plan. +type Plan struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Attribute Attribute `json:"attribute"` + Tag json.RawMessage `json:"tag"` // can be object or empty array + Status bool `json:"status"` + IsCustom bool `json:"is_custom"` + HourlyPrice float64 `json:"hourly_price"` + MonthlyPrice float64 `json:"monthly_price"` + Prices []Price `json:"prices"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ParsedTag returns the tag label if present, 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 + } + return "-" +} + +// listResponse is the STKCNSL API envelope for plan list endpoints. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []Plan `json:"data"` +} + +// Service provides plan API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new plan Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns plans for the given service type. +func (s *Service) List(ctx context.Context, svc ServiceType) ([]Plan, error) { + path := "/plans/service/" + url.PathEscape(string(svc)) + var resp listResponse + if err := s.client.Get(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("listing %s plans: %w", svc, err) + } + return resp.Data, nil +} diff --git a/internal/api/plan/plan_test.go b/internal/api/plan/plan_test.go new file mode 100644 index 0000000..c3aab1c --- /dev/null +++ b/internal/api/plan/plan_test.go @@ -0,0 +1,199 @@ +package plan_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/plan" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestListVMPlans(t *testing.T) { + expected := map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": []map[string]interface{}{ + { + "id": "plan-1", + "name": "BP_4vC-8GB", + "slug": "bp-4vc-8gb", + "attribute": map[string]interface{}{ + "cpu": 4, + "memory": 8192, + "storage": 0, + "formatted_memory": "8.0 (GB)", + "formatted_cpu": 4, + }, + "status": true, + "is_custom": false, + "hourly_price": 9.39, + "monthly_price": 3440, + "prices": []interface{}{}, + "tag": []interface{}{}, + }, + { + "id": "plan-2", + "name": "BP_2vC-4GB", + "slug": "bp-2vc-4gb", + "attribute": map[string]interface{}{ + "cpu": 2, + "memory": 4096, + "storage": 0, + "formatted_memory": "4.0 (GB)", + "formatted_cpu": 2, + }, + "status": true, + "is_custom": false, + "hourly_price": 4.7, + "monthly_price": 1720, + "prices": []interface{}{}, + "tag": map[string]interface{}{ + "label": "Recommended", + "value": "Recommended", + "color": "red", + }, + }, + }, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(expected) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := plan.NewService(client) + plans, err := svc.List(context.Background(), plan.ServiceVM) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if gotPath != "/plans/service/Virtual Machine" { + t.Errorf("request path = %q, want /plans/service/Virtual Machine", gotPath) + } + + if len(plans) != 2 { + t.Fatalf("List() returned %d plans, want 2", len(plans)) + } + if plans[0].ID != "plan-1" { + t.Errorf("plans[0].ID = %q, want %q", plans[0].ID, "plan-1") + } + if plans[0].Name != "BP_4vC-8GB" { + t.Errorf("plans[0].Name = %q, want %q", plans[0].Name, "BP_4vC-8GB") + } + if plans[0].MonthlyPrice != 3440 { + t.Errorf("plans[0].MonthlyPrice = %v, want 3440", plans[0].MonthlyPrice) + } + if plans[0].Status != true { + t.Errorf("plans[0].Status = %v, want true", plans[0].Status) + } +} + +func TestListBlockStoragePlans(t *testing.T) { + expected := map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": []map[string]interface{}{ + { + "id": "storage-1", + "name": "50 GB", + "slug": "50-gb", + "attribute": map[string]interface{}{ + "size": 50, + "formatted_size": "50.0 (GB)", + }, + "status": true, + "is_custom": false, + "hourly_price": 1.74, + "monthly_price": 425, + "prices": []interface{}{}, + "tag": []interface{}{}, + }, + }, + } + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(expected) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := plan.NewService(client) + plans, err := svc.List(context.Background(), plan.ServiceBlockStorage) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if gotPath != "/plans/service/Block Storage" { + t.Errorf("request path = %q, want /plans/service/Block Storage", gotPath) + } + + if len(plans) != 1 { + t.Fatalf("List() returned %d plans, want 1", len(plans)) + } + if plans[0].Name != "50 GB" { + t.Errorf("plans[0].Name = %q, want %q", plans[0].Name, "50 GB") + } +} + +func TestParsedTag(t *testing.T) { + tests := []struct { + name string + tag string + want string + }{ + {"empty array", `[]`, "-"}, + {"object with label", `{"label":"Recommended","value":"Recommended","color":"red"}`, "Recommended"}, + {"empty object", `{}`, "-"}, + {"null-like", ``, "-"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := plan.Plan{Tag: json.RawMessage(tt.tag)} + if got := p.ParsedTag(); got != tt.want { + t.Errorf("ParsedTag() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"status":"Error","message":"Unauthorized"}`)) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := plan.NewService(client) + _, err := svc.List(context.Background(), plan.ServiceVM) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/portforward/portforward.go b/internal/api/portforward/portforward.go index 2fa10b8..859b7a8 100644 --- a/internal/api/portforward/portforward.go +++ b/internal/api/portforward/portforward.go @@ -4,41 +4,47 @@ package portforward import ( "context" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// PortForwardRule represents a ZCP port forwarding rule. +// PortForwardRule represents a ZCP port forwarding rule from the STKCNSL API. type PortForwardRule struct { - UUID string `json:"uuid"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - Protocol string `json:"protocol"` - PublicPort string `json:"publicPort"` - PublicEndPort string `json:"publicEndPort"` - PrivatePort string `json:"privatePort"` - PrivateEndPort string `json:"privateEndPort"` - IPAddressUUID string `json:"ipAddressUuid"` - VirtualMachineName string `json:"virtualMachineName"` - ZoneUUID string `json:"zoneUuid"` + ID string `json:"id"` + RuleID string `json:"rule_id"` + Protocol string `json:"protocol"` + PublicStartPort string `json:"public_start_port"` + PublicEndPort string `json:"public_end_port"` + PrivateStartPort string `json:"private_start_port"` + PrivateEndPort string `json:"private_end_port"` + VirtualMachine string `json:"virtual_machine"` + State string `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // CreateRequest holds parameters for creating a port forwarding rule. type CreateRequest struct { - IPAddressUUID string `json:"ipAddressUuid"` - Protocol string `json:"protocol"` - PublicPort string `json:"publicPort"` - PublicEndPort string `json:"publicEndPort,omitempty"` - PrivatePort string `json:"privatePort"` - PrivateEndPort string `json:"privateEndPort,omitempty"` - VirtualMachineUUID string `json:"virtualmachineUuid"` - NetworkUUID string `json:"networkUuid,omitempty"` + Protocol string `json:"protocol"` + PublicStartPort string `json:"public_start_port"` + PublicEndPort string `json:"public_end_port,omitempty"` + PrivateStartPort string `json:"private_start_port"` + PrivateEndPort string `json:"private_end_port,omitempty"` + VirtualMachine string `json:"virtual_machine"` } -type listPortForwardingResponse struct { - Count int `json:"count"` - ListPortForwardingResponse []PortForwardRule `json:"listPortForwardingResponse"` +// listResponse is the STKCNSL envelope for port forwarding rule lists. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []PortForwardRule `json:"data"` +} + +// singleResponse is the STKCNSL envelope for a single port forwarding rule response. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data PortForwardRule `json:"data"` } // Service provides port forwarding rule API operations. @@ -51,41 +57,31 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns port forwarding rules. zoneUUID is required; uuid, vmUUID, and ipAddressUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, uuid, vmUUID, ipAddressUUID string) ([]PortForwardRule, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if vmUUID != "" { - q.Set("vmUuid", vmUUID) +// List returns port forwarding rules for a public IP address. +// ipSlug is the IP address slug (e.g. "1036521143"). +func (s *Service) List(ctx context.Context, ipSlug string) ([]PortForwardRule, error) { + var resp listResponse + if err := s.client.Get(ctx, "/ipaddresses/"+ipSlug+"/port-forwarding-rules", nil, &resp); err != nil { + return nil, fmt.Errorf("listing port forwarding rules for IP %s: %w", ipSlug, err) } - if ipAddressUUID != "" { - q.Set("ipAddressUuid", ipAddressUUID) - } - var resp listPortForwardingResponse - if err := s.client.Get(ctx, "/restapi/portforwardingrule/portForwardingRuleList", q, &resp); err != nil { - return nil, fmt.Errorf("listing port forwarding rules: %w", err) - } - return resp.ListPortForwardingResponse, nil + return resp.Data, nil } -// Create adds a new port forwarding rule. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*PortForwardRule, error) { - var resp listPortForwardingResponse - if err := s.client.Post(ctx, "/restapi/portforwardingrule/createPortForwardingRule", req, &resp); err != nil { - return nil, fmt.Errorf("creating port forwarding rule: %w", err) - } - if len(resp.ListPortForwardingResponse) == 0 { - return nil, fmt.Errorf("create port forwarding rule returned empty response") +// Create adds a new port forwarding rule on a public IP address. +// ipSlug is the IP address slug. +func (s *Service) Create(ctx context.Context, ipSlug string, req CreateRequest) (*PortForwardRule, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/ipaddresses/"+ipSlug+"/port-forwarding-rules", req, &resp); err != nil { + return nil, fmt.Errorf("creating port forwarding rule for IP %s: %w", ipSlug, err) } - return &resp.ListPortForwardingResponse[0], nil + return &resp.Data, nil } -// Delete removes a port forwarding rule by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/portforwardingrule/deletePortForwardingRule/"+uuid, nil); err != nil { - return fmt.Errorf("deleting port forwarding rule %s: %w", uuid, err) +// Delete removes a port forwarding rule by ID from a public IP address. +// ipSlug is the IP address slug; ruleID is the port forwarding rule ID. +func (s *Service) Delete(ctx context.Context, ipSlug, ruleID string) error { + if err := s.client.Delete(ctx, "/ipaddresses/"+ipSlug+"/port-forwarding-rules/"+ruleID, nil); err != nil { + return fmt.Errorf("deleting port forwarding rule %s for IP %s: %w", ruleID, ipSlug, err) } return nil } diff --git a/internal/api/portforward/portforward_test.go b/internal/api/portforward/portforward_test.go index fc8c611..901629c 100644 --- a/internal/api/portforward/portforward_test.go +++ b/internal/api/portforward/portforward_test.go @@ -14,132 +14,75 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listPortForwardingResponse struct { - Count int `json:"count"` - ListPortForwardingResponse []portforward.PortForwardRule `json:"listPortForwardingResponse"` -} - -func TestPortForwardList(t *testing.T) { - expected := []portforward.PortForwardRule{ - {UUID: "pf-1", Protocol: "tcp", PublicPort: "2222", PrivatePort: "22", ZoneUUID: "zone-1"}, - {UUID: "pf-2", Protocol: "tcp", PublicPort: "8080", PrivatePort: "80", ZoneUUID: "zone-1"}, - } - - var gotZone string +func TestList(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/portforwardingrule/portForwardingRuleList" { - http.NotFound(w, r) - return - } - gotZone = r.URL.Query().Get("zoneUuid") - if gotZone == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return + if r.URL.Path != "/ipaddresses/1.2.3.4/port-forwarding-rules" { + t.Errorf("path = %q", r.URL.Path) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listPortForwardingResponse{Count: len(expected), ListPortForwardingResponse: expected}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": []map[string]interface{}{ + {"id": "pf-1", "protocol": "tcp", "public_start_port": "8080", "private_start_port": "80"}, + }, + }) })) defer srv.Close() svc := portforward.NewService(newClient(srv.URL)) - rules, err := svc.List(context.Background(), "zone-1", "", "", "") + rules, err := svc.List(context.Background(), "1.2.3.4") if err != nil { t.Fatalf("List() error = %v", err) } - if len(rules) != 2 { - t.Fatalf("List() returned %d rules, want 2", len(rules)) + if len(rules) != 1 { + t.Fatalf("got %d rules, want 1", len(rules)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if rules[0].UUID != "pf-1" { - t.Errorf("rules[0].UUID = %q, want %q", rules[0].UUID, "pf-1") + if rules[0].ID != "pf-1" { + t.Errorf("ID = %q, want %q", rules[0].ID, "pf-1") } } -func TestPortForwardCreate(t *testing.T) { - created := portforward.PortForwardRule{ - UUID: "pf-new", - Protocol: "tcp", - PublicPort: "2222", - PrivatePort: "22", - IPAddressUUID: "ip-1", - ZoneUUID: "zone-1", - } - - var gotBody map[string]interface{} +func TestCreate(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/portforwardingrule/createPortForwardingRule" { - http.NotFound(w, r) - return + t.Errorf("method = %q", r.Method) } - json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listPortForwardingResponse{Count: 1, ListPortForwardingResponse: []portforward.PortForwardRule{created}}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": map[string]interface{}{"id": "pf-new", "protocol": "tcp"}, + }) })) defer srv.Close() svc := portforward.NewService(newClient(srv.URL)) - req := portforward.CreateRequest{ - IPAddressUUID: "ip-1", - Protocol: "tcp", - PublicPort: "2222", - PrivatePort: "22", - VirtualMachineUUID: "vm-1", - } - rule, err := svc.Create(context.Background(), req) + rule, err := svc.Create(context.Background(), "1.2.3.4", portforward.CreateRequest{Protocol: "tcp"}) if err != nil { t.Fatalf("Create() error = %v", err) } - if rule.UUID != "pf-new" { - t.Errorf("rule.UUID = %q, want %q", rule.UUID, "pf-new") - } - if gotBody["ipAddressUuid"] != "ip-1" { - t.Errorf("body ipAddressUuid = %v, want %q", gotBody["ipAddressUuid"], "ip-1") - } - if gotBody["protocol"] != "tcp" { - t.Errorf("body protocol = %v, want %q", gotBody["protocol"], "tcp") - } - if gotBody["publicPort"] != "2222" { - t.Errorf("body publicPort = %v, want %q", gotBody["publicPort"], "2222") - } - if gotBody["privatePort"] != "22" { - t.Errorf("body privatePort = %v, want %q", gotBody["privatePort"], "22") - } - if gotBody["virtualmachineUuid"] != "vm-1" { - t.Errorf("body virtualmachineUuid = %v, want %q", gotBody["virtualmachineUuid"], "vm-1") + if rule.ID != "pf-new" { + t.Errorf("ID = %q, want %q", rule.ID, "pf-new") } } -func TestPortForwardDelete(t *testing.T) { - var gotPath, gotMethod string +func TestDelete(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path + if r.Method != http.MethodDelete { + t.Errorf("method = %q", r.Method) + } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() svc := portforward.NewService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "pf-del-1") + err := svc.Delete(context.Background(), "1.2.3.4", "pf-1") if err != nil { t.Fatalf("Delete() error = %v", err) } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) - } - if gotPath != "/restapi/portforwardingrule/deletePortForwardingRule/pf-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/portforwardingrule/deletePortForwardingRule/pf-del-1") - } } diff --git a/internal/api/product/product.go b/internal/api/product/product.go new file mode 100644 index 0000000..f21d62d --- /dev/null +++ b/internal/api/product/product.go @@ -0,0 +1,98 @@ +// Package product provides ZCP product and product category API operations. +package product + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Category represents a product category. +type Category struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status bool `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Product represents a product in the store. +type Product struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status bool `json:"status"` + Price float64 `json:"price"` + ProductCategoryID string `json:"product_category_id"` + ProductCategory *Category `json:"product_category,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// listCategoriesResponse wraps the paginated product categories response. +type listCategoriesResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Category `json:"data"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// listProductsResponse wraps the paginated products response. +type listProductsResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Product `json:"data"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// Service provides product API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new product Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// ListCategories returns all product categories. +func (s *Service) ListCategories(ctx context.Context) ([]Category, error) { + var resp listCategoriesResponse + if err := s.client.Get(ctx, "/list-products-categories", nil, &resp); err != nil { + return nil, fmt.Errorf("listing product categories: %w", err) + } + return resp.Data, nil +} + +// ListAll returns all products with optional filters. +func (s *Service) ListAll(ctx context.Context, cardType, cardSlug, include string) ([]Product, error) { + q := url.Values{} + if cardType != "" { + q.Set("card_type", cardType) + } + if cardSlug != "" { + q.Set("card_slug", cardSlug) + } + if include != "" { + q.Set("include", include) + } + + var resp listProductsResponse + if err := s.client.Get(ctx, "/list-all-products", q, &resp); err != nil { + return nil, fmt.Errorf("listing products: %w", err) + } + return resp.Data, nil +} diff --git a/internal/api/project/project.go b/internal/api/project/project.go new file mode 100644 index 0000000..b261ad6 --- /dev/null +++ b/internal/api/project/project.go @@ -0,0 +1,198 @@ +// Package project provides ZCP Project API operations (STKCNSL). +package project + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// envelope is the standard STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` +} + +// Project represents a ZCP project. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + IconID string `json:"icon_id"` + Purpose string `json:"purpose"` + Status interface{} `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreateRequest holds parameters for creating a project. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Purpose string `json:"purpose,omitempty"` + Status int `json:"status"` +} + +// UpdateRequest holds parameters for updating a project. +type UpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + IconID string `json:"icon_id,omitempty"` + Purpose string `json:"purpose,omitempty"` +} + +// Icon represents a project icon. +type Icon struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} + +// User represents a user assigned to a project. +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` +} + +// AddUserRequest holds parameters for adding a user to a project. +type AddUserRequest struct { + Email string `json:"email"` + Role string `json:"role,omitempty"` +} + +// DashboardService represents a service entry on the project dashboard. +type DashboardService struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Count int `json:"count"` +} + +// Service provides Project API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new Project Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// decode unwraps the STKCNSL envelope and unmarshals data into dst. +func decode(raw json.RawMessage, dst interface{}) error { + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + // Not wrapped — try direct decode. + return json.Unmarshal(raw, dst) + } + if env.Status != "" && env.Data != nil { + return json.Unmarshal(env.Data, dst) + } + // Fallback: raw is the data itself. + return json.Unmarshal(raw, dst) +} + +// List returns all projects. +func (s *Service) List(ctx context.Context) ([]Project, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/projects", nil, &raw); err != nil { + return nil, fmt.Errorf("listing projects: %w", err) + } + var projects []Project + if err := decode(raw, &projects); err != nil { + return nil, fmt.Errorf("decoding projects: %w", err) + } + return projects, nil +} + +// Create provisions a new project. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*Project, error) { + var raw json.RawMessage + if err := s.client.Post(ctx, "/projects", req, &raw); err != nil { + return nil, fmt.Errorf("creating project: %w", err) + } + var p Project + if err := decode(raw, &p); err != nil { + return nil, fmt.Errorf("decoding project: %w", err) + } + return &p, nil +} + +// Update modifies an existing project identified by slug. +func (s *Service) Update(ctx context.Context, slug string, req UpdateRequest) (*Project, error) { + var raw json.RawMessage + if err := s.client.Put(ctx, "/projects/"+slug, nil, req, &raw); err != nil { + return nil, fmt.Errorf("updating project %s: %w", slug, err) + } + var p Project + if err := decode(raw, &p); err != nil { + return nil, fmt.Errorf("decoding project: %w", err) + } + return &p, nil +} + +// Dashboard returns services for a project's dashboard. +func (s *Service) Dashboard(ctx context.Context, slug string) ([]DashboardService, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/projects/dashboard/"+slug+"/services", nil, &raw); err != nil { + return nil, fmt.Errorf("getting project dashboard %s: %w", slug, err) + } + var services []DashboardService + if err := decode(raw, &services); err != nil { + return nil, fmt.Errorf("decoding dashboard services: %w", err) + } + return services, nil +} + +// ListIcons returns all available project icons. +func (s *Service) ListIcons(ctx context.Context) ([]Icon, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/project-icons", nil, &raw); err != nil { + return nil, fmt.Errorf("listing project icons: %w", err) + } + var icons []Icon + if err := decode(raw, &icons); err != nil { + return nil, fmt.Errorf("decoding project icons: %w", err) + } + return icons, nil +} + +// ListUsers returns users assigned to a project. +func (s *Service) ListUsers(ctx context.Context, slug string) ([]User, error) { + var raw json.RawMessage + if err := s.client.Get(ctx, "/projects/"+slug+"/users", nil, &raw); err != nil { + return nil, fmt.Errorf("listing users for project %s: %w", slug, err) + } + var users []User + if err := decode(raw, &users); err != nil { + return nil, fmt.Errorf("decoding project users: %w", err) + } + return users, nil +} + +// AddUser adds a user to a project by slug. +func (s *Service) AddUser(ctx context.Context, slug string, req AddUserRequest) (*User, error) { + var raw json.RawMessage + if err := s.client.Post(ctx, "/projects/"+slug+"/users", req, &raw); err != nil { + return nil, fmt.Errorf("adding user to project %s: %w", slug, err) + } + var u User + if err := decode(raw, &u); err != nil { + return nil, fmt.Errorf("decoding project user: %w", err) + } + return &u, nil +} + +// Delete removes a project by slug. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/projects/"+slug, nil); err != nil { + return fmt.Errorf("deleting project %s: %w", slug, err) + } + return nil +} diff --git a/internal/api/project/project_test.go b/internal/api/project/project_test.go new file mode 100644 index 0000000..1aa471f --- /dev/null +++ b/internal/api/project/project_test.go @@ -0,0 +1,277 @@ +package project_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/project" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +// wrap produces a STKCNSL envelope response. +func wrap(data interface{}) map[string]interface{} { + return map[string]interface{}{ + "status": "Success", + "data": data, + } +} + +func TestProjectList(t *testing.T) { + expected := []project.Project{ + {ID: "1", Name: "Alpha", Slug: "alpha", Description: "First project"}, + {ID: "2", Name: "Beta", Slug: "beta", Description: "Second project"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "expected GET", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(expected)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + projects, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(projects) != 2 { + t.Fatalf("List() returned %d projects, want 2", len(projects)) + } + if projects[0].Slug != "alpha" { + t.Errorf("projects[0].Slug = %q, want %q", projects[0].Slug, "alpha") + } +} + +func TestProjectCreate(t *testing.T) { + created := project.Project{ + ID: "3", + Name: "Gamma", + Slug: "gamma", + Description: "New project", + } + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/projects" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(created)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + result, err := svc.Create(context.Background(), project.CreateRequest{ + Name: "Gamma", + Description: "New project", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.Slug != "gamma" { + t.Errorf("result.Slug = %q, want %q", result.Slug, "gamma") + } + if gotBody["name"] != "Gamma" { + t.Errorf("body name = %v, want %q", gotBody["name"], "Gamma") + } +} + +func TestProjectUpdate(t *testing.T) { + updated := project.Project{ + ID: "1", + Name: "Alpha Renamed", + Slug: "alpha", + Description: "Updated description", + } + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "expected PUT", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/projects/alpha" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(updated)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + result, err := svc.Update(context.Background(), "alpha", project.UpdateRequest{ + Name: "Alpha Renamed", + Description: "Updated description", + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if result.Name != "Alpha Renamed" { + t.Errorf("result.Name = %q, want %q", result.Name, "Alpha Renamed") + } +} + +func TestProjectDashboard(t *testing.T) { + expected := []project.DashboardService{ + {Name: "web-app", Type: "compute", Status: "Running", Count: 3}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/dashboard/alpha/services" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(expected)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + services, err := svc.Dashboard(context.Background(), "alpha") + if err != nil { + t.Fatalf("Dashboard() error = %v", err) + } + if len(services) != 1 { + t.Fatalf("Dashboard() returned %d services, want 1", len(services)) + } + if services[0].Name != "web-app" { + t.Errorf("services[0].Name = %q, want %q", services[0].Name, "web-app") + } +} + +func TestProjectListIcons(t *testing.T) { + expected := []project.Icon{ + {ID: "1", Name: "server", URL: "https://icons.example.com/server.svg"}, + {ID: "2", Name: "database", URL: "https://icons.example.com/database.svg"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/project-icons" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(expected)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + icons, err := svc.ListIcons(context.Background()) + if err != nil { + t.Fatalf("ListIcons() error = %v", err) + } + if len(icons) != 2 { + t.Fatalf("ListIcons() returned %d icons, want 2", len(icons)) + } + if icons[0].Name != "server" { + t.Errorf("icons[0].Name = %q, want %q", icons[0].Name, "server") + } +} + +func TestProjectListUsers(t *testing.T) { + expected := []project.User{ + {ID: "10", Name: "Alice", Email: "alice@example.com", Role: "admin"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/alpha/users" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(expected)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + users, err := svc.ListUsers(context.Background(), "alpha") + if err != nil { + t.Fatalf("ListUsers() error = %v", err) + } + if len(users) != 1 { + t.Fatalf("ListUsers() returned %d users, want 1", len(users)) + } + if users[0].Email != "alice@example.com" { + t.Errorf("users[0].Email = %q, want %q", users[0].Email, "alice@example.com") + } +} + +func TestProjectAddUser(t *testing.T) { + added := project.User{ + ID: "11", + Name: "Bob", + Email: "bob@example.com", + Role: "member", + } + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/projects/alpha/users" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wrap(added)) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + result, err := svc.AddUser(context.Background(), "alpha", project.AddUserRequest{ + Email: "bob@example.com", + Role: "member", + }) + if err != nil { + t.Fatalf("AddUser() error = %v", err) + } + if result.Email != "bob@example.com" { + t.Errorf("result.Email = %q, want %q", result.Email, "bob@example.com") + } + if gotBody["email"] != "bob@example.com" { + t.Errorf("body email = %v, want %q", gotBody["email"], "bob@example.com") + } +} + +func TestProjectListError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + })) + defer srv.Close() + + svc := project.NewService(newClient(srv.URL)) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("List() expected error on 500, got nil") + } +} diff --git a/internal/api/quota/quota_test.go b/internal/api/quota/quota_test.go index e5cb327..6db2eb9 100644 --- a/internal/api/quota/quota_test.go +++ b/internal/api/quota/quota_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/region/region.go b/internal/api/region/region.go new file mode 100644 index 0000000..de20ee5 --- /dev/null +++ b/internal/api/region/region.go @@ -0,0 +1,89 @@ +// Package region provides ZCP region API operations (STKCNSL). +package region + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// CloudProvider is the embedded cloud provider within a region. +type CloudProvider struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` + Description string `json:"description"` + IsMultiRegionSetup bool `json:"is_multi_region_setup"` + Status bool `json:"status"` + Services []string `json:"services"` +} + +// CloudProviderSetup is the setup configuration nested in a region. +type CloudProviderSetup struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CloudProviderID string `json:"cloud_provider_id"` + CredentialOf string `json:"credential_of"` + Version string `json:"version"` + Monitoring string `json:"monitoring"` + Timezone string `json:"timezone"` + Status bool `json:"status"` +} + +// Region represents a STKCNSL region. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CloudProvider *CloudProvider `json:"cloud_provider"` + CloudProviderSetup *CloudProviderSetup `json:"cloud_provider_setup"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + ContinentCode string `json:"continent_code"` + ContinentName string `json:"continent_name"` + Description string `json:"description"` + Status bool `json:"status"` + CPUSpeed int `json:"cpu_speed"` + IsComingSoon bool `json:"is_coming_soon"` + ConsoleProxyIPAddress string `json:"console_proxy_ip_address"` + ConsoleProxyDomain string `json:"console_proxy_domain"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides region API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new region Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all regions. +func (s *Service) List(ctx context.Context) ([]Region, error) { + var env envelope + if err := s.client.Get(ctx, "/regions", nil, &env); err != nil { + return nil, fmt.Errorf("listing regions: %w", err) + } + + var regions []Region + if err := json.Unmarshal(env.Data, ®ions); err != nil { + return nil, fmt.Errorf("decoding regions: %w", err) + } + + return regions, nil +} diff --git a/internal/api/region/region_test.go b/internal/api/region/region_test.go new file mode 100644 index 0000000..626df67 --- /dev/null +++ b/internal/api/region/region_test.go @@ -0,0 +1,107 @@ +package region_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/region" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestRegionList(t *testing.T) { + expected := []region.Region{ + { + ID: "region-1", + Name: "NOIDA", + Slug: "noida", + Country: "India", + Status: true, + CloudProvider: ®ion.CloudProvider{ + ID: "cp-1", + Name: "nimbo", + DisplayName: "Webberstop Cloud", + Slug: "nimbo", + }, + }, + { + ID: "region-2", + Name: "Mumbai", + Slug: "mumbai", + Country: "India", + Status: true, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/regions" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := region.NewService(client) + regions, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(regions) != 2 { + t.Fatalf("List() returned %d regions, want 2", len(regions)) + } + if regions[0].ID != "region-1" { + t.Errorf("regions[0].ID = %q, want %q", regions[0].ID, "region-1") + } + if regions[0].Name != "NOIDA" { + t.Errorf("regions[0].Name = %q, want %q", regions[0].Name, "NOIDA") + } + if regions[0].CloudProvider == nil { + t.Fatal("regions[0].CloudProvider is nil, want non-nil") + } + if regions[0].CloudProvider.DisplayName != "Webberstop Cloud" { + t.Errorf("regions[0].CloudProvider.DisplayName = %q, want %q", + regions[0].CloudProvider.DisplayName, "Webberstop Cloud") + } + if regions[1].Slug != "mumbai" { + t.Errorf("regions[1].Slug = %q, want %q", regions[1].Slug, "mumbai") + } +} + +func TestRegionListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := region.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/response/response.go b/internal/api/response/response.go new file mode 100644 index 0000000..d5908a5 --- /dev/null +++ b/internal/api/response/response.go @@ -0,0 +1,28 @@ +// Package response defines common STKCNSL API response envelope types. +package response + +// Envelope is the standard STKCNSL API response wrapper for list endpoints. +type Envelope[T any] struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + CurrentPage int `json:"current_page"` + Data []T `json:"data"` + FirstPageURL *string `json:"first_page_url"` + From *int `json:"from"` + LastPage int `json:"last_page"` + LastPageURL *string `json:"last_page_url"` + NextPageURL *string `json:"next_page_url"` + Path string `json:"path"` + PerPage int `json:"per_page"` + PrevPageURL *string `json:"prev_page_url"` + To *int `json:"to"` + Total int `json:"total"` +} + +// Single is for endpoints that return a single object in data. +type Single[T any] struct { + Status string `json:"status"` + Message string `json:"message"` + Data T `json:"data"` +} diff --git a/internal/api/securitygroup/securitygroup_test.go b/internal/api/securitygroup/securitygroup_test.go index 8b0aa11..8ee5986 100644 --- a/internal/api/securitygroup/securitygroup_test.go +++ b/internal/api/securitygroup/securitygroup_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/server/server.go b/internal/api/server/server.go new file mode 100644 index 0000000..b24ecc5 --- /dev/null +++ b/internal/api/server/server.go @@ -0,0 +1,54 @@ +// Package server provides ZCP server API operations (STKCNSL). +package server + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Server represents a STKCNSL server (e.g. "Cloud Compute"). +type Server struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status bool `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Icon string `json:"icon"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides server API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new server Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all servers. +func (s *Service) List(ctx context.Context) ([]Server, error) { + var env envelope + if err := s.client.Get(ctx, "/servers", nil, &env); err != nil { + return nil, fmt.Errorf("listing servers: %w", err) + } + + var servers []Server + if err := json.Unmarshal(env.Data, &servers); err != nil { + return nil, fmt.Errorf("decoding servers: %w", err) + } + + return servers, nil +} diff --git a/internal/api/server/server_test.go b/internal/api/server/server_test.go new file mode 100644 index 0000000..6256bbf --- /dev/null +++ b/internal/api/server/server_test.go @@ -0,0 +1,78 @@ +package server_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/server" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestServerList(t *testing.T) { + expected := []server.Server{ + {ID: "srv-1", Name: "Cloud Compute", Slug: "cloud-compute", Status: true}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/servers" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := server.NewService(client) + servers, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(servers) != 1 { + t.Fatalf("List() returned %d servers, want 1", len(servers)) + } + if servers[0].Name != "Cloud Compute" { + t.Errorf("servers[0].Name = %q, want %q", servers[0].Name, "Cloud Compute") + } + if servers[0].Slug != "cloud-compute" { + t.Errorf("servers[0].Slug = %q, want %q", servers[0].Slug, "cloud-compute") + } +} + +func TestServerListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := server.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/snapshot/snapshot.go b/internal/api/snapshot/snapshot.go index 2c796ed..6eed844 100644 --- a/internal/api/snapshot/snapshot.go +++ b/internal/api/snapshot/snapshot.go @@ -1,4 +1,5 @@ -// Package snapshot provides ZCP volume snapshot API operations. +// Package snapshot provides ZCP block storage snapshot API operations +// targeting the STKCNSL API. package snapshot import ( @@ -9,32 +10,61 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Snapshot represents a ZCP volume snapshot. +// Snapshot represents a STKCNSL block storage snapshot. type Snapshot struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - VolumeUUID string `json:"volumeUuid"` - SnapshotType string `json:"snapshotType"` - DomainName string `json:"domainName"` - ZoneUUID string `json:"zoneUuid"` - SnapshotTime string `json:"snapshotTime"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + BlockstorageID string `json:"blockstorage_id"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + HasContract bool `json:"has_contract"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` } -// CreateRequest holds parameters for creating a snapshot. -type CreateRequest struct { - Name string `json:"name"` - VolumeUUID string `json:"volumeUuid"` - ZoneUUID string `json:"zoneUuid"` +// listResponse is the STKCNSL paginated envelope for block storage snapshots. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Snapshot `json:"data"` + Total int `json:"total"` +} + +// singleResponse is used when the API returns a single snapshot in `data`. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Snapshot `json:"data"` } -type listSnapshotResponse struct { - Count int `json:"count"` - ListSnapShotResponse []Snapshot `json:"listSnapShotResponse"` +// CreateRequest holds parameters for creating a block storage snapshot. +type CreateRequest struct { + Name string `json:"name"` + Plan string `json:"plan"` + Service string `json:"service"` + IsMemory bool `json:"is_memory"` + Project string `json:"project"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + BillingCycle string `json:"billing_cycle"` + Coupon string `json:"coupon,omitempty"` } -// Service provides snapshot API operations. +// Service provides block storage snapshot API operations. type Service struct { client *httpclient.Client } @@ -44,38 +74,34 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns snapshots. zoneUUID and snapshotUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, snapshotUUID string) ([]Snapshot, error) { - q := url.Values{} - if zoneUUID != "" { - q.Set("zoneUuid", zoneUUID) - } - if snapshotUUID != "" { - q.Set("uuid", snapshotUUID) +// List returns block storage snapshots. +func (s *Service) List(ctx context.Context) ([]Snapshot, error) { + q := url.Values{ + "include": {"blockstorage,region,cloud_provider,project"}, } - var resp listSnapshotResponse - if err := s.client.Get(ctx, "/restapi/snapshot/snapshotList", q, &resp); err != nil { - return nil, fmt.Errorf("listing snapshots: %w", err) + var resp listResponse + if err := s.client.Get(ctx, "/blockstorages/snapshots", q, &resp); err != nil { + return nil, fmt.Errorf("listing block storage snapshots: %w", err) } - return resp.ListSnapShotResponse, nil + return resp.Data, nil } -// Create creates a new snapshot of the given volume. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*Snapshot, error) { - var resp listSnapshotResponse - if err := s.client.Post(ctx, "/restapi/snapshot/createSnapshot", req, &resp); err != nil { - return nil, fmt.Errorf("creating snapshot: %w", err) - } - if len(resp.ListSnapShotResponse) == 0 { - return nil, fmt.Errorf("create snapshot returned empty response") +// Create creates a new block storage snapshot. +func (s *Service) Create(ctx context.Context, blockstorageSlug string, req CreateRequest) (*Snapshot, error) { + var resp singleResponse + path := fmt.Sprintf("/blockstorages/%s/snapshots", blockstorageSlug) + if err := s.client.Post(ctx, path, req, &resp); err != nil { + return nil, fmt.Errorf("creating block storage snapshot: %w", err) } - return &resp.ListSnapShotResponse[0], nil + return &resp.Data, nil } -// Delete permanently removes a snapshot. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/snapshot/deleteSnapshot/"+uuid, nil); err != nil { - return fmt.Errorf("deleting snapshot %s: %w", uuid, err) +// Revert reverts a block storage volume to a snapshot state. +func (s *Service) Revert(ctx context.Context, blockstorageSlug, snapshotSlug string) (*Snapshot, error) { + var resp singleResponse + path := fmt.Sprintf("/blockstorages/%s/snapshots/%s/revert", blockstorageSlug, snapshotSlug) + if err := s.client.Post(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("reverting block storage snapshot %s: %w", snapshotSlug, err) } - return nil + return &resp.Data, nil } diff --git a/internal/api/snapshot/snapshot_test.go b/internal/api/snapshot/snapshot_test.go index 749c641..8b2b391 100644 --- a/internal/api/snapshot/snapshot_test.go +++ b/internal/api/snapshot/snapshot_test.go @@ -12,139 +12,152 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -type listSnapshotResponse struct { - Count int `json:"count"` - ListSnapShotResponse []snapshot.Snapshot `json:"listSnapShotResponse"` +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []snapshot.Snapshot `json:"data"` + Total int `json:"total"` +} + +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data snapshot.Snapshot `json:"data"` } func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { t.Helper() return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } func TestSnapshotList(t *testing.T) { expected := []snapshot.Snapshot{ - {UUID: "snap-1", Name: "snap-a", Status: "BackedUp", VolumeUUID: "vol-1", ZoneUUID: "zone-1"}, - {UUID: "snap-2", Name: "snap-b", Status: "BackedUp", VolumeUUID: "vol-2", ZoneUUID: "zone-1"}, + {ID: "snap-1", Name: "snap-a", Slug: "snap-a", BlockstorageID: "vol-1"}, + {ID: "snap-2", Name: "snap-b", Slug: "snap-b", BlockstorageID: "vol-2"}, } - var gotZone string + var gotInclude string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/snapshot/snapshotList" { + if r.URL.Path != "/blockstorages/snapshots" { http.NotFound(w, r) return } - gotZone = r.URL.Query().Get("zoneUuid") + gotInclude = r.URL.Query().Get("include") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listSnapshotResponse{Count: len(expected), ListSnapShotResponse: expected}) + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + Message: "Ok", + Data: expected, + Total: len(expected), + }) })) defer srv.Close() svc := snapshot.NewService(newTestClient(t, srv)) - snapshots, err := svc.List(context.Background(), "zone-1", "") + snapshots, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(snapshots) != 2 { t.Fatalf("List() returned %d snapshots, want 2", len(snapshots)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") + if gotInclude == "" { + t.Error("include query param was empty, expected relations") } - if snapshots[0].UUID != "snap-1" { - t.Errorf("snapshots[0].UUID = %q, want %q", snapshots[0].UUID, "snap-1") - } -} - -func TestSnapshotListWithUUIDFilter(t *testing.T) { - var gotUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listSnapshotResponse{Count: 0, ListSnapShotResponse: nil}) - })) - defer srv.Close() - - svc := snapshot.NewService(newTestClient(t, srv)) - svc.List(context.Background(), "", "snap-xyz") - - if gotUUID != "snap-xyz" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "snap-xyz") + if snapshots[0].ID != "snap-1" { + t.Errorf("snapshots[0].ID = %q, want %q", snapshots[0].ID, "snap-1") } } func TestSnapshotCreate(t *testing.T) { expectedSnap := snapshot.Snapshot{ - UUID: "snap-new", - Name: "my-snap", - Status: "BackingUp", - VolumeUUID: "vol-1", - ZoneUUID: "zone-1", + ID: "snap-new", + Name: "my-snap", + Slug: "my-snap", + BlockstorageID: "vol-1", } + var gotPath string var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "want POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/snapshot/createSnapshot" { - http.NotFound(w, r) - return - } + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listSnapshotResponse{Count: 1, ListSnapShotResponse: []snapshot.Snapshot{expectedSnap}}) + json.NewEncoder(w).Encode(singleResponse{ + Status: "Success", + Message: "Ok", + Data: expectedSnap, + }) })) defer srv.Close() svc := snapshot.NewService(newTestClient(t, srv)) req := snapshot.CreateRequest{ - Name: "my-snap", - VolumeUUID: "vol-1", - ZoneUUID: "zone-1", - } - snap, err := svc.Create(context.Background(), req) + Name: "my-snap", + Plan: "snapshot-per-gb", + Service: "Block Storage Snapshot", + CloudProvider: "nimbo", + Region: "noida", + BillingCycle: "hourly", + Project: "default-73", + } + snap, err := svc.Create(context.Background(), "root-4153", req) if err != nil { t.Fatalf("Create() error = %v", err) } - if snap.UUID != "snap-new" { - t.Errorf("snap.UUID = %q, want %q", snap.UUID, "snap-new") + if snap.ID != "snap-new" { + t.Errorf("snap.ID = %q, want %q", snap.ID, "snap-new") + } + if gotPath != "/blockstorages/root-4153/snapshots" { + t.Errorf("path = %q, want %q", gotPath, "/blockstorages/root-4153/snapshots") } if gotBody["name"] != "my-snap" { t.Errorf("body name = %v, want %q", gotBody["name"], "my-snap") } - if gotBody["volumeUuid"] != "vol-1" { - t.Errorf("body volumeUuid = %v, want %q", gotBody["volumeUuid"], "vol-1") - } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body zoneUuid = %v, want %q", gotBody["zoneUuid"], "zone-1") - } } -func TestSnapshotDelete(t *testing.T) { +func TestSnapshotRevert(t *testing.T) { + expectedSnap := snapshot.Snapshot{ + ID: "snap-1", + Name: "snap-a", + Slug: "snap-a", + BlockstorageID: "vol-1", + } + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{ + Status: "Success", + Message: "Ok", + Data: expectedSnap, + }) })) defer srv.Close() svc := snapshot.NewService(newTestClient(t, srv)) - err := svc.Delete(context.Background(), "snap-abc") + snap, err := svc.Revert(context.Background(), "root-4153", "snap-a") if err != nil { - t.Fatalf("Delete() error = %v", err) + t.Fatalf("Revert() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + if gotPath != "/blockstorages/root-4153/snapshots/snap-a/revert" { + t.Errorf("path = %q, want %q", gotPath, "/blockstorages/root-4153/snapshots/snap-a/revert") } - if gotPath != "/restapi/snapshot/deleteSnapshot/snap-abc" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/snapshot/deleteSnapshot/snap-abc") + if snap.ID != "snap-1" { + t.Errorf("snap.ID = %q, want %q", snap.ID, "snap-1") } } diff --git a/internal/api/snapshotpolicy/snapshotpolicy_test.go b/internal/api/snapshotpolicy/snapshotpolicy_test.go index e6ae369..0b1e0d5 100644 --- a/internal/api/snapshotpolicy/snapshotpolicy_test.go +++ b/internal/api/snapshotpolicy/snapshotpolicy_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/sshkey/sshkey.go b/internal/api/sshkey/sshkey.go index 02be846..da1d9c0 100644 --- a/internal/api/sshkey/sshkey.go +++ b/internal/api/sshkey/sshkey.go @@ -1,32 +1,35 @@ -// Package sshkey provides ZCP SSH key API operations. +// Package sshkey provides STKCNSL SSH key API operations. package sshkey import ( "context" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// SSHKey represents a ZCP SSH key. +// SSHKey represents a STKCNSL SSH key. type SSHKey struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - DomainName string `json:"domainName"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + PublicKey string `json:"public_key"` + User *Owner `json:"user,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } -// CreateRequest holds parameters for creating/importing an SSH key. -type CreateRequest struct { - Name string `json:"name"` - PublicKey string `json:"publicKey"` +// Owner holds the user who owns the SSH key. +type Owner struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` } -type listSSHKeyResponse struct { - Count int `json:"count"` - ListSSHKeyResponse []SSHKey `json:"listSSHKeyResponse"` +// CreateRequest holds parameters for creating an SSH key. +type CreateRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` } // Service provides SSH key API operations. @@ -37,31 +40,28 @@ type Service struct { // NewService creates a new SSH key Service. func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns all SSH keys for the authenticated domain. +// List returns all SSH keys for the authenticated user. func (s *Service) List(ctx context.Context) ([]SSHKey, error) { - var resp listSSHKeyResponse - if err := s.client.Get(ctx, "/restapi/sshkey/sshkeyList", url.Values{}, &resp); err != nil { + var keys []SSHKey + if err := s.client.GetEnvelope(ctx, "/users/ssh-keys", nil, &keys); err != nil { return nil, fmt.Errorf("listing SSH keys: %w", err) } - return resp.ListSSHKeyResponse, nil + return keys, nil } // Create imports an SSH public key with the given name. func (s *Service) Create(ctx context.Context, req CreateRequest) (*SSHKey, error) { - var resp listSSHKeyResponse - if err := s.client.Post(ctx, "/restapi/sshkey/createSSHkey", req, &resp); err != nil { + var key SSHKey + if err := s.client.PostEnvelope(ctx, "/users/ssh-keys", req, &key); err != nil { return nil, fmt.Errorf("creating SSH key: %w", err) } - if len(resp.ListSSHKeyResponse) == 0 { - return nil, fmt.Errorf("create SSH key returned empty response") - } - return &resp.ListSSHKeyResponse[0], nil + return &key, nil } -// Delete removes an SSH key by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/sshkey/deleteSSHkey/"+uuid, nil); err != nil { - return fmt.Errorf("deleting SSH key %s: %w", uuid, err) +// Delete removes an SSH key by key identifier (slug or ID). +func (s *Service) Delete(ctx context.Context, keyID string) error { + if err := s.client.Delete(ctx, "/users/ssh-keys/"+keyID, nil); err != nil { + return fmt.Errorf("deleting SSH key %s: %w", keyID, err) } return nil } diff --git a/internal/api/sshkey/sshkey_test.go b/internal/api/sshkey/sshkey_test.go index d45a4bc..e40ecfc 100644 --- a/internal/api/sshkey/sshkey_test.go +++ b/internal/api/sshkey/sshkey_test.go @@ -14,33 +14,36 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listSSHKeyResponse struct { - Count int `json:"count"` - ListSSHKeyResponse []sshkey.SSHKey `json:"listSSHKeyResponse"` +// envelope wraps data in the STKCNSL response envelope. +func envelope(data interface{}) map[string]interface{} { + return map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": data, + } } func TestSSHKeyList(t *testing.T) { - expected := []sshkey.SSHKey{ - {UUID: "key-1", Name: "my-key", Status: "active", IsActive: true, DomainName: "default"}, - {UUID: "key-2", Name: "other-key", Status: "active", IsActive: true, DomainName: "default"}, + expected := []map[string]interface{}{ + {"id": "key-1", "name": "my-key", "slug": "my-key", "public_key": "ssh-rsa AAA..."}, + {"id": "key-2", "name": "other-key", "slug": "other-key", "public_key": "ssh-ed25519 BBB..."}, } var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path - if r.URL.Path != "/restapi/sshkey/sshkeyList" { + if r.URL.Path != "/users/ssh-keys" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listSSHKeyResponse{Count: len(expected), ListSSHKeyResponse: expected}) + json.NewEncoder(w).Encode(envelope(expected)) })) defer srv.Close() @@ -49,14 +52,14 @@ func TestSSHKeyList(t *testing.T) { if err != nil { t.Fatalf("List() error = %v", err) } - if gotPath != "/restapi/sshkey/sshkeyList" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/sshkey/sshkeyList") + if gotPath != "/users/ssh-keys" { + t.Errorf("path = %q, want %q", gotPath, "/users/ssh-keys") } if len(keys) != 2 { t.Fatalf("List() returned %d keys, want 2", len(keys)) } - if keys[0].UUID != "key-1" { - t.Errorf("keys[0].UUID = %q, want %q", keys[0].UUID, "key-1") + if keys[0].ID != "key-1" { + t.Errorf("keys[0].ID = %q, want %q", keys[0].ID, "key-1") } if keys[1].Name != "other-key" { t.Errorf("keys[1].Name = %q, want %q", keys[1].Name, "other-key") @@ -64,12 +67,11 @@ func TestSSHKeyList(t *testing.T) { } func TestSSHKeyCreate(t *testing.T) { - created := sshkey.SSHKey{ - UUID: "key-new", - Name: "imported-key", - Status: "active", - IsActive: true, - DomainName: "default", + created := map[string]interface{}{ + "id": "key-new", + "name": "imported-key", + "slug": "imported-key", + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAA test@host", } var gotBody map[string]interface{} @@ -78,13 +80,13 @@ func TestSSHKeyCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/sshkey/createSSHkey" { + if r.URL.Path != "/users/ssh-keys" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listSSHKeyResponse{Count: 1, ListSSHKeyResponse: []sshkey.SSHKey{created}}) + json.NewEncoder(w).Encode(envelope(created)) })) defer srv.Close() @@ -97,14 +99,14 @@ func TestSSHKeyCreate(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if key.UUID != "key-new" { - t.Errorf("key.UUID = %q, want %q", key.UUID, "key-new") + if key.ID != "key-new" { + t.Errorf("key.ID = %q, want %q", key.ID, "key-new") } if gotBody["name"] != "imported-key" { t.Errorf("body name = %v, want %q", gotBody["name"], "imported-key") } - if gotBody["publicKey"] != "ssh-rsa AAAAB3NzaC1yc2EAAAA test@host" { - t.Errorf("body publicKey = %v, want publicKey value", gotBody["publicKey"]) + if gotBody["public_key"] != "ssh-rsa AAAAB3NzaC1yc2EAAAA test@host" { + t.Errorf("body public_key = %v, want public_key value", gotBody["public_key"]) } } @@ -125,7 +127,7 @@ func TestSSHKeyDelete(t *testing.T) { if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/sshkey/deleteSSHkey/key-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/sshkey/deleteSSHkey/key-del-1") + if gotPath != "/users/ssh-keys/key-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/users/ssh-keys/key-del-1") } } diff --git a/internal/api/storagecategory/storagecategory.go b/internal/api/storagecategory/storagecategory.go new file mode 100644 index 0000000..3b69bf3 --- /dev/null +++ b/internal/api/storagecategory/storagecategory.go @@ -0,0 +1,53 @@ +// Package storagecategory provides ZCP storage category API operations (STKCNSL). +package storagecategory + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// StorageCategory represents a STKCNSL storage category +// (e.g. SSD Storage, NVMe, HDD Storage). +type StorageCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Status bool `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// envelope is the STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// Service provides storage category API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new storage category Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all storage categories. +func (s *Service) List(ctx context.Context) ([]StorageCategory, error) { + var env envelope + if err := s.client.Get(ctx, "/storage-categories", nil, &env); err != nil { + return nil, fmt.Errorf("listing storage categories: %w", err) + } + + var categories []StorageCategory + if err := json.Unmarshal(env.Data, &categories); err != nil { + return nil, fmt.Errorf("decoding storage categories: %w", err) + } + + return categories, nil +} diff --git a/internal/api/storagecategory/storagecategory_test.go b/internal/api/storagecategory/storagecategory_test.go new file mode 100644 index 0000000..55b5ba1 --- /dev/null +++ b/internal/api/storagecategory/storagecategory_test.go @@ -0,0 +1,80 @@ +package storagecategory_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/storagecategory" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func TestStorageCategoryList(t *testing.T) { + expected := []storagecategory.StorageCategory{ + {ID: "sc-1", Name: "SSD Storage", Slug: "ssd-storage", Status: true}, + {ID: "sc-2", Name: "NVMe", Slug: "nvme", Status: true}, + {ID: "sc-3", Name: "HDD Storage", Slug: "hdd-storage", Status: true}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/storage-categories" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + data, _ := json.Marshal(expected) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": json.RawMessage(data), + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := storagecategory.NewService(client) + categories, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(categories) != 3 { + t.Fatalf("List() returned %d categories, want 3", len(categories)) + } + if categories[0].Slug != "ssd-storage" { + t.Errorf("categories[0].Slug = %q, want %q", categories[0].Slug, "ssd-storage") + } + if categories[1].Name != "NVMe" { + t.Errorf("categories[1].Name = %q, want %q", categories[1].Name, "NVMe") + } +} + +func TestStorageCategoryListAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Unauthenticated.", + }) + })) + defer srv.Close() + + client := httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) + + svc := storagecategory.NewService(client) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} diff --git a/internal/api/store/store.go b/internal/api/store/store.go new file mode 100644 index 0000000..c3c3a61 --- /dev/null +++ b/internal/api/store/store.go @@ -0,0 +1,100 @@ +// Package store provides ZCP store API operations. +package store + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Item represents a store item. +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CheckoutProduct describes a product within a checkout request. +type CheckoutProduct struct { + Description string `json:"description"` + Product string `json:"product"` + Quantity int `json:"quantity"` + Status string `json:"status"` + UserID string `json:"user_id,omitempty"` +} + +// CheckoutRequest holds the body for POST /store/checkout. +type CheckoutRequest struct { + Service string `json:"service"` + Products []CheckoutProduct `json:"products"` + BillingCycle string `json:"billing_cycle"` + Coupon *string `json:"coupon"` +} + +// listItemsResponse wraps the paginated store items response. +type listItemsResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Item `json:"data"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// checkoutResponse wraps the checkout response. +type checkoutResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// Service provides store API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new store Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// ListItems returns store items with optional sorting and pagination. +func (s *Service) ListItems(ctx context.Context, sort string, page, limit int) ([]Item, int, error) { + q := url.Values{} + if sort != "" { + q.Set("sort", sort) + } + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + if limit > 0 { + q.Set("limit", fmt.Sprintf("%d", limit)) + } + + var resp listItemsResponse + if err := s.client.Get(ctx, "/store/items", q, &resp); err != nil { + return nil, 0, fmt.Errorf("listing store items: %w", err) + } + + return resp.Data, resp.Total, nil +} + +// Checkout submits a checkout/purchase request. +func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) error { + var resp checkoutResponse + if err := s.client.Post(ctx, "/store/checkout", req, &resp); err != nil { + return fmt.Errorf("store checkout: %w", err) + } + if resp.Status != "" && resp.Status != "Success" { + return fmt.Errorf("store checkout failed: %s", resp.Message) + } + return nil +} diff --git a/internal/api/support/support.go b/internal/api/support/support.go new file mode 100644 index 0000000..2c8eb79 --- /dev/null +++ b/internal/api/support/support.go @@ -0,0 +1,222 @@ +// Package support provides STKCNSL support ticket, reply, feedback, and FAQ API operations. +package support + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// ---------- Models ---------- + +// Ticket represents a support ticket. +type Ticket struct { + ID string `json:"id"` + Subject string `json:"subject"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Department string `json:"department"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// CreateTicketRequest holds parameters for creating a support ticket. +type CreateTicketRequest struct { + Subject string `json:"subject"` + Description string `json:"description"` + Priority string `json:"priority,omitempty"` + Department string `json:"department,omitempty"` +} + +// Reply represents a reply on a support ticket. +type Reply struct { + ID string `json:"id"` + TicketID string `json:"ticketId"` + Message string `json:"message"` + Author string `json:"author"` + CreatedAt string `json:"createdAt"` +} + +// CreateReplyRequest holds parameters for replying to a ticket. +type CreateReplyRequest struct { + Message string `json:"message"` +} + +// Feedback represents feedback on a support ticket. +type Feedback struct { + ID string `json:"id"` + TicketID string `json:"ticketId"` + Rating int `json:"rating"` + Comment string `json:"comment"` + CreatedAt string `json:"createdAt"` +} + +// SubmitFeedbackRequest holds parameters for submitting ticket feedback. +type SubmitFeedbackRequest struct { + Rating int `json:"rating"` + Comment string `json:"comment,omitempty"` +} + +// FAQ represents a frequently asked question. +type FAQ struct { + ID string `json:"id"` + Question string `json:"question"` + Answer string `json:"answer"` + Category string `json:"category"` +} + +// TicketSummary represents the ticket count summary. +type TicketSummary struct { + Total int `json:"total"` + Open int `json:"open"` + Closed int `json:"closed"` +} + +// ---------- Response envelopes ---------- +// STKCNSL API wraps responses in {"status": "Success", "data": ...}. + +type listTicketsResponse struct { + Status string `json:"status"` + Data []Ticket `json:"data"` +} + +type singleTicketResponse struct { + Status string `json:"status"` + Data Ticket `json:"data"` +} + +type ticketSummaryResponse struct { + Status string `json:"status"` + Data TicketSummary `json:"data"` +} + +type listRepliesResponse struct { + Status string `json:"status"` + Data []Reply `json:"data"` +} + +type singleReplyResponse struct { + Status string `json:"status"` + Data Reply `json:"data"` +} + +type feedbackResponse struct { + Status string `json:"status"` + Data Feedback `json:"data"` +} + +type listFAQsResponse struct { + Status string `json:"status"` + Data []FAQ `json:"data"` +} + +type deleteResponse struct { + Status string `json:"status"` +} + +// ---------- Service ---------- + +// Service provides support ticket API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new support Service. +func NewService(client *httpclient.Client) *Service { return &Service{client: client} } + +// ListTickets returns all support tickets for the authenticated user. +func (s *Service) ListTickets(ctx context.Context) ([]Ticket, error) { + var resp listTicketsResponse + if err := s.client.Get(ctx, "/support/tickets", url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("listing support tickets: %w", err) + } + return resp.Data, nil +} + +// CreateTicket creates a new support ticket. +func (s *Service) CreateTicket(ctx context.Context, req CreateTicketRequest) (*Ticket, error) { + var resp singleTicketResponse + if err := s.client.Post(ctx, "/support/tickets", req, &resp); err != nil { + return nil, fmt.Errorf("creating support ticket: %w", err) + } + return &resp.Data, nil +} + +// GetTicket returns a single support ticket by ID. +func (s *Service) GetTicket(ctx context.Context, id string) (*Ticket, error) { + var resp singleTicketResponse + if err := s.client.Get(ctx, "/support/tickets/"+id, url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("getting support ticket %s: %w", id, err) + } + return &resp.Data, nil +} + +// DeleteTicket deletes a support ticket by ID. +func (s *Service) DeleteTicket(ctx context.Context, id string) error { + var resp deleteResponse + if err := s.client.Get(ctx, "/support/tickets/"+id, url.Values{}, &resp); err != nil { + // Verify the ticket exists before attempting delete. + return fmt.Errorf("verifying support ticket %s: %w", id, err) + } + if err := s.client.Delete(ctx, "/support/tickets/"+id, nil); err != nil { + return fmt.Errorf("deleting support ticket %s: %w", id, err) + } + return nil +} + +// Summary returns a count summary of support tickets. +func (s *Service) Summary(ctx context.Context) (*TicketSummary, error) { + var resp ticketSummaryResponse + if err := s.client.Get(ctx, "/services/Ticket/summary", url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("getting ticket summary: %w", err) + } + return &resp.Data, nil +} + +// ListReplies returns all replies for a given ticket ID. +func (s *Service) ListReplies(ctx context.Context, ticketID string) ([]Reply, error) { + var resp listRepliesResponse + if err := s.client.Get(ctx, "/support/tickets-reply/"+ticketID, url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("listing replies for ticket %s: %w", ticketID, err) + } + return resp.Data, nil +} + +// CreateReply adds a reply to a support ticket. +func (s *Service) CreateReply(ctx context.Context, ticketID string, req CreateReplyRequest) (*Reply, error) { + var resp singleReplyResponse + if err := s.client.Post(ctx, "/support/tickets-reply/"+ticketID, req, &resp); err != nil { + return nil, fmt.Errorf("replying to ticket %s: %w", ticketID, err) + } + return &resp.Data, nil +} + +// GetFeedback returns feedback for a given ticket ID. +func (s *Service) GetFeedback(ctx context.Context, ticketID string) (*Feedback, error) { + var resp feedbackResponse + if err := s.client.Get(ctx, "/support/ticket-feedbacks/"+ticketID, url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("getting feedback for ticket %s: %w", ticketID, err) + } + return &resp.Data, nil +} + +// SubmitFeedback submits feedback for a support ticket. +func (s *Service) SubmitFeedback(ctx context.Context, ticketID string, req SubmitFeedbackRequest) (*Feedback, error) { + var resp feedbackResponse + if err := s.client.Post(ctx, "/support/ticket-feedbacks/"+ticketID, req, &resp); err != nil { + return nil, fmt.Errorf("submitting feedback for ticket %s: %w", ticketID, err) + } + return &resp.Data, nil +} + +// ListFAQs returns all FAQs. +func (s *Service) ListFAQs(ctx context.Context) ([]FAQ, error) { + var resp listFAQsResponse + if err := s.client.Get(ctx, "/faqs", url.Values{}, &resp); err != nil { + return nil, fmt.Errorf("listing FAQs: %w", err) + } + return resp.Data, nil +} diff --git a/internal/api/support/support_test.go b/internal/api/support/support_test.go new file mode 100644 index 0000000..65bc2ae --- /dev/null +++ b/internal/api/support/support_test.go @@ -0,0 +1,326 @@ +package support_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/support" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +// ---------- Ticket tests ---------- + +func TestListTickets(t *testing.T) { + expected := []support.Ticket{ + {ID: "t-1", Subject: "Cannot SSH", Status: "open", Priority: "high"}, + {ID: "t-2", Subject: "Billing question", Status: "closed", Priority: "low"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/support/tickets" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + tickets, err := svc.ListTickets(context.Background()) + if err != nil { + t.Fatalf("ListTickets() error = %v", err) + } + if len(tickets) != 2 { + t.Fatalf("ListTickets() returned %d tickets, want 2", len(tickets)) + } + if tickets[0].ID != "t-1" { + t.Errorf("tickets[0].ID = %q, want %q", tickets[0].ID, "t-1") + } + if tickets[1].Subject != "Billing question" { + t.Errorf("tickets[1].Subject = %q, want %q", tickets[1].Subject, "Billing question") + } +} + +func TestCreateTicket(t *testing.T) { + created := support.Ticket{ID: "t-new", Subject: "New issue", Status: "open", Priority: "medium"} + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/support/tickets" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + ticket, err := svc.CreateTicket(context.Background(), support.CreateTicketRequest{ + Subject: "New issue", + Description: "Details here", + Priority: "medium", + }) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + if ticket.ID != "t-new" { + t.Errorf("ticket.ID = %q, want %q", ticket.ID, "t-new") + } + if gotBody["subject"] != "New issue" { + t.Errorf("body subject = %v, want %q", gotBody["subject"], "New issue") + } +} + +func TestGetTicket(t *testing.T) { + expected := support.Ticket{ID: "t-1", Subject: "Cannot SSH", Status: "open"} + + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + ticket, err := svc.GetTicket(context.Background(), "t-1") + if err != nil { + t.Fatalf("GetTicket() error = %v", err) + } + if gotPath != "/support/tickets/t-1" { + t.Errorf("path = %q, want %q", gotPath, "/support/tickets/t-1") + } + if ticket.Subject != "Cannot SSH" { + t.Errorf("ticket.Subject = %q, want %q", ticket.Subject, "Cannot SSH") + } +} + +func TestDeleteTicket(t *testing.T) { + var methods []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": support.Ticket{ID: "t-del"}, + }) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + err := svc.DeleteTicket(context.Background(), "t-del") + if err != nil { + t.Fatalf("DeleteTicket() error = %v", err) + } + if len(methods) < 2 { + t.Fatalf("expected at least 2 requests (GET + DELETE), got %d", len(methods)) + } + if methods[len(methods)-1] != http.MethodDelete { + t.Errorf("last method = %q, want %q", methods[len(methods)-1], http.MethodDelete) + } +} + +func TestSummary(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/services/Ticket/summary" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": support.TicketSummary{Total: 10, Open: 3, Closed: 7}, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + summary, err := svc.Summary(context.Background()) + if err != nil { + t.Fatalf("Summary() error = %v", err) + } + if summary.Total != 10 { + t.Errorf("Total = %d, want 10", summary.Total) + } + if summary.Open != 3 { + t.Errorf("Open = %d, want 3", summary.Open) + } +} + +// ---------- Reply tests ---------- + +func TestListReplies(t *testing.T) { + expected := []support.Reply{ + {ID: "r-1", TicketID: "t-1", Message: "Thanks for reporting"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/support/tickets-reply/t-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + replies, err := svc.ListReplies(context.Background(), "t-1") + if err != nil { + t.Fatalf("ListReplies() error = %v", err) + } + if len(replies) != 1 { + t.Fatalf("ListReplies() returned %d, want 1", len(replies)) + } + if replies[0].Message != "Thanks for reporting" { + t.Errorf("replies[0].Message = %q, want %q", replies[0].Message, "Thanks for reporting") + } +} + +func TestCreateReply(t *testing.T) { + created := support.Reply{ID: "r-new", TicketID: "t-1", Message: "Here is more info"} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/support/tickets-reply/t-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + reply, err := svc.CreateReply(context.Background(), "t-1", support.CreateReplyRequest{Message: "Here is more info"}) + if err != nil { + t.Fatalf("CreateReply() error = %v", err) + } + if reply.ID != "r-new" { + t.Errorf("reply.ID = %q, want %q", reply.ID, "r-new") + } +} + +// ---------- Feedback tests ---------- + +func TestGetFeedback(t *testing.T) { + expected := support.Feedback{ID: "fb-1", TicketID: "t-1", Rating: 5, Comment: "Great support"} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/support/ticket-feedbacks/t-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + fb, err := svc.GetFeedback(context.Background(), "t-1") + if err != nil { + t.Fatalf("GetFeedback() error = %v", err) + } + if fb.Rating != 5 { + t.Errorf("Rating = %d, want 5", fb.Rating) + } +} + +func TestSubmitFeedback(t *testing.T) { + created := support.Feedback{ID: "fb-new", TicketID: "t-1", Rating: 4, Comment: "Good"} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/support/ticket-feedbacks/t-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + fb, err := svc.SubmitFeedback(context.Background(), "t-1", support.SubmitFeedbackRequest{Rating: 4, Comment: "Good"}) + if err != nil { + t.Fatalf("SubmitFeedback() error = %v", err) + } + if fb.ID != "fb-new" { + t.Errorf("fb.ID = %q, want %q", fb.ID, "fb-new") + } +} + +// ---------- FAQ tests ---------- + +func TestListFAQs(t *testing.T) { + expected := []support.FAQ{ + {ID: "faq-1", Question: "How do I reset?", Answer: "Go to settings", Category: "Account"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/faqs" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": expected, + }) + })) + defer srv.Close() + + svc := support.NewService(newClient(srv.URL)) + faqs, err := svc.ListFAQs(context.Background()) + if err != nil { + t.Fatalf("ListFAQs() error = %v", err) + } + if len(faqs) != 1 { + t.Fatalf("ListFAQs() returned %d, want 1", len(faqs)) + } + if faqs[0].Question != "How do I reset?" { + t.Errorf("faqs[0].Question = %q, want %q", faqs[0].Question, "How do I reset?") + } +} diff --git a/internal/api/tags/tags_test.go b/internal/api/tags/tags_test.go index f87050e..e3b641f 100644 --- a/internal/api/tags/tags_test.go +++ b/internal/api/tags/tags_test.go @@ -14,10 +14,9 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/template/template.go b/internal/api/template/template.go index 1b21577..188b2e9 100644 --- a/internal/api/template/template.go +++ b/internal/api/template/template.go @@ -9,22 +9,148 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Template represents a ZCP VM template. +// Template represents a ZCP VM template from the public catalog. type Template struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description"` - Format string `json:"format"` - OsCategoryName string `json:"osCategoryName"` - ZoneName string `json:"zoneName"` - ZoneUUID string `json:"zoneUuid"` - TemplateCost string `json:"templateCost"` - IsActive string `json:"isActive"` + ID string `json:"id"` + TemplateID string `json:"template_id"` + AccountID *string `json:"account_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RegionID string `json:"region_id"` + Type string `json:"type"` + Name string `json:"name"` + Slug string `json:"slug"` + OperatingSystemID string `json:"operating_system_id"` + OperatingSystemVersionID string `json:"operating_system_version_id"` + Status bool `json:"status"` + PasswordEnabled bool `json:"password_enabled"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartupScript *string `json:"startup_script"` + FileType string `json:"file_type"` + ImageType string `json:"image_type"` + PasswordMethod string `json:"password_method"` + EnableResetPassword bool `json:"enable_reset_password"` + IconURL string `json:"icon_url"` + OperatingSystemVersion *OperatingSystemVersion `json:"operating_system_version,omitempty"` + OperatingSystem *OperatingSystem `json:"operating_system,omitempty"` } +// OperatingSystem represents the OS of a template. +type OperatingSystem struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + VMDefaultUsername string `json:"vm_default_username"` + Family string `json:"family"` + SortOrder int `json:"sort_order"` + Icon string `json:"icon"` +} + +// OperatingSystemVersion represents the OS version of a template. +type OperatingSystemVersion struct { + ID string `json:"id"` + OperatingSystemID string `json:"operating_system_id"` + Version string `json:"version"` + PricingType string `json:"pricing_type"` +} + +// AccountTemplate represents a user-created template (account template). +type AccountTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + TemplateID string `json:"template_id"` + AccountID string `json:"account_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RegionID string `json:"region_id"` + ProjectID string `json:"project_id"` + State string `json:"state"` + Status string `json:"status"` + ImageType string `json:"image_type"` + FileType string `json:"file_type"` + Format string `json:"format"` + PasswordEnabled bool `json:"password_enabled"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Region *Region `json:"region,omitempty"` + Project *Project `json:"project,omitempty"` + CloudProvider *CloudProvider `json:"cloud_provider,omitempty"` +} + +// Region represents the region of a template resource. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Project represents the project of a template resource. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// CloudProvider represents the cloud provider of a template resource. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// CreateAccountTemplateRequest holds parameters for creating an account template. +type CreateAccountTemplateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + Project string `json:"project"` + OSTypeID string `json:"os_type_id"` + ImageType string `json:"image_type"` + OperatingSystem string `json:"operating_system"` + OperatingSystemVersion string `json:"operating_system_version"` + PasswordEnabled bool `json:"password_enabled"` + BillingCycle string `json:"billing_cycle"` + Plan string `json:"plan,omitempty"` + Format string `json:"format,omitempty"` + RootDiskController string `json:"root_disk_controller,omitempty"` + TemplateType string `json:"template_type,omitempty"` + IsFeatured bool `json:"is_featured,omitempty"` + RequiresHVM bool `json:"requires_hvm,omitempty"` + IsDynamicallyScalable bool `json:"is_dynamically_scalable,omitempty"` + IsUploadFromLocal bool `json:"is_upload_from_local"` + Coupon string `json:"coupon,omitempty"` + VirtualMachine string `json:"virtual_machine,omitempty"` +} + +// listTemplateResponse is the STKCNSL paginated response envelope for templates. type listTemplateResponse struct { - Count int `json:"count"` - ListTemplateResponse []Template `json:"listTemplateResponse"` + Status string `json:"status"` + Message string `json:"message"` + Data []Template `json:"data"` + Total int `json:"total"` +} + +// listAccountTemplateResponse is the STKCNSL paginated response envelope for account templates. +type listAccountTemplateResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []AccountTemplate `json:"data"` + Total int `json:"total"` +} + +// singleAccountTemplateResponse is the STKCNSL single-object response envelope. +type singleAccountTemplateResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data AccountTemplate `json:"data"` } // Service provides template API operations. @@ -37,19 +163,44 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns all templates. zoneUUID and templateUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, templateUUID string) ([]Template, error) { +// List returns all public templates. regionSlug is an optional filter. +func (s *Service) List(ctx context.Context, regionSlug string) ([]Template, error) { q := url.Values{} - if zoneUUID != "" { - q.Set("zoneUuid", zoneUUID) - } - if templateUUID != "" { - q.Set("uuid", templateUUID) + q.Set("include", "operating_system,operating_system_version,region,cloud_provider") + if regionSlug != "" { + q.Set("filter[region]", regionSlug) } - var resp listTemplateResponse - if err := s.client.Get(ctx, "/restapi/template/templateList", q, &resp); err != nil { + if err := s.client.Get(ctx, "/templates", q, &resp); err != nil { return nil, fmt.Errorf("listing templates: %w", err) } - return resp.ListTemplateResponse, nil + return resp.Data, nil +} + +// ListAccount returns templates owned by the authenticated account. +func (s *Service) ListAccount(ctx context.Context) ([]AccountTemplate, error) { + q := url.Values{} + q.Set("include", "region,cloud_provider,template,project") + var resp listAccountTemplateResponse + if err := s.client.Get(ctx, "/account/templates", q, &resp); err != nil { + return nil, fmt.Errorf("listing account templates: %w", err) + } + return resp.Data, nil +} + +// CreateAccount creates a new account template. +func (s *Service) CreateAccount(ctx context.Context, req CreateAccountTemplateRequest) (*AccountTemplate, error) { + var resp singleAccountTemplateResponse + if err := s.client.Post(ctx, "/account/templates", req, &resp); err != nil { + return nil, fmt.Errorf("creating account template: %w", err) + } + return &resp.Data, nil +} + +// DeleteAccount removes an account template by slug. +func (s *Service) DeleteAccount(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/account/templates/"+slug, nil); err != nil { + return fmt.Errorf("deleting account template %s: %w", slug, err) + } + return nil } diff --git a/internal/api/usage/usage_test.go b/internal/api/usage/usage_test.go index ae532fa..e6f63e1 100644 --- a/internal/api/usage/usage_test.go +++ b/internal/api/usage/usage_test.go @@ -15,10 +15,9 @@ import ( func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { t.Helper() return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/userprofile/userprofile.go b/internal/api/userprofile/userprofile.go new file mode 100644 index 0000000..7c533b3 --- /dev/null +++ b/internal/api/userprofile/userprofile.go @@ -0,0 +1,329 @@ +// Package userprofile provides STKCNSL user profile, MFA, password, +// user management, time-settings, and API access operations. +package userprofile + +import ( + "context" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Profile represents the authenticated user's profile from GET /profile. +type Profile struct { + User User `json:"user"` +} + +// User holds the top-level user data within the profile. +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerifiedAt *string `json:"email_verified_at"` + RegistrationType string `json:"registration_type"` + UserType string `json:"user_type"` + Domain string `json:"domain"` + IsTwoFactor bool `json:"is_two_factor"` + IsBlocked bool `json:"is_blocked"` + IsInvited bool `json:"is_invited"` + LastLogin string `json:"last_login"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Account Account `json:"account"` + Company *Company `json:"company"` + Address *Address `json:"address"` +} + +// Account holds account-level metadata. +type Account struct { + ID string `json:"id"` + CRN string `json:"crn"` + Status string `json:"status"` + AccountStatus string `json:"account_status"` + PaymentMode string `json:"payment_mode"` + Timezone string `json:"timezone"` + DateTimeFormat string `json:"date_time_format"` + Enforce2FA bool `json:"enforce_2fa_to_all"` + OwnerName string `json:"owner_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Company holds company details. +type Company struct { + Name string `json:"name"` + Website string `json:"website"` +} + +// Address holds an address record. +type Address struct { + ID string `json:"id"` + Type string `json:"type"` + Country string `json:"country"` + State string `json:"state"` + City string `json:"city"` + PostalCode string `json:"postal_code"` + Line1 string `json:"line_1"` + Line2 string `json:"line_2"` + BillingName string `json:"billing_name"` +} + +// UpdateProfileRequest holds fields for PUT /profile. +type UpdateProfileRequest struct { + Name string `json:"name,omitempty"` +} + +// UpdateCompanyRequest holds fields for PUT /profile/company-details. +type UpdateCompanyRequest struct { + BillingName string `json:"billing_name,omitempty"` + Country string `json:"country,omitempty"` + State string `json:"state,omitempty"` + City string `json:"city,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + Line1 string `json:"line_1,omitempty"` + Line2 string `json:"line_2,omitempty"` + GST string `json:"GST,omitempty"` +} + +// TimeSettingsRequest holds fields for POST /profile/time-settings. +type TimeSettingsRequest struct { + Timezone string `json:"timezone"` + DateTimeFormat string `json:"date_time_format,omitempty"` +} + +// ChangePasswordRequest holds fields for POST /users/change-password. +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"password"` + NewPasswordConfirm string `json:"password_confirmation"` +} + +// MFASendOTPRequest holds fields for MFA OTP send endpoints. +type MFASendOTPRequest struct { + Type string `json:"type,omitempty"` // "email" or "sms" +} + +// MFAVerifyOTPRequest holds fields for MFA OTP verify endpoints. +type MFAVerifyOTPRequest struct { + OTP string `json:"otp"` +} + +// Enforce2FARequest holds fields for POST /account/enforce2fa. +type Enforce2FARequest struct { + Enforce bool `json:"enforce_2fa_to_all"` +} + +// CreateUserRequest holds fields for POST /api/users. +type CreateUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + RoleID string `json:"role_id,omitempty"` +} + +// UpdateUserRequest holds fields for PUT /api/users/{id}. +type UpdateUserRequest struct { + Name string `json:"name,omitempty"` + RoleID string `json:"role_id,omitempty"` +} + +// ManagedUser represents a user managed under this account. +type ManagedUser struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + UserType string `json:"user_type"` + IsBlocked bool `json:"is_blocked"` + IsInvited bool `json:"is_invited"` + LastLogin string `json:"last_login"` + CreatedAt string `json:"created_at"` +} + +// LogEntry represents a login or activity log entry. +type LogEntry struct { + ID string `json:"id"` + Action string `json:"action"` + IPAddress string `json:"ip_address"` + Details string `json:"details"` + CreatedAt string `json:"created_at"` +} + +// DocLinks holds the API documentation URL. +type DocLinks struct { + URL string `json:"url"` +} + +// StatusResponse is a generic response for operations that return a status message. +type StatusResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// Service provides user profile API operations against STKCNSL. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new user profile Service. +func NewService(client *httpclient.Client) *Service { return &Service{client: client} } + +// Get returns the authenticated user's profile. +func (s *Service) Get(ctx context.Context) (*Profile, error) { + var p Profile + if err := s.client.GetEnvelope(ctx, "/profile", nil, &p); err != nil { + return nil, fmt.Errorf("getting profile: %w", err) + } + return &p, nil +} + +// Update updates the authenticated user's profile. +func (s *Service) Update(ctx context.Context, req UpdateProfileRequest) (*Profile, error) { + var p Profile + if err := s.client.PutEnvelope(ctx, "/profile", nil, req, &p); err != nil { + return nil, fmt.Errorf("updating profile: %w", err) + } + return &p, nil +} + +// UpdateCompany updates the company/billing details. +func (s *Service) UpdateCompany(ctx context.Context, req UpdateCompanyRequest) error { + if err := s.client.PutEnvelope(ctx, "/profile/company-details", nil, req, nil); err != nil { + return fmt.Errorf("updating company details: %w", err) + } + return nil +} + +// UpdateTimeSettings updates timezone and date format preferences. +func (s *Service) UpdateTimeSettings(ctx context.Context, req TimeSettingsRequest) error { + if err := s.client.PostEnvelope(ctx, "/profile/time-settings", req, nil); err != nil { + return fmt.Errorf("updating time settings: %w", err) + } + return nil +} + +// EnableAPI enables API access for the account. +func (s *Service) EnableAPI(ctx context.Context) error { + if err := s.client.PostEnvelope(ctx, "/profile/api/enable", nil, nil); err != nil { + return fmt.Errorf("enabling API access: %w", err) + } + return nil +} + +// DisableAPI disables API access for the account. +func (s *Service) DisableAPI(ctx context.Context) error { + if err := s.client.Delete(ctx, "/profile/api/disable", nil); err != nil { + return fmt.Errorf("disabling API access: %w", err) + } + return nil +} + +// GetDocLinks returns the API documentation URL. +func (s *Service) GetDocLinks(ctx context.Context) (*DocLinks, error) { + var d DocLinks + if err := s.client.GetEnvelope(ctx, "/profile/swagger-doc-links", nil, &d); err != nil { + return nil, fmt.Errorf("getting doc links: %w", err) + } + return &d, nil +} + +// LoginActivity returns login activity logs for the given CRN. +func (s *Service) LoginActivity(ctx context.Context, crn string) ([]LogEntry, error) { + var entries []LogEntry + if err := s.client.GetEnvelope(ctx, "/loggers/"+crn, nil, &entries); err != nil { + return nil, fmt.Errorf("getting login activity: %w", err) + } + return entries, nil +} + +// ActivityLogs returns activity logs for the given CRN. +func (s *Service) ActivityLogs(ctx context.Context, crn string) ([]LogEntry, error) { + var entries []LogEntry + if err := s.client.GetEnvelope(ctx, "/loggers/activity/"+crn, nil, &entries); err != nil { + return nil, fmt.Errorf("getting activity logs: %w", err) + } + return entries, nil +} + +// ChangePassword changes the authenticated user's password. +func (s *Service) ChangePassword(ctx context.Context, req ChangePasswordRequest) error { + if err := s.client.PostEnvelope(ctx, "/users/change-password", req, nil); err != nil { + return fmt.Errorf("changing password: %w", err) + } + return nil +} + +// MFAEnableSendOTP sends an OTP to begin enabling 2FA. +func (s *Service) MFAEnableSendOTP(ctx context.Context, req MFASendOTPRequest) error { + if err := s.client.PostEnvelope(ctx, "/mfa/enable/send-otp", req, nil); err != nil { + return fmt.Errorf("sending 2FA enable OTP: %w", err) + } + return nil +} + +// MFAEnableVerifyOTP verifies the OTP to complete enabling 2FA. +func (s *Service) MFAEnableVerifyOTP(ctx context.Context, req MFAVerifyOTPRequest) error { + if err := s.client.PostEnvelope(ctx, "/mfa/enable/verify-otp", req, nil); err != nil { + return fmt.Errorf("verifying 2FA enable OTP: %w", err) + } + return nil +} + +// MFADisableSendOTP sends an OTP to begin disabling 2FA. +func (s *Service) MFADisableSendOTP(ctx context.Context, req MFASendOTPRequest) error { + if err := s.client.PostEnvelope(ctx, "/mfa/disable/send-otp", req, nil); err != nil { + return fmt.Errorf("sending 2FA disable OTP: %w", err) + } + return nil +} + +// MFADisableVerifyOTP verifies the OTP to complete disabling 2FA. +func (s *Service) MFADisableVerifyOTP(ctx context.Context, req MFAVerifyOTPRequest) error { + if err := s.client.PostEnvelope(ctx, "/mfa/disable/verify-otp", req, nil); err != nil { + return fmt.Errorf("disabling 2FA: %w", err) + } + return nil +} + +// Enforce2FA sets whether 2FA is enforced for all users on the account. +func (s *Service) Enforce2FA(ctx context.Context, req Enforce2FARequest) error { + if err := s.client.PostEnvelope(ctx, "/account/enforce2fa", req, nil); err != nil { + return fmt.Errorf("enforcing 2FA: %w", err) + } + return nil +} + +// CreateUser creates a new user under the account. +func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*ManagedUser, error) { + var u ManagedUser + if err := s.client.PostEnvelope(ctx, "/api/users", req, &u); err != nil { + return nil, fmt.Errorf("creating user: %w", err) + } + return &u, nil +} + +// UpdateUser updates an existing user by ID. +func (s *Service) UpdateUser(ctx context.Context, id string, req UpdateUserRequest) (*ManagedUser, error) { + var u ManagedUser + if err := s.client.PutEnvelope(ctx, "/api/users/"+id, nil, req, &u); err != nil { + return nil, fmt.Errorf("updating user %s: %w", id, err) + } + return &u, nil +} + +// ReInviteUser re-sends an invitation to a user. +func (s *Service) ReInviteUser(ctx context.Context, id string) error { + if err := s.client.PostEnvelope(ctx, "/users/re-invite/"+id, nil, nil); err != nil { + return fmt.Errorf("re-inviting user %s: %w", id, err) + } + return nil +} + +// DeleteUser removes a user by ID. +func (s *Service) DeleteUser(ctx context.Context, id string) error { + q := url.Values{} + if err := s.client.Delete(ctx, "/users/"+id, q); err != nil { + return fmt.Errorf("deleting user %s: %w", id, err) + } + return nil +} diff --git a/internal/api/userprofile/userprofile_test.go b/internal/api/userprofile/userprofile_test.go new file mode 100644 index 0000000..1510bb8 --- /dev/null +++ b/internal/api/userprofile/userprofile_test.go @@ -0,0 +1,240 @@ +package userprofile_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/userprofile" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +// envelope wraps data in the STKCNSL response envelope. +func envelope(data interface{}) map[string]interface{} { + return map[string]interface{}{ + "status": "Success", + "message": "OK", + "data": data, + } +} + +func TestGetProfile(t *testing.T) { + profileData := map[string]interface{}{ + "user": map[string]interface{}{ + "id": "user-123", + "name": "Test User", + "email": "test@example.com", + "account": map[string]interface{}{ + "id": "acct-123", + "crn": "001001", + "status": "ACTIVE", + }, + }, + } + + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(envelope(profileData)) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + p, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if gotPath != "/profile" { + t.Errorf("path = %q, want %q", gotPath, "/profile") + } + if gotAuth != "Bearer test-token" { + t.Errorf("auth = %q, want %q", gotAuth, "Bearer test-token") + } + if p.User.ID != "user-123" { + t.Errorf("user.ID = %q, want %q", p.User.ID, "user-123") + } + if p.User.Name != "Test User" { + t.Errorf("user.Name = %q, want %q", p.User.Name, "Test User") + } + if p.User.Account.CRN != "001001" { + t.Errorf("account.CRN = %q, want %q", p.User.Account.CRN, "001001") + } +} + +func TestUpdateProfile(t *testing.T) { + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "expected PUT", http.StatusMethodNotAllowed) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(envelope(map[string]interface{}{ + "user": map[string]interface{}{ + "id": "user-123", + "name": "Updated Name", + }, + })) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + p, err := svc.Update(context.Background(), userprofile.UpdateProfileRequest{Name: "Updated Name"}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if gotBody["name"] != "Updated Name" { + t.Errorf("body name = %v, want %q", gotBody["name"], "Updated Name") + } + if p.User.Name != "Updated Name" { + t.Errorf("user.Name = %q, want %q", p.User.Name, "Updated Name") + } +} + +func TestChangePassword(t *testing.T) { + var gotPath, gotMethod string + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(envelope(nil)) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + err := svc.ChangePassword(context.Background(), userprofile.ChangePasswordRequest{ + CurrentPassword: "old123", + NewPassword: "new456", + NewPasswordConfirm: "new456", + }) + if err != nil { + t.Fatalf("ChangePassword() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/users/change-password" { + t.Errorf("path = %q, want %q", gotPath, "/users/change-password") + } + if gotBody["current_password"] != "old123" { + t.Errorf("body current_password = %v, want %q", gotBody["current_password"], "old123") + } +} + +func TestEnableAPI(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(envelope(nil)) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + err := svc.EnableAPI(context.Background()) + if err != nil { + t.Fatalf("EnableAPI() error = %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } + if gotPath != "/profile/api/enable" { + t.Errorf("path = %q, want %q", gotPath, "/profile/api/enable") + } +} + +func TestDisableAPI(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + err := svc.DisableAPI(context.Background()) + if err != nil { + t.Fatalf("DisableAPI() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + } + if gotPath != "/profile/api/disable" { + t.Errorf("path = %q, want %q", gotPath, "/profile/api/disable") + } +} + +func TestCreateUser(t *testing.T) { + created := map[string]interface{}{ + "id": "user-new", + "name": "New User", + "email": "new@example.com", + } + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(envelope(created)) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + u, err := svc.CreateUser(context.Background(), userprofile.CreateUserRequest{ + Name: "New User", + Email: "new@example.com", + }) + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + if u.ID != "user-new" { + t.Errorf("user.ID = %q, want %q", u.ID, "user-new") + } + if gotBody["email"] != "new@example.com" { + t.Errorf("body email = %v, want %q", gotBody["email"], "new@example.com") + } +} + +func TestDeleteUser(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := userprofile.NewService(newClient(srv.URL)) + err := svc.DeleteUser(context.Background(), "user-del-1") + if err != nil { + t.Fatalf("DeleteUser() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) + } + if gotPath != "/users/user-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/users/user-del-1") + } +} diff --git a/internal/api/virtualrouter/virtualrouter.go b/internal/api/virtualrouter/virtualrouter.go new file mode 100644 index 0000000..2103ac3 --- /dev/null +++ b/internal/api/virtualrouter/virtualrouter.go @@ -0,0 +1,82 @@ +// Package virtualrouter provides ZCP virtual router API operations. +package virtualrouter + +import ( + "context" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// VirtualRouter represents a ZCP virtual router. +type VirtualRouter struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + State string `json:"state"` + Gateway string `json:"gateway"` + PublicIP string `json:"public_ip"` + GuestIP string `json:"guest_ip"` + NetworkSlug string `json:"network_slug"` + ZoneSlug string `json:"zone_slug"` + ZoneName string `json:"zone_name"` + Role string `json:"role"` + Redundant bool `json:"is_redundant"` + Version string `json:"version"` +} + +// CreateRequest holds parameters for creating a virtual router. +type CreateRequest struct { + Name string `json:"name"` + NetworkSlug string `json:"network_slug"` + PlanSlug string `json:"plan_slug,omitempty"` + ZoneSlug string `json:"zone_slug,omitempty"` +} + +type listVirtualRouterResponse struct { + Status string `json:"status"` + Data []VirtualRouter `json:"data"` +} + +type singleVirtualRouterResponse struct { + Status string `json:"status"` + Data VirtualRouter `json:"data"` +} + +// Service provides virtual router API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new virtual router Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all virtual routers. +func (s *Service) List(ctx context.Context) ([]VirtualRouter, error) { + var resp listVirtualRouterResponse + if err := s.client.Get(ctx, "/virtual-routers", nil, &resp); err != nil { + return nil, fmt.Errorf("listing virtual routers: %w", err) + } + return resp.Data, nil +} + +// Create provisions a new virtual router. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*VirtualRouter, error) { + var resp singleVirtualRouterResponse + if err := s.client.Post(ctx, "/virtual-routers", req, &resp); err != nil { + return nil, fmt.Errorf("creating virtual router: %w", err) + } + return &resp.Data, nil +} + +// Reboot restarts a virtual router by slug. +func (s *Service) Reboot(ctx context.Context, slug string) (*VirtualRouter, error) { + var resp singleVirtualRouterResponse + if err := s.client.Get(ctx, "/virtual-routers/"+slug+"/reboot", nil, &resp); err != nil { + return nil, fmt.Errorf("rebooting virtual router %s: %w", slug, err) + } + return &resp.Data, nil +} diff --git a/internal/api/virtualrouter/virtualrouter_test.go b/internal/api/virtualrouter/virtualrouter_test.go new file mode 100644 index 0000000..d2ad674 --- /dev/null +++ b/internal/api/virtualrouter/virtualrouter_test.go @@ -0,0 +1,143 @@ +package virtualrouter_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/virtualrouter" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newClient(baseURL string) *httpclient.Client { + return httpclient.New(httpclient.Options{ + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +func makeRouter(slug, name string) virtualrouter.VirtualRouter { + return virtualrouter.VirtualRouter{ + ID: "1", + Slug: slug, + Name: name, + Status: "Running", + State: "Running", + Gateway: "10.0.0.1", + PublicIP: "203.0.113.10", + GuestIP: "10.0.0.1", + ZoneSlug: "yow-1", + Role: "VIRTUAL_ROUTER", + } +} + +func TestVirtualRouterList(t *testing.T) { + routers := []virtualrouter.VirtualRouter{ + makeRouter("router-1", "vr-web"), + makeRouter("router-2", "vr-db"), + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/virtual-routers" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": routers, + }) + })) + defer srv.Close() + + svc := virtualrouter.NewService(newClient(srv.URL)) + result, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(result) != 2 { + t.Fatalf("List() returned %d routers, want 2", len(result)) + } + if result[0].Slug != "router-1" { + t.Errorf("result[0].Slug = %q, want %q", result[0].Slug, "router-1") + } + if result[1].Name != "vr-db" { + t.Errorf("result[1].Name = %q, want %q", result[1].Name, "vr-db") + } +} + +func TestVirtualRouterCreate(t *testing.T) { + created := makeRouter("new-router", "my-router") + + var gotBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/virtual-routers" { + http.NotFound(w, r) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": created, + }) + })) + defer srv.Close() + + svc := virtualrouter.NewService(newClient(srv.URL)) + req := virtualrouter.CreateRequest{ + Name: "my-router", + NetworkSlug: "web-network", + } + vr, err := svc.Create(context.Background(), req) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if vr.Slug != "new-router" { + t.Errorf("vr.Slug = %q, want %q", vr.Slug, "new-router") + } + if gotBody["name"] != "my-router" { + t.Errorf("body[name] = %v, want %q", gotBody["name"], "my-router") + } + if gotBody["network_slug"] != "web-network" { + t.Errorf("body[network_slug] = %v, want %q", gotBody["network_slug"], "web-network") + } +} + +func TestVirtualRouterReboot(t *testing.T) { + rebooted := makeRouter("router-1", "vr-web") + rebooted.Status = "Running" + + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "Success", + "data": rebooted, + }) + })) + defer srv.Close() + + svc := virtualrouter.NewService(newClient(srv.URL)) + vr, err := svc.Reboot(context.Background(), "router-1") + if err != nil { + t.Fatalf("Reboot() error = %v", err) + } + if gotPath != "/virtual-routers/router-1/reboot" { + t.Errorf("path = %q, want %q", gotPath, "/virtual-routers/router-1/reboot") + } + if vr.Status != "Running" { + t.Errorf("vr.Status = %q, want %q", vr.Status, "Running") + } +} diff --git a/internal/api/vmbackup/vmbackup.go b/internal/api/vmbackup/vmbackup.go new file mode 100644 index 0000000..247f091 --- /dev/null +++ b/internal/api/vmbackup/vmbackup.go @@ -0,0 +1,104 @@ +// Package vmbackup provides ZCP VM backup API operations (STKCNSL). +package vmbackup + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// ---------- Response envelope ---------- + +// Envelope wraps paginated STKCNSL responses. +type Envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + CurrentPage int `json:"current_page"` + Data json.RawMessage `json:"data"` + Total int `json:"total"` +} + +// ActionResponse wraps simple action responses. +type ActionResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + Data interface{} `json:"data"` +} + +// ---------- Types ---------- + +// VMBackup represents a STKCNSL VM backup. +type VMBackup struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + VirtualMachineID string `json:"virtual_machine_id"` + State string `json:"state"` + ServiceName string `json:"service_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` +} + +// ---------- Request types ---------- + +// CreateRequest holds parameters for creating a VM backup. +type CreateRequest struct { + Interval string `json:"interval"` + At int `json:"at"` + Immediate int `json:"immediate"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + BillingCycle string `json:"billing_cycle"` + Plan string `json:"plan"` + PsudoService string `json:"psudo_service"` + Project string `json:"project"` + IsVMSnapshot bool `json:"is_vm_snapshot"` + Coupon *string `json:"coupon"` +} + +// ---------- Service ---------- + +// Service provides VM backup API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new VMBackup Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all VM backups. +func (s *Service) List(ctx context.Context) ([]VMBackup, error) { + var env Envelope + if err := s.client.Get(ctx, "/virtual-machines/backups", nil, &env); err != nil { + return nil, fmt.Errorf("listing VM backups: %w", err) + } + var backups []VMBackup + if err := json.Unmarshal(env.Data, &backups); err != nil { + return nil, fmt.Errorf("decoding VM backups: %w", err) + } + return backups, nil +} + +// Create creates a new VM backup on the given VM slug. +func (s *Service) Create(ctx context.Context, vmSlug string, req CreateRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+vmSlug+"/backups", req, &resp); err != nil { + return nil, fmt.Errorf("creating VM backup on %s: %w", vmSlug, err) + } + return &resp, nil +} diff --git a/internal/api/vmsnapshot/vmsnapshot.go b/internal/api/vmsnapshot/vmsnapshot.go index 333a861..61c55fc 100644 --- a/internal/api/vmsnapshot/vmsnapshot.go +++ b/internal/api/vmsnapshot/vmsnapshot.go @@ -1,47 +1,74 @@ -// Package vmsnapshot provides ZCP VM snapshot API operations. +// Package vmsnapshot provides ZCP VM snapshot API operations (STKCNSL). package vmsnapshot import ( "context" + "encoding/json" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// VMSnapshot represents a ZCP VM snapshot (whole-machine snapshot). -type VMSnapshot struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - IsCurrent bool `json:"isCurrent"` - JobID string `json:"jobId"` - ZoneUUID string `json:"zoneUuid"` - DomainName string `json:"domainName"` - CreatedAt string `json:"createdTimeStamp"` +// ---------- Response envelope ---------- + +// Envelope wraps paginated STKCNSL responses. +type Envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + CurrentPage int `json:"current_page"` + Data json.RawMessage `json:"data"` + Total int `json:"total"` } -// DeleteResponse is returned when deleting a VM snapshot. -type DeleteResponse struct { - UUID string `json:"uuid"` - Status string `json:"status"` +// ActionResponse wraps simple action responses (revert). +type ActionResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Timezone string `json:"timezone"` + Data interface{} `json:"data"` } +// ---------- Types ---------- + +// VMSnapshot represents a STKCNSL VM snapshot. +type VMSnapshot struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + VirtualMachineID string `json:"virtual_machine_id"` + State string `json:"state"` + ServiceName string `json:"service_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` +} + +// ---------- Request types ---------- + // CreateRequest holds parameters for creating a VM snapshot. type CreateRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - VirtualMachineUUID string `json:"virtualmachineUuid"` - Description string `json:"description,omitempty"` - SnapshotMemory bool `json:"snapshotMemory"` + Name string `json:"name"` + BillingCycle string `json:"billing_cycle"` + Plan string `json:"plan"` + IsMemory bool `json:"is_memory"` + IsVMSnapshot bool `json:"is_vm_snapshot"` + Project string `json:"project"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + Service string `json:"service"` + Coupon *string `json:"coupon"` } -type listVMSnapshotResponse struct { - Count int `json:"count"` - ListVmSnapshotResponse []VMSnapshot `json:"listVmSnapshotResponse"` -} +// ---------- Service ---------- // Service provides VM snapshot API operations. type Service struct { @@ -53,51 +80,41 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns VM snapshots. zoneUUID and snapshotUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, snapshotUUID string) ([]VMSnapshot, error) { - q := url.Values{} - if zoneUUID != "" { - q.Set("zoneUuid", zoneUUID) - } - if snapshotUUID != "" { - q.Set("uuid", snapshotUUID) - } - var resp listVMSnapshotResponse - if err := s.client.Get(ctx, "/restapi/vmsnapshot/vmsnapshotList", q, &resp); err != nil { +// List returns all VM snapshots. +func (s *Service) List(ctx context.Context) ([]VMSnapshot, error) { + var env Envelope + if err := s.client.Get(ctx, "/virtual-machines/snapshots", nil, &env); err != nil { return nil, fmt.Errorf("listing VM snapshots: %w", err) } - return resp.ListVmSnapshotResponse, nil + var snapshots []VMSnapshot + if err := json.Unmarshal(env.Data, &snapshots); err != nil { + return nil, fmt.Errorf("decoding VM snapshots: %w", err) + } + return snapshots, nil } -// Create creates a new VM snapshot. Returns the snapshot including jobId for async tracking. -func (s *Service) Create(ctx context.Context, req CreateRequest) (*VMSnapshot, error) { - var resp listVMSnapshotResponse - if err := s.client.Post(ctx, "/restapi/vmsnapshot/createVmSnapshot", req, &resp); err != nil { - return nil, fmt.Errorf("creating VM snapshot: %w", err) - } - if len(resp.ListVmSnapshotResponse) == 0 { - return nil, fmt.Errorf("create VM snapshot returned empty response") +// Create creates a new VM snapshot on the given VM slug. +func (s *Service) Create(ctx context.Context, vmSlug string, req CreateRequest) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/"+vmSlug+"/snapshots", req, &resp); err != nil { + return nil, fmt.Errorf("creating VM snapshot on %s: %w", vmSlug, err) } - return &resp.ListVmSnapshotResponse[0], nil + return &resp, nil } -// Delete permanently removes a VM snapshot. -func (s *Service) Delete(ctx context.Context, uuid string) (*DeleteResponse, error) { - if err := s.client.Delete(ctx, "/restapi/vmsnapshot/deleteVmSnapshot/"+uuid, nil); err != nil { - return nil, fmt.Errorf("deleting VM snapshot %s: %w", uuid, err) +// Delete permanently removes a VM snapshot by slug. +func (s *Service) Delete(ctx context.Context, snapshotSlug string) error { + if err := s.client.Delete(ctx, "/virtual-machines/snapshots/"+snapshotSlug, nil); err != nil { + return fmt.Errorf("deleting VM snapshot %s: %w", snapshotSlug, err) } - return &DeleteResponse{UUID: uuid, Status: "deleted"}, nil + return nil } -// Revert reverts an instance to a VM snapshot state (async — check jobId). -func (s *Service) Revert(ctx context.Context, uuid string) (*VMSnapshot, error) { - q := url.Values{"uuid": {uuid}} - var resp listVMSnapshotResponse - if err := s.client.Get(ctx, "/restapi/vmsnapshot/revertToVmSnapshot", q, &resp); err != nil { - return nil, fmt.Errorf("reverting to VM snapshot %s: %w", uuid, err) - } - if len(resp.ListVmSnapshotResponse) == 0 { - return nil, fmt.Errorf("revert returned empty response") +// Revert reverts an instance to a VM snapshot state. +func (s *Service) Revert(ctx context.Context, snapshotSlug string) (*ActionResponse, error) { + var resp ActionResponse + if err := s.client.Post(ctx, "/virtual-machines/snapshots/"+snapshotSlug+"/revert", nil, &resp); err != nil { + return nil, fmt.Errorf("reverting VM snapshot %s: %w", snapshotSlug, err) } - return &resp.ListVmSnapshotResponse[0], nil + return &resp, nil } diff --git a/internal/api/vmsnapshot/vmsnapshot_test.go b/internal/api/vmsnapshot/vmsnapshot_test.go index 332ccf3..d03c1bd 100644 --- a/internal/api/vmsnapshot/vmsnapshot_test.go +++ b/internal/api/vmsnapshot/vmsnapshot_test.go @@ -14,100 +14,86 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listVMSnapshotResponse struct { - Count int `json:"count"` - ListVmSnapshotResponse []vmsnapshot.VMSnapshot `json:"listVmSnapshotResponse"` -} - func TestVMSnapshotList(t *testing.T) { expected := []vmsnapshot.VMSnapshot{ - {UUID: "vmsnap-1", Name: "snap-a", ZoneUUID: "zone-1", Status: "Ready"}, - {UUID: "vmsnap-2", Name: "snap-b", ZoneUUID: "zone-1", Status: "Ready"}, + {ID: "id-1", Name: "snap-a", Slug: "snap-a", State: "Ready", RegionID: "rgn-1", VirtualMachineID: "vm-1"}, + {ID: "id-2", Name: "snap-b", Slug: "snap-b", State: "Ready", RegionID: "rgn-1", VirtualMachineID: "vm-2"}, } - var gotZone string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vmsnapshot/vmsnapshotList" { + if r.URL.Path != "/virtual-machines/snapshots" { http.NotFound(w, r) return } - gotZone = r.URL.Query().Get("zoneUuid") + data, _ := json.Marshal(expected) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVMSnapshotResponse{Count: len(expected), ListVmSnapshotResponse: expected}) + json.NewEncoder(w).Encode(vmsnapshot.Envelope{ + Status: "Success", + Message: "ok", + Data: data, + Total: len(expected), + }) })) defer srv.Close() svc := vmsnapshot.NewService(newClient(srv.URL)) - snaps, err := svc.List(context.Background(), "zone-1", "") + snaps, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(snaps) != 2 { t.Fatalf("List() returned %d snapshots, want 2", len(snaps)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if snaps[0].UUID != "vmsnap-1" { - t.Errorf("snaps[0].UUID = %q, want %q", snaps[0].UUID, "vmsnap-1") + if snaps[0].Slug != "snap-a" { + t.Errorf("snaps[0].Slug = %q, want %q", snaps[0].Slug, "snap-a") } } func TestVMSnapshotCreate(t *testing.T) { - created := vmsnapshot.VMSnapshot{ - UUID: "vmsnap-new", - Name: "my-vmsnap", - ZoneUUID: "zone-1", - JobID: "job-abc", - Status: "Creating", - } - + var gotPath, gotMethod string var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/vmsnapshot/createVmSnapshot" { - http.NotFound(w, r) - return - } + gotMethod = r.Method + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVMSnapshotResponse{Count: 1, ListVmSnapshotResponse: []vmsnapshot.VMSnapshot{created}}) + json.NewEncoder(w).Encode(vmsnapshot.ActionResponse{ + Status: "Success", + Message: "Snapshot creation initiated", + }) })) defer srv.Close() svc := vmsnapshot.NewService(newClient(srv.URL)) req := vmsnapshot.CreateRequest{ - Name: "my-vmsnap", - ZoneUUID: "zone-1", - VirtualMachineUUID: "vm-1", - Description: "test snapshot", - SnapshotMemory: false, - } - snap, err := svc.Create(context.Background(), req) + Name: "my-vmsnap", + BillingCycle: "monthly", + Plan: "basic", + IsMemory: false, + IsVMSnapshot: true, + Project: "proj-1", + } + resp, err := svc.Create(context.Background(), "my-vm", req) if err != nil { t.Fatalf("Create() error = %v", err) } - if snap.UUID != "vmsnap-new" { - t.Errorf("snap.UUID = %q, want %q", snap.UUID, "vmsnap-new") + if resp.Status != "Success" { + t.Errorf("resp.Status = %q, want %q", resp.Status, "Success") } - if gotBody["name"] != "my-vmsnap" { - t.Errorf("body name = %v, want %q", gotBody["name"], "my-vmsnap") + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body zoneUuid = %v, want %q", gotBody["zoneUuid"], "zone-1") + if gotPath != "/virtual-machines/my-vm/snapshots" { + t.Errorf("path = %q, want %q", gotPath, "/virtual-machines/my-vm/snapshots") } - if gotBody["virtualmachineUuid"] != "vm-1" { - t.Errorf("body virtualmachineUuid = %v, want %q", gotBody["virtualmachineUuid"], "vm-1") + if gotBody["name"] != "my-vmsnap" { + t.Errorf("body name = %v, want %q", gotBody["name"], "my-vmsnap") } } @@ -121,51 +107,43 @@ func TestVMSnapshotDelete(t *testing.T) { defer srv.Close() svc := vmsnapshot.NewService(newClient(srv.URL)) - resp, err := svc.Delete(context.Background(), "vmsnap-del-1") + err := svc.Delete(context.Background(), "snap-del-1") if err != nil { t.Fatalf("Delete() error = %v", err) } - if resp == nil { - t.Fatal("Delete() returned nil response") - } if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/vmsnapshot/deleteVmSnapshot/vmsnap-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vmsnapshot/deleteVmSnapshot/vmsnap-del-1") + if gotPath != "/virtual-machines/snapshots/snap-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/virtual-machines/snapshots/snap-del-1") } } func TestVMSnapshotRevert(t *testing.T) { - reverted := vmsnapshot.VMSnapshot{ - UUID: "vmsnap-1", - Name: "snap-a", - ZoneUUID: "zone-1", - JobID: "job-revert", - Status: "Reverting", - } - - var gotUUID string + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vmsnapshot/revertToVmSnapshot" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") + gotMethod = r.Method + gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVMSnapshotResponse{Count: 1, ListVmSnapshotResponse: []vmsnapshot.VMSnapshot{reverted}}) + json.NewEncoder(w).Encode(vmsnapshot.ActionResponse{ + Status: "Success", + Message: "Revert initiated", + }) })) defer srv.Close() svc := vmsnapshot.NewService(newClient(srv.URL)) - snap, err := svc.Revert(context.Background(), "vmsnap-1") + resp, err := svc.Revert(context.Background(), "snap-1") if err != nil { t.Fatalf("Revert() error = %v", err) } - if snap.UUID != "vmsnap-1" { - t.Errorf("snap.UUID = %q, want %q", snap.UUID, "vmsnap-1") + if resp.Status != "Success" { + t.Errorf("resp.Status = %q, want %q", resp.Status, "Success") + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) } - if gotUUID != "vmsnap-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vmsnap-1") + if gotPath != "/virtual-machines/snapshots/snap-1/revert" { + t.Errorf("path = %q, want %q", gotPath, "/virtual-machines/snapshots/snap-1/revert") } } diff --git a/internal/api/volume/volume.go b/internal/api/volume/volume.go index efdade9..0b00812 100644 --- a/internal/api/volume/volume.go +++ b/internal/api/volume/volume.go @@ -1,55 +1,134 @@ -// Package volume provides ZCP volume API operations. +// Package volume provides ZCP block storage (volume) API operations +// targeting the STKCNSL API. package volume import ( "context" "fmt" "net/url" - "strconv" "github.com/zsoftly/zcp-cli/internal/httpclient" ) -// Volume represents a ZCP data volume. +// StorageSetting represents the storage configuration for a block storage volume. +type StorageSetting struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + StorageCategoryID string `json:"storage_category_id"` + RegionID string `json:"region_id"` +} + +// Region represents the region where the volume is deployed. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` +} + +// CloudProvider represents the cloud provider for the volume. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// BillingCycle represents a billing cycle attached to an offering. +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Offering represents the billing/plan offering on a volume. +type Offering struct { + ID string `json:"id"` + Size string `json:"size"` + Price string `json:"price"` + BillingStatus bool `json:"billing_status"` + RenewAt string `json:"renew_at"` + BillingCycle *BillingCycle `json:"billing_cycle"` +} + +// Volume represents a STKCNSL block storage volume. type Volume struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Status string `json:"status"` - IsActive bool `json:"isActive"` - VolumeType string `json:"volumeType"` - StorageDiskSize string `json:"storageDiskSize"` - StorageOfferingUUID string `json:"storageOfferingUuid"` - StorageOfferingName string `json:"storageOfferingName"` - VMInstanceUUID string `json:"vmUuid"` - VMInstanceName string `json:"vmInstanceName"` - ZoneUUID string `json:"zoneUuid"` - DomainName string `json:"domainName"` - JobID string `json:"jobId"` - CreatedAt int64 `json:"createdTimeStamp"` - ErrorMessage string `json:"errorMessage"` - IsShrink bool `json:"isShrink"` -} - -// DeleteResponse is returned when deleting a volume. -type DeleteResponse struct { - UUID string `json:"uuid"` - Status string `json:"status"` -} - -// CreateRequest holds parameters for creating a volume. + ID string `json:"id"` + BlockstorageID string `json:"blockstorage_id"` + VirtualMachineID string `json:"virtual_machine_id"` + Size string `json:"size"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + RequestStatus bool `json:"request_status"` + VolumeType string `json:"volume_type"` + Bootable bool `json:"bootable"` + IsRoot bool `json:"is_root"` + IsSnapshotHidden bool `json:"is_snapshot_hidden"` + IsRestricted bool `json:"is_restricted"` + IsResizeEnable bool `json:"is_resize_enable"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + AllTimeConsumption float64 `json:"all_time_consumption"` + HasContract bool `json:"has_contract"` + IsServiceTrialExpired bool `json:"is_service_trial_expired"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` + StorageSetting *StorageSetting `json:"storage_setting"` + CloudProvider *CloudProvider `json:"cloud_provider"` + Region *Region `json:"region"` + Offering *Offering `json:"offering"` +} + +// listResponse is the STKCNSL paginated envelope for block storage volumes. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Volume `json:"data"` + Total int `json:"total"` +} + +// singleResponse is used when the API returns a single volume in `data`. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Volume `json:"data"` +} + +// CreateRequest holds parameters for creating a block storage volume. type CreateRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - StorageOfferingUUID string `json:"storageOfferingUuid"` - DiskSize int `json:"diskSize,omitempty"` + Name string `json:"name"` + Project string `json:"project"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + BillingCycle string `json:"billing_cycle"` + StorageCategory string `json:"storage_category"` + Plan string `json:"plan"` + IsCustomPlan bool `json:"is_custom_plan"` + CustomPlan string `json:"custom_plan,omitempty"` + VirtualMachine string `json:"virtual_machine,omitempty"` + Coupon string `json:"coupon,omitempty"` + IsFreeTrial bool `json:"is_free_trial_plan"` } -type listVolumeResponse struct { - Count int `json:"count"` - ListVolumeResponse []Volume `json:"listVolumeResponse"` +// AttachRequest holds parameters for attaching a volume to a VM. +type AttachRequest struct { + VirtualMachine string `json:"virtual_machine"` } -// Service provides volume API operations. +// Service provides block storage API operations. type Service struct { client *httpclient.Client } @@ -59,83 +138,44 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns volumes. zoneUUID is required. vmUUID and volumeUUID are optional filters. -func (s *Service) List(ctx context.Context, zoneUUID, vmUUID, volumeUUID string) ([]Volume, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if vmUUID != "" { - q.Set("vmUuid", vmUUID) - } - if volumeUUID != "" { - q.Set("uuid", volumeUUID) +// List returns block storage volumes. Use the include parameter to embed related resources. +func (s *Service) List(ctx context.Context) ([]Volume, error) { + q := url.Values{ + "include": {"cloud_provider,region,virtual_machine,project,snapshots,offering"}, } - var resp listVolumeResponse - if err := s.client.Get(ctx, "/restapi/volume/volumeList", q, &resp); err != nil { - return nil, fmt.Errorf("listing volumes: %w", err) + var resp listResponse + if err := s.client.Get(ctx, "/blockstorages", q, &resp); err != nil { + return nil, fmt.Errorf("listing block storages: %w", err) } - return resp.ListVolumeResponse, nil + return resp.Data, nil } -// Create creates a new data volume. Returns the volume (may include jobId for async tracking). +// Create creates a new block storage volume. func (s *Service) Create(ctx context.Context, req CreateRequest) (*Volume, error) { - var resp listVolumeResponse - if err := s.client.Post(ctx, "/restapi/volume/createVolume", req, &resp); err != nil { - return nil, fmt.Errorf("creating volume: %w", err) - } - if len(resp.ListVolumeResponse) == 0 { - return nil, fmt.Errorf("create volume returned empty response") - } - return &resp.ListVolumeResponse[0], nil -} - -// Attach attaches a volume to an instance. -func (s *Service) Attach(ctx context.Context, volumeUUID, instanceUUID string) (*Volume, error) { - q := url.Values{"uuid": {volumeUUID}, "instanceUuid": {instanceUUID}} - var resp listVolumeResponse - if err := s.client.Get(ctx, "/restapi/volume/attachVolume", q, &resp); err != nil { - return nil, fmt.Errorf("attaching volume %s to instance %s: %w", volumeUUID, instanceUUID, err) - } - if len(resp.ListVolumeResponse) == 0 { - return nil, fmt.Errorf("attach volume returned empty response") + var resp singleResponse + if err := s.client.Post(ctx, "/blockstorages", req, &resp); err != nil { + return nil, fmt.Errorf("creating block storage: %w", err) } - return &resp.ListVolumeResponse[0], nil + return &resp.Data, nil } -// Detach detaches a volume from its instance. -func (s *Service) Detach(ctx context.Context, volumeUUID string) (*Volume, error) { - q := url.Values{"uuid": {volumeUUID}} - var resp listVolumeResponse - if err := s.client.Get(ctx, "/restapi/volume/detachVolume", q, &resp); err != nil { - return nil, fmt.Errorf("detaching volume %s: %w", volumeUUID, err) +// Attach attaches a volume to a virtual machine. +func (s *Service) Attach(ctx context.Context, volumeSlug, vmSlug string) (*Volume, error) { + body := AttachRequest{VirtualMachine: vmSlug} + var resp singleResponse + path := fmt.Sprintf("/blockstorages/%s/attach", volumeSlug) + if err := s.client.Post(ctx, path, body, &resp); err != nil { + return nil, fmt.Errorf("attaching block storage %s to VM %s: %w", volumeSlug, vmSlug, err) } - if len(resp.ListVolumeResponse) == 0 { - return nil, fmt.Errorf("detach volume returned empty response") - } - return &resp.ListVolumeResponse[0], nil -} - -// Delete deletes a volume permanently. -func (s *Service) Delete(ctx context.Context, uuid string) (*DeleteResponse, error) { - if err := s.client.Delete(ctx, "/restapi/volume/deleteVolume/"+uuid, nil); err != nil { - return nil, fmt.Errorf("deleting volume %s: %w", uuid, err) - } - return &DeleteResponse{UUID: uuid, Status: "deleted"}, nil + return &resp.Data, nil } -// Resize changes a volume's storage offering or disk size. -func (s *Service) Resize(ctx context.Context, uuid, storageOfferingUUID string, diskSize int, isShrink bool) (*Volume, error) { - q := url.Values{"uuid": {uuid}, "storageOfferingUuid": {storageOfferingUUID}} - if diskSize > 0 { - q.Set("diskSize", strconv.Itoa(diskSize)) - } - if isShrink { - q.Set("isShrink", "true") - } - var resp listVolumeResponse - if err := s.client.Get(ctx, "/restapi/volume/resizeVolume", q, &resp); err != nil { - return nil, fmt.Errorf("resizing volume %s: %w", uuid, err) - } - if len(resp.ListVolumeResponse) == 0 { - return nil, fmt.Errorf("resize volume returned empty response") +// Detach detaches a volume from its virtual machine. +func (s *Service) Detach(ctx context.Context, volumeSlug string) (*Volume, error) { + var resp singleResponse + path := fmt.Sprintf("/blockstorages/%s/detach", volumeSlug) + if err := s.client.Post(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("detaching block storage %s: %w", volumeSlug, err) } - return &resp.ListVolumeResponse[0], nil + return &resp.Data, nil } diff --git a/internal/api/volume/volume_test.go b/internal/api/volume/volume_test.go index a3e1ffd..b6e0c8a 100644 --- a/internal/api/volume/volume_test.go +++ b/internal/api/volume/volume_test.go @@ -12,84 +12,77 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -type listVolumeResponse struct { - Count int `json:"count"` - ListVolumeResponse []volume.Volume `json:"listVolumeResponse"` +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []volume.Volume `json:"data"` + Total int `json:"total"` +} + +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data volume.Volume `json:"data"` } func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { t.Helper() return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } func TestVolumeList(t *testing.T) { expected := []volume.Volume{ - {UUID: "vol-1", Name: "disk-a", Status: "Ready", ZoneUUID: "zone-1", VolumeType: "DATADISK"}, - {UUID: "vol-2", Name: "disk-b", Status: "Ready", ZoneUUID: "zone-1", VolumeType: "DATADISK"}, + {ID: "vol-1", Name: "ROOT-4153", Slug: "root-4153", Size: "50", VolumeType: "ROOT"}, + {ID: "vol-2", Name: "data-disk", Slug: "data-disk", Size: "100", VolumeType: "DATA"}, } - var gotZone string + var gotInclude string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/volume/volumeList" { + if r.URL.Path != "/blockstorages" { http.NotFound(w, r) return } - gotZone = r.URL.Query().Get("zoneUuid") + gotInclude = r.URL.Query().Get("include") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: len(expected), ListVolumeResponse: expected}) + json.NewEncoder(w).Encode(listResponse{ + Status: "Success", + Message: "Ok", + Data: expected, + Total: len(expected), + }) })) defer srv.Close() svc := volume.NewService(newTestClient(t, srv)) - volumes, err := svc.List(context.Background(), "zone-1", "", "") + volumes, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(volumes) != 2 { t.Fatalf("List() returned %d volumes, want 2", len(volumes)) } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") + if gotInclude == "" { + t.Error("include query param was empty, expected relations") } - if volumes[0].UUID != "vol-1" { - t.Errorf("volumes[0].UUID = %q, want %q", volumes[0].UUID, "vol-1") + if volumes[0].ID != "vol-1" { + t.Errorf("volumes[0].ID = %q, want %q", volumes[0].ID, "vol-1") } -} - -func TestVolumeListOptionalFilters(t *testing.T) { - var gotVM, gotUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotVM = r.URL.Query().Get("vmUuid") - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: 0, ListVolumeResponse: nil}) - })) - defer srv.Close() - - svc := volume.NewService(newTestClient(t, srv)) - svc.List(context.Background(), "zone-1", "vm-abc", "vol-xyz") - - if gotVM != "vm-abc" { - t.Errorf("vmUuid query param = %q, want %q", gotVM, "vm-abc") - } - if gotUUID != "vol-xyz" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vol-xyz") + if volumes[0].VolumeType != "ROOT" { + t.Errorf("volumes[0].VolumeType = %q, want %q", volumes[0].VolumeType, "ROOT") } } func TestVolumeCreate(t *testing.T) { expectedVol := volume.Volume{ - UUID: "vol-new", - Name: "my-disk", - Status: "Pending", - ZoneUUID: "zone-1", - StorageOfferingUUID: "offer-1", - JobID: "job-123", + ID: "vol-new", + Name: "my-volume", + Slug: "my-volume", + Size: "50", } var gotBody map[string]interface{} @@ -98,162 +91,100 @@ func TestVolumeCreate(t *testing.T) { http.Error(w, "want POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/volume/createVolume" { + if r.URL.Path != "/blockstorages" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: 1, ListVolumeResponse: []volume.Volume{expectedVol}}) + json.NewEncoder(w).Encode(singleResponse{ + Status: "Success", + Message: "Ok", + Data: expectedVol, + }) })) defer srv.Close() svc := volume.NewService(newTestClient(t, srv)) req := volume.CreateRequest{ - Name: "my-disk", - ZoneUUID: "zone-1", - StorageOfferingUUID: "offer-1", - DiskSize: 50, + Name: "my-volume", + Project: "default-73", + CloudProvider: "nimbo", + Region: "noida", + BillingCycle: "hourly", + StorageCategory: "nvme", + Plan: "50-gb-2", } vol, err := svc.Create(context.Background(), req) if err != nil { t.Fatalf("Create() error = %v", err) } - if vol.UUID != "vol-new" { - t.Errorf("vol.UUID = %q, want %q", vol.UUID, "vol-new") + if vol.ID != "vol-new" { + t.Errorf("vol.ID = %q, want %q", vol.ID, "vol-new") } - if vol.JobID != "job-123" { - t.Errorf("vol.JobID = %q, want %q", vol.JobID, "job-123") + if gotBody["name"] != "my-volume" { + t.Errorf("body name = %v, want %q", gotBody["name"], "my-volume") } - if gotBody["name"] != "my-disk" { - t.Errorf("body name = %v, want %q", gotBody["name"], "my-disk") - } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body zoneUuid = %v, want %q", gotBody["zoneUuid"], "zone-1") - } - if gotBody["storageOfferingUuid"] != "offer-1" { - t.Errorf("body storageOfferingUuid = %v, want %q", gotBody["storageOfferingUuid"], "offer-1") + if gotBody["cloud_provider"] != "nimbo" { + t.Errorf("body cloud_provider = %v, want %q", gotBody["cloud_provider"], "nimbo") } } func TestVolumeAttach(t *testing.T) { - var gotVolumeUUID, gotInstanceUUID string + var gotPath string + var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/volume/attachVolume" { - http.NotFound(w, r) + if r.Method != http.MethodPost { + http.Error(w, "want POST", http.StatusMethodNotAllowed) return } - gotVolumeUUID = r.URL.Query().Get("uuid") - gotInstanceUUID = r.URL.Query().Get("instanceUuid") - result := volume.Volume{UUID: "vol-1", Status: "Attached", VMInstanceUUID: "vm-1"} + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) + result := volume.Volume{ID: "vol-1", Slug: "root-4153", VirtualMachineID: "vm-1"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: 1, ListVolumeResponse: []volume.Volume{result}}) + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Message: "Ok", Data: result}) })) defer srv.Close() svc := volume.NewService(newTestClient(t, srv)) - vol, err := svc.Attach(context.Background(), "vol-1", "vm-1") + vol, err := svc.Attach(context.Background(), "root-4153", "test-vm-1") if err != nil { t.Fatalf("Attach() error = %v", err) } - if gotVolumeUUID != "vol-1" { - t.Errorf("uuid query param = %q, want %q", gotVolumeUUID, "vol-1") + if gotPath != "/blockstorages/root-4153/attach" { + t.Errorf("path = %q, want %q", gotPath, "/blockstorages/root-4153/attach") } - if gotInstanceUUID != "vm-1" { - t.Errorf("instanceUuid query param = %q, want %q", gotInstanceUUID, "vm-1") + if gotBody["virtual_machine"] != "test-vm-1" { + t.Errorf("body virtual_machine = %v, want %q", gotBody["virtual_machine"], "test-vm-1") } - if vol.VMInstanceUUID != "vm-1" { - t.Errorf("vol.VMInstanceUUID = %q, want %q", vol.VMInstanceUUID, "vm-1") + if vol.VirtualMachineID != "vm-1" { + t.Errorf("vol.VirtualMachineID = %q, want %q", vol.VirtualMachineID, "vm-1") } } func TestVolumeDetach(t *testing.T) { - var gotUUID string + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/volume/detachVolume" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - result := volume.Volume{UUID: "vol-1", Status: "Detached"} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: 1, ListVolumeResponse: []volume.Volume{result}}) - })) - defer srv.Close() - - svc := volume.NewService(newTestClient(t, srv)) - vol, err := svc.Detach(context.Background(), "vol-1") - if err != nil { - t.Fatalf("Detach() error = %v", err) - } - if gotUUID != "vol-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vol-1") - } - if vol.Status != "Detached" { - t.Errorf("vol.Status = %q, want %q", vol.Status, "Detached") - } -} - -func TestVolumeDelete(t *testing.T) { - var gotPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - http.Error(w, "want DELETE", http.StatusMethodNotAllowed) - return - } + gotMethod = r.Method gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - svc := volume.NewService(newTestClient(t, srv)) - resp, err := svc.Delete(context.Background(), "vol-abc") - if err != nil { - t.Fatalf("Delete() error = %v", err) - } - if gotPath != "/restapi/volume/deleteVolume/vol-abc" { - t.Errorf("DELETE path = %q, want %q", gotPath, "/restapi/volume/deleteVolume/vol-abc") - } - if resp == nil { - t.Fatal("Delete() returned nil response") - } -} - -func TestVolumeResize(t *testing.T) { - var gotUUID, gotOffering, gotDiskSize, gotShrink string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/volume/resizeVolume" { - http.NotFound(w, r) - return - } - gotUUID = r.URL.Query().Get("uuid") - gotOffering = r.URL.Query().Get("storageOfferingUuid") - gotDiskSize = r.URL.Query().Get("diskSize") - gotShrink = r.URL.Query().Get("isShrink") - result := volume.Volume{UUID: "vol-1", StorageDiskSize: "100"} + result := volume.Volume{ID: "vol-1", Slug: "root-4153"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVolumeResponse{Count: 1, ListVolumeResponse: []volume.Volume{result}}) + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Message: "Ok", Data: result}) })) defer srv.Close() svc := volume.NewService(newTestClient(t, srv)) - vol, err := svc.Resize(context.Background(), "vol-1", "offer-2", 100, true) + vol, err := svc.Detach(context.Background(), "root-4153") if err != nil { - t.Fatalf("Resize() error = %v", err) - } - if gotUUID != "vol-1" { - t.Errorf("uuid param = %q, want %q", gotUUID, "vol-1") - } - if gotOffering != "offer-2" { - t.Errorf("storageOfferingUuid param = %q, want %q", gotOffering, "offer-2") + t.Fatalf("Detach() error = %v", err) } - if gotDiskSize != "100" { - t.Errorf("diskSize param = %q, want %q", gotDiskSize, "100") + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) } - if gotShrink != "true" { - t.Errorf("isShrink param = %q, want %q", gotShrink, "true") + if gotPath != "/blockstorages/root-4153/detach" { + t.Errorf("path = %q, want %q", gotPath, "/blockstorages/root-4153/detach") } - if vol.StorageDiskSize != "100" { - t.Errorf("vol.StorageDiskSize = %q, want %q", vol.StorageDiskSize, "100") + if vol.Slug != "root-4153" { + t.Errorf("vol.Slug = %q, want %q", vol.Slug, "root-4153") } } diff --git a/internal/api/vpc/vpc.go b/internal/api/vpc/vpc.go index c1ed80c..9a649b6 100644 --- a/internal/api/vpc/vpc.go +++ b/internal/api/vpc/vpc.go @@ -3,6 +3,7 @@ package vpc import ( "context" + "encoding/json" "fmt" "net/url" @@ -11,13 +12,11 @@ import ( // VPC represents a ZCP Virtual Private Cloud. type VPC struct { - UUID string `json:"uuid"` + Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` - IsActive bool `json:"isActive"` - CIDR string `json:"cIDR"` - ZoneUUID string `json:"zoneUuid"` + CIDR string `json:"cidr"` ZoneName string `json:"zoneName"` DomainName string `json:"domainName"` } @@ -25,55 +24,72 @@ type VPC struct { // CreateRequest holds parameters for creating a VPC. type CreateRequest struct { Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - VPCOfferingUUID string `json:"vpcOfferingUuid"` - CIDR string `json:"cIDR"` - Description string `json:"description"` + ZoneSlug string `json:"zoneSlug"` + VPCOfferingSlug string `json:"vpcOfferingSlug"` + CIDR string `json:"cidr"` + Description string `json:"description,omitempty"` NetworkDomain string `json:"networkDomain,omitempty"` - PublicLoadBalancerProvider string `json:"publicLoadBalancerProvider"` + PublicLoadBalancerProvider string `json:"publicLoadBalancerProvider,omitempty"` } -// CreateNetworkRequest holds parameters for creating a VPC tier network. -type CreateNetworkRequest struct { - Name string `json:"name"` - ZoneUUID string `json:"zoneUuid"` - NetworkOfferingUUID string `json:"networkOfferingUuid"` - VPCUUID string `json:"vpcUuid"` - Gateway string `json:"gateway"` - Netmask string `json:"netmask"` - ACLUUID string `json:"aclUuid,omitempty"` +// UpdateRequest holds parameters for updating a VPC. +type UpdateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` } -// VPCNetwork represents a network tier inside a VPC. -type VPCNetwork struct { - UUID string `json:"uuid"` - Name string `json:"name"` - IsActive bool `json:"isActive"` - DomainName string `json:"domainName"` - CIDR string `json:"cIDR"` - Gateway string `json:"gateway"` - NetworkType string `json:"networkType"` - NetworkOfferingUUID string `json:"networkOfferingUuid"` - ZoneUUID string `json:"zoneUuid"` - NetworkDomain string `json:"networkDomain"` - Status string `json:"status"` +// NetworkACL represents a network ACL inside a VPC. +type NetworkACL struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` } -type listVpcNetworkResponse struct { - Count int `json:"count"` - ListNetworkResponse []VPCNetwork `json:"listNetworkResponse"` +// ACLRuleCreateRequest holds parameters for creating a network ACL rule. +type ACLRuleCreateRequest struct { + Protocol string `json:"protocol"` + CIDRList string `json:"cidrList,omitempty"` + StartPort int `json:"startPort,omitempty"` + EndPort int `json:"endPort,omitempty"` + TrafficType string `json:"trafficType,omitempty"` + Action string `json:"action"` + Number int `json:"number,omitempty"` + ICMPCode int `json:"icmpCode,omitempty"` + ICMPType int `json:"icmpType,omitempty"` } -// UpdateRequest holds parameters for updating a VPC. -type UpdateRequest struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description,omitempty"` +// ACLRule represents a single ACL rule. +type ACLRule struct { + Slug string `json:"slug"` + Protocol string `json:"protocol"` + CIDRList string `json:"cidrList"` + StartPort int `json:"startPort"` + EndPort int `json:"endPort"` + TrafficType string `json:"trafficType"` + Action string `json:"action"` + Number int `json:"number"` +} + +// VPNGateway represents a VPN gateway attached to a VPC. +type VPNGateway struct { + Slug string `json:"slug"` + PublicIP string `json:"publicIpAddress"` + VPCUUID string `json:"vpcUuid"` + VPCSlug string `json:"vpcSlug"` + ZoneName string `json:"zoneName"` + Status string `json:"status"` +} + +// VPNGatewayCreateRequest holds parameters for creating a VPN gateway. +type VPNGatewayCreateRequest struct { + // Intentionally empty — the VPC slug is in the URL path. } -type listVpcResponse struct { - Count int `json:"count"` - ListVpcResponse []VPC `json:"listVpcResponse"` +// apiResponse is the STKCNSL response envelope. +type apiResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` } // Service provides VPC API operations. @@ -86,118 +102,149 @@ func NewService(client *httpclient.Client) *Service { return &Service{client: client} } -// List returns VPCs in a zone. zoneUUID is required; uuid is an optional filter. -func (s *Service) List(ctx context.Context, zoneUUID, uuid string) ([]VPC, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) +// List returns all VPCs. zoneSlug is an optional filter. +func (s *Service) List(ctx context.Context, zoneSlug string) ([]VPC, error) { + var q url.Values + if zoneSlug != "" { + q = url.Values{"zoneSlug": {zoneSlug}} } - var resp listVpcResponse - if err := s.client.Get(ctx, "/restapi/vpc/vpcList", q, &resp); err != nil { + var env apiResponse + if err := s.client.Get(ctx, "/vpcs", q, &env); err != nil { return nil, fmt.Errorf("listing VPCs: %w", err) } - return resp.ListVpcResponse, nil + var vpcs []VPC + if err := json.Unmarshal(env.Data, &vpcs); err != nil { + return nil, fmt.Errorf("decoding VPC list: %w", err) + } + return vpcs, nil } -// Get returns a single VPC by UUID using the dedicated vpcId endpoint. -func (s *Service) Get(ctx context.Context, uuid string) (*VPC, error) { - q := url.Values{"uuid": {uuid}} - var resp listVpcResponse - if err := s.client.Get(ctx, "/restapi/vpc/vpcId", q, &resp); err != nil { - return nil, fmt.Errorf("getting VPC %s: %w", uuid, err) +// Get returns a single VPC by slug. +func (s *Service) Get(ctx context.Context, slug string) (*VPC, error) { + vpcs, err := s.List(ctx, "") + if err != nil { + return nil, err } - if len(resp.ListVpcResponse) == 0 { - return nil, fmt.Errorf("VPC %q not found", uuid) + for i := range vpcs { + if vpcs[i].Slug == slug { + return &vpcs[i], nil + } } - return &resp.ListVpcResponse[0], nil + return nil, fmt.Errorf("VPC %q not found", slug) } // Create provisions a new VPC. func (s *Service) Create(ctx context.Context, req CreateRequest) (*VPC, error) { - var resp listVpcResponse - if err := s.client.Post(ctx, "/restapi/vpc/createVpc", req, &resp); err != nil { + var env apiResponse + if err := s.client.Post(ctx, "/vpcs", req, &env); err != nil { return nil, fmt.Errorf("creating VPC: %w", err) } - if len(resp.ListVpcResponse) == 0 { - return nil, fmt.Errorf("create VPC returned empty response") + var v VPC + if err := json.Unmarshal(env.Data, &v); err != nil { + return nil, fmt.Errorf("decoding created VPC: %w", err) } - return &resp.ListVpcResponse[0], nil + return &v, nil } -// CreateNetwork creates a VPC tier network using the dedicated VPC endpoint. -func (s *Service) CreateNetwork(ctx context.Context, req CreateNetworkRequest) (*VPCNetwork, error) { - var resp listVpcNetworkResponse - if err := s.client.Post(ctx, "/restapi/vpc/createVpcNetwork", req, &resp); err != nil { - return nil, fmt.Errorf("creating VPC network: %w", err) +// Update modifies a VPC's mutable attributes. +func (s *Service) Update(ctx context.Context, slug string, req UpdateRequest) (*VPC, error) { + var env apiResponse + if err := s.client.Put(ctx, "/vpcs/"+slug, nil, req, &env); err != nil { + return nil, fmt.Errorf("updating VPC %s: %w", slug, err) } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("create VPC network returned empty response") + var v VPC + if err := json.Unmarshal(env.Data, &v); err != nil { + return nil, fmt.Errorf("decoding updated VPC: %w", err) } - return &resp.ListNetworkResponse[0], nil + return &v, nil } -// UpdateNetworkRequest holds parameters for updating a VPC tier network. -type UpdateNetworkRequest struct { - UUID string `json:"uuid"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - NetworkOfferingUUID string `json:"networkOfferingUuid"` - NetworkDomain string `json:"networkDomain,omitempty"` +// Delete removes a VPC by slug. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/vpcs/"+slug, nil); err != nil { + return fmt.Errorf("deleting VPC %s: %w", slug, err) + } + return nil } -// UpdateNetwork modifies a VPC tier network. -func (s *Service) UpdateNetwork(ctx context.Context, req UpdateNetworkRequest) (*VPCNetwork, error) { - var resp listVpcNetworkResponse - if err := s.client.Put(ctx, "/restapi/vpc/updateVpcNetwork", nil, req, &resp); err != nil { - return nil, fmt.Errorf("updating VPC network: %w", err) +// Restart restarts a VPC by slug. +func (s *Service) Restart(ctx context.Context, slug string) (*VPC, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpcs/"+slug+"/restart", nil, &env); err != nil { + return nil, fmt.Errorf("restarting VPC %s: %w", slug, err) } - if len(resp.ListNetworkResponse) == 0 { - return nil, fmt.Errorf("update VPC network returned empty response") + var v VPC + if err := json.Unmarshal(env.Data, &v); err != nil { + return nil, fmt.Errorf("decoding restarted VPC: %w", err) } - return &resp.ListNetworkResponse[0], nil + return &v, nil } -// Update modifies a VPC's mutable attributes. -func (s *Service) Update(ctx context.Context, req UpdateRequest) (*VPC, error) { - var resp listVpcResponse - if err := s.client.Put(ctx, "/restapi/vpc/updateVpc", nil, req, &resp); err != nil { - return nil, fmt.Errorf("updating VPC %s: %w", req.UUID, err) +// ListACLs returns the network ACLs for a VPC. +func (s *Service) ListACLs(ctx context.Context, vpcSlug string) ([]NetworkACL, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpcs/"+vpcSlug+"/network-acl-list", nil, &env); err != nil { + return nil, fmt.Errorf("listing ACLs for VPC %s: %w", vpcSlug, err) + } + var acls []NetworkACL + if err := json.Unmarshal(env.Data, &acls); err != nil { + return nil, fmt.Errorf("decoding ACL list: %w", err) + } + return acls, nil +} + +// CreateACLRule creates a new ACL rule in a VPC. +func (s *Service) CreateACLRule(ctx context.Context, vpcSlug string, req ACLRuleCreateRequest) (*ACLRule, error) { + var env apiResponse + if err := s.client.Post(ctx, "/vpcs/"+vpcSlug+"/network-acl-list", req, &env); err != nil { + return nil, fmt.Errorf("creating ACL rule in VPC %s: %w", vpcSlug, err) } - if len(resp.ListVpcResponse) == 0 { - return nil, fmt.Errorf("update VPC returned empty response") + var rule ACLRule + if err := json.Unmarshal(env.Data, &rule); err != nil { + return nil, fmt.Errorf("decoding created ACL rule: %w", err) } - return &resp.ListVpcResponse[0], nil + return &rule, nil } -// Delete removes a VPC by UUID. -func (s *Service) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/vpc/deleteVpc/"+uuid, nil); err != nil { - return fmt.Errorf("deleting VPC %s: %w", uuid, err) +// ReplaceNetworkACL replaces the ACL on a network by slug. +func (s *Service) ReplaceNetworkACL(ctx context.Context, networkSlug string, req map[string]string) error { + var env apiResponse + if err := s.client.Post(ctx, "/networks/"+networkSlug+"/replace-acl-list", req, &env); err != nil { + return fmt.Errorf("replacing ACL on network %s: %w", networkSlug, err) } return nil } -// Restart restarts a VPC. cleanUp triggers cleanup; redundant enables redundant router. -func (s *Service) Restart(ctx context.Context, uuid string, cleanUp, redundant bool) (*VPC, error) { - cleanUpStr := "false" - if cleanUp { - cleanUpStr = "true" +// ListVPNGateways returns VPN gateways for a VPC. +func (s *Service) ListVPNGateways(ctx context.Context, vpcSlug string) ([]VPNGateway, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpcs/"+vpcSlug+"/vpn-gateways", nil, &env); err != nil { + return nil, fmt.Errorf("listing VPN gateways for VPC %s: %w", vpcSlug, err) } - redundantStr := "false" - if redundant { - redundantStr = "true" + var gateways []VPNGateway + if err := json.Unmarshal(env.Data, &gateways); err != nil { + return nil, fmt.Errorf("decoding VPN gateway list: %w", err) } - q := url.Values{ - "uuid": {uuid}, - "cleanUpVPC": {cleanUpStr}, - "redundantVpcRouter": {redundantStr}, + return gateways, nil +} + +// CreateVPNGateway creates a VPN gateway for a VPC. +func (s *Service) CreateVPNGateway(ctx context.Context, vpcSlug string) (*VPNGateway, error) { + var env apiResponse + if err := s.client.Post(ctx, "/vpcs/"+vpcSlug+"/vpn-gateways", VPNGatewayCreateRequest{}, &env); err != nil { + return nil, fmt.Errorf("creating VPN gateway for VPC %s: %w", vpcSlug, err) } - var resp listVpcResponse - if err := s.client.Get(ctx, "/restapi/vpc/restartVpc", q, &resp); err != nil { - return nil, fmt.Errorf("restarting VPC %s: %w", uuid, err) + var gw VPNGateway + if err := json.Unmarshal(env.Data, &gw); err != nil { + return nil, fmt.Errorf("decoding created VPN gateway: %w", err) } - if len(resp.ListVpcResponse) == 0 { - return nil, fmt.Errorf("restart VPC returned empty response") + return &gw, nil +} + +// DeleteVPNGateway deletes a VPN gateway from a VPC. +func (s *Service) DeleteVPNGateway(ctx context.Context, vpcSlug, gatewayID string) error { + if err := s.client.Delete(ctx, "/vpcs/"+vpcSlug+"/vpn-gateways/"+gatewayID, nil); err != nil { + return fmt.Errorf("deleting VPN gateway %s from VPC %s: %w", gatewayID, vpcSlug, err) } - return &resp.ListVpcResponse[0], nil + return nil } diff --git a/internal/api/vpc/vpc_test.go b/internal/api/vpc/vpc_test.go index 30212bb..4da9042 100644 --- a/internal/api/vpc/vpc_test.go +++ b/internal/api/vpc/vpc_test.go @@ -14,104 +14,95 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -type listVpcResponse struct { - Count int `json:"count"` - ListVpcResponse []vpc.VPC `json:"listVpcResponse"` +// apiEnvelope mirrors the ZCP response envelope used by the service. +type apiEnvelope struct { + Status string `json:"status"` + Data interface{} `json:"data"` } -func makeVPC(uuid, name string) vpc.VPC { +func makeVPC(slug, name string) vpc.VPC { return vpc.VPC{ - UUID: uuid, - Name: name, - Status: "Enabled", - IsActive: true, - CIDR: "10.0.0.0/8", - ZoneUUID: "zone-uuid-1", - ZoneName: "TestZone", - DomainName: "testdomain.com", + Slug: slug, + Name: name, + Status: "Enabled", + CIDR: "10.0.0.0/8", + ZoneName: "TestZone", + DomainName: "testdomain.com", + Description: "", } } -// TestVPCList verifies URL path, required zoneUuid param, and response parsing. +// TestVPCList verifies URL path, optional zoneSlug param, and response parsing. func TestVPCList(t *testing.T) { vpcs := []vpc.VPC{ - makeVPC("vpc-1", "production-vpc"), - makeVPC("vpc-2", "staging-vpc"), + makeVPC("production-vpc", "production-vpc"), + makeVPC("staging-vpc", "staging-vpc"), } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpc/vpcList" { + if r.URL.Path != "/vpcs" { http.NotFound(w, r) return } - zoneUUID := r.URL.Query().Get("zoneUuid") - if zoneUUID == "" { - http.Error(w, "zoneUuid required", http.StatusBadRequest) - return - } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpcResponse{ - Count: len(vpcs), - ListVpcResponse: vpcs, + json.NewEncoder(w).Encode(apiEnvelope{ + Status: "ok", + Data: vpcs, }) })) defer srv.Close() svc := vpc.NewService(newClient(srv.URL)) - result, err := svc.List(context.Background(), "zone-uuid-1", "") + result, err := svc.List(context.Background(), "") if err != nil { t.Fatalf("List() error = %v", err) } if len(result) != 2 { t.Fatalf("List() returned %d VPCs, want 2", len(result)) } - if result[0].UUID != "vpc-1" { - t.Errorf("result[0].UUID = %q, want %q", result[0].UUID, "vpc-1") + if result[0].Slug != "production-vpc" { + t.Errorf("result[0].Slug = %q, want %q", result[0].Slug, "production-vpc") } if result[1].Name != "staging-vpc" { t.Errorf("result[1].Name = %q, want %q", result[1].Name, "staging-vpc") } } -// TestVPCGet verifies uuid param is sent and a single result is returned. +// TestVPCGet verifies that Get filters by slug from the list. func TestVPCGet(t *testing.T) { - expected := makeVPC("vpc-99", "target-vpc") - - var gotUUID string + allVPCs := []vpc.VPC{ + makeVPC("other-vpc", "other-vpc"), + makeVPC("target-vpc", "target-vpc"), + } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpc/vpcId" { + if r.URL.Path != "/vpcs" { http.NotFound(w, r) return } - gotUUID = r.URL.Query().Get("uuid") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpcResponse{ - Count: 1, - ListVpcResponse: []vpc.VPC{expected}, + json.NewEncoder(w).Encode(apiEnvelope{ + Status: "ok", + Data: allVPCs, }) })) defer srv.Close() svc := vpc.NewService(newClient(srv.URL)) - v, err := svc.Get(context.Background(), "vpc-99") + v, err := svc.Get(context.Background(), "target-vpc") if err != nil { t.Fatalf("Get() error = %v", err) } - if gotUUID != "vpc-99" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "vpc-99") - } - if v.UUID != "vpc-99" { - t.Errorf("v.UUID = %q, want %q", v.UUID, "vpc-99") + if v.Slug != "target-vpc" { + t.Errorf("v.Slug = %q, want %q", v.Slug, "target-vpc") } if v.Name != "target-vpc" { t.Errorf("v.Name = %q, want %q", v.Name, "target-vpc") @@ -120,7 +111,7 @@ func TestVPCGet(t *testing.T) { // TestVPCCreate verifies POST body and response parsing. func TestVPCCreate(t *testing.T) { - created := makeVPC("new-vpc-1", "my-vpc") + created := makeVPC("my-vpc", "my-vpc") var gotBody map[string]interface{} @@ -129,15 +120,15 @@ func TestVPCCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/vpc/createVpc" { + if r.URL.Path != "/vpcs" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpcResponse{ - Count: 1, - ListVpcResponse: []vpc.VPC{created}, + json.NewEncoder(w).Encode(apiEnvelope{ + Status: "ok", + Data: created, }) })) defer srv.Close() @@ -146,8 +137,8 @@ func TestVPCCreate(t *testing.T) { req := vpc.CreateRequest{ Name: "my-vpc", - ZoneUUID: "zone-1", - VPCOfferingUUID: "offering-1", + ZoneSlug: "zone-1", + VPCOfferingSlug: "offering-1", CIDR: "10.0.0.0/8", } @@ -155,20 +146,20 @@ func TestVPCCreate(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if v.UUID != "new-vpc-1" { - t.Errorf("v.UUID = %q, want %q", v.UUID, "new-vpc-1") + if v.Slug != "my-vpc" { + t.Errorf("v.Slug = %q, want %q", v.Slug, "my-vpc") } if gotBody["name"] != "my-vpc" { t.Errorf("body[name] = %v, want %q", gotBody["name"], "my-vpc") } - if gotBody["zoneUuid"] != "zone-1" { - t.Errorf("body[zoneUuid] = %v, want %q", gotBody["zoneUuid"], "zone-1") + if gotBody["zoneSlug"] != "zone-1" { + t.Errorf("body[zoneSlug] = %v, want %q", gotBody["zoneSlug"], "zone-1") } - if gotBody["vpcOfferingUuid"] != "offering-1" { - t.Errorf("body[vpcOfferingUuid] = %v, want %q", gotBody["vpcOfferingUuid"], "offering-1") + if gotBody["vpcOfferingSlug"] != "offering-1" { + t.Errorf("body[vpcOfferingSlug] = %v, want %q", gotBody["vpcOfferingSlug"], "offering-1") } - if gotBody["cIDR"] != "10.0.0.0/8" { - t.Errorf("body[cIDR] = %v, want %q", gotBody["cIDR"], "10.0.0.0/8") + if gotBody["cidr"] != "10.0.0.0/8" { + t.Errorf("body[cidr] = %v, want %q", gotBody["cidr"], "10.0.0.0/8") } } @@ -177,19 +168,21 @@ func TestVPCUpdate(t *testing.T) { updated := makeVPC("vpc-upd-1", "renamed-vpc") var gotMethod string + var gotPath string var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpc/updateVpc" { + if r.URL.Path != "/vpcs/vpc-upd-1" { http.NotFound(w, r) return } gotMethod = r.Method + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpcResponse{ - Count: 1, - ListVpcResponse: []vpc.VPC{updated}, + json.NewEncoder(w).Encode(apiEnvelope{ + Status: "ok", + Data: updated, }) })) defer srv.Close() @@ -197,30 +190,29 @@ func TestVPCUpdate(t *testing.T) { svc := vpc.NewService(newClient(srv.URL)) req := vpc.UpdateRequest{ - UUID: "vpc-upd-1", Name: "renamed-vpc", Description: "updated description", } - v, err := svc.Update(context.Background(), req) + v, err := svc.Update(context.Background(), "vpc-upd-1", req) if err != nil { t.Fatalf("Update() error = %v", err) } if gotMethod != http.MethodPut { t.Errorf("HTTP method = %q, want %q", gotMethod, http.MethodPut) } - if v.UUID != "vpc-upd-1" { - t.Errorf("v.UUID = %q, want %q", v.UUID, "vpc-upd-1") + if gotPath != "/vpcs/vpc-upd-1" { + t.Errorf("path = %q, want %q", gotPath, "/vpcs/vpc-upd-1") } - if gotBody["uuid"] != "vpc-upd-1" { - t.Errorf("body[uuid] = %v, want %q", gotBody["uuid"], "vpc-upd-1") + if v.Slug != "vpc-upd-1" { + t.Errorf("v.Slug = %q, want %q", v.Slug, "vpc-upd-1") } if gotBody["name"] != "renamed-vpc" { t.Errorf("body[name] = %v, want %q", gotBody["name"], "renamed-vpc") } } -// TestVPCDelete verifies DELETE path includes uuid. +// TestVPCDelete verifies DELETE path includes slug. func TestVPCDelete(t *testing.T) { var gotPath string @@ -240,7 +232,7 @@ func TestVPCDelete(t *testing.T) { if err != nil { t.Fatalf("Delete() error = %v", err) } - if gotPath != "/restapi/vpc/deleteVpc/vpc-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpc/deleteVpc/vpc-del-1") + if gotPath != "/vpcs/vpc-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/vpcs/vpc-del-1") } } diff --git a/internal/api/vpn/connection.go b/internal/api/vpn/connection.go deleted file mode 100644 index bba95da..0000000 --- a/internal/api/vpn/connection.go +++ /dev/null @@ -1,93 +0,0 @@ -package vpn - -import ( - "context" - "fmt" - "net/url" - - "github.com/zsoftly/zcp-cli/internal/httpclient" -) - -// Connection represents a ZCP VPN connection. -type Connection struct { - UUID string `json:"uuid"` - State string `json:"state"` - IsActive bool `json:"isActive"` - IKEPolicy string `json:"ikePolicy"` - ESPPolicy string `json:"espPolicy"` - IPSecPSK string `json:"ipsecPresharedKey"` - PublicIP string `json:"publicIpAddress"` - ZoneUUID string `json:"zoneUuid"` - CustomerGatewayUUID string `json:"customerGatewayUuid"` - VPNGatewayUUID string `json:"vpnGatewayUuid"` -} - -// ConnectionCreateRequest holds parameters for creating a VPN connection. -type ConnectionCreateRequest struct { - VPCUUID string `json:"vpcUuid"` - CustomerGatewayUUID string `json:"customerGatewayUuid"` - Passive bool `json:"passive"` -} - -type listVpnConnectionResponse struct { - Count int `json:"count"` - ListVpnConnectionResponse []Connection `json:"listVpnConnectionResponse"` -} - -// ConnectionService provides VPN connection API operations. -type ConnectionService struct { - client *httpclient.Client -} - -// NewConnectionService creates a new ConnectionService. -func NewConnectionService(client *httpclient.Client) *ConnectionService { - return &ConnectionService{client: client} -} - -// List returns VPN connections. zoneUUID is required; uuid and vpcUUID are optional filters. -func (s *ConnectionService) List(ctx context.Context, zoneUUID, uuid, vpcUUID string) ([]Connection, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if vpcUUID != "" { - q.Set("vpcUuid", vpcUUID) - } - var resp listVpnConnectionResponse - if err := s.client.Get(ctx, "/restapi/vpnconnection/vpnConnectionList", q, &resp); err != nil { - return nil, fmt.Errorf("listing VPN connections: %w", err) - } - return resp.ListVpnConnectionResponse, nil -} - -// Create establishes a new VPN connection. -func (s *ConnectionService) Create(ctx context.Context, req ConnectionCreateRequest) (*Connection, error) { - var resp listVpnConnectionResponse - if err := s.client.Post(ctx, "/restapi/vpnconnection/addVpnConnection", req, &resp); err != nil { - return nil, fmt.Errorf("creating VPN connection: %w", err) - } - if len(resp.ListVpnConnectionResponse) == 0 { - return nil, fmt.Errorf("create VPN connection returned empty response") - } - return &resp.ListVpnConnectionResponse[0], nil -} - -// Reset resets a VPN connection by UUID using a PUT with no body. -func (s *ConnectionService) Reset(ctx context.Context, uuid string) (*Connection, error) { - var resp listVpnConnectionResponse - if err := s.client.Put(ctx, "/restapi/vpnconnection/resetVpnConnection/"+uuid, nil, nil, &resp); err != nil { - return nil, fmt.Errorf("resetting VPN connection %s: %w", uuid, err) - } - if len(resp.ListVpnConnectionResponse) == 0 { - return nil, fmt.Errorf("reset VPN connection returned empty response") - } - return &resp.ListVpnConnectionResponse[0], nil -} - -// Delete removes a VPN connection by UUID. -func (s *ConnectionService) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/vpnconnection/deleteVpnConnection/"+uuid, nil); err != nil { - return fmt.Errorf("deleting VPN connection %s: %w", uuid, err) - } - return nil -} diff --git a/internal/api/vpn/customergateway.go b/internal/api/vpn/customergateway.go index 073cd9f..7387a43 100644 --- a/internal/api/vpn/customergateway.go +++ b/internal/api/vpn/customergateway.go @@ -2,17 +2,19 @@ package vpn import ( "context" + "encoding/json" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) // CustomerGateway represents a ZCP VPN customer gateway. type CustomerGateway struct { - UUID string `json:"uuid"` - IsActive bool `json:"isActive"` + Slug string `json:"slug"` + Name string `json:"name"` + Gateway string `json:"gateway"` IKEPolicy string `json:"ikepolicy"` + ESPPolicy string `json:"esppolicy"` ESPLifetime string `json:"esplifetime"` IKELifetime string `json:"ikelifetime"` IPSecPSK string `json:"ipsecPresharedKey"` @@ -20,9 +22,10 @@ type CustomerGateway struct { CIDRList string `json:"cidrList"` ForceEncap bool `json:"forceencap"` SplitConnection bool `json:"splitConnection"` + DPD bool `json:"dpd"` } -// CustomerGatewayRequest holds parameters for creating or updating a VPN customer gateway. +// CustomerGatewayRequest holds parameters for creating a VPN customer gateway. type CustomerGatewayRequest struct { Name string `json:"name"` Gateway string `json:"gateway"` @@ -30,29 +33,18 @@ type CustomerGatewayRequest struct { IPSecPSK string `json:"ipsecpsk"` IKEPolicy string `json:"ikepolicy"` ESPPolicy string `json:"esppolicy"` - IKELifetime string `json:"ikelifetime"` - ESPLifetime string `json:"esplifetime"` - IKEEncryption string `json:"ikeEncryption"` - IKEHash string `json:"ikeHash"` + IKELifetime string `json:"ikelifetime,omitempty"` + ESPLifetime string `json:"esplifetime,omitempty"` + IKEEncryption string `json:"ikeEncryption,omitempty"` + IKEHash string `json:"ikeHash,omitempty"` IKEVersion string `json:"ikeVersion,omitempty"` - ESPEncryption string `json:"espEncryption"` - ESPHash string `json:"espHash"` + ESPEncryption string `json:"espEncryption,omitempty"` + ESPHash string `json:"espHash,omitempty"` ForceEncap bool `json:"forceencap"` SplitConnection bool `json:"splitConnection"` DPD bool `json:"dpd"` } -// CustomerGatewayUpdateRequest holds the UUID plus all create fields for an update. -type CustomerGatewayUpdateRequest struct { - UUID string `json:"uuid"` - CustomerGatewayRequest -} - -type listVpnCustomerGatewayResponse struct { - Count int `json:"count"` - ListVpnCustomerGatewayResponse []CustomerGateway `json:"listVpnCustomerGatewayResponse"` -} - // CustomerGatewayService provides VPN customer gateway API operations. type CustomerGatewayService struct { client *httpclient.Client @@ -63,47 +55,49 @@ func NewCustomerGatewayService(client *httpclient.Client) *CustomerGatewayServic return &CustomerGatewayService{client: client} } -// List returns VPN customer gateways. uuid is an optional filter. -func (s *CustomerGatewayService) List(ctx context.Context, uuid string) ([]CustomerGateway, error) { - var q url.Values - if uuid != "" { - q = url.Values{"uuid": {uuid}} - } - var resp listVpnCustomerGatewayResponse - if err := s.client.Get(ctx, "/restapi/vpncustomergateway/vpnCustomerGatewayList", q, &resp); err != nil { +// List returns all VPN customer gateways. +func (s *CustomerGatewayService) List(ctx context.Context) ([]CustomerGateway, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpn-customer-gateways", nil, &env); err != nil { return nil, fmt.Errorf("listing VPN customer gateways: %w", err) } - return resp.ListVpnCustomerGatewayResponse, nil + var cgs []CustomerGateway + if err := json.Unmarshal(env.Data, &cgs); err != nil { + return nil, fmt.Errorf("decoding VPN customer gateway list: %w", err) + } + return cgs, nil } // Create provisions a new VPN customer gateway. func (s *CustomerGatewayService) Create(ctx context.Context, req CustomerGatewayRequest) (*CustomerGateway, error) { - var resp listVpnCustomerGatewayResponse - if err := s.client.Post(ctx, "/restapi/vpncustomergateway/addVpnCustomerGateway", req, &resp); err != nil { + var env apiResponse + if err := s.client.Post(ctx, "/vpn-customer-gateways", req, &env); err != nil { return nil, fmt.Errorf("creating VPN customer gateway: %w", err) } - if len(resp.ListVpnCustomerGatewayResponse) == 0 { - return nil, fmt.Errorf("create VPN customer gateway returned empty response") + var cg CustomerGateway + if err := json.Unmarshal(env.Data, &cg); err != nil { + return nil, fmt.Errorf("decoding created VPN customer gateway: %w", err) } - return &resp.ListVpnCustomerGatewayResponse[0], nil + return &cg, nil } // Update modifies a VPN customer gateway. -func (s *CustomerGatewayService) Update(ctx context.Context, req CustomerGatewayUpdateRequest) (*CustomerGateway, error) { - var resp listVpnCustomerGatewayResponse - if err := s.client.Put(ctx, "/restapi/vpncustomergateway/updateVpnCustomerGateway", nil, req, &resp); err != nil { - return nil, fmt.Errorf("updating VPN customer gateway %s: %w", req.UUID, err) +func (s *CustomerGatewayService) Update(ctx context.Context, slug string, req CustomerGatewayRequest) (*CustomerGateway, error) { + var env apiResponse + if err := s.client.Put(ctx, "/vpn-customer-gateways/"+slug, nil, req, &env); err != nil { + return nil, fmt.Errorf("updating VPN customer gateway %s: %w", slug, err) } - if len(resp.ListVpnCustomerGatewayResponse) == 0 { - return nil, fmt.Errorf("update VPN customer gateway returned empty response") + var cg CustomerGateway + if err := json.Unmarshal(env.Data, &cg); err != nil { + return nil, fmt.Errorf("decoding updated VPN customer gateway: %w", err) } - return &resp.ListVpnCustomerGatewayResponse[0], nil + return &cg, nil } -// Delete removes a VPN customer gateway by UUID. -func (s *CustomerGatewayService) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/vpncustomergateway/deleteVpnCustomerGateway/"+uuid, nil); err != nil { - return fmt.Errorf("deleting VPN customer gateway %s: %w", uuid, err) +// Delete removes a VPN customer gateway by slug. +func (s *CustomerGatewayService) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/vpn-customer-gateways/"+slug, nil); err != nil { + return fmt.Errorf("deleting VPN customer gateway %s: %w", slug, err) } return nil } diff --git a/internal/api/vpn/gateway.go b/internal/api/vpn/gateway.go deleted file mode 100644 index a256251..0000000 --- a/internal/api/vpn/gateway.go +++ /dev/null @@ -1,73 +0,0 @@ -// Package vpn provides ZCP VPN API operations for gateways, customer gateways, connections, and users. -package vpn - -import ( - "context" - "fmt" - "net/url" - - "github.com/zsoftly/zcp-cli/internal/httpclient" -) - -// Gateway represents a ZCP VPN gateway. -type Gateway struct { - UUID string `json:"uuid"` - PublicIP string `json:"publicIpAddress"` - DomainName string `json:"domainName"` - ZoneUUID string `json:"zoneUuid"` - IsActive bool `json:"isActive"` - VPCUUID string `json:"vpcUuid"` - Status string `json:"status"` -} - -type listVpnGatewayResponse struct { - Count int `json:"count"` - ListVpnGatewayResponse []Gateway `json:"listVpnGatewayResponse"` -} - -// GatewayService provides VPN gateway API operations. -type GatewayService struct { - client *httpclient.Client -} - -// NewGatewayService creates a new GatewayService. -func NewGatewayService(client *httpclient.Client) *GatewayService { - return &GatewayService{client: client} -} - -// List returns VPN gateways. zoneUUID is required; uuid and vpcUUID are optional filters. -func (s *GatewayService) List(ctx context.Context, zoneUUID, uuid, vpcUUID string) ([]Gateway, error) { - q := url.Values{"zoneUuid": {zoneUUID}} - if uuid != "" { - q.Set("uuid", uuid) - } - if vpcUUID != "" { - q.Set("vpcUuid", vpcUUID) - } - var resp listVpnGatewayResponse - if err := s.client.Get(ctx, "/restapi/vpngateway/vpnGatewayList", q, &resp); err != nil { - return nil, fmt.Errorf("listing VPN gateways: %w", err) - } - return resp.ListVpnGatewayResponse, nil -} - -// Create provisions a new VPN gateway for the specified VPC. -func (s *GatewayService) Create(ctx context.Context, vpcUUID string) (*Gateway, error) { - body := map[string]string{"vpcUuid": vpcUUID} - var resp listVpnGatewayResponse - if err := s.client.Post(ctx, "/restapi/vpngateway/addVpnGateway", body, &resp); err != nil { - return nil, fmt.Errorf("creating VPN gateway: %w", err) - } - if len(resp.ListVpnGatewayResponse) == 0 { - return nil, fmt.Errorf("create VPN gateway returned empty response") - } - return &resp.ListVpnGatewayResponse[0], nil -} - -// Delete removes a VPN gateway by UUID. -func (s *GatewayService) Delete(ctx context.Context, uuid string) error { - if err := s.client.Delete(ctx, "/restapi/vpngateway/deleteVpnGateway/"+uuid, nil); err != nil { - return fmt.Errorf("deleting VPN gateway %s: %w", uuid, err) - } - return nil -} diff --git a/internal/api/vpn/user.go b/internal/api/vpn/user.go index e8061bd..9e4615c 100644 --- a/internal/api/vpn/user.go +++ b/internal/api/vpn/user.go @@ -1,25 +1,31 @@ +// Package vpn provides ZCP VPN API operations for users and customer gateways. package vpn import ( "context" + "encoding/json" "fmt" - "net/url" "github.com/zsoftly/zcp-cli/internal/httpclient" ) // User represents a ZCP VPN user. type User struct { - UUID string `json:"uuid"` - UserName string `json:"userName"` - IsActive bool `json:"isActive"` - DomainUUID string `json:"domainUuid"` - Status string `json:"status"` + Slug string `json:"slug"` + UserName string `json:"userName"` + Status string `json:"status"` } -type listVpnUserResponse struct { - Count int `json:"count"` - ListVpnUserResponse []User `json:"listVpnUserResponse"` +// UserCreateRequest holds parameters for creating a VPN user. +type UserCreateRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// apiResponse is the STKCNSL response envelope. +type apiResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` } // UserService provides VPN user API operations. @@ -32,40 +38,40 @@ func NewUserService(client *httpclient.Client) *UserService { return &UserService{client: client} } -// List returns VPN users. uuid is an optional filter. -func (s *UserService) List(ctx context.Context, uuid string) ([]User, error) { - var q url.Values - if uuid != "" { - q = url.Values{"uuid": {uuid}} - } - var resp listVpnUserResponse - if err := s.client.Get(ctx, "/restapi/vpnuser/vpnUserlist", q, &resp); err != nil { +// List returns all VPN users. +func (s *UserService) List(ctx context.Context) ([]User, error) { + var env apiResponse + if err := s.client.Get(ctx, "/vpn-users", nil, &env); err != nil { return nil, fmt.Errorf("listing VPN users: %w", err) } - return resp.ListVpnUserResponse, nil + var users []User + if err := json.Unmarshal(env.Data, &users); err != nil { + return nil, fmt.Errorf("decoding VPN user list: %w", err) + } + return users, nil } // Create adds a new VPN user with the given username and password. func (s *UserService) Create(ctx context.Context, username, password string) (*User, error) { - body := map[string]string{ - "username": username, - "password": password, + body := UserCreateRequest{ + Username: username, + Password: password, } - var resp listVpnUserResponse - if err := s.client.Post(ctx, "/restapi/vpnuser/addVpnUser", body, &resp); err != nil { + var env apiResponse + if err := s.client.Post(ctx, "/vpn-users", body, &env); err != nil { return nil, fmt.Errorf("creating VPN user: %w", err) } - if len(resp.ListVpnUserResponse) == 0 { - return nil, fmt.Errorf("create VPN user returned empty response") + var u User + if err := json.Unmarshal(env.Data, &u); err != nil { + return nil, fmt.Errorf("decoding created VPN user: %w", err) } - return &resp.ListVpnUserResponse[0], nil + return &u, nil } -// Delete removes a VPN user by username (not UUID) via query param. -func (s *UserService) Delete(ctx context.Context, username string) error { - q := url.Values{"userName": {username}} - if err := s.client.Delete(ctx, "/restapi/vpnuser/deleteVpnUser", q); err != nil { - return fmt.Errorf("deleting VPN user %q: %w", username, err) +// Delete removes a VPN user by slug. +func (s *UserService) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/vpn-users/"+slug, nil); err != nil { + return fmt.Errorf("deleting VPN user %q: %w", slug, err) } return nil } diff --git a/internal/api/vpn/vpn_test.go b/internal/api/vpn/vpn_test.go index 14eda75..120d320 100644 --- a/internal/api/vpn/vpn_test.go +++ b/internal/api/vpn/vpn_test.go @@ -14,365 +14,56 @@ import ( func newClient(baseURL string) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: baseURL, - APIKey: "testkey", - SecretKey: "testsecret", - Timeout: 5 * time.Second, + BaseURL: baseURL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } -// --------------------------------------------------------------------------- -// Gateway tests -// --------------------------------------------------------------------------- - -type listVpnGatewayResponse struct { - Count int `json:"count"` - ListVpnGatewayResponse []vpn.Gateway `json:"listVpnGatewayResponse"` -} - -func TestGatewayList(t *testing.T) { - expected := []vpn.Gateway{ - {UUID: "gw-1", VPCUUID: "vpc-1", ZoneUUID: "zone-1", Status: "Enabled"}, - {UUID: "gw-2", VPCUUID: "vpc-2", ZoneUUID: "zone-1", Status: "Enabled"}, - } - - var gotZone string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpngateway/vpnGatewayList" { - http.NotFound(w, r) - return - } - gotZone = r.URL.Query().Get("zoneUuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnGatewayResponse{Count: len(expected), ListVpnGatewayResponse: expected}) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - gws, err := svc.List(context.Background(), "zone-1", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(gws) != 2 { - t.Fatalf("List() returned %d gateways, want 2", len(gws)) - } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if gws[0].UUID != "gw-1" { - t.Errorf("gws[0].UUID = %q, want %q", gws[0].UUID, "gw-1") - } -} - -func TestGatewayListWithFilters(t *testing.T) { - var gotUUID, gotVPCUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - gotVPCUUID = r.URL.Query().Get("vpcUuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnGatewayResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "gw-1", "vpc-1") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if gotUUID != "gw-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "gw-1") - } - if gotVPCUUID != "vpc-1" { - t.Errorf("vpcUuid query param = %q, want %q", gotVPCUUID, "vpc-1") - } -} - -func TestGatewayCreate(t *testing.T) { - created := vpn.Gateway{UUID: "gw-new", VPCUUID: "vpc-1", Status: "Enabled"} - - var gotBody map[string]interface{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/vpngateway/addVpnGateway" { - http.NotFound(w, r) - return - } - json.NewDecoder(r.Body).Decode(&gotBody) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnGatewayResponse{Count: 1, ListVpnGatewayResponse: []vpn.Gateway{created}}) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - result, err := svc.Create(context.Background(), "vpc-1") - if err != nil { - t.Fatalf("Create() error = %v", err) - } - if result.UUID != "gw-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "gw-new") - } - if gotBody["vpcUuid"] != "vpc-1" { - t.Errorf("body vpcUuid = %v, want %q", gotBody["vpcUuid"], "vpc-1") - } -} - -func TestGatewayCreateEmptyResponse(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(listVpnGatewayResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), "vpc-1") - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") - } -} - -func TestGatewayDelete(t *testing.T) { - var gotPath, gotMethod string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "gw-del-1") - if err != nil { - t.Fatalf("Delete() error = %v", err) - } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) - } - if gotPath != "/restapi/vpngateway/deleteVpnGateway/gw-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpngateway/deleteVpnGateway/gw-del-1") - } -} - -func TestGatewayListError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "server error", http.StatusInternalServerError) - })) - defer srv.Close() - - svc := vpn.NewGatewayService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "zone-1", "", "") - if err == nil { - t.Fatal("List() expected error on 500, got nil") - } -} - -// --------------------------------------------------------------------------- -// Connection tests -// --------------------------------------------------------------------------- - -type listVpnConnectionResponse struct { - Count int `json:"count"` - ListVpnConnectionResponse []vpn.Connection `json:"listVpnConnectionResponse"` -} - -func TestConnectionList(t *testing.T) { - expected := []vpn.Connection{ - {UUID: "conn-1", VPNGatewayUUID: "gw-1", CustomerGatewayUUID: "cgw-1", ZoneUUID: "zone-1"}, - } - - var gotZone string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpnconnection/vpnConnectionList" { - http.NotFound(w, r) - return - } - gotZone = r.URL.Query().Get("zoneUuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnConnectionResponse{Count: len(expected), ListVpnConnectionResponse: expected}) - })) - defer srv.Close() - - svc := vpn.NewConnectionService(newClient(srv.URL)) - conns, err := svc.List(context.Background(), "zone-1", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(conns) != 1 { - t.Fatalf("List() returned %d connections, want 1", len(conns)) - } - if gotZone != "zone-1" { - t.Errorf("zoneUuid query param = %q, want %q", gotZone, "zone-1") - } - if conns[0].UUID != "conn-1" { - t.Errorf("conns[0].UUID = %q, want %q", conns[0].UUID, "conn-1") - } -} - -func TestConnectionCreate(t *testing.T) { - created := vpn.Connection{UUID: "conn-new", VPNGatewayUUID: "gw-1", CustomerGatewayUUID: "cgw-1"} - - var gotBody map[string]interface{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "expected POST", http.StatusMethodNotAllowed) - return - } - if r.URL.Path != "/restapi/vpnconnection/addVpnConnection" { - http.NotFound(w, r) - return - } - json.NewDecoder(r.Body).Decode(&gotBody) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnConnectionResponse{Count: 1, ListVpnConnectionResponse: []vpn.Connection{created}}) - })) - defer srv.Close() - - svc := vpn.NewConnectionService(newClient(srv.URL)) - req := vpn.ConnectionCreateRequest{ - VPCUUID: "vpc-1", - CustomerGatewayUUID: "cgw-1", - Passive: false, - } - result, err := svc.Create(context.Background(), req) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - if result.UUID != "conn-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "conn-new") - } - if gotBody["vpcUuid"] != "vpc-1" { - t.Errorf("body vpcUuid = %v, want %q", gotBody["vpcUuid"], "vpc-1") - } - if gotBody["customerGatewayUuid"] != "cgw-1" { - t.Errorf("body customerGatewayUuid = %v, want %q", gotBody["customerGatewayUuid"], "cgw-1") - } -} - -func TestConnectionCreateEmptyResponse(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(listVpnConnectionResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewConnectionService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), vpn.ConnectionCreateRequest{VPCUUID: "vpc-1", CustomerGatewayUUID: "cgw-1"}) - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") - } -} - -func TestConnectionReset(t *testing.T) { - resetConn := vpn.Connection{UUID: "conn-1", State: "Connected"} - - var gotPath, gotMethod string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnConnectionResponse{Count: 1, ListVpnConnectionResponse: []vpn.Connection{resetConn}}) - })) - defer srv.Close() - - svc := vpn.NewConnectionService(newClient(srv.URL)) - result, err := svc.Reset(context.Background(), "conn-1") - if err != nil { - t.Fatalf("Reset() error = %v", err) - } - if result.UUID != "conn-1" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "conn-1") - } - if gotMethod != http.MethodPut { - t.Errorf("method = %q, want %q", gotMethod, http.MethodPut) - } - if gotPath != "/restapi/vpnconnection/resetVpnConnection/conn-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpnconnection/resetVpnConnection/conn-1") - } -} - -func TestConnectionDelete(t *testing.T) { - var gotPath, gotMethod string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - w.WriteHeader(http.StatusNoContent) - })) - defer srv.Close() - - svc := vpn.NewConnectionService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "conn-del-1") - if err != nil { - t.Fatalf("Delete() error = %v", err) - } - if gotMethod != http.MethodDelete { - t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) - } - if gotPath != "/restapi/vpnconnection/deleteVpnConnection/conn-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpnconnection/deleteVpnConnection/conn-del-1") - } +// apiEnvelope mirrors the ZCP response envelope used by the services. +type apiEnvelope struct { + Status string `json:"status"` + Data interface{} `json:"data"` } // --------------------------------------------------------------------------- // CustomerGateway tests // --------------------------------------------------------------------------- -type listVpnCustomerGatewayResponse struct { - Count int `json:"count"` - ListVpnCustomerGatewayResponse []vpn.CustomerGateway `json:"listVpnCustomerGatewayResponse"` -} - func TestCustomerGatewayList(t *testing.T) { expected := []vpn.CustomerGateway{ - {UUID: "cgw-1", CIDRList: "10.0.0.0/8", IKEPolicy: "aes-128-sha1-modp1536"}, - {UUID: "cgw-2", CIDRList: "192.168.0.0/16", IKEPolicy: "aes-256-sha256-modp2048"}, + {Slug: "cgw-1", Name: "gw-one", CIDRList: "10.0.0.0/8", IKEPolicy: "aes-128-sha1-modp1536"}, + {Slug: "cgw-2", Name: "gw-two", CIDRList: "192.168.0.0/16", IKEPolicy: "aes-256-sha256-modp2048"}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpncustomergateway/vpnCustomerGatewayList" { + if r.URL.Path != "/vpn-customer-gateways" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnCustomerGatewayResponse{Count: len(expected), ListVpnCustomerGatewayResponse: expected}) + json.NewEncoder(w).Encode(apiEnvelope{Status: "ok", Data: expected}) })) defer srv.Close() svc := vpn.NewCustomerGatewayService(newClient(srv.URL)) - cgws, err := svc.List(context.Background(), "") + cgws, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(cgws) != 2 { t.Fatalf("List() returned %d customer gateways, want 2", len(cgws)) } - if cgws[0].UUID != "cgw-1" { - t.Errorf("cgws[0].UUID = %q, want %q", cgws[0].UUID, "cgw-1") - } -} - -func TestCustomerGatewayListWithUUID(t *testing.T) { - var gotUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnCustomerGatewayResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewCustomerGatewayService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "cgw-1") - if err != nil { - t.Fatalf("List() error = %v", err) + if cgws[0].Slug != "cgw-1" { + t.Errorf("cgws[0].Slug = %q, want %q", cgws[0].Slug, "cgw-1") } - if gotUUID != "cgw-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "cgw-1") + if cgws[1].CIDRList != "192.168.0.0/16" { + t.Errorf("cgws[1].CIDRList = %q, want %q", cgws[1].CIDRList, "192.168.0.0/16") } } func TestCustomerGatewayCreate(t *testing.T) { - created := vpn.CustomerGateway{UUID: "cgw-new", CIDRList: "10.0.0.0/8"} + created := vpn.CustomerGateway{Slug: "cgw-new", Name: "my-cgw", CIDRList: "10.0.0.0/8"} var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -380,13 +71,13 @@ func TestCustomerGatewayCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/vpncustomergateway/addVpnCustomerGateway" { + if r.URL.Path != "/vpn-customer-gateways" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnCustomerGatewayResponse{Count: 1, ListVpnCustomerGatewayResponse: []vpn.CustomerGateway{created}}) + json.NewEncoder(w).Encode(apiEnvelope{Status: "ok", Data: created}) })) defer srv.Close() @@ -401,8 +92,8 @@ func TestCustomerGatewayCreate(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if result.UUID != "cgw-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "cgw-new") + if result.Slug != "cgw-new" { + t.Errorf("result.Slug = %q, want %q", result.Slug, "cgw-new") } if gotBody["name"] != "my-cgw" { t.Errorf("body name = %v, want %q", gotBody["name"], "my-cgw") @@ -412,56 +103,40 @@ func TestCustomerGatewayCreate(t *testing.T) { } } -func TestCustomerGatewayCreateEmptyResponse(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(listVpnCustomerGatewayResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewCustomerGatewayService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), vpn.CustomerGatewayRequest{Name: "x"}) - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") - } -} - func TestCustomerGatewayUpdate(t *testing.T) { - updated := vpn.CustomerGateway{UUID: "cgw-1", CIDRList: "172.16.0.0/12"} + updated := vpn.CustomerGateway{Slug: "cgw-1", Name: "updated-cgw", CIDRList: "172.16.0.0/12"} var gotBody map[string]interface{} + var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "expected PUT", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/vpncustomergateway/updateVpnCustomerGateway" { - http.NotFound(w, r) - return - } + gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnCustomerGatewayResponse{Count: 1, ListVpnCustomerGatewayResponse: []vpn.CustomerGateway{updated}}) + json.NewEncoder(w).Encode(apiEnvelope{Status: "ok", Data: updated}) })) defer srv.Close() svc := vpn.NewCustomerGatewayService(newClient(srv.URL)) - req := vpn.CustomerGatewayUpdateRequest{ - UUID: "cgw-1", - CustomerGatewayRequest: vpn.CustomerGatewayRequest{ - Name: "updated-cgw", - CIDRList: "172.16.0.0/12", - }, - } - result, err := svc.Update(context.Background(), req) + req := vpn.CustomerGatewayRequest{ + Name: "updated-cgw", + CIDRList: "172.16.0.0/12", + } + result, err := svc.Update(context.Background(), "cgw-1", req) if err != nil { t.Fatalf("Update() error = %v", err) } - if result.UUID != "cgw-1" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "cgw-1") + if gotPath != "/vpn-customer-gateways/cgw-1" { + t.Errorf("path = %q, want %q", gotPath, "/vpn-customer-gateways/cgw-1") } - if gotBody["uuid"] != "cgw-1" { - t.Errorf("body uuid = %v, want %q", gotBody["uuid"], "cgw-1") + if result.Slug != "cgw-1" { + t.Errorf("result.Slug = %q, want %q", result.Slug, "cgw-1") + } + if gotBody["name"] != "updated-cgw" { + t.Errorf("body name = %v, want %q", gotBody["name"], "updated-cgw") } } @@ -482,8 +157,21 @@ func TestCustomerGatewayDelete(t *testing.T) { if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/vpncustomergateway/deleteVpnCustomerGateway/cgw-del-1" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpncustomergateway/deleteVpnCustomerGateway/cgw-del-1") + if gotPath != "/vpn-customer-gateways/cgw-del-1" { + t.Errorf("path = %q, want %q", gotPath, "/vpn-customer-gateways/cgw-del-1") + } +} + +func TestCustomerGatewayListError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server error", http.StatusInternalServerError) + })) + defer srv.Close() + + svc := vpn.NewCustomerGatewayService(newClient(srv.URL)) + _, err := svc.List(context.Background()) + if err == nil { + t.Fatal("List() expected error on 500, got nil") } } @@ -491,64 +179,40 @@ func TestCustomerGatewayDelete(t *testing.T) { // User tests // --------------------------------------------------------------------------- -type listVpnUserResponse struct { - Count int `json:"count"` - ListVpnUserResponse []vpn.User `json:"listVpnUserResponse"` -} - func TestVPNUserList(t *testing.T) { expected := []vpn.User{ - {UUID: "usr-1", UserName: "alice", IsActive: true}, - {UUID: "usr-2", UserName: "bob", IsActive: false}, + {Slug: "usr-1", UserName: "alice", Status: "Enabled"}, + {Slug: "usr-2", UserName: "bob", Status: "Disabled"}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/restapi/vpnuser/vpnUserlist" { + if r.URL.Path != "/vpn-users" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnUserResponse{Count: len(expected), ListVpnUserResponse: expected}) + json.NewEncoder(w).Encode(apiEnvelope{Status: "ok", Data: expected}) })) defer srv.Close() svc := vpn.NewUserService(newClient(srv.URL)) - users, err := svc.List(context.Background(), "") + users, err := svc.List(context.Background()) if err != nil { t.Fatalf("List() error = %v", err) } if len(users) != 2 { t.Fatalf("List() returned %d users, want 2", len(users)) } - if users[0].UUID != "usr-1" { - t.Errorf("users[0].UUID = %q, want %q", users[0].UUID, "usr-1") + if users[0].Slug != "usr-1" { + t.Errorf("users[0].Slug = %q, want %q", users[0].Slug, "usr-1") } if users[0].UserName != "alice" { t.Errorf("users[0].UserName = %q, want %q", users[0].UserName, "alice") } } -func TestVPNUserListWithUUID(t *testing.T) { - var gotUUID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUUID = r.URL.Query().Get("uuid") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnUserResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewUserService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "usr-1") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if gotUUID != "usr-1" { - t.Errorf("uuid query param = %q, want %q", gotUUID, "usr-1") - } -} - func TestVPNUserCreate(t *testing.T) { - created := vpn.User{UUID: "usr-new", UserName: "carol", IsActive: true} + created := vpn.User{Slug: "usr-new", UserName: "carol", Status: "Enabled"} var gotBody map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -556,13 +220,13 @@ func TestVPNUserCreate(t *testing.T) { http.Error(w, "expected POST", http.StatusMethodNotAllowed) return } - if r.URL.Path != "/restapi/vpnuser/addVpnUser" { + if r.URL.Path != "/vpn-users" { http.NotFound(w, r) return } json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(listVpnUserResponse{Count: 1, ListVpnUserResponse: []vpn.User{created}}) + json.NewEncoder(w).Encode(apiEnvelope{Status: "ok", Data: created}) })) defer srv.Close() @@ -571,8 +235,8 @@ func TestVPNUserCreate(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if result.UUID != "usr-new" { - t.Errorf("result.UUID = %q, want %q", result.UUID, "usr-new") + if result.Slug != "usr-new" { + t.Errorf("result.Slug = %q, want %q", result.Slug, "usr-new") } if gotBody["username"] != "carol" { t.Errorf("body username = %v, want %q", gotBody["username"], "carol") @@ -582,43 +246,25 @@ func TestVPNUserCreate(t *testing.T) { } } -func TestVPNUserCreateEmptyResponse(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(listVpnUserResponse{Count: 0}) - })) - defer srv.Close() - - svc := vpn.NewUserService(newClient(srv.URL)) - _, err := svc.Create(context.Background(), "dave", "secret") - if err == nil { - t.Fatal("Create() expected error on empty response, got nil") - } -} - func TestVPNUserDelete(t *testing.T) { - var gotPath, gotMethod, gotUserName string + var gotPath, gotMethod string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path - gotUserName = r.URL.Query().Get("userName") w.WriteHeader(http.StatusNoContent) })) defer srv.Close() svc := vpn.NewUserService(newClient(srv.URL)) - err := svc.Delete(context.Background(), "alice") + err := svc.Delete(context.Background(), "usr-alice") if err != nil { t.Fatalf("Delete() error = %v", err) } if gotMethod != http.MethodDelete { t.Errorf("method = %q, want %q", gotMethod, http.MethodDelete) } - if gotPath != "/restapi/vpnuser/deleteVpnUser" { - t.Errorf("path = %q, want %q", gotPath, "/restapi/vpnuser/deleteVpnUser") - } - if gotUserName != "alice" { - t.Errorf("userName query param = %q, want %q", gotUserName, "alice") + if gotPath != "/vpn-users/usr-alice" { + t.Errorf("path = %q, want %q", gotPath, "/vpn-users/usr-alice") } } @@ -642,7 +288,7 @@ func TestVPNUserListError(t *testing.T) { defer srv.Close() svc := vpn.NewUserService(newClient(srv.URL)) - _, err := svc.List(context.Background(), "") + _, err := svc.List(context.Background()) if err == nil { t.Fatal("List() expected error on 500, got nil") } diff --git a/internal/api/waiters/waiter_test.go b/internal/api/waiters/waiter_test.go index c0269c2..d0cdeb1 100644 --- a/internal/api/waiters/waiter_test.go +++ b/internal/api/waiters/waiter_test.go @@ -24,10 +24,9 @@ type statusResponse struct { func newTestClient(srv *httptest.Server) *httpclient.Client { return httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "test-key", - SecretKey: "test-secret", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, }) } diff --git a/internal/api/zone/zone_test.go b/internal/api/zone/zone_test.go index 7e7d845..5b8b1a2 100644 --- a/internal/api/zone/zone_test.go +++ b/internal/api/zone/zone_test.go @@ -37,10 +37,9 @@ func TestZoneList(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, }) svc := zone.NewService(client) @@ -71,10 +70,9 @@ func TestZoneListWithUUIDFilter(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, }) svc := zone.NewService(client) @@ -98,10 +96,9 @@ func TestZoneListAPIError(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "bad", - SecretKey: "creds", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "bad-token", + Timeout: 5 * time.Second, }) svc := zone.NewService(client) diff --git a/internal/commands/acl.go b/internal/commands/acl.go index 8d5aad2..9a0e980 100644 --- a/internal/commands/acl.go +++ b/internal/commands/acl.go @@ -1,11 +1,8 @@ package commands import ( - "bufio" "context" "fmt" - "os" - "strings" "time" "github.com/spf13/cobra" @@ -19,91 +16,96 @@ func NewACLCmd() *cobra.Command { Short: "Manage Network ACLs", } cmd.AddCommand(newACLListCmd()) - cmd.AddCommand(newACLCreateCmd()) - cmd.AddCommand(newACLDeleteCmd()) + cmd.AddCommand(newACLCreateRuleCmd()) cmd.AddCommand(newACLReplaceCmd()) return cmd } func newACLListCmd() *cobra.Command { - var zoneUUID, vpcUUID string - cmd := &cobra.Command{ - Use: "list", - Short: "List network ACLs in a zone", - Example: ` zcp acl list --zone - zcp acl list --zone --vpc `, + Use: "list ", + Short: "List network ACLs for a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp acl list `, RunE: func(cmd *cobra.Command, args []string) error { - return runACLList(cmd, zoneUUID, vpcUUID) + return runACLList(cmd, args[0]) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "Filter by VPC UUID") return cmd } -func runACLList(cmd *cobra.Command, zoneUUID, vpcUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runACLList(cmd *cobra.Command, vpcSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := acl.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - acls, err := svc.List(ctx, zoneUUID, "", vpcUUID) + acls, err := svc.List(ctx, vpcSlug) if err != nil { return fmt.Errorf("acl list: %w", err) } - headers := []string{"UUID", "NAME", "DESCRIPTION", "VPC", "STATUS"} + headers := []string{"SLUG", "NAME", "DESCRIPTION", "VPC", "STATUS"} rows := make([][]string, 0, len(acls)) for _, a := range acls { rows = append(rows, []string{ - a.UUID, + a.Slug, a.Name, a.Description, - a.VPCUUID, + a.VPCSlug, a.Status, }) } return printer.PrintTable(headers, rows) } -func newACLCreateCmd() *cobra.Command { - var vpcUUID, name, description string +func newACLCreateRuleCmd() *cobra.Command { + var protocol, cidrList, trafficType, action string + var startPort, endPort, number, icmpCode, icmpType int cmd := &cobra.Command{ - Use: "create", - Short: "Create a new network ACL", - Example: ` zcp acl create --vpc --name my-acl - zcp acl create --vpc --name my-acl --description "Web tier ACL"`, + Use: "create-rule ", + Short: "Create a network ACL rule in a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp acl create-rule --protocol tcp --action allow --start-port 80 --end-port 80 --cidr 0.0.0.0/0 + zcp acl create-rule --protocol icmp --action deny --icmp-type 8 --icmp-code 0`, RunE: func(cmd *cobra.Command, args []string) error { - if vpcUUID == "" { - return fmt.Errorf("--vpc is required") + if protocol == "" { + return fmt.Errorf("--protocol is required") } - if name == "" { - return fmt.Errorf("--name is required") + if action == "" { + return fmt.Errorf("--action is required") } - return runACLCreate(cmd, acl.CreateRequest{ - Name: name, - VPCUUID: vpcUUID, - Description: description, + return runACLCreateRule(cmd, args[0], acl.ACLRuleCreateRequest{ + Protocol: protocol, + CIDRList: cidrList, + StartPort: startPort, + EndPort: endPort, + TrafficType: trafficType, + Action: action, + Number: number, + ICMPCode: icmpCode, + ICMPType: icmpType, }) }, } - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "VPC UUID (required)") - cmd.Flags().StringVar(&name, "name", "", "ACL name (required)") - cmd.Flags().StringVar(&description, "description", "", "ACL description") + cmd.Flags().StringVar(&protocol, "protocol", "", "Protocol (tcp, udp, icmp, all) (required)") + cmd.Flags().StringVar(&cidrList, "cidr", "", "CIDR list (e.g. 0.0.0.0/0)") + cmd.Flags().IntVar(&startPort, "start-port", 0, "Start port") + cmd.Flags().IntVar(&endPort, "end-port", 0, "End port") + cmd.Flags().StringVar(&trafficType, "traffic-type", "", "Traffic type (ingress, egress)") + cmd.Flags().StringVar(&action, "action", "", "Action (allow, deny) (required)") + cmd.Flags().IntVar(&number, "number", 0, "Rule number (ordering)") + cmd.Flags().IntVar(&icmpCode, "icmp-code", 0, "ICMP code") + cmd.Flags().IntVar(&icmpType, "icmp-type", 0, "ICMP type") return cmd } -func runACLCreate(cmd *cobra.Command, req acl.CreateRequest) error { +func runACLCreateRule(cmd *cobra.Command, vpcSlug string, req acl.ACLRuleCreateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -113,91 +115,48 @@ func runACLCreate(cmd *cobra.Command, req acl.CreateRequest) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - a, err := svc.Create(ctx, req) + rule, err := svc.CreateRule(ctx, vpcSlug, req) if err != nil { - return fmt.Errorf("acl create: %w", err) + return fmt.Errorf("acl create-rule: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", a.UUID}, - {"Name", a.Name}, - {"Description", a.Description}, - {"VPC UUID", a.VPCUUID}, - {"Status", a.Status}, + {"Slug", rule.Slug}, + {"Protocol", rule.Protocol}, + {"Action", rule.Action}, + {"CIDR List", rule.CIDRList}, + {"Start Port", fmt.Sprintf("%d", rule.StartPort)}, + {"End Port", fmt.Sprintf("%d", rule.EndPort)}, + {"Traffic Type", rule.TrafficType}, + {"Number", fmt.Sprintf("%d", rule.Number)}, } return printer.PrintTable(headers, rows) } -func newACLDeleteCmd() *cobra.Command { - var yes bool - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a network ACL", - Args: cobra.ExactArgs(1), - Example: ` zcp acl delete - zcp acl delete --yes`, - RunE: func(cmd *cobra.Command, args []string) error { - return runACLDelete(cmd, args[0], yes) - }, - } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") - return cmd -} - -func runACLDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete network ACL %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } - } - - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := acl.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("acl delete: %w", err) - } - - printer.Fprintf("Network ACL %q deleted.\n", uuid) - return nil -} - func newACLReplaceCmd() *cobra.Command { - var networkUUID, aclUUID string + var networkSlug, aclSlug string cmd := &cobra.Command{ Use: "replace", Short: "Replace the ACL on a network", - Example: ` zcp acl replace --network --acl `, + Example: ` zcp acl replace --network --acl `, RunE: func(cmd *cobra.Command, args []string) error { - if networkUUID == "" { + if networkSlug == "" { return fmt.Errorf("--network is required") } - if aclUUID == "" { + if aclSlug == "" { return fmt.Errorf("--acl is required") } - return runACLReplace(cmd, networkUUID, aclUUID) + return runACLReplace(cmd, networkSlug, aclSlug) }, } - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID (required)") - cmd.Flags().StringVar(&aclUUID, "acl", "", "ACL UUID (required)") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") + cmd.Flags().StringVar(&aclSlug, "acl", "", "ACL slug (required)") return cmd } -func runACLReplace(cmd *cobra.Command, networkUUID, aclUUID string) error { +func runACLReplace(cmd *cobra.Command, networkSlug, aclSlug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -207,15 +166,10 @@ func runACLReplace(cmd *cobra.Command, networkUUID, aclUUID string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - nets, err := svc.ReplaceNetworkACL(ctx, networkUUID, aclUUID) - if err != nil { + if err := svc.ReplaceNetworkACL(ctx, networkSlug, aclSlug); err != nil { return fmt.Errorf("acl replace: %w", err) } - headers := []string{"UUID", "NAME"} - rows := make([][]string, 0, len(nets)) - for _, n := range nets { - rows = append(rows, []string{n.UUID, n.Name}) - } - return printer.PrintTable(headers, rows) + printer.Fprintf("ACL replaced on network %q.\n", networkSlug) + return nil } diff --git a/internal/commands/affinitygroup.go b/internal/commands/affinitygroup.go new file mode 100644 index 0000000..83554d0 --- /dev/null +++ b/internal/commands/affinitygroup.go @@ -0,0 +1,190 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/affinitygroup" +) + +// NewAffinityGroupCmd returns the 'affinity-group' cobra command. +func NewAffinityGroupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "affinity-group", + Short: "Manage affinity groups", + } + cmd.AddCommand(newAffinityGroupListCmd()) + cmd.AddCommand(newAffinityGroupCreateCmd()) + cmd.AddCommand(newAffinityGroupDeleteCmd()) + return cmd +} + +func newAffinityGroupListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List affinity groups", + Example: ` zcp affinity-group list + zcp affinity-group list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAffinityGroupList(cmd) + }, + } + return cmd +} + +func runAffinityGroupList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := affinitygroup.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + groups, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("affinity-group list: %w", err) + } + + headers := []string{"SLUG", "NAME", "TYPE", "DESCRIPTION", "CREATED"} + rows := make([][]string, 0, len(groups)) + for _, g := range groups { + rows = append(rows, []string{ + g.Slug, + g.Name, + g.Type, + g.Description, + g.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newAffinityGroupCreateCmd() *cobra.Command { + var ( + name string + groupType string + description string + project string + region string + cloudProvider string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an affinity group", + Example: ` zcp affinity-group create --name my-group --type "host affinity" \ + --cloud-provider nimbo --project default-1 --region yow-1`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if groupType == "" { + return fmt.Errorf("--type is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + req := affinitygroup.CreateRequest{ + Name: name, + Type: groupType, + Description: description, + Project: project, + Region: region, + CloudProvider: cloudProvider, + } + return runAffinityGroupCreate(cmd, req) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Group name (required)") + cmd.Flags().StringVar(&groupType, "type", "", "Affinity type: 'host affinity' or 'host anti-affinity' (required)") + cmd.Flags().StringVar(&description, "description", "", "Group description") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + return cmd +} + +func runAffinityGroupCreate(cmd *cobra.Command, req affinitygroup.CreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := affinitygroup.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + g, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("affinity-group create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", g.Slug}, + {"Name", g.Name}, + {"Type", g.Type}, + {"Description", g.Description}, + {"Created", g.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +func newAffinityGroupDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an affinity group", + Args: cobra.ExactArgs(1), + Example: ` zcp affinity-group delete my-group + zcp affinity-group delete my-group --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAffinityGroupDelete(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runAffinityGroupDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete affinity group %q? [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := affinitygroup.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("affinity-group delete: %w", err) + } + + printer.Fprintf("Affinity group %q deleted.\n", slug) + return nil +} diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 40134c0..56f7b54 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -50,12 +50,11 @@ If the call succeeds, the credentials are valid.`, baseURL := config.ActiveAPIURL(profile, apiURL) opts := httpclient.Options{ - BaseURL: baseURL, - APIKey: profile.APIKey, - SecretKey: profile.SecretKey, - Timeout: time.Duration(timeoutSec) * time.Second, - Debug: debugFlag, - DebugOut: os.Stderr, + BaseURL: baseURL, + BearerToken: profile.BearerToken, + Timeout: time.Duration(timeoutSec) * time.Second, + Debug: debugFlag, + DebugOut: os.Stderr, } client := httpclient.New(opts) diff --git a/internal/commands/autoscale.go b/internal/commands/autoscale.go new file mode 100644 index 0000000..dc15a7d --- /dev/null +++ b/internal/commands/autoscale.go @@ -0,0 +1,844 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/autoscale" +) + +// NewAutoscaleCmd returns the 'autoscale' cobra command. +func NewAutoscaleCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "autoscale", + Short: "Manage VM autoscale groups, policies, and conditions", + } + cmd.AddCommand(newAutoscaleListCmd()) + cmd.AddCommand(newAutoscaleCreateCmd()) + cmd.AddCommand(newAutoscaleEnableCmd()) + cmd.AddCommand(newAutoscaleDisableCmd()) + cmd.AddCommand(newAutoscaleChangePlanCmd()) + cmd.AddCommand(newAutoscaleChangeTemplateCmd()) + cmd.AddCommand(newAutoscalePolicyCmd()) + cmd.AddCommand(newAutoscaleConditionCmd()) + return cmd +} + +// --------------------------------------------------------------------------- +// autoscale list +// --------------------------------------------------------------------------- + +func newAutoscaleListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List autoscale groups", + Example: ` zcp autoscale list + zcp autoscale list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAutoscaleList(cmd) + }, + } + return cmd +} + +func runAutoscaleList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + groups, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("autoscale list: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATE", "PLAN", "TEMPLATE", "MIN", "MAX", "CURRENT", "ZONE"} + rows := make([][]string, 0, len(groups)) + for _, g := range groups { + rows = append(rows, []string{ + g.Slug, + g.Name, + g.State, + g.Plan, + g.Template, + strconv.Itoa(g.MinInstances), + strconv.Itoa(g.MaxInstances), + strconv.Itoa(g.CurrentCount), + g.ZoneSlug, + }) + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale create +// --------------------------------------------------------------------------- + +func newAutoscaleCreateCmd() *cobra.Command { + var ( + name string + plan string + template string + minInstances int + maxInstances int + cooldownPeriod int + zoneSlug string + networkSlug string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new autoscale group", + Example: ` zcp autoscale create --name web-group --plan small --template ubuntu-22 --min 1 --max 5 --zone yow-1 + zcp autoscale create --name web-group --plan small --template ubuntu-22 --min 2 --max 10 --zone yow-1 --network default --cooldown 300`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if plan == "" { + return fmt.Errorf("--plan is required") + } + if template == "" { + return fmt.Errorf("--template is required") + } + if zoneSlug == "" { + return fmt.Errorf("--zone is required") + } + if minInstances < 0 { + return fmt.Errorf("--min must be >= 0") + } + if maxInstances < minInstances { + return fmt.Errorf("--max must be >= --min") + } + return runAutoscaleCreate(cmd, autoscale.CreateRequest{ + Name: name, + Plan: plan, + Template: template, + MinInstances: minInstances, + MaxInstances: maxInstances, + CooldownPeriod: cooldownPeriod, + ZoneSlug: zoneSlug, + NetworkSlug: networkSlug, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Autoscale group name (required)") + cmd.Flags().StringVar(&plan, "plan", "", "Compute plan slug (required)") + cmd.Flags().StringVar(&template, "template", "", "Template slug (required)") + cmd.Flags().IntVar(&minInstances, "min", 1, "Minimum number of instances") + cmd.Flags().IntVar(&maxInstances, "max", 1, "Maximum number of instances") + cmd.Flags().IntVar(&cooldownPeriod, "cooldown", 0, "Cooldown period in seconds between scaling actions") + cmd.Flags().StringVar(&zoneSlug, "zone", "", "Zone slug (required)") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug") + return cmd +} + +func runAutoscaleCreate(cmd *cobra.Command, req autoscale.CreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + group, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("autoscale create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", group.Slug}, + {"Name", group.Name}, + {"State", group.State}, + {"Plan", group.Plan}, + {"Template", group.Template}, + {"Min Instances", strconv.Itoa(group.MinInstances)}, + {"Max Instances", strconv.Itoa(group.MaxInstances)}, + {"Current Count", strconv.Itoa(group.CurrentCount)}, + {"Cooldown Period", strconv.Itoa(group.CooldownPeriod)}, + {"Zone", group.ZoneSlug}, + {"Network", group.NetworkSlug}, + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale enable / disable +// --------------------------------------------------------------------------- + +func newAutoscaleEnableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable ", + Short: "Enable an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale enable web-group`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAutoscaleEnable(cmd, args[0]) + }, + } + return cmd +} + +func runAutoscaleEnable(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + group, err := svc.Enable(ctx, slug) + if err != nil { + return fmt.Errorf("autoscale enable: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATE"} + rows := [][]string{{group.Slug, group.Name, group.State}} + return printer.PrintTable(headers, rows) +} + +func newAutoscaleDisableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable ", + Short: "Disable an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale disable web-group`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAutoscaleDisable(cmd, args[0]) + }, + } + return cmd +} + +func runAutoscaleDisable(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + group, err := svc.Disable(ctx, slug) + if err != nil { + return fmt.Errorf("autoscale disable: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATE"} + rows := [][]string{{group.Slug, group.Name, group.State}} + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale change-plan +// --------------------------------------------------------------------------- + +func newAutoscaleChangePlanCmd() *cobra.Command { + var plan string + + cmd := &cobra.Command{ + Use: "change-plan ", + Short: "Change the compute plan of an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale change-plan web-group --plan medium`, + RunE: func(cmd *cobra.Command, args []string) error { + if plan == "" { + return fmt.Errorf("--plan is required") + } + return runAutoscaleChangePlan(cmd, args[0], plan) + }, + } + cmd.Flags().StringVar(&plan, "plan", "", "New compute plan slug (required)") + return cmd +} + +func runAutoscaleChangePlan(cmd *cobra.Command, slug, plan string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + group, err := svc.ChangePlan(ctx, slug, plan) + if err != nil { + return fmt.Errorf("autoscale change-plan: %w", err) + } + + headers := []string{"SLUG", "NAME", "PLAN", "STATE"} + rows := [][]string{{group.Slug, group.Name, group.Plan, group.State}} + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale change-template +// --------------------------------------------------------------------------- + +func newAutoscaleChangeTemplateCmd() *cobra.Command { + var template string + + cmd := &cobra.Command{ + Use: "change-template ", + Short: "Change the template of an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale change-template web-group --template ubuntu-24`, + RunE: func(cmd *cobra.Command, args []string) error { + if template == "" { + return fmt.Errorf("--template is required") + } + return runAutoscaleChangeTemplate(cmd, args[0], template) + }, + } + cmd.Flags().StringVar(&template, "template", "", "New template slug (required)") + return cmd +} + +func runAutoscaleChangeTemplate(cmd *cobra.Command, slug, template string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + group, err := svc.ChangeTemplate(ctx, slug, template) + if err != nil { + return fmt.Errorf("autoscale change-template: %w", err) + } + + headers := []string{"SLUG", "NAME", "TEMPLATE", "STATE"} + rows := [][]string{{group.Slug, group.Name, group.Template, group.State}} + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale policy (subcommand group) +// --------------------------------------------------------------------------- + +func newAutoscalePolicyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Manage scale-up policies", + } + cmd.AddCommand(newPolicyCreateCmd()) + cmd.AddCommand(newPolicyUpdateCmd()) + cmd.AddCommand(newPolicyDeleteCmd()) + return cmd +} + +// --------------------------------------------------------------------------- +// autoscale policy create +// --------------------------------------------------------------------------- + +func newPolicyCreateCmd() *cobra.Command { + var ( + name string + metric string + operator string + threshold int + duration int + scaleAmount int + cooldown int + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a scale-up policy for an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale policy create web-group --name cpu-high --metric cpu --operator gte --threshold 80 --duration 300 --scale-amount 2 + zcp autoscale policy create web-group --name mem-high --metric memory --operator gte --threshold 90 --duration 120 --scale-amount 1 --cooldown 600`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if metric == "" { + return fmt.Errorf("--metric is required") + } + if operator == "" { + return fmt.Errorf("--operator is required") + } + if threshold <= 0 { + return fmt.Errorf("--threshold must be > 0") + } + if duration <= 0 { + return fmt.Errorf("--duration must be > 0") + } + if scaleAmount <= 0 { + return fmt.Errorf("--scale-amount must be > 0") + } + return runPolicyCreate(cmd, args[0], autoscale.PolicyRequest{ + Name: name, + Metric: metric, + Operator: operator, + Threshold: threshold, + Duration: duration, + ScaleAmount: scaleAmount, + Cooldown: cooldown, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Policy name (required)") + cmd.Flags().StringVar(&metric, "metric", "", "Metric to monitor, e.g. cpu, memory (required)") + cmd.Flags().StringVar(&operator, "operator", "", "Comparison operator, e.g. gte, lte, gt, lt (required)") + cmd.Flags().IntVar(&threshold, "threshold", 0, "Threshold value as a percentage (required)") + cmd.Flags().IntVar(&duration, "duration", 0, "Duration in seconds the condition must hold (required)") + cmd.Flags().IntVar(&scaleAmount, "scale-amount", 0, "Number of instances to add (required)") + cmd.Flags().IntVar(&cooldown, "cooldown", 0, "Cooldown in seconds before this policy can trigger again") + return cmd +} + +func runPolicyCreate(cmd *cobra.Command, slug string, req autoscale.PolicyRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + policy, err := svc.CreatePolicy(ctx, slug, req) + if err != nil { + return fmt.Errorf("autoscale policy create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", policy.ID}, + {"Name", policy.Name}, + {"Metric", policy.Metric}, + {"Operator", policy.Operator}, + {"Threshold", strconv.Itoa(policy.Threshold)}, + {"Duration", strconv.Itoa(policy.Duration)}, + {"Scale Amount", strconv.Itoa(policy.ScaleAmount)}, + {"Cooldown", strconv.Itoa(policy.Cooldown)}, + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale policy update +// --------------------------------------------------------------------------- + +func newPolicyUpdateCmd() *cobra.Command { + var ( + policyID int + name string + metric string + operator string + threshold int + duration int + scaleAmount int + cooldown int + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a scale-up policy for an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale policy update web-group --policy-id 42 --name cpu-high --metric cpu --operator gte --threshold 85 --duration 300 --scale-amount 3`, + RunE: func(cmd *cobra.Command, args []string) error { + if policyID <= 0 { + return fmt.Errorf("--policy-id is required and must be > 0") + } + if name == "" { + return fmt.Errorf("--name is required") + } + if metric == "" { + return fmt.Errorf("--metric is required") + } + if operator == "" { + return fmt.Errorf("--operator is required") + } + if threshold <= 0 { + return fmt.Errorf("--threshold must be > 0") + } + if duration <= 0 { + return fmt.Errorf("--duration must be > 0") + } + if scaleAmount <= 0 { + return fmt.Errorf("--scale-amount must be > 0") + } + return runPolicyUpdate(cmd, args[0], policyID, autoscale.PolicyRequest{ + Name: name, + Metric: metric, + Operator: operator, + Threshold: threshold, + Duration: duration, + ScaleAmount: scaleAmount, + Cooldown: cooldown, + }) + }, + } + cmd.Flags().IntVar(&policyID, "policy-id", 0, "Policy ID to update (required)") + cmd.Flags().StringVar(&name, "name", "", "Policy name (required)") + cmd.Flags().StringVar(&metric, "metric", "", "Metric to monitor (required)") + cmd.Flags().StringVar(&operator, "operator", "", "Comparison operator (required)") + cmd.Flags().IntVar(&threshold, "threshold", 0, "Threshold value as a percentage (required)") + cmd.Flags().IntVar(&duration, "duration", 0, "Duration in seconds (required)") + cmd.Flags().IntVar(&scaleAmount, "scale-amount", 0, "Number of instances to add (required)") + cmd.Flags().IntVar(&cooldown, "cooldown", 0, "Cooldown in seconds") + return cmd +} + +func runPolicyUpdate(cmd *cobra.Command, slug string, policyID int, req autoscale.PolicyRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + policy, err := svc.UpdatePolicy(ctx, slug, policyID, req) + if err != nil { + return fmt.Errorf("autoscale policy update: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", policy.ID}, + {"Name", policy.Name}, + {"Metric", policy.Metric}, + {"Operator", policy.Operator}, + {"Threshold", strconv.Itoa(policy.Threshold)}, + {"Duration", strconv.Itoa(policy.Duration)}, + {"Scale Amount", strconv.Itoa(policy.ScaleAmount)}, + {"Cooldown", strconv.Itoa(policy.Cooldown)}, + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale policy delete +// --------------------------------------------------------------------------- + +func newPolicyDeleteCmd() *cobra.Command { + var ( + policyID int + yes bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a scale-up policy from an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale policy delete web-group --policy-id 42 + zcp autoscale policy delete web-group --policy-id 42 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + if policyID <= 0 { + return fmt.Errorf("--policy-id is required and must be > 0") + } + return runPolicyDelete(cmd, args[0], policyID, yes) + }, + } + cmd.Flags().IntVar(&policyID, "policy-id", 0, "Policy ID to delete (required)") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} + +func runPolicyDelete(cmd *cobra.Command, slug string, policyID int, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete scale-up policy %d from autoscale group %q? [y/N]: ", policyID, slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeletePolicy(ctx, slug, policyID); err != nil { + return fmt.Errorf("autoscale policy delete: %w", err) + } + + printer.Fprintf("Scale-up policy %d deleted from autoscale group %q.\n", policyID, slug) + return nil +} + +// --------------------------------------------------------------------------- +// autoscale condition (subcommand group) +// --------------------------------------------------------------------------- + +func newAutoscaleConditionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "condition", + Short: "Manage scale-down conditions", + } + cmd.AddCommand(newConditionCreateCmd()) + cmd.AddCommand(newConditionUpdateCmd()) + cmd.AddCommand(newConditionDeleteCmd()) + return cmd +} + +// --------------------------------------------------------------------------- +// autoscale condition create +// --------------------------------------------------------------------------- + +func newConditionCreateCmd() *cobra.Command { + var ( + name string + metric string + operator string + threshold int + duration int + scaleAmount int + cooldown int + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a scale-down condition for an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale condition create web-group --name cpu-low --metric cpu --operator lte --threshold 20 --duration 600 --scale-amount 1 + zcp autoscale condition create web-group --name mem-low --metric memory --operator lte --threshold 30 --duration 300 --scale-amount 1 --cooldown 600`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if metric == "" { + return fmt.Errorf("--metric is required") + } + if operator == "" { + return fmt.Errorf("--operator is required") + } + if threshold <= 0 { + return fmt.Errorf("--threshold must be > 0") + } + if duration <= 0 { + return fmt.Errorf("--duration must be > 0") + } + if scaleAmount <= 0 { + return fmt.Errorf("--scale-amount must be > 0") + } + return runConditionCreate(cmd, args[0], autoscale.ConditionRequest{ + Name: name, + Metric: metric, + Operator: operator, + Threshold: threshold, + Duration: duration, + ScaleAmount: scaleAmount, + Cooldown: cooldown, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Condition name (required)") + cmd.Flags().StringVar(&metric, "metric", "", "Metric to monitor, e.g. cpu, memory (required)") + cmd.Flags().StringVar(&operator, "operator", "", "Comparison operator, e.g. gte, lte, gt, lt (required)") + cmd.Flags().IntVar(&threshold, "threshold", 0, "Threshold value as a percentage (required)") + cmd.Flags().IntVar(&duration, "duration", 0, "Duration in seconds the condition must hold (required)") + cmd.Flags().IntVar(&scaleAmount, "scale-amount", 0, "Number of instances to remove (required)") + cmd.Flags().IntVar(&cooldown, "cooldown", 0, "Cooldown in seconds before this condition can trigger again") + return cmd +} + +func runConditionCreate(cmd *cobra.Command, slug string, req autoscale.ConditionRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + cond, err := svc.CreateCondition(ctx, slug, req) + if err != nil { + return fmt.Errorf("autoscale condition create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", cond.ID}, + {"Name", cond.Name}, + {"Metric", cond.Metric}, + {"Operator", cond.Operator}, + {"Threshold", strconv.Itoa(cond.Threshold)}, + {"Duration", strconv.Itoa(cond.Duration)}, + {"Scale Amount", strconv.Itoa(cond.ScaleAmount)}, + {"Cooldown", strconv.Itoa(cond.Cooldown)}, + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale condition update +// --------------------------------------------------------------------------- + +func newConditionUpdateCmd() *cobra.Command { + var ( + conditionID int + name string + metric string + operator string + threshold int + duration int + scaleAmount int + cooldown int + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a scale-down condition for an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale condition update web-group --condition-id 7 --name cpu-low --metric cpu --operator lte --threshold 15 --duration 600 --scale-amount 1`, + RunE: func(cmd *cobra.Command, args []string) error { + if conditionID <= 0 { + return fmt.Errorf("--condition-id is required and must be > 0") + } + if name == "" { + return fmt.Errorf("--name is required") + } + if metric == "" { + return fmt.Errorf("--metric is required") + } + if operator == "" { + return fmt.Errorf("--operator is required") + } + if threshold <= 0 { + return fmt.Errorf("--threshold must be > 0") + } + if duration <= 0 { + return fmt.Errorf("--duration must be > 0") + } + if scaleAmount <= 0 { + return fmt.Errorf("--scale-amount must be > 0") + } + return runConditionUpdate(cmd, args[0], conditionID, autoscale.ConditionRequest{ + Name: name, + Metric: metric, + Operator: operator, + Threshold: threshold, + Duration: duration, + ScaleAmount: scaleAmount, + Cooldown: cooldown, + }) + }, + } + cmd.Flags().IntVar(&conditionID, "condition-id", 0, "Condition ID to update (required)") + cmd.Flags().StringVar(&name, "name", "", "Condition name (required)") + cmd.Flags().StringVar(&metric, "metric", "", "Metric to monitor (required)") + cmd.Flags().StringVar(&operator, "operator", "", "Comparison operator (required)") + cmd.Flags().IntVar(&threshold, "threshold", 0, "Threshold value as a percentage (required)") + cmd.Flags().IntVar(&duration, "duration", 0, "Duration in seconds (required)") + cmd.Flags().IntVar(&scaleAmount, "scale-amount", 0, "Number of instances to remove (required)") + cmd.Flags().IntVar(&cooldown, "cooldown", 0, "Cooldown in seconds") + return cmd +} + +func runConditionUpdate(cmd *cobra.Command, slug string, conditionID int, req autoscale.ConditionRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + cond, err := svc.UpdateCondition(ctx, slug, conditionID, req) + if err != nil { + return fmt.Errorf("autoscale condition update: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", cond.ID}, + {"Name", cond.Name}, + {"Metric", cond.Metric}, + {"Operator", cond.Operator}, + {"Threshold", strconv.Itoa(cond.Threshold)}, + {"Duration", strconv.Itoa(cond.Duration)}, + {"Scale Amount", strconv.Itoa(cond.ScaleAmount)}, + {"Cooldown", strconv.Itoa(cond.Cooldown)}, + } + return printer.PrintTable(headers, rows) +} + +// --------------------------------------------------------------------------- +// autoscale condition delete +// --------------------------------------------------------------------------- + +func newConditionDeleteCmd() *cobra.Command { + var ( + conditionID int + yes bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a scale-down condition from an autoscale group", + Args: cobra.ExactArgs(1), + Example: ` zcp autoscale condition delete web-group --condition-id 7 + zcp autoscale condition delete web-group --condition-id 7 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + if conditionID <= 0 { + return fmt.Errorf("--condition-id is required and must be > 0") + } + return runConditionDelete(cmd, args[0], conditionID, yes) + }, + } + cmd.Flags().IntVar(&conditionID, "condition-id", 0, "Condition ID to delete (required)") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} + +func runConditionDelete(cmd *cobra.Command, slug string, conditionID int, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete scale-down condition %d from autoscale group %q? [y/N]: ", conditionID, slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := autoscale.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteCondition(ctx, slug, conditionID); err != nil { + return fmt.Errorf("autoscale condition delete: %w", err) + } + + printer.Fprintf("Scale-down condition %d deleted from autoscale group %q.\n", conditionID, slug) + return nil +} diff --git a/internal/commands/backup.go b/internal/commands/backup.go new file mode 100644 index 0000000..ad8b21f --- /dev/null +++ b/internal/commands/backup.go @@ -0,0 +1,143 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/backup" +) + +// NewBackupCmd returns the 'backup' cobra command for block storage backups. +func NewBackupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "Manage block storage backups", + } + cmd.AddCommand(newBackupListCmd()) + cmd.AddCommand(newBackupCreateCmd()) + return cmd +} + +func newBackupListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List block storage backups", + Example: ` zcp backup list + zcp backup list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := backup.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + backups, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("backup list: %w", err) + } + + headers := []string{"SLUG", "NAME", "VOLUME ID", "INTERVAL", "SERVICE", "CREATED"} + rows := make([][]string, 0, len(backups)) + for _, b := range backups { + rows = append(rows, []string{ + b.Slug, + b.Name, + b.BlockstorageID, + b.Interval, + b.ServiceDisplayName, + b.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) + }, + } + return cmd +} + +func newBackupCreateCmd() *cobra.Command { + var blockstorageSlug, interval, cloudProvider, region, billingCycle, plan, pseudoService, project string + var at, immediate int + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a block storage backup", + Example: ` zcp backup create --volume root-4153 --interval dailyAt --at 1 --immediate 1 --cloud-provider nimbo --region noida --billing-cycle hourly --plan backup-1 --project default-73 + zcp backup create --volume root-4153 --interval dailyAt --at 1 --immediate 0 --cloud-provider nimbo --region noida --billing-cycle hourly --plan backup-1 --project default-73`, + RunE: func(cmd *cobra.Command, args []string) error { + if blockstorageSlug == "" { + return fmt.Errorf("--volume is required") + } + if interval == "" { + return fmt.Errorf("--interval is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + if plan == "" { + return fmt.Errorf("--plan is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := backup.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if pseudoService == "" { + pseudoService = "Virtual Machine Backup" + } + + req := backup.CreateRequest{ + Interval: interval, + At: at, + Immediate: immediate, + CloudProvider: cloudProvider, + Region: region, + BillingCycle: billingCycle, + Plan: plan, + PseudoService: pseudoService, + Project: project, + } + bak, err := svc.Create(ctx, blockstorageSlug, req) + if err != nil { + return fmt.Errorf("backup create: %w", err) + } + + headers := []string{"SLUG", "NAME", "VOLUME ID", "INTERVAL", "SERVICE", "CREATED"} + rows := [][]string{{ + bak.Slug, + bak.Name, + bak.BlockstorageID, + bak.Interval, + bak.ServiceDisplayName, + bak.CreatedAt, + }} + return printer.PrintTable(headers, rows) + }, + } + cmd.Flags().StringVar(&blockstorageSlug, "volume", "", "Block storage volume slug (required)") + cmd.Flags().StringVar(&interval, "interval", "", "Backup interval, e.g. dailyAt (required)") + cmd.Flags().IntVar(&at, "at", 1, "Hour at which the backup triggers (e.g. 1 for 1 AM)") + cmd.Flags().IntVar(&immediate, "immediate", 0, "Run backup immediately: 1 for yes, 0 for no") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly (required)") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug, e.g. backup-1 (required)") + cmd.Flags().StringVar(&pseudoService, "pseudo-service", "Virtual Machine Backup", "Service type for the backup") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + return cmd +} diff --git a/internal/commands/billing.go b/internal/commands/billing.go new file mode 100644 index 0000000..885b610 --- /dev/null +++ b/internal/commands/billing.go @@ -0,0 +1,862 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/billing" +) + +// NewBillingCmd returns the 'billing' cobra command group. +func NewBillingCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "billing", + Short: "View billing, costs, usage, invoices, subscriptions, and payments", + } + cmd.AddCommand(newBillingBalanceCmd()) + cmd.AddCommand(newBillingCostsCmd()) + cmd.AddCommand(newBillingMonthlyUsageCmd()) + cmd.AddCommand(newBillingServiceCountsCmd()) + cmd.AddCommand(newBillingCreditLimitCmd()) + cmd.AddCommand(newBillingInvoicesCmd()) + cmd.AddCommand(newBillingInvoiceCountCmd()) + cmd.AddCommand(newBillingUsageCmd()) + cmd.AddCommand(newBillingFreeCreditsCmd()) + cmd.AddCommand(newBillingSubscriptionsCmd()) + cmd.AddCommand(newBillingContractsCmd()) + cmd.AddCommand(newBillingTrialsCmd()) + cmd.AddCommand(newBillingCancelRequestsCmd()) + cmd.AddCommand(newBillingCancelServiceCmd()) + cmd.AddCommand(newBillingPaymentsCmd()) + cmd.AddCommand(newBillingCouponsCmd()) + cmd.AddCommand(newBillingRedeemCouponCmd()) + cmd.AddCommand(newBillingBudgetAlertCmd()) + cmd.AddCommand(newBillingBudgetAlertSetCmd()) + return cmd +} + +// --- balance --- + +func newBillingBalanceCmd() *cobra.Command { + return &cobra.Command{ + Use: "balance", + Short: "Show account balance summary", + Example: ` zcp billing balance + zcp billing balance --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingBalance(cmd) + }, + } +} + +func runBillingBalance(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + bal, err := svc.GetBalance(ctx) + if err != nil { + return fmt.Errorf("billing balance: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Available Balance", fmt.Sprintf("%.2f", bal.AvailableBalance)}, + {"Available Net Balance", fmt.Sprintf("%.2f", bal.AvailableNetBalance)}, + {"Deposited", fmt.Sprintf("%.2f", bal.Deposited)}, + {"Charged", fmt.Sprintf("%.2f", bal.Charged)}, + {"Current Usage", fmt.Sprintf("%.2f", bal.CurrentUsage)}, + {"Hourly Usage", fmt.Sprintf("%.6f", bal.HourlyUsage)}, + {"Current Hourly Rate", fmt.Sprintf("%.7f", bal.CurrentHourlyRate)}, + {"All-Time Usage", fmt.Sprintf("%.2f", bal.AllTimeUsage)}, + {"Current Month Usage", fmt.Sprintf("%.2f", bal.CurrentMonthUsage)}, + {"Estimated Hourly Usage", fmt.Sprintf("%.4f", bal.EstimatedHourlyUsage)}, + {"Free Credits", fmt.Sprintf("%.2f", bal.AvailableFreeCredits)}, + {"Unpaid Invoices", fmt.Sprintf("%.2f", bal.UnpaidInvoices)}, + {"Subscription Amount", fmt.Sprintf("%.4f", bal.SubscriptionAmount)}, + } + return printer.PrintTable(headers, rows) +} + +// --- costs --- + +func newBillingCostsCmd() *cobra.Command { + return &cobra.Command{ + Use: "costs", + Short: "Show per-service cost breakdown", + Example: ` zcp billing costs + zcp billing costs --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingCosts(cmd) + }, + } +} + +func runBillingCosts(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + costs, err := svc.ListServiceCosts(ctx) + if err != nil { + return fmt.Errorf("billing costs: %w", err) + } + + headers := []string{"SERVICE", "DISPLAY NAME", "TOTAL COST"} + rows := make([][]string, 0, len(costs)) + for _, c := range costs { + if c.TotalCost > 0 { + rows = append(rows, []string{ + c.Name, + c.DisplayName, + fmt.Sprintf("%.4f", c.TotalCost), + }) + } + } + // If no costs with value, show all + if len(rows) == 0 { + for _, c := range costs { + rows = append(rows, []string{ + c.Name, + c.DisplayName, + fmt.Sprintf("%.4f", c.TotalCost), + }) + } + } + return printer.PrintTable(headers, rows) +} + +// --- monthly-usage --- + +func newBillingMonthlyUsageCmd() *cobra.Command { + return &cobra.Command{ + Use: "monthly-usage", + Short: "Show month-by-month usage history", + Example: ` zcp billing monthly-usage + zcp billing monthly-usage --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingMonthlyUsage(cmd) + }, + } +} + +func runBillingMonthlyUsage(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + usage, err := svc.ListMonthlyUsage(ctx) + if err != nil { + return fmt.Errorf("billing monthly-usage: %w", err) + } + + headers := []string{"MONTH", "YEAR", "COST"} + rows := make([][]string, 0, len(usage)) + for _, u := range usage { + rows = append(rows, []string{ + u.Month, + u.Year, + u.Cost.String(), + }) + } + return printer.PrintTable(headers, rows) +} + +// --- service-counts --- + +func newBillingServiceCountsCmd() *cobra.Command { + return &cobra.Command{ + Use: "service-counts", + Short: "Show active service counts by type", + Example: ` zcp billing service-counts + zcp billing service-counts --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingServiceCounts(cmd) + }, + } +} + +func runBillingServiceCounts(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + counts, err := svc.GetServiceCounts(ctx) + if err != nil { + return fmt.Errorf("billing service-counts: %w", err) + } + + // Sort keys for deterministic output + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + + headers := []string{"SERVICE", "COUNT"} + rows := make([][]string, 0, len(counts)) + for _, k := range keys { + rows = append(rows, []string{ + k, + fmt.Sprintf("%d", counts[k]), + }) + } + return printer.PrintTable(headers, rows) +} + +// --- credit-limit --- + +func newBillingCreditLimitCmd() *cobra.Command { + return &cobra.Command{ + Use: "credit-limit", + Short: "Show account credit limit", + Example: ` zcp billing credit-limit + zcp billing credit-limit --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingCreditLimit(cmd) + }, + } +} + +func runBillingCreditLimit(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + limit, err := svc.GetCreditLimit(ctx) + if err != nil { + return fmt.Errorf("billing credit-limit: %w", err) + } + + headers := []string{"CREDIT LIMIT", "USAGE AMOUNT", "AVAILABLE TO SPEND"} + rows := [][]string{ + { + limit.Limit, + fmt.Sprintf("%.2f", limit.UsageAmount), + fmt.Sprintf("%.2f", limit.AvailableToSpend), + }, + } + return printer.PrintTable(headers, rows) +} + +// --- invoices --- + +func newBillingInvoicesCmd() *cobra.Command { + var page int + + cmd := &cobra.Command{ + Use: "invoices", + Short: "List billing invoices", + Example: ` zcp billing invoices + zcp billing invoices --page 2 + zcp billing invoices --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingInvoices(cmd, page) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number for paginated results") + return cmd +} + +func runBillingInvoices(cmd *cobra.Command, page int) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + invoices, total, err := svc.ListInvoices(ctx, page) + if err != nil { + return fmt.Errorf("billing invoices: %w", err) + } + + headers := []string{"NUMBER", "AMOUNT", "TAX", "STATUS", "TYPE", "DATE", "PAID AT", "PAYMENT METHOD"} + rows := make([][]string, 0, len(invoices)) + for _, inv := range invoices { + rows = append(rows, []string{ + inv.CustomNumber, + inv.SubAmount, + fmt.Sprintf("%.2f", inv.TaxAmount), + inv.Status, + inv.Type, + inv.InvoiceAt, + inv.PaidAt, + inv.PaymentMethods, + }) + } + if err := printer.PrintTable(headers, rows); err != nil { + return err + } + printer.Fprintf("Total invoices: %d\n", total) + return nil +} + +// --- invoices-count --- + +func newBillingInvoiceCountCmd() *cobra.Command { + return &cobra.Command{ + Use: "invoices-count", + Short: "Show total number of invoices", + Example: ` zcp billing invoices-count`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingInvoiceCount(cmd) + }, + } +} + +func runBillingInvoiceCount(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + count, err := svc.GetInvoiceCount(ctx) + if err != nil { + return fmt.Errorf("billing invoices-count: %w", err) + } + + headers := []string{"INVOICE COUNT"} + rows := [][]string{{fmt.Sprintf("%d", count)}} + return printer.PrintTable(headers, rows) +} + +// --- usage --- + +func newBillingUsageCmd() *cobra.Command { + return &cobra.Command{ + Use: "usage", + Short: "Show detailed account usage", + Example: ` zcp billing usage + zcp billing usage --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingUsage(cmd) + }, + } +} + +func runBillingUsage(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.GetAccountUsage(ctx) + if err != nil { + return fmt.Errorf("billing usage: %w", err) + } + + return printer.Print(result) +} + +// --- free-credits --- + +func newBillingFreeCreditsCmd() *cobra.Command { + return &cobra.Command{ + Use: "free-credits", + Short: "Show available free credits", + Example: ` zcp billing free-credits + zcp billing free-credits --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingFreeCredits(cmd) + }, + } +} + +func runBillingFreeCredits(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.GetFreeCredits(ctx) + if err != nil { + return fmt.Errorf("billing free-credits: %w", err) + } + + return printer.Print(result) +} + +// --- subscriptions --- + +func newBillingSubscriptionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "subscriptions", + Short: "View active and inactive subscriptions", + } + cmd.AddCommand(newBillingSubscriptionsActiveCmd()) + cmd.AddCommand(newBillingSubscriptionsInactiveCmd()) + return cmd +} + +func newBillingSubscriptionsActiveCmd() *cobra.Command { + var page int + + cmd := &cobra.Command{ + Use: "active", + Short: "List active service subscriptions", + Example: ` zcp billing subscriptions active + zcp billing subscriptions active --page 2 + zcp billing subscriptions active --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingSubscriptionsActive(cmd, page) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number for paginated results") + return cmd +} + +func runBillingSubscriptionsActive(cmd *cobra.Command, page int) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + subs, total, err := svc.ListActiveSubscriptions(ctx, page) + if err != nil { + return fmt.Errorf("billing subscriptions active: %w", err) + } + + headers := []string{"NAME", "PRODUCT", "PRICE", "TOTAL USAGE", "BILLING CYCLE", "PROJECT", "RENEW AT"} + rows := make([][]string, 0, len(subs)) + for _, sub := range subs { + rows = append(rows, []string{ + sub.Name, + sub.ProductDisplayName, + sub.Price, + sub.TotalUsage, + sub.BillingCycle.Name, + sub.Project.Name, + sub.RenewAt, + }) + } + if err := printer.PrintTable(headers, rows); err != nil { + return err + } + printer.Fprintf("Total active subscriptions: %d\n", total) + return nil +} + +func newBillingSubscriptionsInactiveCmd() *cobra.Command { + var page int + + cmd := &cobra.Command{ + Use: "inactive", + Short: "List inactive service subscriptions", + Example: ` zcp billing subscriptions inactive + zcp billing subscriptions inactive --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingSubscriptionsInactive(cmd, page) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number for paginated results") + return cmd +} + +func runBillingSubscriptionsInactive(cmd *cobra.Command, page int) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + subs, total, err := svc.ListInactiveSubscriptions(ctx, page) + if err != nil { + return fmt.Errorf("billing subscriptions inactive: %w", err) + } + + headers := []string{"NAME", "PRODUCT", "PRICE", "TOTAL USAGE", "BILLING CYCLE", "PROJECT"} + rows := make([][]string, 0, len(subs)) + for _, sub := range subs { + rows = append(rows, []string{ + sub.Name, + sub.ProductDisplayName, + sub.Price, + sub.TotalUsage, + sub.BillingCycle.Name, + sub.Project.Name, + }) + } + if err := printer.PrintTable(headers, rows); err != nil { + return err + } + printer.Fprintf("Total inactive subscriptions: %d\n", total) + return nil +} + +// --- contracts --- + +func newBillingContractsCmd() *cobra.Command { + return &cobra.Command{ + Use: "contracts", + Short: "List service contracts", + Example: ` zcp billing contracts + zcp billing contracts --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingContracts(cmd) + }, + } +} + +func runBillingContracts(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.ListServiceContracts(ctx) + if err != nil { + return fmt.Errorf("billing contracts: %w", err) + } + + return printer.Print(result) +} + +// --- trials --- + +func newBillingTrialsCmd() *cobra.Command { + return &cobra.Command{ + Use: "trials", + Short: "List active free trials", + Example: ` zcp billing trials + zcp billing trials --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingTrials(cmd) + }, + } +} + +func runBillingTrials(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.ListServiceTrials(ctx) + if err != nil { + return fmt.Errorf("billing trials: %w", err) + } + + return printer.Print(result) +} + +// --- cancel-requests --- + +func newBillingCancelRequestsCmd() *cobra.Command { + return &cobra.Command{ + Use: "cancel-requests", + Short: "List scheduled service cancellation requests", + Example: ` zcp billing cancel-requests + zcp billing cancel-requests --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingCancelRequests(cmd) + }, + } +} + +func runBillingCancelRequests(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.ListCancelRequests(ctx) + if err != nil { + return fmt.Errorf("billing cancel-requests: %w", err) + } + + return printer.Print(result) +} + +// --- cancel-service --- + +func newBillingCancelServiceCmd() *cobra.Command { + var serviceName, reason, cancelType, description string + cmd := &cobra.Command{ + Use: "cancel-service ", + Short: "Submit a cancellation request for a service", + Example: ` zcp billing cancel-service demo-prj-vm --service "Virtual Machine" --reason not_needed_anymore + zcp billing cancel-service root-4153 --service "Block Storage" --reason not_needed_anymore --type Immediate`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if serviceName == "" { + return fmt.Errorf("--service is required (e.g. 'Virtual Machine', 'Block Storage', 'IP Address')") + } + if reason == "" { + reason = "not_needed_anymore" + } + if cancelType == "" { + cancelType = "Immediate" + } + return runBillingCancelService(cmd, args[0], serviceName, reason, cancelType, description) + }, + } + cmd.Flags().StringVar(&serviceName, "service", "", "Service type (e.g. 'Virtual Machine', 'Block Storage', 'IP Address', 'Object Storage')") + cmd.Flags().StringVar(&reason, "reason", "not_needed_anymore", "Reason: limit_expenses, not_needed_anymore, better_offer, not_satisfied, switch_product, other") + cmd.Flags().StringVar(&cancelType, "type", "Immediate", "Cancel type: Immediate or 'End of billing period'") + cmd.Flags().StringVar(&description, "description", "", "Additional description (optional)") + return cmd +} + +func runBillingCancelService(cmd *cobra.Command, slug, serviceName, reason, cancelType, description string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + req := billing.CancelServiceRequest{ + ServiceName: serviceName, + Reason: reason, + Type: cancelType, + Description: description, + } + if err := svc.CancelService(ctx, slug, req); err != nil { + return fmt.Errorf("billing cancel-service: %w", err) + } + + printer.Fprintf("Cancellation request submitted for %s (%s)\n", slug, serviceName) + return nil +} + +// --- payments --- + +func newBillingPaymentsCmd() *cobra.Command { + var page int + + cmd := &cobra.Command{ + Use: "payments", + Short: "List payment transactions", + Example: ` zcp billing payments + zcp billing payments --page 2 + zcp billing payments --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingPayments(cmd, page) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page number for paginated results") + return cmd +} + +func runBillingPayments(cmd *cobra.Command, page int) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.ListPayments(ctx, page) + if err != nil { + return fmt.Errorf("billing payments: %w", err) + } + + return printer.Print(result) +} + +// --- coupons --- + +func newBillingCouponsCmd() *cobra.Command { + return &cobra.Command{ + Use: "coupons", + Short: "List coupons associated with the account", + Example: ` zcp billing coupons + zcp billing coupons --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingCoupons(cmd) + }, + } +} + +func runBillingCoupons(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.ListCoupons(ctx) + if err != nil { + return fmt.Errorf("billing coupons: %w", err) + } + + return printer.Print(result) +} + +// --- redeem-coupon --- + +func newBillingRedeemCouponCmd() *cobra.Command { + return &cobra.Command{ + Use: "redeem-coupon ", + Short: "Apply a coupon code to the account", + Example: ` zcp billing redeem-coupon SAVE50`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingRedeemCoupon(cmd, args[0]) + }, + } +} + +func runBillingRedeemCoupon(cmd *cobra.Command, code string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.RedeemCoupon(ctx, code) + if err != nil { + return fmt.Errorf("billing redeem-coupon: %w", err) + } + + return printer.Print(result) +} + +// --- budget-alert --- + +func newBillingBudgetAlertCmd() *cobra.Command { + return &cobra.Command{ + Use: "budget-alert", + Short: "Show current budget alert settings", + Example: ` zcp billing budget-alert + zcp billing budget-alert --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingBudgetAlert(cmd) + }, + } +} + +func runBillingBudgetAlert(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.GetBudgetAlert(ctx) + if err != nil { + return fmt.Errorf("billing budget-alert: %w", err) + } + + return printer.Print(result) +} + +// --- budget-alert-set --- + +func newBillingBudgetAlertSetCmd() *cobra.Command { + var amount, threshold float64 + var enabled bool + + cmd := &cobra.Command{ + Use: "budget-alert-set", + Short: "Configure budget alert settings", + Example: ` zcp billing budget-alert-set --amount 500 --threshold 80 --enabled + zcp billing budget-alert-set --amount 1000 --threshold 90 --enabled=false`, + RunE: func(cmd *cobra.Command, args []string) error { + return runBillingBudgetAlertSet(cmd, amount, threshold, enabled) + }, + } + cmd.Flags().Float64Var(&amount, "amount", 0, "Budget amount (required)") + cmd.Flags().Float64Var(&threshold, "threshold", 0, "Alert threshold percentage (required)") + cmd.Flags().BoolVar(&enabled, "enabled", true, "Enable or disable the alert") + cmd.MarkFlagRequired("amount") + cmd.MarkFlagRequired("threshold") + return cmd +} + +func runBillingBudgetAlertSet(cmd *cobra.Command, amount, threshold float64, enabled bool) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := billing.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.SetBudgetAlert(ctx, billing.SetBudgetAlertRequest{ + Amount: amount, + Threshold: threshold, + IsEnabled: enabled, + }) + if err != nil { + return fmt.Errorf("billing budget-alert-set: %w", err) + } + + return printer.Print(result) +} diff --git a/internal/commands/dashboard.go b/internal/commands/dashboard.go new file mode 100644 index 0000000..9bf9471 --- /dev/null +++ b/internal/commands/dashboard.go @@ -0,0 +1,113 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/dashboard" +) + +// NewDashboardCmd returns the 'dashboard' cobra command. +func NewDashboardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dashboard", + Short: "Account dashboard and service management", + Long: `View account service counts and manage service cancellations. + +The dashboard command provides a quick overview of active resources +in your account and allows you to submit service cancellation requests.`, + } + cmd.AddCommand(newDashboardSummaryCmd()) + cmd.AddCommand(newDashboardCancelCmd()) + return cmd +} + +// ── Summary ───────────────────────────────────────────────────────────────── + +func newDashboardSummaryCmd() *cobra.Command { + return &cobra.Command{ + Use: "summary", + Short: "Show a summary of active service counts", + Example: ` zcp dashboard summary + zcp dashboard summary --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDashboardSummary(cmd) + }, + } +} + +func runDashboardSummary(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dashboard.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + counts, err := svc.GetServiceCounts(ctx) + if err != nil { + return fmt.Errorf("dashboard summary: %w", err) + } + + headers := []string{"SERVICE", "COUNT"} + rows := [][]string{ + {"Instances", strconv.Itoa(counts.Instance)}, + {"Kubernetes", strconv.Itoa(counts.Kubernetes)}, + {"Volumes", strconv.Itoa(counts.Volume)}, + {"Snapshots", strconv.Itoa(counts.Snapshot)}, + {"Networks", strconv.Itoa(counts.Network)}, + {"VPCs", strconv.Itoa(counts.VPC)}, + {"Public IPs", strconv.Itoa(counts.PublicIP)}, + {"Firewalls", strconv.Itoa(counts.Firewall)}, + {"Load Balancers", strconv.Itoa(counts.LoadBalancer)}, + {"VPNs", strconv.Itoa(counts.VPN)}, + {"SSH Keys", strconv.Itoa(counts.SSHKey)}, + {"Templates", strconv.Itoa(counts.Template)}, + } + return printer.PrintTable(headers, rows) +} + +// ── Cancel ────────────────────────────────────────────────────────────────── + +func newDashboardCancelCmd() *cobra.Command { + var serviceSlug string + + cmd := &cobra.Command{ + Use: "cancel-service", + Short: "Submit a service cancellation request", + Example: ` zcp dashboard cancel-service --slug vm-abc-123 + zcp dashboard cancel-service --slug k8s-cluster-456`, + RunE: func(cmd *cobra.Command, args []string) error { + if serviceSlug == "" { + return fmt.Errorf("--slug is required") + } + return runDashboardCancel(cmd, serviceSlug) + }, + } + cmd.Flags().StringVar(&serviceSlug, "slug", "", "Service slug to cancel (required)") + return cmd +} + +func runDashboardCancel(cmd *cobra.Command, serviceSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dashboard.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resp, err := svc.CancelService(ctx, serviceSlug, "not_needed_anymore") + if err != nil { + return fmt.Errorf("dashboard cancel-service: %w", err) + } + + printer.Fprintf("Service %s: %s\n", serviceSlug, resp.Message) + return nil +} diff --git a/internal/commands/dns.go b/internal/commands/dns.go new file mode 100644 index 0000000..2371321 --- /dev/null +++ b/internal/commands/dns.go @@ -0,0 +1,369 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/dns" +) + +// NewDNSCmd returns the 'dns' cobra command. +func NewDNSCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Manage DNS domains and records", + } + cmd.AddCommand(newDNSListCmd()) + cmd.AddCommand(newDNSShowCmd()) + cmd.AddCommand(newDNSCreateCmd()) + cmd.AddCommand(newDNSDeleteCmd()) + cmd.AddCommand(newDNSRecordCreateCmd()) + cmd.AddCommand(newDNSRecordDeleteCmd()) + return cmd +} + +func newDNSListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List DNS domains", + Example: ` zcp dns list + zcp dns list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDNSList(cmd) + }, + } + return cmd +} + +func runDNSList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + domains, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("dns list: %w", err) + } + + headers := []string{"SLUG", "NAME", "DNS PROVIDER", "STATUS", "CREATED"} + rows := make([][]string, 0, len(domains)) + for _, d := range domains { + rows = append(rows, []string{ + d.Slug, + d.Name, + d.DNSProvider, + d.Status, + d.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newDNSShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show DNS domain details and records", + Args: cobra.ExactArgs(1), + Example: ` zcp dns show example-com-1 + zcp dns show example-com-1 --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDNSShow(cmd, args[0]) + }, + } + return cmd +} + +func runDNSShow(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + domain, err := svc.Show(ctx, slug) + if err != nil { + return fmt.Errorf("dns show: %w", err) + } + + // Print domain details + detailHeaders := []string{"FIELD", "VALUE"} + detailRows := [][]string{ + {"Slug", domain.Slug}, + {"Name", domain.Name}, + {"DNS Provider", domain.DNSProvider}, + {"Status", domain.Status}, + {"Created", domain.CreatedAt}, + {"Updated", domain.UpdatedAt}, + } + if err := printer.PrintTable(detailHeaders, detailRows); err != nil { + return err + } + + // Print records if any + if len(domain.Records) > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "Records (%d):\n", len(domain.Records)) + recHeaders := []string{"ID", "NAME", "TYPE", "CONTENT", "TTL"} + recRows := make([][]string, 0, len(domain.Records)) + for _, r := range domain.Records { + recRows = append(recRows, []string{ + r.ID, + r.Name, + r.Type, + r.Content, + strconv.Itoa(r.TTL), + }) + } + return printer.PrintTable(recHeaders, recRows) + } + + return nil +} + +func newDNSCreateCmd() *cobra.Command { + var name, project, dnsProvider string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a DNS domain", + Example: ` zcp dns create --name example.com --project default-60 --dns-provider powerdns + zcp dns create --name example.com --project default-60`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if dnsProvider == "" { + dnsProvider = "powerdns" + } + return runDNSCreate(cmd, dns.CreateDomainRequest{ + Name: name, + Project: project, + DNSProvider: dnsProvider, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Domain name (required, e.g. example.com)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required, e.g. default-60)") + cmd.Flags().StringVar(&dnsProvider, "dns-provider", "powerdns", "DNS provider (default: powerdns)") + return cmd +} + +func runDNSCreate(cmd *cobra.Command, req dns.CreateDomainRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + domain, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("dns create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", domain.Slug}, + {"Name", domain.Name}, + {"DNS Provider", domain.DNSProvider}, + {"Status", domain.Status}, + {"Created", domain.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +func newDNSDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a DNS domain", + Args: cobra.ExactArgs(1), + Example: ` zcp dns delete example-com-1 + zcp dns delete example-com-1 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDNSDelete(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runDNSDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete DNS domain %q? This will remove all records. [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("dns delete: %w", err) + } + + printer.Fprintf("DNS domain %q deleted.\n", slug) + return nil +} + +func newDNSRecordCreateCmd() *cobra.Command { + var domain, name, recordType, content string + var ttl int + + cmd := &cobra.Command{ + Use: "record-create", + Short: "Create a DNS record", + Example: ` zcp dns record-create --domain example-com-1 --name www --type A --content 192.0.2.1 + zcp dns record-create --domain example-com-1 --name mail --type MX --content mail.example.com --ttl 3600`, + RunE: func(cmd *cobra.Command, args []string) error { + if domain == "" { + return fmt.Errorf("--domain is required (use the domain slug)") + } + if name == "" { + return fmt.Errorf("--name is required") + } + if recordType == "" { + return fmt.Errorf("--type is required (e.g. A, AAAA, CNAME, MX, TXT)") + } + if content == "" { + return fmt.Errorf("--content is required") + } + if ttl <= 0 { + ttl = 14400 + } + return runDNSRecordCreate(cmd, domain, dns.CreateRecordRequest{ + Name: name, + Type: recordType, + Content: content, + TTL: ttl, + }) + }, + } + cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") + cmd.Flags().StringVar(&name, "name", "", "Record name / subdomain (required)") + cmd.Flags().StringVar(&recordType, "type", "", "Record type: A, AAAA, CNAME, MX, TXT, etc. (required)") + cmd.Flags().StringVar(&content, "content", "", "Record content / value (required)") + cmd.Flags().IntVar(&ttl, "ttl", 14400, "Time-to-live in seconds (default: 14400)") + return cmd +} + +func runDNSRecordCreate(cmd *cobra.Command, domainSlug string, req dns.CreateRecordRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + domain, err := svc.CreateRecord(ctx, domainSlug, req) + if err != nil { + return fmt.Errorf("dns record-create: %w", err) + } + + // Show the record table for the domain + if len(domain.Records) > 0 { + headers := []string{"ID", "NAME", "TYPE", "CONTENT", "TTL"} + rows := make([][]string, 0, len(domain.Records)) + for _, r := range domain.Records { + rows = append(rows, []string{ + r.ID, + r.Name, + r.Type, + r.Content, + strconv.Itoa(r.TTL), + }) + } + return printer.PrintTable(headers, rows) + } + + printer.Fprintf("Record created on domain %q.\n", domainSlug) + return nil +} + +func newDNSRecordDeleteCmd() *cobra.Command { + var domain string + var recordID int + var yes bool + + cmd := &cobra.Command{ + Use: "record-delete", + Short: "Delete a DNS record", + Example: ` zcp dns record-delete --domain example-com-1 --record-id 42 + zcp dns record-delete --domain example-com-1 --record-id 42 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + if domain == "" { + return fmt.Errorf("--domain is required (use the domain slug)") + } + if recordID <= 0 { + return fmt.Errorf("--record-id is required") + } + return runDNSRecordDelete(cmd, domain, recordID, yes) + }, + } + cmd.Flags().StringVar(&domain, "domain", "", "Domain slug (required)") + cmd.Flags().IntVar(&recordID, "record-id", 0, "Record ID to delete (required; use 'dns show' to find IDs)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runDNSRecordDelete(cmd *cobra.Command, domainSlug string, recordID int, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete DNS record %d on domain %q? [y/N]: ", recordID, domainSlug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := dns.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteRecord(ctx, domainSlug, recordID); err != nil { + return fmt.Errorf("dns record-delete: %w", err) + } + + printer.Fprintf("DNS record %d deleted from domain %q.\n", recordID, domainSlug) + return nil +} diff --git a/internal/commands/egress.go b/internal/commands/egress.go index dc3e295..99d5584 100644 --- a/internal/commands/egress.go +++ b/internal/commands/egress.go @@ -25,7 +25,7 @@ func validateEgressProtocol(protocol string) error { func NewEgressCmd() *cobra.Command { cmd := &cobra.Command{ Use: "egress", - Short: "Manage egress rules", + Short: "Manage egress firewall rules", } cmd.AddCommand(newEgressListCmd()) cmd.AddCommand(newEgressCreateCmd()) @@ -34,50 +34,48 @@ func NewEgressCmd() *cobra.Command { } func newEgressListCmd() *cobra.Command { - var zoneUUID, networkUUID string + var networkSlug string cmd := &cobra.Command{ Use: "list", - Short: "List egress rules", - Example: ` zcp egress list --zone - zcp egress list --zone --network `, + Short: "List egress rules for a network", + Example: ` zcp egress list --network + zcp egress list --network --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runEgressList(cmd, zoneUUID, networkUUID) + if networkSlug == "" { + return fmt.Errorf("--network is required") + } + return runEgressList(cmd, networkSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Filter by network UUID") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") return cmd } -func runEgressList(cmd *cobra.Command, zoneUUID, networkUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runEgressList(cmd *cobra.Command, networkSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := egress.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rules, err := svc.List(ctx, zoneUUID, "", networkUUID) + rules, err := svc.List(ctx, networkSlug) if err != nil { return fmt.Errorf("egress list: %w", err) } - headers := []string{"UUID", "PROTOCOL", "PORTS", "NETWORK", "STATUS"} + headers := []string{"ID", "PROTOCOL", "PORTS", "CIDR", "STATUS"} rows := make([][]string, 0, len(rules)) for _, r := range rules { ports := formatEgressPorts(r.StartPort, r.EndPort) rows = append(rows, []string{ - r.UUID, + r.ID, r.Protocol, ports, - r.NetworkUUID, + r.CIDR, r.Status, }) } @@ -85,15 +83,15 @@ func runEgressList(cmd *cobra.Command, zoneUUID, networkUUID string) error { } func newEgressCreateCmd() *cobra.Command { - var networkUUID, protocol, startPort, endPort, cidr, icmpType, icmpCode string + var networkSlug, protocol, startPort, endPort, cidr, icmpType, icmpCode string cmd := &cobra.Command{ Use: "create", Short: "Create an egress rule", - Example: ` zcp egress create --network --protocol tcp --start-port 443 - zcp egress create --network --protocol all`, + Example: ` zcp egress create --network --protocol tcp --start-port 443 + zcp egress create --network --protocol all`, RunE: func(cmd *cobra.Command, args []string) error { - if networkUUID == "" { + if networkSlug == "" { return fmt.Errorf("--network is required") } if protocol == "" { @@ -107,21 +105,21 @@ func newEgressCreateCmd() *cobra.Command { fmt.Fprintln(os.Stderr, "Warning: no ports specified for TCP/UDP rule; all ports will be affected.") } return runEgressCreate(cmd, egress.CreateRequest{ - NetworkUUID: networkUUID, + NetworkSlug: networkSlug, Protocol: proto, StartPort: startPort, EndPort: endPort, - CIDRList: cidr, + CIDR: cidr, ICMPType: icmpType, ICMPCode: icmpCode, }) }, } - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID (required)") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") cmd.Flags().StringVar(&protocol, "protocol", "", "Protocol: tcp, udp, icmp, or all (required)") cmd.Flags().StringVar(&startPort, "start-port", "", "Start port number") cmd.Flags().StringVar(&endPort, "end-port", "", "End port number") - cmd.Flags().StringVar(&cidr, "cidr", "", "Comma-separated CIDR list") + cmd.Flags().StringVar(&cidr, "cidr", "", "CIDR (e.g. 0.0.0.0/0)") cmd.Flags().StringVar(&icmpType, "icmp-type", "", "ICMP type (ICMP protocol only)") cmd.Flags().StringVar(&icmpCode, "icmp-code", "", "ICMP code (ICMP protocol only)") return cmd @@ -144,35 +142,41 @@ func runEgressCreate(cmd *cobra.Command, req egress.CreateRequest) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", rule.UUID}, + {"ID", rule.ID}, {"Protocol", rule.Protocol}, {"Ports", formatEgressPorts(rule.StartPort, rule.EndPort)}, - {"Network UUID", rule.NetworkUUID}, + {"CIDR", rule.CIDR}, {"Status", rule.Status}, } return printer.PrintTable(headers, rows) } func newEgressDeleteCmd() *cobra.Command { + var networkSlug string var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete an egress rule", Args: cobra.ExactArgs(1), - Example: ` zcp egress delete - zcp egress delete --yes`, + Example: ` zcp egress delete 42 --network + zcp egress delete 42 --network --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runEgressDelete(cmd, args[0], yes) + if networkSlug == "" { + return fmt.Errorf("--network is required") + } + ruleID := args[0] + return runEgressDelete(cmd, networkSlug, ruleID, yes) }, } + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } -func runEgressDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete egress rule %q? [y/N]: ", uuid) +func runEgressDelete(cmd *cobra.Command, networkSlug string, ruleID string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete egress rule %s from network %q? [y/N]: ", ruleID, networkSlug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -191,11 +195,11 @@ func runEgressDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { + if err := svc.Delete(ctx, networkSlug, ruleID); err != nil { return fmt.Errorf("egress delete: %w", err) } - printer.Fprintf("Egress rule %q deleted.\n", uuid) + printer.Fprintf("Deleted egress rule %s from network %q\n", ruleID, networkSlug) return nil } diff --git a/internal/commands/firewall.go b/internal/commands/firewall.go index d577372..734583f 100644 --- a/internal/commands/firewall.go +++ b/internal/commands/firewall.go @@ -34,72 +34,65 @@ func NewFirewallCmd() *cobra.Command { } func newFirewallListCmd() *cobra.Command { - var zoneUUID, ipUUID string + var ipSlug string cmd := &cobra.Command{ - Use: "list", - Short: "List firewall rules", - Example: ` zcp firewall list --zone - zcp firewall list --zone --ip `, + Use: "list", + Short: "List firewall rules", + Example: ` zcp firewall list --ip `, RunE: func(cmd *cobra.Command, args []string) error { - return runFirewallList(cmd, zoneUUID, ipUUID) + if ipSlug == "" { + return fmt.Errorf("--ip is required") + } + return runFirewallList(cmd, ipSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&ipUUID, "ip", "", "Filter by IP address UUID") + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") return cmd } -func runFirewallList(cmd *cobra.Command, zoneUUID, ipUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runFirewallList(cmd *cobra.Command, ipSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := firewall.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rules, err := svc.List(ctx, zoneUUID, "", ipUUID) + rules, err := svc.List(ctx, ipSlug) if err != nil { - // Kong returns error instead of empty list when account has no IPs - if strings.Contains(err.Error(), "Invalid IpAddress") { - rules = nil - } else { - return fmt.Errorf("firewall list: %w", err) - } + return fmt.Errorf("firewall list: %w", err) } - headers := []string{"UUID", "PROTOCOL", "PORTS", "CIDR", "IP ADDRESS", "STATUS"} + headers := []string{"ID", "PROTOCOL", "PORTS", "CIDR", "STATE"} rows := make([][]string, 0, len(rules)) for _, r := range rules { - ports := formatPorts(r.StartPort, r.EndPort) + ports := formatFWPorts(fmt.Sprintf("%v", r.StartPort), fmt.Sprintf("%v", r.EndPort)) rows = append(rows, []string{ - r.UUID, + r.ID, r.Protocol, ports, r.CIDRList, - r.IPAddressUUID, - r.Status, + r.State, }) } return printer.PrintTable(headers, rows) } func newFirewallCreateCmd() *cobra.Command { - var ipUUID, protocol, startPort, endPort, cidr, icmpType, icmpCode string + var ipSlug, protocol, cidr, destCIDR string + var startPort, endPort string cmd := &cobra.Command{ Use: "create", Short: "Create a firewall rule", - Example: ` zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 - zcp firewall create --ip --protocol icmp`, + Example: ` zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 + zcp firewall create --ip --protocol tcp --start-port 80 --end-port 80 --cidr 0.0.0.0/0 + zcp firewall create --ip --protocol icmp`, RunE: func(cmd *cobra.Command, args []string) error { - if ipUUID == "" { + if ipSlug == "" { return fmt.Errorf("--ip is required") } if protocol == "" { @@ -108,32 +101,29 @@ func newFirewallCreateCmd() *cobra.Command { if err := validateFirewallProtocol(protocol); err != nil { return err } - proto := strings.ToUpper(protocol) - if (proto == "TCP" || proto == "UDP") && startPort == "" { + proto := strings.ToLower(protocol) + if (proto == "tcp" || proto == "udp") && startPort == "" { fmt.Fprintln(os.Stderr, "Warning: no ports specified for TCP/UDP rule; all ports will be affected.") } - return runFirewallCreate(cmd, firewall.CreateRequest{ - IPAddressUUID: ipUUID, - Protocol: proto, - StartPort: startPort, - EndPort: endPort, - CIDRList: cidr, - ICMPType: icmpType, - ICMPCode: icmpCode, + return runFirewallCreate(cmd, ipSlug, firewall.CreateRequest{ + Protocol: proto, + CIDRList: cidr, + DestinationCIDRList: destCIDR, + StartPort: startPort, + EndPort: endPort, }) }, } - cmd.Flags().StringVar(&ipUUID, "ip", "", "IP address UUID (required)") + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") cmd.Flags().StringVar(&protocol, "protocol", "", "Protocol: tcp, udp, icmp, or all (required)") cmd.Flags().StringVar(&startPort, "start-port", "", "Start port number") cmd.Flags().StringVar(&endPort, "end-port", "", "End port number") - cmd.Flags().StringVar(&cidr, "cidr", "", "Comma-separated CIDR list (e.g. 0.0.0.0/0)") - cmd.Flags().StringVar(&icmpType, "icmp-type", "", "ICMP type (ICMP protocol only)") - cmd.Flags().StringVar(&icmpCode, "icmp-code", "", "ICMP code (ICMP protocol only)") + cmd.Flags().StringVar(&cidr, "cidr", "", "Source CIDR list (e.g. 0.0.0.0/0)") + cmd.Flags().StringVar(&destCIDR, "dest-cidr", "", "Destination CIDR list") return cmd } -func runFirewallCreate(cmd *cobra.Command, req firewall.CreateRequest) error { +func runFirewallCreate(cmd *cobra.Command, ipSlug string, req firewall.CreateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -143,43 +133,47 @@ func runFirewallCreate(cmd *cobra.Command, req firewall.CreateRequest) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rule, err := svc.Create(ctx, req) + rule, err := svc.Create(ctx, ipSlug, req) if err != nil { return fmt.Errorf("firewall create: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", rule.UUID}, + {"ID", rule.ID}, {"Protocol", rule.Protocol}, - {"Ports", formatPorts(rule.StartPort, rule.EndPort)}, + {"Ports", formatFWPorts(fmt.Sprintf("%v", rule.StartPort), fmt.Sprintf("%v", rule.EndPort))}, {"CIDR", rule.CIDRList}, - {"IP Address UUID", rule.IPAddressUUID}, - {"Status", rule.Status}, + {"State", rule.State}, } return printer.PrintTable(headers, rows) } func newFirewallDeleteCmd() *cobra.Command { + var ipSlug string var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a firewall rule", Args: cobra.ExactArgs(1), - Example: ` zcp firewall delete - zcp firewall delete --yes`, + Example: ` zcp firewall delete --ip + zcp firewall delete --ip --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runFirewallDelete(cmd, args[0], yes) + if ipSlug == "" { + return fmt.Errorf("--ip is required") + } + return runFirewallDelete(cmd, ipSlug, args[0], yes) }, } + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } -func runFirewallDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete firewall rule %q? [y/N]: ", uuid) +func runFirewallDelete(cmd *cobra.Command, ipSlug, ruleID string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete firewall rule %q on IP %q? [y/N]: ", ruleID, ipSlug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -198,15 +192,33 @@ func runFirewallDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { + if err := svc.Delete(ctx, ipSlug, ruleID); err != nil { return fmt.Errorf("firewall delete: %w", err) } - printer.Fprintf("Firewall rule %q deleted.\n", uuid) + printer.Fprintf("Firewall rule %q deleted.\n", ruleID) return nil } -// formatPorts returns a human-readable ports string from start/end port values. +// formatFWPorts returns a human-readable ports string from start/end port values. +func formatFWPorts(start, end string) string { + if start == "" || start == "" || start == "null" { + start = "" + } + if end == "" || end == "" || end == "null" { + end = "" + } + if start == "" && end == "" { + return "all" + } + if end == "" || end == start { + return start + } + return start + "-" + end +} + +// formatPorts returns a human-readable ports string from start/end port string values. +// Retained for backward compatibility with other commands that may reference it. func formatPorts(start, end string) string { if start == "" && end == "" { return "all" diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go index 7e6fa14..140a816 100644 --- a/internal/commands/helpers.go +++ b/internal/commands/helpers.go @@ -39,12 +39,11 @@ func buildClientAndPrinter(cmd *cobra.Command) (*config.Profile, *httpclient.Cli baseURL := config.ActiveAPIURL(profile, apiURL) opts := httpclient.Options{ - BaseURL: baseURL, - APIKey: profile.APIKey, - SecretKey: profile.SecretKey, - Timeout: time.Duration(timeoutSec) * time.Second, - Debug: debugFlag, - DebugOut: os.Stderr, + BaseURL: baseURL, + BearerToken: profile.BearerToken, + Timeout: time.Duration(timeoutSec) * time.Second, + Debug: debugFlag, + DebugOut: os.Stderr, } client := httpclient.New(opts) @@ -79,3 +78,22 @@ func getTimeout(cmd *cobra.Command) int { } return t } + +// autoApproved returns true if the global --auto-approve / -y flag is set. +func autoApproved(cmd *cobra.Command) bool { + v, _ := cmd.Root().PersistentFlags().GetBool("auto-approve") + return v +} + +// confirmAction prompts the user for confirmation unless --auto-approve is set. +// Returns true if the action should proceed, false if cancelled. +func confirmAction(cmd *cobra.Command, format string, args ...interface{}) bool { + if autoApproved(cmd) { + return true + } + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N]: ", msg) + var confirm string + fmt.Fscanln(cmd.InOrStdin(), &confirm) + return confirm == "y" || confirm == "Y" +} diff --git a/internal/commands/instance.go b/internal/commands/instance.go index df83f9a..d78a6b1 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -24,248 +24,315 @@ func NewInstanceCmd() *cobra.Command { cmd.AddCommand(newInstanceCreateCmd()) cmd.AddCommand(newInstanceStartCmd()) cmd.AddCommand(newInstanceStopCmd()) - cmd.AddCommand(newInstanceDeleteCmd()) cmd.AddCommand(newInstanceRebootCmd()) - cmd.AddCommand(newInstanceResizeCmd()) - cmd.AddCommand(newInstanceNetworkListCmd()) - cmd.AddCommand(newInstancePasswordListCmd()) - cmd.AddCommand(newInstanceStatusCmd()) - cmd.AddCommand(newInstanceRecoverCmd()) - cmd.AddCommand(newInstanceRenameCmd()) + cmd.AddCommand(newInstanceResetCmd()) + cmd.AddCommand(newInstanceLogsCmd()) + cmd.AddCommand(newInstanceTagCreateCmd()) + cmd.AddCommand(newInstanceTagDeleteCmd()) + cmd.AddCommand(newInstanceChangeHostnameCmd()) + cmd.AddCommand(newInstanceChangePasswordCmd()) + cmd.AddCommand(newInstanceChangePlanCmd()) + cmd.AddCommand(newInstanceChangeOSCmd()) + cmd.AddCommand(newInstanceChangeScriptCmd()) + cmd.AddCommand(newInstanceAddNetworkCmd()) + cmd.AddCommand(newInstanceAddonsCmd()) + cmd.AddCommand(newInstancePurchaseAddonCmd()) cmd.AddCommand(newInstanceSSHCmd()) return cmd } -func newInstanceListCmd() *cobra.Command { - var zoneUUID string +// ---------- list ---------- +func newInstanceListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List instances in a zone", - Example: ` zcp instance list --zone - zcp instance list --zone --output json`, + Short: "List virtual machines", + Example: ` zcp instance list + zcp instance list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceList(cmd, zoneUUID) + return runInstanceList(cmd) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") return cmd } -func runInstanceList(cmd *cobra.Command, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runInstanceList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := instance.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - instances, err := svc.List(ctx, zoneUUID, "") + vms, err := svc.List(ctx) if err != nil { return fmt.Errorf("instance list: %w", err) } - headers := []string{"UUID", "NAME", "STATE", "PRIVATE IP", "MEMORY", "TEMPLATE", "ZONE"} - rows := make([][]string, 0, len(instances)) - for _, inst := range instances { + headers := []string{"SLUG", "NAME", "STATE", "PRIVATE IP", "PUBLIC IP", "REGION", "TEMPLATE", "CREATED"} + rows := make([][]string, 0, len(vms)) + for _, vm := range vms { + templateName := "" + if vm.Template != nil { + templateName = vm.Template.Name + } + regionName := "" + if vm.Region != nil { + regionName = vm.Region.Name + } rows = append(rows, []string{ - inst.UUID, - inst.Name, - inst.State, - inst.PrivateIP, - inst.Memory, - inst.TemplateName, - inst.ZoneUUID, + vm.Slug, + vm.Name, + vm.State, + instance.StringVal(vm.PrivateIP), + instance.StringVal(vm.PublicIP), + regionName, + templateName, + vm.CreatedAt, }) } return printer.PrintTable(headers, rows) } -func newInstanceGetCmd() *cobra.Command { - var zoneUUID string +// ---------- get ---------- +func newInstanceGetCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "get ", - Short: "Get details of a specific instance", + Use: "get ", + Short: "Get details of a specific virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance get --zone - zcp instance get --zone --output json`, + Example: ` zcp instance get my-vm + zcp instance get my-vm --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceGet(cmd, args[0], zoneUUID) + return runInstanceGet(cmd, args[0]) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") return cmd } -func runInstanceGet(cmd *cobra.Command, vmUUID, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runInstanceGet(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := instance.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Get(ctx, zoneUUID, vmUUID) + vm, err := svc.Get(ctx, slug) if err != nil { return fmt.Errorf("instance get: %w", err) } - // Render as two-column key-value table + templateName := "" + osFamily := "" + if vm.Template != nil { + templateName = vm.Template.Name + if vm.Template.OperatingSystem != nil { + osFamily = vm.Template.OperatingSystem.Family + } + } + regionName := "" + if vm.Region != nil { + regionName = vm.Region.Name + } + billingCycle := "" + if vm.BillingCycle != nil { + billingCycle = vm.BillingCycle.Name + } + storageName := "" + if vm.StorageSetting != nil { + storageName = vm.StorageSetting.Name + } + headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", inst.UUID}, - {"Name", inst.Name}, - {"Display Name", inst.DisplayName}, - {"Description", inst.Description}, - {"State", inst.State}, - {"Active", strconv.FormatBool(inst.IsActive)}, - {"Memory", inst.Memory}, - {"Template Name", inst.TemplateName}, - {"Template UUID", inst.TemplateUUID}, - {"Compute Offering UUID", inst.ComputeOfferingUUID}, - {"Storage Offering UUID", inst.StorageOfferingUUID}, - {"Network Name", inst.NetworkName}, - {"Network UUID", inst.NetworkUUID}, - {"Private IP", inst.PrivateIP}, - {"Zone UUID", inst.ZoneUUID}, - {"SSH Key UUID", inst.SSHKeyUUID}, - {"Owner", inst.OwnerName}, - {"Root Disk Size", strconv.FormatInt(inst.RootDiskSize, 10)}, - {"Volume Size", inst.VolumeSize}, - {"Disk Size", strconv.FormatInt(inst.DiskSize, 10)}, - {"CPU Cores", inst.CPUCore}, + {"Slug", vm.Slug}, + {"Name", vm.Name}, + {"Hostname", vm.Hostname}, + {"State", vm.State}, + {"Username", vm.Username}, + {"Private IP", instance.StringVal(vm.PrivateIP)}, + {"Public IP", instance.StringVal(vm.PublicIP)}, + {"Region", regionName}, + {"Template", templateName}, + {"OS Family", osFamily}, + {"Billing Cycle", billingCycle}, + {"Storage", storageName}, + {"Service", vm.ServiceName}, + {"Total Consumption", fmt.Sprintf("%.2f", vm.AllTimeConsumption)}, + {"Has Contract", strconv.FormatBool(vm.HasContract)}, + {"Created", vm.CreatedAt}, + {"Updated", vm.UpdatedAt}, + } + if vm.Description != nil && *vm.Description != "" { + rows = append(rows, []string{"Description", *vm.Description}) } return printer.PrintTable(headers, rows) } +// ---------- create ---------- + func newInstanceCreateCmd() *cobra.Command { var ( - zoneUUID string - name string - templateUUID string - computeOfferingUUID string - networkUUID string - storageOfferingUUID string - diskSize int - rootDiskSize int - sshKeyName string - securityGroup string - wait bool + name string + cloudProvider string + project string + region string + template string + plan string + billingCycle string + networkType string + sshKey string + hostname string + storageCategory string + computeCategory string + blockstoragePlan string + wait bool ) cmd := &cobra.Command{ Use: "create", - Short: "Create a new instance", - Example: ` zcp instance create --zone --name my-vm --template --compute-offering --network - zcp instance create --zone --name my-vm --template --compute-offering --network --ssh-key mykey`, + Short: "Create a new virtual machine", + Example: ` zcp instance create --name my-vm --cloud-provider nimbo --project default --region noida --template ubuntu-22f --plan box2cm2 --billing-cycle hourly + zcp instance create --name my-vm --cloud-provider nimbo --project default --region noida --template ubuntu-22f --plan box2cm2 --billing-cycle hourly --wait`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - if templateUUID == "" { + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if template == "" { return fmt.Errorf("--template is required") } - if computeOfferingUUID == "" { - return fmt.Errorf("--compute-offering is required") + if plan == "" { + return fmt.Errorf("--plan is required") } - if networkUUID == "" { - return fmt.Errorf("--network is required") + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + if blockstoragePlan == "" { + return fmt.Errorf("--blockstorage-plan is required (e.g. 50-gb-2, 100gb — see: zcp plan storage)") } - return runInstanceCreate(cmd, instance.CreateRequest{ - Name: name, - ZoneUUID: zoneUUID, - TemplateUUID: templateUUID, - ComputeOfferingUUID: computeOfferingUUID, - NetworkUUID: networkUUID, - StorageOfferingUUID: storageOfferingUUID, - DiskSize: diskSize, - RootDiskSize: rootDiskSize, - SSHKeyName: sshKeyName, - SecurityGroupName: securityGroup, - }, wait) + + h := hostname + if h == "" { + h = name + } + + var sshKeyPtr *string + if sshKey != "" { + sshKeyPtr = &sshKey + } + + req := instance.CreateRequest{ + Name: name, + CloudProvider: cloudProvider, + Project: project, + Region: region, + BootSource: "image", + Server: "cloud-compute", + Template: template, + IsPublic: true, + NetworkType: networkType, + Networks: []string{}, + BillingCycle: billingCycle, + SSHKey: sshKeyPtr, + Plan: plan, + OSFamily: "Linux", + TemplateType: "Operating System", + Hostname: h, + Addons: []string{}, + StorageCategory: storageCategory, + ComputeCategory: computeCategory, + BlockstoragePlan: blockstoragePlan, + } + return runInstanceCreate(cmd, req, wait) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&name, "name", "", "Instance name (required)") - cmd.Flags().StringVar(&templateUUID, "template", "", "Template UUID (required)") - cmd.Flags().StringVar(&computeOfferingUUID, "compute-offering", "", "Compute offering UUID (required)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID (required)") - cmd.Flags().StringVar(&storageOfferingUUID, "storage-offering", "", "Storage offering UUID (optional)") - cmd.Flags().IntVar(&diskSize, "disk-size", 0, "Data disk size in GB (optional)") - cmd.Flags().IntVar(&rootDiskSize, "root-disk-size", 0, "Root disk size in GB (optional)") - cmd.Flags().StringVar(&sshKeyName, "ssh-key", "", "SSH key name (optional)") - cmd.Flags().StringVar(&securityGroup, "security-group", "", "Security group name (optional)") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state (polls until Running or timeout)") + cmd.Flags().StringVar(&name, "name", "", "VM name (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + 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)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug: hourly, monthly, etc. (required)") + cmd.Flags().StringVar(&networkType, "network-type", "Isolated", "Network type (default: Isolated)") + cmd.Flags().StringVar(&sshKey, "ssh-key", "", "SSH key name (optional)") + cmd.Flags().StringVar(&hostname, "hostname", "", "Hostname (defaults to --name)") + cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug (optional)") + cmd.Flags().StringVar(&computeCategory, "compute-category", "", "Compute category slug (optional)") + cmd.Flags().StringVar(&blockstoragePlan, "blockstorage-plan", "", "Block storage plan slug, e.g. 50-gb-2 (required)") + cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state") return cmd } func runInstanceCreate(cmd *cobra.Command, req instance.CreateRequest, wait bool) error { - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - req.ZoneUUID = resolveZone(profile, req.ZoneUUID) - if req.ZoneUUID == "" { - return errNoZone() - } svc := instance.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Create(ctx, req) + vm, err := svc.Create(ctx, req) if err != nil { return fmt.Errorf("instance create: %w", err) } if wait { - fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Running...\n", inst.UUID) + fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Running...\n", vm.Slug) waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd)+300)*time.Second) defer waitCancel() - status, err := svc.WaitForState(waitCtx, inst.UUID, []string{"Running"}, 0) + vm, err = svc.WaitForState(waitCtx, vm.Slug, []string{"Running"}, 0) if err != nil { return fmt.Errorf("waiting for instance create: %w", err) } - fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", inst.UUID, status.Status) + fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", vm.Slug, vm.State) } - headers := []string{"UUID", "NAME", "STATE", "PRIVATE IP", "MEMORY", "TEMPLATE", "ZONE"} + templateName := "" + if vm.Template != nil { + templateName = vm.Template.Name + } + headers := []string{"SLUG", "NAME", "STATE", "TEMPLATE", "CREATED"} rows := [][]string{ - {inst.UUID, inst.Name, inst.State, inst.PrivateIP, inst.Memory, inst.TemplateName, inst.ZoneUUID}, + {vm.Slug, vm.Name, vm.State, templateName, vm.CreatedAt}, } return printer.PrintTable(headers, rows) } +// ---------- start ---------- + func newInstanceStartCmd() *cobra.Command { var wait bool cmd := &cobra.Command{ - Use: "start ", - Short: "Start a stopped instance", + Use: "start ", + Short: "Start a stopped virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance start `, + Example: ` zcp instance start my-vm`, RunE: func(cmd *cobra.Command, args []string) error { return runInstanceStart(cmd, args[0], wait) }, } - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state (polls until Running or timeout)") + cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state") return cmd } -func runInstanceStart(cmd *cobra.Command, uuid string, wait bool) error { +func runInstanceStart(cmd *cobra.Command, slug string, wait bool) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -275,46 +342,46 @@ func runInstanceStart(cmd *cobra.Command, uuid string, wait bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Start(ctx, uuid) + resp, err := svc.Start(ctx, slug) if err != nil { return fmt.Errorf("instance start: %w", err) } if wait { - fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Running...\n", uuid) + fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Running...\n", slug) waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd)+300)*time.Second) defer waitCancel() - status, err := svc.WaitForState(waitCtx, uuid, []string{"Running"}, 0) + vm, err := svc.WaitForState(waitCtx, slug, []string{"Running"}, 0) if err != nil { return fmt.Errorf("waiting for instance start: %w", err) } - fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", uuid, status.Status) + fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", slug, vm.State) } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{inst.UUID, inst.Name, inst.State}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } +// ---------- stop ---------- + func newInstanceStopCmd() *cobra.Command { - var force, wait bool + var wait bool cmd := &cobra.Command{ - Use: "stop ", - Short: "Stop a running instance", - Args: cobra.ExactArgs(1), - Example: ` zcp instance stop - zcp instance stop --force`, + Use: "stop ", + Short: "Stop a running virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance stop my-vm`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceStop(cmd, args[0], force, wait) + return runInstanceStop(cmd, args[0], wait) }, } - cmd.Flags().BoolVar(&force, "force", false, "Force stop (bypass graceful shutdown)") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Stopped state (polls until Stopped or timeout)") + cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Stopped state") return cmd } -func runInstanceStop(cmd *cobra.Command, uuid string, force bool, wait bool) error { +func runInstanceStop(cmd *cobra.Command, slug string, wait bool) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -324,61 +391,128 @@ func runInstanceStop(cmd *cobra.Command, uuid string, force bool, wait bool) err ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Stop(ctx, uuid, force) + resp, err := svc.Stop(ctx, slug) if err != nil { return fmt.Errorf("instance stop: %w", err) } if wait { - fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Stopped...\n", uuid) + fmt.Fprintf(os.Stderr, "Waiting for instance %s to be Stopped...\n", slug) waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd)+300)*time.Second) defer waitCancel() - status, err := svc.WaitForState(waitCtx, uuid, []string{"Stopped"}, 0) + vm, err := svc.WaitForState(waitCtx, slug, []string{"Stopped"}, 0) if err != nil { return fmt.Errorf("waiting for instance stop: %w", err) } - fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", uuid, status.Status) + fmt.Fprintf(os.Stderr, "Instance %s is now %s\n", slug, vm.State) } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{inst.UUID, inst.Name, inst.State}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstanceDeleteCmd() *cobra.Command { - var yes, expunge bool +// ---------- reboot ---------- +func newInstanceRebootCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete an instance", + Use: "reboot ", + Short: "Reboot a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance reboot my-vm`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInstanceReboot(cmd, args[0]) + }, + } + return cmd +} + +func runInstanceReboot(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := instance.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resp, err := svc.Reboot(ctx, slug) + if err != nil { + return fmt.Errorf("instance reboot: %w", err) + } + + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} + return printer.PrintTable(headers, rows) +} + +// ---------- reset ---------- + +func newInstanceResetCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "reset ", + Short: "Reset a virtual machine (hard reset, may lose unsaved data)", Args: cobra.ExactArgs(1), - Example: ` zcp instance delete - zcp instance delete --yes - zcp instance delete --expunge --yes`, + Example: ` zcp instance reset my-vm + zcp instance reset my-vm --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceDelete(cmd, args[0], yes, expunge) + slug := args[0] + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "WARNING: Reset %q will forcefully restart the VM. Unsaved data may be lost. [y/N]: ", slug) + var answer string + fmt.Scanln(&answer) + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Fprintln(os.Stdout, "Aborted.") + return nil + } + } + return runInstanceReset(cmd, slug) }, } cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - cmd.Flags().BoolVar(&expunge, "expunge", false, "Permanently expunge the instance (irreversible)") return cmd } -func runInstanceDelete(cmd *cobra.Command, uuid string, yes, expunge bool) error { - if !yes { - if expunge { - fmt.Fprintf(os.Stdout, "WARNING: --expunge will permanently delete instance %q and cannot be undone.\n", uuid) - } - fmt.Fprintf(os.Stdout, "Delete instance %q? [y/N]: ", uuid) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(strings.TrimSpace(answer)) != "y" { - fmt.Fprintln(os.Stdout, "Aborted.") - return nil - } +func runInstanceReset(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err } - _, client, _, err := buildClientAndPrinter(cmd) + svc := instance.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resp, err := svc.Reset(ctx, slug) + if err != nil { + return fmt.Errorf("instance reset: %w", err) + } + + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} + return printer.PrintTable(headers, rows) +} + +// ---------- logs ---------- + +func newInstanceLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs ", + Short: "Show activity logs for a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance logs my-vm`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInstanceLogs(cmd, args[0]) + }, + } + return cmd +} + +func runInstanceLogs(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } @@ -387,81 +521,175 @@ func runInstanceDelete(cmd *cobra.Command, uuid string, yes, expunge bool) error ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Destroy(ctx, uuid, expunge); err != nil { - return fmt.Errorf("instance delete: %w", err) + logs, err := svc.ActivityLogs(ctx, slug) + if err != nil { + return fmt.Errorf("instance logs: %w", err) } - fmt.Fprintf(os.Stdout, "Instance %q deleted.\n", uuid) - return nil + headers := []string{"ID", "ACTION", "STATUS", "DESCRIPTION", "CREATED"} + rows := make([][]string, 0, len(logs)) + for _, l := range logs { + rows = append(rows, []string{ + l.ID, + l.Action, + l.Status, + l.Description, + l.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) } -func newInstanceRebootCmd() *cobra.Command { +// ---------- tag create ---------- + +func newInstanceTagCreateCmd() *cobra.Command { + var key, value string + cmd := &cobra.Command{ - Use: "reboot ", - Short: "Reboot an instance (stop then start)", + Use: "tag-create ", + Short: "Create a tag on a virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance reboot `, + Example: ` zcp instance tag-create my-vm --key Environment --value Production`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceReboot(cmd, args[0]) + if key == "" { + return fmt.Errorf("--key is required") + } + if value == "" { + return fmt.Errorf("--value is required") + } + return runInstanceTagCreate(cmd, args[0], key, value) }, } + cmd.Flags().StringVar(&key, "key", "", "Tag key (required)") + cmd.Flags().StringVar(&value, "value", "", "Tag value (required)") return cmd } -func runInstanceReboot(cmd *cobra.Command, uuid string) error { +func runInstanceTagCreate(cmd *cobra.Command, slug, key, value string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } svc := instance.NewService(client) - timeout := time.Duration(getTimeout(cmd)) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resp, err := svc.CreateTag(ctx, slug, instance.TagRequest{Key: key, Value: value}) + if err != nil { + return fmt.Errorf("instance tag-create: %w", err) + } + + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} + return printer.PrintTable(headers, rows) +} - fmt.Fprintf(os.Stdout, "Stopping instance %q...\n", uuid) - stopCtx, stopCancel := context.WithTimeout(context.Background(), timeout) - defer stopCancel() +// ---------- tag delete ---------- - if _, err := svc.Stop(stopCtx, uuid, false); err != nil { - return fmt.Errorf("instance reboot (stop phase): %w", err) +func newInstanceTagDeleteCmd() *cobra.Command { + var key string + + cmd := &cobra.Command{ + Use: "tag-delete ", + Short: "Delete a tag from a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance tag-delete my-vm --key Environment`, + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") + } + return runInstanceTagDelete(cmd, args[0], key) + }, + } + cmd.Flags().StringVar(&key, "key", "", "Tag key to delete (required)") + return cmd +} + +func runInstanceTagDelete(cmd *cobra.Command, slug, key string) error { + _, client, _, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := instance.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteTag(ctx, slug, key); err != nil { + return fmt.Errorf("instance tag-delete: %w", err) + } + + fmt.Fprintf(os.Stdout, "Tag %q deleted from instance %q.\n", key, slug) + return nil +} + +// ---------- change-hostname ---------- + +func newInstanceChangeHostnameCmd() *cobra.Command { + var hostname string + + cmd := &cobra.Command{ + Use: "change-hostname ", + Short: "Change the hostname of a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance change-hostname my-vm --hostname new-hostname`, + RunE: func(cmd *cobra.Command, args []string) error { + if hostname == "" { + return fmt.Errorf("--hostname is required") + } + return runInstanceChangeHostname(cmd, args[0], hostname) + }, + } + cmd.Flags().StringVar(&hostname, "hostname", "", "New hostname (required)") + return cmd +} + +func runInstanceChangeHostname(cmd *cobra.Command, slug, hostname string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err } - fmt.Fprintf(os.Stdout, "Starting instance %q...\n", uuid) - startCtx, startCancel := context.WithTimeout(context.Background(), timeout) - defer startCancel() + svc := instance.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() - inst, err := svc.Start(startCtx, uuid) + resp, err := svc.ChangeHostname(ctx, slug, instance.ChangeLabelRequest{ + Name: hostname, + Hostname: hostname, + }) if err != nil { - return fmt.Errorf("instance reboot (start phase): %w", err) + return fmt.Errorf("instance change-hostname: %w", err) } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{inst.UUID, inst.Name, inst.State}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstanceResizeCmd() *cobra.Command { - var offeringUUID, cpuCores, memory string +// ---------- change-password ---------- + +func newInstanceChangePasswordCmd() *cobra.Command { + var password string cmd := &cobra.Command{ - Use: "resize ", - Short: "Resize instance compute offering", - Args: cobra.ExactArgs(1), - Example: ` zcp instance resize --offering - zcp instance resize --offering --cpu 4 --memory 8192`, + Use: "change-password ", + Short: "Reset the password of a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance change-password my-vm --password "newSecureP@ss"`, RunE: func(cmd *cobra.Command, args []string) error { - if offeringUUID == "" { - return fmt.Errorf("--offering is required") + if password == "" { + return fmt.Errorf("--password is required") } - return runInstanceResize(cmd, args[0], offeringUUID, cpuCores, memory) + return runInstanceChangePassword(cmd, args[0], password) }, } - cmd.Flags().StringVar(&offeringUUID, "offering", "", "Compute offering UUID (required)") - cmd.Flags().StringVar(&cpuCores, "cpu", "", "Number of CPU cores (optional, for custom offerings)") - cmd.Flags().StringVar(&memory, "memory", "", "Memory in MB (optional, for custom offerings)") + cmd.Flags().StringVar(&password, "password", "", "New password (required)") return cmd } -func runInstanceResize(cmd *cobra.Command, uuid, offeringUUID, cpuCores, memory string) error { +func runInstanceChangePassword(cmd *cobra.Command, slug, password string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -471,30 +699,45 @@ func runInstanceResize(cmd *cobra.Command, uuid, offeringUUID, cpuCores, memory ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Resize(ctx, uuid, offeringUUID, cpuCores, memory) + resp, err := svc.ChangePassword(ctx, slug, instance.ChangePasswordRequest{ + Password: password, + VM: slug, + }) if err != nil { - return fmt.Errorf("instance resize: %w", err) + return fmt.Errorf("instance change-password: %w", err) } - headers := []string{"UUID", "NAME", "STATE", "COMPUTE OFFERING", "MEMORY"} - rows := [][]string{{inst.UUID, inst.Name, inst.State, inst.ComputeOfferingUUID, inst.Memory}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstanceNetworkListCmd() *cobra.Command { +// ---------- change-plan ---------- + +func newInstanceChangePlanCmd() *cobra.Command { + var plan, billingCycle string + cmd := &cobra.Command{ - Use: "network-list ", - Short: "List networks attached to an instance", + Use: "change-plan ", + Short: "Change the plan of a virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance network-list `, + Example: ` zcp instance change-plan my-vm --plan box2cm4 --billing-cycle hourly`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceNetworkList(cmd, args[0]) + if plan == "" { + return fmt.Errorf("--plan is required") + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + return runInstanceChangePlan(cmd, args[0], plan, billingCycle) }, } + cmd.Flags().StringVar(&plan, "plan", "", "New plan slug (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug (required)") return cmd } -func runInstanceNetworkList(cmd *cobra.Command, uuid string) error { +func runInstanceChangePlan(cmd *cobra.Command, slug, plan, billingCycle string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -504,89 +747,138 @@ func runInstanceNetworkList(cmd *cobra.Command, uuid string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - networks, err := svc.ListNetworks(ctx, uuid) + resp, err := svc.ChangePlan(ctx, slug, instance.ChangePlanRequest{ + Plan: plan, + Slug: slug, + VM: slug, + BillingCycle: billingCycle, + }) if err != nil { - return fmt.Errorf("instance network-list: %w", err) + return fmt.Errorf("instance change-plan: %w", err) } - headers := []string{"UUID", "NAME", "TYPE", "PRIVATE IP", "PUBLIC IP", "DEFAULT"} - rows := make([][]string, 0, len(networks)) - for _, n := range networks { - rows = append(rows, []string{ - n.UUID, - n.Name, - n.Type, - n.PrivateIP, - n.PublicIP, - strconv.FormatBool(n.DefaultNetwork), - }) - } + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstancePasswordListCmd() *cobra.Command { - var zoneUUID, instanceUUID string +// ---------- change-os ---------- + +func newInstanceChangeOSCmd() *cobra.Command { + var template string + var yes bool cmd := &cobra.Command{ - Use: "password-list", - Short: "List instance passwords", - Example: ` zcp instance password-list --zone - zcp instance password-list --zone --instance `, + Use: "change-os ", + Short: "Change the OS template of a virtual machine (DESTRUCTIVE)", + Args: cobra.ExactArgs(1), + Example: ` zcp instance change-os my-vm --template ubuntu-22f + zcp instance change-os my-vm --template ubuntu-22f --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstancePasswordList(cmd, zoneUUID, instanceUUID) + if template == "" { + return fmt.Errorf("--template is required") + } + slug := args[0] + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "WARNING: Changing OS on %q will reinstall the VM and erase all data. [y/N]: ", slug) + var answer string + fmt.Scanln(&answer) + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Fprintln(os.Stdout, "Aborted.") + return nil + } + } + return runInstanceChangeOS(cmd, slug, template) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&instanceUUID, "instance", "", "Filter by instance UUID (optional)") + cmd.Flags().StringVar(&template, "template", "", "New template slug (required)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") return cmd } -func runInstancePasswordList(cmd *cobra.Command, zoneUUID, instanceUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runInstanceChangeOS(cmd *cobra.Command, slug, template string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := instance.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - passwords, err := svc.ListPasswords(ctx, zoneUUID, instanceUUID) + resp, err := svc.ChangeOS(ctx, slug, instance.ChangeTemplateRequest{Template: template}) if err != nil { - return fmt.Errorf("instance password-list: %w", err) + return fmt.Errorf("instance change-os: %w", err) } - if len(passwords) == 0 { - fmt.Fprintln(os.Stdout, "No passwords found. Passwords may be empty until the instance is first started.") - return nil + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} + return printer.PrintTable(headers, rows) +} + +// ---------- change-script ---------- + +func newInstanceChangeScriptCmd() *cobra.Command { + var userData string + + cmd := &cobra.Command{ + Use: "change-script ", + Short: "Change the startup script of a virtual machine", + Args: cobra.ExactArgs(1), + Example: ` zcp instance change-script my-vm --user-data "#!/bin/bash\napt update"`, + RunE: func(cmd *cobra.Command, args []string) error { + if userData == "" { + return fmt.Errorf("--user-data is required") + } + return runInstanceChangeScript(cmd, args[0], userData) + }, + } + cmd.Flags().StringVar(&userData, "user-data", "", "Startup script content (required)") + return cmd +} + +func runInstanceChangeScript(cmd *cobra.Command, slug, userData string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err } - headers := []string{"UUID", "PASSWORD"} - rows := make([][]string, 0, len(passwords)) - for _, p := range passwords { - rows = append(rows, []string{p.UUID, p.Password}) + svc := instance.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resp, err := svc.ChangeStartupScript(ctx, slug, instance.ChangeStartupScriptRequest{UserData: userData}) + if err != nil { + return fmt.Errorf("instance change-script: %w", err) } + + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstanceStatusCmd() *cobra.Command { +// ---------- add-network ---------- + +func newInstanceAddNetworkCmd() *cobra.Command { + var network string + cmd := &cobra.Command{ - Use: "status ", - Short: "Get the current status of an instance", + Use: "add-network ", + Short: "Add a network to a virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance status `, + Example: ` zcp instance add-network my-vm --network my-network-slug`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceStatus(cmd, args[0]) + if network == "" { + return fmt.Errorf("--network is required") + } + return runInstanceAddNetwork(cmd, args[0], network) }, } + cmd.Flags().StringVar(&network, "network", "", "Network slug to add (required)") return cmd } -func runInstanceStatus(cmd *cobra.Command, uuid string) error { +func runInstanceAddNetwork(cmd *cobra.Command, slug, network string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -596,30 +888,32 @@ func runInstanceStatus(cmd *cobra.Command, uuid string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - status, err := svc.GetStatus(ctx, uuid) + resp, err := svc.AddNetwork(ctx, slug, instance.AddNetworkRequest{Network: network}) if err != nil { - return fmt.Errorf("instance status: %w", err) + return fmt.Errorf("instance add-network: %w", err) } - headers := []string{"UUID", "STATUS"} - rows := [][]string{{status.UUID, status.Status}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } -func newInstanceRecoverCmd() *cobra.Command { +// ---------- addons ---------- + +func newInstanceAddonsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "recover ", - Short: "Recover an instance from an error state", + Use: "addons ", + Short: "List addons for a virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp instance recover `, + Example: ` zcp instance addons my-vm`, RunE: func(cmd *cobra.Command, args []string) error { - return runInstanceRecover(cmd, args[0]) + return runInstanceAddons(cmd, args[0]) }, } return cmd } -func runInstanceRecover(cmd *cobra.Command, uuid string) error { +func runInstanceAddons(cmd *cobra.Command, slug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -629,36 +923,87 @@ func runInstanceRecover(cmd *cobra.Command, uuid string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Recover(ctx, uuid) + addons, err := svc.ListAddons(ctx, slug) if err != nil { - return fmt.Errorf("instance recover: %w", err) + return fmt.Errorf("instance addons: %w", err) + } + + if len(addons) == 0 { + fmt.Fprintln(os.Stdout, "No addons found.") + return nil } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{inst.UUID, inst.Name, inst.State}} + headers := []string{"ID", "NAME", "SLUG", "STATUS", "CREATED"} + rows := make([][]string, 0, len(addons)) + for _, a := range addons { + rows = append(rows, []string{ + a.ID, + a.Name, + a.Slug, + strconv.FormatBool(a.Status), + a.CreatedAt, + }) + } return printer.PrintTable(headers, rows) } -func newInstanceRenameCmd() *cobra.Command { - var displayName string +// ---------- purchase-addon ---------- + +func newInstancePurchaseAddonCmd() *cobra.Command { + var ( + vmSlug string + project string + region string + cloudProvider string + addonSlug string + addonCategory string + addonID string + billingCycle string + quantity int + ) cmd := &cobra.Command{ - Use: "rename ", - Short: "Update an instance's display name", - Args: cobra.ExactArgs(1), - Example: ` zcp instance rename --display-name "My Web Server"`, + Use: "purchase-addon", + Short: "Purchase an addon for a virtual machine", + Example: ` zcp instance purchase-addon --vm my-vm --project default --region noida --cloud-provider nimbo --addon-slug remote-desktop-license --addon-category microsoft-spla-licenses --addon-id --billing-cycle hourly`, RunE: func(cmd *cobra.Command, args []string) error { - if displayName == "" { - return fmt.Errorf("--display-name is required") + if vmSlug == "" { + return fmt.Errorf("--vm is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") } - return runInstanceRename(cmd, args[0], displayName) + if addonSlug == "" { + return fmt.Errorf("--addon-slug is required") + } + if addonID == "" { + return fmt.Errorf("--addon-id is required") + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + return runInstancePurchaseAddon(cmd, vmSlug, project, region, cloudProvider, addonSlug, addonCategory, addonID, billingCycle, quantity) }, } - cmd.Flags().StringVar(&displayName, "display-name", "", "New display name for the instance (required)") + cmd.Flags().StringVar(&vmSlug, "vm", "", "VM slug (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(&addonSlug, "addon-slug", "", "Addon slug (required)") + cmd.Flags().StringVar(&addonCategory, "addon-category", "", "Addon category slug (optional)") + cmd.Flags().StringVar(&addonID, "addon-id", "", "Addon ID (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug (required)") + cmd.Flags().IntVar(&quantity, "quantity", 1, "Quantity (default: 1)") return cmd } -func runInstanceRename(cmd *cobra.Command, uuid, displayName string) error { +func runInstancePurchaseAddon(cmd *cobra.Command, vmSlug, project, region, cloudProvider, addonSlug, addonCategory, addonID, billingCycle string, quantity int) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -668,36 +1013,54 @@ func runInstanceRename(cmd *cobra.Command, uuid, displayName string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - inst, err := svc.Rename(ctx, uuid, displayName) + req := instance.PurchaseAddonRequest{ + VirtualMachine: vmSlug, + Project: project, + Region: region, + CloudProvider: cloudProvider, + Addons: []instance.AddonInput{ + { + Category: addonCategory, + ID: addonID, + Slug: addonSlug, + Quantity: quantity, + }, + }, + Service: "Store", + BillingCycle: billingCycle, + } + + resp, err := svc.PurchaseAddon(ctx, req) if err != nil { - return fmt.Errorf("instance rename: %w", err) + return fmt.Errorf("instance purchase-addon: %w", err) } - headers := []string{"UUID", "NAME", "DISPLAY NAME", "STATE"} - rows := [][]string{{inst.UUID, inst.Name, inst.DisplayName, inst.State}} + headers := []string{"STATUS", "MESSAGE"} + rows := [][]string{{resp.Status, resp.Message}} return printer.PrintTable(headers, rows) } +// ---------- ssh ---------- + func newInstanceSSHCmd() *cobra.Command { var user, identityFile string var port int cmd := &cobra.Command{ - Use: "ssh ", - Short: "Open an SSH session to an instance", - Long: `Open an SSH session to an instance by resolving its IP address via the API. + Use: "ssh ", + Short: "Open an SSH session to a virtual machine", + Long: `Open an SSH session to a virtual machine by resolving its IP address via the API. -The CLI looks up the instance's attached networks and connects to the first -available private IP address. If no private IP is found, it falls back to a -public IP. The default SSH user is "root"; use --user to override. +The CLI looks up the VM's private or public IP and connects. The default SSH user +is "root"; use --user to override. Requirements: - ssh must be installed and available in your PATH - - The instance must be reachable from your local machine (VPN or public IP)`, + - The VM must be reachable from your local machine (VPN or public IP)`, Args: cobra.ExactArgs(1), - Example: ` zcp instance ssh - zcp instance ssh --user ubuntu - zcp instance ssh --user root --identity-file ~/.ssh/my-key.pem`, + Example: ` zcp instance ssh my-vm + zcp instance ssh my-vm --user ubuntu + zcp instance ssh my-vm --user root --identity-file ~/.ssh/my-key.pem`, RunE: func(cmd *cobra.Command, args []string) error { return runInstanceSSH(cmd, args[0], user, identityFile, port) }, @@ -708,7 +1071,7 @@ Requirements: return cmd } -func runInstanceSSH(cmd *cobra.Command, uuid, user, identityFile string, port int) error { +func runInstanceSSH(cmd *cobra.Command, slug, user, identityFile string, port int) error { _, client, _, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -718,32 +1081,23 @@ func runInstanceSSH(cmd *cobra.Command, uuid, user, identityFile string, port in ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - networks, err := svc.ListNetworks(ctx, uuid) + vm, err := svc.Get(ctx, slug) if err != nil { - return fmt.Errorf("resolving instance network: %w", err) - } - if len(networks) == 0 { - return fmt.Errorf("instance %s has no attached networks", uuid) + return fmt.Errorf("resolving instance IP: %w", err) } // Prefer private IP; fall back to public IP - ip := "" - for _, n := range networks { - if n.PrivateIP != "" { - ip = n.PrivateIP - break - } - } + ip := instance.StringVal(vm.PrivateIP) if ip == "" { - for _, n := range networks { - if n.PublicIP != "" { - ip = n.PublicIP - break - } - } + ip = instance.StringVal(vm.PublicIP) } if ip == "" { - return fmt.Errorf("instance %s has no usable IP address", uuid) + return fmt.Errorf("instance %s has no usable IP address", slug) + } + + // Use VM username if no --user override and username is available + if user == "root" && vm.Username != "" { + user = vm.Username } // Build SSH command diff --git a/internal/commands/internallb.go b/internal/commands/internallb.go index af92cb6..2bdb9f6 100644 --- a/internal/commands/internallb.go +++ b/internal/commands/internallb.go @@ -169,7 +169,7 @@ func newInternalLBDeleteCmd() *cobra.Command { } func runInternalLBDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete internal load balancer %q? This action cannot be undone. [y/N]: ", uuid) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() diff --git a/internal/commands/ip.go b/internal/commands/ip.go index db8ab27..4f20e62 100644 --- a/internal/commands/ip.go +++ b/internal/commands/ip.go @@ -20,94 +20,98 @@ func NewIPCmd() *cobra.Command { } cmd.AddCommand(newIPListCmd()) cmd.AddCommand(newIPAllocateCmd()) - cmd.AddCommand(newIPReleaseCmd()) natCmd := &cobra.Command{Use: "static-nat", Short: "Manage static NAT on IP addresses"} natCmd.AddCommand(newIPStaticNATEnableCmd()) - natCmd.AddCommand(newIPStaticNATDisableCmd()) cmd.AddCommand(natCmd) + vpnCmd := &cobra.Command{Use: "vpn", Short: "Manage remote access VPN on IP addresses"} + vpnCmd.AddCommand(newIPVPNListCmd()) + vpnCmd.AddCommand(newIPVPNEnableCmd()) + vpnCmd.AddCommand(newIPVPNDisableCmd()) + cmd.AddCommand(vpnCmd) + return cmd } func newIPListCmd() *cobra.Command { - var zoneUUID, networkUUID string + var vpcSlug string cmd := &cobra.Command{ Use: "list", Short: "List public IP addresses", - Example: ` zcp ip list --zone - zcp ip list --zone --network `, + Example: ` zcp ip list + zcp ip list --vpc `, RunE: func(cmd *cobra.Command, args []string) error { - return runIPList(cmd, zoneUUID, networkUUID) + return runIPList(cmd, vpcSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Filter by network UUID") + cmd.Flags().StringVar(&vpcSlug, "vpc", "", "Filter by VPC slug") return cmd } -func runIPList(cmd *cobra.Command, zoneUUID, networkUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runIPList(cmd *cobra.Command, vpcSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := ipaddress.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - ips, err := svc.List(ctx, zoneUUID, networkUUID) + ips, err := svc.List(ctx, vpcSlug) if err != nil { return fmt.Errorf("ip list: %w", err) } - headers := []string{"UUID", "PUBLIC IP", "STATE", "NETWORK", "SOURCE NAT"} + headers := []string{"SLUG", "IP ADDRESS", "STRATEGY", "VM", "NETWORK ID", "VPC ID"} rows := make([][]string, 0, len(ips)) for _, ip := range ips { - sourceNAT := "false" - if ip.IsSourceNAT { - sourceNAT = "true" - } rows = append(rows, []string{ - ip.UUID, - ip.PublicIPAddress, - ip.State, - ip.NetworkUUID, - sourceNAT, + ip.Slug, + ip.IPAddress, + ip.Strategy, + ip.VirtualMachineName, + ip.NetworkID, + ip.VPCID, }) } return printer.PrintTable(headers, rows) } func newIPAllocateCmd() *cobra.Command { - var networkUUID, networkType string + var vpc, network, plan, billingCycle string cmd := &cobra.Command{ Use: "allocate", Short: "Allocate a new public IP address", - Example: ` zcp ip allocate --network - zcp ip allocate --network --type Isolated`, + Example: ` zcp ip allocate --plan ip-plan --billing-cycle hourly + zcp ip allocate --plan ip-plan --billing-cycle hourly --network + zcp ip allocate --plan ip-plan --billing-cycle hourly --vpc `, RunE: func(cmd *cobra.Command, args []string) error { - if networkUUID == "" { - return fmt.Errorf("--network is required") + if plan == "" { + return fmt.Errorf("--plan is required") } - if networkType == "" { - networkType = "Isolated" + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") } - return runIPAllocate(cmd, networkUUID, networkType) + return runIPAllocate(cmd, ipaddress.CreateRequest{ + VPC: vpc, + Network: network, + Plan: plan, + BillingCycle: billingCycle, + }) }, } - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID (required)") - cmd.Flags().StringVar(&networkType, "type", "Isolated", "Network type (Isolated or VPC)") + cmd.Flags().StringVar(&vpc, "vpc", "", "VPC slug") + cmd.Flags().StringVar(&network, "network", "", "Network slug") + cmd.Flags().StringVar(&plan, "plan", "", "IP plan slug (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug (required, e.g. hourly, monthly)") return cmd } -func runIPAllocate(cmd *cobra.Command, networkUUID, networkType string) error { +func runIPAllocate(cmd *cobra.Command, req ipaddress.CreateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -117,52 +121,85 @@ func runIPAllocate(cmd *cobra.Command, networkUUID, networkType string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - ip, err := svc.Acquire(ctx, networkUUID, networkType) + ip, err := svc.Allocate(ctx, req) if err != nil { return fmt.Errorf("ip allocate: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", ip.UUID}, - {"Public IP", ip.PublicIPAddress}, - {"State", ip.State}, - {"Network UUID", ip.NetworkUUID}, - {"Zone UUID", ip.ZoneUUID}, - {"Source NAT", fmt.Sprintf("%v", ip.IsSourceNAT)}, + {"Slug", ip.Slug}, + {"IP Address", ip.IPAddress}, + {"Strategy", ip.Strategy}, + {"Network ID", ip.NetworkID}, + {"VPC ID", ip.VPCID}, + {"Region ID", ip.RegionID}, + {"Created At", ip.CreatedAt}, } return printer.PrintTable(headers, rows) } -func newIPReleaseCmd() *cobra.Command { - var yes bool +func newIPStaticNATEnableCmd() *cobra.Command { + var vmSlug string cmd := &cobra.Command{ - Use: "release ", - Short: "Release a public IP address", - Args: cobra.ExactArgs(1), - Example: ` zcp ip release - zcp ip release --yes`, + Use: "enable ", + Short: "Enable static NAT on an IP address", + Args: cobra.ExactArgs(1), + Example: ` zcp ip static-nat enable --instance `, RunE: func(cmd *cobra.Command, args []string) error { - return runIPRelease(cmd, args[0], yes) + if vmSlug == "" { + return fmt.Errorf("--instance is required") + } + return runIPStaticNATEnable(cmd, args[0], vmSlug) }, } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&vmSlug, "instance", "", "VM slug to associate (required)") return cmd } -func runIPRelease(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Release IP address %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } +func runIPStaticNATEnable(cmd *cobra.Command, ipSlug, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := ipaddress.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + ip, err := svc.EnableStaticNAT(ctx, ipSlug, vmSlug) + if err != nil { + return fmt.Errorf("ip static-nat enable: %w", err) } + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", ip.Slug}, + {"IP Address", ip.IPAddress}, + {"Strategy", ip.Strategy}, + {"VM", ip.VirtualMachineName}, + {"Network ID", ip.NetworkID}, + } + return printer.PrintTable(headers, rows) +} + +// ─── Remote Access VPN ─────────────────────────────────────────────────────── + +func newIPVPNListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List remote access VPNs on an IP address", + Args: cobra.ExactArgs(1), + Example: ` zcp ip vpn list `, + RunE: func(cmd *cobra.Command, args []string) error { + return runIPVPNList(cmd, args[0]) + }, + } + return cmd +} + +func runIPVPNList(cmd *cobra.Command, ipSlug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -172,38 +209,38 @@ func runIPRelease(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Release(ctx, uuid); err != nil { - return fmt.Errorf("ip release: %w", err) + vpns, err := svc.ListRemoteAccessVPNs(ctx, ipSlug) + if err != nil { + return fmt.Errorf("ip vpn list: %w", err) } - printer.Fprintf("IP address %q released.\n", uuid) - return nil + headers := []string{"ID", "PUBLIC IP", "STATE", "CREATED AT"} + rows := make([][]string, 0, len(vpns)) + for _, v := range vpns { + rows = append(rows, []string{ + v.ID, + v.PublicIP, + v.State, + v.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) } -func newIPStaticNATEnableCmd() *cobra.Command { - var instanceUUID, networkUUID string - +func newIPVPNEnableCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "enable ", - Short: "Enable static NAT on an IP address", + Use: "enable ", + Short: "Enable remote access VPN on an IP address", Args: cobra.ExactArgs(1), - Example: ` zcp ip static-nat enable --instance --network `, + Example: ` zcp ip vpn enable `, RunE: func(cmd *cobra.Command, args []string) error { - if instanceUUID == "" { - return fmt.Errorf("--instance is required") - } - if networkUUID == "" { - return fmt.Errorf("--network is required") - } - return runIPStaticNATEnable(cmd, args[0], instanceUUID, networkUUID) + return runIPVPNEnable(cmd, args[0]) }, } - cmd.Flags().StringVar(&instanceUUID, "instance", "", "VM UUID to associate (required)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID (required)") return cmd } -func runIPStaticNATEnable(cmd *cobra.Command, ipUUID, vmUUID, networkUUID string) error { +func runIPVPNEnable(cmd *cobra.Command, ipSlug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -213,42 +250,41 @@ func runIPStaticNATEnable(cmd *cobra.Command, ipUUID, vmUUID, networkUUID string ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cfg, err := svc.EnableStaticNAT(ctx, ipUUID, vmUUID, networkUUID) + vpn, err := svc.EnableRemoteAccessVPN(ctx, ipSlug) if err != nil { - return fmt.Errorf("ip static-nat enable: %w", err) + return fmt.Errorf("ip vpn enable: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"IP Address UUID", cfg.IPAddressUUID}, - {"VM UUID", cfg.VMUUID}, - {"VM Name", cfg.VMName}, - {"Network UUID", cfg.NetworkUUID}, - {"Status", cfg.Status}, + {"ID", vpn.ID}, + {"Public IP", vpn.PublicIP}, + {"State", vpn.State}, + {"Created At", vpn.CreatedAt}, } return printer.PrintTable(headers, rows) } -func newIPStaticNATDisableCmd() *cobra.Command { +func newIPVPNDisableCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "disable ", - Short: "Disable static NAT on an IP address", - Args: cobra.ExactArgs(1), - Example: ` zcp ip static-nat disable - zcp ip static-nat disable --yes`, + Use: "disable ", + Short: "Disable remote access VPN on an IP address", + Args: cobra.ExactArgs(2), + Example: ` zcp ip vpn disable + zcp ip vpn disable --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runIPStaticNATDisable(cmd, args[0], yes) + return runIPVPNDisable(cmd, args[0], args[1], yes) }, } cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } -func runIPStaticNATDisable(cmd *cobra.Command, ipUUID string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Disable static NAT for IP address %q? [y/N]: ", ipUUID) +func runIPVPNDisable(cmd *cobra.Command, ipSlug, vpnID string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Disable remote access VPN %q on IP %q? [y/N]: ", vpnID, ipSlug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -267,10 +303,10 @@ func runIPStaticNATDisable(cmd *cobra.Command, ipUUID string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.DisableStaticNAT(ctx, ipUUID); err != nil { - return fmt.Errorf("ip static-nat disable: %w", err) + if err := svc.DisableRemoteAccessVPN(ctx, ipSlug, vpnID); err != nil { + return fmt.Errorf("ip vpn disable: %w", err) } - printer.Fprintf("Static NAT disabled for IP address %q.\n", ipUUID) + printer.Fprintf("Remote access VPN %q disabled on IP %q.\n", vpnID, ipSlug) return nil } diff --git a/internal/commands/iso.go b/internal/commands/iso.go new file mode 100644 index 0000000..8d62504 --- /dev/null +++ b/internal/commands/iso.go @@ -0,0 +1,269 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/iso" +) + +// NewISOCmd returns the 'iso' cobra command. +func NewISOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "iso", + Short: "Manage ISO images", + } + cmd.AddCommand(newISOListCmd()) + cmd.AddCommand(newISOCreateCmd()) + cmd.AddCommand(newISOUpdateCmd()) + cmd.AddCommand(newISODeleteCmd()) + return cmd +} + +func newISOListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List ISO images", + Example: ` zcp iso list + zcp iso list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runISOList(cmd) + }, + } + return cmd +} + +func runISOList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := iso.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + isos, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("iso list: %w", err) + } + + headers := []string{"SLUG", "NAME", "IMAGE TYPE", "BOOTABLE", "EXTRACTABLE", "PASSWORD", "CREATED"} + rows := make([][]string, 0, len(isos)) + for _, i := range isos { + rows = append(rows, []string{ + i.Slug, + i.Name, + i.ImageType, + strconv.FormatBool(i.IsBootable), + strconv.FormatBool(i.IsExtractable), + strconv.FormatBool(i.PasswordEnabled), + i.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newISOCreateCmd() *cobra.Command { + var ( + name string + description string + isoURL string + cloudProvider string + project string + region string + osTypeID string + imageType string + operatingSystem string + osVersion string + billingCycle string + passwordEnabled bool + isExtractable bool + isBootable bool + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create (register) an ISO image", + Example: ` zcp iso create --name my-iso --url https://example.com/my.iso \ + --cloud-provider nimbo --project default-1 --region yow-1 \ + --os-type-id --image-type "Operating System" \ + --os ubuntu --os-version "22.04 LTS" --billing-cycle hourly`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if isoURL == "" { + return fmt.Errorf("--url is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + req := iso.CreateRequest{ + Name: name, + Description: description, + URL: isoURL, + CloudProvider: cloudProvider, + Project: project, + Region: region, + OSTypeID: osTypeID, + ImageType: imageType, + OperatingSystem: operatingSystem, + OperatingSystemVersion: osVersion, + BillingCycle: billingCycle, + PasswordEnabled: passwordEnabled, + IsExtractable: isExtractable, + IsBootable: isBootable, + Service: "ISO", + } + return runISOCreate(cmd, req) + }, + } + cmd.Flags().StringVar(&name, "name", "", "ISO name (required)") + cmd.Flags().StringVar(&description, "description", "", "ISO description") + cmd.Flags().StringVar(&isoURL, "url", "", "URL to the ISO file (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&osTypeID, "os-type-id", "", "OS type UUID") + cmd.Flags().StringVar(&imageType, "image-type", "Operating System", "Image type") + cmd.Flags().StringVar(&operatingSystem, "os", "", "Operating system name") + cmd.Flags().StringVar(&osVersion, "os-version", "", "Operating system version") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "hourly", "Billing cycle (hourly, monthly)") + cmd.Flags().BoolVar(&passwordEnabled, "password-enabled", true, "Enable password on the ISO") + cmd.Flags().BoolVar(&isExtractable, "extractable", false, "ISO is extractable") + cmd.Flags().BoolVar(&isBootable, "bootable", false, "ISO is bootable") + return cmd +} + +func runISOCreate(cmd *cobra.Command, req iso.CreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := iso.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + i, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("iso create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", i.Slug}, + {"Name", i.Name}, + {"Image Type", i.ImageType}, + {"Bootable", strconv.FormatBool(i.IsBootable)}, + {"Extractable", strconv.FormatBool(i.IsExtractable)}, + {"Password Enabled", strconv.FormatBool(i.PasswordEnabled)}, + {"Created", i.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +func newISOUpdateCmd() *cobra.Command { + var ( + passwordEnabled bool + isExtractable bool + isBootable bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update ISO permissions", + Args: cobra.ExactArgs(1), + Example: ` zcp iso update my-iso --bootable --password-enabled + zcp iso update my-iso --extractable=false`, + RunE: func(cmd *cobra.Command, args []string) error { + req := iso.UpdateRequest{ + PasswordEnabled: passwordEnabled, + IsExtractable: isExtractable, + IsBootable: isBootable, + } + return runISOUpdate(cmd, args[0], req) + }, + } + cmd.Flags().BoolVar(&passwordEnabled, "password-enabled", true, "Enable password") + cmd.Flags().BoolVar(&isExtractable, "extractable", false, "ISO is extractable") + cmd.Flags().BoolVar(&isBootable, "bootable", false, "ISO is bootable") + return cmd +} + +func runISOUpdate(cmd *cobra.Command, slug string, req iso.UpdateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := iso.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Update(ctx, slug, req); err != nil { + return fmt.Errorf("iso update: %w", err) + } + + printer.Fprintf("ISO %q updated.\n", slug) + return nil +} + +func newISODeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an ISO image", + Args: cobra.ExactArgs(1), + Example: ` zcp iso delete my-iso + zcp iso delete my-iso --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runISODelete(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runISODelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete ISO %q? [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := iso.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("iso delete: %w", err) + } + + printer.Fprintf("ISO %q deleted.\n", slug) + return nil +} diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index 60ff9a9..d8b638a 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -19,85 +19,28 @@ func NewKubernetesCmd() *cobra.Command { Aliases: []string{"k8s"}, Short: "Manage Kubernetes clusters (alias: k8s)", } - cmd.AddCommand(newK8sVersionListCmd()) cmd.AddCommand(newK8sClusterListCmd()) cmd.AddCommand(newK8sClusterCreateCmd()) - cmd.AddCommand(newK8sClusterDeleteCmd()) cmd.AddCommand(newK8sClusterStartCmd()) cmd.AddCommand(newK8sClusterStopCmd()) - cmd.AddCommand(newK8sClusterScaleCmd()) - cmd.AddCommand(newK8sNodeListCmd()) + cmd.AddCommand(newK8sClusterUpgradeCmd()) return cmd } -func newK8sVersionListCmd() *cobra.Command { - var zoneUUID string - - cmd := &cobra.Command{ - Use: "version list", - Short: "List supported Kubernetes versions for a zone", - Example: ` zcp kubernetes version list --zone - zcp k8s version list --zone --output json`, - RunE: func(cmd *cobra.Command, args []string) error { - return runK8sVersionList(cmd, zoneUUID) - }, - } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - return cmd -} - -func runK8sVersionList(cmd *cobra.Command, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } - - svc := kubernetes.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - versions, err := svc.ListVersions(ctx, zoneUUID) - if err != nil { - return fmt.Errorf("kubernetes version list: %w", err) - } - - headers := []string{"UUID", "NAME", "DESCRIPTION", "MIN MEMORY", "MIN CPU", "ACTIVE"} - rows := make([][]string, 0, len(versions)) - for _, v := range versions { - rows = append(rows, []string{ - v.UUID, - v.Name, - v.Description, - strconv.Itoa(v.MinMemory), - strconv.Itoa(v.MinCPUNumber), - strconv.FormatBool(v.IsActive), - }) - } - return printer.PrintTable(headers, rows) -} - func newK8sClusterListCmd() *cobra.Command { - var clusterUUID string - cmd := &cobra.Command{ Use: "list", Short: "List Kubernetes clusters", Example: ` zcp kubernetes list - zcp kubernetes list --cluster zcp k8s list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runK8sClusterList(cmd, clusterUUID) + return runK8sClusterList(cmd) }, } - cmd.Flags().StringVar(&clusterUUID, "cluster", "", "Filter by cluster UUID (optional)") return cmd } -func runK8sClusterList(cmd *cobra.Command, clusterUUID string) error { +func runK8sClusterList(cmd *cobra.Command) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -107,21 +50,28 @@ func runK8sClusterList(cmd *cobra.Command, clusterUUID string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - clusters, err := svc.List(ctx, clusterUUID) + clusters, err := svc.List(ctx) if err != nil { return fmt.Errorf("kubernetes list: %w", err) } - headers := []string{"UUID", "NAME", "STATE", "WORKERS", "CONTROL NODES", "NETWORK"} + headers := []string{"SLUG", "NAME", "STATE", "VERSION", "WORKERS", "CONTROL NODES", "HA", "REGION", "CREATED"} rows := make([][]string, 0, len(clusters)) for _, c := range clusters { + regionName := "" + if c.Region != nil { + regionName = c.Region.Name + } rows = append(rows, []string{ - c.UUID, + c.Slug, c.Name, c.State, - strconv.Itoa(c.Size), + c.Version, + strconv.Itoa(c.NodeSize), strconv.Itoa(c.ControlNodes), - c.TransNetworkUUID, + strconv.FormatBool(c.EnableHA), + regionName, + c.CreatedAt, }) } return printer.PrintTable(headers, rows) @@ -129,87 +79,107 @@ func runK8sClusterList(cmd *cobra.Command, clusterUUID string) error { func newK8sClusterCreateCmd() *cobra.Command { var ( - zoneUUID string - name string - versionUUID string - computeOfferingUUID string - networkUUID string - workers int - controlNodes int - sshKeyName string - haEnabled bool - description string - diskSize int64 - externalLBIP string + name string + version string + nodeSize int + controlNodes int + cloudProvider string + region string + project string + billingCycle string + enableHA bool + plan string + storageCategory string + sshKey string + authMethod string + username string + password string ) cmd := &cobra.Command{ Use: "create", Short: "Create a new Kubernetes cluster", - Example: ` zcp kubernetes create --zone --name my-cluster --version --compute-offering --network --workers 3 --ssh-key mykey - zcp kubernetes create --zone --name ha-cluster --version --compute-offering --network --workers 3 --ssh-key mykey --ha --control-nodes 3`, + Example: ` zcp kubernetes create --name my-cluster --version v1.28.4 --plan k8s-plan-1 --region noida --project default-59 --cloud-provider nimbo --billing-cycle monthly --workers 3 --ssh-key mykey + zcp kubernetes create --name ha-cluster --version v1.28.4 --plan k8s-plan-1 --region noida --project default-59 --cloud-provider nimbo --billing-cycle monthly --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") } - if versionUUID == "" { + if version == "" { return fmt.Errorf("--version is required") } - if computeOfferingUUID == "" { - return fmt.Errorf("--compute-offering is required") + if plan == "" { + return fmt.Errorf("--plan is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") } - if networkUUID == "" { - return fmt.Errorf("--network is required") + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") } - if workers < 1 { + if nodeSize < 1 { return fmt.Errorf("--workers must be >= 1") } - if sshKeyName == "" { - return fmt.Errorf("--ssh-key is required") + if sshKey == "" && authMethod == "ssh-key" { + return fmt.Errorf("--ssh-key is required when --auth-method is ssh-key") } - if haEnabled && controlNodes < 3 { + 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) } return runK8sClusterCreate(cmd, kubernetes.CreateRequest{ - Name: name, - ZoneUUID: zoneUUID, - VersionUUID: versionUUID, - ComputeOfferingUUID: computeOfferingUUID, - TransNetworkUUID: networkUUID, - Size: int64(workers), - ControlNodes: int64(controlNodes), - SSHKeyName: sshKeyName, - HAEnabled: haEnabled, - Description: description, - NodeRootDiskSize: diskSize, - ExternalLoadbalancerIP: externalLBIP, + Name: name, + Version: version, + NodeSize: nodeSize, + ControlNodes: controlNodes, + CloudProvider: cloudProvider, + Region: region, + Project: project, + BillingCycle: billingCycle, + EnableHA: enableHA, + Networks: []string{}, + Plan: plan, + WithPoolCard: false, + IsCustomPlan: false, + CustomPlan: nil, + VirtualMachine: "", + Coupon: nil, + StorageCategory: storageCategory, + SSHKey: sshKey, + AuthMethod: authMethod, + Username: username, + Password: password, }) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") cmd.Flags().StringVar(&name, "name", "", "Cluster name (required)") - cmd.Flags().StringVar(&versionUUID, "version", "", "Kubernetes version UUID (required)") - cmd.Flags().StringVar(&computeOfferingUUID, "compute-offering", "", "Compute offering UUID (required)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Transit network UUID (required)") - cmd.Flags().IntVar(&workers, "workers", 0, "Number of worker nodes (required, >= 1)") - cmd.Flags().StringVar(&sshKeyName, "ssh-key", "", "SSH key name (required)") + cmd.Flags().StringVar(&version, "version", "", "Kubernetes version, e.g. v1.28.4 (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().BoolVar(&haEnabled, "ha", false, "Enable high availability") - cmd.Flags().StringVar(&description, "description", "", "Cluster description (optional)") - cmd.Flags().Int64Var(&diskSize, "disk-size", 0, "Node root disk size in GB (optional)") - cmd.Flags().StringVar(&externalLBIP, "external-lb-ip", "", "External load balancer IP address (optional)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + 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().StringVar(&storageCategory, "storage-category", "", "Storage category slug (optional)") + cmd.Flags().StringVar(&sshKey, "ssh-key", "", "SSH key name") + cmd.Flags().StringVar(&authMethod, "auth-method", "ssh-key", "Authentication method: ssh-key or password") + cmd.Flags().StringVar(&username, "username", "", "Username for password auth (optional)") + cmd.Flags().StringVar(&password, "password", "", "Password for password auth (optional)") return cmd } func runK8sClusterCreate(cmd *cobra.Command, req kubernetes.CreateRequest) error { - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - req.ZoneUUID = resolveZone(profile, req.ZoneUUID) - if req.ZoneUUID == "" { - return errNoZone() - } svc := kubernetes.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) @@ -220,69 +190,25 @@ func runK8sClusterCreate(cmd *cobra.Command, req kubernetes.CreateRequest) error return fmt.Errorf("kubernetes create: %w", err) } - headers := []string{"UUID", "NAME", "STATE", "WORKERS", "CONTROL NODES", "NETWORK"} + headers := []string{"SLUG", "NAME", "STATE", "VERSION", "WORKERS", "CONTROL NODES", "HA"} rows := [][]string{{ - cluster.UUID, + cluster.Slug, cluster.Name, cluster.State, - strconv.Itoa(cluster.Size), + cluster.Version, + strconv.Itoa(cluster.NodeSize), strconv.Itoa(cluster.ControlNodes), - cluster.TransNetworkUUID, + strconv.FormatBool(cluster.EnableHA), }} return printer.PrintTable(headers, rows) } -func newK8sClusterDeleteCmd() *cobra.Command { - var yes bool - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a Kubernetes cluster", - Args: cobra.ExactArgs(1), - Example: ` zcp kubernetes delete - zcp kubernetes delete --yes`, - RunE: func(cmd *cobra.Command, args []string) error { - return runK8sClusterDelete(cmd, args[0], yes) - }, - } - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - return cmd -} - -func runK8sClusterDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stdout, "Delete Kubernetes cluster %q? [y/N]: ", uuid) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(strings.TrimSpace(answer)) != "y" { - fmt.Fprintln(os.Stdout, "Aborted.") - 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, uuid); err != nil { - return fmt.Errorf("kubernetes delete: %w", err) - } - - fmt.Fprintf(os.Stdout, "Kubernetes cluster %q deleted.\n", uuid) - return nil -} - func newK8sClusterStartCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "start ", + Use: "start ", Short: "Start a stopped Kubernetes cluster", Args: cobra.ExactArgs(1), - Example: ` zcp kubernetes start `, + Example: ` zcp kubernetes start my-cluster`, RunE: func(cmd *cobra.Command, args []string) error { return runK8sClusterStart(cmd, args[0]) }, @@ -290,8 +216,8 @@ func newK8sClusterStartCmd() *cobra.Command { return cmd } -func runK8sClusterStart(cmd *cobra.Command, uuid string) error { - _, client, printer, err := buildClientAndPrinter(cmd) +func runK8sClusterStart(cmd *cobra.Command, slug string) error { + _, client, _, err := buildClientAndPrinter(cmd) if err != nil { return err } @@ -300,25 +226,23 @@ func runK8sClusterStart(cmd *cobra.Command, uuid string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cluster, err := svc.Start(ctx, uuid) - if err != nil { + if err := svc.Start(ctx, slug); err != nil { return fmt.Errorf("kubernetes start: %w", err) } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{cluster.UUID, cluster.Name, cluster.State}} - return printer.PrintTable(headers, rows) + fmt.Fprintf(os.Stdout, "Kubernetes cluster %q start requested.\n", slug) + return nil } func newK8sClusterStopCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "stop ", + Use: "stop ", Short: "Stop a running Kubernetes cluster", Args: cobra.ExactArgs(1), - Example: ` zcp kubernetes stop - zcp kubernetes stop --yes`, + Example: ` zcp kubernetes stop my-cluster + zcp kubernetes stop my-cluster --yes`, RunE: func(cmd *cobra.Command, args []string) error { return runK8sClusterStop(cmd, args[0], yes) }, @@ -327,9 +251,9 @@ func newK8sClusterStopCmd() *cobra.Command { return cmd } -func runK8sClusterStop(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stdout, "Stop cluster %q? [y/N]: ", uuid) +func runK8sClusterStop(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "Stop cluster %q? [y/N]: ", slug) var answer string fmt.Scanln(&answer) if strings.ToLower(strings.TrimSpace(answer)) != "y" { @@ -338,7 +262,7 @@ func runK8sClusterStop(cmd *cobra.Command, uuid string, yes bool) error { } } - _, client, printer, err := buildClientAndPrinter(cmd) + _, client, _, err := buildClientAndPrinter(cmd) if err != nil { return err } @@ -347,42 +271,40 @@ func runK8sClusterStop(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cluster, err := svc.Stop(ctx, uuid) - if err != nil { + if err := svc.Stop(ctx, slug); err != nil { return fmt.Errorf("kubernetes stop: %w", err) } - headers := []string{"UUID", "NAME", "STATE"} - rows := [][]string{{cluster.UUID, cluster.Name, cluster.State}} - return printer.PrintTable(headers, rows) + fmt.Fprintf(os.Stdout, "Kubernetes cluster %q stop requested.\n", slug) + return nil } -func newK8sClusterScaleCmd() *cobra.Command { +func newK8sClusterUpgradeCmd() *cobra.Command { var ( - workers int - autoscaling bool + plan string + billingCycle string ) cmd := &cobra.Command{ - Use: "scale ", - Short: "Scale the worker node count of a Kubernetes cluster", + Use: "upgrade ", + Short: "Upgrade (change plan of) a Kubernetes cluster", Args: cobra.ExactArgs(1), - Example: ` zcp kubernetes scale --workers 5 - zcp kubernetes scale --workers 5 --autoscaling`, + Example: ` zcp kubernetes upgrade my-cluster --plan k8s-plan-2 + zcp kubernetes upgrade my-cluster --plan k8s-plan-2 --billing-cycle hourly`, RunE: func(cmd *cobra.Command, args []string) error { - if !cmd.Flags().Changed("workers") { - return fmt.Errorf("--workers is required") + if plan == "" { + return fmt.Errorf("--plan is required") } - return runK8sClusterScale(cmd, args[0], workers, autoscaling) + return runK8sClusterUpgrade(cmd, args[0], plan, billingCycle) }, } - cmd.Flags().IntVar(&workers, "workers", 0, "New worker node count (required)") - cmd.Flags().BoolVar(&autoscaling, "autoscaling", false, "Enable autoscaling") + cmd.Flags().StringVar(&plan, "plan", "", "New plan slug (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly, monthly (optional)") return cmd } -func runK8sClusterScale(cmd *cobra.Command, uuid string, workers int, autoscaling bool) error { - _, client, printer, err := buildClientAndPrinter(cmd) +func runK8sClusterUpgrade(cmd *cobra.Command, slug, plan, billingCycle string) error { + _, client, _, err := buildClientAndPrinter(cmd) if err != nil { return err } @@ -391,56 +313,17 @@ func runK8sClusterScale(cmd *cobra.Command, uuid string, workers int, autoscalin ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cluster, err := svc.Scale(ctx, uuid, workers, autoscaling) - if err != nil { - return fmt.Errorf("kubernetes scale: %w", err) + req := kubernetes.UpgradeRequest{ + Plan: plan, + Slug: slug, + BillingCycle: billingCycle, + IsCustomPlan: false, + CustomPlan: nil, } - - headers := []string{"UUID", "NAME", "STATE", "WORKERS"} - rows := [][]string{{cluster.UUID, cluster.Name, cluster.State, strconv.Itoa(cluster.Size)}} - return printer.PrintTable(headers, rows) -} - -func newK8sNodeListCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "nodes ", - Short: "List nodes in a Kubernetes cluster", - Args: cobra.ExactArgs(1), - Example: ` zcp kubernetes nodes - zcp k8s nodes --output json`, - RunE: func(cmd *cobra.Command, args []string) error { - return runK8sNodeList(cmd, args[0]) - }, - } - return cmd -} - -func runK8sNodeList(cmd *cobra.Command, clusterUUID string) error { - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err + if err := svc.Upgrade(ctx, slug, req); err != nil { + return fmt.Errorf("kubernetes upgrade: %w", err) } - svc := kubernetes.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - nodes, err := svc.ListNodes(ctx, clusterUUID) - if err != nil { - return fmt.Errorf("kubernetes nodes: %w", err) - } - - headers := []string{"UUID", "NAME", "STATE", "MEMORY", "PRIVATE IP", "ZONE"} - rows := make([][]string, 0, len(nodes)) - for _, n := range nodes { - rows = append(rows, []string{ - n.UUID, - n.Name, - n.State, - n.Memory, - n.PrivateIP, - n.ZoneUUID, - }) - } - return printer.PrintTable(headers, rows) + fmt.Fprintf(os.Stdout, "Kubernetes cluster %q upgrade to plan %q requested.\n", slug, plan) + return nil } diff --git a/internal/commands/loadbalancer.go b/internal/commands/loadbalancer.go index f36a184..0072e3d 100644 --- a/internal/commands/loadbalancer.go +++ b/internal/commands/loadbalancer.go @@ -22,110 +22,140 @@ var validLBAlgorithms = map[string]bool{ func NewLoadBalancerCmd() *cobra.Command { cmd := &cobra.Command{ Use: "loadbalancer", - Short: "Manage load balancer rules", + Short: "Manage load balancers", } cmd.AddCommand(newLBListCmd()) cmd.AddCommand(newLBCreateCmd()) - cmd.AddCommand(newLBUpdateCmd()) - cmd.AddCommand(newLBDeleteCmd()) + cmd.AddCommand(newLBCreateRuleCmd()) + cmd.AddCommand(newLBAttachVMCmd()) return cmd } func newLBListCmd() *cobra.Command { - var zoneUUID, ipUUID string - cmd := &cobra.Command{ Use: "list", - Short: "List load balancer rules in a zone", - Example: ` zcp loadbalancer list --zone - zcp loadbalancer list --zone --ip `, + Short: "List load balancers", + Example: ` zcp loadbalancer list + zcp loadbalancer list -o json`, RunE: func(cmd *cobra.Command, args []string) error { - return runLBList(cmd, zoneUUID, ipUUID) + return runLBList(cmd) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&ipUUID, "ip", "", "Filter by IP address UUID") return cmd } -func runLBList(cmd *cobra.Command, zoneUUID, ipUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runLBList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := loadbalancer.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rules, err := svc.List(ctx, zoneUUID, "", ipUUID) + lbs, err := svc.List(ctx) if err != nil { return fmt.Errorf("loadbalancer list: %w", err) } - headers := []string{"UUID", "NAME", "ALGORITHM", "PUBLIC PORT", "PRIVATE PORT", "IP", "STATUS"} - rows := make([][]string, 0, len(rules)) - for _, r := range rules { + headers := []string{"SLUG", "NAME", "STATE", "IP", "REGION", "PROJECT", "CREATED"} + rows := make([][]string, 0, len(lbs)) + for _, lb := range lbs { + ip := "" + if lb.IPAddress != nil { + ip = lb.IPAddress.IPAddress + } + region := "" + if lb.Region != nil { + region = lb.Region.Name + } + project := "" + if lb.Project != nil { + project = lb.Project.Name + } rows = append(rows, []string{ - r.UUID, - r.Name, - r.Algorithm, - r.PublicPort, - r.PrivatePort, - r.IPAddressUUID, - r.Status, + lb.Slug, + lb.Name, + lb.State, + ip, + region, + project, + lb.CreatedAt, }) } return printer.PrintTable(headers, rows) } func newLBCreateCmd() *cobra.Command { - var ipUUID, name, publicPort, privatePort, algorithm, networkUUID string + var ( + name string + cloudProvider string + project string + region string + network string + plan string + billingCycle string + acquireNewIP bool + ipAddress string + ) cmd := &cobra.Command{ Use: "create", - Short: "Create a new load balancer rule", - Example: ` zcp loadbalancer create --ip --name my-lb --public-port 80 --private-port 8080 --algorithm roundrobin - zcp loadbalancer create --ip --name my-lb --public-port 443 --private-port 8443 --algorithm leastconn --network `, + Short: "Create a new load balancer", + Example: ` zcp loadbalancer create --name my-lb --cloud-provider nimbo --project default-33 --region ixg-belagavi --network d-net-test --plan load-balancer --billing-cycle hourly --acquire-new-ip + zcp loadbalancer create --name my-lb --cloud-provider nimbo --project default-33 --region ixg-belagavi --network d-net-test --plan load-balancer --billing-cycle monthly --ip existing-ip-slug`, RunE: func(cmd *cobra.Command, args []string) error { - if ipUUID == "" { - return fmt.Errorf("--ip is required") - } if name == "" { return fmt.Errorf("--name is required") } - if publicPort == "" { - return fmt.Errorf("--public-port is required") + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") } - if privatePort == "" { - return fmt.Errorf("--private-port is required") + if project == "" { + return fmt.Errorf("--project is required") } - if algorithm == "" { - return fmt.Errorf("--algorithm is required") + if region == "" { + return fmt.Errorf("--region is required") } - if !validLBAlgorithms[algorithm] { - return fmt.Errorf("--algorithm must be one of: roundrobin, leastconn, source") + if network == "" { + return fmt.Errorf("--network is required") } - return runLBCreate(cmd, loadbalancer.CreateRequest{ - Name: name, - PublicIPUUID: ipUUID, - PublicPort: publicPort, - PrivatePort: privatePort, - Algorithm: algorithm, - NetworkUUID: networkUUID, - }) + if plan == "" { + plan = "load-balancer" + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + + req := loadbalancer.CreateRequest{ + Name: name, + CloudProvider: cloudProvider, + Project: project, + Region: region, + Network: network, + Plan: plan, + BillingCycle: billingCycle, + AcquireNewIP: acquireNewIP, + Rules: []loadbalancer.CreateRuleSpec{}, + } + if ipAddress != "" { + req.IPAddress = &ipAddress + req.AcquireNewIP = false + } + + return runLBCreate(cmd, req) }, } - cmd.Flags().StringVar(&ipUUID, "ip", "", "Public IP address UUID (required)") - cmd.Flags().StringVar(&name, "name", "", "Load balancer rule name (required)") - cmd.Flags().StringVar(&publicPort, "public-port", "", "Public port (required)") - cmd.Flags().StringVar(&privatePort, "private-port", "", "Private port (required)") - cmd.Flags().StringVar(&algorithm, "algorithm", "", "Algorithm: roundrobin, leastconn, or source (required)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID") + cmd.Flags().StringVar(&name, "name", "", "Load balancer name (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&network, "network", "", "Network slug (required)") + cmd.Flags().StringVar(&plan, "plan", "load-balancer", "Plan slug") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle: hourly, monthly, quarterly, yearly (required)") + cmd.Flags().BoolVar(&acquireNewIP, "acquire-new-ip", true, "Acquire a new public IP for the load balancer") + cmd.Flags().StringVar(&ipAddress, "ip", "", "Existing IP address slug (overrides --acquire-new-ip)") return cmd } @@ -139,55 +169,98 @@ func runLBCreate(cmd *cobra.Command, req loadbalancer.CreateRequest) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - r, err := svc.Create(ctx, req) + lb, err := svc.Create(ctx, req) if err != nil { return fmt.Errorf("loadbalancer create: %w", err) } headers := []string{"FIELD", "VALUE"} + ip := "" + if lb.IPAddress != nil { + ip = lb.IPAddress.IPAddress + } rows := [][]string{ - {"UUID", r.UUID}, - {"Name", r.Name}, - {"Algorithm", r.Algorithm}, - {"Public Port", r.PublicPort}, - {"Private Port", r.PrivatePort}, - {"IP UUID", r.IPAddressUUID}, - {"Status", r.Status}, + {"Slug", lb.Slug}, + {"Name", lb.Name}, + {"State", lb.State}, + {"IP", ip}, + {"Created", lb.CreatedAt}, } return printer.PrintTable(headers, rows) } -func newLBUpdateCmd() *cobra.Command { - var name, algorithm string +func newLBCreateRuleCmd() *cobra.Command { + var ( + name string + publicPort string + privatePort string + protocol string + algorithm string + stickyMethod string + enableTLS bool + enableProxyProtocol bool + vmSlugs []string + ) cmd := &cobra.Command{ - Use: "update ", - Short: "Update a load balancer rule", - Args: cobra.ExactArgs(1), - Example: ` zcp loadbalancer update --name new-name --algorithm leastconn`, + Use: "create-rule ", + Short: "Create a rule on an existing load balancer", + Args: cobra.ExactArgs(1), + Example: ` zcp loadbalancer create-rule my-lb --name web-rule --public-port 80 --private-port 8080 --protocol tcp --algorithm roundrobin + zcp loadbalancer create-rule my-lb --name ssl-rule --public-port 443 --private-port 8443 --protocol tcp --algorithm leastconn --vm vm-slug-1 --vm vm-slug-2`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } + if publicPort == "" { + return fmt.Errorf("--public-port is required") + } + if privatePort == "" { + return fmt.Errorf("--private-port is required") + } if algorithm == "" { return fmt.Errorf("--algorithm is required") } if !validLBAlgorithms[algorithm] { return fmt.Errorf("--algorithm must be one of: roundrobin, leastconn, source") } - return runLBUpdate(cmd, loadbalancer.UpdateRequest{ - UUID: args[0], - Name: name, - Algorithm: algorithm, - }) + if protocol == "" { + protocol = "tcp" + } + + vms := make([]loadbalancer.VMAttachment, 0, len(vmSlugs)) + for _, slug := range vmSlugs { + vms = append(vms, loadbalancer.VMAttachment{Slug: slug}) + } + + rule := loadbalancer.CreateRuleSpec{ + Name: name, + PublicPort: publicPort, + PrivatePort: privatePort, + Protocol: protocol, + Algorithm: algorithm, + StickyMethod: stickyMethod, + EnableTLSProtocol: enableTLS, + EnableProxyProtocol: enableProxyProtocol, + VirtualMachines: vms, + } + + return runLBCreateRule(cmd, args[0], loadbalancer.CreateRuleRequest{Rules: []loadbalancer.CreateRuleSpec{rule}}) }, } - cmd.Flags().StringVar(&name, "name", "", "New load balancer rule name (required)") + cmd.Flags().StringVar(&name, "name", "", "Rule name (required)") + cmd.Flags().StringVar(&publicPort, "public-port", "", "Public port (required)") + cmd.Flags().StringVar(&privatePort, "private-port", "", "Private port (required)") + cmd.Flags().StringVar(&protocol, "protocol", "tcp", "Protocol (e.g. tcp, udp)") cmd.Flags().StringVar(&algorithm, "algorithm", "", "Algorithm: roundrobin, leastconn, or source (required)") + cmd.Flags().StringVar(&stickyMethod, "sticky-method", "", "Sticky session method (e.g. AppCookie)") + cmd.Flags().BoolVar(&enableTLS, "enable-tls", false, "Enable TLS protocol") + cmd.Flags().BoolVar(&enableProxyProtocol, "enable-proxy-protocol", false, "Enable proxy protocol") + cmd.Flags().StringArrayVar(&vmSlugs, "vm", nil, "VM slug to attach (can be repeated)") return cmd } -func runLBUpdate(cmd *cobra.Command, req loadbalancer.UpdateRequest) error { +func runLBCreateRule(cmd *cobra.Command, lbSlug string, req loadbalancer.CreateRuleRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -197,50 +270,73 @@ func runLBUpdate(cmd *cobra.Command, req loadbalancer.UpdateRequest) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - r, err := svc.Update(ctx, req) - if err != nil { - return fmt.Errorf("loadbalancer update: %w", err) + if err := svc.CreateRule(ctx, lbSlug, req); err != nil { + return fmt.Errorf("loadbalancer create-rule: %w", err) } - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", r.UUID}, - {"Name", r.Name}, - {"Algorithm", r.Algorithm}, - {"Status", r.Status}, - } - return printer.PrintTable(headers, rows) + printer.Fprintf("Rule created on load balancer %q.\n", lbSlug) + return nil } -func newLBDeleteCmd() *cobra.Command { - var yes bool +func newLBAttachVMCmd() *cobra.Command { + var ( + cloudProvider string + region string + project string + vmSlugs []string + ) cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a load balancer rule", - Args: cobra.ExactArgs(1), - Example: ` zcp loadbalancer delete - zcp loadbalancer delete --yes`, + Use: "attach-vm ", + Short: "Attach VMs to a load balancer rule", + Args: cobra.ExactArgs(2), + Example: ` zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --vm vm-slug-2 --cloud-provider nimbo --region ixg-belagavi --project default-33 + zcp loadbalancer attach-vm my-lb rule-123 --vm vm-slug-1 --cloud-provider nimbo --region ixg-belagavi --project default-33 --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runLBDelete(cmd, args[0], yes) + if len(vmSlugs) == 0 { + return fmt.Errorf("at least one --vm is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + + yes, _ := cmd.Flags().GetBool("yes") + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Attach %d VM(s) to rule %q on load balancer %q? [y/N]: ", len(vmSlugs), args[1], args[0]) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + req := loadbalancer.AttachVMRequest{ + VirtualMachines: vmSlugs, + CloudProvider: cloudProvider, + Region: region, + Project: project, + } + + return runLBAttachVM(cmd, args[0], args[1], req) }, } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().StringArrayVar(&vmSlugs, "vm", nil, "VM slug to attach (can be repeated, required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().Bool("yes", false, "Skip confirmation prompt") return cmd } -func runLBDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete load balancer rule %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } - } - +func runLBAttachVM(cmd *cobra.Command, lbSlug, ruleID string, req loadbalancer.AttachVMRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -250,10 +346,10 @@ func runLBDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("loadbalancer delete: %w", err) + if err := svc.AttachVM(ctx, lbSlug, ruleID, req); err != nil { + return fmt.Errorf("loadbalancer attach-vm: %w", err) } - printer.Fprintf("Load balancer rule %q deleted.\n", uuid) + printer.Fprintf("Attached %d VM(s) to rule %q on load balancer %q.\n", len(req.VirtualMachines), ruleID, lbSlug) return nil } diff --git a/internal/commands/marketplace.go b/internal/commands/marketplace.go new file mode 100644 index 0000000..ccab6af --- /dev/null +++ b/internal/commands/marketplace.go @@ -0,0 +1,83 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/marketplace" +) + +// NewMarketplaceCmd returns the 'marketplace' cobra command. +func NewMarketplaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "marketplace", + Aliases: []string{"apps"}, + Short: "Browse marketplace applications", + } + cmd.AddCommand(newMarketplaceListCmd()) + return cmd +} + +func newMarketplaceListCmd() *cobra.Command { + var ( + region string + include string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List marketplace applications", + Example: ` zcp marketplace list + zcp marketplace list --region zone-my-bangsarsouth + zcp marketplace list --include versions + zcp marketplace list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMarketplaceList(cmd, region, include) + }, + } + cmd.Flags().StringVar(®ion, "region", "", "Filter by region slug") + cmd.Flags().StringVar(&include, "include", "", "Include related data (e.g. versions)") + return cmd +} + +func runMarketplaceList(cmd *cobra.Command, region, include string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := marketplace.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + apps, err := svc.ListApps(ctx, region, include) + if err != nil { + return fmt.Errorf("marketplace list: %w", err) + } + + if len(apps) == 0 { + printer.Fprintf("No marketplace apps found\n") + return nil + } + + headers := []string{"NAME", "SLUG", "CATEGORY", "FEATURED", "DESCRIPTION", "URL"} + rows := make([][]string, 0, len(apps)) + for _, app := range apps { + desc := app.ShortDescription + if len(desc) > 60 { + desc = desc[:57] + "..." + } + rows = append(rows, []string{ + app.Name, + app.Slug, + app.Category, + strconv.FormatBool(app.IsFeatured), + desc, + app.URL, + }) + } + return printer.PrintTable(headers, rows) +} diff --git a/internal/commands/monitoring.go b/internal/commands/monitoring.go new file mode 100644 index 0000000..7d960f0 --- /dev/null +++ b/internal/commands/monitoring.go @@ -0,0 +1,307 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/monitoring" +) + +// NewMonitoringCmd returns the 'monitoring' cobra command. +func NewMonitoringCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "monitoring", + Short: "View resource monitoring and VM metrics", + } + cmd.AddCommand(newMonitoringGlobalCmd()) + cmd.AddCommand(newMonitoringCPUCmd()) + cmd.AddCommand(newMonitoringMemoryCmd()) + cmd.AddCommand(newMonitoringDiskCmd()) + cmd.AddCommand(newMonitoringDiskIOCmd()) + cmd.AddCommand(newMonitoringNetworkCmd()) + cmd.AddCommand(newMonitoringChartsCmd()) + return cmd +} + +func newMonitoringGlobalCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "global", + Short: "Show global resource monitoring overview", + Example: ` zcp monitoring global`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringGlobal(cmd) + }, + } + return cmd +} + +func runMonitoringGlobal(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + resources, err := svc.Global(ctx) + if err != nil { + return fmt.Errorf("monitoring global: %w", err) + } + + headers := []string{"NAME", "TOTAL", "USED", "FREE", "UNIT", "USAGE %"} + rows := make([][]string, 0, len(resources)) + for _, r := range resources { + rows = append(rows, []string{ + r.Name, + fmt.Sprintf("%.1f", r.Total), + fmt.Sprintf("%.1f", r.Used), + fmt.Sprintf("%.1f", r.Free), + r.Unit, + fmt.Sprintf("%.1f%%", r.Percentage), + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringCPUCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cpu ", + Short: "Show CPU usage metrics for a VM", + Args: cobra.ExactArgs(1), + Example: ` zcp monitoring cpu my-vm-slug + zcp monitoring cpu my-vm-slug --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringCPU(cmd, args[0]) + }, + } + return cmd +} + +func runMonitoringCPU(cmd *cobra.Command, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + points, err := svc.CPUUsage(ctx, vmSlug) + if err != nil { + return fmt.Errorf("monitoring cpu: %w", err) + } + + headers := []string{"TIMESTAMP", "VALUE", "UNIT"} + rows := make([][]string, 0, len(points)) + for _, p := range points { + rows = append(rows, []string{ + p.Timestamp, + fmt.Sprintf("%.2f", p.Value), + p.Unit, + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringMemoryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "memory ", + Short: "Show memory usage metrics for a VM", + Args: cobra.ExactArgs(1), + Example: ` zcp monitoring memory my-vm-slug + zcp monitoring memory my-vm-slug --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringMemory(cmd, args[0]) + }, + } + return cmd +} + +func runMonitoringMemory(cmd *cobra.Command, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + points, err := svc.MemoryUsage(ctx, vmSlug) + if err != nil { + return fmt.Errorf("monitoring memory: %w", err) + } + + headers := []string{"TIMESTAMP", "VALUE", "UNIT"} + rows := make([][]string, 0, len(points)) + for _, p := range points { + rows = append(rows, []string{ + p.Timestamp, + fmt.Sprintf("%.2f", p.Value), + p.Unit, + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringDiskCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disk ", + Short: "Show disk read/write metrics for a VM", + Args: cobra.ExactArgs(1), + Example: ` zcp monitoring disk my-vm-slug + zcp monitoring disk my-vm-slug --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringDisk(cmd, args[0]) + }, + } + return cmd +} + +func runMonitoringDisk(cmd *cobra.Command, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + points, err := svc.DiskReadWrite(ctx, vmSlug) + if err != nil { + return fmt.Errorf("monitoring disk: %w", err) + } + + headers := []string{"TIMESTAMP", "READ", "WRITE", "UNIT"} + rows := make([][]string, 0, len(points)) + for _, p := range points { + rows = append(rows, []string{ + p.Timestamp, + fmt.Sprintf("%.2f", p.Read), + fmt.Sprintf("%.2f", p.Write), + p.Unit, + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringDiskIOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disk-io ", + Short: "Show disk IO read/write metrics for a VM", + Args: cobra.ExactArgs(1), + Example: ` zcp monitoring disk-io my-vm-slug + zcp monitoring disk-io my-vm-slug --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringDiskIO(cmd, args[0]) + }, + } + return cmd +} + +func runMonitoringDiskIO(cmd *cobra.Command, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + points, err := svc.DiskIOReadWrite(ctx, vmSlug) + if err != nil { + return fmt.Errorf("monitoring disk-io: %w", err) + } + + headers := []string{"TIMESTAMP", "READ", "WRITE", "UNIT"} + rows := make([][]string, 0, len(points)) + for _, p := range points { + rows = append(rows, []string{ + p.Timestamp, + fmt.Sprintf("%.2f", p.Read), + fmt.Sprintf("%.2f", p.Write), + p.Unit, + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringNetworkCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "network ", + Short: "Show network traffic metrics for a VM", + Args: cobra.ExactArgs(1), + Example: ` zcp monitoring network my-vm-slug + zcp monitoring network my-vm-slug --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringNetwork(cmd, args[0]) + }, + } + return cmd +} + +func runMonitoringNetwork(cmd *cobra.Command, vmSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + points, err := svc.NetworkTraffic(ctx, vmSlug) + if err != nil { + return fmt.Errorf("monitoring network: %w", err) + } + + headers := []string{"TIMESTAMP", "INCOMING", "OUTGOING", "UNIT"} + rows := make([][]string, 0, len(points)) + for _, p := range points { + rows = append(rows, []string{ + p.Timestamp, + fmt.Sprintf("%.2f", p.Incoming), + fmt.Sprintf("%.2f", p.Outgoing), + p.Unit, + }) + } + return printer.PrintTable(headers, rows) +} + +func newMonitoringChartsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "charts", + Short: "Show monitoring charts data", + Example: ` zcp monitoring charts`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitoringCharts(cmd) + }, + } + return cmd +} + +func runMonitoringCharts(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := monitoring.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + result, err := svc.Charts(ctx) + if err != nil { + return fmt.Errorf("monitoring charts: %w", err) + } + + // Response schema is undefined — always output as raw JSON + return printer.Print(result) +} diff --git a/internal/commands/network.go b/internal/commands/network.go index 8a5238b..c7ceb5b 100644 --- a/internal/commands/network.go +++ b/internal/commands/network.go @@ -1,11 +1,8 @@ package commands import ( - "bufio" "context" "fmt" - "os" - "strings" "time" "github.com/spf13/cobra" @@ -16,167 +13,98 @@ import ( func NewNetworkCmd() *cobra.Command { cmd := &cobra.Command{ Use: "network", - Short: "Manage virtual networks", + Short: "Manage isolated networks", } cmd.AddCommand(newNetworkListCmd()) - cmd.AddCommand(newNetworkGetCmd()) cmd.AddCommand(newNetworkCreateCmd()) - cmd.AddCommand(newNetworkDeleteCmd()) - cmd.AddCommand(newNetworkRestartCmd()) + cmd.AddCommand(newNetworkUpdateCmd()) + cmd.AddCommand(newNetworkCategoriesCmd()) return cmd } func newNetworkListCmd() *cobra.Command { - var zoneUUID string - cmd := &cobra.Command{ Use: "list", - Short: "List networks in a zone", - Example: ` zcp network list --zone - zcp network list --zone --output json`, + Short: "List isolated networks", + Example: ` zcp network list + zcp network list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runNetworkList(cmd, zoneUUID) + return runNetworkList(cmd) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") return cmd } -func runNetworkList(cmd *cobra.Command, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runNetworkList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := network.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - nets, err := svc.List(ctx, zoneUUID, "") + nets, err := svc.List(ctx) if err != nil { return fmt.Errorf("network list: %w", err) } - headers := []string{"UUID", "NAME", "TYPE", "CIDR", "GATEWAY", "STATUS"} + headers := []string{"SLUG", "NAME", "TYPE", "CIDR", "GATEWAY", "STATUS", "ZONE"} rows := make([][]string, 0, len(nets)) for _, n := range nets { rows = append(rows, []string{ - n.UUID, + n.Slug, n.Name, n.NetworkType, n.CIDR, n.Gateway, n.Status, + n.ZoneSlug, }) } return printer.PrintTable(headers, rows) } -func newNetworkGetCmd() *cobra.Command { - var zoneUUID string - - cmd := &cobra.Command{ - Use: "get ", - Short: "Get details of a network", - Args: cobra.ExactArgs(1), - Example: ` zcp network get --zone `, - RunE: func(cmd *cobra.Command, args []string) error { - return runNetworkGet(cmd, args[0], zoneUUID) - }, - } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - return cmd -} - -func runNetworkGet(cmd *cobra.Command, uuid, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } - - svc := network.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - n, err := svc.Get(ctx, zoneUUID, uuid) - if err != nil { - return fmt.Errorf("network get: %w", err) - } - - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", n.UUID}, - {"Name", n.Name}, - {"Type", n.NetworkType}, - {"CIDR", n.CIDR}, - {"Gateway", n.Gateway}, - {"Status", n.Status}, - {"Zone UUID", n.ZoneUUID}, - {"Domain Name", n.DomainName}, - {"Network Domain", n.NetworkDomain}, - {"Offering UUID", n.NetworkOfferingUUID}, - } - return printer.PrintTable(headers, rows) -} - func newNetworkCreateCmd() *cobra.Command { - var zoneUUID, name, offeringUUID, vmUUID, vpcUUID, gateway, netmask, aclUUID string - var isPublic bool + var name, categorySlug, zoneSlug, gateway, netmask, description string cmd := &cobra.Command{ Use: "create", - Short: "Create a new network", - Example: ` zcp network create --zone --name my-net --offering - zcp network create --zone --name my-tier --offering --vpc --gateway 10.1.1.1 --netmask 255.255.255.0`, + Short: "Create a new isolated network", + Example: ` zcp network create --name my-net --category + zcp network create --name my-net --category --gateway 10.1.1.1 --netmask 255.255.255.0`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - if offeringUUID == "" { - return fmt.Errorf("--offering is required") + if categorySlug == "" { + return fmt.Errorf("--category is required") } return runNetworkCreate(cmd, network.CreateRequest{ - Name: name, - ZoneUUID: zoneUUID, - NetworkOfferingUUID: offeringUUID, - VirtualMachineUUID: vmUUID, - IsPublic: isPublic, - VPCUUID: vpcUUID, - Gateway: gateway, - Netmask: netmask, - ACLUUID: aclUUID, + Name: name, + CategorySlug: categorySlug, + ZoneSlug: zoneSlug, + Gateway: gateway, + Netmask: netmask, + Description: description, }) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") cmd.Flags().StringVar(&name, "name", "", "Network name (required)") - cmd.Flags().StringVar(&offeringUUID, "offering", "", "Network offering UUID (required)") - cmd.Flags().StringVar(&vmUUID, "instance", "", "Virtual machine UUID to attach on creation") - cmd.Flags().BoolVar(&isPublic, "public", false, "Mark network as public") - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "VPC UUID (for creating a VPC tier network)") - cmd.Flags().StringVar(&gateway, "gateway", "", "Gateway IP (required for VPC tiers)") - cmd.Flags().StringVar(&netmask, "netmask", "", "Netmask (required for VPC tiers, e.g. 255.255.255.0)") - cmd.Flags().StringVar(&aclUUID, "acl", "", "Network ACL UUID (for VPC tiers)") + cmd.Flags().StringVar(&categorySlug, "category", "", "Network category slug (required)") + cmd.Flags().StringVar(&zoneSlug, "zone", "", "Zone slug") + cmd.Flags().StringVar(&gateway, "gateway", "", "Gateway IP") + cmd.Flags().StringVar(&netmask, "netmask", "", "Netmask (e.g. 255.255.255.0)") + cmd.Flags().StringVar(&description, "description", "", "Network description") return cmd } func runNetworkCreate(cmd *cobra.Command, req network.CreateRequest) error { - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - req.ZoneUUID = resolveZone(profile, req.ZoneUUID) - if req.ZoneUUID == "" { - return errNoZone() - } svc := network.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) @@ -189,46 +117,42 @@ func runNetworkCreate(cmd *cobra.Command, req network.CreateRequest) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", n.UUID}, + {"Slug", n.Slug}, {"Name", n.Name}, {"Type", n.NetworkType}, {"CIDR", n.CIDR}, {"Gateway", n.Gateway}, {"Status", n.Status}, - {"Zone UUID", n.ZoneUUID}, + {"Zone", n.ZoneSlug}, } return printer.PrintTable(headers, rows) } -func newNetworkDeleteCmd() *cobra.Command { - var yes bool +func newNetworkUpdateCmd() *cobra.Command { + var name, description string cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a network", + Use: "update ", + Short: "Update a network", Args: cobra.ExactArgs(1), - Example: ` zcp network delete - zcp network delete --yes`, + Example: ` zcp network update --name new-name + zcp network update --description "Updated description"`, RunE: func(cmd *cobra.Command, args []string) error { - return runNetworkDelete(cmd, args[0], yes) + if name == "" && description == "" { + return fmt.Errorf("at least one of --name or --description is required") + } + return runNetworkUpdate(cmd, args[0], network.UpdateRequest{ + Name: name, + Description: description, + }) }, } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&name, "name", "", "New network name") + cmd.Flags().StringVar(&description, "description", "", "New description") return cmd } -func runNetworkDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete network %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } - } - +func runNetworkUpdate(cmd *cobra.Command, slug string, req network.UpdateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -238,43 +162,37 @@ func runNetworkDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("network delete: %w", err) + n, err := svc.Update(ctx, slug, req) + if err != nil { + return fmt.Errorf("network update: %w", err) } - // Verify deletion — Kong may return 204 even when delete silently fails - time.Sleep(2 * time.Second) - profile, _, _, _ := buildClientAndPrinter(cmd) - zoneUUID := resolveZone(profile, "") - if zoneUUID != "" { - if _, err := svc.Get(ctx, zoneUUID, uuid); err == nil { - fmt.Fprintln(os.Stderr, "WARNING: network may not have been deleted (e.g. has active VMs).") - return fmt.Errorf("network %q still exists after delete — check dependencies", uuid) - } + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", n.Slug}, + {"Name", n.Name}, + {"Type", n.NetworkType}, + {"CIDR", n.CIDR}, + {"Gateway", n.Gateway}, + {"Status", n.Status}, } - - printer.Fprintf("Network %q deleted.\n", uuid) - return nil + return printer.PrintTable(headers, rows) } -func newNetworkRestartCmd() *cobra.Command { - var cleanUp bool - +func newNetworkCategoriesCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "restart ", - Short: "Restart a network", - Args: cobra.ExactArgs(1), - Example: ` zcp network restart - zcp network restart --cleanup`, + Use: "categories", + Short: "List network categories", + Example: ` zcp network categories + zcp network categories --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runNetworkRestart(cmd, args[0], cleanUp) + return runNetworkCategories(cmd) }, } - cmd.Flags().BoolVar(&cleanUp, "cleanup", false, "Clean up stale resources during restart") return cmd } -func runNetworkRestart(cmd *cobra.Command, uuid string, cleanUp bool) error { +func runNetworkCategories(cmd *cobra.Command) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -284,16 +202,19 @@ func runNetworkRestart(cmd *cobra.Command, uuid string, cleanUp bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - n, err := svc.Restart(ctx, uuid, cleanUp) + cats, err := svc.ListCategories(ctx) if err != nil { - return fmt.Errorf("network restart: %w", err) + return fmt.Errorf("network categories: %w", err) } - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", n.UUID}, - {"Name", n.Name}, - {"Status", n.Status}, + headers := []string{"SLUG", "NAME", "DESCRIPTION"} + rows := make([][]string, 0, len(cats)) + for _, c := range cats { + rows = append(rows, []string{ + c.Slug, + c.Name, + c.Description, + }) } return printer.PrintTable(headers, rows) } diff --git a/internal/commands/plan.go b/internal/commands/plan.go new file mode 100644 index 0000000..908b9ad --- /dev/null +++ b/internal/commands/plan.go @@ -0,0 +1,457 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/plan" +) + +// NewPlanCmd returns the 'plan' cobra command with subcommands for each +// STKCNSL service type. +func NewPlanCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plan", + Short: "List service plans and pricing", + Long: `List available service plans and pricing from the ZCP catalog. + +Each subcommand queries a specific service type and displays the plans +with their resource attributes and pricing.`, + } + cmd.AddCommand(newPlanVMCmd()) + cmd.AddCommand(newPlanRouterCmd()) + cmd.AddCommand(newPlanStorageCmd()) + cmd.AddCommand(newPlanLBCmd()) + cmd.AddCommand(newPlanK8sCmd()) + cmd.AddCommand(newPlanIPCmd()) + cmd.AddCommand(newPlanVMSnapshotCmd()) + cmd.AddCommand(newPlanTemplateCmd()) + cmd.AddCommand(newPlanISOCmd()) + cmd.AddCommand(newPlanBackupCmd()) + return cmd +} + +// --------------------------------------------------------------------------- +// Virtual Machine +// --------------------------------------------------------------------------- + +func newPlanVMCmd() *cobra.Command { + return &cobra.Command{ + Use: "vm", + Short: "List Virtual Machine plans", + Example: ` zcp plan vm + zcp plan vm --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceVM) + if err != nil { + return fmt.Errorf("plan vm: %w", err) + } + + headers := []string{"ID", "NAME", "CPU", "MEMORY", "STORAGE", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.Attribute.FormattedCPU.String(), + p.Attribute.FormattedMemory, + p.Attribute.FormattedStorage, + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// Virtual Router +// --------------------------------------------------------------------------- + +func newPlanRouterCmd() *cobra.Command { + return &cobra.Command{ + Use: "router", + Short: "List Virtual Router plans", + Example: ` zcp plan router + zcp plan router --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceVirtualRouter) + if err != nil { + return fmt.Errorf("plan router: %w", err) + } + + headers := []string{"ID", "NAME", "CPU", "MEMORY", "NETWORK RATE", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.Attribute.CPU.String(), + p.Attribute.FormattedMemory, + p.Attribute.NetworkRate, + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// Block Storage +// --------------------------------------------------------------------------- + +func newPlanStorageCmd() *cobra.Command { + return &cobra.Command{ + Use: "storage", + Short: "List Block Storage plans", + Example: ` zcp plan storage + zcp plan storage --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceBlockStorage) + if err != nil { + return fmt.Errorf("plan storage: %w", err) + } + + headers := []string{"ID", "NAME", "SIZE", "HOURLY", "MONTHLY", "CUSTOM", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.Attribute.FormattedSize, + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.IsCustom), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// Load Balancer +// --------------------------------------------------------------------------- + +func newPlanLBCmd() *cobra.Command { + return &cobra.Command{ + Use: "lb", + Short: "List Load Balancer plans", + Example: ` zcp plan lb + zcp plan lb --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceLoadBalancer) + if err != nil { + return fmt.Errorf("plan lb: %w", err) + } + + headers := []string{"ID", "NAME", "TAG", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.ParsedTag(), + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// Kubernetes +// --------------------------------------------------------------------------- + +func newPlanK8sCmd() *cobra.Command { + return &cobra.Command{ + Use: "kubernetes", + Short: "List Kubernetes plans", + Aliases: []string{"k8s"}, + Example: ` zcp plan kubernetes + zcp plan k8s --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceKubernetes) + if err != nil { + return fmt.Errorf("plan kubernetes: %w", err) + } + + headers := []string{"ID", "NAME", "CPU", "MEMORY", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.Attribute.FormattedCPU.String(), + p.Attribute.FormattedMemory, + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// IP Address +// --------------------------------------------------------------------------- + +func newPlanIPCmd() *cobra.Command { + return &cobra.Command{ + Use: "ip", + Short: "List IP Address plans", + Example: ` zcp plan ip + zcp plan ip --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceIPAddress) + if err != nil { + return fmt.Errorf("plan ip: %w", err) + } + + headers := []string{"ID", "NAME", "TAG", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.ParsedTag(), + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// VM Snapshot +// --------------------------------------------------------------------------- + +func newPlanVMSnapshotCmd() *cobra.Command { + return &cobra.Command{ + Use: "vm-snapshot", + Short: "List VM Snapshot plans", + Example: ` zcp plan vm-snapshot + zcp plan vm-snapshot --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceVMSnapshot) + if err != nil { + return fmt.Errorf("plan vm-snapshot: %w", err) + } + + headers := []string{"ID", "NAME", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// My Template +// --------------------------------------------------------------------------- + +func newPlanTemplateCmd() *cobra.Command { + return &cobra.Command{ + Use: "template", + Short: "List My Template plans", + Example: ` zcp plan template + zcp plan template --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceMyTemplate) + if err != nil { + return fmt.Errorf("plan template: %w", err) + } + + headers := []string{"ID", "NAME", "TAG", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.ParsedTag(), + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// ISO +// --------------------------------------------------------------------------- + +func newPlanISOCmd() *cobra.Command { + return &cobra.Command{ + Use: "iso", + Short: "List ISO plans", + Example: ` zcp plan iso + zcp plan iso --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceISO) + if err != nil { + return fmt.Errorf("plan iso: %w", err) + } + + headers := []string{"ID", "NAME", "TAG", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.ParsedTag(), + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// --------------------------------------------------------------------------- +// Backups +// --------------------------------------------------------------------------- + +func newPlanBackupCmd() *cobra.Command { + return &cobra.Command{ + Use: "backup", + Short: "List Backup plans", + Example: ` zcp plan backup + zcp plan backup --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := plan.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + plans, err := svc.List(ctx, plan.ServiceBackups) + if err != nil { + return fmt.Errorf("plan backup: %w", err) + } + + headers := []string{"ID", "NAME", "TAG", "HOURLY", "MONTHLY", "ACTIVE"} + rows := make([][]string, 0, len(plans)) + for _, p := range plans { + rows = append(rows, []string{ + p.ID, + p.Name, + p.ParsedTag(), + formatPrice(p.HourlyPrice), + formatPrice(p.MonthlyPrice), + strconv.FormatBool(p.Status), + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +// formatPrice renders a float price as a string with up to 4 decimal places, +// trimming trailing zeros for readability. +func formatPrice(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} diff --git a/internal/commands/portforward.go b/internal/commands/portforward.go index eb000ab..27e8eb1 100644 --- a/internal/commands/portforward.go +++ b/internal/commands/portforward.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "os" - "strconv" "strings" "time" @@ -22,12 +21,11 @@ func validatePortForwardProtocol(protocol string) error { return nil } -func validatePortNumber(port, flagName string) error { - if port == "" { +func validatePortNumber(port int, flagName string) error { + if port == 0 { return nil } - n, err := strconv.Atoi(port) - if err != nil || n < 1 || n > 65535 { + if port < 1 || port > 65535 { return fmt.Errorf("%s must be a number between 1 and 65535", flagName) } return nil @@ -46,70 +44,65 @@ func NewPortForwardCmd() *cobra.Command { } func newPortForwardListCmd() *cobra.Command { - var zoneUUID, ipUUID, instanceUUID string + var ipSlug string cmd := &cobra.Command{ - Use: "list", - Short: "List port forwarding rules", - Example: ` zcp portforward list --zone - zcp portforward list --zone --ip - zcp portforward list --zone --instance `, + Use: "list", + Short: "List port forwarding rules", + Example: ` zcp portforward list --ip `, RunE: func(cmd *cobra.Command, args []string) error { - return runPortForwardList(cmd, zoneUUID, ipUUID, instanceUUID) + if ipSlug == "" { + return fmt.Errorf("--ip is required") + } + return runPortForwardList(cmd, ipSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&ipUUID, "ip", "", "Filter by IP address UUID") - cmd.Flags().StringVar(&instanceUUID, "instance", "", "Filter by VM UUID") + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") return cmd } -func runPortForwardList(cmd *cobra.Command, zoneUUID, ipUUID, instanceUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runPortForwardList(cmd *cobra.Command, ipSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := portforward.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rules, err := svc.List(ctx, zoneUUID, "", instanceUUID, ipUUID) + rules, err := svc.List(ctx, ipSlug) if err != nil { return fmt.Errorf("portforward list: %w", err) } - headers := []string{"UUID", "PROTOCOL", "PUBLIC PORT", "PRIVATE PORT", "INSTANCE", "IP", "STATUS"} + headers := []string{"ID", "PROTOCOL", "PUBLIC PORT", "PRIVATE PORT", "VM", "STATE"} rows := make([][]string, 0, len(rules)) for _, r := range rules { - publicPort := formatPFPorts(r.PublicPort, r.PublicEndPort) - privatePort := formatPFPorts(r.PrivatePort, r.PrivateEndPort) + publicPort := formatPFPortsInt(r.PublicStartPort, r.PublicEndPort) + privatePort := formatPFPortsInt(r.PrivateStartPort, r.PrivateEndPort) rows = append(rows, []string{ - r.UUID, + r.ID, r.Protocol, publicPort, privatePort, - r.VirtualMachineName, - r.IPAddressUUID, - r.Status, + r.VirtualMachine, + r.State, }) } return printer.PrintTable(headers, rows) } func newPortForwardCreateCmd() *cobra.Command { - var ipUUID, protocol, publicPort, publicEndPort, privatePort, privateEndPort, instanceUUID, networkUUID string + var ipSlug, protocol, vmSlug string + var publicStartPort, publicEndPort, privateStartPort, privateEndPort string cmd := &cobra.Command{ Use: "create", Short: "Create a port forwarding rule", - Example: ` zcp portforward create --ip --protocol tcp --public-port 8080 --private-port 80 --instance `, + Example: ` zcp portforward create --ip --protocol tcp --public-port 8080 --private-port 80 --instance `, RunE: func(cmd *cobra.Command, args []string) error { - if ipUUID == "" { + if ipSlug == "" { return fmt.Errorf("--ip is required") } if protocol == "" { @@ -118,49 +111,47 @@ func newPortForwardCreateCmd() *cobra.Command { if err := validatePortForwardProtocol(protocol); err != nil { return err } - if publicPort == "" { + if publicStartPort == "" { return fmt.Errorf("--public-port is required") } - if privatePort == "" { + if privateStartPort == "" { return fmt.Errorf("--private-port is required") } - if instanceUUID == "" { + if vmSlug == "" { return fmt.Errorf("--instance is required") } - for _, check := range []struct{ port, flag string }{ - {publicPort, "--public-port"}, - {publicEndPort, "--public-end-port"}, - {privatePort, "--private-port"}, - {privateEndPort, "--private-end-port"}, + for _, check := range []struct { + port string + flag string + }{ + {publicStartPort, "--public-port"}, + {privateStartPort, "--private-port"}, } { - if err := validatePortNumber(check.port, check.flag); err != nil { - return err + if check.port == "" { + return fmt.Errorf("%s is required", check.flag) } } - return runPortForwardCreate(cmd, portforward.CreateRequest{ - IPAddressUUID: ipUUID, - Protocol: strings.ToUpper(protocol), - PublicPort: publicPort, - PublicEndPort: publicEndPort, - PrivatePort: privatePort, - PrivateEndPort: privateEndPort, - VirtualMachineUUID: instanceUUID, - NetworkUUID: networkUUID, + return runPortForwardCreate(cmd, ipSlug, portforward.CreateRequest{ + Protocol: strings.ToLower(protocol), + PublicStartPort: publicStartPort, + PublicEndPort: publicEndPort, + PrivateStartPort: privateStartPort, + PrivateEndPort: privateEndPort, + VirtualMachine: vmSlug, }) }, } - cmd.Flags().StringVar(&ipUUID, "ip", "", "IP address UUID (required)") + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") cmd.Flags().StringVar(&protocol, "protocol", "", "Protocol: tcp or udp (required)") - cmd.Flags().StringVar(&publicPort, "public-port", "", "Public port number (required)") + cmd.Flags().StringVar(&publicStartPort, "public-port", "", "Public port number (required)") cmd.Flags().StringVar(&publicEndPort, "public-end-port", "", "Public end port for range") - cmd.Flags().StringVar(&privatePort, "private-port", "", "Private port number (required)") + cmd.Flags().StringVar(&privateStartPort, "private-port", "", "Private port number (required)") cmd.Flags().StringVar(&privateEndPort, "private-end-port", "", "Private end port for range") - cmd.Flags().StringVar(&instanceUUID, "instance", "", "VM UUID (required)") - cmd.Flags().StringVar(&networkUUID, "network", "", "Network UUID") + cmd.Flags().StringVar(&vmSlug, "instance", "", "VM slug (required)") return cmd } -func runPortForwardCreate(cmd *cobra.Command, req portforward.CreateRequest) error { +func runPortForwardCreate(cmd *cobra.Command, ipSlug string, req portforward.CreateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -170,44 +161,48 @@ func runPortForwardCreate(cmd *cobra.Command, req portforward.CreateRequest) err ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - rule, err := svc.Create(ctx, req) + rule, err := svc.Create(ctx, ipSlug, req) if err != nil { return fmt.Errorf("portforward create: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", rule.UUID}, + {"ID", rule.ID}, {"Protocol", rule.Protocol}, - {"Public Port", formatPFPorts(rule.PublicPort, rule.PublicEndPort)}, - {"Private Port", formatPFPorts(rule.PrivatePort, rule.PrivateEndPort)}, - {"Instance", rule.VirtualMachineName}, - {"IP Address UUID", rule.IPAddressUUID}, - {"Status", rule.Status}, + {"Public Port", formatPFPortsInt(rule.PublicStartPort, rule.PublicEndPort)}, + {"Private Port", formatPFPortsInt(rule.PrivateStartPort, rule.PrivateEndPort)}, + {"VM", rule.VirtualMachine}, + {"State", rule.State}, } return printer.PrintTable(headers, rows) } func newPortForwardDeleteCmd() *cobra.Command { + var ipSlug string var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a port forwarding rule", Args: cobra.ExactArgs(1), - Example: ` zcp portforward delete - zcp portforward delete --yes`, + Example: ` zcp portforward delete --ip + zcp portforward delete --ip --yes`, RunE: func(cmd *cobra.Command, args []string) error { - return runPortForwardDelete(cmd, args[0], yes) + if ipSlug == "" { + return fmt.Errorf("--ip is required") + } + return runPortForwardDelete(cmd, ipSlug, args[0], yes) }, } + cmd.Flags().StringVar(&ipSlug, "ip", "", "IP address slug (required)") cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") return cmd } -func runPortForwardDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete port forwarding rule %q? [y/N]: ", uuid) +func runPortForwardDelete(cmd *cobra.Command, ipSlug, ruleID string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete port forwarding rule %q on IP %q? [y/N]: ", ruleID, ipSlug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -226,15 +221,16 @@ func runPortForwardDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { + if err := svc.Delete(ctx, ipSlug, ruleID); err != nil { return fmt.Errorf("portforward delete: %w", err) } - printer.Fprintf("Port forwarding rule %q deleted.\n", uuid) + printer.Fprintf("Port forwarding rule %q deleted.\n", ruleID) return nil } -// formatPFPorts returns a human-readable port range string for port forwarding rules. +// formatPFPorts returns a human-readable port range string for port forwarding rules (string args). +// Retained for backward compatibility with other commands that may reference it. func formatPFPorts(start, end string) string { if start == "" { return "" @@ -244,3 +240,14 @@ func formatPFPorts(start, end string) string { } return start + "-" + end } + +// formatPFPortsInt returns a human-readable port range string for port forwarding rules (int args). +func formatPFPortsInt(start, end string) string { + if start == "" { + return "" + } + if end == "" || end == start { + return start + } + return start + "-" + end +} diff --git a/internal/commands/product.go b/internal/commands/product.go new file mode 100644 index 0000000..8de79ea --- /dev/null +++ b/internal/commands/product.go @@ -0,0 +1,134 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/product" +) + +// NewProductCmd returns the 'product' cobra command. +func NewProductCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "product", + Short: "View products and product categories", + } + cmd.AddCommand(newProductCategoriesCmd()) + cmd.AddCommand(newProductListCmd()) + return cmd +} + +func newProductCategoriesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "categories", + Short: "List product categories", + Example: ` zcp product categories + zcp product categories --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProductCategories(cmd) + }, + } + return cmd +} + +func runProductCategories(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := product.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + categories, err := svc.ListCategories(ctx) + if err != nil { + return fmt.Errorf("product categories: %w", err) + } + + if len(categories) == 0 { + printer.Fprintf("No product categories found\n") + return nil + } + + headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "STATUS", "CREATED"} + rows := make([][]string, 0, len(categories)) + for _, c := range categories { + rows = append(rows, []string{ + c.ID, + c.Name, + c.Slug, + c.Description, + strconv.FormatBool(c.Status), + c.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newProductListCmd() *cobra.Command { + var ( + cardType string + cardSlug string + include string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List all products", + Example: ` zcp product list + zcp product list --card-type RateCard --card-slug default + zcp product list --include product_category + zcp product list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProductList(cmd, cardType, cardSlug, include) + }, + } + cmd.Flags().StringVar(&cardType, "card-type", "", "Card type filter (e.g. RateCard)") + cmd.Flags().StringVar(&cardSlug, "card-slug", "", "Card slug filter (e.g. default)") + cmd.Flags().StringVar(&include, "include", "", "Include related data (e.g. product_category)") + return cmd +} + +func runProductList(cmd *cobra.Command, cardType, cardSlug, include string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := product.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + products, err := svc.ListAll(ctx, cardType, cardSlug, include) + if err != nil { + return fmt.Errorf("product list: %w", err) + } + + if len(products) == 0 { + printer.Fprintf("No products found\n") + return nil + } + + headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "STATUS", "CATEGORY ID", "CREATED"} + rows := make([][]string, 0, len(products)) + for _, p := range products { + categoryID := p.ProductCategoryID + if p.ProductCategory != nil { + categoryID = p.ProductCategory.Name + } + rows = append(rows, []string{ + p.ID, + p.Name, + p.Slug, + p.Description, + strconv.FormatBool(p.Status), + categoryID, + p.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} diff --git a/internal/commands/profile.go b/internal/commands/profile.go index 9b23d2c..cfa14be 100644 --- a/internal/commands/profile.go +++ b/internal/commands/profile.go @@ -19,7 +19,7 @@ func NewProfileCmd() *cobra.Command { Short: "Manage configuration profiles", Long: `Profiles store named credential sets for different ZCP environments or accounts. -Each profile contains an API key, secret key, and optionally a custom API URL. +Each profile contains a bearer token and optionally a custom API URL. One profile can be set as the active (default) profile.`, } cmd.AddCommand(newProfileAddCmd()) @@ -33,7 +33,7 @@ One profile can be set as the active (default) profile.`, } func newProfileAddCmd() *cobra.Command { - var apiKey, secretKey, apiURL string + var bearerToken, apiURL string var nonInteractive bool cmd := &cobra.Command{ @@ -41,7 +41,7 @@ func newProfileAddCmd() *cobra.Command { Short: "Add or update a profile", Args: cobra.ExactArgs(1), Example: ` zcp profile add default - zcp profile add prod --api-key --secret-key `, + zcp profile add prod --bearer-token `, RunE: func(cmd *cobra.Command, args []string) error { name := args[0] @@ -52,22 +52,16 @@ func newProfileAddCmd() *cobra.Command { // If flags not provided, prompt interactively if !nonInteractive { - if apiKey == "" { - apiKey, err = prompt("API Key: ", false) - if err != nil { - return err - } - } - if secretKey == "" { - secretKey, err = prompt("Secret Key: ", true) + if bearerToken == "" { + bearerToken, err = prompt("Bearer Token: ", true) if err != nil { return err } } } - if apiKey == "" || secretKey == "" { - return fmt.Errorf("apikey and secretkey are required") + if bearerToken == "" { + return fmt.Errorf("bearer token is required") } if cfg.Profiles == nil { @@ -75,10 +69,9 @@ func newProfileAddCmd() *cobra.Command { } cfg.Profiles[name] = config.Profile{ - Name: name, - APIKey: apiKey, - SecretKey: secretKey, - APIURL: apiURL, + Name: name, + BearerToken: bearerToken, + APIURL: apiURL, } // Set as active if it's the first or only profile @@ -97,8 +90,7 @@ func newProfileAddCmd() *cobra.Command { return nil }, } - cmd.Flags().StringVar(&apiKey, "api-key", "", "API key (prompted if not provided)") - cmd.Flags().StringVar(&secretKey, "secret-key", "", "Secret key (prompted if not provided)") + cmd.Flags().StringVar(&bearerToken, "bearer-token", "", "Bearer token (prompted if not provided)") cmd.Flags().StringVar(&apiURL, "api-url-override", "", "Custom API URL (optional)") cmd.Flags().BoolVar(&nonInteractive, "no-input", false, "Fail if credentials not provided via flags") return cmd @@ -226,13 +218,12 @@ func newProfileShowCmd() *cobra.Command { } fmt.Fprintf(os.Stdout, "Profile: %s\n", name) fmt.Fprintf(os.Stdout, "API URL: %s\n", apiURL) - fmt.Fprintf(os.Stdout, "API Key: %s\n", maskSecret(p.APIKey)) - fmt.Fprintf(os.Stdout, "Secret Key: %s\n", maskSecret(p.SecretKey)) + fmt.Fprintf(os.Stdout, "Bearer Token: %s\n", maskSecret(p.BearerToken)) if name == cfg.ActiveProfile { fmt.Fprintln(os.Stdout, "Status: active") } // Credential completeness hint - if p.APIKey == "" || p.SecretKey == "" { + if p.BearerToken == "" { fmt.Fprintln(os.Stdout, "Warning: profile is missing credentials — run: zcp profile update "+name) } return nil @@ -241,15 +232,14 @@ func newProfileShowCmd() *cobra.Command { } func newProfileUpdateCmd() *cobra.Command { - var apiKey, secretKey, apiURL string + var bearerToken, apiURL string cmd := &cobra.Command{ Use: "update ", Short: "Update fields of an existing profile", Args: cobra.ExactArgs(1), - Example: ` zcp profile update prod --api-key - zcp profile update prod --api-url-override https://new.api.url - zcp profile update prod --secret-key `, + Example: ` zcp profile update prod --bearer-token + zcp profile update prod --api-url-override https://new.api.url`, RunE: func(cmd *cobra.Command, args []string) error { name := args[0] cfg, err := config.Load() @@ -261,12 +251,8 @@ func newProfileUpdateCmd() *cobra.Command { return fmt.Errorf("profile %q not found — run: zcp profile list", name) } changed := false - if apiKey != "" { - p.APIKey = apiKey - changed = true - } - if secretKey != "" { - p.SecretKey = secretKey + if bearerToken != "" { + p.BearerToken = bearerToken changed = true } if cmd.Flags().Changed("api-url-override") { @@ -274,7 +260,7 @@ func newProfileUpdateCmd() *cobra.Command { changed = true } if !changed { - return fmt.Errorf("no fields to update — use --api-key, --secret-key, or --api-url-override") + return fmt.Errorf("no fields to update — use --bearer-token or --api-url-override") } cfg.Profiles[name] = p if err := config.Save(cfg); err != nil { @@ -284,8 +270,7 @@ func newProfileUpdateCmd() *cobra.Command { return nil }, } - cmd.Flags().StringVar(&apiKey, "api-key", "", "New API key") - cmd.Flags().StringVar(&secretKey, "secret-key", "", "New secret key") + cmd.Flags().StringVar(&bearerToken, "bearer-token", "", "New bearer token") cmd.Flags().StringVar(&apiURL, "api-url-override", "", "New custom API URL (set to empty string to clear)") return cmd } diff --git a/internal/commands/project.go b/internal/commands/project.go new file mode 100644 index 0000000..58f891e --- /dev/null +++ b/internal/commands/project.go @@ -0,0 +1,421 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/project" +) + +// NewProjectCmd returns the 'project' cobra command. +func NewProjectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage projects, project icons, and project users", + } + cmd.AddCommand(newProjectListCmd()) + cmd.AddCommand(newProjectCreateCmd()) + cmd.AddCommand(newProjectUpdateCmd()) + cmd.AddCommand(newProjectDeleteCmd()) + cmd.AddCommand(newProjectDashboardCmd()) + cmd.AddCommand(newProjectIconCmd()) + cmd.AddCommand(newProjectUserCmd()) + return cmd +} + +// ─── Project List ──────────────────────────────────────────────────────────── + +func newProjectListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all projects", + Example: ` zcp project list`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectList(cmd) + }, + } + return cmd +} + +func runProjectList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + projects, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("project list: %w", err) + } + + headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "STATUS", "CREATED"} + rows := make([][]string, 0, len(projects)) + for _, p := range projects { + rows = append(rows, []string{ + p.ID, + p.Name, + p.Slug, + p.Description, + fmt.Sprintf("%v", p.Status), + p.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +// ─── Project Create ────────────────────────────────────────────────────────── + +func newProjectCreateCmd() *cobra.Command { + var name, description, icon, purpose string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new project", + Example: ` zcp project create --name my-project --icon cloud-15 --purpose "Development" + zcp project create --name my-project --description "My project" --icon cloud-13 --purpose "Testing"`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if icon == "" { + icon = "cloud-13" + } + if purpose == "" { + purpose = "Development & Testing" + } + return runProjectCreate(cmd, project.CreateRequest{ + Name: name, + Description: description, + Icon: icon, + Purpose: purpose, + Status: 1, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Project name (required)") + cmd.Flags().StringVar(&description, "description", "", "Project description") + cmd.Flags().StringVar(&icon, "icon", "cloud-13", "Icon name (see: zcp project icon list)") + cmd.Flags().StringVar(&purpose, "purpose", "Development & Testing", "Project purpose") + return cmd +} + +func runProjectCreate(cmd *cobra.Command, req project.CreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + p, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("project create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", p.ID}, + {"Name", p.Name}, + {"Slug", p.Slug}, + {"Description", p.Description}, + {"Default", fmt.Sprintf("%v", p.Status)}, + {"Created At", p.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ─── Project Update ────────────────────────────────────────────────────────── + +func newProjectUpdateCmd() *cobra.Command { + var name, description string + var iconID string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing project", + Args: cobra.ExactArgs(1), + Example: ` zcp project update my-project --name "New Name" + zcp project update my-project --description "Updated description" --icon 3`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectUpdate(cmd, args[0], project.UpdateRequest{ + Name: name, + Description: description, + IconID: iconID, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "New project name") + cmd.Flags().StringVar(&description, "description", "", "New project description") + cmd.Flags().StringVar(&iconID, "icon", "", "New icon ID (see: zcp project icon list)") + return cmd +} + +func runProjectUpdate(cmd *cobra.Command, slug string, req project.UpdateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + p, err := svc.Update(ctx, slug, req) + if err != nil { + return fmt.Errorf("project update: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", p.ID}, + {"Name", p.Name}, + {"Slug", p.Slug}, + {"Description", p.Description}, + {"Default", fmt.Sprintf("%v", p.Status)}, + {"Updated At", p.UpdatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ─── Project Delete ───────────────────────────────────────────────────────── + +func newProjectDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: cobra.ExactArgs(1), + Example: ` zcp project delete my-project + zcp project delete my-project --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + slug := args[0] + if !confirmAction(cmd, "Delete project %q?", slug) { + fmt.Fprintln(cmd.ErrOrStderr(), "Cancelled.") + return nil + } + return runProjectDelete(cmd, slug) + }, + } + return cmd +} + +func runProjectDelete(cmd *cobra.Command, slug string) error { + _, client, _, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("project delete: %w", err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Project %q deleted.\n", slug) + return nil +} + +// ─── Project Dashboard ─────────────────────────────────────────────────────── + +func newProjectDashboardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dashboard ", + Short: "Show project dashboard services", + Args: cobra.ExactArgs(1), + Example: ` zcp project dashboard my-project`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectDashboard(cmd, args[0]) + }, + } + return cmd +} + +func runProjectDashboard(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + services, err := svc.Dashboard(ctx, slug) + if err != nil { + return fmt.Errorf("project dashboard: %w", err) + } + + headers := []string{"NAME", "TYPE", "STATUS", "COUNT"} + rows := make([][]string, 0, len(services)) + for _, s := range services { + rows = append(rows, []string{ + s.Name, + s.Type, + s.Status, + strconv.Itoa(s.Count), + }) + } + return printer.PrintTable(headers, rows) +} + +// ─── Project Icon ──────────────────────────────────────────────────────────── + +func newProjectIconCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "icon", + Short: "Manage project icons", + } + cmd.AddCommand(newProjectIconListCmd()) + return cmd +} + +func newProjectIconListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List available project icons", + Example: ` zcp project icon list`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectIconList(cmd) + }, + } + return cmd +} + +func runProjectIconList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + icons, err := svc.ListIcons(ctx) + if err != nil { + return fmt.Errorf("project icon list: %w", err) + } + + headers := []string{"ID", "NAME", "URL"} + rows := make([][]string, 0, len(icons)) + for _, ic := range icons { + rows = append(rows, []string{ + ic.ID, + ic.Name, + ic.URL, + }) + } + return printer.PrintTable(headers, rows) +} + +// ─── Project User ──────────────────────────────────────────────────────────── + +func newProjectUserCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Manage project users", + } + cmd.AddCommand(newProjectUserListCmd()) + cmd.AddCommand(newProjectUserAddCmd()) + return cmd +} + +func newProjectUserListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List users in a project", + Args: cobra.ExactArgs(1), + Example: ` zcp project user list my-project`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectUserList(cmd, args[0]) + }, + } + return cmd +} + +func runProjectUserList(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + users, err := svc.ListUsers(ctx, slug) + if err != nil { + return fmt.Errorf("project user list: %w", err) + } + + headers := []string{"ID", "NAME", "EMAIL", "ROLE"} + rows := make([][]string, 0, len(users)) + for _, u := range users { + rows = append(rows, []string{ + u.ID, + u.Name, + u.Email, + u.Role, + }) + } + return printer.PrintTable(headers, rows) +} + +func newProjectUserAddCmd() *cobra.Command { + var email, role string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a user to a project", + Args: cobra.ExactArgs(1), + Example: ` zcp project user add my-project --email alice@example.com + zcp project user add my-project --email alice@example.com --role admin`, + RunE: func(cmd *cobra.Command, args []string) error { + if email == "" { + return fmt.Errorf("--email is required") + } + return runProjectUserAdd(cmd, args[0], project.AddUserRequest{ + Email: email, + Role: role, + }) + }, + } + cmd.Flags().StringVar(&email, "email", "", "User email address (required)") + cmd.Flags().StringVar(&role, "role", "", "User role (e.g. admin, member)") + return cmd +} + +func runProjectUserAdd(cmd *cobra.Command, slug string, req project.AddUserRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := project.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + u, err := svc.AddUser(ctx, slug, req) + if err != nil { + return fmt.Errorf("project user add: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", u.ID}, + {"Name", u.Name}, + {"Email", u.Email}, + {"Role", u.Role}, + } + return printer.PrintTable(headers, rows) +} diff --git a/internal/commands/securitygroup.go b/internal/commands/securitygroup.go index fe7db5c..d377a22 100644 --- a/internal/commands/securitygroup.go +++ b/internal/commands/securitygroup.go @@ -229,7 +229,7 @@ func newSGDeleteCmd() *cobra.Command { } func runSGDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "WARNING: deleting security group %q will also delete all its rules.\n", uuid) fmt.Fprintf(os.Stderr, "Delete security group %q? [y/N]: ", uuid) scanner := bufio.NewScanner(os.Stdin) @@ -444,7 +444,7 @@ func newSGRuleDeleteCmd() *cobra.Command { } func runSGRuleDelete(cmd *cobra.Command, sgUUID, ruleType, ruleUUID string, yes bool) error { - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete %s rule %q from security group %q? [y/N]: ", ruleType, ruleUUID, sgUUID) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() diff --git a/internal/commands/snapshot.go b/internal/commands/snapshot.go index cb18f81..4faee5e 100644 --- a/internal/commands/snapshot.go +++ b/internal/commands/snapshot.go @@ -11,26 +11,23 @@ import ( "github.com/zsoftly/zcp-cli/internal/api/snapshot" ) -// NewSnapshotCmd returns the 'snapshot' cobra command. +// NewSnapshotCmd returns the 'snapshot' cobra command for block storage snapshots. func NewSnapshotCmd() *cobra.Command { cmd := &cobra.Command{ Use: "snapshot", - Short: "Manage volume snapshots", + Short: "Manage block storage snapshots", } cmd.AddCommand(newSnapshotListCmd()) cmd.AddCommand(newSnapshotCreateCmd()) - cmd.AddCommand(newSnapshotDeleteCmd()) + cmd.AddCommand(newSnapshotRevertCmd()) return cmd } func newSnapshotListCmd() *cobra.Command { - var zoneUUID, snapshotUUID string - cmd := &cobra.Command{ Use: "list", - Short: "List volume snapshots", + Short: "List block storage snapshots", Example: ` zcp snapshot list - zcp snapshot list --zone zcp snapshot list --output json`, RunE: func(cmd *cobra.Command, args []string) error { _, client, printer, err := buildClientAndPrinter(cmd) @@ -41,101 +38,119 @@ func newSnapshotListCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - snapshots, err := svc.List(ctx, zoneUUID, snapshotUUID) + snapshots, err := svc.List(ctx) if err != nil { return fmt.Errorf("snapshot list: %w", err) } - headers := []string{"UUID", "NAME", "STATUS", "VOLUME", "ZONE", "TIME"} + headers := []string{"SLUG", "NAME", "VOLUME ID", "SERVICE", "CREATED"} rows := make([][]string, 0, len(snapshots)) for _, s := range snapshots { rows = append(rows, []string{ - s.UUID, + s.Slug, s.Name, - s.Status, - s.VolumeUUID, - s.ZoneUUID, - s.SnapshotTime, + s.BlockstorageID, + s.ServiceDisplayName, + s.CreatedAt, }) } return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Filter by zone UUID") - cmd.Flags().StringVar(&snapshotUUID, "uuid", "", "Filter by snapshot UUID") return cmd } func newSnapshotCreateCmd() *cobra.Command { - var volumeUUID, zoneUUID, name string + var blockstorageSlug, name, plan, project, cloudProvider, region, billingCycle, coupon string cmd := &cobra.Command{ Use: "create", - Short: "Create a volume snapshot", - Example: ` zcp snapshot create --volume --zone --name my-snapshot`, + Short: "Create a block storage snapshot", + Example: ` zcp snapshot create --volume root-4153 --name my-snapshot --plan snapshot-per-gb --cloud-provider nimbo --region noida --billing-cycle hourly --project default-73`, RunE: func(cmd *cobra.Command, args []string) error { - if volumeUUID == "" { + if blockstorageSlug == "" { return fmt.Errorf("--volume is required") } if name == "" { return fmt.Errorf("--name is required") } - profile, client, printer, err := buildClientAndPrinter(cmd) + if plan == "" { + return fmt.Errorf("--plan is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if region == "" { + return fmt.Errorf("--region is required") + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + if project == "" { + return fmt.Errorf("--project is required") + } + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := snapshot.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() req := snapshot.CreateRequest{ - Name: name, - VolumeUUID: volumeUUID, - ZoneUUID: zoneUUID, + Name: name, + Plan: plan, + Service: "Block Storage Snapshot", + CloudProvider: cloudProvider, + Region: region, + BillingCycle: billingCycle, + Project: project, + Coupon: coupon, } - snap, err := svc.Create(ctx, req) + snap, err := svc.Create(ctx, blockstorageSlug, req) if err != nil { - if strings.Contains(err.Error(), "not in Ready state") { - return fmt.Errorf("snapshot create: volume must be attached to a running instance before taking a snapshot") - } return fmt.Errorf("snapshot create: %w", err) } - headers := []string{"UUID", "NAME", "STATUS", "VOLUME", "ZONE", "TIME"} + headers := []string{"SLUG", "NAME", "VOLUME ID", "SERVICE", "CREATED"} rows := [][]string{{ - snap.UUID, + snap.Slug, snap.Name, - snap.Status, - snap.VolumeUUID, - snap.ZoneUUID, - snap.SnapshotTime, + snap.BlockstorageID, + snap.ServiceDisplayName, + snap.CreatedAt, }} return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&volumeUUID, "volume", "", "Volume UUID to snapshot (required)") - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") + cmd.Flags().StringVar(&blockstorageSlug, "volume", "", "Block storage volume slug to snapshot (required)") cmd.Flags().StringVar(&name, "name", "", "Snapshot name (required)") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug, e.g. snapshot-per-gb (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(&coupon, "coupon", "", "Coupon code") return cmd } -func newSnapshotDeleteCmd() *cobra.Command { +func newSnapshotRevertCmd() *cobra.Command { var yes bool + var blockstorageSlug string cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a snapshot permanently", + Use: "revert ", + Short: "Revert a block storage volume to a snapshot state (DESTRUCTIVE)", Args: cobra.ExactArgs(1), - Example: ` zcp snapshot delete - zcp snapshot delete --yes`, + Example: ` zcp snapshot revert --volume + zcp snapshot revert --volume --yes`, RunE: func(cmd *cobra.Command, args []string) error { - uuid := args[0] - if !yes { - fmt.Fprintf(os.Stdout, "Are you sure you want to delete %q? This cannot be undone. [y/N]: ", uuid) + snapshotSlug := args[0] + if blockstorageSlug == "" { + return fmt.Errorf("--volume is required") + } + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "WARNING: Reverting snapshot %q on volume %q will discard all changes since the snapshot. This cannot be undone. [y/N]: ", snapshotSlug, blockstorageSlug) var answer string fmt.Scanln(&answer) if strings.ToLower(strings.TrimSpace(answer)) != "y" { @@ -151,14 +166,23 @@ func newSnapshotDeleteCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("snapshot delete: %w", err) + snap, err := svc.Revert(ctx, blockstorageSlug, snapshotSlug) + if err != nil { + return fmt.Errorf("snapshot revert: %w", err) } - printer.Fprintf("Snapshot %q deleted.\n", uuid) - return nil + headers := []string{"SLUG", "NAME", "VOLUME ID", "SERVICE", "CREATED"} + rows := [][]string{{ + snap.Slug, + snap.Name, + snap.BlockstorageID, + snap.ServiceDisplayName, + snap.CreatedAt, + }} + return printer.PrintTable(headers, rows) }, } cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&blockstorageSlug, "volume", "", "Block storage volume slug (required)") return cmd } diff --git a/internal/commands/snapshotpolicy.go b/internal/commands/snapshotpolicy.go index a595a86..13dc3eb 100644 --- a/internal/commands/snapshotpolicy.go +++ b/internal/commands/snapshotpolicy.go @@ -148,7 +148,7 @@ func newSnapshotPolicyDeleteCmd() *cobra.Command { zcp snapshot-policy delete --yes`, RunE: func(cmd *cobra.Command, args []string) error { uuid := args[0] - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stdout, "Are you sure you want to delete %q? This cannot be undone. [y/N]: ", uuid) var answer string fmt.Scanln(&answer) diff --git a/internal/commands/sshkey.go b/internal/commands/sshkey.go index 7126337..0e03e7d 100644 --- a/internal/commands/sshkey.go +++ b/internal/commands/sshkey.go @@ -52,14 +52,14 @@ func runSSHKeyList(cmd *cobra.Command) error { return fmt.Errorf("ssh-key list: %w", err) } - headers := []string{"UUID", "NAME", "STATUS", "DOMAIN"} + headers := []string{"ID", "NAME", "SLUG", "CREATED"} rows := make([][]string, 0, len(keys)) for _, k := range keys { rows = append(rows, []string{ - k.UUID, + k.ID, k.Name, - k.Status, - k.DomainName, + k.Slug, + k.CreatedAt, }) } return printer.PrintTable(headers, rows) @@ -116,10 +116,10 @@ func runSSHKeyImport(cmd *cobra.Command, req sshkey.CreateRequest) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", key.UUID}, + {"ID", key.ID}, {"Name", key.Name}, - {"Status", key.Status}, - {"Domain", key.DomainName}, + {"Slug", key.Slug}, + {"Created", key.CreatedAt}, } return printer.PrintTable(headers, rows) } @@ -142,7 +142,7 @@ func newSSHKeyDeleteCmd() *cobra.Command { } func runSSHKeyDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete SSH key %q? [y/N]: ", uuid) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() diff --git a/internal/commands/store.go b/internal/commands/store.go new file mode 100644 index 0000000..66e03d6 --- /dev/null +++ b/internal/commands/store.go @@ -0,0 +1,143 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/store" +) + +// NewStoreCmd returns the 'store' cobra command. +func NewStoreCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "store", + Short: "Manage store items and checkout", + } + cmd.AddCommand(newStoreListCmd()) + cmd.AddCommand(newStoreCheckoutCmd()) + return cmd +} + +func newStoreListCmd() *cobra.Command { + var ( + sort string + page int + limit int + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List store items", + Example: ` zcp store list + zcp store list --sort -created_at + zcp store list --page 1 --limit 10 + zcp store list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStoreList(cmd, sort, page, limit) + }, + } + cmd.Flags().StringVar(&sort, "sort", "-created_at", "Sort order (e.g. -created_at)") + cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().IntVar(&limit, "limit", 0, "Items per page (0 = all)") + return cmd +} + +func runStoreList(cmd *cobra.Command, sort string, page, limit int) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := store.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + items, total, err := svc.ListItems(ctx, sort, page, limit) + if err != nil { + return fmt.Errorf("store list: %w", err) + } + + if len(items) == 0 { + printer.Fprintf("No store items found (total: %d)\n", total) + return nil + } + + headers := []string{"ID", "NAME", "SLUG", "DESCRIPTION", "STATUS", "CREATED"} + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.ID, + item.Name, + item.Slug, + item.Description, + item.Status, + item.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newStoreCheckoutCmd() *cobra.Command { + var ( + productSlug string + description string + quantity int + billingCycle string + coupon string + ) + + cmd := &cobra.Command{ + Use: "checkout", + Short: "Purchase a store product", + Example: ` zcp store checkout --product product-002 --description "Testing" --quantity 1 + zcp store checkout --product product-002 --description "Order" --quantity 2 --billing-cycle monthly + zcp store checkout --product product-002 --description "Order" --quantity 1 --coupon SAVE10`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStoreCheckout(cmd, productSlug, description, quantity, billingCycle, coupon) + }, + } + cmd.Flags().StringVar(&productSlug, "product", "", "Product slug (required)") + cmd.Flags().StringVar(&description, "description", "", "Order description (required)") + cmd.Flags().IntVar(&quantity, "quantity", 1, "Quantity to purchase") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "monthly", "Billing cycle (e.g. hourly, monthly)") + cmd.Flags().StringVar(&coupon, "coupon", "", "Coupon code (optional)") + cmd.MarkFlagRequired("product") + cmd.MarkFlagRequired("description") + return cmd +} + +func runStoreCheckout(cmd *cobra.Command, productSlug, description string, quantity int, billingCycle, coupon string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := store.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + req := store.CheckoutRequest{ + Service: "Store", + Products: []store.CheckoutProduct{ + { + Description: description, + Product: productSlug, + Quantity: quantity, + Status: "Completed", + }, + }, + BillingCycle: billingCycle, + } + if coupon != "" { + req.Coupon = &coupon + } + + if err := svc.Checkout(ctx, req); err != nil { + return fmt.Errorf("store checkout: %w", err) + } + + printer.Fprintf("Checkout completed successfully for product %q (quantity: %d)\n", productSlug, quantity) + return nil +} diff --git a/internal/commands/support.go b/internal/commands/support.go new file mode 100644 index 0000000..5c2e537 --- /dev/null +++ b/internal/commands/support.go @@ -0,0 +1,519 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/support" +) + +// NewSupportCmd returns the 'support' cobra command. +func NewSupportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "support", + Short: "Manage support tickets, replies, feedback, and FAQs", + } + cmd.AddCommand(newTicketCmd()) + cmd.AddCommand(newFAQCmd()) + return cmd +} + +// ---------- ticket subcommand ---------- + +func newTicketCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ticket", + Short: "Manage support tickets", + } + cmd.AddCommand(newTicketListCmd()) + cmd.AddCommand(newTicketCreateCmd()) + cmd.AddCommand(newTicketShowCmd()) + cmd.AddCommand(newTicketDeleteCmd()) + cmd.AddCommand(newTicketSummaryCmd()) + cmd.AddCommand(newTicketReplyCmd()) + cmd.AddCommand(newTicketRepliesCmd()) + cmd.AddCommand(newTicketFeedbackCmd()) + cmd.AddCommand(newTicketFeedbackSubmitCmd()) + return cmd +} + +// ---------- ticket list ---------- + +func newTicketListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List support tickets", + Example: ` zcp support ticket list + zcp support ticket list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketList(cmd) + }, + } + return cmd +} + +func runTicketList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + tickets, err := svc.ListTickets(ctx) + if err != nil { + return fmt.Errorf("support ticket list: %w", err) + } + + headers := []string{"ID", "SUBJECT", "STATUS", "PRIORITY", "DEPARTMENT", "CREATED"} + rows := make([][]string, 0, len(tickets)) + for _, t := range tickets { + rows = append(rows, []string{ + t.ID, + t.Subject, + t.Status, + t.Priority, + t.Department, + t.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket create ---------- + +func newTicketCreateCmd() *cobra.Command { + var subject, description, priority, department string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a support ticket", + Example: ` zcp support ticket create --subject "Cannot SSH" --description "Connection refused on port 22" + zcp support ticket create --subject "Billing" --description "Wrong charge" --priority high --department billing`, + RunE: func(cmd *cobra.Command, args []string) error { + if subject == "" { + return fmt.Errorf("--subject is required") + } + if description == "" { + return fmt.Errorf("--description is required") + } + return runTicketCreate(cmd, support.CreateTicketRequest{ + Subject: subject, + Description: description, + Priority: priority, + Department: department, + }) + }, + } + cmd.Flags().StringVar(&subject, "subject", "", "Ticket subject (required)") + cmd.Flags().StringVar(&description, "description", "", "Ticket description (required)") + cmd.Flags().StringVar(&priority, "priority", "", "Ticket priority (e.g. low, medium, high)") + cmd.Flags().StringVar(&department, "department", "", "Department to route the ticket to") + return cmd +} + +func runTicketCreate(cmd *cobra.Command, req support.CreateTicketRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + ticket, err := svc.CreateTicket(ctx, req) + if err != nil { + return fmt.Errorf("support ticket create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", ticket.ID}, + {"Subject", ticket.Subject}, + {"Description", ticket.Description}, + {"Status", ticket.Status}, + {"Priority", ticket.Priority}, + {"Department", ticket.Department}, + {"Created", ticket.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket show ---------- + +func newTicketShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket show + zcp support ticket show --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketShow(cmd, args[0]) + }, + } + return cmd +} + +func runTicketShow(cmd *cobra.Command, id string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + ticket, err := svc.GetTicket(ctx, id) + if err != nil { + return fmt.Errorf("support ticket show: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", ticket.ID}, + {"Subject", ticket.Subject}, + {"Description", ticket.Description}, + {"Status", ticket.Status}, + {"Priority", ticket.Priority}, + {"Department", ticket.Department}, + {"Created", ticket.CreatedAt}, + {"Updated", ticket.UpdatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket delete ---------- + +func newTicketDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket delete + zcp support ticket delete --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketDelete(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runTicketDelete(cmd *cobra.Command, id string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete support ticket %q? [y/N]: ", id) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteTicket(ctx, id); err != nil { + return fmt.Errorf("support ticket delete: %w", err) + } + + printer.Fprintf("Support ticket %q deleted.\n", id) + return nil +} + +// ---------- ticket summary ---------- + +func newTicketSummaryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "summary", + Short: "Show ticket count summary", + Example: ` zcp support ticket summary + zcp support ticket summary --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketSummary(cmd) + }, + } + return cmd +} + +func runTicketSummary(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + summary, err := svc.Summary(ctx) + if err != nil { + return fmt.Errorf("support ticket summary: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Total", strconv.Itoa(summary.Total)}, + {"Open", strconv.Itoa(summary.Open)}, + {"Closed", strconv.Itoa(summary.Closed)}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket reply (create) ---------- + +func newTicketReplyCmd() *cobra.Command { + var message string + + cmd := &cobra.Command{ + Use: "reply ", + Short: "Reply to a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket reply --message "Here is more detail..."`, + RunE: func(cmd *cobra.Command, args []string) error { + if message == "" { + return fmt.Errorf("--message is required") + } + return runTicketReply(cmd, args[0], support.CreateReplyRequest{Message: message}) + }, + } + cmd.Flags().StringVar(&message, "message", "", "Reply message (required)") + return cmd +} + +func runTicketReply(cmd *cobra.Command, ticketID string, req support.CreateReplyRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + reply, err := svc.CreateReply(ctx, ticketID, req) + if err != nil { + return fmt.Errorf("support ticket reply: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", reply.ID}, + {"Ticket ID", reply.TicketID}, + {"Message", reply.Message}, + {"Author", reply.Author}, + {"Created", reply.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket replies (list) ---------- + +func newTicketRepliesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "replies ", + Short: "List replies for a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket replies + zcp support ticket replies --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketReplies(cmd, args[0]) + }, + } + return cmd +} + +func runTicketReplies(cmd *cobra.Command, ticketID string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + replies, err := svc.ListReplies(ctx, ticketID) + if err != nil { + return fmt.Errorf("support ticket replies: %w", err) + } + + headers := []string{"ID", "AUTHOR", "MESSAGE", "CREATED"} + rows := make([][]string, 0, len(replies)) + for _, r := range replies { + rows = append(rows, []string{ + r.ID, + r.Author, + r.Message, + r.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket feedback (get) ---------- + +func newTicketFeedbackCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "feedback ", + Short: "Get feedback for a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket feedback + zcp support ticket feedback --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTicketFeedback(cmd, args[0]) + }, + } + return cmd +} + +func runTicketFeedback(cmd *cobra.Command, ticketID string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + fb, err := svc.GetFeedback(ctx, ticketID) + if err != nil { + return fmt.Errorf("support ticket feedback: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", fb.ID}, + {"Ticket ID", fb.TicketID}, + {"Rating", strconv.Itoa(fb.Rating)}, + {"Comment", fb.Comment}, + {"Created", fb.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- ticket feedback-submit ---------- + +func newTicketFeedbackSubmitCmd() *cobra.Command { + var rating int + var comment string + + cmd := &cobra.Command{ + Use: "feedback-submit ", + Short: "Submit feedback for a support ticket", + Args: cobra.ExactArgs(1), + Example: ` zcp support ticket feedback-submit --rating 5 + zcp support ticket feedback-submit --rating 4 --comment "Quick resolution"`, + RunE: func(cmd *cobra.Command, args []string) error { + if rating < 1 || rating > 5 { + return fmt.Errorf("--rating must be between 1 and 5") + } + return runTicketFeedbackSubmit(cmd, args[0], support.SubmitFeedbackRequest{ + Rating: rating, + Comment: comment, + }) + }, + } + cmd.Flags().IntVar(&rating, "rating", 0, "Rating from 1 to 5 (required)") + cmd.Flags().StringVar(&comment, "comment", "", "Optional feedback comment") + return cmd +} + +func runTicketFeedbackSubmit(cmd *cobra.Command, ticketID string, req support.SubmitFeedbackRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + fb, err := svc.SubmitFeedback(ctx, ticketID, req) + if err != nil { + return fmt.Errorf("support ticket feedback-submit: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", fb.ID}, + {"Ticket ID", fb.TicketID}, + {"Rating", strconv.Itoa(fb.Rating)}, + {"Comment", fb.Comment}, + {"Created", fb.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +// ---------- faq subcommand ---------- + +func newFAQCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "faq", + Short: "View frequently asked questions", + } + cmd.AddCommand(newFAQListCmd()) + return cmd +} + +func newFAQListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List FAQs", + Example: ` zcp support faq list + zcp support faq list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runFAQList(cmd) + }, + } + return cmd +} + +func runFAQList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := support.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + faqs, err := svc.ListFAQs(ctx) + if err != nil { + return fmt.Errorf("support faq list: %w", err) + } + + headers := []string{"ID", "CATEGORY", "QUESTION", "ANSWER"} + rows := make([][]string, 0, len(faqs)) + for _, f := range faqs { + rows = append(rows, []string{ + f.ID, + f.Category, + f.Question, + f.Answer, + }) + } + return printer.PrintTable(headers, rows) +} diff --git a/internal/commands/tag.go b/internal/commands/tag.go index a946101..3a1f755 100644 --- a/internal/commands/tag.go +++ b/internal/commands/tag.go @@ -154,7 +154,7 @@ func newTagDeleteCmd() *cobra.Command { } func runTagDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { + if !yes && !autoApproved(cmd) { fmt.Fprintf(os.Stderr, "Delete tag %q? [y/N]: ", uuid) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() diff --git a/internal/commands/template.go b/internal/commands/template.go index 6b69f8c..d69b58f 100644 --- a/internal/commands/template.go +++ b/internal/commands/template.go @@ -1,8 +1,12 @@ package commands import ( + "bufio" "context" "fmt" + "os" + "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -16,45 +20,254 @@ func NewTemplateCmd() *cobra.Command { Short: "Manage VM templates", } cmd.AddCommand(newTemplateListCmd()) + cmd.AddCommand(newTemplateAccountListCmd()) + cmd.AddCommand(newTemplateAccountCreateCmd()) + cmd.AddCommand(newTemplateAccountDeleteCmd()) return cmd } func newTemplateListCmd() *cobra.Command { - var zoneUUID string + var region string cmd := &cobra.Command{ Use: "list", - Short: "List available templates", - Example: ` zcp template list --zone - zcp template list --zone --output json`, + Short: "List available public templates", + Example: ` zcp template list + zcp template list --region yow-1 + zcp template list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err + return runTemplateList(cmd, region) + }, + } + cmd.Flags().StringVar(®ion, "region", "", "Filter by region slug") + return cmd +} + +func runTemplateList(cmd *cobra.Command, region string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := template.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + templates, err := svc.List(ctx, region) + if err != nil { + return fmt.Errorf("template list: %w", err) + } + + headers := []string{"SLUG", "NAME", "TYPE", "OS", "VERSION", "IMAGE TYPE", "PASSWORD"} + rows := make([][]string, 0, len(templates)) + for _, t := range templates { + osName := "" + osVersion := "" + if t.OperatingSystem != nil { + osName = t.OperatingSystem.Name + } + if t.OperatingSystemVersion != nil { + osVersion = t.OperatingSystemVersion.Version + } + rows = append(rows, []string{ + t.Slug, + t.Name, + t.Type, + osName, + osVersion, + t.ImageType, + strconv.FormatBool(t.PasswordEnabled), + }) + } + return printer.PrintTable(headers, rows) +} + +func newTemplateAccountListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "account-list", + Short: "List account (user-created) templates", + Example: ` zcp template account-list + zcp template account-list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTemplateAccountList(cmd) + }, + } + return cmd +} + +func runTemplateAccountList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := template.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + templates, err := svc.ListAccount(ctx) + if err != nil { + return fmt.Errorf("template account-list: %w", err) + } + + headers := []string{"SLUG", "NAME", "FORMAT", "IMAGE TYPE", "STATUS", "CREATED"} + rows := make([][]string, 0, len(templates)) + for _, t := range templates { + rows = append(rows, []string{ + t.Slug, + t.Name, + t.Format, + t.ImageType, + t.Status, + t.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) +} + +func newTemplateAccountCreateCmd() *cobra.Command { + var ( + name string + description string + templateURL string + cloudProvider string + region string + project string + osTypeID string + imageType string + operatingSystem string + osVersion string + passwordEnabled bool + billingCycle string + plan string + format string + ) + + cmd := &cobra.Command{ + Use: "account-create", + Short: "Create an account template", + Example: ` zcp template account-create --name my-template --cloud-provider nimbo \ + --region yow-1 --project default-1 --os-type-id \ + --image-type "Operating System" --os ubuntu --os-version "22.04 LTS" \ + --billing-cycle hourly --url https://example.com/image.qcow2 --format QCOW2`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") } - svc := template.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - templates, err := svc.List(ctx, zoneUUID, "") - if err != nil { - return fmt.Errorf("template list: %w", err) + if region == "" { + return fmt.Errorf("--region is required") } - - headers := []string{"UUID", "NAME", "OS CATEGORY", "FORMAT", "ZONE", "ACTIVE"} - rows := make([][]string, 0, len(templates)) - for _, t := range templates { - rows = append(rows, []string{ - t.UUID, t.Name, t.OsCategoryName, t.Format, t.ZoneName, t.IsActive, - }) + if project == "" { + return fmt.Errorf("--project is required") + } + req := template.CreateAccountTemplateRequest{ + Name: name, + Description: description, + URL: templateURL, + CloudProvider: cloudProvider, + Region: region, + Project: project, + OSTypeID: osTypeID, + ImageType: imageType, + OperatingSystem: operatingSystem, + OperatingSystemVersion: osVersion, + PasswordEnabled: passwordEnabled, + BillingCycle: billingCycle, + Plan: plan, + Format: format, } - return printer.PrintTable(headers, rows) + return runTemplateAccountCreate(cmd, req) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") + cmd.Flags().StringVar(&name, "name", "", "Template name (required)") + cmd.Flags().StringVar(&description, "description", "", "Template description") + cmd.Flags().StringVar(&templateURL, "url", "", "URL to the template image") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(&osTypeID, "os-type-id", "", "OS type UUID") + cmd.Flags().StringVar(&imageType, "image-type", "Operating System", "Image type") + cmd.Flags().StringVar(&operatingSystem, "os", "", "Operating system name") + cmd.Flags().StringVar(&osVersion, "os-version", "", "Operating system version") + cmd.Flags().BoolVar(&passwordEnabled, "password-enabled", true, "Enable password login") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "hourly", "Billing cycle (hourly, monthly)") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug") + cmd.Flags().StringVar(&format, "format", "", "Image format (QCOW2, RAW, etc.)") return cmd } + +func runTemplateAccountCreate(cmd *cobra.Command, req template.CreateAccountTemplateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := template.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + t, err := svc.CreateAccount(ctx, req) + if err != nil { + return fmt.Errorf("template account-create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", t.Slug}, + {"Name", t.Name}, + {"Format", t.Format}, + {"Image Type", t.ImageType}, + {"Status", t.Status}, + {"Created", t.CreatedAt}, + } + return printer.PrintTable(headers, rows) +} + +func newTemplateAccountDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "account-delete ", + Short: "Delete an account template", + Args: cobra.ExactArgs(1), + Example: ` zcp template account-delete my-template + zcp template account-delete my-template --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTemplateAccountDelete(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + return cmd +} + +func runTemplateAccountDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete account template %q? [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := template.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteAccount(ctx, slug); err != nil { + return fmt.Errorf("template account-delete: %w", err) + } + + printer.Fprintf("Account template %q deleted.\n", slug) + return nil +} diff --git a/internal/commands/virtualrouter.go b/internal/commands/virtualrouter.go new file mode 100644 index 0000000..d4e40e8 --- /dev/null +++ b/internal/commands/virtualrouter.go @@ -0,0 +1,180 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/virtualrouter" +) + +// NewVirtualRouterCmd returns the 'virtual-router' cobra command. +func NewVirtualRouterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "virtual-router", + Aliases: []string{"vr"}, + Short: "Manage virtual routers", + } + cmd.AddCommand(newVirtualRouterListCmd()) + cmd.AddCommand(newVirtualRouterCreateCmd()) + cmd.AddCommand(newVirtualRouterRebootCmd()) + return cmd +} + +func newVirtualRouterListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List virtual routers", + Example: ` zcp virtual-router list + zcp vr list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runVirtualRouterList(cmd) + }, + } + return cmd +} + +func runVirtualRouterList(cmd *cobra.Command) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := virtualrouter.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + routers, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("virtual-router list: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATE", "PUBLIC IP", "GUEST IP", "ZONE", "ROLE"} + rows := make([][]string, 0, len(routers)) + for _, r := range routers { + rows = append(rows, []string{ + r.Slug, + r.Name, + r.State, + r.PublicIP, + r.GuestIP, + r.ZoneSlug, + r.Role, + }) + } + return printer.PrintTable(headers, rows) +} + +func newVirtualRouterCreateCmd() *cobra.Command { + var name, networkSlug, planSlug, zoneSlug string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a virtual router", + Example: ` zcp virtual-router create --name my-router --network + zcp vr create --name my-router --network --plan `, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if networkSlug == "" { + return fmt.Errorf("--network is required") + } + return runVirtualRouterCreate(cmd, virtualrouter.CreateRequest{ + Name: name, + NetworkSlug: networkSlug, + PlanSlug: planSlug, + ZoneSlug: zoneSlug, + }) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Virtual router name (required)") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") + cmd.Flags().StringVar(&planSlug, "plan", "", "Virtual router plan slug") + cmd.Flags().StringVar(&zoneSlug, "zone", "", "Zone slug") + return cmd +} + +func runVirtualRouterCreate(cmd *cobra.Command, req virtualrouter.CreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := virtualrouter.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + vr, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("virtual-router create: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", vr.Slug}, + {"Name", vr.Name}, + {"State", vr.State}, + {"Public IP", vr.PublicIP}, + {"Guest IP", vr.GuestIP}, + {"Zone", vr.ZoneSlug}, + {"Role", vr.Role}, + } + return printer.PrintTable(headers, rows) +} + +func newVirtualRouterRebootCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "reboot ", + Short: "Reboot a virtual router", + Args: cobra.ExactArgs(1), + Example: ` zcp virtual-router reboot + zcp vr reboot --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runVirtualRouterReboot(cmd, args[0], yes) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} + +func runVirtualRouterReboot(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Reboot virtual router %q? [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := virtualrouter.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + vr, err := svc.Reboot(ctx, slug) + if err != nil { + return fmt.Errorf("virtual-router reboot: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", vr.Slug}, + {"Name", vr.Name}, + {"State", vr.State}, + } + return printer.PrintTable(headers, rows) +} diff --git a/internal/commands/vmsnapshot.go b/internal/commands/vmsnapshot.go index 9e731ad..dfe23ad 100644 --- a/internal/commands/vmsnapshot.go +++ b/internal/commands/vmsnapshot.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "os" - "strconv" "strings" "time" "github.com/spf13/cobra" "github.com/zsoftly/zcp-cli/internal/api/vmsnapshot" - "github.com/zsoftly/zcp-cli/internal/api/waiters" ) // NewVMSnapshotCmd returns the 'vm-snapshot' cobra command. @@ -27,13 +25,10 @@ func NewVMSnapshotCmd() *cobra.Command { } func newVMSnapshotListCmd() *cobra.Command { - var zoneUUID, snapshotUUID string - cmd := &cobra.Command{ Use: "list", Short: "List VM snapshots", Example: ` zcp vm-snapshot list - zcp vm-snapshot list --zone zcp vm-snapshot list --output json`, RunE: func(cmd *cobra.Command, args []string) error { _, client, printer, err := buildClientAndPrinter(cmd) @@ -44,99 +39,87 @@ func newVMSnapshotListCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - snapshots, err := svc.List(ctx, zoneUUID, snapshotUUID) + snapshots, err := svc.List(ctx) if err != nil { return fmt.Errorf("vm-snapshot list: %w", err) } - headers := []string{"UUID", "NAME", "STATUS", "CURRENT", "ZONE", "CREATED"} + headers := []string{"SLUG", "NAME", "STATE", "VM ID", "REGION", "CREATED"} rows := make([][]string, 0, len(snapshots)) for _, s := range snapshots { rows = append(rows, []string{ - s.UUID, + s.Slug, s.Name, - s.Status, - strconv.FormatBool(s.IsCurrent), - s.ZoneUUID, + s.State, + s.VirtualMachineID, + s.RegionID, s.CreatedAt, }) } return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Filter by zone UUID") - cmd.Flags().StringVar(&snapshotUUID, "uuid", "", "Filter by VM snapshot UUID") return cmd } func newVMSnapshotCreateCmd() *cobra.Command { - var zoneUUID, name, instanceUUID, description string + var vmSlug, name, plan, billingCycle, project, cloudProvider, region, service string var memory bool - var wait bool + var coupon string cmd := &cobra.Command{ Use: "create", Short: "Create a VM snapshot", - Example: ` zcp vm-snapshot create --zone --name my-snap --instance - zcp vm-snapshot create --zone --name my-snap --instance --description "pre-upgrade" --memory`, + Example: ` zcp vm-snapshot create --vm my-vm --name my-snap --plan basic --billing-cycle monthly --project proj-1 --cloud-provider cp-1 --region rgn-1 --service svc-1 + zcp vm-snapshot create --vm my-vm --name my-snap --plan basic --billing-cycle monthly --project proj-1 --cloud-provider cp-1 --region rgn-1 --service svc-1 --memory`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - if instanceUUID == "" { - return fmt.Errorf("--instance is required") + if vmSlug == "" { + return fmt.Errorf("--vm is required") } - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := vmsnapshot.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() req := vmsnapshot.CreateRequest{ - Name: name, - ZoneUUID: zoneUUID, - VirtualMachineUUID: instanceUUID, - Description: description, - SnapshotMemory: memory, - } - snap, err := svc.Create(ctx, req) + Name: name, + BillingCycle: billingCycle, + Plan: plan, + IsMemory: memory, + IsVMSnapshot: true, + Project: project, + CloudProvider: cloudProvider, + Region: region, + Service: service, + } + if coupon != "" { + req.Coupon = &coupon + } + resp, err := svc.Create(ctx, vmSlug, req) if err != nil { return fmt.Errorf("vm-snapshot create: %w", err) } - if wait && snap.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", snap.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, snap.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "CURRENT", "ZONE", "JOB ID", "CREATED"} - rows := [][]string{{ - snap.UUID, - snap.Name, - snap.Status, - strconv.FormatBool(snap.IsCurrent), - snap.ZoneUUID, - snap.JobID, - snap.CreatedAt, - }} - return printer.PrintTable(headers, rows) + printer.Fprintf("VM snapshot created (status: %s, message: %s)\n", resp.Status, resp.Message) + return nil }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") + cmd.Flags().StringVar(&vmSlug, "vm", "", "VM slug to snapshot (required)") cmd.Flags().StringVar(&name, "name", "", "Snapshot name (required)") - cmd.Flags().StringVar(&instanceUUID, "instance", "", "VM instance UUID (required)") - cmd.Flags().StringVar(&description, "description", "", "Optional description") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug") + cmd.Flags().StringVar(&project, "project", "", "Project slug") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug") + cmd.Flags().StringVar(®ion, "region", "", "Region slug") + cmd.Flags().StringVar(&service, "service", "", "Service slug") cmd.Flags().BoolVar(&memory, "memory", false, "Include memory state in snapshot") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") + cmd.Flags().StringVar(&coupon, "coupon", "", "Optional coupon code") return cmd } @@ -144,15 +127,15 @@ func newVMSnapshotDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a VM snapshot permanently", Args: cobra.ExactArgs(1), - Example: ` zcp vm-snapshot delete - zcp vm-snapshot delete --yes`, + Example: ` zcp vm-snapshot delete + zcp vm-snapshot delete --yes`, RunE: func(cmd *cobra.Command, args []string) error { - uuid := args[0] - if !yes { - fmt.Fprintf(os.Stdout, "Are you sure you want to delete %q? This cannot be undone. [y/N]: ", uuid) + slug := args[0] + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "Are you sure you want to delete %q? This cannot be undone. [y/N]: ", slug) var answer string fmt.Scanln(&answer) if strings.ToLower(strings.TrimSpace(answer)) != "y" { @@ -168,12 +151,11 @@ func newVMSnapshotDeleteCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - resp, err := svc.Delete(ctx, uuid) - if err != nil { + if err := svc.Delete(ctx, slug); err != nil { return fmt.Errorf("vm-snapshot delete: %w", err) } - printer.Fprintf("VM snapshot %q deleted (status: %s)\n", resp.UUID, resp.Status) + printer.Fprintf("VM snapshot %q deleted.\n", slug) return nil }, } @@ -183,18 +165,17 @@ func newVMSnapshotDeleteCmd() *cobra.Command { func newVMSnapshotRevertCmd() *cobra.Command { var yes bool - var wait bool cmd := &cobra.Command{ - Use: "revert ", + Use: "revert ", Short: "Revert a VM to a snapshot state (DESTRUCTIVE)", Args: cobra.ExactArgs(1), - Example: ` zcp vm-snapshot revert - zcp vm-snapshot revert --yes`, + Example: ` zcp vm-snapshot revert + zcp vm-snapshot revert --yes`, RunE: func(cmd *cobra.Command, args []string) error { - uuid := args[0] - if !yes { - fmt.Fprintf(os.Stdout, "WARNING: Reverting to snapshot %q will discard all VM state since the snapshot was taken. This cannot be undone. [y/N]: ", uuid) + slug := args[0] + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stdout, "WARNING: Reverting to snapshot %q will discard all VM state since the snapshot was taken. This cannot be undone. [y/N]: ", slug) var answer string fmt.Scanln(&answer) if strings.ToLower(strings.TrimSpace(answer)) != "y" { @@ -210,32 +191,15 @@ func newVMSnapshotRevertCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - snap, err := svc.Revert(ctx, uuid) + resp, err := svc.Revert(ctx, slug) if err != nil { return fmt.Errorf("vm-snapshot revert: %w", err) } - if wait && snap.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", snap.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, snap.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "CURRENT", "ZONE", "JOB ID"} - rows := [][]string{{ - snap.UUID, - snap.Name, - snap.Status, - strconv.FormatBool(snap.IsCurrent), - snap.ZoneUUID, - snap.JobID, - }} - return printer.PrintTable(headers, rows) + printer.Fprintf("VM snapshot %q reverted (status: %s, message: %s)\n", slug, resp.Status, resp.Message) + return nil }, } cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") return cmd } diff --git a/internal/commands/volume.go b/internal/commands/volume.go index 9e7541d..886c3bf 100644 --- a/internal/commands/volume.go +++ b/internal/commands/volume.go @@ -3,171 +3,167 @@ package commands import ( "context" "fmt" - "os" - "strings" "time" "github.com/spf13/cobra" "github.com/zsoftly/zcp-cli/internal/api/volume" - "github.com/zsoftly/zcp-cli/internal/api/waiters" ) // NewVolumeCmd returns the 'volume' cobra command. func NewVolumeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "volume", - Short: "Manage data volumes", + Short: "Manage block storage volumes", } cmd.AddCommand(newVolumeListCmd()) cmd.AddCommand(newVolumeCreateCmd()) cmd.AddCommand(newVolumeAttachCmd()) cmd.AddCommand(newVolumeDetachCmd()) - cmd.AddCommand(newVolumeDeleteCmd()) - cmd.AddCommand(newVolumeResizeCmd()) return cmd } func newVolumeListCmd() *cobra.Command { - var zoneUUID, instanceUUID, volumeUUID string - cmd := &cobra.Command{ Use: "list", - Short: "List data volumes", - Example: ` zcp volume list --zone - zcp volume list --zone --instance - zcp volume list --zone --output json`, + Short: "List block storage volumes", + Example: ` zcp volume list + zcp volume list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := volume.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - allVolumes, err := svc.List(ctx, zoneUUID, instanceUUID, volumeUUID) + volumes, err := svc.List(ctx) if err != nil { return fmt.Errorf("volume list: %w", err) } - // Deduplicate — Kong API may return duplicate entries - seen := make(map[string]bool) - var volumes []volume.Volume - for _, v := range allVolumes { - if !seen[v.UUID] { - seen[v.UUID] = true - volumes = append(volumes, v) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "SIZE", "TYPE", "INSTANCE", "ZONE"} + headers := []string{"SLUG", "NAME", "SIZE", "TYPE", "REGION", "STORAGE", "CREATED"} rows := make([][]string, 0, len(volumes)) for _, v := range volumes { + regionName := "" + if v.Region != nil { + regionName = v.Region.Name + } + storageName := "" + if v.StorageSetting != nil { + storageName = v.StorageSetting.Name + } rows = append(rows, []string{ - v.UUID, + v.Slug, v.Name, - v.Status, - v.StorageDiskSize, + v.Size, v.VolumeType, - v.VMInstanceName, - v.ZoneUUID, + regionName, + storageName, + v.CreatedAt, }) } return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&instanceUUID, "instance", "", "Filter by instance UUID") - cmd.Flags().StringVar(&volumeUUID, "uuid", "", "Filter by volume UUID") return cmd } func newVolumeCreateCmd() *cobra.Command { - var zoneUUID, name, storageOfferingUUID string - var diskSize int - var wait bool + var name, project, cloudProvider, region, billingCycle, storageCategory, plan string + var vmSlug, coupon string + var isCustomPlan, isFreeTrial bool cmd := &cobra.Command{ Use: "create", - Short: "Create a new data volume", - Example: ` zcp volume create --zone --name my-disk --storage-offering - zcp volume create --zone --name my-disk --storage-offering --disk-size 100`, + Short: "Create a new block storage volume", + Example: ` zcp volume create --name my-disk --project default-73 --cloud-provider nimbo --region noida --billing-cycle hourly --storage-category nvme --plan 50-gb-2 + zcp volume create --name my-disk --project default-73 --cloud-provider nimbo --region noida --billing-cycle hourly --storage-category nvme --plan 50-gb-2 --vm vm-slug`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - if storageOfferingUUID == "" { - return fmt.Errorf("--storage-offering is required") + if project == "" { + return fmt.Errorf("--project is required") + } + if cloudProvider == "" { + return fmt.Errorf("--cloud-provider is required") + } + if region == "" { + return fmt.Errorf("--region is required") } - profile, client, printer, err := buildClientAndPrinter(cmd) + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + if storageCategory == "" { + return fmt.Errorf("--storage-category is required") + } + if plan == "" { + return fmt.Errorf("--plan is required") + } + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := volume.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() req := volume.CreateRequest{ - Name: name, - ZoneUUID: zoneUUID, - StorageOfferingUUID: storageOfferingUUID, - DiskSize: diskSize, + Name: name, + Project: project, + CloudProvider: cloudProvider, + Region: region, + BillingCycle: billingCycle, + StorageCategory: storageCategory, + Plan: plan, + IsCustomPlan: isCustomPlan, + VirtualMachine: vmSlug, + Coupon: coupon, + IsFreeTrial: isFreeTrial, } vol, err := svc.Create(ctx, req) if err != nil { return fmt.Errorf("volume create: %w", err) } - if wait && vol.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", vol.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, vol.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "SIZE", "TYPE", "ZONE", "JOB ID"} + headers := []string{"SLUG", "NAME", "SIZE", "TYPE", "CREATED"} rows := [][]string{{ - vol.UUID, + vol.Slug, vol.Name, - vol.Status, - vol.StorageDiskSize, + vol.Size, vol.VolumeType, - vol.ZoneUUID, - vol.JobID, + vol.CreatedAt, }} return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") cmd.Flags().StringVar(&name, "name", "", "Volume name (required)") - cmd.Flags().StringVar(&storageOfferingUUID, "storage-offering", "", "Storage offering UUID (required)") - cmd.Flags().IntVar(&diskSize, "disk-size", 0, "Custom disk size in GB (for custom offerings)") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") + cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(®ion, "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. nvme (required)") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug, e.g. 50-gb-2 (required)") + cmd.Flags().BoolVar(&isCustomPlan, "custom-plan", false, "Use a custom 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") return cmd } func newVolumeAttachCmd() *cobra.Command { - var instanceUUID string - var wait bool + var vmSlug string cmd := &cobra.Command{ - Use: "attach ", - Short: "Attach a volume to an instance", + Use: "attach ", + Short: "Attach a volume to a virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp volume attach --instance `, + Example: ` zcp volume attach --vm `, RunE: func(cmd *cobra.Command, args []string) error { - volumeUUID := args[0] - if instanceUUID == "" { - return fmt.Errorf("--instance is required") + volumeSlug := args[0] + if vmSlug == "" { + return fmt.Errorf("--vm is required") } _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { @@ -177,45 +173,33 @@ func newVolumeAttachCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - vol, err := svc.Attach(ctx, volumeUUID, instanceUUID) + vol, err := svc.Attach(ctx, volumeSlug, vmSlug) if err != nil { return fmt.Errorf("volume attach: %w", err) } - if wait && vol.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", vol.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, vol.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "INSTANCE", "ZONE"} + headers := []string{"SLUG", "NAME", "SIZE", "VM ID"} rows := [][]string{{ - vol.UUID, + vol.Slug, vol.Name, - vol.Status, - vol.VMInstanceName, - vol.ZoneUUID, + vol.Size, + vol.VirtualMachineID, }} return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&instanceUUID, "instance", "", "Instance UUID to attach to (required)") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") + cmd.Flags().StringVar(&vmSlug, "vm", "", "Virtual machine slug to attach to (required)") return cmd } func newVolumeDetachCmd() *cobra.Command { - var wait bool - cmd := &cobra.Command{ - Use: "detach ", - Short: "Detach a volume from its instance", + Use: "detach ", + Short: "Detach a volume from its virtual machine", Args: cobra.ExactArgs(1), - Example: ` zcp volume detach `, + Example: ` zcp volume detach `, RunE: func(cmd *cobra.Command, args []string) error { - volumeUUID := args[0] + volumeSlug := args[0] _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -224,141 +208,19 @@ func newVolumeDetachCmd() *cobra.Command { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - vol, err := svc.Detach(ctx, volumeUUID) + vol, err := svc.Detach(ctx, volumeSlug) if err != nil { return fmt.Errorf("volume detach: %w", err) } - if wait && vol.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", vol.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, vol.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "ZONE"} - rows := [][]string{{ - vol.UUID, - vol.Name, - vol.Status, - vol.ZoneUUID, - }} - return printer.PrintTable(headers, rows) - }, - } - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") - return cmd -} - -func newVolumeDeleteCmd() *cobra.Command { - var yes bool - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a volume permanently", - Args: cobra.ExactArgs(1), - Example: ` zcp volume delete - zcp volume delete --yes`, - RunE: func(cmd *cobra.Command, args []string) error { - uuid := args[0] - if !yes { - fmt.Fprintf(os.Stdout, "Are you sure you want to delete %q? This cannot be undone. [y/N]: ", uuid) - var answer string - fmt.Scanln(&answer) - if strings.ToLower(strings.TrimSpace(answer)) != "y" { - fmt.Fprintln(os.Stdout, "Aborted.") - return nil - } - } - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - svc := volume.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - resp, err := svc.Delete(ctx, uuid) - if err != nil { - return fmt.Errorf("volume delete: %w", err) - } - - // Verify deletion — Kong may return 204 even when delete silently fails - time.Sleep(2 * time.Second) - profile, _, _, _ := buildClientAndPrinter(cmd) - zoneUUID := resolveZone(profile, "") - if zoneUUID != "" { - vols, _ := svc.List(ctx, zoneUUID, "", uuid) - if len(vols) > 0 { - fmt.Fprintln(os.Stderr, "WARNING: volume may not have been deleted (e.g. still attached to a VM).") - return fmt.Errorf("volume %q still exists after delete — check dependencies", uuid) - } - } - - printer.Fprintf("Volume %q deleted (status: %s)\n", resp.UUID, resp.Status) - return nil - }, - } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") - return cmd -} - -func newVolumeResizeCmd() *cobra.Command { - var storageOfferingUUID string - var diskSize int - var shrink bool - var wait bool - - cmd := &cobra.Command{ - Use: "resize ", - Short: "Resize a volume (change offering or disk size)", - Args: cobra.ExactArgs(1), - Example: ` zcp volume resize --storage-offering - zcp volume resize --storage-offering --disk-size 200 - zcp volume resize --storage-offering --disk-size 50 --shrink`, - RunE: func(cmd *cobra.Command, args []string) error { - uuid := args[0] - if storageOfferingUUID == "" { - return fmt.Errorf("--storage-offering is required") - } - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - svc := volume.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - vol, err := svc.Resize(ctx, uuid, storageOfferingUUID, diskSize, shrink) - if err != nil { - return fmt.Errorf("volume resize: %w", err) - } - - if wait && vol.JobID != "" { - fmt.Fprintf(os.Stderr, "Waiting for job %s to complete...\n", vol.JobID) - waiter := waiters.New(client, waiters.WithProgressWriter(os.Stderr)) - if _, err := waiter.Wait(ctx, vol.JobID); err != nil { - return fmt.Errorf("wait failed: %w", err) - } - } - - headers := []string{"UUID", "NAME", "STATUS", "SIZE", "OFFERING", "ZONE", "JOB ID"} + headers := []string{"SLUG", "NAME", "SIZE"} rows := [][]string{{ - vol.UUID, + vol.Slug, vol.Name, - vol.Status, - vol.StorageDiskSize, - vol.StorageOfferingName, - vol.ZoneUUID, - vol.JobID, + vol.Size, }} return printer.PrintTable(headers, rows) }, } - cmd.Flags().StringVar(&storageOfferingUUID, "storage-offering", "", "Storage offering UUID (required)") - cmd.Flags().IntVar(&diskSize, "disk-size", 0, "New disk size in GB") - cmd.Flags().BoolVar(&shrink, "shrink", false, "Allow shrinking the volume (use with caution)") - cmd.Flags().BoolVar(&wait, "wait", false, "Wait for async operation to complete") return cmd } diff --git a/internal/commands/vpc.go b/internal/commands/vpc.go index 34577b9..c138673 100644 --- a/internal/commands/vpc.go +++ b/internal/commands/vpc.go @@ -24,55 +24,54 @@ func NewVPCCmd() *cobra.Command { cmd.AddCommand(newVPCUpdateCmd()) cmd.AddCommand(newVPCDeleteCmd()) cmd.AddCommand(newVPCRestartCmd()) - cmd.AddCommand(newVPCCreateNetworkCmd()) - cmd.AddCommand(newVPCUpdateNetworkCmd()) + cmd.AddCommand(newVPCACLListCmd()) + cmd.AddCommand(newVPCACLCreateRuleCmd()) + cmd.AddCommand(newVPCACLReplaceCmd()) + cmd.AddCommand(newVPCVPNGatewayCmd()) return cmd } func newVPCListCmd() *cobra.Command { - var zoneUUID string + var zoneSlug string cmd := &cobra.Command{ Use: "list", - Short: "List VPCs in a zone", - Example: ` zcp vpc list --zone - zcp vpc list --zone --output json`, + Short: "List VPCs", + Example: ` zcp vpc list + zcp vpc list --zone + zcp vpc list --output json`, RunE: func(cmd *cobra.Command, args []string) error { - return runVPCList(cmd, zoneUUID) + return runVPCList(cmd, zoneSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") + cmd.Flags().StringVar(&zoneSlug, "zone", "", "Filter by zone slug") return cmd } -func runVPCList(cmd *cobra.Command, zoneUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) +func runVPCList(cmd *cobra.Command, zoneSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } svc := vpc.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - vpcs, err := svc.List(ctx, zoneUUID, "") + vpcs, err := svc.List(ctx, zoneSlug) if err != nil { return fmt.Errorf("vpc list: %w", err) } - headers := []string{"UUID", "NAME", "CIDR", "STATUS", "ZONE"} + headers := []string{"SLUG", "NAME", "CIDR", "STATUS", "ZONE"} rows := make([][]string, 0, len(vpcs)) for _, v := range vpcs { rows = append(rows, []string{ - v.UUID, + v.Slug, v.Name, v.CIDR, v.Status, - v.ZoneUUID, + v.ZoneName, }) } return printer.PrintTable(headers, rows) @@ -80,10 +79,10 @@ func runVPCList(cmd *cobra.Command, zoneUUID string) error { func newVPCGetCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "get ", + Use: "get ", Short: "Get details of a VPC", Args: cobra.ExactArgs(1), - Example: ` zcp vpc get `, + Example: ` zcp vpc get `, RunE: func(cmd *cobra.Command, args []string) error { return runVPCGet(cmd, args[0]) }, @@ -91,7 +90,7 @@ func newVPCGetCmd() *cobra.Command { return cmd } -func runVPCGet(cmd *cobra.Command, uuid string) error { +func runVPCGet(cmd *cobra.Command, slug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -101,19 +100,18 @@ func runVPCGet(cmd *cobra.Command, uuid string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - v, err := svc.Get(ctx, uuid) + v, err := svc.Get(ctx, slug) if err != nil { return fmt.Errorf("vpc get: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", v.UUID}, + {"Slug", v.Slug}, {"Name", v.Name}, {"Description", v.Description}, {"CIDR", v.CIDR}, {"Status", v.Status}, - {"Zone UUID", v.ZoneUUID}, {"Zone Name", v.ZoneName}, {"Domain Name", v.DomainName}, } @@ -121,18 +119,18 @@ func runVPCGet(cmd *cobra.Command, uuid string) error { } func newVPCCreateCmd() *cobra.Command { - var zoneUUID, name, offeringUUID, cidr, description, networkDomain, lbProvider string + var zoneSlug, name, offeringSlug, cidr, description, networkDomain, lbProvider string cmd := &cobra.Command{ Use: "create", Short: "Create a new VPC", - Example: ` zcp vpc create --zone --name my-vpc --offering --cidr 10.0.0.0/8 - zcp vpc create --zone --name my-vpc --offering --cidr 10.0.0.0/8 --description "Production VPC"`, + Example: ` zcp vpc create --zone --name my-vpc --offering --cidr 10.0.0.0/8 + zcp vpc create --zone --name my-vpc --offering --cidr 10.0.0.0/8 --description "Production VPC"`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - if offeringUUID == "" { + if offeringSlug == "" { return fmt.Errorf("--offering is required") } if cidr == "" { @@ -141,10 +139,13 @@ func newVPCCreateCmd() *cobra.Command { if !strings.Contains(cidr, "/") { return fmt.Errorf("--cidr must be a valid CIDR (e.g. 10.0.0.0/8)") } + if zoneSlug == "" { + return fmt.Errorf("--zone is required") + } return runVPCCreate(cmd, vpc.CreateRequest{ Name: name, - ZoneUUID: zoneUUID, - VPCOfferingUUID: offeringUUID, + ZoneSlug: zoneSlug, + VPCOfferingSlug: offeringSlug, CIDR: cidr, Description: description, NetworkDomain: networkDomain, @@ -152,9 +153,9 @@ func newVPCCreateCmd() *cobra.Command { }) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") + cmd.Flags().StringVar(&zoneSlug, "zone", "", "Zone slug (required)") cmd.Flags().StringVar(&name, "name", "", "VPC name (required)") - cmd.Flags().StringVar(&offeringUUID, "offering", "", "VPC offering UUID (required)") + cmd.Flags().StringVar(&offeringSlug, "offering", "", "VPC offering slug (required)") cmd.Flags().StringVar(&cidr, "cidr", "", "CIDR block (required, e.g. 10.0.0.0/8)") cmd.Flags().StringVar(&description, "description", "", "VPC description") cmd.Flags().StringVar(&networkDomain, "network-domain", "", "Network domain") @@ -163,14 +164,10 @@ func newVPCCreateCmd() *cobra.Command { } func runVPCCreate(cmd *cobra.Command, req vpc.CreateRequest) error { - profile, client, printer, err := buildClientAndPrinter(cmd) + _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err } - req.ZoneUUID = resolveZone(profile, req.ZoneUUID) - if req.ZoneUUID == "" { - return errNoZone() - } svc := vpc.NewService(client) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) @@ -183,11 +180,11 @@ func runVPCCreate(cmd *cobra.Command, req vpc.CreateRequest) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", v.UUID}, + {"Slug", v.Slug}, {"Name", v.Name}, {"CIDR", v.CIDR}, {"Status", v.Status}, - {"Zone UUID", v.ZoneUUID}, + {"Zone Name", v.ZoneName}, } return printer.PrintTable(headers, rows) } @@ -196,16 +193,15 @@ func newVPCUpdateCmd() *cobra.Command { var name, description string cmd := &cobra.Command{ - Use: "update ", + Use: "update ", Short: "Update a VPC", Args: cobra.ExactArgs(1), - Example: ` zcp vpc update --name new-name --description "Updated description"`, + Example: ` zcp vpc update --name new-name --description "Updated description"`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return fmt.Errorf("--name is required") } - return runVPCUpdate(cmd, vpc.UpdateRequest{ - UUID: args[0], + return runVPCUpdate(cmd, args[0], vpc.UpdateRequest{ Name: name, Description: description, }) @@ -216,7 +212,7 @@ func newVPCUpdateCmd() *cobra.Command { return cmd } -func runVPCUpdate(cmd *cobra.Command, req vpc.UpdateRequest) error { +func runVPCUpdate(cmd *cobra.Command, slug string, req vpc.UpdateRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -226,14 +222,14 @@ func runVPCUpdate(cmd *cobra.Command, req vpc.UpdateRequest) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - v, err := svc.Update(ctx, req) + v, err := svc.Update(ctx, slug, req) if err != nil { return fmt.Errorf("vpc update: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", v.UUID}, + {"Slug", v.Slug}, {"Name", v.Name}, {"Description", v.Description}, {"Status", v.Status}, @@ -245,11 +241,11 @@ func newVPCDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a VPC", Args: cobra.ExactArgs(1), - Example: ` zcp vpc delete - zcp vpc delete --yes`, + Example: ` zcp vpc delete + zcp vpc delete --yes`, RunE: func(cmd *cobra.Command, args []string) error { return runVPCDelete(cmd, args[0], yes) }, @@ -258,9 +254,9 @@ func newVPCDeleteCmd() *cobra.Command { return cmd } -func runVPCDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete VPC %q? This action cannot be undone. [y/N]: ", uuid) +func runVPCDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete VPC %q? This action cannot be undone. [y/N]: ", slug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -279,164 +275,282 @@ func runVPCDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { + if err := svc.Delete(ctx, slug); err != nil { return fmt.Errorf("vpc delete: %w", err) } - // Verify deletion — Kong may return 204 even when delete silently fails + // Verify deletion time.Sleep(2 * time.Second) - if _, err := svc.Get(ctx, uuid); err == nil { + if _, err := svc.Get(ctx, slug); err == nil { fmt.Fprintln(os.Stderr, "WARNING: VPC may not have been deleted (e.g. has active network tiers).") fmt.Fprintln(os.Stderr, " Delete all network tiers first, then retry.") - return fmt.Errorf("vpc %q still exists after delete — check dependencies", uuid) + return fmt.Errorf("vpc %q still exists after delete — check dependencies", slug) } - printer.Fprintf("VPC %q deleted.\n", uuid) + printer.Fprintf("VPC %q deleted.\n", slug) return nil } func newVPCRestartCmd() *cobra.Command { - var cleanUp, redundant bool + cmd := &cobra.Command{ + Use: "restart ", + Short: "Restart a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp vpc restart `, + RunE: func(cmd *cobra.Command, args []string) error { + return runVPCRestart(cmd, args[0]) + }, + } + return cmd +} + +func runVPCRestart(cmd *cobra.Command, slug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + v, err := svc.Restart(ctx, slug) + if err != nil { + return fmt.Errorf("vpc restart: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", v.Slug}, + {"Name", v.Name}, + {"Status", v.Status}, + } + return printer.PrintTable(headers, rows) +} + +// ─── VPC ACL subcommands ───────────────────────────────────────────────────── + +func newVPCACLListCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "restart ", - Short: "Restart a VPC", - Args: cobra.ExactArgs(1), - Example: ` zcp vpc restart - zcp vpc restart --cleanup --redundant`, + Use: "acl-list ", + Short: "List network ACLs for a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp vpc acl-list `, RunE: func(cmd *cobra.Command, args []string) error { - return runVPCRestart(cmd, args[0], cleanUp, redundant) + return runVPCACLList(cmd, args[0]) }, } - cmd.Flags().BoolVar(&cleanUp, "cleanup", false, "Clean up stale resources during restart") - cmd.Flags().BoolVar(&redundant, "redundant", false, "Enable redundant VPC router") return cmd } -func newVPCCreateNetworkCmd() *cobra.Command { - var zoneUUID, name, offeringUUID, vpcUUID, gateway, netmask, aclUUID string +func runVPCACLList(cmd *cobra.Command, vpcSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + acls, err := svc.ListACLs(ctx, vpcSlug) + if err != nil { + return fmt.Errorf("vpc acl-list: %w", err) + } + + headers := []string{"SLUG", "NAME", "DESCRIPTION", "STATUS"} + rows := make([][]string, 0, len(acls)) + for _, a := range acls { + rows = append(rows, []string{ + a.Slug, + a.Name, + a.Description, + a.Status, + }) + } + return printer.PrintTable(headers, rows) +} + +func newVPCACLCreateRuleCmd() *cobra.Command { + var protocol, cidrList, trafficType, action string + var startPort, endPort, number, icmpCode, icmpType int cmd := &cobra.Command{ - Use: "create-network", - Short: "Create a VPC tier network", - Example: ` zcp vpc create-network --zone --vpc --name my-tier --offering --gateway 10.1.1.1 --netmask 255.255.255.0 - zcp vpc create-network --vpc --name my-tier --offering --gateway 10.1.1.1 --netmask 255.255.255.0 --acl `, + Use: "acl-create-rule ", + Short: "Create a network ACL rule in a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp vpc acl-create-rule --protocol tcp --action allow --start-port 80 --end-port 80 --cidr 0.0.0.0/0 + zcp vpc acl-create-rule --protocol icmp --action deny --icmp-type 8 --icmp-code 0`, RunE: func(cmd *cobra.Command, args []string) error { - if name == "" { - return fmt.Errorf("--name is required") - } - if offeringUUID == "" { - return fmt.Errorf("--offering is required") - } - if vpcUUID == "" { - return fmt.Errorf("--vpc is required") - } - if gateway == "" { - return fmt.Errorf("--gateway is required") + if protocol == "" { + return fmt.Errorf("--protocol is required") } - if netmask == "" { - return fmt.Errorf("--netmask is required") + if action == "" { + return fmt.Errorf("--action is required") } + return runVPCACLCreateRule(cmd, args[0], vpc.ACLRuleCreateRequest{ + Protocol: protocol, + CIDRList: cidrList, + StartPort: startPort, + EndPort: endPort, + TrafficType: trafficType, + Action: action, + Number: number, + ICMPCode: icmpCode, + ICMPType: icmpType, + }) + }, + } + cmd.Flags().StringVar(&protocol, "protocol", "", "Protocol (tcp, udp, icmp, all) (required)") + cmd.Flags().StringVar(&cidrList, "cidr", "", "CIDR list (e.g. 0.0.0.0/0)") + cmd.Flags().IntVar(&startPort, "start-port", 0, "Start port") + cmd.Flags().IntVar(&endPort, "end-port", 0, "End port") + cmd.Flags().StringVar(&trafficType, "traffic-type", "", "Traffic type (ingress, egress)") + cmd.Flags().StringVar(&action, "action", "", "Action (allow, deny) (required)") + cmd.Flags().IntVar(&number, "number", 0, "Rule number (ordering)") + cmd.Flags().IntVar(&icmpCode, "icmp-code", 0, "ICMP code") + cmd.Flags().IntVar(&icmpType, "icmp-type", 0, "ICMP type") + return cmd +} - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } +func runVPCACLCreateRule(cmd *cobra.Command, vpcSlug string, req vpc.ACLRuleCreateRequest) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } - svc := vpc.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - net, err := svc.CreateNetwork(ctx, vpc.CreateNetworkRequest{ - Name: name, - ZoneUUID: zoneUUID, - NetworkOfferingUUID: offeringUUID, - VPCUUID: vpcUUID, - Gateway: gateway, - Netmask: netmask, - ACLUUID: aclUUID, - }) - if err != nil { - return fmt.Errorf("vpc create-network: %w", err) - } + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", net.UUID}, - {"Name", net.Name}, - {"CIDR", net.CIDR}, - {"Gateway", net.Gateway}, - {"Status", net.Status}, + rule, err := svc.CreateACLRule(ctx, vpcSlug, req) + if err != nil { + return fmt.Errorf("vpc acl-create-rule: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", rule.Slug}, + {"Protocol", rule.Protocol}, + {"Action", rule.Action}, + {"CIDR List", rule.CIDRList}, + {"Start Port", fmt.Sprintf("%d", rule.StartPort)}, + {"End Port", fmt.Sprintf("%d", rule.EndPort)}, + {"Traffic Type", rule.TrafficType}, + {"Number", fmt.Sprintf("%d", rule.Number)}, + } + return printer.PrintTable(headers, rows) +} + +func newVPCACLReplaceCmd() *cobra.Command { + var networkSlug, aclSlug string + + cmd := &cobra.Command{ + Use: "acl-replace", + Short: "Replace the ACL on a network", + Example: ` zcp vpc acl-replace --network --acl `, + RunE: func(cmd *cobra.Command, args []string) error { + if networkSlug == "" { + return fmt.Errorf("--network is required") + } + if aclSlug == "" { + return fmt.Errorf("--acl is required") } - return printer.PrintTable(headers, rows) + return runVPCACLReplace(cmd, networkSlug, aclSlug) }, } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&name, "name", "", "Network name (required)") - cmd.Flags().StringVar(&offeringUUID, "offering", "", "Network offering UUID (required)") - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "VPC UUID (required)") - cmd.Flags().StringVar(&gateway, "gateway", "", "Gateway IP (required, e.g. 10.1.1.1)") - cmd.Flags().StringVar(&netmask, "netmask", "", "Netmask (required, e.g. 255.255.255.0)") - cmd.Flags().StringVar(&aclUUID, "acl", "", "Network ACL UUID") + cmd.Flags().StringVar(&networkSlug, "network", "", "Network slug (required)") + cmd.Flags().StringVar(&aclSlug, "acl", "", "ACL slug (required)") return cmd } -func newVPCUpdateNetworkCmd() *cobra.Command { - var name, description, offeringUUID, networkDomain string +func runVPCACLReplace(cmd *cobra.Command, networkSlug, aclSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + req := map[string]string{"aclSlug": aclSlug} + if err := svc.ReplaceNetworkACL(ctx, networkSlug, req); err != nil { + return fmt.Errorf("vpc acl-replace: %w", err) + } + + printer.Fprintf("ACL replaced on network %q.\n", networkSlug) + return nil +} + +// ─── VPC VPN Gateway subcommands ───────────────────────────────────────────── +func newVPCVPNGatewayCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update-network ", - Short: "Update a VPC tier network", - Args: cobra.ExactArgs(1), - Example: ` zcp vpc update-network --offering --name new-name - zcp vpc update-network --offering --description "Updated tier"`, + Use: "vpn-gateway", + Short: "Manage VPN gateways for a VPC", + } + cmd.AddCommand(newVPCVPNGatewayListCmd()) + cmd.AddCommand(newVPCVPNGatewayCreateCmd()) + cmd.AddCommand(newVPCVPNGatewayDeleteCmd()) + return cmd +} + +func newVPCVPNGatewayListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List VPN gateways for a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp vpc vpn-gateway list `, RunE: func(cmd *cobra.Command, args []string) error { - if offeringUUID == "" { - return fmt.Errorf("--offering is required") - } + return runVPCVPNGatewayList(cmd, args[0]) + }, + } + return cmd +} - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } +func runVPCVPNGatewayList(cmd *cobra.Command, vpcSlug string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } - svc := vpc.NewService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() - net, err := svc.UpdateNetwork(ctx, vpc.UpdateNetworkRequest{ - UUID: args[0], - Name: name, - Description: description, - NetworkOfferingUUID: offeringUUID, - NetworkDomain: networkDomain, - }) - if err != nil { - return fmt.Errorf("vpc update-network: %w", err) - } + gateways, err := svc.ListVPNGateways(ctx, vpcSlug) + if err != nil { + return fmt.Errorf("vpc vpn-gateway list: %w", err) + } - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", net.UUID}, - {"Name", net.Name}, - {"CIDR", net.CIDR}, - {"Status", net.Status}, - } - return printer.PrintTable(headers, rows) + headers := []string{"SLUG", "PUBLIC IP", "VPC SLUG", "STATUS"} + rows := make([][]string, 0, len(gateways)) + for _, g := range gateways { + rows = append(rows, []string{ + g.Slug, + g.PublicIP, + g.VPCSlug, + g.Status, + }) + } + return printer.PrintTable(headers, rows) +} + +func newVPCVPNGatewayCreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a VPN gateway for a VPC", + Args: cobra.ExactArgs(1), + Example: ` zcp vpc vpn-gateway create `, + RunE: func(cmd *cobra.Command, args []string) error { + return runVPCVPNGatewayCreate(cmd, args[0]) }, } - cmd.Flags().StringVar(&name, "name", "", "New network name") - cmd.Flags().StringVar(&description, "description", "", "New description") - cmd.Flags().StringVar(&offeringUUID, "offering", "", "Network offering UUID (required)") - cmd.Flags().StringVar(&networkDomain, "network-domain", "", "Network domain") return cmd } -func runVPCRestart(cmd *cobra.Command, uuid string, cleanUp, redundant bool) error { +func runVPCVPNGatewayCreate(cmd *cobra.Command, vpcSlug string) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -446,16 +560,64 @@ func runVPCRestart(cmd *cobra.Command, uuid string, cleanUp, redundant bool) err ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - v, err := svc.Restart(ctx, uuid, cleanUp, redundant) + g, err := svc.CreateVPNGateway(ctx, vpcSlug) if err != nil { - return fmt.Errorf("vpc restart: %w", err) + return fmt.Errorf("vpc vpn-gateway create: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", v.UUID}, - {"Name", v.Name}, - {"Status", v.Status}, + {"Slug", g.Slug}, + {"Public IP", g.PublicIP}, + {"VPC Slug", g.VPCSlug}, + {"Zone Name", g.ZoneName}, + {"Status", g.Status}, } return printer.PrintTable(headers, rows) } + +func newVPCVPNGatewayDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a VPN gateway from a VPC", + Args: cobra.ExactArgs(2), + Example: ` zcp vpc vpn-gateway delete + zcp vpc vpn-gateway delete --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + return runVPCVPNGatewayDelete(cmd, args[0], args[1], yes) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") + return cmd +} + +func runVPCVPNGatewayDelete(cmd *cobra.Command, vpcSlug, gatewayID string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete VPN gateway %q from VPC %q? This action cannot be undone. [y/N]: ", gatewayID, vpcSlug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + + svc := vpc.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteVPNGateway(ctx, vpcSlug, gatewayID); err != nil { + return fmt.Errorf("vpc vpn-gateway delete: %w", err) + } + + printer.Fprintf("VPN gateway %q deleted from VPC %q.\n", gatewayID, vpcSlug) + return nil +} diff --git a/internal/commands/vpn.go b/internal/commands/vpn.go index cac4c4e..9fee92a 100644 --- a/internal/commands/vpn.go +++ b/internal/commands/vpn.go @@ -18,167 +18,13 @@ import ( func NewVPNCmd() *cobra.Command { cmd := &cobra.Command{ Use: "vpn", - Short: "Manage VPN gateways, connections, and users", + Short: "Manage VPN users and customer gateways", } - cmd.AddCommand(newVPNGatewayCmd()) cmd.AddCommand(newVPNCustomerGatewayCmd()) - cmd.AddCommand(newVPNConnectionCmd()) cmd.AddCommand(newVPNUserCmd()) return cmd } -// ─── VPN Gateway ────────────────────────────────────────────────────────────── - -func newVPNGatewayCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "gateway", - Short: "Manage VPN gateways", - } - cmd.AddCommand(newVPNGatewayListCmd()) - cmd.AddCommand(newVPNGatewayCreateCmd()) - cmd.AddCommand(newVPNGatewayDeleteCmd()) - return cmd -} - -func newVPNGatewayListCmd() *cobra.Command { - var zoneUUID, vpcUUID string - - cmd := &cobra.Command{ - Use: "list", - Short: "List VPN gateways in a zone", - Example: ` zcp vpn gateway list --zone - zcp vpn gateway list --zone --vpc `, - RunE: func(cmd *cobra.Command, args []string) error { - return runVPNGatewayList(cmd, zoneUUID, vpcUUID) - }, - } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "Filter by VPC UUID") - return cmd -} - -func runVPNGatewayList(cmd *cobra.Command, zoneUUID, vpcUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } - - svc := vpn.NewGatewayService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - gateways, err := svc.List(ctx, zoneUUID, "", vpcUUID) - if err != nil { - return fmt.Errorf("vpn gateway list: %w", err) - } - - headers := []string{"UUID", "PUBLIC IP", "VPC", "STATUS"} - rows := make([][]string, 0, len(gateways)) - for _, g := range gateways { - rows = append(rows, []string{ - g.UUID, - g.PublicIP, - g.VPCUUID, - g.Status, - }) - } - return printer.PrintTable(headers, rows) -} - -func newVPNGatewayCreateCmd() *cobra.Command { - var vpcUUID string - - cmd := &cobra.Command{ - Use: "create", - Short: "Create a VPN gateway for a VPC", - Example: ` zcp vpn gateway create --vpc `, - RunE: func(cmd *cobra.Command, args []string) error { - if vpcUUID == "" { - return fmt.Errorf("--vpc is required") - } - return runVPNGatewayCreate(cmd, vpcUUID) - }, - } - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "VPC UUID (required)") - return cmd -} - -func runVPNGatewayCreate(cmd *cobra.Command, vpcUUID string) error { - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := vpn.NewGatewayService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - g, err := svc.Create(ctx, vpcUUID) - if err != nil { - return fmt.Errorf("vpn gateway create: %w", err) - } - - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", g.UUID}, - {"Public IP", g.PublicIP}, - {"VPC UUID", g.VPCUUID}, - {"Zone UUID", g.ZoneUUID}, - {"Status", g.Status}, - } - return printer.PrintTable(headers, rows) -} - -func newVPNGatewayDeleteCmd() *cobra.Command { - var yes bool - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a VPN gateway", - Args: cobra.ExactArgs(1), - Example: ` zcp vpn gateway delete - zcp vpn gateway delete --yes`, - RunE: func(cmd *cobra.Command, args []string) error { - return runVPNGatewayDelete(cmd, args[0], yes) - }, - } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") - return cmd -} - -func runVPNGatewayDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete VPN gateway %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } - } - - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := vpn.NewGatewayService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("vpn gateway delete: %w", err) - } - - printer.Fprintf("VPN gateway %q deleted.\n", uuid) - return nil -} - // ─── VPN Customer Gateway ───────────────────────────────────────────────────── func newVPNCustomerGatewayCmd() *cobra.Command { @@ -215,18 +61,19 @@ func runVPNCGList(cmd *cobra.Command) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cgs, err := svc.List(ctx, "") + cgs, err := svc.List(ctx) if err != nil { return fmt.Errorf("vpn customer-gateway list: %w", err) } - headers := []string{"UUID", "IKE POLICY", "ESP LIFETIME", "CIDR"} + headers := []string{"SLUG", "NAME", "GATEWAY", "IKE POLICY", "CIDR"} rows := make([][]string, 0, len(cgs)) for _, cg := range cgs { rows = append(rows, []string{ - cg.UUID, + cg.Slug, + cg.Name, + cg.Gateway, cg.IKEPolicy, - cg.ESPLifetime, cg.CIDRList, }) } @@ -329,7 +176,9 @@ func runVPNCGCreate(cmd *cobra.Command, req vpn.CustomerGatewayRequest) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", cg.UUID}, + {"Slug", cg.Slug}, + {"Name", cg.Name}, + {"Gateway", cg.Gateway}, {"IKE Policy", cg.IKEPolicy}, {"IKE Lifetime", cg.IKELifetime}, {"ESP Lifetime", cg.ESPLifetime}, @@ -349,31 +198,28 @@ func newVPNCGUpdateCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "update ", + Use: "update ", Short: "Update a VPN customer gateway", Args: cobra.ExactArgs(1), - Example: ` zcp vpn customer-gateway update --name new-name --psk newkey`, + Example: ` zcp vpn customer-gateway update --name new-name --psk newkey`, RunE: func(cmd *cobra.Command, args []string) error { - return runVPNCGUpdate(cmd, vpn.CustomerGatewayUpdateRequest{ - UUID: args[0], - CustomerGatewayRequest: vpn.CustomerGatewayRequest{ - Name: name, - Gateway: gateway, - CIDRList: cidr, - IPSecPSK: psk, - IKEPolicy: ikePolicy, - ESPPolicy: espPolicy, - IKELifetime: ikeLifetime, - ESPLifetime: espLifetime, - IKEEncryption: ikeEncryption, - IKEHash: ikeHash, - IKEVersion: ikeVersion, - ESPEncryption: espEncryption, - ESPHash: espHash, - ForceEncap: forceEncap, - SplitConnection: splitConnection, - DPD: dpd, - }, + return runVPNCGUpdate(cmd, args[0], vpn.CustomerGatewayRequest{ + Name: name, + Gateway: gateway, + CIDRList: cidr, + IPSecPSK: psk, + IKEPolicy: ikePolicy, + ESPPolicy: espPolicy, + IKELifetime: ikeLifetime, + ESPLifetime: espLifetime, + IKEEncryption: ikeEncryption, + IKEHash: ikeHash, + IKEVersion: ikeVersion, + ESPEncryption: espEncryption, + ESPHash: espHash, + ForceEncap: forceEncap, + SplitConnection: splitConnection, + DPD: dpd, }) }, } @@ -383,7 +229,7 @@ func newVPNCGUpdateCmd() *cobra.Command { return cmd } -func runVPNCGUpdate(cmd *cobra.Command, req vpn.CustomerGatewayUpdateRequest) error { +func runVPNCGUpdate(cmd *cobra.Command, slug string, req vpn.CustomerGatewayRequest) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -393,14 +239,16 @@ func runVPNCGUpdate(cmd *cobra.Command, req vpn.CustomerGatewayUpdateRequest) er ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - cg, err := svc.Update(ctx, req) + cg, err := svc.Update(ctx, slug, req) if err != nil { return fmt.Errorf("vpn customer-gateway update: %w", err) } headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", cg.UUID}, + {"Slug", cg.Slug}, + {"Name", cg.Name}, + {"Gateway", cg.Gateway}, {"IKE Policy", cg.IKEPolicy}, {"IKE Lifetime", cg.IKELifetime}, {"ESP Lifetime", cg.ESPLifetime}, @@ -413,11 +261,11 @@ func newVPNCGDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a VPN customer gateway", Args: cobra.ExactArgs(1), - Example: ` zcp vpn customer-gateway delete - zcp vpn customer-gateway delete --yes`, + Example: ` zcp vpn customer-gateway delete + zcp vpn customer-gateway delete --yes`, RunE: func(cmd *cobra.Command, args []string) error { return runVPNCGDelete(cmd, args[0], yes) }, @@ -426,9 +274,9 @@ func newVPNCGDeleteCmd() *cobra.Command { return cmd } -func runVPNCGDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete VPN customer gateway %q? This action cannot be undone. [y/N]: ", uuid) +func runVPNCGDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete VPN customer gateway %q? This action cannot be undone. [y/N]: ", slug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -447,215 +295,11 @@ func runVPNCGDelete(cmd *cobra.Command, uuid string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, uuid); err != nil { + if err := svc.Delete(ctx, slug); err != nil { return fmt.Errorf("vpn customer-gateway delete: %w", err) } - printer.Fprintf("VPN customer gateway %q deleted.\n", uuid) - return nil -} - -// ─── VPN Connection ─────────────────────────────────────────────────────────── - -func newVPNConnectionCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "connection", - Short: "Manage VPN connections", - } - cmd.AddCommand(newVPNConnListCmd()) - cmd.AddCommand(newVPNConnCreateCmd()) - cmd.AddCommand(newVPNConnResetCmd()) - cmd.AddCommand(newVPNConnDeleteCmd()) - return cmd -} - -func newVPNConnListCmd() *cobra.Command { - var zoneUUID, vpcUUID string - - cmd := &cobra.Command{ - Use: "list", - Short: "List VPN connections in a zone", - Example: ` zcp vpn connection list --zone - zcp vpn connection list --zone --vpc `, - RunE: func(cmd *cobra.Command, args []string) error { - return runVPNConnList(cmd, zoneUUID, vpcUUID) - }, - } - cmd.Flags().StringVar(&zoneUUID, "zone", "", "Zone UUID (overrides default zone)") - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "Filter by VPC UUID") - return cmd -} - -func runVPNConnList(cmd *cobra.Command, zoneUUID, vpcUUID string) error { - profile, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - zoneUUID = resolveZone(profile, zoneUUID) - if zoneUUID == "" { - return errNoZone() - } - - svc := vpn.NewConnectionService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - conns, err := svc.List(ctx, zoneUUID, "", vpcUUID) - if err != nil { - return fmt.Errorf("vpn connection list: %w", err) - } - - headers := []string{"UUID", "STATE", "IKE POLICY", "CUSTOMER GW", "VPN GW"} - rows := make([][]string, 0, len(conns)) - for _, c := range conns { - rows = append(rows, []string{ - c.UUID, - c.State, - c.IKEPolicy, - c.CustomerGatewayUUID, - c.VPNGatewayUUID, - }) - } - return printer.PrintTable(headers, rows) -} - -func newVPNConnCreateCmd() *cobra.Command { - var vpcUUID, customerGatewayUUID string - var passive bool - - cmd := &cobra.Command{ - Use: "create", - Short: "Create a VPN connection", - Example: ` zcp vpn connection create --vpc --customer-gateway - zcp vpn connection create --vpc --customer-gateway --passive`, - RunE: func(cmd *cobra.Command, args []string) error { - if vpcUUID == "" { - return fmt.Errorf("--vpc is required") - } - if customerGatewayUUID == "" { - return fmt.Errorf("--customer-gateway is required") - } - return runVPNConnCreate(cmd, vpn.ConnectionCreateRequest{ - VPCUUID: vpcUUID, - CustomerGatewayUUID: customerGatewayUUID, - Passive: passive, - }) - }, - } - cmd.Flags().StringVar(&vpcUUID, "vpc", "", "VPC UUID (required)") - cmd.Flags().StringVar(&customerGatewayUUID, "customer-gateway", "", "Customer gateway UUID (required)") - cmd.Flags().BoolVar(&passive, "passive", false, "Create connection in passive mode") - return cmd -} - -func runVPNConnCreate(cmd *cobra.Command, req vpn.ConnectionCreateRequest) error { - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := vpn.NewConnectionService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - c, err := svc.Create(ctx, req) - if err != nil { - return fmt.Errorf("vpn connection create: %w", err) - } - - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", c.UUID}, - {"State", c.State}, - {"IKE Policy", c.IKEPolicy}, - {"ESP Policy", c.ESPPolicy}, - {"Customer Gateway UUID", c.CustomerGatewayUUID}, - {"VPN Gateway UUID", c.VPNGatewayUUID}, - {"Zone UUID", c.ZoneUUID}, - } - return printer.PrintTable(headers, rows) -} - -func newVPNConnResetCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset ", - Short: "Reset a VPN connection", - Args: cobra.ExactArgs(1), - Example: ` zcp vpn connection reset `, - RunE: func(cmd *cobra.Command, args []string) error { - return runVPNConnReset(cmd, args[0]) - }, - } - return cmd -} - -func runVPNConnReset(cmd *cobra.Command, uuid string) error { - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := vpn.NewConnectionService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - c, err := svc.Reset(ctx, uuid) - if err != nil { - return fmt.Errorf("vpn connection reset: %w", err) - } - - headers := []string{"FIELD", "VALUE"} - rows := [][]string{ - {"UUID", c.UUID}, - {"State", c.State}, - {"IKE Policy", c.IKEPolicy}, - } - return printer.PrintTable(headers, rows) -} - -func newVPNConnDeleteCmd() *cobra.Command { - var yes bool - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a VPN connection", - Args: cobra.ExactArgs(1), - Example: ` zcp vpn connection delete - zcp vpn connection delete --yes`, - RunE: func(cmd *cobra.Command, args []string) error { - return runVPNConnDelete(cmd, args[0], yes) - }, - } - cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation prompt") - return cmd -} - -func runVPNConnDelete(cmd *cobra.Command, uuid string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete VPN connection %q? This action cannot be undone. [y/N]: ", uuid) - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil - } - } - - _, client, printer, err := buildClientAndPrinter(cmd) - if err != nil { - return err - } - - svc := vpn.NewConnectionService(client) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) - defer cancel() - - if err := svc.Delete(ctx, uuid); err != nil { - return fmt.Errorf("vpn connection delete: %w", err) - } - - printer.Fprintf("VPN connection %q deleted.\n", uuid) + printer.Fprintf("VPN customer gateway %q deleted.\n", slug) return nil } @@ -694,16 +338,16 @@ func runVPNUserList(cmd *cobra.Command) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - users, err := svc.List(ctx, "") + users, err := svc.List(ctx) if err != nil { return fmt.Errorf("vpn user list: %w", err) } - headers := []string{"UUID", "USERNAME", "STATUS"} + headers := []string{"SLUG", "USERNAME", "STATUS"} rows := make([][]string, 0, len(users)) for _, u := range users { rows = append(rows, []string{ - u.UUID, + u.Slug, u.UserName, u.Status, }) @@ -766,7 +410,7 @@ func runVPNUserCreate(cmd *cobra.Command, username, password string) error { headers := []string{"FIELD", "VALUE"} rows := [][]string{ - {"UUID", u.UUID}, + {"Slug", u.Slug}, {"Username", u.UserName}, {"Status", u.Status}, } @@ -777,11 +421,11 @@ func newVPNUserDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a VPN user by username", + Use: "delete ", + Short: "Delete a VPN user by slug", Args: cobra.ExactArgs(1), - Example: ` zcp vpn user delete alice - zcp vpn user delete alice --yes`, + Example: ` zcp vpn user delete + zcp vpn user delete --yes`, RunE: func(cmd *cobra.Command, args []string) error { return runVPNUserDelete(cmd, args[0], yes) }, @@ -790,9 +434,9 @@ func newVPNUserDeleteCmd() *cobra.Command { return cmd } -func runVPNUserDelete(cmd *cobra.Command, username string, yes bool) error { - if !yes { - fmt.Fprintf(os.Stderr, "Delete VPN user %q? This action cannot be undone. [y/N]: ", username) +func runVPNUserDelete(cmd *cobra.Command, slug string, yes bool) error { + if !yes && !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete VPN user %q? This action cannot be undone. [y/N]: ", slug) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) @@ -811,10 +455,10 @@ func runVPNUserDelete(cmd *cobra.Command, username string, yes bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - if err := svc.Delete(ctx, username); err != nil { + if err := svc.Delete(ctx, slug); err != nil { return fmt.Errorf("vpn user delete: %w", err) } - printer.Fprintf("VPN user %q deleted.\n", username) + printer.Fprintf("VPN user %q deleted.\n", slug) return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 1ab8749..34c4fad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,7 @@ import ( const ( // DefaultAPIURL is the default ZCP API base URL. - DefaultAPIURL = "https://cloud.zcp.zsoftly.ca" + DefaultAPIURL = "https://portal.webberstop.com/backend/api" // DefaultTimeout is the default HTTP request timeout in seconds. DefaultTimeout = 30 ) @@ -21,8 +21,7 @@ const ( // Profile holds credentials and settings for a named profile. type Profile struct { Name string `yaml:"name"` - APIKey string `yaml:"apikey"` - SecretKey string `yaml:"secretkey"` + BearerToken string `yaml:"bearer_token"` APIURL string `yaml:"api_url,omitempty"` DefaultZone string `yaml:"default_zone,omitempty"` } @@ -130,7 +129,7 @@ func ResolveProfile(cfg *Config, profileName string) (*Profile, error) { if !ok { return nil, fmt.Errorf("profile %q not found — run: zcp profile list", name) } - if p.APIKey == "" || p.SecretKey == "" { + if p.BearerToken == "" { return nil, fmt.Errorf("profile %q is missing credentials — run: zcp profile add", name) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 04dbe10..5ab7261 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,10 +37,9 @@ func TestSaveAndLoad(t *testing.T) { ActiveProfile: "default", Profiles: map[string]config.Profile{ "default": { - Name: "default", - APIKey: "testkey", - SecretKey: "testsecret", - APIURL: "", + Name: "default", + BearerToken: "test-bearer-token", + APIURL: "", }, }, } @@ -70,8 +69,8 @@ func TestSaveAndLoad(t *testing.T) { if !ok { t.Fatal("profile 'default' not found after load") } - if p.APIKey != "testkey" { - t.Errorf("APIKey = %q, want %q", p.APIKey, "testkey") + if p.BearerToken != "test-bearer-token" { + t.Errorf("BearerToken = %q, want %q", p.BearerToken, "test-bearer-token") } } @@ -79,7 +78,7 @@ func TestResolveProfile(t *testing.T) { cfg := &config.Config{ ActiveProfile: "prod", Profiles: map[string]config.Profile{ - "prod": {Name: "prod", APIKey: "key", SecretKey: "secret"}, + "prod": {Name: "prod", BearerToken: "token"}, }, } @@ -117,37 +116,15 @@ func TestResolveProfileNoActive(t *testing.T) { } func TestResolveProfileMissingCredentials(t *testing.T) { - tests := []struct { - name string - profile config.Profile - }{ - { - name: "missing APIKey", - profile: config.Profile{Name: "dev", APIKey: "", SecretKey: "secret"}, - }, - { - name: "missing SecretKey", - profile: config.Profile{Name: "dev", APIKey: "key", SecretKey: ""}, - }, - { - name: "missing both", - profile: config.Profile{Name: "dev", APIKey: "", SecretKey: ""}, + cfg := &config.Config{ + ActiveProfile: "dev", + Profiles: map[string]config.Profile{ + "dev": {Name: "dev", BearerToken: ""}, }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &config.Config{ - ActiveProfile: "dev", - Profiles: map[string]config.Profile{ - "dev": tt.profile, - }, - } - _, err := config.ResolveProfile(cfg, "dev") - if err == nil { - t.Errorf("ResolveProfile() expected error for %q, got nil", tt.name) - } - }) + _, err := config.ResolveProfile(cfg, "dev") + if err == nil { + t.Error("ResolveProfile() expected error for missing bearer token, got nil") } } diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index 06f757f..25990f7 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -19,11 +19,10 @@ import ( // Options configures a Client. type Options struct { - BaseURL string - APIKey string - SecretKey string - Timeout time.Duration - Debug bool + BaseURL string + BearerToken string + Timeout time.Duration + Debug bool // 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. @@ -41,7 +40,7 @@ type Client struct { } // New creates a new Client with the given options. -// BaseURL, APIKey, and SecretKey are required. +// BaseURL and BearerToken are required. func New(opts Options) *Client { if opts.Timeout == 0 { opts.Timeout = 30 * time.Second @@ -171,9 +170,8 @@ func (c *Client) doOnce(ctx context.Context, method, path string, query url.Valu return fmt.Errorf("creating request: %w", err) } - // Auth headers - req.Header.Set("apikey", c.opts.APIKey) - req.Header.Set("secretkey", c.opts.SecretKey) + // Auth header + req.Header.Set("Authorization", "Bearer "+c.opts.BearerToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "zcp-cli/"+version.Version) @@ -212,3 +210,52 @@ func (c *Client) doOnce(ctx context.Context, method, path string, query url.Valu return nil } + +// envelope is the standard STKCNSL response wrapper. +type envelope struct { + Status string `json:"status"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +// GetEnvelope performs a GET and unwraps the STKCNSL {status,data} envelope. +// result receives the unmarshalled data field. +func (c *Client) GetEnvelope(ctx context.Context, path string, query url.Values, result interface{}) error { + var env envelope + if err := c.Get(ctx, path, query, &env); err != nil { + return err + } + if result != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, result) + } + return nil +} + +// PostEnvelope performs a POST and unwraps the STKCNSL envelope. +func (c *Client) PostEnvelope(ctx context.Context, path string, body interface{}, result interface{}) error { + var env envelope + if err := c.Post(ctx, path, body, &env); err != nil { + return err + } + if result != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, result) + } + return nil +} + +// PutEnvelope performs a PUT and unwraps the STKCNSL envelope. +func (c *Client) PutEnvelope(ctx context.Context, path string, query url.Values, body interface{}, result interface{}) error { + var env envelope + if err := c.Put(ctx, path, query, body, &env); err != nil { + return err + } + if result != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, result) + } + return nil +} + +// DeleteEnvelope performs a DELETE and unwraps the STKCNSL envelope. +func (c *Client) DeleteEnvelope(ctx context.Context, path string, query url.Values) error { + return c.Delete(ctx, path, query) +} diff --git a/internal/httpclient/client_put_test.go b/internal/httpclient/client_put_test.go index eac242e..17b6369 100644 --- a/internal/httpclient/client_put_test.go +++ b/internal/httpclient/client_put_test.go @@ -12,19 +12,18 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -func TestPutInjectsAuthHeaders(t *testing.T) { - var gotMethod, gotAPIKey, gotSecretKey string +func TestPutInjectsBearerToken(t *testing.T) { + var gotMethod, gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method - gotAPIKey = r.Header.Get("apikey") - gotSecretKey = r.Header.Get("secretkey") + gotAuth = r.Header.Get("Authorization") w.WriteHeader(http.StatusOK) w.Write([]byte("{}")) })) defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, APIKey: "k", SecretKey: "s", Timeout: 5 * time.Second, + BaseURL: srv.URL, BearerToken: "tok", Timeout: 5 * time.Second, }) err := client.Put(context.Background(), "/test", nil, map[string]string{"name": "updated"}, nil) @@ -34,11 +33,8 @@ func TestPutInjectsAuthHeaders(t *testing.T) { if gotMethod != http.MethodPut { t.Errorf("method = %q, want PUT", gotMethod) } - if gotAPIKey != "k" { - t.Errorf("apikey header = %q, want %q", gotAPIKey, "k") - } - if gotSecretKey != "s" { - t.Errorf("secretkey header = %q, want %q", gotSecretKey, "s") + if gotAuth != "Bearer tok" { + t.Errorf("Authorization header = %q, want %q", gotAuth, "Bearer tok") } } @@ -52,7 +48,7 @@ func TestPutWithQueryParams(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, APIKey: "k", SecretKey: "s", Timeout: 5 * time.Second, + BaseURL: srv.URL, BearerToken: "tok", Timeout: 5 * time.Second, }) q := url.Values{"uuid": {"vm-123"}, "size": {"3"}} @@ -78,7 +74,7 @@ func TestPutDecodesResponse(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, APIKey: "k", SecretKey: "s", Timeout: 5 * time.Second, + BaseURL: srv.URL, BearerToken: "tok", Timeout: 5 * time.Second, }) var result resp @@ -94,13 +90,14 @@ func TestPutHTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]interface{}{ - "listErrorResponse": map[string]string{"errorCode": "401", "errorMsg": "Unauthorized"}, + "status": "Error", + "message": "Unauthenticated.", }) })) defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, APIKey: "bad", SecretKey: "creds", Timeout: 5 * time.Second, + BaseURL: srv.URL, BearerToken: "bad-token", Timeout: 5 * time.Second, }) err := client.Put(context.Background(), "/test", nil, nil, nil) diff --git a/internal/httpclient/client_retry_test.go b/internal/httpclient/client_retry_test.go index af567df..f15959a 100644 --- a/internal/httpclient/client_retry_test.go +++ b/internal/httpclient/client_retry_test.go @@ -17,11 +17,10 @@ import ( // 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, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: maxRetries, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: maxRetries, }) } @@ -49,11 +48,10 @@ func TestRetryGET500EventuallySucceeds(t *testing.T) { // custom server that responds immediately, so backoff sleeps dominate — // keep MaxRetries small to keep test fast. client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 3, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 3, }) var result map[string]string @@ -88,11 +86,10 @@ func TestRetryGET429(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 3, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 3, }) var result map[string]string @@ -119,11 +116,10 @@ func TestPOSTDoesNotRetryOn500(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 3, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 3, }) var result map[string]string @@ -151,11 +147,10 @@ func TestRetryGETExceedsMaxRetries(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 2, // 1 initial + 2 retries = 3 total requests + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 2, // 1 initial + 2 retries = 3 total requests }) err := client.Get(context.Background(), "/test", url.Values{}, nil) @@ -182,11 +177,10 @@ func TestRetryContextCancellationStopsLoop(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 3, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 3, }) // Cancel immediately after first request fires. The retry backoff (1s) @@ -225,11 +219,10 @@ func TestRetryGET404DoesNotRetry(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, - MaxRetries: 3, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, + MaxRetries: 3, }) err := client.Get(context.Background(), "/missing", url.Values{}, nil) diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go index 97f8c4d..3e54476 100644 --- a/internal/httpclient/client_test.go +++ b/internal/httpclient/client_test.go @@ -12,22 +12,20 @@ import ( "github.com/zsoftly/zcp-cli/internal/httpclient" ) -func TestGetInjectsAuthHeaders(t *testing.T) { - var gotAPIKey, gotSecretKey string +func TestGetInjectsBearerToken(t *testing.T) { + var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAPIKey = r.Header.Get("apikey") - gotSecretKey = r.Header.Get("secretkey") + gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) })) defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "my-api-key", - SecretKey: "my-secret-key", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "my-test-token", + Timeout: 5 * time.Second, }) var result map[string]string @@ -36,11 +34,8 @@ func TestGetInjectsAuthHeaders(t *testing.T) { t.Fatalf("Get() error = %v", err) } - if gotAPIKey != "my-api-key" { - t.Errorf("apikey header = %q, want %q", gotAPIKey, "my-api-key") - } - if gotSecretKey != "my-secret-key" { - t.Errorf("secretkey header = %q, want %q", gotSecretKey, "my-secret-key") + if gotAuth != "Bearer my-test-token" { + t.Errorf("Authorization header = %q, want %q", gotAuth, "Bearer my-test-token") } } @@ -57,10 +52,9 @@ func TestGetDecodesJSON(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, }) var result testResp @@ -87,10 +81,9 @@ func TestGetQueryParams(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 5 * time.Second, }) q := url.Values{"zoneUuid": {"abc-123"}} @@ -105,19 +98,16 @@ func TestGetHTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]interface{}{ - "listErrorResponse": map[string]string{ - "errorCode": "401", - "errorMsg": "Invalid credentials", - }, + "status": "Error", + "message": "Unauthenticated.", }) })) defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "bad", - SecretKey: "creds", - Timeout: 5 * time.Second, + BaseURL: srv.URL, + BearerToken: "bad-token", + Timeout: 5 * time.Second, }) err := client.Get(context.Background(), "/protected", url.Values{}, nil) @@ -134,10 +124,9 @@ func TestGetContextCancellation(t *testing.T) { defer srv.Close() client := httpclient.New(httpclient.Options{ - BaseURL: srv.URL, - APIKey: "k", - SecretKey: "s", - Timeout: 10 * time.Second, + BaseURL: srv.URL, + BearerToken: "tok", + Timeout: 10 * time.Second, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/tests/integration/lifecycle_test.go b/tests/integration/lifecycle_test.go index d8996b9..e544f10 100644 --- a/tests/integration/lifecycle_test.go +++ b/tests/integration/lifecycle_test.go @@ -87,12 +87,11 @@ func setupClient(t *testing.T) (*httpclient.Client, string) { zoneUUID = defaultZone } client := httpclient.New(httpclient.Options{ - BaseURL: apiURL, - APIKey: profile.APIKey, - SecretKey: profile.SecretKey, - Timeout: 2 * time.Minute, - Debug: os.Getenv("ZCP_TEST_DEBUG") != "", - DebugOut: os.Stderr, + BaseURL: apiURL, + BearerToken: profile.BearerToken, + Timeout: 2 * time.Minute, + Debug: os.Getenv("ZCP_TEST_DEBUG") != "", + DebugOut: os.Stderr, }) return client, zoneUUID }