From 942f6444d472cbaedfa337ac922cb78617231ef5 Mon Sep 17 00:00:00 2001 From: huynp4 Date: Tue, 16 Jun 2026 13:23:09 +0700 Subject: [PATCH 1/4] Add detailed documentation for vServer commands --- docs/commands/vserver/flavor.md | 119 +++++++ docs/commands/vserver/image.md | 61 ++++ docs/commands/vserver/index.md | 69 ++++ docs/commands/vserver/secgroup.md | 316 +++++++++++++++++ docs/commands/vserver/server.md | 429 ++++++++++++++++++++++++ docs/commands/vserver/subnet.md | 159 +++++++++ docs/commands/vserver/volume-type.md | 51 +++ docs/commands/vserver/volume.md | 225 +++++++++++++ docs/commands/vserver/vpc.md | 152 +++++++++ go/cmd/vserver/flavor/flavor.go | 21 ++ go/cmd/vserver/flavor/helpers.go | 153 +++++++++ go/cmd/vserver/flavor/list.go | 151 +++++++++ go/cmd/vserver/flavor/list_codes.go | 52 +++ go/cmd/vserver/flavor/list_families.go | 52 +++ go/cmd/vserver/image/helpers.go | 20 ++ go/cmd/vserver/image/image.go | 19 ++ go/cmd/vserver/image/list.go | 149 ++++++++ go/cmd/vserver/secgroup/create.go | 47 +++ go/cmd/vserver/secgroup/delete.go | 85 +++++ go/cmd/vserver/secgroup/get.go | 46 +++ go/cmd/vserver/secgroup/helpers.go | 27 ++ go/cmd/vserver/secgroup/list.go | 57 ++++ go/cmd/vserver/secgroup/rule/create.go | 111 ++++++ go/cmd/vserver/secgroup/rule/delete.go | 51 +++ go/cmd/vserver/secgroup/rule/get.go | 55 +++ go/cmd/vserver/secgroup/rule/helpers.go | 27 ++ go/cmd/vserver/secgroup/rule/list.go | 60 ++++ go/cmd/vserver/secgroup/rule/rule.go | 22 ++ go/cmd/vserver/secgroup/secgroup.go | 24 ++ go/cmd/vserver/secgroup/zcompletion.go | 8 + go/cmd/vserver/server/create.go | 235 +++++++++++++ go/cmd/vserver/server/delete.go | 100 ++++++ go/cmd/vserver/server/get.go | 43 +++ go/cmd/vserver/server/helpers.go | 276 +++++++++++++++ go/cmd/vserver/server/list.go | 63 ++++ go/cmd/vserver/server/reboot.go | 43 +++ go/cmd/vserver/server/resize.go | 57 ++++ go/cmd/vserver/server/server.go | 26 ++ go/cmd/vserver/server/start.go | 43 +++ go/cmd/vserver/server/stop.go | 43 +++ go/cmd/vserver/server/zcompletion.go | 22 ++ go/cmd/vserver/subnet/create.go | 67 ++++ go/cmd/vserver/subnet/delete.go | 97 ++++++ go/cmd/vserver/subnet/get.go | 50 +++ go/cmd/vserver/subnet/helpers.go | 24 ++ go/cmd/vserver/subnet/list.go | 58 ++++ go/cmd/vserver/subnet/subnet.go | 22 ++ go/cmd/vserver/subnet/zcompletion.go | 18 + go/cmd/vserver/volume/create.go | 108 ++++++ go/cmd/vserver/volume/delete.go | 92 +++++ go/cmd/vserver/volume/get.go | 43 +++ go/cmd/vserver/volume/helpers.go | 87 +++++ go/cmd/vserver/volume/list.go | 57 ++++ go/cmd/vserver/volume/resize.go | 177 ++++++++++ go/cmd/vserver/volume/volume.go | 23 ++ go/cmd/vserver/volume/zcompletion.go | 17 + go/cmd/vserver/volumetype/helpers.go | 108 ++++++ go/cmd/vserver/volumetype/list.go | 74 ++++ go/cmd/vserver/volumetype/volumetype.go | 19 ++ go/cmd/vserver/vpc/create.go | 91 +++++ go/cmd/vserver/vpc/delete.go | 93 +++++ go/cmd/vserver/vpc/get.go | 43 +++ go/cmd/vserver/vpc/helpers.go | 27 ++ go/cmd/vserver/vpc/list.go | 57 ++++ go/cmd/vserver/vpc/vpc.go | 22 ++ go/cmd/vserver/vpc/zcompletion.go | 8 + go/cmd/vserver/vserver.go | 36 ++ go/internal/client/client.go | 35 +- go/internal/formatter/formatter.go | 109 ++++++ go/internal/vserverclient/client.go | 97 ++++++ go/internal/vserverclient/complete.go | 236 +++++++++++++ go/internal/vserverclient/zones.go | 42 +++ mkdocs.yml | 15 + 73 files changed, 5809 insertions(+), 12 deletions(-) create mode 100644 docs/commands/vserver/flavor.md create mode 100644 docs/commands/vserver/image.md create mode 100644 docs/commands/vserver/index.md create mode 100644 docs/commands/vserver/secgroup.md create mode 100644 docs/commands/vserver/server.md create mode 100644 docs/commands/vserver/subnet.md create mode 100644 docs/commands/vserver/volume-type.md create mode 100644 docs/commands/vserver/volume.md create mode 100644 docs/commands/vserver/vpc.md create mode 100644 go/cmd/vserver/flavor/flavor.go create mode 100644 go/cmd/vserver/flavor/helpers.go create mode 100644 go/cmd/vserver/flavor/list.go create mode 100644 go/cmd/vserver/flavor/list_codes.go create mode 100644 go/cmd/vserver/flavor/list_families.go create mode 100644 go/cmd/vserver/image/helpers.go create mode 100644 go/cmd/vserver/image/image.go create mode 100644 go/cmd/vserver/image/list.go create mode 100644 go/cmd/vserver/secgroup/create.go create mode 100644 go/cmd/vserver/secgroup/delete.go create mode 100644 go/cmd/vserver/secgroup/get.go create mode 100644 go/cmd/vserver/secgroup/helpers.go create mode 100644 go/cmd/vserver/secgroup/list.go create mode 100644 go/cmd/vserver/secgroup/rule/create.go create mode 100644 go/cmd/vserver/secgroup/rule/delete.go create mode 100644 go/cmd/vserver/secgroup/rule/get.go create mode 100644 go/cmd/vserver/secgroup/rule/helpers.go create mode 100644 go/cmd/vserver/secgroup/rule/list.go create mode 100644 go/cmd/vserver/secgroup/rule/rule.go create mode 100644 go/cmd/vserver/secgroup/secgroup.go create mode 100644 go/cmd/vserver/secgroup/zcompletion.go create mode 100644 go/cmd/vserver/server/create.go create mode 100644 go/cmd/vserver/server/delete.go create mode 100644 go/cmd/vserver/server/get.go create mode 100644 go/cmd/vserver/server/helpers.go create mode 100644 go/cmd/vserver/server/list.go create mode 100644 go/cmd/vserver/server/reboot.go create mode 100644 go/cmd/vserver/server/resize.go create mode 100644 go/cmd/vserver/server/server.go create mode 100644 go/cmd/vserver/server/start.go create mode 100644 go/cmd/vserver/server/stop.go create mode 100644 go/cmd/vserver/server/zcompletion.go create mode 100644 go/cmd/vserver/subnet/create.go create mode 100644 go/cmd/vserver/subnet/delete.go create mode 100644 go/cmd/vserver/subnet/get.go create mode 100644 go/cmd/vserver/subnet/helpers.go create mode 100644 go/cmd/vserver/subnet/list.go create mode 100644 go/cmd/vserver/subnet/subnet.go create mode 100644 go/cmd/vserver/subnet/zcompletion.go create mode 100644 go/cmd/vserver/volume/create.go create mode 100644 go/cmd/vserver/volume/delete.go create mode 100644 go/cmd/vserver/volume/get.go create mode 100644 go/cmd/vserver/volume/helpers.go create mode 100644 go/cmd/vserver/volume/list.go create mode 100644 go/cmd/vserver/volume/resize.go create mode 100644 go/cmd/vserver/volume/volume.go create mode 100644 go/cmd/vserver/volume/zcompletion.go create mode 100644 go/cmd/vserver/volumetype/helpers.go create mode 100644 go/cmd/vserver/volumetype/list.go create mode 100644 go/cmd/vserver/volumetype/volumetype.go create mode 100644 go/cmd/vserver/vpc/create.go create mode 100644 go/cmd/vserver/vpc/delete.go create mode 100644 go/cmd/vserver/vpc/get.go create mode 100644 go/cmd/vserver/vpc/helpers.go create mode 100644 go/cmd/vserver/vpc/list.go create mode 100644 go/cmd/vserver/vpc/vpc.go create mode 100644 go/cmd/vserver/vpc/zcompletion.go create mode 100644 go/cmd/vserver/vserver.go create mode 100644 go/internal/vserverclient/client.go create mode 100644 go/internal/vserverclient/complete.go create mode 100644 go/internal/vserverclient/zones.go diff --git a/docs/commands/vserver/flavor.md b/docs/commands/vserver/flavor.md new file mode 100644 index 0000000..60aa31e --- /dev/null +++ b/docs/commands/vserver/flavor.md @@ -0,0 +1,119 @@ +# flavor + +Browse available vServer instance flavors (CPU/memory/GPU combinations). + +```bash +grn vserver flavor [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list-families](#list-families) | List available instance families | +| [list-codes](#list-codes) | List available CPU platform codes | +| [list](#list) | List flavors for a family and CPU platform | + +## Discovery workflow + +Flavors are organized by **family** (e.g. `general-purpose`, `memory-optimized`) and **CPU platform code** (e.g. `Intel`, `AMD`). To find a flavor: + +```bash +# Step 1: find available families +grn vserver flavor list-families + +# Step 2: find CPU platform codes +grn vserver flavor list-codes + +# Step 3: list flavors for the chosen family and code +grn vserver flavor list --family --code +``` + +--- + +## list-families + +List available instance families. + +### Synopsis + +``` +grn vserver flavor list-families +``` + +### Examples + +```bash +grn vserver flavor list-families +grn vserver flavor list-families --output table +``` + +--- + +## list-codes + +List available CPU platform codes. + +### Synopsis + +``` +grn vserver flavor list-codes +``` + +### Examples + +```bash +grn vserver flavor list-codes +``` + +--- + +## list + +List available flavors for a specific instance family and CPU platform code. Only flavors with remaining capacity are shown. + +### Synopsis + +``` +grn vserver flavor list + --family + --code + [--zone-id ] + [--page ] + [--page-size ] +``` + +### Options + +`--family` (required) +: Instance family name. Run `grn vserver flavor list-families` to see options. Omit to see available families printed to stderr. + +`--code` (required) +: CPU platform code. Run `grn vserver flavor list-codes` to see options. Omit to see available codes printed to stderr. + +`--zone-id` (string) +: Filter flavors by availability zone. + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +### Examples + +```bash +grn vserver flavor list --family general-purpose --code Intel + +grn vserver flavor list \ + --family memory-optimized \ + --code AMD \ + --zone-id zone-abc123 \ + --output table + +# JMESPath to see only IDs and vCPU/RAM +grn vserver flavor list \ + --family general-purpose \ + --code Intel \ + --query "data[].{id: flavorId, cpu: cpu, ram: ram}" +``` diff --git a/docs/commands/vserver/image.md b/docs/commands/vserver/image.md new file mode 100644 index 0000000..b4ea656 --- /dev/null +++ b/docs/commands/vserver/image.md @@ -0,0 +1,61 @@ +# image + +Browse available vServer OS and GPU images. + +```bash +grn vserver image [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List available images | + +--- + +## list + +List available images by type. Use the image ID with `grn vserver server create --image-id`. + +### Synopsis + +``` +grn vserver image list + --type + [--page ] + [--page-size ] + [--image-version ] +``` + +### Options + +`--type` (required) +: Image type. Accepted values: `os`, `gpu`. + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--image-version` (string) +: Filter by image version (client-side substring match, case-insensitive). + +### Examples + +```bash +# List OS images +grn vserver image list --type os + +# List GPU images +grn vserver image list --type gpu + +# Filter to Ubuntu images +grn vserver image list --type os --image-version ubuntu + +# Table output with JMESPath to extract ID and name +grn vserver image list --type os \ + --query "images[].{id: id, name: name, version: imageVersion}" \ + --output table +``` diff --git a/docs/commands/vserver/index.md b/docs/commands/vserver/index.md new file mode 100644 index 0000000..88209ea --- /dev/null +++ b/docs/commands/vserver/index.md @@ -0,0 +1,69 @@ +# vServer Commands + +VNG Virtual Server (vServer) commands for managing cloud virtual machines and related infrastructure. + +```bash +grn vserver [options] +``` + +## Available resources + +| Resource | Description | +|----------|-------------| +| [server](server.md) | Create and manage vServer instances | +| [volume](volume.md) | Manage block storage volumes | +| [vpc](vpc.md) | Manage VPC networks | +| [subnet](subnet.md) | Manage subnets within a VPC | +| [secgroup](secgroup.md) | Manage security groups and their rules | +| [flavor](flavor.md) | Browse available instance flavors | +| [image](image.md) | Browse available OS/GPU images | +| [volume-type](volume-type.md) | Browse available volume types for a zone | + +## Quick start workflow + +Creating a server requires several prerequisites. Run these commands in order: + +```bash +# 1. Create a VPC +grn vserver vpc create --name my-vpc --cidr 10.0.0.0/16 + +# 2. Create a subnet inside the VPC +grn vserver subnet create --vpc-id --cidr 10.0.1.0/24 --zone-id + +# 3. Find an available zone (omit --zone-id to see the list) +# Zone IDs are shown when you run any command that requires --zone-id + +# 4. Find a flavor +grn vserver flavor list-families +grn vserver flavor list-codes +grn vserver flavor list --family --code + +# 5. Find an image +grn vserver image list --type os + +# 6. Find a volume type for the zone +grn vserver volume-type list --zone-id --type SSD + +# 7. Create the server +grn vserver server create \ + --name my-server \ + --flavor-id \ + --image-id \ + --network-id \ + --subnet-id \ + --root-disk-type-id \ + --zone-id +``` + +## Common global options + +All vserver commands accept these global flags: + +| Flag | Description | +|------|-------------| +| `--region` | Override the configured region | +| `--output` | Output format: `json` (default), `table`, `text` | +| `--query` | JMESPath query to filter output | +| `--profile` | Use a specific credentials profile | +| `--endpoint-url` | Override the vServer API endpoint | +| `--debug` | Print raw HTTP requests and responses | diff --git a/docs/commands/vserver/secgroup.md b/docs/commands/vserver/secgroup.md new file mode 100644 index 0000000..154b4e3 --- /dev/null +++ b/docs/commands/vserver/secgroup.md @@ -0,0 +1,316 @@ +# secgroup + +Manage security groups and their inbound/outbound rules. + +```bash +grn vserver secgroup [options] +grn vserver secgroup rule [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List all security groups | +| [get](#get) | Get details of a security group | +| [create](#create) | Create a new security group | +| [delete](#delete) | Delete a security group | +| [rule list](#rule-list) | List rules in a security group | +| [rule get](#rule-get) | Get details of a rule | +| [rule create](#rule-create) | Add a rule to a security group | +| [rule delete](#rule-delete) | Delete a rule from a security group | + +--- + +## list + +List all security groups in your project. + +### Synopsis + +``` +grn vserver secgroup list + [--page ] + [--page-size ] + [--name ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--name` (string) +: Filter by security group name (substring match). + +### Examples + +```bash +grn vserver secgroup list +grn vserver secgroup list --name web --output table +``` + +--- + +## get + +Get details of a security group. + +### Synopsis + +``` +grn vserver secgroup get --secgroup-id +``` + +### Options + +`--secgroup-id` (required) +: Security group ID. + +### Examples + +```bash +grn vserver secgroup get --secgroup-id sg-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## create + +Create a new security group. + +### Synopsis + +``` +grn vserver secgroup create + --name + [--description ] +``` + +### Options + +`--name` (required) +: Security group name. + +`--description` (string) +: Security group description. + +### Examples + +```bash +grn vserver secgroup create --name web-sg --description "Allow HTTP and HTTPS traffic" +``` + +--- + +## delete + +Delete a security group. Shows a confirmation prompt unless `--force` is used. + +### Synopsis + +``` +grn vserver secgroup delete + --secgroup-id + [--force] +``` + +### Options + +`--secgroup-id` (required) +: Security group ID. + +`--force` (boolean) +: Skip the confirmation prompt. + +### Examples + +```bash +grn vserver secgroup delete --secgroup-id sg-abc12345-0000-0000-0000-000000000001 +grn vserver secgroup delete --secgroup-id sg-abc12345-0000-0000-0000-000000000001 --force +``` + +--- + +## rule list + +List all rules in a security group. + +### Synopsis + +``` +grn vserver secgroup rule list + --secgroup-id + [--page ] + [--page-size ] +``` + +### Options + +`--secgroup-id` (required) +: Security group ID. + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +### Examples + +```bash +grn vserver secgroup rule list --secgroup-id sg-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## rule get + +Get details of a security group rule. + +### Synopsis + +``` +grn vserver secgroup rule get + --secgroup-id + --rule-id +``` + +### Options + +`--secgroup-id` (required) +: Security group ID. + +`--rule-id` (required) +: Security group rule ID. + +### Examples + +```bash +grn vserver secgroup rule get \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --rule-id rule-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## rule create + +Add a new inbound or outbound rule to a security group. + +### Synopsis + +``` +grn vserver secgroup rule create + --secgroup-id + --direction + --protocol + --port-range-min + --port-range-max + --ether-type + --remote-ip-prefix + [--remote-group-id ] + [--description ] +``` + +### Options + +`--secgroup-id` (required) +: Security group ID to add the rule to. + +`--direction` (required) +: Traffic direction. Accepted values: `ingress`, `egress`. + +`--protocol` (required) +: Network protocol. Accepted values: `tcp`, `udp`, `icmp`, `any`. + +`--port-range-min` (required for tcp/udp) +: Minimum port number. Not valid for `icmp` or `any`. + +`--port-range-max` (required for tcp/udp) +: Maximum port number. Must be ≥ `--port-range-min`. Not valid for `icmp` or `any`. + +`--ether-type` (required) +: IP version. Accepted values: `IPv4`, `IPv6`. Default: `IPv4`. + +`--remote-ip-prefix` (required) +: Remote CIDR block, e.g. `0.0.0.0/0` (all IPv4) or `192.168.1.0/24`. + +`--remote-group-id` (string) +: Remote security group ID. Use instead of `--remote-ip-prefix` to allow traffic from another security group. + +`--description` (string) +: Rule description. + +### Examples + +```bash +# Allow all inbound HTTP traffic +grn vserver secgroup rule create \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --direction ingress \ + --protocol tcp \ + --port-range-min 80 \ + --port-range-max 80 \ + --ether-type IPv4 \ + --remote-ip-prefix 0.0.0.0/0 + +# Allow HTTPS from a specific CIDR +grn vserver secgroup rule create \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --direction ingress \ + --protocol tcp \ + --port-range-min 443 \ + --port-range-max 443 \ + --ether-type IPv4 \ + --remote-ip-prefix 203.0.113.0/24 + +# Allow all ICMP (ping) inbound +grn vserver secgroup rule create \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --direction ingress \ + --protocol icmp \ + --ether-type IPv4 \ + --remote-ip-prefix 0.0.0.0/0 + +# Allow all outbound traffic +grn vserver secgroup rule create \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --direction egress \ + --protocol any \ + --ether-type IPv4 \ + --remote-ip-prefix 0.0.0.0/0 +``` + +--- + +## rule delete + +Delete a rule from a security group. + +### Synopsis + +``` +grn vserver secgroup rule delete + --secgroup-id + --rule-id +``` + +### Options + +`--secgroup-id` (required) +: Security group ID. + +`--rule-id` (required) +: Security group rule ID. + +### Examples + +```bash +grn vserver secgroup rule delete \ + --secgroup-id sg-abc12345-0000-0000-0000-000000000001 \ + --rule-id rule-abc12345-0000-0000-0000-000000000001 +``` diff --git a/docs/commands/vserver/server.md b/docs/commands/vserver/server.md new file mode 100644 index 0000000..3973b68 --- /dev/null +++ b/docs/commands/vserver/server.md @@ -0,0 +1,429 @@ +# server + +Manage vServer virtual machine instances. + +```bash +grn vserver server [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List all vServer instances | +| [get](#get) | Get details of an instance | +| [create](#create) | Create a new instance | +| [start](#start) | Start a stopped instance | +| [stop](#stop) | Stop a running instance | +| [reboot](#reboot) | Reboot an instance | +| [resize](#resize) | Change an instance to a different flavor | +| [delete](#delete) | Delete an instance | + +--- + +## list + +List all vServer instances in your project. + +### Synopsis + +``` +grn vserver server list + [--page ] + [--page-size ] + [--no-paginate] + [--name ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--no-paginate` (boolean) +: Disable pagination and return all results. + +`--name` (string) +: Filter by server name (substring match). + +### Examples + +```bash +# List all servers +grn vserver server list + +# Filter by name +grn vserver server list --name web + +# Table output +grn vserver server list --output table + +# JMESPath filter — names and statuses only +grn vserver server list --query "listData[].{name: name, status: status}" +``` + +--- + +## get + +Get full details of a vServer instance. + +### Synopsis + +``` +grn vserver server get + --server-id +``` + +### Options + +`--server-id` (required) +: Server UUID. + +### Examples + +```bash +grn vserver server get --server-id srv-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## create + +Create a new vServer instance. Several prerequisites are needed — run the discovery commands first if you don't have the IDs yet. + +### Synopsis + +``` +grn vserver server create + --name + --flavor-id + --image-id + --network-id + --subnet-id + --root-disk-type-id + --zone-id + [--root-disk-size ] + [--root-disk-encryption-type ] + [--encryption-volume] + [--data-disk-type-id ] + [--data-disk-size ] + [--data-disk-encryption-type ] + [--data-disk-name ] + [--attach-floating] + [--security-group ] + [--ssh-key-id ] + [--user-name ] + [--user-password ] + [--expire-password] + [--server-group-id ] + [--host-group-id ] + [--enable-backup] + [--backup-instance-point-id ] + [--snapshot-instance-point-id ] + [--period ] + [--is-poc] + [--is-enable-auto-renew] + [--os-licence] + [--user-data ] + [--user-data-base64-encoded] + [--dry-run] +``` + +### Options + +**Instance settings** + +`--name` (required) +: Server name. Must be 5–65 characters, alphanumeric, hyphens, and underscores, starting and ending with an alphanumeric character. + +`--flavor-id` (required) +: Flavor (instance type) ID. Run `grn vserver flavor list --family --code ` to browse options. + +`--image-id` (required) +: OS image ID. Run `grn vserver image list --type os` to browse options. + +`--zone-id` (required) +: Availability zone ID. Omit this flag to see available zones printed to stderr. + +**Networking** + +`--network-id` (required) +: VPC (network) ID. Run `grn vserver vpc list` to browse options. + +`--subnet-id` (required) +: Subnet ID within the VPC. Run `grn vserver subnet list --vpc-id ` to browse options. + +`--attach-floating` (boolean) +: Attach a floating (public) IP to the server. Default: `false`. + +`--security-group` (string) +: Comma-separated list of security group IDs to attach. + +**Root disk** + +`--root-disk-type-id` (required) +: Volume type ID for the root disk. Run `grn vserver volume-type list --zone-id --type SSD` to browse options. + +`--root-disk-size` (integer) +: Root disk size in GiB. Minimum: `20`. Default: `20`. + +`--root-disk-encryption-type` (string) +: Encryption type for the root disk. + +`--encryption-volume` (boolean) +: Encrypt the root volume. + +**Data disk (optional)** + +`--data-disk-type-id` (string) +: Volume type ID for an optional data disk. + +`--data-disk-size` (integer) +: Data disk size in GiB. Set to `0` to skip the data disk. + +`--data-disk-encryption-type` (string) +: Encryption type for the data disk. + +`--data-disk-name` (string) +: Name for the data disk. + +**Authentication** + +`--ssh-key-id` (string) +: SSH key pair ID to inject into the server. + +`--user-name` (string) +: OS login username. + +`--user-password` (string) +: OS login password. + +`--expire-password` (boolean) +: Force a password change on first login. Default: `true`. + +**Placement** + +`--server-group-id` (string) +: Server group ID for placement affinity/anti-affinity policy. + +`--host-group-id` (string) +: Dedicated host group ID. + +**Billing** + +`--period` (integer) +: Billing period in months. Default: `1`. + +`--is-poc` (boolean) +: Mark as a proof-of-concept (PoC) instance. + +`--is-enable-auto-renew` (boolean) +: Enable auto-renewal. + +`--os-licence` (boolean) +: Include OS licence in billing. + +**Backup and restore** + +`--enable-backup` (boolean) +: Enable backup for the server. + +`--backup-instance-point-id` (string) +: Restore from a backup instance point. + +`--snapshot-instance-point-id` (string) +: Restore from a snapshot instance point. + +**User data** + +`--user-data` (string) +: Cloud-init user data script. + +`--user-data-base64-encoded` (boolean) +: Indicate that `--user-data` is already base64-encoded. + +**Other** + +`--dry-run` (boolean) +: Validate all parameters and print a report without creating the server. + +### Examples + +```bash +# Minimal server +grn vserver server create \ + --name my-server \ + --flavor-id flv-2c4g \ + --image-id img-ubuntu-22-04 \ + --network-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 \ + --root-disk-type-id vtype-ssd-123 \ + --zone-id zone-abc123 + +# Server with floating IP, security group, and SSH key +grn vserver server create \ + --name web-server \ + --flavor-id flv-4c8g \ + --image-id img-ubuntu-22-04 \ + --network-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 \ + --root-disk-type-id vtype-ssd-123 \ + --root-disk-size 50 \ + --zone-id zone-abc123 \ + --attach-floating \ + --security-group sg-aaa111,sg-bbb222 \ + --ssh-key-id key-abc12345-0000-0000-0000-000000000001 + +# Dry-run to validate parameters +grn vserver server create \ + --name my-server \ + --flavor-id flv-2c4g \ + --image-id img-ubuntu-22-04 \ + --network-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 \ + --root-disk-type-id vtype-ssd-123 \ + --zone-id zone-abc123 \ + --dry-run +``` + +--- + +## start + +Start a stopped vServer instance. + +### Synopsis + +``` +grn vserver server start --server-id +``` + +### Options + +`--server-id` (required) +: Server UUID. + +### Examples + +```bash +grn vserver server start --server-id srv-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## stop + +Stop a running vServer instance. + +### Synopsis + +``` +grn vserver server stop --server-id +``` + +### Options + +`--server-id` (required) +: Server UUID. + +### Examples + +```bash +grn vserver server stop --server-id srv-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## reboot + +Reboot a vServer instance. + +### Synopsis + +``` +grn vserver server reboot --server-id +``` + +### Options + +`--server-id` (required) +: Server UUID. + +### Examples + +```bash +grn vserver server reboot --server-id srv-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## resize + +Change a vServer instance to a different flavor (instance type). The server must be stopped before resizing. + +### Synopsis + +``` +grn vserver server resize + --server-id + --flavor-id +``` + +### Options + +`--server-id` (required) +: Server UUID. + +`--flavor-id` (required) +: New flavor ID. Run `grn vserver flavor list --family --code ` to browse options. + +### Examples + +```bash +grn vserver server resize \ + --server-id srv-abc12345-0000-0000-0000-000000000001 \ + --flavor-id flv-8c16g +``` + +--- + +## delete + +Delete a vServer instance. Shows a preview and asks for confirmation unless `--force` is used. + +### Synopsis + +``` +grn vserver server delete + --server-id + [--delete-all-volumes] + [--force] +``` + +### Options + +`--server-id` (required) +: Server UUID. + +`--delete-all-volumes` (boolean) +: Also delete all volumes attached to the server. + +`--force` (boolean) +: Skip the confirmation prompt. + +### Examples + +```bash +# Interactive confirmation +grn vserver server delete --server-id srv-abc12345-0000-0000-0000-000000000001 + +# Delete server and all its volumes, no prompt +grn vserver server delete \ + --server-id srv-abc12345-0000-0000-0000-000000000001 \ + --delete-all-volumes \ + --force +``` diff --git a/docs/commands/vserver/subnet.md b/docs/commands/vserver/subnet.md new file mode 100644 index 0000000..9103884 --- /dev/null +++ b/docs/commands/vserver/subnet.md @@ -0,0 +1,159 @@ +# subnet + +Manage subnets within a VPC. + +```bash +grn vserver subnet [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List all subnets in a VPC | +| [get](#get) | Get details of a subnet | +| [create](#create) | Create a new subnet | +| [delete](#delete) | Delete a subnet | + +--- + +## list + +List all subnets within a VPC. + +### Synopsis + +``` +grn vserver subnet list + --vpc-id + [--page ] + [--page-size ] +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID. + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +### Examples + +```bash +grn vserver subnet list --vpc-id net-abc12345-0000-0000-0000-000000000001 +grn vserver subnet list --vpc-id net-abc12345-0000-0000-0000-000000000001 --output table +``` + +--- + +## get + +Get details of a subnet. + +### Synopsis + +``` +grn vserver subnet get + --vpc-id + --subnet-id +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID that the subnet belongs to. + +`--subnet-id` (required) +: Subnet UUID. + +### Examples + +```bash +grn vserver subnet get \ + --vpc-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## create + +Create a new subnet inside a VPC. + +### Synopsis + +``` +grn vserver subnet create + --vpc-id + --cidr + --zone-id + [--name ] +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID to create the subnet in. + +`--cidr` (required) +: CIDR block for the subnet, e.g. `10.0.1.0/24`. Must be within the VPC CIDR range and must not overlap with other subnets. + +`--zone-id` (required) +: Availability zone ID. Omit this flag to see available zones printed to stderr. + +`--name` (string) +: Subnet name. + +### Examples + +```bash +grn vserver subnet create \ + --vpc-id net-abc12345-0000-0000-0000-000000000001 \ + --cidr 10.0.1.0/24 \ + --zone-id zone-abc123 \ + --name subnet-app +``` + +--- + +## delete + +Delete a subnet. Shows a confirmation prompt unless `--force` is used. + +### Synopsis + +``` +grn vserver subnet delete + --vpc-id + --subnet-id + [--force] +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID that the subnet belongs to. + +`--subnet-id` (required) +: Subnet UUID. + +`--force` (boolean) +: Skip the confirmation prompt. + +### Examples + +```bash +grn vserver subnet delete \ + --vpc-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 + +# No prompt +grn vserver subnet delete \ + --vpc-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 \ + --force +``` diff --git a/docs/commands/vserver/volume-type.md b/docs/commands/vserver/volume-type.md new file mode 100644 index 0000000..faafa12 --- /dev/null +++ b/docs/commands/vserver/volume-type.md @@ -0,0 +1,51 @@ +# volume-type + +Browse available volume types for a zone. Volume type IDs are used when creating volumes and servers. + +```bash +grn vserver volume-type [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List available volume types for a zone | + +--- + +## list + +List volume types available in a specific availability zone. Volume types are grouped by storage class (e.g. SSD, NVMe). + +### Synopsis + +``` +grn vserver volume-type list + --zone-id + --type +``` + +### Options + +`--zone-id` (required) +: Availability zone ID. Omit to see available zones printed to stderr. + +`--type` (required) +: Volume type zone name (e.g. `SSD`, `NVMe`). Omit to see available type names for the zone printed to stderr. + +### Examples + +```bash +# See what zones are available (omit --zone-id) +grn vserver volume-type list + +# See what types are available in a zone (omit --type) +grn vserver volume-type list --zone-id zone-abc123 + +# List SSD volume types in a zone +grn vserver volume-type list --zone-id zone-abc123 --type SSD + +# List NVMe volume types +grn vserver volume-type list --zone-id zone-abc123 --type NVMe +``` diff --git a/docs/commands/vserver/volume.md b/docs/commands/vserver/volume.md new file mode 100644 index 0000000..7b6dd93 --- /dev/null +++ b/docs/commands/vserver/volume.md @@ -0,0 +1,225 @@ +# volume + +Manage block storage volumes. + +```bash +grn vserver volume [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List all volumes | +| [get](#get) | Get details of a volume | +| [create](#create) | Create a new volume | +| [resize](#resize) | Resize or change the type of a volume | +| [delete](#delete) | Delete a volume | + +--- + +## list + +List all volumes in your project. + +### Synopsis + +``` +grn vserver volume list + [--page ] + [--page-size ] + [--name ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--name` (string) +: Filter by volume name (substring match). + +### Examples + +```bash +grn vserver volume list +grn vserver volume list --name data --output table +``` + +--- + +## get + +Get details of a volume. + +### Synopsis + +``` +grn vserver volume get --volume-id +``` + +### Options + +`--volume-id` (required) +: Volume UUID. + +### Examples + +```bash +grn vserver volume get --volume-id vol-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## create + +Create a new block storage volume. + +### Synopsis + +``` +grn vserver volume create + --name + --volume-type-id + --size + [--zone-id ] + [--description ] + [--encryption-type ] + [--multiattach] + [--is-poc] + [--dry-run] +``` + +### Options + +`--name` (required) +: Volume name. + +`--volume-type-id` (required) +: Volume type ID. Run `grn vserver volume-type list --zone-id --type SSD` to see options. + +`--size` (required) +: Volume size in GiB. + +`--zone-id` (string) +: Availability zone ID. Omit to see available zones. + +`--description` (string) +: Volume description. + +`--encryption-type` (string) +: Encryption type for the volume. + +`--multiattach` (boolean) +: Allow the volume to be attached to multiple servers simultaneously. + +`--is-poc` (boolean) +: Mark as a proof-of-concept volume. + +`--dry-run` (boolean) +: Validate parameters without creating the volume. + +### Examples + +```bash +grn vserver volume create \ + --name data-vol \ + --volume-type-id vtype-ssd-123 \ + --size 100 \ + --zone-id zone-abc123 + +# Validate first +grn vserver volume create \ + --name data-vol \ + --volume-type-id vtype-ssd-123 \ + --size 100 \ + --dry-run +``` + +--- + +## resize + +Resize a volume's size in GiB, change its volume type, or both. Prints the current state and planned change before applying. + +At least one of `--size` or `--volume-type-id` must be provided. + +### Synopsis + +``` +grn vserver volume resize + --volume-id + [--size ] + [--volume-type-id ] + [--dry-run] +``` + +### Options + +`--volume-id` (required) +: Volume UUID. + +`--size` (integer) +: New volume size in GiB. Must be equal to or greater than the current size (volumes cannot be shrunk). + +`--volume-type-id` (string) +: New volume type ID. If omitted, the current volume type is preserved. + +`--dry-run` (boolean) +: Validate parameters without sending the resize request. + +### Examples + +```bash +# Expand volume to 200 GiB +grn vserver volume resize \ + --volume-id vol-abc12345-0000-0000-0000-000000000001 \ + --size 200 + +# Change volume type +grn vserver volume resize \ + --volume-id vol-abc12345-0000-0000-0000-000000000001 \ + --volume-type-id vtype-nvme-456 + +# Resize and change type at the same time +grn vserver volume resize \ + --volume-id vol-abc12345-0000-0000-0000-000000000001 \ + --size 200 \ + --volume-type-id vtype-nvme-456 +``` + +--- + +## delete + +Delete a volume. Shows a confirmation prompt unless `--force` is used. + +### Synopsis + +``` +grn vserver volume delete + --volume-id + [--force] +``` + +### Options + +`--volume-id` (required) +: Volume UUID. + +`--force` (boolean) +: Skip the confirmation prompt. + +### Examples + +```bash +grn vserver volume delete --volume-id vol-abc12345-0000-0000-0000-000000000001 + +# No prompt +grn vserver volume delete \ + --volume-id vol-abc12345-0000-0000-0000-000000000001 \ + --force +``` diff --git a/docs/commands/vserver/vpc.md b/docs/commands/vserver/vpc.md new file mode 100644 index 0000000..55c73c4 --- /dev/null +++ b/docs/commands/vserver/vpc.md @@ -0,0 +1,152 @@ +# vpc + +Manage VPC (Virtual Private Cloud) networks. + +```bash +grn vserver vpc [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List all VPCs | +| [get](#get) | Get details of a VPC | +| [create](#create) | Create a new VPC | +| [delete](#delete) | Delete a VPC | + +--- + +## list + +List all VPCs in your project. + +### Synopsis + +``` +grn vserver vpc list + [--page ] + [--page-size ] + [--name ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--name` (string) +: Filter by VPC name (substring match). + +### Examples + +```bash +grn vserver vpc list +grn vserver vpc list --name prod --output table +``` + +--- + +## get + +Get details of a VPC. + +### Synopsis + +``` +grn vserver vpc get --vpc-id +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID. + +### Examples + +```bash +grn vserver vpc get --vpc-id net-abc12345-0000-0000-0000-000000000001 +``` + +--- + +## create + +Create a new VPC network. + +### Synopsis + +``` +grn vserver vpc create + --name + --cidr + [--description ] + [--is-default] + [--dry-run] +``` + +### Options + +`--name` (required) +: VPC name. + +`--cidr` (required) +: CIDR block for the VPC, e.g. `10.0.0.0/16`. The CIDR must not overlap with other VPCs in the same project. + +`--description` (string) +: VPC description. + +`--is-default` (boolean) +: Mark this VPC as the default network. + +`--dry-run` (boolean) +: Validate parameters without creating the VPC. + +### Examples + +```bash +grn vserver vpc create --name prod-vpc --cidr 10.0.0.0/16 + +grn vserver vpc create \ + --name staging-vpc \ + --cidr 10.1.0.0/16 \ + --description "Staging environment VPC" + +# Validate first +grn vserver vpc create --name prod-vpc --cidr 10.0.0.0/16 --dry-run +``` + +--- + +## delete + +Delete a VPC. Shows a confirmation prompt unless `--force` is used. + +### Synopsis + +``` +grn vserver vpc delete + --vpc-id + [--force] +``` + +### Options + +`--vpc-id` (required) +: VPC (network) ID. + +`--force` (boolean) +: Skip the confirmation prompt. + +### Examples + +```bash +# Interactive confirmation +grn vserver vpc delete --vpc-id net-abc12345-0000-0000-0000-000000000001 + +# No prompt +grn vserver vpc delete --vpc-id net-abc12345-0000-0000-0000-000000000001 --force +``` diff --git a/go/cmd/vserver/flavor/flavor.go b/go/cmd/vserver/flavor/flavor.go new file mode 100644 index 0000000..f20a6b6 --- /dev/null +++ b/go/cmd/vserver/flavor/flavor.go @@ -0,0 +1,21 @@ +package flavor + +import ( + "github.com/spf13/cobra" +) + +// FlavorCmd is the parent command for all flavor subcommands. +var FlavorCmd = &cobra.Command{ + Use: "flavor", + Short: "Manage vServer flavors", + Long: "List and inspect available vServer flavors.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + FlavorCmd.AddCommand(listCmd) + FlavorCmd.AddCommand(listFamiliesCmd) + FlavorCmd.AddCommand(listCodesCmd) +} diff --git a/go/cmd/vserver/flavor/helpers.go b/go/cmd/vserver/flavor/helpers.go new file mode 100644 index 0000000..519d4e0 --- /dev/null +++ b/go/cmd/vserver/flavor/helpers.go @@ -0,0 +1,153 @@ +package flavor + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func transformFamilies(result interface{}) interface{} { + transform := func(item interface{}) interface{} { + obj, ok := item.(map[string]interface{}) + if !ok { + return item + } + out := map[string]interface{}{ + "name": obj["key"], + "description": obj["description"], + } + if cond, ok := obj["condition"].(map[string]interface{}); ok { + out["codes"] = cond["codes"] + } else { + out["codes"] = []interface{}{} + } + return out + } + + switch v := result.(type) { + case []interface{}: + transformed := make([]interface{}, len(v)) + for i, item := range v { + transformed[i] = transform(item) + } + return transformed + case map[string]interface{}: + if items, ok := v["data"].([]interface{}); ok { + transformed := make([]interface{}, len(items)) + for i, item := range items { + transformed[i] = transform(item) + } + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["data"] = transformed + return out + } + } + return result +} + +func transformCodes(result interface{}) interface{} { + transform := func(item interface{}) interface{} { + obj, ok := item.(map[string]interface{}) + if !ok { + return item + } + return map[string]interface{}{ + "name": obj["key"], + "description": obj["description"], + } + } + + switch v := result.(type) { + case []interface{}: + transformed := make([]interface{}, len(v)) + for i, item := range v { + transformed[i] = transform(item) + } + return transformed + case map[string]interface{}: + if items, ok := v["data"].([]interface{}); ok { + transformed := make([]interface{}, len(items)) + for i, item := range items { + transformed[i] = transform(item) + } + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["data"] = transformed + return out + } + } + return result +} + +// dropField removes a field from every object in the response (bare array or {"data":[...]} envelope). +func dropField(result interface{}, field string) interface{} { + deleteFromItems := func(items []interface{}) { + for _, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + delete(obj, field) + } + } + } + + switch v := result.(type) { + case []interface{}: + deleteFromItems(v) + case map[string]interface{}: + if items, ok := v["data"].([]interface{}); ok { + deleteFromItems(items) + } else { + delete(v, field) + } + } + return result +} + +// extractStringSlice converts an API response to a []string for shell completion. +// Handles both a top-level []interface{} and a map with a "data" key. +func extractStringSlice(result interface{}) []string { + var items []interface{} + + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + if d, ok := v["data"].([]interface{}); ok { + items = d + } + } + + out := make([]string, 0, len(items)) + for _, item := range items { + switch s := item.(type) { + case string: + out = append(out, s) + case map[string]interface{}: + // try common name fields + for _, key := range []string{"name", "code", "id", "value"} { + if val, ok := s[key].(string); ok { + out = append(out, val) + break + } + } + } + } + return out +} diff --git a/go/cmd/vserver/flavor/list.go b/go/cmd/vserver/flavor/list.go new file mode 100644 index 0000000..d213bdd --- /dev/null +++ b/go/cmd/vserver/flavor/list.go @@ -0,0 +1,151 @@ +package flavor + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List available flavors", + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", 50, "Number of items per page") + f.String("zone-id", "", "Filter results by availability zone ID (optional)") + f.String("family", "", "Filter by instance family (run 'flavor list-families' to see options) (required)") + f.String("code", "", "Filter by CPU platform code (run 'flavor list-codes' to see options) (required)") + + listCmd.RegisterFlagCompletionFunc("family", completeFamilies) //nolint:errcheck + listCmd.RegisterFlagCompletionFunc("code", completeCodes) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + zoneID, _ := cmd.Flags().GetString("zone-id") + instanceFamily, _ := cmd.Flags().GetString("family") + cpuPlatform, _ := cmd.Flags().GetString("code") + + if instanceFamily == "" { + return suggestFamilies(apiClient, projectID) + } + if cpuPlatform == "" { + return suggestCodes(apiClient, projectID) + } + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + if zoneID != "" { + params["zoneId"] = zoneID + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavors/families/%s/platforms/%s", projectID, instanceFamily, cpuPlatform), params) + if err != nil { + return fmt.Errorf("failed to list flavors: %w", err) + } + + return outputResult(cmd, cfg, filterFlavors(result)) +} + +func suggestFamilies(apiClient interface { + Get(string, map[string]string) (interface{}, error) +}, projectID string) error { + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/families", projectID), nil) + if err != nil { + return fmt.Errorf("--family is required. Also failed to fetch available families: %w", err) + } + fmt.Fprintln(os.Stderr, "Flag --family is required. Available instance families:") + enc := json.NewEncoder(os.Stderr) + enc.SetIndent("", " ") + enc.Encode(transformFamilies(result)) //nolint:errcheck + return fmt.Errorf("flag --family is required") +} + +func suggestCodes(apiClient interface { + Get(string, map[string]string) (interface{}, error) +}, projectID string) error { + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/codes", projectID), nil) + if err != nil { + return fmt.Errorf("--code is required. Also failed to fetch available codes: %w", err) + } + fmt.Fprintln(os.Stderr, "Flag --code is required. Available CPU platform codes:") + enc := json.NewEncoder(os.Stderr) + enc.SetIndent("", " ") + enc.Encode(transformCodes(result)) //nolint:errcheck + return fmt.Errorf("flag --code is required") +} + +// filterFlavors keeps only flavors with remainingVms > 1 and removes +// the metadata and remainingVms fields from each item. +func filterFlavors(result interface{}) interface{} { + // Unwrap envelope: {"data": [...]} or bare [...] + var items []interface{} + var envelope map[string]interface{} + + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + envelope = v + if d, ok := v["data"].([]interface{}); ok { + items = d + } else { + return result + } + default: + return result + } + + filtered := make([]interface{}, 0, len(items)) + for _, item := range items { + flavor, ok := item.(map[string]interface{}) + if !ok { + continue + } + remaining, _ := flavor["remainingVms"].(float64) + if remaining <= 1 { + continue + } + delete(flavor, "metaData") + delete(flavor, "remainingVms") + delete(flavor, "zoneId") + delete(flavor, "isSoldOut") + filtered = append(filtered, flavor) + } + + if envelope != nil { + out := make(map[string]interface{}, len(envelope)) + for k, v := range envelope { + out[k] = v + } + out["data"] = filtered + return out + } + return filtered +} diff --git a/go/cmd/vserver/flavor/list_codes.go b/go/cmd/vserver/flavor/list_codes.go new file mode 100644 index 0000000..d23a746 --- /dev/null +++ b/go/cmd/vserver/flavor/list_codes.go @@ -0,0 +1,52 @@ +package flavor + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCodesCmd = &cobra.Command{ + Use: "list-codes", + Short: "List available CPU platform codes", + RunE: runListCodes, +} + +func runListCodes(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/codes", projectID), nil) + if err != nil { + return fmt.Errorf("failed to list CPU platform codes: %w", err) + } + + return outputResult(cmd, cfg, transformCodes(result)) +} + +// completeCodes is used by RegisterFlagCompletionFunc for --cpu-platform. +func completeCodes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + projectID, err := getProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/codes", projectID), nil) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return extractStringSlice(result), cobra.ShellCompDirectiveNoFileComp +} diff --git a/go/cmd/vserver/flavor/list_families.go b/go/cmd/vserver/flavor/list_families.go new file mode 100644 index 0000000..7c788c1 --- /dev/null +++ b/go/cmd/vserver/flavor/list_families.go @@ -0,0 +1,52 @@ +package flavor + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listFamiliesCmd = &cobra.Command{ + Use: "list-families", + Short: "List available instance families", + RunE: runListFamilies, +} + +func runListFamilies(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/families", projectID), nil) + if err != nil { + return fmt.Errorf("failed to list instance families: %w", err) + } + + return outputResult(cmd, cfg, transformFamilies(result)) +} + +// completeFamilies is used by RegisterFlagCompletionFunc for --instance-family. +func completeFamilies(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + projectID, err := getProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/flavor_zones/families", projectID), nil) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return extractStringSlice(result), cobra.ShellCompDirectiveNoFileComp +} diff --git a/go/cmd/vserver/image/helpers.go b/go/cmd/vserver/image/helpers.go new file mode 100644 index 0000000..5c92ff4 --- /dev/null +++ b/go/cmd/vserver/image/helpers.go @@ -0,0 +1,20 @@ +package image + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} diff --git a/go/cmd/vserver/image/image.go b/go/cmd/vserver/image/image.go new file mode 100644 index 0000000..abd6a25 --- /dev/null +++ b/go/cmd/vserver/image/image.go @@ -0,0 +1,19 @@ +package image + +import ( + "github.com/spf13/cobra" +) + +// ImageCmd is the parent command for all image subcommands. +var ImageCmd = &cobra.Command{ + Use: "image", + Short: "Manage vServer images", + Long: "List available vServer images by type (os, gpu).", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + ImageCmd.AddCommand(listCmd) +} diff --git a/go/cmd/vserver/image/list.go b/go/cmd/vserver/image/list.go new file mode 100644 index 0000000..f1a0f00 --- /dev/null +++ b/go/cmd/vserver/image/list.go @@ -0,0 +1,149 @@ +package image + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var supportedTypes = []string{"os", "gpu"} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List available images", + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.String("type", "", "Image type: os or gpu") + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", 50, "Number of items per page") + f.String("image-version", "", "Filter by imageVersion (client-side substring match)") + + listCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { //nolint:errcheck + return supportedTypes, cobra.ShellCompDirectiveNoFileComp + }) +} + +func runList(cmd *cobra.Command, args []string) error { + imageType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + imageVersion, _ := cmd.Flags().GetString("image-version") + + if imageType == "" { + fmt.Fprintln(os.Stderr, "Flag --type is required. Available image types:") + for _, t := range supportedTypes { + fmt.Fprintf(os.Stderr, " - %s\n", t) + } + return fmt.Errorf("flag --type is required") + } + + if !validImageType(imageType) { + fmt.Fprintln(os.Stderr, "Invalid --type value. Available image types:") + for _, t := range supportedTypes { + fmt.Fprintf(os.Stderr, " - %s\n", t) + } + return fmt.Errorf("invalid image type %q (must be one of: os, gpu)", imageType) + } + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/images/%s", projectID, imageType), params) + if err != nil { + return fmt.Errorf("failed to list %s images: %w", imageType, err) + } + + return outputResult(cmd, cfg, filterByImageVersion(dropImageFields(result), imageVersion)) +} + +func filterByImageVersion(result interface{}, version string) interface{} { + if version == "" { + return result + } + filter := func(items []interface{}) []interface{} { + out := make([]interface{}, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + v, _ := obj["imageVersion"].(string) + if strings.Contains(strings.ToLower(v), strings.ToLower(version)) { + out = append(out, item) + } + } + return out + } + + switch v := result.(type) { + case []interface{}: + return filter(v) + case map[string]interface{}: + for _, key := range []string{"images", "data"} { + if items, ok := v[key].([]interface{}); ok { + v[key] = filter(items) + return v + } + } + } + return result +} + +func dropImageFields(result interface{}) interface{} { + drop := func(items []interface{}) { + for _, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + delete(obj, "flavorZoneIds") + delete(obj, "zoneId") + } + } + } + + switch v := result.(type) { + case []interface{}: + drop(v) + return result + case map[string]interface{}: + // Unwrap envelope — keep only the images list + for _, key := range []string{"images", "data"} { + if items, ok := v[key].([]interface{}); ok { + drop(items) + return map[string]interface{}{key: items} + } + } + } + return result +} + +func validImageType(t string) bool { + for _, s := range supportedTypes { + if s == t { + return true + } + } + return false +} diff --git a/go/cmd/vserver/secgroup/create.go b/go/cmd/vserver/secgroup/create.go new file mode 100644 index 0000000..b9db238 --- /dev/null +++ b/go/cmd/vserver/secgroup/create.go @@ -0,0 +1,47 @@ +package secgroup + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new security group", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + f.String("name", "", "Security group name (required)") + f.String("description", "", "Security group description") + createCmd.MarkFlagRequired("name") +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + body := map[string]interface{}{ + "name": name, + "description": nilIfEmpty(description), + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/secgroups", projectID), body) + if err != nil { + return fmt.Errorf("failed to create security group: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/delete.go b/go/cmd/vserver/secgroup/delete.go new file mode 100644 index 0000000..2362de2 --- /dev/null +++ b/go/cmd/vserver/secgroup/delete.go @@ -0,0 +1,85 @@ +package secgroup + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a security group", + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("secgroup-id", "", "Security group ID (required)") + f.Bool("force", false, "Skip confirmation prompt") + deleteCmd.MarkFlagRequired("secgroup-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + response, err := apiClient.Get(fmt.Sprintf("/v2/%s/secgroups/%s", projectID, secgroupID), nil) + if err != nil { + return fmt.Errorf("failed to fetch security group %s: %w", secgroupID, err) + } + + if err := printSecgroupDeletePreview(response); err != nil { + return err + } + + if !force { + fmt.Print("\nAre you sure you want to delete this security group? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + result, err := apiClient.Delete(fmt.Sprintf("/v2/%s/secgroups/%s", projectID, secgroupID), nil) + if err != nil { + return fmt.Errorf("failed to delete security group %s: %w", secgroupID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printSecgroupDeletePreview(sg interface{}) error { + s, ok := sg.(map[string]interface{}) + if !ok || s == nil { + return fmt.Errorf("could not parse security group details from API response (type: %T)", sg) + } + fmt.Println("The following security group will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", s["id"]) + fmt.Printf(" Name: %v\n", s["name"]) + fmt.Printf(" Description: %v\n", s["description"]) + fmt.Println() + fmt.Println("This action is irreversible.") + return nil +} diff --git a/go/cmd/vserver/secgroup/get.go b/go/cmd/vserver/secgroup/get.go new file mode 100644 index 0000000..995a269 --- /dev/null +++ b/go/cmd/vserver/secgroup/get.go @@ -0,0 +1,46 @@ +package secgroup + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a security group", + RunE: runGet, +} + +func init() { + getCmd.Flags().String("secgroup-id", "", "Security group ID (required)") + if err := getCmd.MarkFlagRequired("secgroup-id"); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", "secgroup-id", err)) + } +} + +func runGet(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/secgroups/%s", projectID, secgroupID), nil) + if err != nil { + return fmt.Errorf("failed to get security group %s: %w", secgroupID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/helpers.go b/go/cmd/vserver/secgroup/helpers.go new file mode 100644 index 0000000..09e1aff --- /dev/null +++ b/go/cmd/vserver/secgroup/helpers.go @@ -0,0 +1,27 @@ +package secgroup + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/go/cmd/vserver/secgroup/list.go b/go/cmd/vserver/secgroup/list.go new file mode 100644 index 0000000..0faec90 --- /dev/null +++ b/go/cmd/vserver/secgroup/list.go @@ -0,0 +1,57 @@ +package secgroup + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all security groups", + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.Flags().String("name", "", "Filter by security group name (substring match)") +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + filterName, _ := cmd.Flags().GetString("name") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + if filterName != "" { + params["name"] = filterName + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/secgroups", projectID), params) + if err != nil { + return fmt.Errorf("failed to list security groups: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/rule/create.go b/go/cmd/vserver/secgroup/rule/create.go new file mode 100644 index 0000000..8ac8e9a --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/create.go @@ -0,0 +1,111 @@ +package rule + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new rule in a security group", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + + // Required + f.String("secgroup-id", "", "Security group ID (required)") + f.String("direction", "", "Traffic direction: ingress or egress (required)") + f.String("protocol", "", "Protocol: tcp, udp, icmp, or any (required)") + + f.Int("port-range-min", 0, "Minimum port number (required for tcp/udp; not valid for icmp/any)") + f.Int("port-range-max", 0, "Maximum port number (required for tcp/udp; not valid for icmp/any)") + f.String("remote-ip-prefix", "", "Remote CIDR, e.g. 0.0.0.0/0 (required)") + f.String("remote-group-id", "", "Remote security group ID (required)") + f.String("ether-type", "IPv4", "Ether type: IPv4 or IPv6 (required)") + + for _, name := range []string{"secgroup-id", "direction", "protocol", "port-range-min", "port-range-max", "ether-type", "remote-ip-prefix"} { + if err := createCmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", name, err)) + } + } + f.String("description", "", "Rule description") +} + +var validProtocols = map[string]bool{ + "tcp": true, "udp": true, "icmp": true, "any": true, +} + +func runCreate(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + direction, _ := cmd.Flags().GetString("direction") + protocol, _ := cmd.Flags().GetString("protocol") + portMin, _ := cmd.Flags().GetInt("port-range-min") + portMax, _ := cmd.Flags().GetInt("port-range-max") + remoteIP, _ := cmd.Flags().GetString("remote-ip-prefix") + remoteGroupID, _ := cmd.Flags().GetString("remote-group-id") + etherType, _ := cmd.Flags().GetString("ether-type") + description, _ := cmd.Flags().GetString("description") + + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + + if direction != "ingress" && direction != "egress" { + return fmt.Errorf("--direction must be 'ingress' or 'egress', got %q", direction) + } + + proto := strings.ToLower(protocol) + if !validProtocols[proto] { + return fmt.Errorf("--protocol must be one of tcp, udp, icmp, any — got %q", protocol) + } + + portMinSet := cmd.Flags().Changed("port-range-min") + portMaxSet := cmd.Flags().Changed("port-range-max") + + if (proto == "icmp" || proto == "any") && (portMinSet || portMaxSet) { + return fmt.Errorf("--port-range-min/max must not be set when protocol is %q", protocol) + } + + if portMinSet && portMaxSet && portMin > portMax { + return fmt.Errorf("--port-range-min (%d) must be ≤ --port-range-max (%d)", portMin, portMax) + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + body := map[string]interface{}{ + "direction": direction, + "protocol": proto, + "etherType": etherType, + "remoteIpPrefix": nilIfEmpty(remoteIP), + "remoteGroupId": nilIfEmpty(remoteGroupID), + "description": nilIfEmpty(description), + } + + // Only include port range fields when explicitly set by the user + if portMinSet { + body["portRangeMin"] = portMin + } + if portMaxSet { + body["portRangeMax"] = portMax + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/secgroups/%s/secgroupRules", projectID, secgroupID), body) + if err != nil { + return fmt.Errorf("failed to create rule in security group %s: %w", secgroupID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/rule/delete.go b/go/cmd/vserver/secgroup/rule/delete.go new file mode 100644 index 0000000..d724b0f --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/delete.go @@ -0,0 +1,51 @@ +package rule + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a rule from a security group", + RunE: runDelete, +} + +func init() { + deleteCmd.Flags().String("secgroup-id", "", "Security group ID (required)") + deleteCmd.Flags().String("rule-id", "", "Security group rule ID (required)") + deleteCmd.MarkFlagRequired("secgroup-id") + deleteCmd.MarkFlagRequired("rule-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + ruleID, _ := cmd.Flags().GetString("rule-id") + + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + if err := validator.ValidateID(ruleID, "rule-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Delete( + fmt.Sprintf("/v2/%s/secgroups/%s/secgroupRules/%s", projectID, secgroupID, ruleID), nil) + if err != nil { + return fmt.Errorf("failed to delete rule %s from security group %s: %w", ruleID, secgroupID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/rule/get.go b/go/cmd/vserver/secgroup/rule/get.go new file mode 100644 index 0000000..6059e3d --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/get.go @@ -0,0 +1,55 @@ +package rule + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a security group rule", + RunE: runGet, +} + +func init() { + f := getCmd.Flags() + f.String("secgroup-id", "", "Security group ID (required)") + f.String("rule-id", "", "Security group rule ID (required)") + + for _, name := range []string{"secgroup-id", "rule-id"} { + if err := getCmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", name, err)) + } + } +} + +func runGet(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + ruleID, _ := cmd.Flags().GetString("rule-id") + + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + if err := validator.ValidateID(ruleID, "rule-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/secgroups/%s/secgroupRules/%s", projectID, secgroupID, ruleID), nil) + if err != nil { + return fmt.Errorf("failed to get rule %s: %w", ruleID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/rule/helpers.go b/go/cmd/vserver/secgroup/rule/helpers.go new file mode 100644 index 0000000..e1a6788 --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/helpers.go @@ -0,0 +1,27 @@ +package rule + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/go/cmd/vserver/secgroup/rule/list.go b/go/cmd/vserver/secgroup/rule/list.go new file mode 100644 index 0000000..e98654f --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/list.go @@ -0,0 +1,60 @@ +package rule + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all rules in a security group", + RunE: runList, +} + +func init() { + listCmd.Flags().String("secgroup-id", "", "Security group ID (required)") + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.MarkFlagRequired("secgroup-id") +} + +func runList(cmd *cobra.Command, args []string) error { + secgroupID, _ := cmd.Flags().GetString("secgroup-id") + if err := validator.ValidateID(secgroupID, "secgroup-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/secgroups/%s/secGroupRules", projectID, secgroupID), params) + if err != nil { + return fmt.Errorf("failed to list rules for security group %s: %w", secgroupID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/secgroup/rule/rule.go b/go/cmd/vserver/secgroup/rule/rule.go new file mode 100644 index 0000000..770fc5a --- /dev/null +++ b/go/cmd/vserver/secgroup/rule/rule.go @@ -0,0 +1,22 @@ +package rule + +import ( + "github.com/spf13/cobra" +) + +// RuleCmd is the parent command for all security group rule subcommands. +var RuleCmd = &cobra.Command{ + Use: "rule", + Short: "Manage security group rules", + Long: "Create, list, and delete rules within a security group.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + RuleCmd.AddCommand(listCmd) + RuleCmd.AddCommand(getCmd) + RuleCmd.AddCommand(createCmd) + RuleCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vserver/secgroup/secgroup.go b/go/cmd/vserver/secgroup/secgroup.go new file mode 100644 index 0000000..4065087 --- /dev/null +++ b/go/cmd/vserver/secgroup/secgroup.go @@ -0,0 +1,24 @@ +package secgroup + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/cmd/vserver/secgroup/rule" +) + +// SecgroupCmd is the parent command for all security group subcommands. +var SecgroupCmd = &cobra.Command{ + Use: "secgroup", + Short: "Manage security groups", + Long: "Create, list, and delete security groups and their rules.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + SecgroupCmd.AddCommand(listCmd) + SecgroupCmd.AddCommand(getCmd) + SecgroupCmd.AddCommand(createCmd) + SecgroupCmd.AddCommand(deleteCmd) + SecgroupCmd.AddCommand(rule.RuleCmd) +} diff --git a/go/cmd/vserver/secgroup/zcompletion.go b/go/cmd/vserver/secgroup/zcompletion.go new file mode 100644 index 0000000..358f135 --- /dev/null +++ b/go/cmd/vserver/secgroup/zcompletion.go @@ -0,0 +1,8 @@ +package secgroup + +import "github.com/vngcloud/greennode-cli/internal/vserverclient" + +func init() { + getCmd.RegisterFlagCompletionFunc("secgroup-id", vserverclient.CompleteSecgroupIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("secgroup-id", vserverclient.CompleteSecgroupIDs) //nolint:errcheck +} diff --git a/go/cmd/vserver/server/create.go b/go/cmd/vserver/server/create.go new file mode 100644 index 0000000..642e44f --- /dev/null +++ b/go/cmd/vserver/server/create.go @@ -0,0 +1,235 @@ +package server + +import ( + "fmt" + "regexp" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var serverNameRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9\-_]{0,63}[a-zA-Z0-9]$`) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new vServer instance", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + + // Required + f.String("name", "", "Server name (required)") + f.String("flavor-id", "", "Flavor ID — run 'vserver flavor list' to see options (required)") + f.String("image-id", "", "Image ID — run 'vserver image list --type os|gpu' to see options (required)") + f.String("network-id", "", "VPC ID — run 'vserver vpc list' to see options (required)") + f.String("subnet-id", "", "Subnet ID — run 'vserver subnet list --vpc-id ' to see options (required)") + f.String("root-disk-type-id", "", "Volume type ID — run 'vserver volume-type list' to see options (required)") + f.String("zone-id", "", "Availability zone ID — run without this flag to see available zones (required)") + + if err := createCmd.MarkFlagRequired("name"); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", "name", err)) + } + + // Root disk + f.Int("root-disk-size", 20, "Root disk size in GiB (minimum 20)") + f.String("root-disk-encryption-type", "", "Root disk encryption type") + f.Bool("encryption-volume", false, "Encrypt the root volume") + + // Data disk (optional) + f.String("data-disk-type-id", "", "Data disk volume type ID") + f.Int("data-disk-size", 0, "Data disk size in GiB (0 = no data disk)") + f.String("data-disk-encryption-type", "", "Data disk encryption type") + f.String("data-disk-name", "", "Data disk name") + + // Network + f.Bool("attach-floating", false, "Attach a floating IP to the server") + f.String("security-group", "", "Security group IDs (comma-separated)") + + // Auth + f.String("ssh-key-id", "", "SSH key ID to inject into the server") + f.String("user-name", "", "OS login username") + f.String("user-password", "", "OS login password") + f.Bool("expire-password", true, "Force password change on first login") + + // Placement + f.String("server-group-id", "", "Server group ID for placement policy") + f.String("host-group-id", "", "Dedicated host group ID") + + // Backup / restore + f.Bool("enable-backup", false, "Enable backup for the server") + f.String("backup-instance-point-id", "", "Backup instance point ID to restore from") + f.String("snapshot-instance-point-id", "", "Snapshot instance point ID to restore from") + + // Billing + f.Int("period", 1, "Billing period in months") + f.Bool("is-poc", false, "Mark as PoC (proof-of-concept) instance") + f.Bool("is-enable-auto-renew", false, "Enable auto-renewal") + f.Bool("os-licence", false, "Include OS licence in billing") + + // User data + f.String("user-data", "", "User data script passed to cloud-init") + f.Bool("user-data-base64-encoded", false, "Indicate that --user-data value is already base64-encoded") + + f.Bool("dry-run", false, "Validate parameters without creating the server") +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + flavorID, _ := cmd.Flags().GetString("flavor-id") + imageID, _ := cmd.Flags().GetString("image-id") + networkID, _ := cmd.Flags().GetString("network-id") + subnetID, _ := cmd.Flags().GetString("subnet-id") + rootDiskTypeID, _ := cmd.Flags().GetString("root-disk-type-id") + zoneID, _ := cmd.Flags().GetString("zone-id") + rootDiskSize, _ := cmd.Flags().GetInt("root-disk-size") + rootDiskEncType, _ := cmd.Flags().GetString("root-disk-encryption-type") + encryptionVolume, _ := cmd.Flags().GetBool("encryption-volume") + dataDiskTypeID, _ := cmd.Flags().GetString("data-disk-type-id") + dataDiskSize, _ := cmd.Flags().GetInt("data-disk-size") + dataDiskEncType, _ := cmd.Flags().GetString("data-disk-encryption-type") + dataDiskName, _ := cmd.Flags().GetString("data-disk-name") + attachFloating, _ := cmd.Flags().GetBool("attach-floating") + securityGroup, _ := cmd.Flags().GetString("security-group") + sshKeyID, _ := cmd.Flags().GetString("ssh-key-id") + userName, _ := cmd.Flags().GetString("user-name") + userPassword, _ := cmd.Flags().GetString("user-password") + expirePassword, _ := cmd.Flags().GetBool("expire-password") + serverGroupID, _ := cmd.Flags().GetString("server-group-id") + hostGroupID, _ := cmd.Flags().GetString("host-group-id") + enableBackup, _ := cmd.Flags().GetBool("enable-backup") + backupPointID, _ := cmd.Flags().GetString("backup-instance-point-id") + snapshotPointID, _ := cmd.Flags().GetString("snapshot-instance-point-id") + period, _ := cmd.Flags().GetInt("period") + isPoc, _ := cmd.Flags().GetBool("is-poc") + autoRenew, _ := cmd.Flags().GetBool("is-enable-auto-renew") + osLicence, _ := cmd.Flags().GetBool("os-licence") + userData, _ := cmd.Flags().GetString("user-data") + userDataB64, _ := cmd.Flags().GetBool("user-data-base64-encoded") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if dryRun { + return validateCreate(name, flavorID, imageID, networkID, subnetID, rootDiskTypeID, zoneID, rootDiskSize) + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + if zoneID == "" { + return suggestZones(apiClient, projectID) + } + if networkID == "" { + return suggestVPCs(apiClient, projectID) + } + if subnetID == "" { + return suggestSubnets(apiClient, projectID, networkID) + } + if imageID == "" { + return suggestImages() + } + if flavorID == "" { + return suggestFlavors() + } + if rootDiskTypeID == "" { + return suggestRootDiskTypes(zoneID) + } + + body := map[string]interface{}{ + "isPoc": isPoc, + "name": name, + "flavorId": flavorID, + "imageId": imageID, + "networkId": networkID, + "subnetId": subnetID, + "rootDiskTypeId": rootDiskTypeID, + "rootDiskSize": rootDiskSize, + "rootDiskEncryptionType": nilIfEmpty(rootDiskEncType), + "encryptionVolume": encryptionVolume, + "attachFloating": attachFloating, + "securityGroup": parseCommaSeparated(securityGroup), + "sshKeyId": nilIfEmpty(sshKeyID), + "serverGroupId": nilIfEmpty(serverGroupID), + "hostGroupId": nilIfEmpty(hostGroupID), + "expirePassword": expirePassword, + "osLicence": osLicence, + "enableBackup": enableBackup, + "backupInstancePointId": nilIfEmpty(backupPointID), + "snapshotInstancePointId": nilIfEmpty(snapshotPointID), + "createdFrom": "NEW", + "tags": []interface{}{}, + "configVolumeRestores": []interface{}{}, + "userData": nilIfEmpty(userData), + "userDataBase64Encoded": userDataB64, + "zoneId": zoneID, + "dataDiskTypeId": nilIfEmpty(dataDiskTypeID), + "dataDiskEncryptionType": nilIfEmpty(dataDiskEncType), + "dataDiskName": nilIfEmpty(dataDiskName), + "period": period, + "isEnableAutoRenew": autoRenew, + } + + if dataDiskSize > 0 { + body["dataDiskSize"] = dataDiskSize + } else { + body["dataDiskSize"] = nil + } + + if userName != "" { + body["userName"] = userName + } + if userPassword != "" { + body["userPassword"] = userPassword + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/servers", projectID), body) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } + + return outputResult(cmd, cfg, transformServerResult(result)) +} + +func validateCreate(name, flavorID, imageID, networkID, subnetID, rootDiskTypeID, zoneID string, rootDiskSize int) error { + var errs []string + + if len(name) < 5 || !serverNameRE.MatchString(name) { + errs = append(errs, fmt.Sprintf( + "server name %q is invalid — must be 5–65 chars, alphanumeric/hyphens/underscores, start/end with alphanumeric", name)) + } + if rootDiskSize < 20 { + errs = append(errs, fmt.Sprintf("root disk size %d GiB is too small (minimum 20 GiB)", rootDiskSize)) + } + + for _, check := range []struct{ val, flag string }{ + {flavorID, "flavor-id"}, + {imageID, "image-id"}, + {networkID, "network-id"}, + {subnetID, "subnet-id"}, + {rootDiskTypeID, "root-disk-type-id"}, + } { + if err := validator.ValidateID(check.val, check.flag); err != nil { + errs = append(errs, err.Error()) + } + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + if len(errs) > 0 { + fmt.Printf("Found %d error(s):\n", len(errs)) + for _, e := range errs { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("dry-run validation failed with %d error(s)", len(errs)) + } + + fmt.Println("All parameters are valid. Run without --dry-run to create the server.") + return nil +} diff --git a/go/cmd/vserver/server/delete.go b/go/cmd/vserver/server/delete.go new file mode 100644 index 0000000..3f8d611 --- /dev/null +++ b/go/cmd/vserver/server/delete.go @@ -0,0 +1,100 @@ +package server + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a vServer instance", + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("server-id", "", "Server ID (required)") + f.Bool("delete-all-volumes", false, "Delete all volumes associated with the server") + f.Bool("force", false, "Skip confirmation prompt") + deleteCmd.MarkFlagRequired("server-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + deleteAllVolumes, _ := cmd.Flags().GetBool("delete-all-volumes") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + response, err := apiClient.Get(fmt.Sprintf("/v2/%s/servers/%s", projectID, serverID), nil) + if err != nil { + return fmt.Errorf("failed to fetch server %s: %w", serverID, err) + } + + serverData, ok := response.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response type from API: %T", response) + } + + if err := printDeletePreview(serverData["data"], deleteAllVolumes); err != nil { + return err + } + + if !force { + fmt.Print("\nAre you sure you want to delete this server? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + body := map[string]interface{}{ + "deleteAllVolumes": deleteAllVolumes, + } + + result, err := apiClient.DeleteWithBody(fmt.Sprintf("/v2/%s/servers/%s", projectID, serverID), body) + if err != nil { + return fmt.Errorf("failed to delete server %s: %w", serverID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printDeletePreview(server interface{}, deleteAllVolumes bool) error { + s, ok := server.(map[string]interface{}) + if !ok || s == nil { + return fmt.Errorf("could not parse server details from API response (type: %T)", server) + } + + fmt.Println("The following server will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", s["uuid"]) + fmt.Printf(" Name: %v\n", s["name"]) + fmt.Printf(" Status: %v\n", s["status"]) + if deleteAllVolumes { + fmt.Printf(" Delete all volumes: true\n") + } + fmt.Println() + fmt.Println("This action is irreversible.") + return nil +} diff --git a/go/cmd/vserver/server/get.go b/go/cmd/vserver/server/get.go new file mode 100644 index 0000000..a5cf1f7 --- /dev/null +++ b/go/cmd/vserver/server/get.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a vServer instance", + RunE: runGet, +} + +func init() { + getCmd.Flags().String("server-id", "", "Server ID (required)") + getCmd.MarkFlagRequired("server-id") +} + +func runGet(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/servers/%s", projectID, serverID), nil) + if err != nil { + return fmt.Errorf("failed to get server %s: %w", serverID, err) + } + + return outputServerDetail(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/helpers.go b/go/cmd/vserver/server/helpers.go new file mode 100644 index 0000000..4eccbc3 --- /dev/null +++ b/go/cmd/vserver/server/helpers.go @@ -0,0 +1,276 @@ +package server + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +// serverListColumns defines the columns shown in table mode for server list. +var serverListColumns = []string{"name", "status", "privateIp", "publicIp", "zone", "created", "app", "uuid"} + +// serverDetailColumns defines the columns shown in table mode for a single server. +var serverDetailColumns = []string{"uuid", "name", "status", "privateIp", "publicIp", "zone", "created"} + +// serverListFields defines which fields are included in list output (JSON and table). +var serverListFields = map[string]bool{ + "name": true, + "status": true, + "privateIp": true, + "publicIp": true, + "zone": true, + "created": true, + "app": true, + "uuid": true, +} + +func outputServerList(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.OutputWithColumns(cmd, cfg, data, serverListColumns) +} + +func outputServerDetail(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.OutputWithColumns(cmd, cfg, data, serverDetailColumns) +} + +func parseCommaSeparated(s string) []string { + result := []string{} + if s == "" { + return result + } + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func suggestZones(apiClient *client.GreenodeClient, projectID string) error { + return vserverclient.SuggestZoneIDs(apiClient, projectID) +} + +func suggestVPCs(apiClient *client.GreenodeClient, projectID string) error { + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks", projectID), map[string]string{"page": "1", "size": "50"}) + if err != nil { + return fmt.Errorf("--network-id is required (also failed to fetch VPCs: %w)", err) + } + fmt.Fprintln(os.Stderr, "Flag --network-id is required. Available VPCs:") + printItems(result, []string{"listData"}, func(obj map[string]interface{}) { + fmt.Fprintf(os.Stderr, " - %-40s name: %v cidr: %v\n", obj["id"], obj["displayName"], obj["cidr"]) + }) + return fmt.Errorf("flag --network-id is required") +} + +func suggestSubnets(apiClient *client.GreenodeClient, projectID, networkID string) error { + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s/subnets", projectID, networkID), map[string]string{"page": "1", "size": "50"}) + if err != nil { + return fmt.Errorf("--subnet-id is required (also failed to fetch subnets: %w)", err) + } + fmt.Fprintln(os.Stderr, "Flag --subnet-id is required. Available subnets for VPC "+networkID+":") + printItems(result, []string{"data"}, func(obj map[string]interface{}) { + id := obj["uuid"] + if id == nil { + id = obj["id"] + } + fmt.Fprintf(os.Stderr, " - %-40v name: %v cidr: %v\n", id, obj["name"], obj["cidr"]) + }) + return fmt.Errorf("flag --subnet-id is required") +} + +func suggestImages() error { + fmt.Fprintln(os.Stderr, "Flag --image-id is required. To see available images, run:") + fmt.Fprintln(os.Stderr, " grn vserver image list --type os") + fmt.Fprintln(os.Stderr, " grn vserver image list --type gpu") + return fmt.Errorf("flag --image-id is required") +} + +func suggestFlavors() error { + fmt.Fprintln(os.Stderr, "Flag --flavor-id is required. To see available flavors, run:") + fmt.Fprintln(os.Stderr, " grn vserver flavor list-families # see instance families") + fmt.Fprintln(os.Stderr, " grn vserver flavor list-codes # see CPU platform codes") + fmt.Fprintln(os.Stderr, " grn vserver flavor list --family --code ") + return fmt.Errorf("flag --flavor-id is required") +} + +func suggestRootDiskTypes(zoneID string) error { + fmt.Fprintln(os.Stderr, "Flag --root-disk-type-id is required. To see available volume types, run:") + fmt.Fprintf(os.Stderr, " grn vserver volume-type list --zone-id %s\n", zoneID) + return fmt.Errorf("flag --root-disk-type-id is required") +} + +var serverRemoveKeys = map[string]bool{ + "zone": true, + "stopBeforeMigrate": true, + "migrationStatus": true, + "migrateState": true, + "enableLog": true, + "enableMetric": true, + "metadata": true, +} + +func formatDateOnly(v interface{}) interface{} { + s, ok := v.(string) + if !ok || s == "" { + return v + } + formats := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t.UTC().Format("2006-01-02") + } + } + if len(s) >= 10 { + return s[:10] + } + return s +} + +func transformServer(obj map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(obj)) + for k, v := range obj { + if serverRemoveKeys[k] { + continue + } + switch k { + case "image": + if img, ok := v.(map[string]interface{}); ok { + result["imageId"] = img["id"] + } else { + result["imageId"] = v + } + case "flavor": + if flv, ok := v.(map[string]interface{}); ok { + result["flavorId"] = flv["flavorId"] + } else { + result["flavorId"] = v + } + case "internalInterfaces": + if ifaces, ok := v.([]interface{}); ok && len(ifaces) > 0 { + if iface, ok := ifaces[0].(map[string]interface{}); ok { + result["privateIp"] = iface["fixedIp"] + result["publicIp"] = iface["floatingIp"] + } + } + case "zoneId": + result["zone"] = v + case "createdAt": + result["created"] = formatDateOnly(v) + case "product": + if prod, ok := v.(map[string]interface{}); ok { + if name, ok := prod["name"].(string); ok && name != "" { + result["app"] = name + } + } + default: + result[k] = v + } + } + return result +} + +func filterServerListFields(obj map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(serverListFields)) + for k, v := range obj { + if serverListFields[k] && v != nil { + result[k] = v + } + } + return result +} + +func transformServerList(items []interface{}) []interface{} { + out := make([]interface{}, len(items)) + for i, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + out[i] = filterServerListFields(transformServer(obj)) + } else { + out[i] = item + } + } + return out +} + +// transformServerResult applies field removals and renames to API server responses. +// Handles envelopes: {"data": {...}}, {"listData": [...]}, plain object, and plain array. +func transformServerResult(result interface{}) interface{} { + switch v := result.(type) { + case map[string]interface{}: + // Single-object envelope: {"data": {...}} + if data, ok := v["data"].(map[string]interface{}); ok { + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["data"] = transformServer(data) + return out + } + // List envelope: {"listData": [...]} + if listData, ok := v["listData"].([]interface{}); ok { + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["listData"] = transformServerList(listData) + return out + } + // Plain server object + return transformServer(v) + case []interface{}: + return transformServerList(v) + } + return result +} + +// printItems iterates the items array from a response envelope and calls fn for each object. +func printItems(result interface{}, keys []string, fn func(map[string]interface{})) { + var items []interface{} + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + for _, key := range keys { + if d, ok := v[key].([]interface{}); ok { + items = d + break + } + } + } + for _, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + fn(obj) + } + } +} diff --git a/go/cmd/vserver/server/list.go b/go/cmd/vserver/server/list.go new file mode 100644 index 0000000..b57206f --- /dev/null +++ b/go/cmd/vserver/server/list.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all vServer instances", + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.Flags().Bool("no-paginate", false, "Disable auto-pagination") + listCmd.Flags().String("name", "", "Filter by server name (substring match)") +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + noPaginate, _ := cmd.Flags().GetBool("no-paginate") + filterName, _ := cmd.Flags().GetString("name") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + if filterName != "" { + params["name"] = filterName + } + if noPaginate { + delete(params, "page") + delete(params, "size") + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/servers", projectID), params) + if err != nil { + return fmt.Errorf("failed to list servers: %w", err) + } + + return outputServerList(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/reboot.go b/go/cmd/vserver/server/reboot.go new file mode 100644 index 0000000..e10db3e --- /dev/null +++ b/go/cmd/vserver/server/reboot.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var rebootCmd = &cobra.Command{ + Use: "reboot", + Short: "Reboot a vServer instance", + RunE: runReboot, +} + +func init() { + rebootCmd.Flags().String("server-id", "", "Server ID (required)") + rebootCmd.MarkFlagRequired("server-id") +} + +func runReboot(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Put(fmt.Sprintf("/v2/%s/servers/%s/reboot", projectID, serverID), nil) + if err != nil { + return fmt.Errorf("failed to reboot server %s: %w", serverID, err) + } + + return outputResult(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/resize.go b/go/cmd/vserver/server/resize.go new file mode 100644 index 0000000..66d1d39 --- /dev/null +++ b/go/cmd/vserver/server/resize.go @@ -0,0 +1,57 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var resizeCmd = &cobra.Command{ + Use: "resize", + Short: "Resize a vServer instance to a different flavor", + RunE: runResize, +} + +func init() { + f := resizeCmd.Flags() + f.String("server-id", "", "Server ID (required)") + f.String("flavor-id", "", "New flavor ID — run 'vserver flavor list' to see options (required)") + + if err := resizeCmd.MarkFlagRequired("server-id"); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", "server-id", err)) + } +} + +func runResize(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + flavorID, _ := cmd.Flags().GetString("flavor-id") + + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + if flavorID == "" { + return suggestFlavors() + } + + result, err := apiClient.Put( + fmt.Sprintf("/v2/%s/servers/%s/resize", projectID, serverID), + map[string]interface{}{"flavorId": flavorID}, + ) + if err != nil { + return fmt.Errorf("failed to resize server %s: %w", serverID, err) + } + + return outputResult(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/server.go b/go/cmd/vserver/server/server.go new file mode 100644 index 0000000..61cb7b7 --- /dev/null +++ b/go/cmd/vserver/server/server.go @@ -0,0 +1,26 @@ +package server + +import ( + "github.com/spf13/cobra" +) + +// ServerCmd is the parent command for all server subcommands. +var ServerCmd = &cobra.Command{ + Use: "server", + Short: "Manage vServer instances", + Long: "Create, list, get, and manage vServer instances.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + ServerCmd.AddCommand(listCmd) + ServerCmd.AddCommand(getCmd) + ServerCmd.AddCommand(createCmd) + ServerCmd.AddCommand(startCmd) + ServerCmd.AddCommand(stopCmd) + ServerCmd.AddCommand(rebootCmd) + ServerCmd.AddCommand(resizeCmd) + ServerCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vserver/server/start.go b/go/cmd/vserver/server/start.go new file mode 100644 index 0000000..f2fc1e3 --- /dev/null +++ b/go/cmd/vserver/server/start.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start a stopped vServer instance", + RunE: runStart, +} + +func init() { + startCmd.Flags().String("server-id", "", "Server ID (required)") + startCmd.MarkFlagRequired("server-id") +} + +func runStart(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Put(fmt.Sprintf("/v2/%s/servers/%s/start", projectID, serverID), nil) + if err != nil { + return fmt.Errorf("failed to start server %s: %w", serverID, err) + } + + return outputResult(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/stop.go b/go/cmd/vserver/server/stop.go new file mode 100644 index 0000000..4c12c76 --- /dev/null +++ b/go/cmd/vserver/server/stop.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop a running vServer instance", + RunE: runStop, +} + +func init() { + stopCmd.Flags().String("server-id", "", "Server ID (required)") + stopCmd.MarkFlagRequired("server-id") +} + +func runStop(cmd *cobra.Command, args []string) error { + serverID, _ := cmd.Flags().GetString("server-id") + if err := validator.ValidateID(serverID, "server-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Put(fmt.Sprintf("/v2/%s/servers/%s/stop", projectID, serverID), nil) + if err != nil { + return fmt.Errorf("failed to stop server %s: %w", serverID, err) + } + + return outputResult(cmd, cfg, transformServerResult(result)) +} diff --git a/go/cmd/vserver/server/zcompletion.go b/go/cmd/vserver/server/zcompletion.go new file mode 100644 index 0000000..f930338 --- /dev/null +++ b/go/cmd/vserver/server/zcompletion.go @@ -0,0 +1,22 @@ +package server + +import "github.com/vngcloud/greennode-cli/internal/vserverclient" + +func init() { + // --server-id on all commands that target an existing server + getCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + startCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + stopCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + rebootCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("server-id", vserverclient.CompleteServerIDs) //nolint:errcheck + + // create: zone, network, subnet, image, volume types, security group + createCmd.RegisterFlagCompletionFunc("zone-id", vserverclient.CompleteZoneIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("network-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("subnet-id", vserverclient.CompleteSubnetIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("image-id", vserverclient.CompleteImageIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("root-disk-type-id", vserverclient.CompleteVolumeTypeIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("data-disk-type-id", vserverclient.CompleteVolumeTypeIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("security-group", vserverclient.CompleteSecgroupIDs) //nolint:errcheck +} diff --git a/go/cmd/vserver/subnet/create.go b/go/cmd/vserver/subnet/create.go new file mode 100644 index 0000000..646df05 --- /dev/null +++ b/go/cmd/vserver/subnet/create.go @@ -0,0 +1,67 @@ +package subnet + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new subnet", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + + f.String("vpc-id", "", "VPC (network) ID to create the subnet in (required)") + f.String("cidr", "", "CIDR block for the subnet, e.g. 10.0.1.0/24 (required)") + f.String("zone-id", "", "Availability zone ID — run without this flag to see available zones (required)") + f.String("name", "", "Subnet name") + + for _, name := range []string{"vpc-id", "cidr"} { + if err := createCmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", name, err)) + } + } +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + vpcID, _ := cmd.Flags().GetString("vpc-id") + cidr, _ := cmd.Flags().GetString("cidr") + zoneID, _ := cmd.Flags().GetString("zone-id") + + if err := validator.ValidateID(vpcID, "vpc-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + if zoneID == "" { + return suggestZones(apiClient, projectID) + } + + body := map[string]interface{}{ + "name": name, + "cidr": cidr, + "zoneId": zoneID, + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/networks/%s/subnets", projectID, vpcID), body) + if err != nil { + return fmt.Errorf("failed to create subnet: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/subnet/delete.go b/go/cmd/vserver/subnet/delete.go new file mode 100644 index 0000000..3307088 --- /dev/null +++ b/go/cmd/vserver/subnet/delete.go @@ -0,0 +1,97 @@ +package subnet + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a subnet", + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("subnet-id", "", "Subnet ID (required)") + f.String("vpc-id", "", "VPC (network) ID the subnet belongs to (required)") + f.Bool("force", false, "Skip confirmation prompt") + deleteCmd.MarkFlagRequired("subnet-id") + deleteCmd.MarkFlagRequired("vpc-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + subnetID, _ := cmd.Flags().GetString("subnet-id") + vpcID, _ := cmd.Flags().GetString("vpc-id") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(subnetID, "subnet-id"); err != nil { + return err + } + if err := validator.ValidateID(vpcID, "vpc-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + response, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s/subnets/%s", projectID, vpcID, subnetID), nil) + if err != nil { + return fmt.Errorf("failed to fetch subnet %s: %w", subnetID, err) + } + + subnetData, ok := response.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response type from API: %T", response) + } + + if err := printSubnetDeletePreview(subnetData); err != nil { + return err + } + + if !force { + fmt.Print("\nAre you sure you want to delete this subnet? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + result, err := apiClient.Delete(fmt.Sprintf("/v2/%s/networks/%s/subnets/%s", projectID, vpcID, subnetID), nil) + if err != nil { + return fmt.Errorf("failed to delete subnet %s: %w", subnetID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printSubnetDeletePreview(subnet interface{}) error { + s, ok := subnet.(map[string]interface{}) + if !ok || s == nil { + return fmt.Errorf("could not parse subnet details from API response (type: %T)", subnet) + } + + fmt.Println("The following subnet will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", s["uuid"]) + fmt.Printf(" Name: %v\n", s["name"]) + fmt.Printf(" CIDR: %v\n", s["cidr"]) + fmt.Println() + fmt.Println("This action is irreversible.") + return nil +} diff --git a/go/cmd/vserver/subnet/get.go b/go/cmd/vserver/subnet/get.go new file mode 100644 index 0000000..c0a783a --- /dev/null +++ b/go/cmd/vserver/subnet/get.go @@ -0,0 +1,50 @@ +package subnet + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a subnet", + RunE: runGet, +} + +func init() { + getCmd.Flags().String("subnet-id", "", "Subnet ID (required)") + getCmd.Flags().String("vpc-id", "", "VPC (network) ID the subnet belongs to (required)") + getCmd.MarkFlagRequired("subnet-id") + getCmd.MarkFlagRequired("vpc-id") +} + +func runGet(cmd *cobra.Command, args []string) error { + subnetID, _ := cmd.Flags().GetString("subnet-id") + vpcID, _ := cmd.Flags().GetString("vpc-id") + + if err := validator.ValidateID(subnetID, "subnet-id"); err != nil { + return err + } + if err := validator.ValidateID(vpcID, "vpc-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s/subnets/%s", projectID, vpcID, subnetID), nil) + if err != nil { + return fmt.Errorf("failed to get subnet %s: %w", subnetID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/subnet/helpers.go b/go/cmd/vserver/subnet/helpers.go new file mode 100644 index 0000000..ef7fb29 --- /dev/null +++ b/go/cmd/vserver/subnet/helpers.go @@ -0,0 +1,24 @@ +package subnet + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func suggestZones(apiClient *client.GreenodeClient, projectID string) error { + return vserverclient.SuggestZoneIDs(apiClient, projectID) +} diff --git a/go/cmd/vserver/subnet/list.go b/go/cmd/vserver/subnet/list.go new file mode 100644 index 0000000..c29e8de --- /dev/null +++ b/go/cmd/vserver/subnet/list.go @@ -0,0 +1,58 @@ +package subnet + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all subnets", + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.Flags().String("vpc-id", "", "VPC (network) ID (required)") + + if err := listCmd.MarkFlagRequired("vpc-id"); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(\"vpc-id\"): %v", err)) + } +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + vpcID, _ := cmd.Flags().GetString("vpc-id") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s/subnets", projectID, vpcID), params) + if err != nil { + return fmt.Errorf("failed to list subnets for VPC %s: %w", vpcID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/subnet/subnet.go b/go/cmd/vserver/subnet/subnet.go new file mode 100644 index 0000000..5223aa8 --- /dev/null +++ b/go/cmd/vserver/subnet/subnet.go @@ -0,0 +1,22 @@ +package subnet + +import ( + "github.com/spf13/cobra" +) + +// SubnetCmd is the parent command for all subnet subcommands. +var SubnetCmd = &cobra.Command{ + Use: "subnet", + Short: "Manage subnets", + Long: "Create, list, get, and delete subnets within a VPC.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + SubnetCmd.AddCommand(listCmd) + SubnetCmd.AddCommand(getCmd) + SubnetCmd.AddCommand(createCmd) + SubnetCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vserver/subnet/zcompletion.go b/go/cmd/vserver/subnet/zcompletion.go new file mode 100644 index 0000000..ef3adb9 --- /dev/null +++ b/go/cmd/vserver/subnet/zcompletion.go @@ -0,0 +1,18 @@ +package subnet + +import "github.com/vngcloud/greennode-cli/internal/vserverclient" + +func init() { + // --vpc-id on all subnet commands + listCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + + // --subnet-id: resolved from --vpc-id + getCmd.RegisterFlagCompletionFunc("subnet-id", vserverclient.CompleteSubnetIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("subnet-id", vserverclient.CompleteSubnetIDs) //nolint:errcheck + + // create: zone + createCmd.RegisterFlagCompletionFunc("zone-id", vserverclient.CompleteZoneIDs) //nolint:errcheck +} diff --git a/go/cmd/vserver/volume/create.go b/go/cmd/vserver/volume/create.go new file mode 100644 index 0000000..7ee00e0 --- /dev/null +++ b/go/cmd/vserver/volume/create.go @@ -0,0 +1,108 @@ +package volume + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new volume", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + + // Required — registered first, then marked required + f.String("name", "", "Volume name (required)") + f.String("volume-type-id", "", "Volume type ID (required)") + f.String("zone-id", "", "Availability zone ID, e.g. HCM03-1A (required)") + f.Int("size", 0, "Volume size in GiB (required)") + + for _, name := range []string{"name", "volume-type-id", "size"} { + if err := createCmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", name, err)) + } + } + + // Optional + f.String("description", "", "Volume description") + f.String("encryption-type", "", "Encryption type") + f.Bool("multiattach", false, "Allow the volume to be attached to multiple servers") + f.Bool("is-poc", false, "Mark as PoC (proof-of-concept) volume") + f.Bool("dry-run", false, "Validate parameters without creating the volume") +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + volumeTypeID, _ := cmd.Flags().GetString("volume-type-id") + zoneID, _ := cmd.Flags().GetString("zone-id") + size, _ := cmd.Flags().GetInt("size") + description, _ := cmd.Flags().GetString("description") + encryptionType, _ := cmd.Flags().GetString("encryption-type") + multiattach, _ := cmd.Flags().GetBool("multiattach") + isPoc, _ := cmd.Flags().GetBool("is-poc") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if dryRun { + return validateCreate(name, size) + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + if zoneID == "" { + return suggestZones(apiClient, projectID) + } + + body := map[string]interface{}{ + "name": name, + "volumeTypeId": volumeTypeID, + "zoneId": zoneID, + "size": size, + "description": nilIfEmpty(description), + "encryptionType": nilIfEmpty(encryptionType), + "multiattach": multiattach, + "isPoc": isPoc, + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/volumes", projectID), body) + if err != nil { + return fmt.Errorf("failed to create volume: %w", err) + } + + return outputResult(cmd, cfg, result) +} + +func validateCreate(name string, size int) error { + var errs []string + + if len(name) < 1 { + errs = append(errs, "volume name cannot be empty") + } + if size < 1 { + errs = append(errs, fmt.Sprintf("volume size %d GiB is invalid (minimum 1 GiB)", size)) + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + if len(errs) > 0 { + fmt.Printf("Found %d error(s):\n", len(errs)) + for _, e := range errs { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("dry-run validation failed with %d error(s)", len(errs)) + } + + fmt.Println("All parameters are valid. Run without --dry-run to create the volume.") + return nil +} diff --git a/go/cmd/vserver/volume/delete.go b/go/cmd/vserver/volume/delete.go new file mode 100644 index 0000000..8c2e7aa --- /dev/null +++ b/go/cmd/vserver/volume/delete.go @@ -0,0 +1,92 @@ +package volume + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a volume", + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("volume-id", "", "Volume ID (required)") + f.Bool("force", false, "Skip confirmation prompt") + deleteCmd.MarkFlagRequired("volume-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + volumeID, _ := cmd.Flags().GetString("volume-id") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(volumeID, "volume-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + response, err := apiClient.Get(fmt.Sprintf("/v2/%s/volumes/%s", projectID, volumeID), nil) + if err != nil { + return fmt.Errorf("failed to fetch volume %s: %w", volumeID, err) + } + + volumeData, ok := response.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response type from API: %T", response) + } + + if err := printVolumeDeletePreview(volumeData["data"]); err != nil { + return err + } + + if !force { + fmt.Print("\nAre you sure you want to delete this volume? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + result, err := apiClient.Delete(fmt.Sprintf("/v2/%s/volumes/%s", projectID, volumeID), nil) + if err != nil { + return fmt.Errorf("failed to delete volume %s: %w", volumeID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printVolumeDeletePreview(volume interface{}) error { + v, ok := volume.(map[string]interface{}) + if !ok || v == nil { + return fmt.Errorf("could not parse volume details from API response (type: %T)", volume) + } + + fmt.Println("The following volume will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", v["id"]) + fmt.Printf(" Name: %v\n", v["name"]) + fmt.Printf(" Size: %v GiB\n", v["size"]) + fmt.Printf(" Status: %v\n", v["status"]) + fmt.Println() + fmt.Println("This action is irreversible.") + return nil +} diff --git a/go/cmd/vserver/volume/get.go b/go/cmd/vserver/volume/get.go new file mode 100644 index 0000000..e54b739 --- /dev/null +++ b/go/cmd/vserver/volume/get.go @@ -0,0 +1,43 @@ +package volume + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a volume", + RunE: runGet, +} + +func init() { + getCmd.Flags().String("volume-id", "", "Volume ID (required)") + getCmd.MarkFlagRequired("volume-id") +} + +func runGet(cmd *cobra.Command, args []string) error { + volumeID, _ := cmd.Flags().GetString("volume-id") + if err := validator.ValidateID(volumeID, "volume-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/volumes/%s", projectID, volumeID), nil) + if err != nil { + return fmt.Errorf("failed to get volume %s: %w", volumeID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/volume/helpers.go b/go/cmd/vserver/volume/helpers.go new file mode 100644 index 0000000..46328e0 --- /dev/null +++ b/go/cmd/vserver/volume/helpers.go @@ -0,0 +1,87 @@ +package volume + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, transformVolumeResult(data)) +} + +var volumeRemoveKeys = map[string]bool{ + "zone": true, + "product": true, + "bootIndex": true, + "updatedAt": true, + "volumeType": true, +} + +func transformVolume(obj map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(obj)) + for k, v := range obj { + if !volumeRemoveKeys[k] { + result[k] = v + } + } + return result +} + +func transformVolumeList(items []interface{}) []interface{} { + out := make([]interface{}, len(items)) + for i, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + out[i] = transformVolume(obj) + } else { + out[i] = item + } + } + return out +} + +func transformVolumeResult(result interface{}) interface{} { + switch v := result.(type) { + case map[string]interface{}: + if data, ok := v["data"].(map[string]interface{}); ok { + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["data"] = transformVolume(data) + return out + } + if listData, ok := v["listData"].([]interface{}); ok { + out := make(map[string]interface{}, len(v)) + for k, val := range v { + out[k] = val + } + out["listData"] = transformVolumeList(listData) + return out + } + return transformVolume(v) + case []interface{}: + return transformVolumeList(v) + } + return result +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func suggestZones(apiClient *client.GreenodeClient, projectID string) error { + return vserverclient.SuggestZoneIDs(apiClient, projectID) +} diff --git a/go/cmd/vserver/volume/list.go b/go/cmd/vserver/volume/list.go new file mode 100644 index 0000000..02be80a --- /dev/null +++ b/go/cmd/vserver/volume/list.go @@ -0,0 +1,57 @@ +package volume + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all volumes", + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.Flags().String("name", "", "Filter by volume name (substring match)") +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + filterName, _ := cmd.Flags().GetString("name") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + if filterName != "" { + params["name"] = filterName + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/volumes", projectID), params) + if err != nil { + return fmt.Errorf("failed to list volumes: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/volume/resize.go b/go/cmd/vserver/volume/resize.go new file mode 100644 index 0000000..f53ddbd --- /dev/null +++ b/go/cmd/vserver/volume/resize.go @@ -0,0 +1,177 @@ +package volume + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var resizeCmd = &cobra.Command{ + Use: "resize", + Short: "Resize a volume's size or change its volume type", + Long: `Resize a volume by changing its size in GiB, its volume type, or both. + +At least one of --size or --volume-type-id must be provided. + +Examples: + # Expand volume to 100 GiB + grn vserver volume resize --volume-id vol-abc123 --size 100 + + # Change volume type + grn vserver volume resize --volume-id vol-abc123 --volume-type-id vtype-xyz789 + + # Resize and change type at the same time + grn vserver volume resize --volume-id vol-abc123 --size 200 --volume-type-id vtype-xyz789 + + # Dry-run validation only + grn vserver volume resize --volume-id vol-abc123 --size 100 --dry-run`, + RunE: runResize, +} + +func init() { + f := resizeCmd.Flags() + f.String("volume-id", "", "Volume ID (required)") + f.Int("size", 0, "New volume size in GiB (must be equal to or greater than current size)") + f.String("volume-type-id", "", "New volume type ID — run 'grn vserver volume-type list' to see options") + f.Bool("dry-run", false, "Validate parameters without sending the resize request") + + if err := resizeCmd.MarkFlagRequired("volume-id"); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", "volume-id", err)) + } +} + +func runResize(cmd *cobra.Command, args []string) error { + volumeID, _ := cmd.Flags().GetString("volume-id") + size, _ := cmd.Flags().GetInt("size") + sizeSet := cmd.Flags().Changed("size") + volumeTypeID, _ := cmd.Flags().GetString("volume-type-id") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(volumeID, "volume-id"); err != nil { + return err + } + + if size == 0 && volumeTypeID == "" { + return fmt.Errorf("at least one of --size or --volume-type-id must be provided") + } + + if dryRun { + return validateResize(volumeID, size, volumeTypeID) + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + current, err := apiClient.Get(fmt.Sprintf("/v2/%s/volumes/%s", projectID, volumeID), nil) + if err != nil { + return fmt.Errorf("failed to fetch current volume %s: %w", volumeID, err) + } + + currentData, err := extractVolumeData(current) + if err != nil { + return err + } + + printVolumeResizePreview(currentData, size, volumeTypeID) + + if volumeTypeID == "" { + id, ok := currentData["volumeTypeId"].(string) + if !ok || id == "" { + return fmt.Errorf("could not extract volumeTypeId from volume response") + } + volumeTypeID = id + } + + body := map[string]interface{}{ + "newVolumeTypeId": volumeTypeID, + } + if sizeSet && size > 0 { + body["newSize"] = size + } + + result, err := apiClient.Put( + fmt.Sprintf("/v2/%s/volumes/%s/resize", projectID, volumeID), + body, + ) + if err != nil { + return fmt.Errorf("failed to resize volume %s: %w", volumeID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printVolumeResizePreview(v map[string]interface{}, newSize int, newVolumeTypeID string) { + fmt.Println("Current volume info:") + fmt.Println() + fmt.Printf(" ID: %v\n", v["id"]) + fmt.Printf(" Name: %v\n", v["name"]) + fmt.Printf(" Size: %v GiB\n", v["size"]) + fmt.Printf(" Volume Type: %v\n", v["volumeTypeId"]) + fmt.Printf(" Status: %v\n", v["status"]) + fmt.Println() + fmt.Println("Resize plan:") + fmt.Println() + if newSize > 0 { + fmt.Printf(" Size: %v GiB → %d GiB\n", v["size"], newSize) + } else { + fmt.Printf(" Size: %v GiB (unchanged)\n", v["size"]) + } + if newVolumeTypeID != "" && newVolumeTypeID != fmt.Sprintf("%v", v["volumeTypeId"]) { + fmt.Printf(" Volume Type: %v → %s\n", v["volumeTypeId"], newVolumeTypeID) + } else { + fmt.Printf(" Volume Type: %v (unchanged)\n", v["volumeTypeId"]) + } + fmt.Println() +} + +func validateResize(volumeID string, size int, volumeTypeID string) error { + var errs []string + + if size < 0 { + errs = append(errs, fmt.Sprintf("size %d GiB is invalid (must be a positive integer)", size)) + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + fmt.Printf(" Volume ID: %s\n", volumeID) + if size > 0 { + fmt.Printf(" New size: %d GiB\n", size) + } + if volumeTypeID != "" { + fmt.Printf(" New type: %s\n", volumeTypeID) + } else { + fmt.Printf(" New type: (reuse current volume type)\n") + } + fmt.Println() + + if len(errs) > 0 { + fmt.Printf("Found %d error(s):\n", len(errs)) + for _, e := range errs { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("dry-run validation failed with %d error(s)", len(errs)) + } + + fmt.Println("All parameters are valid. Run without --dry-run to resize the volume.") + return nil +} + +func extractVolumeData(response interface{}) (map[string]interface{}, error) { + m, ok := response.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected response format from API") + } + data, ok := m["data"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not parse volume data from API response") + } + return data, nil +} diff --git a/go/cmd/vserver/volume/volume.go b/go/cmd/vserver/volume/volume.go new file mode 100644 index 0000000..3fb0674 --- /dev/null +++ b/go/cmd/vserver/volume/volume.go @@ -0,0 +1,23 @@ +package volume + +import ( + "github.com/spf13/cobra" +) + +// VolumeCmd is the parent command for all volume subcommands. +var VolumeCmd = &cobra.Command{ + Use: "volume", + Short: "Manage vServer volumes", + Long: "Create, list, get, and delete vServer block storage volumes.\n\nTo see available volume types for a zone, run: grn vserver volume-type list", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + VolumeCmd.AddCommand(listCmd) + VolumeCmd.AddCommand(getCmd) + VolumeCmd.AddCommand(createCmd) + VolumeCmd.AddCommand(resizeCmd) + VolumeCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vserver/volume/zcompletion.go b/go/cmd/vserver/volume/zcompletion.go new file mode 100644 index 0000000..d5c2557 --- /dev/null +++ b/go/cmd/vserver/volume/zcompletion.go @@ -0,0 +1,17 @@ +package volume + +import "github.com/vngcloud/greennode-cli/internal/vserverclient" + +func init() { + // --volume-id on commands that target an existing volume + getCmd.RegisterFlagCompletionFunc("volume-id", vserverclient.CompleteVolumeIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("volume-id", vserverclient.CompleteVolumeIDs) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("volume-id", vserverclient.CompleteVolumeIDs) //nolint:errcheck + + // create: zone, volume type + createCmd.RegisterFlagCompletionFunc("zone-id", vserverclient.CompleteZoneIDs) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("volume-type-id", vserverclient.CompleteVolumeTypeIDs) //nolint:errcheck + + // resize: volume type + resizeCmd.RegisterFlagCompletionFunc("volume-type-id", vserverclient.CompleteVolumeTypeIDs) //nolint:errcheck +} diff --git a/go/cmd/vserver/volumetype/helpers.go b/go/cmd/vserver/volumetype/helpers.go new file mode 100644 index 0000000..02fa795 --- /dev/null +++ b/go/cmd/vserver/volumetype/helpers.go @@ -0,0 +1,108 @@ +package volumetype + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func suggestZones(apiClient *client.GreenodeClient, projectID string) error { + return vserverclient.SuggestZoneIDs(apiClient, projectID) +} + +// extractVolumeTypeZoneNames returns the "name" of every item in the volume_type_zones response. +func extractVolumeTypeZoneNames(result interface{}) []string { + var items []interface{} + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + for _, key := range []string{"volumeTypeZones", "data"} { + if d, ok := v[key].([]interface{}); ok { + items = d + break + } + } + } + names := make([]string, 0, len(items)) + for _, item := range items { + if obj, ok := item.(map[string]interface{}); ok { + if name, ok := obj["name"].(string); ok && name != "" { + names = append(names, name) + } + } + } + return names +} + +// extractVolumeTypeZoneID pulls the zone ID out of the volume_type_zones response. +// If typeName is non-empty, it matches the item whose "name" field equals typeName. +// Otherwise it returns the ID of the first item. +func extractVolumeTypeZoneID(result interface{}, typeName string) (string, error) { + tryID := func(obj map[string]interface{}) (string, bool) { + for _, key := range []string{"id", "uuid", "volumeTypeZoneId", "zoneId"} { + if v, ok := obj[key].(string); ok && v != "" { + return v, true + } + } + return "", false + } + + matchesType := func(obj map[string]interface{}) bool { + if typeName == "" { + return true + } + name, _ := obj["name"].(string) + return name == typeName + } + + var items []interface{} + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + for _, key := range []string{"volumeTypeZones", "data"} { + if d, ok := v[key].([]interface{}); ok { + items = d + break + } + } + if items == nil && matchesType(v) { + if id, ok := tryID(v); ok { + return id, nil + } + } + } + + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + if matchesType(obj) { + if id, ok := tryID(obj); ok { + return id, nil + } + } + } + + if typeName != "" { + return "", fmt.Errorf("no volume type zone with name %q found", typeName) + } + return "", fmt.Errorf("could not find volume type zone ID in response") +} diff --git a/go/cmd/vserver/volumetype/list.go b/go/cmd/vserver/volumetype/list.go new file mode 100644 index 0000000..3dec323 --- /dev/null +++ b/go/cmd/vserver/volumetype/list.go @@ -0,0 +1,74 @@ +package volumetype + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List available volume types for a zone", + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.String("zone-id", "", "Availability zone ID (required)") + f.String("type", "", "Volume type zone name to filter by (e.g. SSD, NVMe)") +} + +func runList(cmd *cobra.Command, args []string) error { + zoneID, _ := cmd.Flags().GetString("zone-id") + typeName, _ := cmd.Flags().GetString("type") + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + if zoneID == "" { + return suggestZones(apiClient, projectID) + } + + zoneResult, err := apiClient.Get( + fmt.Sprintf("/v1/%s/volume_type_zones", projectID), + map[string]string{"zoneId": zoneID}, + ) + if err != nil { + return fmt.Errorf("failed to fetch volume type zones for %s: %w", zoneID, err) + } + + if typeName == "" { + fmt.Fprintln(os.Stderr, "Flag --type is required. Available volume type zones:") + for _, name := range extractVolumeTypeZoneNames(zoneResult) { + fmt.Fprintf(os.Stderr, " - %s\n", name) + } + return fmt.Errorf("flag --type is required") + } + + volumeTypeZoneID, err := extractVolumeTypeZoneID(zoneResult, typeName) + if err != nil { + fmt.Fprintln(os.Stderr, "Available volume type zones:") + for _, name := range extractVolumeTypeZoneNames(zoneResult) { + fmt.Fprintf(os.Stderr, " - %s\n", name) + } + return fmt.Errorf("volume type zone %q not found in zone %s", typeName, zoneID) + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/%s/%s/volume_types", projectID, volumeTypeZoneID), + nil, + ) + if err != nil { + return fmt.Errorf("failed to list volume types: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/volumetype/volumetype.go b/go/cmd/vserver/volumetype/volumetype.go new file mode 100644 index 0000000..d7969fb --- /dev/null +++ b/go/cmd/vserver/volumetype/volumetype.go @@ -0,0 +1,19 @@ +package volumetype + +import ( + "github.com/spf13/cobra" +) + +// VolumeTypeCmd is the parent command for all volume type subcommands. +var VolumeTypeCmd = &cobra.Command{ + Use: "volume-type", + Short: "Manage vServer volume types", + Long: "List available volume types for a zone.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + VolumeTypeCmd.AddCommand(listCmd) +} diff --git a/go/cmd/vserver/vpc/create.go b/go/cmd/vserver/vpc/create.go new file mode 100644 index 0000000..c059fb9 --- /dev/null +++ b/go/cmd/vserver/vpc/create.go @@ -0,0 +1,91 @@ +package vpc + +import ( + "fmt" + "net" + + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new VPC", + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + + f.String("name", "", "VPC name (required)") + f.String("cidr", "", "CIDR block for the VPC, e.g. 10.0.0.0/16 (required)") + + for _, name := range []string{"name", "cidr"} { + if err := createCmd.MarkFlagRequired(name); err != nil { + panic(fmt.Sprintf("BUG: MarkFlagRequired(%q): %v", name, err)) + } + } + + f.String("description", "", "VPC description") + f.Bool("is-default", false, "Mark as the default VPC") + f.Bool("dry-run", false, "Validate parameters without creating the VPC") +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + cidr, _ := cmd.Flags().GetString("cidr") + description, _ := cmd.Flags().GetString("description") + isDefault, _ := cmd.Flags().GetBool("is-default") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if dryRun { + return validateCreate(name, cidr) + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + body := map[string]interface{}{ + "name": name, + "cidr": cidr, + "description": nilIfEmpty(description), + "isDefault": isDefault, + } + + result, err := apiClient.Post(fmt.Sprintf("/v2/%s/networks", projectID), body) + if err != nil { + return fmt.Errorf("failed to create VPC: %w", err) + } + + return outputResult(cmd, cfg, result) +} + +func validateCreate(name, cidr string) error { + var errs []string + + if len(name) < 1 { + errs = append(errs, "VPC name cannot be empty") + } + if _, _, err := net.ParseCIDR(cidr); err != nil { + errs = append(errs, fmt.Sprintf("CIDR %q is invalid: %v", cidr, err)) + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + if len(errs) > 0 { + fmt.Printf("Found %d error(s):\n", len(errs)) + for _, e := range errs { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("dry-run validation failed with %d error(s)", len(errs)) + } + + fmt.Println("All parameters are valid. Run without --dry-run to create the VPC.") + return nil +} diff --git a/go/cmd/vserver/vpc/delete.go b/go/cmd/vserver/vpc/delete.go new file mode 100644 index 0000000..43f8b31 --- /dev/null +++ b/go/cmd/vserver/vpc/delete.go @@ -0,0 +1,93 @@ +package vpc + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a VPC", + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("vpc-id", "", "VPC (network) ID (required)") + f.Bool("force", false, "Skip confirmation prompt") + deleteCmd.MarkFlagRequired("vpc-id") +} + +func runDelete(cmd *cobra.Command, args []string) error { + vpcID, _ := cmd.Flags().GetString("vpc-id") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(vpcID, "vpc-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + response, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s", projectID, vpcID), nil) + if err != nil { + return fmt.Errorf("failed to fetch VPC %s: %w", vpcID, err) + } + + vpcData, ok := response.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected response type from API: %T", response) + } + + if err := printVpcDeletePreview(vpcData); err != nil { + return err + } + + if !force { + fmt.Print("\nAre you sure you want to delete this VPC? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + result, err := apiClient.Delete(fmt.Sprintf("/v2/%s/networks/%s", projectID, vpcID), nil) + if err != nil { + return fmt.Errorf("failed to delete VPC %s: %w", vpcID, err) + } + + return outputResult(cmd, cfg, result) +} + +func printVpcDeletePreview(vpc interface{}) error { + v, ok := vpc.(map[string]interface{}) + if !ok || v == nil { + return fmt.Errorf("could not parse VPC details from API response (type: %T)", vpc) + } + + fmt.Println("The following VPC will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", v["id"]) + fmt.Printf(" Name: %v\n", v["displayName"]) + fmt.Printf(" CIDR: %v\n", v["cidr"]) + fmt.Printf(" Status: %v\n", v["status"]) + fmt.Printf(" Dns Status: %v\n", v["dnsStatus"]) + fmt.Println() + fmt.Println("This action is irreversible.") + return nil +} diff --git a/go/cmd/vserver/vpc/get.go b/go/cmd/vserver/vpc/get.go new file mode 100644 index 0000000..cb0925c --- /dev/null +++ b/go/cmd/vserver/vpc/get.go @@ -0,0 +1,43 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a VPC", + RunE: runGet, +} + +func init() { + getCmd.Flags().String("vpc-id", "", "VPC (network) ID (required)") + getCmd.MarkFlagRequired("vpc-id") +} + +func runGet(cmd *cobra.Command, args []string) error { + vpcID, _ := cmd.Flags().GetString("vpc-id") + if err := validator.ValidateID(vpcID, "vpc-id"); err != nil { + return err + } + + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks/%s", projectID, vpcID), nil) + if err != nil { + return fmt.Errorf("failed to get VPC %s: %w", vpcID, err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/vpc/helpers.go b/go/cmd/vserver/vpc/helpers.go new file mode 100644 index 0000000..97cc1bc --- /dev/null +++ b/go/cmd/vserver/vpc/helpers.go @@ -0,0 +1,27 @@ +package vpc + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/vserverclient" +) + +func createClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + return vserverclient.BuildClient(cmd) +} + +func getProjectID(cfg *config.Config) (string, error) { + return vserverclient.ProjectID(cfg) +} + +func outputResult(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + return vserverclient.Output(cmd, cfg, data) +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/go/cmd/vserver/vpc/list.go b/go/cmd/vserver/vpc/list.go new file mode 100644 index 0000000..e43a48d --- /dev/null +++ b/go/cmd/vserver/vpc/list.go @@ -0,0 +1,57 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all VPCs", + RunE: runList, +} + +func init() { + listCmd.Flags().Int("page", 1, "Page number (1-based)") + listCmd.Flags().Int("page-size", 50, "Number of items per page") + listCmd.Flags().String("name", "", "Filter by VPC name (substring match)") +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, cfg, err := createClient(cmd) + if err != nil { + return err + } + + projectID, err := getProjectID(cfg) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + filterName, _ := cmd.Flags().GetString("name") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "size": fmt.Sprintf("%d", pageSize), + } + if filterName != "" { + params["name"] = filterName + } + + result, err := apiClient.Get(fmt.Sprintf("/v2/%s/networks", projectID), params) + if err != nil { + return fmt.Errorf("failed to list VPCs: %w", err) + } + + return outputResult(cmd, cfg, result) +} diff --git a/go/cmd/vserver/vpc/vpc.go b/go/cmd/vserver/vpc/vpc.go new file mode 100644 index 0000000..a4d2a1f --- /dev/null +++ b/go/cmd/vserver/vpc/vpc.go @@ -0,0 +1,22 @@ +package vpc + +import ( + "github.com/spf13/cobra" +) + +// VpcCmd is the parent command for all VPC subcommands. +var VpcCmd = &cobra.Command{ + Use: "vpc", + Short: "Manage VPCs (virtual private clouds)", + Long: "Create, list, get, and delete VPC networks.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + VpcCmd.AddCommand(listCmd) + VpcCmd.AddCommand(getCmd) + VpcCmd.AddCommand(createCmd) + VpcCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vserver/vpc/zcompletion.go b/go/cmd/vserver/vpc/zcompletion.go new file mode 100644 index 0000000..39b3bc5 --- /dev/null +++ b/go/cmd/vserver/vpc/zcompletion.go @@ -0,0 +1,8 @@ +package vpc + +import "github.com/vngcloud/greennode-cli/internal/vserverclient" + +func init() { + getCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("vpc-id", vserverclient.CompleteVPCIDs) //nolint:errcheck +} diff --git a/go/cmd/vserver/vserver.go b/go/cmd/vserver/vserver.go new file mode 100644 index 0000000..4febffc --- /dev/null +++ b/go/cmd/vserver/vserver.go @@ -0,0 +1,36 @@ +package vserver + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/cmd/vserver/flavor" + "github.com/vngcloud/greennode-cli/cmd/vserver/image" + "github.com/vngcloud/greennode-cli/cmd/vserver/secgroup" + "github.com/vngcloud/greennode-cli/cmd/vserver/server" + "github.com/vngcloud/greennode-cli/cmd/vserver/subnet" + "github.com/vngcloud/greennode-cli/cmd/vserver/volume" + "github.com/vngcloud/greennode-cli/cmd/vserver/volumetype" + "github.com/vngcloud/greennode-cli/cmd/vserver/vpc" + "github.com/vngcloud/greennode-cli/internal/cli" +) + +// VServerCmd is the parent command for all vServer subcommands. +var VServerCmd = &cobra.Command{ + Use: "vserver", + Short: "VNG Virtual Server (vServer) commands", + Long: "Manage vServer instances and related resources.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + VServerCmd.AddCommand(server.ServerCmd) + VServerCmd.AddCommand(volume.VolumeCmd) + VServerCmd.AddCommand(vpc.VpcCmd) + VServerCmd.AddCommand(subnet.SubnetCmd) + VServerCmd.AddCommand(secgroup.SecgroupCmd) + VServerCmd.AddCommand(flavor.FlavorCmd) + VServerCmd.AddCommand(volumetype.VolumeTypeCmd) + VServerCmd.AddCommand(image.ImageCmd) + cli.RegisterService(VServerCmd) +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index 944cf04..2eac694 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -16,9 +16,9 @@ import ( ) const ( - maxRetries = 3 - retryBaseDelay = 1 * time.Second - defaultTimeout = 30 * time.Second + maxRetries = 3 + retryBaseDelay = 1 * time.Second + defaultTimeout = 30 * time.Second ) var statusMessages = map[int]string{ @@ -175,13 +175,17 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin var lastErr error + var jsonBody []byte + if body != nil { + jsonBody, err = json.Marshal(body) + if err != nil { + return "", fmt.Errorf("failed to marshal request body: %w", err) + } + } + for attempt := 0; attempt <= maxRetries; attempt++ { var reqBody io.Reader - if body != nil { - jsonBody, err := json.Marshal(body) - if err != nil { - return "", fmt.Errorf("failed to marshal request body: %w", err) - } + if jsonBody != nil { reqBody = bytes.NewReader(jsonBody) } @@ -195,8 +199,7 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin if c.debug { fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, fullURL) - if body != nil { - jsonBody, _ := json.Marshal(body) + if jsonBody != nil { fmt.Fprintf(os.Stderr, "[debug] request body: %s\n", string(jsonBody)) } } @@ -225,8 +228,12 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin if err != nil { return "", err } - // Retry with new token - req2, _ := http.NewRequest(method, fullURL, reqBody) + // Retry with new token; reqBody is exhausted so reset from jsonBody. + var retryBody io.Reader + if jsonBody != nil { + retryBody = bytes.NewReader(jsonBody) + } + req2, _ := http.NewRequest(method, fullURL, retryBody) req2.Header.Set("Authorization", "Bearer "+token) req2.Header.Set("Content-Type", "application/json") resp2, err := c.httpClient.Do(req2) @@ -310,3 +317,7 @@ func formatError(statusCode int, body []byte) string { } return fmt.Sprintf("API error (HTTP %d %s)", statusCode, statusText) } + +func (c *GreenodeClient) DeleteWithBody(path string, body interface{}) (interface{}, error) { + return c.request("DELETE", path, nil, body) +} diff --git a/go/internal/formatter/formatter.go b/go/internal/formatter/formatter.go index 4d92db6..20dcc5b 100644 --- a/go/internal/formatter/formatter.go +++ b/go/internal/formatter/formatter.go @@ -240,3 +240,112 @@ func padRight(s string, n int) string { } return s + strings.Repeat(" ", n-len(s)) } +func FormatTableWithColumns(data interface{}, columns []string, query string, w io.Writer) error { + if w == nil { + w = os.Stdout + } + if query != "" { + result, err := jmespath.Search(query, data) + if err != nil { + return fmt.Errorf("JMESPath query error: %w", err) + } + data = result + } + if data == nil { + return nil + } + formatTableColumns(data, columns, w) + return nil +} + +func formatTableColumns(data interface{}, columns []string, w io.Writer) { + if data == nil || isEmptyMap(data) { + return + } + rows := extractRows(data) + if len(rows) == 0 { + return + } + if _, ok := rows[0].(map[string]interface{}); !ok { + return + } + + headers := make([]string, len(columns)) + for i, c := range columns { + headers[i] = formatHeader(c) + } + + colWidths := make([]int, len(columns)) + for i, h := range headers { + colWidths[i] = len(h) + } + + strRows := make([][]string, len(rows)) + for i, row := range rows { + m, _ := row.(map[string]interface{}) + strRows[i] = make([]string, len(columns)) + for j, col := range columns { + val := fmt.Sprint(m[col]) + if val == "" { + val = "" + } + strRows[i][j] = val + if len(val) > colWidths[j] { + colWidths[j] = len(val) + } + } + } + + headerParts := make([]string, len(headers)) + sepParts := make([]string, len(headers)) + for i, h := range headers { + headerParts[i] = padRight(h, colWidths[i]) + sepParts[i] = strings.Repeat("-", colWidths[i]) + } + fmt.Fprintln(w, strings.Join(headerParts, " | ")) + fmt.Fprintln(w, strings.Join(sepParts, "-+-")) + + for _, row := range strRows { + parts := make([]string, len(row)) + for i, val := range row { + parts[i] = padRight(val, colWidths[i]) + } + fmt.Fprintln(w, strings.Join(parts, " | ")) + } +} + +func extractRows(data interface{}) []interface{} { + switch v := data.(type) { + case []interface{}: + return v + case map[string]interface{}: + // List envelope: {"listData": [...]} or any key holding a slice + for _, value := range v { + if items, ok := value.([]interface{}); ok { + return items + } + } + // Single-object envelope: {"data": {...}} — unwrap the inner object + if inner, ok := v["data"].(map[string]interface{}); ok { + return []interface{}{inner} + } + return []interface{}{v} + default: + return []interface{}{v} + } +} +func formatHeader(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if i > 0 && c >= 'A' && c <= 'Z' { + b.WriteByte(' ') + } + if c >= 'a' && c <= 'z' { + b.WriteByte(c - 32) + } else { + b.WriteByte(c) + } + } + return b.String() +} diff --git a/go/internal/vserverclient/client.go b/go/internal/vserverclient/client.go new file mode 100644 index 0000000..eb0513b --- /dev/null +++ b/go/internal/vserverclient/client.go @@ -0,0 +1,97 @@ +package vserverclient + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/formatter" +) + +// BuildClient creates a GreenodeClient from cobra command flags. +func BuildClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, error) { + profile, _ := cmd.Flags().GetString("profile") + region, _ := cmd.Flags().GetString("region") + endpointURL, _ := cmd.Flags().GetString("endpoint-url") + noVerifySSL, _ := cmd.Flags().GetBool("no-verify-ssl") + debug, _ := cmd.Flags().GetBool("debug") + readTimeout, _ := cmd.Flags().GetInt("cli-read-timeout") + + cfg, err := config.LoadConfig(profile) + if err != nil { + return nil, nil, err + } + + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, nil, fmt.Errorf("credentials not configured. Run 'grn configure' to set up credentials") + } + + if region != "" { + cfg.Region = region + } + + var baseURL string + if endpointURL != "" { + baseURL = endpointURL + } else { + baseURL, err = cfg.GetEndpoint("vserver") + if err != nil { + return nil, nil, err + } + } + + if noVerifySSL { + fmt.Fprintln(os.Stderr, "Warning: SSL certificate verification is disabled. This is not recommended for production use.") + } + + tokenManager := auth.NewTokenManager(cfg.ClientID, cfg.ClientSecret) + timeout := time.Duration(readTimeout) * time.Second + + return client.NewGreenodeClient(baseURL, tokenManager, timeout, !noVerifySSL, debug), cfg, nil +} + +// ProjectID extracts and validates the project ID from config. +func ProjectID(cfg *config.Config) (string, error) { + if cfg.ProjectID == "" { + return "", fmt.Errorf("project_id is not configured. Run 'grn configure' or set GRN_DEFAULT_PROJECT_ID") + } + return cfg.ProjectID, nil +} + +// Output formats and writes the API result to stdout. +func Output(cmd *cobra.Command, cfg *config.Config, data interface{}) error { + output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") + + if output == "" && cfg != nil { + output = cfg.Output + } + if output == "" { + output = "json" + } + + return formatter.Format(data, output, query, os.Stdout) +} + +// OutputWithColumns formats and writes the API result to stdout. +// When the output format is "table", only the specified columns are shown in the given order. +func OutputWithColumns(cmd *cobra.Command, cfg *config.Config, data interface{}, columns []string) error { + output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") + + if output == "" && cfg != nil { + output = cfg.Output + } + if output == "" { + output = "json" + } + + if output == "table" && len(columns) > 0 { + return formatter.FormatTableWithColumns(data, columns, query, os.Stdout) + } + return formatter.Format(data, output, query, os.Stdout) +} diff --git a/go/internal/vserverclient/complete.go b/go/internal/vserverclient/complete.go new file mode 100644 index 0000000..626f256 --- /dev/null +++ b/go/internal/vserverclient/complete.go @@ -0,0 +1,236 @@ +package vserverclient + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/client" +) + +// extractCompletions pulls "id\tname" pairs from an API response. +// listKeys: envelope keys to try in order (e.g. "listData", "data", "images"). +// idKey: field used as the completion value; nameKey: shown as the tab description. +func extractCompletions(result interface{}, listKeys []string, idKey, nameKey string) []string { + var items []interface{} + switch v := result.(type) { + case []interface{}: + items = v + case map[string]interface{}: + for _, key := range listKeys { + if d, ok := v[key].([]interface{}); ok { + items = d + break + } + } + } + out := make([]string, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + id, _ := obj[idKey].(string) + if id == "" { + continue + } + if name, _ := obj[nameKey].(string); name != "" { + out = append(out, id+"\t"+name) + } else { + out = append(out, id) + } + } + return out +} + +func buildCompleter(fetch func(*client.GreenodeClient, string) ([]string, error)) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + c, cfg, err := BuildClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + projectID, err := ProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + completions, err := fetch(c, projectID) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +// CompleteServerIDs completes --server-id flags. +var CompleteServerIDs = buildCompleter(func(c *client.GreenodeClient, projectID string) ([]string, error) { + result, err := c.Get(fmt.Sprintf("/v2/%s/servers", projectID), map[string]string{"page": "1", "size": "100"}) + if err != nil { + return nil, err + } + return extractCompletions(result, []string{"listData"}, "uuid", "name"), nil +}) + +// CompleteVolumeIDs completes --volume-id flags. +var CompleteVolumeIDs = buildCompleter(func(c *client.GreenodeClient, projectID string) ([]string, error) { + result, err := c.Get(fmt.Sprintf("/v2/%s/volumes", projectID), map[string]string{"page": "1", "size": "100"}) + if err != nil { + return nil, err + } + return extractCompletions(result, []string{"listData"}, "uuid", "name"), nil +}) + +// CompleteVPCIDs completes --network-id and --vpc-id flags. +var CompleteVPCIDs = buildCompleter(func(c *client.GreenodeClient, projectID string) ([]string, error) { + result, err := c.Get(fmt.Sprintf("/v2/%s/networks", projectID), map[string]string{"page": "1", "size": "100"}) + if err != nil { + return nil, err + } + return extractCompletions(result, []string{"listData"}, "id", "displayName"), nil +}) + +// CompleteSecgroupIDs completes --secgroup-id and --security-group flags. +var CompleteSecgroupIDs = buildCompleter(func(c *client.GreenodeClient, projectID string) ([]string, error) { + result, err := c.Get(fmt.Sprintf("/v2/%s/secgroups", projectID), map[string]string{"page": "1", "size": "100"}) + if err != nil { + return nil, err + } + return extractCompletions(result, []string{"listData"}, "id", "name"), nil +}) + +// CompleteImageIDs completes --image-id flags by combining OS and GPU images. +var CompleteImageIDs = buildCompleter(func(c *client.GreenodeClient, projectID string) ([]string, error) { + var completions []string + for _, imageType := range []string{"os", "gpu"} { + result, err := c.Get(fmt.Sprintf("/v1/%s/images/%s", projectID, imageType), map[string]string{"page": "1", "size": "100"}) + if err != nil { + continue + } + completions = append(completions, extractCompletions(result, []string{"images", "data"}, "id", "name")...) + } + return completions, nil +}) + +// CompleteZoneIDs completes --zone-id flags, showing only enabled zones. +func CompleteZoneIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + c, cfg, err := BuildClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + projectID, err := ProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + result, err := c.Get(fmt.Sprintf("/v1/%s/zones", projectID), nil) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var items []interface{} + if m, ok := result.(map[string]interface{}); ok { + if d, ok := m["data"].([]interface{}); ok { + items = d + } + } + var completions []string + for _, item := range items { + zone, ok := item.(map[string]interface{}) + if !ok { + continue + } + if enabled, _ := zone["isEnabled"].(bool); !enabled { + continue + } + uuid, _ := zone["uuid"].(string) + name, _ := zone["name"].(string) + if uuid == "" { + continue + } + if name != "" { + completions = append(completions, uuid+"\t"+name) + } else { + completions = append(completions, uuid) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// CompleteSubnetIDs completes --subnet-id flags. +// Reads VPC from --network-id (server create) or --vpc-id (subnet commands). +func CompleteSubnetIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + networkID, _ := cmd.Flags().GetString("network-id") + if networkID == "" { + networkID, _ = cmd.Flags().GetString("vpc-id") + } + if networkID == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + c, cfg, err := BuildClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + projectID, err := ProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + result, err := c.Get(fmt.Sprintf("/v2/%s/networks/%s/subnets", projectID, networkID), map[string]string{"page": "1", "size": "100"}) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return extractCompletions(result, []string{"data", "listData"}, "uuid", "name"), cobra.ShellCompDirectiveNoFileComp +} + +// CompleteVolumeTypeIDs completes --volume-type-id flags. +// Reads the zone from --zone-id to perform the two-step lookup. +func CompleteVolumeTypeIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + zoneID, _ := cmd.Flags().GetString("zone-id") + if zoneID == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + c, cfg, err := BuildClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + projectID, err := ProjectID(cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + // Step 1: resolve volume-type-zone IDs for this zone + zoneResult, err := c.Get(fmt.Sprintf("/v1/%s/volume_type_zones", projectID), map[string]string{"zoneId": zoneID}) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var vzItems []interface{} + switch v := zoneResult.(type) { + case []interface{}: + vzItems = v + case map[string]interface{}: + for _, key := range []string{"volumeTypeZones", "data"} { + if d, ok := v[key].([]interface{}); ok { + vzItems = d + break + } + } + } + // Step 2: fetch volume types for each volume-type-zone + var completions []string + for _, item := range vzItems { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + var vzID string + for _, key := range []string{"id", "uuid", "volumeTypeZoneId"} { + if v, ok := obj[key].(string); ok && v != "" { + vzID = v + break + } + } + if vzID == "" { + continue + } + result, err := c.Get(fmt.Sprintf("/v1/%s/%s/volume_types", projectID, vzID), nil) + if err != nil { + continue + } + completions = append(completions, extractCompletions(result, []string{"data", "volumeTypes"}, "id", "name")...) + } + return completions, cobra.ShellCompDirectiveNoFileComp +} diff --git a/go/internal/vserverclient/zones.go b/go/internal/vserverclient/zones.go new file mode 100644 index 0000000..1b3515a --- /dev/null +++ b/go/internal/vserverclient/zones.go @@ -0,0 +1,42 @@ +package vserverclient + +import ( + "fmt" + "os" + + "github.com/vngcloud/greennode-cli/internal/client" +) + +// SuggestZoneIDs fetches available zones and prints enabled ones to stderr, +// then returns an error telling the user to set --zone-id. +func SuggestZoneIDs(apiClient *client.GreenodeClient, projectID string) error { + result, err := apiClient.Get(fmt.Sprintf("/v1/%s/zones", projectID), nil) + if err != nil { + return fmt.Errorf("--zone-id is required (also failed to fetch zones: %w)", err) + } + + var items []interface{} + if m, ok := result.(map[string]interface{}); ok { + if d, ok := m["data"].([]interface{}); ok { + items = d + } + } else if arr, ok := result.([]interface{}); ok { + items = arr + } + + fmt.Fprintln(os.Stderr, "Flag --zone-id is required. Available zones:") + for _, item := range items { + zone, ok := item.(map[string]interface{}) + if !ok { + continue + } + if enabled, _ := zone["isEnabled"].(bool); !enabled { + continue + } + uuid, _ := zone["uuid"].(string) + name, _ := zone["name"].(string) + desc, _ := zone["description"].(string) + fmt.Fprintf(os.Stderr, " - %-15s (%s) %s\n", uuid, name, desc) + } + return fmt.Errorf("flag --zone-id is required") +} diff --git a/mkdocs.yml b/mkdocs.yml index e3a5535..db25f92 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,21 @@ nav: - get-quota: commands/vks/get-quota.md - Waiter: - wait: commands/vks/wait.md + - vServer Commands: + - Overview: commands/vserver/index.md + - Server: + - server: commands/vserver/server.md + - Networking: + - vpc: commands/vserver/vpc.md + - subnet: commands/vserver/subnet.md + - Storage: + - volume: commands/vserver/volume.md + - volume-type: commands/vserver/volume-type.md + - Security: + - secgroup: commands/vserver/secgroup.md + - Discovery: + - flavor: commands/vserver/flavor.md + - image: commands/vserver/image.md - Development: - Contributing: development/contributing.md - Architecture & Adding a Service: development/architecture.md From 70505a9c97f074c200bacc07188543d53d0e1452 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Mon, 29 Jun 2026 14:53:47 +0700 Subject: [PATCH 2/4] fix(vserver): mount vServer CLI by blank-importing it in register.go cmd/vserver registered via cli.RegisterService(VServerCmd) but was never imported, so `grn vserver` did not appear. Uncomment the blank import so the package init() runs and the service mounts. Verified: `grn --help` now lists `vserver`; `grn vserver --help` shows all 8 subcommands; full test suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- go/cmd/register.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go/cmd/register.go b/go/cmd/register.go index 3c94693..2db6db2 100644 --- a/go/cmd/register.go +++ b/go/cmd/register.go @@ -5,7 +5,6 @@ package cmd // the registry and never needs editing. import ( _ "github.com/vngcloud/greennode-cli/cmd/vks" + _ "github.com/vngcloud/greennode-cli/cmd/vserver" _ "github.com/vngcloud/greennode-cli/internal/resources/vserver" - // New products add a line here, e.g.: - // _ "github.com/vngcloud/greennode-cli/cmd/vserver" ) From 66c50445c3685dfe85ac5be14de118de1c4cd216 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Mon, 29 Jun 2026 15:17:33 +0700 Subject: [PATCH 3/4] chore(vserver): add CODEOWNERS for vServer CLI paths Route reviews of cmd/vserver, internal/vserverclient, and docs/commands/vserver to the vserver team. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1dfc01..6326241 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,9 +15,8 @@ /CLAUDE.md @vngcloud/platform # Product CLIs — owned by their product teams. -/go/cmd/vks/ @vngcloud/vks -/docs/commands/vks/ @vngcloud/vks - -# When a new product joins, add e.g.: -# /go/cmd/vserver/ @vngcloud/vserver -# /docs/commands/vserver/ @vngcloud/vserver +/go/cmd/vks/ @vngcloud/vks +/docs/commands/vks/ @vngcloud/vks +/go/cmd/vserver/ @vngcloud/vserver +/go/internal/vserverclient/ @vngcloud/vserver +/docs/commands/vserver/ @vngcloud/vserver From f4c3d923e925e57bb0d4610f821d14bb13db062f Mon Sep 17 00:00:00 2001 From: tytv2 Date: Mon, 29 Jun 2026 15:40:33 +0700 Subject: [PATCH 4/4] ci: link test binaries with external linker (fix macOS LC_UUID abort) go test on macos-latest with Go 1.22 aborted with "missing LC_UUID load command": the internal linker omits LC_UUID and recent macOS dyld rejects such binaries. Build/run (CGO_ENABLED=0) is unaffected; only test binaries crash. Run tests with CGO_ENABLED=1 -ldflags=-linkmode=external (system cc is present on ubuntu and macos runners). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/run-tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 09e4173..3fb50cf 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,4 +34,8 @@ jobs: - name: Run tests working-directory: go - run: go test ./... -v + # Use the external (system) linker so test binaries get an LC_UUID load + # command. Go 1.22's internal linker omits it, and recent macOS dyld + # aborts such binaries ("missing LC_UUID load command"). CGO is enabled + # so the system cc (present on both ubuntu and macos runners) links. + run: CGO_ENABLED=1 go test -ldflags='-linkmode=external' ./... -v